안녕하세요? 이번 글은 Bluemix를 이용하여 간단한 클라우드 애플리케이션을 디자인하고 만들어 가는 과정을 내용으로 연재되고 있습니다. 아래와 같은 순서로 진행되고 있으므로 참고 부탁 드립니다.


  1. 클라우드 환경 이해
  2. 애플리케이션 구상 및 요건 정의
  3. 요건에 대한 Usecase 및 Wireframe 작성
  4. 마이크로 서비스 아키텍쳐 설계
  5. 애플리케이션 서버 환경 준비
  6. 애플리케이션 로컬 개발 환경 준비
  7. 애플리케이션 프로토타입 작성 Part1
  8. 애플리케이션 프로토타입 작성 Part2
  9. 애플리케이션 프로토타입 작성 Part3
  10. 애플리케이션 DevOps 환경 구성

애플리케이션 프로토타입 작성 Part3

지난 글에서 Cloudant를 이용한 방문 예약 정보 서비스를 구성하였습니다. 이번 글에서는 이어서 남은 API 서비스인 임시 출입증 정보 서비스(smv-badge)와 사용자 정보 서비스(smv-userinfo) 그리고 사용자 UI를 담당하는 smv-ui-app을 작성하도록 하겠습니다.

임시 출입증 정보 서비스 프로토타입 구현

임시 출입증 정보 서비스가 제공하는 기능은 앞서 애플리케이션 프로토타입 작성 Part2에서 작성한 방문 예약 정보 서비스(smv-visit)와 유사한 기능을 제공하므로 이를 참고하여 기능을 구현합니다.

서비스 Handler 환경 구성

방문 정보 관리 서비스(smv-visit)에서 처럼 controllers라는 폴더를 생성하고 다음과 같이 해당 서비스를 노출하는 SMVBadgeController.js 파일을 작성합니다.

'use strict';

const BASE_PATH = '/api/smv/v1/badge';

function newBadgeInfo(req, res) {

  // TODO
  res.statusCode = 200;
  res.end('OK');
}

function getBadgeInfo(req, res) {
  var docid = req.params.id;

  console.log(`getBadgeInfo: badge id ${docid}`);
  
  // TODO
  res.statusCode = 200;
  res.end('OK');
}

function updateBadgeInfo(req, res) {
  var docid = req.params.id;
  
  console.log(`updateBadgeInfo: badge id ${docid}`);

  // TODO
  res.statusCode = 200;
  res.end('OK');
}

function deleteBadgeInfo(req, res) {
  var docid = req.params.id;
  
  console.log(`deleteBadgeInfo: badge id ${docid}`);

  // TODO
  res.statusCode = 200;
  res.end('OK');
}

function searchBadgeInfos(req, res) {

  var type = req.query.type;
  var keyword = req.query.keyword;
  var page = Number(req.query.page);
  var size = Number(req.query.size);

  console.log(`searchBadgeInfos: type=${type}, keyword=${keyword}, page=${page}, size=${size}`);
  
  // TODO
  res.statusCode = 200;
  res.end('OK');
}

module.exports = function(app, options) {
  app.post(BASE_PATH, authenticatedFilter, newBadgeInfo);

  // Expose search option ahead of general document getting function
  app.post(BASE_PATH, newBadgeInfo);
  app.get(BASE_PATH + '/search', searchBadgeInfos);
  app.get(BASE_PATH + '/:id', getBadgeInfo);
  app.put(BASE_PATH + '/:id', updateBadgeInfo);
  app.delete(BASE_PATH + '/:id', deleteBadgeInfo);
};

그리고 app.js에서 SMVBadgeController 모듈을 로딩하고 초기화하는 코드를 아래와 같이 추가합니다.

...
// Expose the SMVBadgeController
var SMVBadgeController = require('./controllers/SMVBadgeController');
SMVBadgeController(app);
...

사용자 인증 공통 모듈 적용

앞서 사용자 인증 서비스에서 구성한 공통 모듈인 SMVAuthTokenHelper.js를 복사합니다. NPM package로 등록되어 있다면 이를 이용할 수도 있겠으나 각각의 구분된 프로젝트에서 사용하는 것이므로 이 부분은 직접 복제를 하도록 합니다. 그리고, SMVBadgeController.js에 사용자 인증 필터를 아래와 같이 추가합니다.

const AUTH_TOKEN_KEY = 'X-AUTH-TOKEN';

var SMVAuthTokenHelper = require('./SMVAuthTokenHelper');

function extractAuthToken(req) {
  var token = req.headers[AUTH_TOKEN_KEY] || req.headers[AUTH_TOKEN_KEY.toLowerCase()];
  if (!token) {
    console.error(`${AUTH_TOKEN_KEY} is not in the header as key`);
  }
  return token;
}

function authenticatedFilter(req, res, next) {
  var token = extractAuthToken(req);
  SMVAuthTokenHelper.isValidAuthToken(token, function(valid) {
    if (valid) {
      // go next
      return next();
    }

    res.statusCode = 401;
    res.end('Unauthorized');
  });
}

...

module.exports = function(app, options) {
  app.post(BASE_PATH, authenticatedFilter, newBadgeInfo);
  app.get(BASE_PATH + '/search', authenticatedFilter, searchBadgeInfos);
  app.get(BASE_PATH + '/:id', authenticatedFilter, getBadgeInfo);
  app.put(BASE_PATH + '/:id', authenticatedFilter, updateBadgeInfo);
  app.delete(BASE_PATH + '/:id', authenticatedFilter, deleteBadgeInfo);
};

서비스 Handler 구현

앞서 구성해 놓은 API Handler를 Cloudant를 이용하여 구현합니다. 방문 정보 서비스와 동일한 방법을 사용하나 저장하는 정보가 다르므로 이에 대한 내용만 간단히 정리하도록 하겠습니다.

Document 생성

임시 출입증에 대한 BadgeObject 정보는 다음과 같은 object로 설계를 했었습니다.

  BadgeObject:
    type: object
    properties:
      id:
        type: string
        description: 임시 출입증 ID
        example: 12392391239
      type:
        type: string
        description: 임시 출입증 종류
        enum:
          - VISITOR
      number:
        type: string
        description: 임시 출입증 번호
        example: 01
      updated:
        type: integer
        format: int64
        description: '정보 갱신 Timestamp(밀리초), 자동생성'
        example: 1493349000000

이를 json으로 표현한 예제는 다음과 같습니다.

{
  'type': 'VISITOR',
  'number': '01',
  'updated': 1493349000000
}

이 JSON 데이터를 Cloudant에 저장하면 되는데, 앞서 cloudant를 각각 서비스가 공유해서 사용하므로 이를 구분할 수 있도록 type이라는 이름의 properity를 사용했었습니다. 따라서, API로 노출할 때는 type 이라는 이름으로 제공하지만, 실제 Cloudant에서 데이터를 저장하고 불러 오는 경우는 다른이름으로 처리하도록 합니다.

newBadgeInfo, updateBadgeInfo 그리고 searchBadgeInfo에서 type으로 입력 받은 정보를 badgetype이라는 이름으로 변경하여 저장하고 Cloudant 저장에 필요한 정보로 재작성합니다.

...
  var json = Object.assign({}, req.body);
  if (json.hasOwnProperty('id')) {
    json['_id'] = String(json.id);
    json['id'] = undefined;
  }
  json['type'] = DOC_TYPE;
  json['badgetype'] = req.body.type;
  json['number'] = req.body.number;
  json['updated'] = new Date().getTime();
...
}

또한, cloudant를 통해 얻어진 BadgeObject의 경우도 API로 노출되는 정보로 처리하도록 cleanseBadgeObject라는 함수를 생성하고 이를 getBadgeInfosearchBadgeInfo에서 이용하도록 처리합니다.

function cleanseBadgeObject(badge) {
  // 
  var id = badge._id || badge.id;
  //var rev = badge._rev || badge.rev;
  //var type = badge.type;
  var json = Object.assign({}, badge);
  json['id'] = id;
  json['_id'] = undefined;
  json['_rev'] = undefined;
  json['badgetype'] = undefined;
  json['type'] = badge.badgetype;

  return json;
}

다음은 API로 부터 데이터를 넘겨 받아 cloudant document를 생성하는 코드입니다.

function newBadgeInfo(req, res) {
  console.log(`newBadgeInfo: badge id ${req.body.id} for ${req.body.type} with ${req.body.number}`);

  var json = Object.assign({}, req.body);
  if (json.hasOwnProperty('id')) {
    json['_id'] = String(json.id);
    json['id'] = undefined;
  }
  json['type'] = DOC_TYPE;
  json['badgetype'] = req.body.type;
  json['number'] = req.body.number;
  json['updated'] = new Date().getTime();

  mydb.insert(json, function(err, doc) {
    if (err) {
      console.error(err);
      // Error 
      res.statusCode = 500;
      res.end('Internel Server Error');
    } else {
      res.json(cleanseBadgeObject(Object.assign(json, doc)));
      res.end();
    }
  });
}

Document 읽기

Read에 대한 부분은 다음과 같이 구현됩니다.

function getBadgeInfo(req, res) {
  var docid = req.params.id;

  console.log(`getBadgeInfo: badge id ${docid}`);

  mydb.get(docid, function(err, doc) {
    if (err) {
      console.error(err);
      // Error 
      res.statusCode = 500;
      res.end('Internel Server Error');
    } else {
      res.json(cleanseBadgeObject(doc));
      res.end();
    }
  });
}

Document 갱신

임시 출입증에 대한 정보는 타입과 번호만 갱신 가능한 형태가 되어있는데, 이 때 갱신되는 정보가 type인 경우 실제 저장되는 정보는 type이 아닌 badgetype이어야 하는 것에 주의합니다.

const UPDATABLE_PROPS = ['type', 'number'];
...
function updateBadgeInfo(req, res) {
  var docid = req.params.id;
  
  console.log(`updateBadgeInfo: badge id ${docid}`);

  mydb.get(docid, function(err, doc) {
    if (err) {
      console.error(err);
      // Error 
      res.statusCode = 500;
      res.end('Internel Server Error');
    } else {
      var jsondoc = Object.assign({}, doc);
      //console.log(`key:${key}`);
      // Select field to update
      for (var idx in UPDATABLE_PROPS) {
        var key = UPDATABLE_PROPS[idx];
        console.log(`key:${key}`);
        if (req.body.hasOwnProperty(key)) {
          if (key == 'type') {
            jsondoc['badgetype'] = req.body['type'];
          } else {
            jsondoc[key] = req.body[key]; 
          }
        }
      }

      jsondoc['updated'] = new Date().getTime();

      mydb.insert(jsondoc, function(err, doc) {
        if (err) {
          console.error(err);
          // Error 
          res.statusCode = 500;
          res.end('Internel Server Error');
        } else {
          // new jsondoc is saved well
          res.json(cleanseBadgeObject(jsondoc));
          res.end();
        }
      });
    }
  });
}

Document 삭제

function deleteBadgeInfo(req, res) {
  var docid = req.params.id;
  
  console.log(`deleteBadgeInfo: badge id ${docid}`);

  mydb.get(docid, function(err, doc) {
    if (err) {
      console.error(err);
      // Error 
      res.statusCode = 500;
      res.end('Internel Server Error');
    } else {
      // Delete
      mydb.destroy(doc._id, doc._rev, function(err, doc) {
        if (err) {
          console.error(err);
          // Error 
          res.statusCode = 500;
          res.end('Internel Server Error');
        } else {
          res.end('OK');
        }
      });
    }
  });
}

Document 조회

임시 출입증은 종류와 번호로 조회가 가능합니다. 이 때도 마찬가지로 type이 아닌 badgetype으로 조회해야 합니다.

function searchBadgeInfos(req, res) {

  var type = req.query.type;
  var keyword = req.query.keyword;
  var page = Number(req.query.page);
  var size = Number(req.query.size);

  console.log(`searchBadgeInfos: type=${type}, keyword=${keyword}, page=${page}, size=${size}`);

  if (size && Number.isNaN(size)) {
    // Error 
    res.statusCode = 400;
    res.end('Invalid Query Parameter: the size is NaN');
    return;
  }
  if (page && Number.isNaN(page)) {
    // Error 
    res.statusCode = 400;
    res.end('Invalid Query Parameter: the page is NaN');
    return;
  }
  if (size <= 0) {
    // Error 
    res.statusCode = 400;
    res.end('Invalid Query Parameter: the size is 0 or negative value');
    return;
  }
  if (page <= 0) {
    // Error 
    res.statusCode = 400;
    res.end('Invalid Query Parameter: the page is 0 or negative value');
    return;
  }

  var selector = {
    '$and':[
      {'type': DOC_TYPE}
    ]
  };
  var queryIndex;
  if (type) {
    var keytype = type.toLowerCase();
    if (keytype == 'type') keytype = 'badgetype';
    var ekeyword = keyword.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');

    var searchByType = {};
    searchByType[`${keytype}`] = {'$regex': `(?i)${ekeyword}`}; 

    selector['$and'].push(searchByType);
    //queryIndex = ['_design/smv-badge-indexes', `badge-${keytype}`];
  }

  mydb.find({
    selector: selector,
    fields: ['type'],
    use_index: queryIndex
  }, function(err, totalInfo) {
    if (err) {
      console.error(err);
      // Error 
      res.statusCode = 500;
      res.end('Internel Server Error');
    } else {
      if (totalInfo.warning) {
        console.error(totalInfo.warning);
      }

      console.log(`total count : ${totalInfo.docs.length}`);
      var total = totalInfo.docs.length;

      var limit, skip;
      if (size > 0) {
        limit = size;
        
        // Set the page as last
        var maxpage = Math.floor(total/size-1);
        page = page || 0; // defaul page index is 0

        if (maxpage < page) {
          page = maxpage;
        }
        //console.log(`page:${page}, size:${size}`);
        skip = page * size;
      } else {
        size = undefined;
        page = 0;
      }

      mydb.find({
        selector: selector,
        limit: limit,
        skip: skip,
        use_index: queryIndex
      }, function(err, result) {

        if (err) {
          console.error(err);
          // Error 
          res.statusCode = 500;
          res.end('Internel Server Error');
        } else {

          console.log(`searched count : ${result.docs.length}`);
          var resObject = {
            result: result.docs.map(function(item){
              return cleanseBadgeObject(item);
            }),
            paging: {
              page: page,
              size: limit,
              total: total
            }
          };

          res.json(resObject);
          res.end();
        }
      });
    }
  });
}

이 부분에 대한 자세한 내용은 Github https://github.com/mc500/smv/blob/master/projects/smv-visit/controllers/SMVVisitController.js 코드를 참고하시기 바랍니다.

사용자 정보 서비스 프로토타입 구현

사용자 정보 서비스(smv-userinfo)는 조직 정보와 연계되어 사용자의 정보를 제공하는 서비스입니다. LDAP이나 사내 정보 시스템과 연계하기도 하지만 본 글에서는 하드코딩으로 임의의 정보를 제공하는 형태로 구성합니다.

API로 조회 가능한 사용자는 다음과 같습니다.

Name ID Email Role
Jone Doe CN=John Doe/OU=ACME/O=IBM john.doe@acme.ibm.com ESCORT
Sally Doe CN=Sally Doe/OU=ACME/O=IBM sally.doe@acme.ibm.com RECEPTION
Lisa Doe CN=Lisa Doe/OU=ACME/O=IBM lisa.doe@acme.ibm.com ADMIN

사실 이 부분은 앞서 작성한 사용자 인증 서비스(smv-userauth)와도 관계가 있습니다. 처음 로그인 가능한 사용자를 지정할 때는 jone.doe@acme.ibm.com만 가능하도록 했지만 위와 같이 추가된 사용자도 로그인 할 수 있도록 smv-userauth/controllers/SMVUserAuthController.js의 일부 코드를 아래와 같이 변경합니다.

우선 예제 사용자 정보를 추가합니다.

...
var USER_INFO_SAMPLES = [
  {
    'role' : 'ESCORT',
    'serial' : '1234567890',
    'phone' : '+82-2-1234-0000',
    'name' : 'John Doe',
    'mobile' : '+82-10-1234-0000',
    'userid' : 'CN=John Doe/OU=ACME/O=IBM',
    'email' : 'john.doe@acme.ibm.com',
    'dept': 'Client Innovation Lab',
    'passwd': 'passw0rd'
  },
  {
    'role' : 'RECEPTION',
    'serial' : '1000000000',
    'phone' : '+82-2-1234-1111',
    'name' : 'Jay Doe',
    'mobile' : '+82-10-1234-1111',
    'userid' : 'CN=Sally Doe/OU=ACME/O=IBM',
    'email' : 'sally.doe@acme.ibm.com',
    'dept': 'Client Service',
    'passwd': 'passw0rd'
  },
  {
    'role' : 'ADMIN',
    'serial' : '9000000000',
    'phone' : '+82-2-1234-9999',
    'name' : 'Lisa Doe',
    'mobile' : '+82-10-1234-9999',
    'userid' : 'CN=Lisa Doe/OU=ACME/O=IBM',
    'email' : 'lisa.doe@acme.ibm.com',
    'dept': 'Client Service',
    'passwd': 'passw0rd'
  }
];
...

그리고 module.exports.loginPOST 함수에서 id/pw를 확인하고 사용자 정보를 생성하는 부분을 다음과 같이 변경합니다.

...
  var sampleUserInfo;
  var email = args.email.value;
  var passwd = args.passwd.value;
  var isFound = USER_INFO_SAMPLE.some(function(userinfo) {
    if (userinfo.email == email) {
      sampleUserInfo = JSON.stringify(userinfo);
      return true;
    }
  });

  if (isFound && passwd === 'passw0rd') {
    SMVAuthTokenHelper.generateAuthToken(function(token) {
      // Set the key of user
      SMVAuthTokenHelper.setAuthTokenValue(token, 'userinfo', sampleUserInfo, function(result){
        if (result) {
          res.setHeader(AUTH_TOKEN_KEY, token);
          res.setHeader('Content-Type', 'application/json');
          res.end(sampleUserInfo);
        } else {
          // Error 
          res.statusCode = 500;
          res.end('Internel Server Error');
        }
      });
    });

    return;
  }
...

이제 smv-userinfo로 되돌아와서 사용자의 정보를 조회하는 코드를 작성합니다.

서비스 Handler 환경 구성

사용자 인증 서비스에서에서 처럼 REST 서비스를 위한 Handler 코드를 구성합니다. 사용자 인증 서비스와 유사한 구조로 하기 위해 controllers라는 폴더를 생성하고 다음과 같이 해당 서비스를 노출하는 SMVUserInfoController.js 파일을 작성합니다.

'use strict';

const BASE_PATH = '/api/smv/v1/userinfo';

function getUserInfo(req, res) {
  var userid = req.params.id;

  // TODO
  res.statusCode = 200;
  res.end('OK');
}

function updateUserRole(req, res) {
  var userid = req.params.id;
  var newrole = req.params.role;
  

  // TODO
  res.statusCode = 200;
  res.end('OK');
}

module.exports = function(app, options) {

  // Expose search option ahead of general document getting function
  app.get(BASE_PATH + '/:id', getUserInfo);
  app.put(BASE_PATH + '/:id/role/:role', updateUserRole);
};

그리고 app.js에서 SMVUserInfoController 모듈을 로딩하고 초기화하는 코드를 아래와 같이 추가합니다.

...
// Expose the SMVUserInfoController
var SMVUserInfoController = require('./controllers/SMVUserInfoController');
SMVUserInfoController(app);
...

사용자 인증 공통 모듈 적용

앞서 사용자 인증 서비스에서 구성한 공통 모듈인 SMVAuthTokenHelper.js를 복사합니다. NPM package로 등록되어 있다면 이를 이용할 수도 있겠으나 각각의 구분된 프로젝트에서 사용하는 것이므로 이 부분은 직접 복제를 하도록 합니다. 그리고, SMVUserInfoController.js에 사용자 인증 필터를 아래와 같이 추가합니다.

const AUTH_TOKEN_KEY = 'X-AUTH-TOKEN';

var SMVAuthTokenHelper = require('./SMVAuthTokenHelper');

function extractAuthToken(req) {
  var token = req.headers[AUTH_TOKEN_KEY] || req.headers[AUTH_TOKEN_KEY.toLowerCase()];
  if (!token) {
    console.error(`${AUTH_TOKEN_KEY} is not in the header as key`);
  }
  return token;
}

function authenticatedFilter(req, res, next) {
  var token = extractAuthToken(req);
  SMVAuthTokenHelper.isValidAuthToken(token, function(valid) {
    if (valid) {
      // go next
      return next();
    }

    res.statusCode = 401;
    res.end('Unauthorized');
  });
}

...

module.exports = function(app, options) {
  app.get(BASE_PATH + '/:id', authenticatedFilter, getUserInfo);
  app.put(BASE_PATH + '/:id/role/:role', authenticatedFilter, updateUserRole);
};

서비스 Handler 구현

앞서 다른 서비스 API와 달리 테스트를 위해 임의의 사용자 정보를 가지고 있으므로 앞서 작성했던 사용자 인증 서비스의 smv-userauth/controllers/SMVUserAuthController.js 의 내용을 참조하여 아래와 같이 사용자 정보 USER_INFO_SAMPLES 를 구성합니다. 다만, updateUserRole 함수 같은 경우 사용자의 역할(role)을 변경하기 위한 목적이므로 실제 로그인 한 사용자에 대한 정보를 외부에 별도 저장하는 방식으로 구성해야 하나 나중에 기회가 되면 업데이트 하기로 하고 우선은 임시로 구성해 놓은 형태이니 참고 바랍니다.

...

var USER_INFO_SAMPLES = [
  {
    'role' : 'ESCORT',
    'serial' : '1234567890',
    'phone' : '+82-2-1234-0000',
    'name' : 'John Doe',
    'mobile' : '+82-10-1234-0000',
    'userid' : 'CN=John Doe/OU=ACME/O=IBM',
    'email' : 'john.doe@acme.ibm.com',
    'dept': 'Client Innovation Lab'
  },
  {
    'role' : 'RECEPTION',
    'serial' : '1000000000',
    'phone' : '+82-2-1234-1111',
    'name' : 'Jay Doe',
    'mobile' : '+82-10-1234-1111',
    'userid' : 'CN=Sally Doe/OU=ACME/O=IBM',
    'email' : 'sally.doe@acme.ibm.com',
    'dept': 'Client Service'
  },
  {
    'role' : 'ADMIN',
    'serial' : '9000000000',
    'phone' : '+82-2-1234-9999',
    'name' : 'Lisa Doe',
    'mobile' : '+82-10-1234-9999',
    'userid' : 'CN=Lisa Doe/OU=ACME/O=IBM',
    'email' : 'lisa.doe@acme.ibm.com',
    'dept': 'Client Service'
  }
];

var USER_ROLES = ['ESCORT', 'RECEPTION', 'ADMIN'];

function getUserInfo(req, res) {
  var userid = req.params.id;

  console.log(`getUserInfo: user id ${userid}`);

  var isFound = USER_INFO_SAMPLES.some(function(userinfo) {
    if (userinfo.userid == userid) {
      res.json(userinfo);
      res.end();
      return true;
    }
  });

  if (isFound) return;

  // Not Found
  console.error('User Not Found');

  // Error 
  res.statusCode = 404;
  res.end('User Not Found');
}

function updateUserRole(req, res) {
  var userid = req.params.id;
  var newrole = req.params.role;
  
  console.log(`updateUserRole: user id ${userid}, ${newrole}`);

  if (!userid) {
    console.error('User\'s id is empty');
    // Error 
    res.statusCode = 400;
    res.end('Invalid Argument');
  }

  // Find roles
  if (USER_ROLES.indexOf(newrole) < 0) {
    // Not Found
    console.error(`Unknown role code : ${newrole}`);
    // Error 
    res.statusCode = 400;
    res.end('Invalid Argument');
  }

  // Find user info
  var foundUserInfo = undefined;
  USER_INFO_SAMPLES.some(function(userinfo) {
    if (userinfo.userid == userid) {
      foundUserInfo = Object.assign({}, userinfo);
      foundUserInfo.role = newrole;
      return true;
    }
  });

  if (foundUserInfo) {
    res.json(foundUserInfo);
    res.end();
    return ;
  }

  // Not Found
  console.error('User Not Found');

  // Error 
  res.statusCode = 404;
  res.end('User Not Found');
}

SMV-UI API 통합 프로토타입 구현

UI 앱이라 하더라도 단순히 화면만 필요현 형태가 아니라 로그인과 로그아웃 그리고 화면(view 또는 page)에 필요한 데이터를 처리해야 합니다. 따라서, 앞서 서비스들과 같이 Controller 정보를 구성합니다.

서비스 Handler 구성

다른 서비스와 유사한 구조로 하기 위해 controllers라는 폴더를 생성하고 다음과 같이 해당 서비스를 노출하는 SMVUIAppController.js 파일을 아래와 같이 작성합니다. 여기에 사용자 인증 모듈도 추가된 형태입니다.

'use strict';

const BASE_PATH = '';
const AUTH_TOKEN_KEY = 'X-AUTH-TOKEN';

var SMVAuthTokenHelper = require('./SMVAuthTokenHelper');

function extractAuthToken(req) {
  var token = req.headers[AUTH_TOKEN_KEY] || req.headers[AUTH_TOKEN_KEY.toLowerCase()];
  if (!token) {
    console.error(`${AUTH_TOKEN_KEY} is not in the header as key`);
  }
  return token;
}

function buildAuthHeaders(authtoken) {
  var authheaders = {};
  authheaders[AUTH_TOKEN_KEY] = authtoken;
  return authheaders;
}

function login(req, res, next) {
  // Login

  // TODO
  res.statusCode = 200;
  res.end('OK');
}

function logout(req, res, next) {
  // Logout
  
  // TODO
  res.statusCode = 200;
  res.end('OK');
}

module.exports = function(app, options) {
  app.post(BASE_PATH + '/api/login', login);
  app.get(BASE_PATH + '/api/logout', logout);
};

서비스 Handler 구현

로그인 API의 경우 smv-userauth 서비스를 호출하게 되므로 다음과 같이 request 모듈을 이용하여 구현합니다.

...
const SMV_USERAUTH_BASE_URL = process.env['SMV_USERAUTH_BASE_URL'];
const EXPIRES_IN_SECS = 3600; // an hour

var request = require('request');

...

function login(req, res, next) {
  // Login
  request.post({
    url: SMV_USERAUTH_BASE_URL+'/api/smv/v1/auth/login',
    form: req.body
  }, function(error, response, body) {
    if (error) {
      console.log(error);
      //return res.end(error);
      // Redirect to visiting list
      res.redirect(BASE_PATH + '/login?error='+error);
      return;
    }

    if (response.statusCode != 200) {
      console.error(`response code is ${response.statusCode}`);
      res.redirect(BASE_PATH + '/login?error='+response.body);
      return;
    }

    var authtoken = extractAuthToken(response);
    if (!authtoken) {
      res.redirect(BASE_PATH + '/login?error=authtoken is empty');
      return; 
    }

    // keep authtoken as cookie
    res.cookie(AUTH_TOKEN_KEY, authtoken, {
      maxAge: EXPIRES_IN_SECS*1000 // in milliseconds
    });
      
    // Redirect to visiting list
    res.redirect(BASE_PATH + '/visiting/list');
  });
}

로그인이 성공하면 smv-userauth를 사용하고 인증 토큰을 얻게됩니다. 얻어진 token을 Web Browser에 API 호출 결과로 전달 할 때 cookie 방식으로 전달합니다. 사용자 인증 서비스에서 유효 시간을 1시간으로 해 놓았기에 cookie에서도 1시간으로 해 놓았습니다. 이는 필요에 따라서 조절 할 수 있습니다.

로그아웃은 cookie에서 인증 토큰을 얻어 처리하도록 되어 있습니다

function logout(req, res, next) {
  // Logout
  var authtoken = req.cookies[AUTH_TOKEN_KEY];
  if (!authtoken) {
    // Redirect to visiting list
    res.redirect(BASE_PATH + '/');
  }

  request.get({
    url: SMV_USERAUTH_BASE_URL+'/api/smv/v1/auth/logout',
    headers: buildAuthHeaders(authtoken)
  }, function(error, response, body) {
    console.log('logout done');

    // Remove Cookie
    res.clearCookie(AUTH_TOKEN_KEY);
    
    // Redirect to visiting list
    res.redirect(BASE_PATH + '/');
  });
}

그리고, app.js에 다음과 같이 서비스를 로딩하는 코드를 추가합니다.

...
// Expose the SMVUIAppController
var SMVUIAppController = require('./controllers/SMVUIAppController');
SMVUIAppController(app);
...

서비스 API가 준비되었으므로 이제 사용자 UI를 구성 할 차례입니다.

express view(또는 page)와 data의 관계

smv-ui-app은 Bluemix Boilerplate로 구성되어 Node.js 와 Cloudant를 이용한 Web UI Application으로서 ejs를 express의 rendering 엔진으로 사용한 형태로 구성되어 있습니다.

이는 app.js의 코드를 통해 확인 해 볼 수 있습니다.

...
app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);
...

이는 express가 view를 위해 /views 디렉토리 아래에 있는 리소스를 사용하겠으며 view engine으로 ejs 방식을 선택하고 html 파일에 대한 처리를 할 때 ejs의 renderFile을 통해 처리하겠다는 내용입니다.

또한 아래와 같은 부분도 보이는데

...
var express = require('express'),
    routes = require('./routes'),
...
app.get('/', routes.index);
...

/routes 디렉토리에 있는 index.js를 로딩하고 index.js에 노출되어 있는 index 함수를 / 경로로 연결하는 코드입니다.

실제 /routes/index.js 파일을 보면 다음과 같습니다.

exports.index = function(req, res){
  res.render('index.html', { title: 'Cloudant Boiler Plate' });
};

/views 디렉토리에 있는 index.html을 렌터링하며 이 때 title 값을 'Cloudant Boiler Plate'로 전달하여 화면에 생성되도록 합니다.

다만 /views/index.html 파일을 보면 title에 대응되는 ejs 표현이 없는데 다음과 같이 작성된 <title> tag를

...
<title>Favorites Organizer powered by Cloudant</title>
...

아래와 같이 변경하면

...
<title><%=title%></title>
...

출력되는 title이 Favorites Organizer powered by Cloudant가 아닌 Cloudant Boiler Plate가 되는 것을 볼 수 있습니다.

expressejs에 대한 자세한 내용은 아래 링크를 참고 하시기 바랍니다.

화면 구성 (임직원용)

보통 프로토 타입을 만들때 한번에 만드는 것이 아니라 일정한 기능을 만들고 이를 개발 일정에 맞춰 여러번의 반복된 개선 작업을 통해 완성도를 높여가는 방식으로 진행됩니다. 본 글에서는 위에 언급된 view 중에서 임직원이 고객 방문 예약을 위한 기능에 대한 내용을 위주로 진행 하겠습니다.

smv-ui-app이 필요한 화면은 앞서 작성 했던 Wireframe과 Usecase의 정보를 참고 할 수 있습니다. https://developer.ibm.com/kr/cloud/bluemix/cf-applications/2017/04/21/develop-cloud-app-step3/

해당 화면을 view라고 했을 때 다음과 같이 연결해 볼 수 있습니다.

화면 이름 HTML 파일 호출 경로 참고
로그인 views/login.html /login SMV-UCS-000-001-사용자 로그인
방문자 등록 views/visiting/register.html /visiting/register SMV-UCS-100-001-방문 예정 정보 추가
방문 일정 조회 views/visiting/list.html /visiting/list?query&paging SMV-UCS-100-002-방문 예정 정보 조회
방문자 상세 정보 views/visiting/detail.html /visiting/detail SMV-UCS-100-002-방문 예정 정보 조회

사용자 로그인

사용자 로그인은 smv-ui-app에 로그인 되지 않은 상태인 경우 무조건 보여주는 페이지입니다. 로그인이 성공하면 초기 화면인 방문 예정 정보 화면이 보이게 됩니다.

따라서, 다음과 같이 views/login.html 파일을 준비합니다.

<!DOCTYPE html>
<html>
<head>
    <%- include('common-head.html'); %>
</head>
<body>
    <div class = "container login">
        로그인
        <form method="post" action="/login">
            <input name="email" id="email" placeholder="email" value="john.doe@acme.ibm.com"><br>
            <input name="passwd" id="passwd" placeholder="password" type="password" value="passw0rd"><br>
            <input name="savepw" id="savepw" type="checkbox">비밀번호 저장<br>
            <button id="submit" type="submit">로그인</button>
        </form>
    </div>
    <script type="text/javascript" src="/scripts/login.js"></script>
</body>
</html>

로그인 화면은 사용자로 부터 email과 비밀번호를 입력 받아 /login으로 전달합니다. 이는 SMVUIAppController에서 작성했던 login 함수를 통해 처리됩니다.

그리고, 앞서 index.html을 /router/index.js에서 연결 했던 것과 같이 login.html과 '/login'으로 연결하는 부분이 있어야 합니다. 우리는 SMVUIAppController에 이를 구현하도록 합니다.

...
function renderFunction(req, res, view, obj) {
  obj = obj || {};
  obj['SMV_VISIT_BASE_URL'] = SMV_VISIT_BASE_URL;

  var authtoken = extractViewAuthToken(req);
  SMVAuthTokenHelper.getAuthTokenValue(authtoken, 'userinfo', (result)=>{
      if (result) {
        obj['userinfo'] = JSON.parse(result); 
      }
      return res.render(view, obj);
  });
}

function loginView(req, res) {
  renderFunction(req, res, 'login.html', {
    title: '로그인'
  });
}
...
  // Page view
  app.get(BASE_PATH + '/login', loginView);
...

Web Browser에서 /login URL로 접근하면 GET 방식이므로 POST 방식 접근 처리를 위한 login() 대신 loginView()가 호출 됩니다. loginView()에서는 renderFunction이라는 공통 함수에 보여주어야 할 HTML과 Data Object를 전달합니다. 이렇게 전달된 정보와 공통으로 전달되어야 하는 Data를 포함하여 response.render 함수를 호출하도록 되어 있습니다. 참고로, 전달하는 Data Object에 title 값이 있는데 login.html에 ejs 문법을 이용하여 title 값을 사용하는 것을 볼 수 있습니다.

![]((https://developer.ibm.com/kr/wp-content/uploads/sites/98//loginview.jpg)

로그인이 성공했다면 visiting/list로 이동하도록 되어 있으므로 방문 예정 목록을 구성해 보도록 합니다.

방문 예정 목록

로그인이 성공하면 브라우저 기준 오늘 날짜에 맞춰 방문 예정 정보 리스트를 보여줍니다.

방문 정보 목록 View

방문 예정 목록을 위해 visiting/list.html를 준비합니다.

<!DOCTYPE html>
<html>
<head>
    <%- include('../common-head.html'); %>
</head>
<body>
    <%- include('../common-header.html'); %>
    <div class = "container visiting list">
        <!-- 방문 일정 조회 -->
        <div>
            <input id="date" type="date" value="<%=date%>">
            <input id="keyword" placeholder="검색내용" value="<%=keyword%>">
            <select id="type">
                <option value="NAME" <% if(type == 'NAME' || type == undefined) { %>selected<% } %> >방문자명</option>
                <option value="EMAIL" <% if(type == 'EMAIL') { %>selected<% } %> >이메일</option>
                <option value="CONTACT" <% if(type == 'CONTACT') { %>selected<% } %> >연락처</option>
            </select>
            <button id="search">검색</button>
            <button id="register" onclick="window.location='/visiting/register';">방문등록</button>
        </div>
<% if (typeof error != "undefined") { %>
        <div class="error">
            오류가 발생했습니다: <%=error%>
        </div>
<% } else { %>
        <table>
            <tbody>
                <tr>
                    <th>#</th>
                    <th>일시</th>
                    <th>이름</th>
                    <th>직위</th>
                    <th>연락처</th>
                    <th>회사</th>
                    <th>상태</th>
                    <th>카드종류</th>
                    <th>카드번호</th>
                    <th>임직원</th>
                    <th>연락처</th>
                    <th>부서</th>
                </tr>
                <% for (var i=0 ; i < result.length ; i++) {%>
                <tr class="visiting" data-id="<%=result[i].id%>" data-escort-id="<%=result[i].escort.id%>">
                    <td><%=result[i].idx%></td>
                    <td><%=result[i].ndate%></td>
                    <td><%=result[i].visitor.name%></td>
                    <td><%=result[i].visitor.title%></td>
                    <td><%=result[i].visitor.contact%></td>
                    <td><%=result[i].visitor.company%></td>
                    <td><% if (result[i].agreement) { %>
                        확인
                    <% } else { %>
                        미동의
                    <% } %></td>
                    <% if (result[i].badge) { %>
                    <td><%=result[i].badge.type%></td>
                    <td><%=result[i].badge.number%></td>
                    <% } else { %>
                    <td colspan="2">미할당</td>
                    <% } %>
                    <td><%=result[i].escort.name%></td>
                    <td><%=result[i].escort.contact%></td>
                    <td><%=result[i].escort.dept%></td>
                </tr>
                <% } %>

                <% if (result.length == 0) {%>
                <tr>
                    <td></td>
                    <td colspan="11">방문 등록 정보가 없습니다.</td>
                </tr>
                <% } %>
            </tbody>
        </table>
        <%- include('../common-paging.html'); %>
<% } %>
    </div>
    <script type="text/javascript" src="/scripts/visiting/list.js"></script>
    <%- include('../common-footer.html'); %>
</body>
</html>

ejs에서는 공통으로 사용하는 내용인 경우 별도로 분리하고 사용할 수 있도록 <%- include(); %> 기능을 제공합니다. 다음과 같은 경우 list.html의 상위 디렉토리에 있는 common-head.html 이라는 파일이 자동으로 포함됩니다.

    <%- include('../common-head.html'); %>

common-head.html은 HTML head에 공통으로 필요한 부분 또는 반복되는 부분을 분리해 놓은 내용입니다. 앞서 Data Object로 전달했던 title 값도 이 파일에서 사용하고 있습니다.

    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no"/>
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <link rel="stylesheet" href="/style/style.css" />
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
    <script type="text/javascript">
        function getSmvVisitBase(path) {
            path = path || '';
            return `<%=SMV_VISIT_BASE_URL%>${path}`;
        }
    </script>
    <% if (typeof title != "undefined") { %>
    <title><%=title%></title>
    <% } %>

또한 공통으로 사용되는 HTML은 다음과 같습니다.

자세한 내용은 GitHub https://github.com/mc500/smv/tree/master/projects/smv-ui-app/views를 참고 하시기 바랍니다.

이제 SMVUIAppController.js 파일에 visiting/list에 대한 서비스 코드를 작성합니다.

...
function extractViewAuthToken(req) {
  //var token = req.headers[AUTH_TOKEN_KEY] || req.headers[AUTH_TOKEN_KEY.toLowerCase()];
  var token = req.cookies[AUTH_TOKEN_KEY];
  if (!token) {
    console.error(`${AUTH_TOKEN_KEY} is not in cookies`);
  }
  return token;
}

function authenticatedViewFilter(req, res, next) {
  var token = extractViewAuthToken(req);
  SMVAuthTokenHelper.isValidAuthToken(token, function(valid) {
    //valid = true;
    if (valid) {
      // go next
      return next();
    }

  // redirect to login
    res.redirect(BASE_PATH + '/login');
  });
}
...

function visitinglistView(req, res) {
  console.log(`visiting list`);

  var authtoken = extractViewAuthToken(req);
  
  // TODO
  res.statusCode = 200;
  res.end('OK');
}
...
app.get(BASE_PATH + '/visiting/list', authenticatedViewFilter, visitinglistView);
...

authenticatedViewFilter는 앞서 API 서비스에서 작성한 내용과 거의 동일하지만 Header로 전달되는 인증토큰이 아닌 cookie로 전달되는 것을 처리하기 위한 함수입니다.

방문 정보 목록 Controller

visitinglistView 함수는 Cloudant를 이용하여 방문 정보를 조회하는데 URL paramter로 조회 정보를 입력받고 이를 처리하도록 합니다.

parameter 설명
date 조회 날짜, 형식(2017-01-01)
keyword 조회 할 내용
type 조회 할 종류 (방문자명, 이메일, 연락처)
page 화면에 표시할 페이지 번호 (기본값 1)
size 화면에 표시할 페이지 size (기본값 10)
  var date = new Date(req.query.date ? req.query.date : getDateString());
  var keyword = req.query.keyword;
  var type = req.query.type;
  var page = Number(req.query.page);
  var size = req.query.size || PAGE_SIZE;

그리고 smv-visit API를 호출하여 정보를 얻습니다.

...
  var query = {
    date: date,
    type: type,
    keyword: keyword,
    page: page-1, // page starts with 0
    size: size
  };

  // search 
  request.get({
    url: `${SMV_VISIT_BASE_URL}/api/smv/v1/visit/search`,
    headers: buildAuthHeaders(authtoken),
    qs: query
  }, function(error, response, body) {
...

API 호출 결과를 body로 전달받고 이를 배열로 만들어서 화면에 테이블 형태로 표시합니다.

...
    var json = body ? JSON.parse(body) : [];
...
    for(var i=0; i< len; i++) {
      var pagenum = off+i+1;
      pageinfo.pages.push({
        page: pagenum,
        query: querystring.stringify({
          date: getDateString(date),
          type: type,
          keyword: keyword,
          page: pagenum,
          size: size
        })
      });
    }

    renderFunction(req, res, 'visiting/list.html', {
      title: '방문 일정 조회',
      date: getDateString(date),
      datetime: getDateTimeString(date),
      keyword: keyword,
      type: type,
      result: json.result.map((item, idx)=>{        
        var ret = Object.assign({}, item);
        ret.idx = (page - 1) * pageSize + idx + 1;
        // date
        ret.ndate = getDateTimeString(new Date(item.date));
        return ret;
      }),
      pageinfo : pageinfo
    });
...

방문 정보 목록 Web UI Controller

방문 예정 목록 화면이 앞서 로그인 화면과 조금 다른 부분이 있는데 바로 Web Browser의 JavaScript를 이용하는 점입니다. 약간 혼동이 올 수 있겠는데 Node.js용 서버 코드로 실행되는 JavaScript와 Web Browser에서 실행되는 JavaScript는 분리되어 있습니다. 본 글에서는 Web UI를 제어하기 위해 JavaScript를 이용하며 특히 jQuery Library를 이용하고 있습니다.

공통 HTML인 common-head.html 파일을 보면 다음과 같이 jquery 라이브러리를 사용하고 있는 것을 볼 수 있습니다.

...
<script type="text/javascript" src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
...

또한, visiting/list.html에서도 해당 view가 사용하는 스크립트를 로딩하고 있습니다.

...
<script type="text/javascript" src="/scripts/visiting/list.js"></script>
...

smv-ui-app에서는 public이라는 디렉토리에 이미지 리소스나 스크립트 파일을 두면 Web Browser에서 접근가능한 상태가 됩니다. list.js JavaScript 파일을 public/scripts/visiting 디렉토리에 두고 위와 같이 HTML을 작성하면 해당 스크립트가 로딩되어 필요한 기능을 사용 할 수 있습니다.

// visiting/list.js
'use strict';

(function() {
  //
  $('#search').on('click', ()=>{
    //
    var date = $('#date').val();
    var type = $('#type').val();
    var keyword = $('#keyword').val();
    var query = [];
    var url = '/visiting/list';
    if (date) query.push(`date=${date}`);
    if (type) query.push(`type=${type}`);
    if (keyword) query.push(`keyword=${keyword}`);
    if (query.length > 0) {
      url += '?'+query.join('&');
    }

    window.location.href = url;
    return false;
  });

  $('table tr.visiting').on('click', (evt)=>{
    var $visiting = $(evt.currentTarget);
    //var $target = $(evt.target);

    //alert('click! '+$visiting.attr('data-id'));
    window.location.href = '/visiting/detail/'+$visiting.attr('data-id');

    return false;
  });

})();

list.js에는 검색 버튼이 click 이벤트를 인식하여 URL을 조합하는 기능과 리스트로 보여주는 방문 예정 정보를 선택하여 상세 정보 페이지 view로 이동하는 두 가지의 기능이 구현되어 있습니다.

jQuery에 대한 내용은 다음 링크를 참고 바랍니다.

초기에는 데이터가 등록되어 있지 않기 때문에 아래와 같이 항목이 없는 화면을 볼 수 있습니다.

방문 정보 목록 정렬

사실 앞서 작성해 놓았던 smv-visit 서비스에서는 한가지 빠진 부분이 있습니다. 바로 조회된 목록에 보이는 항목들이 아이템 생성 또는 업데이트를 기준으로 정렬되어 있다는 점입니다. 일반적인 경우 날짜와 같은 특정 항목을 기준으로 정렬하는데 Cloudant의 경우는 저장된 document의 key가 아닌 경우 index를 생성하지 않이므로 find 명령으로 문서를 찾을 때 sort 옵션으로 호출하면 오류가 발생합니다. 따라서, 다음과 같이 Cloudant Dashboard의 Cloudant Query 메뉴로 진입하여 index를 생성합니다.

{
  "index": {
    "fields": [
      "date:number"
    ]
  },
  "type": "json"
}

인덱스가 정상적으로 생성되었다면 Cloudant Query 메뉴에 Queryable indexes: 항목에 json:number라는 형식으로 추가된 것을 볼 수 있습니다.

검색 옵션으로 sort 항목을 추가하여 날짜 순서대로 정렬되도록 처리합니다.

...
function searchVisits(req, res) {
...
      mydb.find({
        selector: selector,
        sort:[{'date:number': 'asc'}],
        limit: limit,
        skip: skip,
        use_index: queryIndex
      }, function(err, result) {
...

방문 정보 등록

방문 정보 등록 화면은 임직원(ESCORT)이 방문 예정자 정보를 입력하는 화면입니다.

방문 정보 등록 View

방문 정보 목록 화면에서 방문등록 버튼을 클릭하여 진입 할 수 있습니다. 방문 정보 목록용 list.js에서 Script로 로딩하는 방식이 아닌 링크를 제공하여 페이지 view를 보여주는 방식으로 구성되어 있습니다.

...
<button id="register" onclick="window.location='/visiting/register';">방문등록</button>
...

이제 방문 정보 등록을 위해 visiting/register.html를 준비합니다.

<!DOCTYPE html>
<html>
<head>
    <%- include('../common-head.html'); %>
</head>
<body>
    <%- include('../common-header.html'); %>
    <div class = "container visiting register">
        <!-- 방문자 등록 -->
        <div class="hidden">
            <input id="euserid" hidden value="<%=userinfo.userid%>">
            <input id="eemail" hidden value="<%=userinfo.email%>">
        </div>
        <table>
            <tbody>
                <tr>
                    <td>일시</td>
                    <td><input id="datetime" type="datetime-local" value="<%=datetime%>"></td>
                    <td>임직원</td>
                    <td><input id="ename" disabled value="<%=userinfo.name%>"></td>
                </tr>
                <tr>
                    <td>이름</td>
                    <td><input id="name" value="Jane Doe"></td>
                    <td>연락처</td>
                    <td><input id="emobile" disabled value="<%=userinfo.mobile%>"></td>
                </tr>
                <tr>
                    <td>직위</td>
                    <td><input id="title" value="Dr."></td>
                    <td>부서</td>
                    <td><input id="edept" disabled value="<%=userinfo.dept%>"></td>
                </tr>
                <tr>
                    <td>연락처</td>
                    <td><input id="contact" value="+82-1-1234-0678"></td>
                </tr>
                <tr>
                    <td>이메일</td>
                    <td><input id="email" value="jane.doe@customer.com"></td>
                </tr>
                <tr>
                    <td>회사</td>
                    <td><input id="company" value="Customers Inc"></td>
                </tr>
            </tbody>
        </table>
        <div>
            <button id="register">등록</button>
            <button id="cancel" onclick="history.back(1);">취소</button>
        </div>
    </div>
    <script type="text/javascript" src="/scripts/visiting/register.js"></script>
    <%- include('../common-footer.html'); %>
</body>
</html>

방문자 등록 페이지에는 현재 로그인한 임직원이 ESCORT로 자동으로 입력되게 됩니다. 따라서, 현재 로그인한 사용자의 정보인 userinfo라는 항목 정보를 이용하여 작성된 것을 볼 수 있습니다.

방문 정보 등록 Controller

방문 정보 등록 화면은 임직원(ESCORT)이 방문 예정자 정보를 입력하는 화면입니다. 방문 정보 목록 화면에서 방문등록 버튼을 클릭하여 진입 할 수 있습니다.

이제 SMVUIAppController.js 파일에 visiting/register에 대한 서비스 코드를 작성합니다.

...
function registerView(req, res) {
  renderFunction(req, res, 'visiting/register.html', {
    title: '방문자 등록',
    datetime: getNextDateTimeString()
  });
}
...
  app.get(BASE_PATH + '/visiting/register', authenticatedViewFilter, registerView);
...

간단하게 visiting/register.html이 사용되며 등록 날짜 및 시간 정보를 위해 getNextDateTimeString 함수를 통해 정보를 얻습니다. getNextDateTimeString 함수는 현재 입력 시간을 기준으로 가장 가까운 날짜를 얻는 함수인데, 우선은 다음날 9시를 기준으로 되어 있습니다. 나중에 일정한 규칙을 두고 해당 내용을 구현할 수 있습니다.

...
function getNextDateTimeString() {
  var date = new Date();
  date.setHours(9);
  date.setMinutes(0);
  date.setSeconds(0);
  date.setMilliseconds(0);
  // next day
  date.setDate(date.getDate()+1);

  return getDateTimeString(date);
}
...

그리고, HTML에 userinfo라는 이름으로 현재 로그인한 임직원 정보가 입력된다고 했는데 실제 renderFunction에 전달되는 정보에는 해당 정보가 없는 것을 볼 수 있습니다. 이는 renderFunction 내부에 공통으로 사용되는 코드에서 자동으로 입력되는 형태라 별다른 정보 입력이 없어도 userinfo를 사용할 수 있습니다.

...
function renderFunction(req, res, view, obj) {
  obj = obj || {};
  obj['SMV_VISIT_BASE_URL'] = SMV_VISIT_BASE_URL;

  var authtoken = extractViewAuthToken(req);
  SMVAuthTokenHelper.getAuthTokenValue(authtoken, 'userinfo', (result)=>{
      if (result) {
        obj['userinfo'] = JSON.parse(result); 
      }
      return res.render(view, obj);
  });
}
...

방문 정보 등록 Web UI Controller

방문 예정 목록처럼 방문 정보 등록 화면을 위한 JavaScript 코드로 public/scripts/visiting/register.js 파일을 사용합니다.

register라는 id를 가진 버튼이 클릭되면 현재 화면에 있는 모든 input의 값을 수집하고 만약 값이 비어 있는 경우는 진행하지 않도록 합니다. 방문자와 임직원 정보가 모두 입력된 것을 확인하면 smv-visit API를 XHR(XML HTTP Request)로 호출하여 새로운 방문 예정 정보를 생성합니다. $.ajax는 jQuery에서 제공하는 XHR 호출 함수입니다.

// visiting/register.js
'use strict';

(function() {
  // 
  $('#register').on('click', ()=>{
    // check fields
    var checked = Array.prototype.some.call($('input'), function(field){
      return !$(field).val();
    });
    if (checked) {
      alert('입력되지 않은 정보가 있습니다.');
      return false;
    }

    // visitor info
    var datetime = $('#datetime').val();
    var name = $('#name').val();
    var title = $('#title').val();
    var contact = $('#contact').val();
    var email = $('#email').val();
    var company = $('#company').val();

    // employee info
    var euserid = $('#euserid').val();
    var ename = $('#ename').val();
    var eemail = $('#eemail').val();
    var emobile = $('#emobile').val();
    var edept = $('#edept').val();

    console.log(`${datetime}, ${name}, ${title}, ${contact}, ${company}`);
    console.log(`${euserid}, ${ename}, ${eemail}, ${emobile}, ${edept}`);
    
    var visitObject = {
      'date': new Date(datetime).toString(),
      'visitor': {
        'name': name,
        'title': title,
        'contact': contact,
        'email': email,
        'company': company
      },
      'escort': {
        'id': euserid,
        'name': ename,
        'email': eemail,
        'dept': edept,
        'mobile': emobile
      },
      'agreement': undefined,
      'badge': undefined
    };

    $.ajax({
      type: 'POST',
      url: getSmvVisitBase('/api/smv/v1/visit'),
      xhrFields: {
        withCredentials: true
      },
      data: visitObject,
      success: function (data) {
        console.log(data);
        alert('등록 성공');
        window.location.href = '/visiting/list';
      },
      error: function (err) {
        console.log(err);
        if (err.status == 401) {
          // redirect to login
          window.location.href = '/login?error='+err.responseText;
        } else {
          alert('error:'+err.responseText);
        }
      }
    });

    return false;
  });

})();

완성된 화면은 다음과 같습니다.

방문 정보를 등록하면 다음과 같이 방문 정보 리스트에서 항목이 나타나는 것을 볼 수 있습니다.

방문 정보 상세

방문 정보 상세 화면은 이미 등록된 정보를 확인, 변경 또는 삭제를 위한 화면입니다.

방문 정보 상세 View

방문 예정 목록 중 하나를 선택하면 해당 예정 정보의 상세 화면을 보여줍니다. 방문 정보 등록 화면과 상당히 유사합니다. list.js에서 JavaScript를 통해 detail 화면으로 이동합니다.

...
  $('table tr.visiting').on('click', (evt)=>{
    var $visiting = $(evt.currentTarget);
    //var $target = $(evt.target);

    //alert('click! '+$visiting.attr('data-id'));
    window.location.href = '/visiting/detail/'+$visiting.attr('data-id');

    return false;
  });
...

이제 방문 정보 등록을 위해 visiting/detail.html를 준비합니다.

<!DOCTYPE html>
<html>
<head>
    <%- include('../common-head.html'); %>
</head>
<body>
    <%- include('../common-header.html'); %>
    <div class = "container visiting detail">
        <!-- 방문자 상세 정보 -->

<% if (typeof error != "undefined") { %>
        <div class="error">
            오류가 발생했습니다: <%=error%>
        </div>
        <div>
            <button id="cancel" onclick="history.back(1);">뒤로</button>
        </div>
<% } else { %>
        <div class="hidden">
            <input id="euserid" hidden value="<%=escort.id%>">
            <input id="eemail" hidden value="<%=escort.email%>">
            <input id="visitingid" hidden value="<%=visitingid%>">
        </div>
        <table>
            <tbody>
                <tr>
                    <td>일시</td>
                    <td><input id="datetime" type="datetime-local" value="<%=ndate%>"></td>
                    <td>임직원</td>
                    <td><input id="ename" disabled value="<%=escort.name%>"></td>
                </tr>
                <tr>
                    <td>이름</td>
                    <td><input id="name" value="<%=visitor.name%>"></td>
                    <td>연락처</td>
                    <td><input id="emobile" disabled value="<%=escort.mobile%>"></td>
                </tr>
                <tr>
                    <td>직위</td>
                    <td><input id="title" value="<%=visitor.title%>"></td>
                    <td>부서</td>
                    <td><input id="edept" disabled value="<%=escort.dept%>"></td>
                </tr>
                <tr>
                    <td>연락처</td>
                    <td><input id="contact" value="<%=visitor.contact%>"></td>
                    <td>카드번호</td>
                    <td><input id="cardnumber" disabled></td>
                </tr>
                <tr>
                    <td>회사</td>
                    <td><input id="company" value="<%=visitor.company%>"></td>
                    <td>보안서명</td>
                    <td><span><a onclick="alert('대기중');">대기</a></span></td>
                </tr>
            </tbody>
        </table>
        <div>
            <button id="update">변경</button>
            <button id="delete">삭제</button>
            <button id="cancel" onclick="history.back(1);">뒤로</button>
        </div>
<% } %>
    </div>
    <script type="text/javascript" src="/scripts/visiting/detail.js"></script>
    <%- include('../common-footer.html'); %>
</body>
</html>

방문 정보 상세 Controller

이제 SMVUIAppController.js 파일에 visiting/detail에 대한 서비스 코드를 작성합니다.

...
function detailView(req, res) {
  console.log(`visiting detail with id : ${req.params.id}`);

  var authtoken = extractViewAuthToken(req);
  var visitingid = req.params.id;

  // get visit item with id
  request.get({
    url: `${SMV_VISIT_BASE_URL}/api/smv/v1/visit/${visitingid}`,
    headers: buildAuthHeaders(authtoken)
  }, function(error, response, body) {
    if (error) {
      console.log(error);
      return renderFunction(req, res, 'visiting/detail.html', {
        title: '방문자 상세 정보',
        error: error
      });
    }

    // Check Error Condition
    if (response.statusCode != 200) {
      return renderFunction(req, res, 'visiting/detail.html', {
        title: '방문자 상세 정보',
        error: `Response with code ${response.statusCode}, ${body}`
      });
    }

    var json = body ? JSON.parse(body) : {};
    console.log(json);

    json.title = '방문자 상세 정보';
    json.visitingid = visitingid;
    json.ndate = getDateTimeString(new Date(json.date));
    renderFunction(req, res, 'visiting/detail.html', json);
  });
}
...
  app.get(BASE_PATH + '/visiting/detail/:id', authenticatedViewFilter, detailView);
...

방문 정보 상세 Web UI Controller

방문 예정 등록처럼 방문 정보 상세 화면을 위한 JavaScript 코드로 public/scripts/visiting/detail.js 파일을 사용합니다. 그리고, smv-visit API도 XHR 방식으로 호출합니다.

기존 정보를 편집하려면 PUT 방식을 이용합니다.

  $('#update').on('click', ()=>{
    // check fields
    var checked = Array.prototype.some.call($('input.visitor'), function(field){
      return !$(field).val();
    });
    if (checked) {
      alert('입력되지 않은 정보가 있습니다.');
      return false;
    }

    // 
    var visitingid = $('#visitingid').val();
    var datetime = $('#datetime').val();

    // visitor info
    var name = $('#name').val();
    var title = $('#title').val();
    var contact = $('#contact').val();
    var email = $('#email').val();
    var company = $('#company').val();

    console.log(`${datetime}, ${name}, ${title}, ${contact}, ${company}`);

    var visitObject = {
      'date': new Date(datetime).toString(),
      'visitor': {
        'name': name,
        'title': title,
        'contact': contact,
        'email': email,
        'company': company
      },
      //'agreement': undefined,
      //'badge': undefined
    };

    $.ajax({
      type: 'PUT',
      url: getSmvVisitBase(`/api/smv/v1/visit/${visitingid}`),
      xhrFields: {
        withCredentials: true
      },
      data: visitObject,
      success: function (data) {
        console.log(data);
        alert('변경 성공');
        //window.location.href = '/visiting/list';
      },
      error: function (err) {
        console.log(err);
        if (err.status == 401) {
          // redirect to login
          window.location.href = '/login?error='+err.responseText;
        } else {
          alert('error:'+err.responseText);
        }
      }
    });

    return false;
  });

그리고, 삭제를 하기위해서는 DELETE 방식을 이용합니다.

  $('#delete').on('click', ()=>{

    var visitingid = $('#visitingid').val();
    
    $.ajax({
      type: 'DELETE',
      url: getSmvVisitBase(`/api/smv/v1/visit/${visitingid}`),
      xhrFields: {
        withCredentials: true
      },
      success: function (data) {
        console.log(data);
        alert('삭제 성공');
        //window.location.href = '/visiting/list';
        history.back(1);
      },
      error: function (err) {
        console.log(err);
        if (err.status == 401) {
          // redirect to login
          window.location.href = '/login?error='+err.responseText;
        } else {
          alert('error:'+err.responseText);
        }
      }
    });

    return false;
  });

완성된 화면은 다음과 같습니다.

Local test 환경에서 Bluemix 서버로 배포시 이슈 정리

XSRF Cross Domin 이슈

사실 Server Code에서 API를 호출하는 경우 문제가 되지 않지만 Web Browser에서 API를 호출 할 때 서버의 domain name과 다른 경우 cross-domain 오류를 발생합니다. 이런 경우 API를 제공하는 서버에서 Cross Domin을 허용하는 서버의 domain name을 입력하도록 되어 있습니다. 본 글에서는 smv-visit 서비스가 대상이 되는데 해당 정보는 smv-ui-app에서만 호출하게 되므로 SMV_UI_APP_BASE_URL 환경 변수 정보를 활용하여 app.js에 다음과 같은 내용을 추가해 주는 것으로 해결 합니다.

...
const SMV_UI_APP_BASE_URL = process.env['SMV_UI_APP_BASE_URL'];

// to be placed before api service handlers
app.use(function (req, res, next) {

  // Website you wish to allow to connect
  res.setHeader('Access-Control-Allow-Origin', SMV_UI_APP_BASE_URL);

  // Request methods you wish to allow
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');

  // Request headers you wish to allow
  res.setHeader('Access-Control-Allow-Headers', 'X-AUTH-TOKEN,applicaiton/json');

  // Set to true if you need the website to include cookies in the requests sent
  // to the API (e.g. in case you use sessions)
  res.setHeader('Access-Control-Allow-Credentials', true);

  // Pass to next layer of middleware
  next();
});
...

인증 토큰을 XHR Cookie로 전달

초기 서비스 API 설계 시 인증 토큰은 Request Header를 통해 전달하는 것으로 구상했었습니다. 그러나, UI App의 경우 Page에서 사용하는 인증 정보 관리를 위해 token 정보를 cookie로 관리 했습니다. 앞서 XHR의 Cross Domain 이슈는 서버 코드를 수정하는 것으로 해결을 했는데 cookie는 UI App과 Service API의 domain 이름을 동일한 것을 사용하지 않는 이상 문제가 됩니다.

현재로서 bluemix에 배포시 얻는 domain은 .mybluemix.net이므로 이를 cookie domain으로 설정하는 방법을 사용할 수 있지만, 만약 문제가 된다면 cookie 정보를 직접 읽어 API 호출 header에 추가하는 방법을 이용해야 합니다.

먼저 cookie domain이름을 .mybluemix.net로 설정하는 경우는 smv-ui-appSMVUIAppController.js에서 cookie 생성 코드를 다음과 같이 작성합니다.

...
    // keep authtoken as cookie
    res.cookie(AUTH_TOKEN_KEY, authtoken, {
      domain: '.mybluemix.net',
      maxAge: EXPIRES_IN_SECS*1000 // in milliseconds
    });
...

그리고, API에서도 인증 token 수집 시 cookie에서도 얻을 수 있도록 SMVVisitController.jsextractAuthToken 함수를 변경합니다.

function extractAuthToken(req) {
  var token = req.headers[AUTH_TOKEN_KEY] || req.headers[AUTH_TOKEN_KEY.toLowerCase()] || req.cookies[AUTH_TOKEN_KEY];

  if (!token) {
    console.error(`${AUTH_TOKEN_KEY} is not in headers or cookies`);
  }
  console.log(token);
  return token;
}

XHR Header로 인증 토큰 전달

XHR 호출 시 전달하는 인증 토큰을 Header로 전달하는 경우 common-head.html에서 인증 토큰 정보를 제공하는 JavaScript 함수를 구성하여 이를 활용 하도록 구성합니다.

...
        function getAuthTokenHeader(header) {
            header = header || {};
            var obj = Object.assign({}, header);
<% if (typeof AUTH_TOKEN_VALUE != "undefined") { %>
            obj[`<%=AUTH_TOKEN_KEY%>`] = `<%=AUTH_TOKEN_VALUE%>`;
<% } %>
            return obj;
        }
...

그리고 XHR 호출 시 getAuthTokenHeader 를 호출하도록 합니다.

...
var headersObject = getAuthTokenHeader();

    $.ajax({
      type: 'POST',
      url: getSmvVisitBase('/api/smv/v1/visit'),
      xhrFields: {
        withCredentials: true
      },
      headers: headersObject,
...

Timezone 차이

기본적으로 웹 브라우저는 시스템이 제공하는 TimeZone을 사용합니다. 만약 PC또는 Server에 설정된 Timezone이 한국인 경우 KST(Korea Standard Timezone)으로 GMT+09:00를 기준으로 표시됩니다. 그러므로 Local에서 서버를 실행하는 경우와 이를 서버로 배포 후 실행할 때 차이가 발생 할 수 있습니다. 특히, Web Browser에서 사용하는 Timezone과 서버와 다를 경우는 더욱 문제가 될 수 있습니다. 이런경우 웹 브라우저와 서버에 상관 없이 절대시간 정보를 이용하기도 하지만, 본 글에서와 같이 웹 브라우저 기준 날짜로 검색하는 경우에는 실제 전달되어야 할 정보는 날짜와 더불어 Timezone 정보 (또는 offset)도 같이 전달되어야 합니다.

smv-visit에서 조회를 위한 searchVisits 함수에는 다음과 같은 코드가 들어 있습니다.

...
    // Convert date in msec
    var d = new Date(date);
    var sdate = (d.getTime()-1000*(d.getSeconds()+d.getMinutes()*60+d.getHours()*3600)-d.getMilliseconds());
...

의도 했던 것은 절대 시간으로 입력받은 정보에서 날짜 정보만 사용하기 위해 시,분,초, 밀리초 정보를 0으로 설정하는 것이었는데 이는 Server의 Timezone을 기준으로 처리가 되는 것이므로 오동작을 할 수 있는 부분입니다. API 상으로 제공되는 부분이므로 Web Browser 시간을 기준으로 0시 0분 0초를 작성하여 API를 호출하면 해당 시간을 기준으로 +24시간 동안의 정보를 조회하는 형태로 제공되어야 합니다.

...
    // Convert date in msec
    var d = new Date(date);
    var sdate = d.getTime();
...

또한, smv-ui-app에서 query로 전달하는 경우도 text 값이 아닌 밀리초 정보가 포함된 형태의 시간 정보로 전달되어야 합니다만, 서버에서 사용하는 Timezone 정보를 KST로 고정하고 이를 처리하는 형태로 제공하는 경우도 많습니다. KST의 TimeZone 이름은 Asia/Seoul 이며, GMT+9시간의 차이를 가지고 있으므로 이를 이용 할 수 있는데 날짜와 시간 등을 얻기위한 계산을 해야 하므로 이를 지원하는 module을 사용하도록 합니다.

이 모듈을 설치하고 이를 이용한 날짜 및 시간 정보를 계산합니다.

...
const TIMEZONE_NAME = 'Asia/Seoul';
const DATE_FORMAT = 'YYYY-MM-DD';
const DATETIME_FORMAT = 'YYYY-MM-DDTHH:mm';
...
var moment = require('moment-timezone');
...
function getDateString(date) {
  return moment.tz(date, TIMEZONE_NAME).format(DATE_FORMAT);
}

function getDateTimeString(date) {
  return moment.tz(date, TIMEZONE_NAME).format(DATETIME_FORMAT);
}

function visitinglistView(req, res) {
...
  var dateText = req.query.date ? req.query.date : moment.tz(new Date(), TIMEZONE_NAME).format(DATE_FORMAT);

  // converted
  var date = moment.tz(dateText, TIMEZONE_NAME).toDate();
...

지금까지 방문자 관리 서비스들과 이를 사용하는 Web UI 앱에 대한 프로토타입을 구성해 보았습니다. 다음 글에서는 현재 개발된 코드를 Bluemix Cloud로 배포하고 이에 대한 DevOps 환경을 구축하는 내용으로 진행 하겠습니다.

  1. 클라우드 환경 이해
  2. 애플리케이션 구상 및 요건 정의
  3. 요건에 대한 Usecase 및 Wireframe 작성
  4. 마이크로 서비스 아키텍쳐 설계
  5. 애플리케이션 서버 환경 준비
  6. 애플리케이션 로컬 개발 환경 준비
  7. 애플리케이션 프로토타입 작성 Part1
  8. 애플리케이션 프로토타입 작성 Part2
  9. 애플리케이션 프로토타입 작성 Part3
  10. 애플리케이션 DevOps 환경 구성

참고

토론 참가

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다