이번 스프린트는 MVC 패턴과 더불어 MVC 패턴을 기본적으로 따르는 ORM 모듈 Sequelize를 배운다. 이 시간을 통해서 내가 왜 클라이언트보다 서버에 더 관심이 가는 이유를 알게 되었다. 이전에 객체지향디자인패턴을 접한 후 나는 일정한 패턴, 형식, 구조 그리고 명확한 역할 분배를 하는 일에 흥미를 느낀다는 것을 알았다. 서버는 디테일보다 아키택처를 중요시한다(물론 디테일을 신경쓰지 않는다는 말은 아니다. 클라이언트보다 상대적으로 그렇다는 의미다). 서버가 구동되는 전체적인 그림을 그렸을 때, 어떤 부분이 다른 어떤 부분과 상호작용을 하고 각각의 부분은 어떤 식으로 작동을 하는 지 등 톱니바퀴처럼 맞물려 돌아가는 모습을 보면 기분이 좋아진다.
주니어 개발자가 곧바로 백앤드 개발자로 되는 경우가 드물다. 그래서 프론트엔드를 먼저 준비하고 어느 정도 경력이 쌓이면 백엔드를 도전할 생각이다. 코치님께서 추천하신 <클린코드>, <클린아키택처> 책을 나중에 꼭 사서 읽어볼 생각이다. 지금은 코드를 많이 작성하는 데 우선을 둘 생각이다. 이제 다음주 마지막 HA를 치르고 나면 프로젝트에 들어간다. 두 번의 프로젝트에서 프론트, 백앤드 둘 다 해볼까 했는데 백앤드로 쭉 하기로 마음 먹었다. 물론 백앤드 지원자가 여러 명일 경우엔 못 할 수도 있지만. 하하
- SW Architecture 디자인 패턴 중 하나이다. 기능,역할에 따라 Model, View, Controller로 나눈다.
- Model, View, Controller 별로 폴더를 나누고 필요에 따라 모듈을 불러오는 방식을 통해 모듈 간 의존성을 낮추는(loose coupling) 장점이 있다. 한 모듈에 문제가 생기면 그 모듈에 문제가 있음을 바로 파악할 수 있고(버그 잡기가 용이해짐) 해당 모듈을 수정하더라도 다른 모듈에 영향이 가지 않아 유지보수적인 면에서도 좋다.
- View는 UI 렌더링과 관련이 있다. 예전과 달리 SPA(Single Page Application)로 웹앱을 구현하기 때문에 View는 클라이언트 단으로 넘어갔다. 또한 다양한 디바이스의 등장으로 서버측에서 디바이스별로 view들을 관리한다는 자체가 불필요한 자원 보관을 유발한다(가령, 웹페이지 전용 view는 스마트폰 view에서는 불필요하다). 사실상 view의 기능이 상실되었다고 볼 수 있다.
- Model은 관련된 데이터들의 형태를 정의하고 DB와 연동하여 데이터를 생성/읽기/수정/삭제와 같은 CRUD 처리를 담당한다.
- Controller는 클라이언트 요청에 따라 Model과 View에 특정 동작을 하도록 지시한다. 프로그래밍적으로 설명하자면, Model의 메서드들과 View의 메서드들을 논리적인 순서, 흐름에 맞게 호출하고 조합하는 과정이 담겨진다. 예를 들면(서버에서 View를 관리할 경우), 클라이언트에서 user profile 페이지를 요청했을 때 GET /users/1
controller는 Model의 메서드 getUserById(id)
를 호출하고 Model에서 가져온 데이터를 바탕으로 View에게 UI를 구성 createUserProfileView(user)
하도록 지시한다. View의 UI 결과물을 받아 최종적으로 클라이언트에 응답한다.
- MVC 패턴에서 Model에 속하며, Model과 DB의 상호작용을 가리킨다.
- 관계형 데이터베이스에서 데이터를 표현하는 방식과 데이터를 접근하는 방식을 프로그래밍 언어로, 즉 javscript 객체/클래스로 표현할 수 있게 도와준다.
처음 Sequelize를 접했을 때는 sql이 더 편한 것 같다고 생각했다. 새롭게 배우는 용어들도 있고 특히나 migration이 어떤 역할을 하는지 이해하는 데 어려움을 겪었다. 언제나 익숙한 건 쉬워보이고 새로운 건 어려워보인다. 공식문서를 반복해서 읽고 정리하면서 익숙해지니 점차 뭐가 뭔지 보이기 시작했다.
Sequelize가 편한 점은 CRUD를 직접 구현할 필요없이 Model 클래스를 상속받아 이미 구현된 CRUD 메서드를 사용할 수 있다는 것이다. 그저 어떤 데이터들로 구성할 지, 데이터 타입/형식은 어떠한지만 고려하면 된다. 개념을 완전히 소화하지 못했을 때는 Sequelize 메서드를 Model 안에서만 써야 한다고 생각했다. 그래서 office hour 시간에 불꽃이 튀듯 키보드를 두드려댔다. 왜 Model 안에서 Sequelize 메서드를 쓰지 않고 Controller에 쓰는지에 대해 의문을 제기했다. 사실 따지고 보면 그저 wrapping할 뿐이다. 물론 여러 단계를 거치는 경우에는 하나의 함수로 빼는 게 좋지만 wrapping만 하는 거면 차라리 controllers에서 직접 쓰는 게 좋은 것 같다.
github에 정말 오랜만에 질문을 했다. 역시 질문을 자주 안 하니 적을 때 시간이 꽤 걸렸다. 단수형으로 모델명을 지었는데 데이터베이스에서 찾을 때는 복수형인 까닭이 궁금하다는 내용이다. 답변을 들어보니 model:generate
에서 단수를 쓰더라도 복수형으로 바꿔준단다. 만약 단수로 유지하고 싶으면 추가적으로 옵션 freezeTableName: true
을 설정하면 된다.
- 먼저 database를 생성한 후, new연산자를 이용해 sequelize 생성자를 호출하여 database정보를 넘겨준다.
const sequelize = new Sequelize(database, username, password, config);
- 터미널에서 npx sequelize-cli init
을 입력하면 이 과정을 자동으로 해준다. 더불어 config, models, migrations, seeders 폴더를 생성한다.
option | description |
---|---|
config | 데이터베이스 연결방법을 cli에 알려주는 config파일이 들어있는 폴더 |
models | 프로젝트의 모든 모델들이 들어있는 폴더 |
migrations | 모든 migration 파일들이 들어있는 폴더 |
seeders | 모든 seed 파일들이 들어있는 폴더 |
// models파일 안에 기본적으로 들어있는 index.js
// 모든 model 모듈을 load하여 db객체에 담는 과정이 구현되어있다.
'use strict';
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require(__dirname + '/../config/config.json')[env]; // env 설정에 맞게 config를 가져온다.
const db = {};
let sequelize;
// 데이터베이스와 연결시키는 과정
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(config.database, config.username, config.password, config);
}
fs
.readdirSync(__dirname) // models 폴더를 읽고
.filter(file => { // index.js 파일을 제외한 js파일들을 걸러낸다.
return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
})
.forEach(file => {
const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes);
db[model.name] = model; // index.js파일 외의 모델 파일들의 이름을 key,
//class를 value로 지정하여 db 객체에 저장한다.
});
/*
db = {
user : class user,
post : class post,
:
};
*/
Object.keys(db).forEach(modelName => {
if (db[modelName].associate) { // model에 associate 메소드가 존재하면
db[modelName].associate(db); // associate 메소드를 실행한다.
// associate는 hasOne, belongsTo, hasMany, belongsToMany와 같은 메소드를 사용하여
// 테이블 관계를 명시하고 foregin key 설정하는 과정이 구현되어야 한다.
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db; // Controller에서 Model들을 접근할 때 이 db객체를 사용한다.
// ex) const db = require('../models');
// console.log(db.user); // user class
command
model:generate
필수 옵션- name : 모델명(table명) - attributes : model의 attribute(field) 리스트
npx sequelize-cli mode:generate -- name User --attributes firstName:string, lastName:string, email:string
- 커맨드 실행 결과 : models폴더에 user파일 생성, migrations폴더에 user migration 파일 생성
// user.js 파일
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
static associate(models) {
}
}
user.init(
{
firstName: DataTypes.STRING,
lastName: DataTypes.STRING,
email: DataTypes.STRING,
},
{
sequelize,
modelName: 'user',
}
);
return url;
};
- npx sequelize-cli db:migrate
- 커맨드 실행결과 : database에서 SequelizeMeta로 불리는 테이블 생성. SequelizeMeta 테이블은 어떤 migrations가 현재 데이터베이스에서 실행중인지 기록함 , 실제로 database에 table 생성
- migration 파일은 table 변경에 대한 커밋 혹은 로그를 남기는 것. migration 파일을 바탕으로 테이블을 데이터베이스에 생성하기 때문에 model을 수정했을 경우 migration파일을 새로 생성하여 수정된 부분을 업데이트하는 과정을 작성해야 한다
- npx sequelize-cli migration:generate --name migration-skeleton
명령어로 migration 파일 생성
// 커맨드 실행 후 생성된 migration파일
module.exports = {
up: ( queryInterface, Sequelize ) => {
// 새로운 상태로 변경하기 위한 로직
// db:migrate 할 때 호출
},
down : ( queryInterface, Sequelize ) => {
// 변경사항을 되돌리기 위한 로직
// db:migrate:undo 할 때 호출
}
}
- queryInterface 는 데이터베이스를 수정할 때 사용한다.
- Sequelize 는 데이터 타입을 지정할 때 사용한다.
- up, down 메서드는 Promise 객체를 반환한다.
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
// Posts 테이블에 userId field를 추가하는 과정
return queryInterface.addColumn('Posts', 'userId', {
type: Sequelize.DataTypes.INTEGER,
references: {
model: {
tableName: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
});
},
down: async (queryInterface, Sequelize) => {
return queryInterface.removeColumn('Posts', 'userId');
},
};
- 데이터베이스의 테이블을 class로 표현 : table 이름을 class 이름으로 짓고 각 cloumns는 class의 속성으로 지정, 추가적으로 데이터 처리 명령 메소드들이 포함됨
- Model을 상속받음
- model의 이름은 보통 단수형으로 지정하지만 table은 주로 복수형으로 지정
- 모델이 변경될 때마다 마이그레이션 파일을 새로 생성한 후 db:migrate
command를 실행해야함
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('sqlite::memory:');
const User = sequelize.define('User', {
firstName:{
type: DataTypes.STRING,
allowNull: false
}
:
});
const { Sequelize, DataTypes, Model } = require('sequelize');
const sequelize = new Sequelize('sqlite::memory');
class User extends Model {}
User.init({
firstName : {
type: DataTypes.STRING,
allowNull: false
},
:
}, {
sequelize, // connection 인스턴스
modelName: 'User' // 모델명
});
- 데이터 타입을 지정할 때 id, createdAt, updatedAt은 sequelize에서 자동으로 생성해준다. 만약 필요없다면 옵션으로 { timestamps: false }
를 준다. 둘 중 하나만 필요하다면 { timestamps:true, createdAt : false }
방식으로 옵션을 준다.
- Model의 인스턴스는 database에 record를 의미한다.
const record = await User.create( { name: 'Jane' } );
// create와 달리 먼저 name이 'jane'인 record가 있는지 확인하고
// 없으면 새로 생성, 있으면 기존에 있던 record를 반환한다.
// created 가 true이면 기존에 record가 없다는 의미이다.
const [record,created] = await User.findOrCreate( { namd: 'Jane' } );
// record를 직접 업데이트하는 방법
const record = await User.create( { name: 'Jane' } );
record.name = "Ada";
await record.save( );
// 테이블 안에 record를 찾아 업데이트하는 방법
await User.update({ name: 'Mina' } ,
where: {
name: 'Jane'
}
});
// record를 직접 삭제하는 방법
const record = await User.create( { name: 'Jane' } );
await record.destroy( );
// 테이블 안에 record를 찾아 삭제하는 방법
await User.destroy({
where: { name: 'Jane' }
});
// 테이블 안의 모든 records 삭제
await User.destroy({
truncate:true
});
// 유저테이블의 모든 컬럼값 들고오기 SELECT * FROM User;
const users = await User.findAll( );
// 유저테이블의 특정 컬럼값 들고오기 SELECT columns FROM User;
const users = await User.findAll( {
attributes: [ 'firstName', 'lastName' ]
});
// 유저테이블에서 특정 조건에 해당하는 컬럼값만 들고오기 SELECT * FROM User WHERE conditions;
const users = await User.findAll({
where: {
id : 2
}
});
// 유저테이블에서 특정조건에 해당하는 컬럼값 하나만 들고오기
const user = await User.findOne( {
where:{
firstName: 'john'
}
});
- 테이블 간 관계를 명시할 때 Model로부터 상속받은 메서드들 hasOne
, belongsTo
, belongsToMany
를 사용한다.
- 공식문서에서는 아래와 같이 사용할 것을 권한다.
일대일 관계 : hasOne, belongsTo
ex) User.hasOne(UserProfile); UserProfile.belongsTo(User);
일대다 관계 : hasMany, belongsTo
ex) User.hasMany(Post); Post.belongsTo(User);
다대다 관계 : belongsToMany, belongsToMany
ex) Post.belongsToMany(Tag, { through : PostTags }); Tag.belongsToMany(Post, { through : PostTags });
// User.js 파일
const crypto = require('crypto');
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class User extends Model {
// Model/index.js 에서 호출하는 메서드로 db객체를 인자로 넘겨준다.
// models === db
static associate(models) {
// onDelete를 'cascade'로 지정하면 user가 삭제될 때 post도 따라 삭제된다.
this.hasMany(models.Post, { onDelete: 'cascade' });
}
}
User.init({
first_name: DataTypes.STRING,
last_name : DataTypes.STRING,
email: DataTypes.STRING,
password:DataTypes.STRING
},{
hooks:{ // Model 객체의 lifecycle
afterValidate: (data,options) => {
// 패스워드 암호화
const shasum = crypto.createHash('sha1');
shasum.update(data.password);
data.password = shasum.digest('hex');
}
},
sequelize,
modelName:'User'
});
return User;
};
// Post.js파일
const { Model } = require('sequelize');
module.exports = (sequelize,DataTypes) => {
class Post extends Model {
static async associate(models) {
this.belongsTo(models.User, { onDelete : 'cascade' });
await sequelize.sync({force:true});
}
}
Post.init( {
title: DataTypes.STRING,
body: DataTypes.STRING,
img_url:DataTypes.STRING
},{
sequelize,
modelName:'Post'
});
return Post;
}
// sync하지 않고 직접 foreign key로 지정하는 방법
// post migration 파일
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('Posts', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
title: {
allowNull:false,
type: Sequelize.STRING
},
body:{
allowNull:false,
type: Sequelize.STRING,
},
userId:{ // foreign key를 직접 지정한 후 db:migrate하여 DB에 반영
allowNull:false,
type: Sequelize.INTEGER,
references:{
model:'User',
key:'id'
},
onDelete: 'cascade'
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('users');
}
};
jwt를 한 번 써봤지만 개념을 이해하지 못하고 그저 하라는 대로 따라쳤었다. 쿠키, 세션도 강의도 듣고 개념도 찾아봤는데 막상 코드로 작성할 때는 코드는 간단해도 어떤 과정을 거치는 지 이해하느라 애를 먹었다.
check point시간에 세션 개념이 어려워 질문을 했다. 모듈을 설치하지 않고 아주 간단하게 session 배열을 만들어서 session id를 보관하는 스프린트 때문에 헷갈렸다. 개념적으로는 세션 객체를 서버의 세션 배열(세션스토어)에 보관하지만 쿠키안에 들어있는 것도 세션 배열(세션스토어)안에 들어있는 session id니 쿠키도 세션 객체를 보관하는 것 아니냐는 요지였다. express-session 모듈
을 배운 뒤에 의문이 풀렸다. 세션스토어는 단순히 session id만 보관하는 것이 아니라 유저정보가 담긴 session 객체도 같이 들고 있다. session id는 session객체에 접근할 수 있는 일종의 열쇠다. 쿠키는 그 열쇠만 들고 있을 뿐이다.
express-session
모듈을 사용하면서 생긴 의문.
question : 아직 login도 안 한 상태, 섹션 객체를 생성하지 않은 상태인데 쿠키가 왜 생길까? 쿠키 안에 session-id가 들어있을까? 매번 삭제해도 새로고침하면 생성된다
answer : login 유무에 상관없이 브라우저가 서버에 연결되면 해당 브라우저에 더미session id를 부여한다.
'http 통신은 stateless'하다는 의미는 각각의 http통신이 독립적으로 이뤄지기 때문에 이전 요청과 응답의 기록을 알 수 없다는 뜻이다. 즉, 이전에 클라이언트가 어떤 요청을 보냈는 지 서버에서는 알 수 없고 매번 다른 path로 진입할 때마다 로그인해야 하는 불편함(사용자 경험 저하) 등등 이 생긴다. 클라이언트의 상태를 추적할 필요성에 따라 '해당 유저는 로그인 한 유저임을 인증'하는 수단인 session, jwt가 나왔다.
- 브라우저에서 관리하고 http request header에 담기는 속성 중 하나다.
- 쿠키에 인증정보를 그대로 노출시키는 방식은 보안에 취약하기 때문에 주로 세션과 같이 사용된다.
- 서버에서 sessionStore에 key가 session id, value는 session 객체 형태로 유저정보를 보관한다.
// sessionStore를 아주 간략하게 표현한 것
const sessionStore = {
sid1 : {
userId: 1,
username:'josh'
},
sid2 : {
userId: 5,
username:'mina'
}
};
- session id는 유저, 브라우저,클라이언트를 식별하는 고유값이다.
- 서버는 session id를 암호화하여 set-cookie
헤더와 함께 클라이언트에 전달한다.
const crypto = require('crypto');
const token = crypto
.createHash('sha256','secret')
.update(password)
.digest('hex');
response.setHeader('Set-Cookie', [`session_id=${token}`]);
암호화 : 원본값을 알아볼 수 없게 특정 알고리즘을 통해 다른 값으로 변환하는 과정, 주로 원본값에 추가적인 값(salt)를 붙인다. salt는 서버에서 보관하기 때문에 원본값과 알고리즘을 알아도 유저데이터를 가져올 수 없게 한다.
- 클라이언트는 서버에게 받은 암호화된 session id를 cookie
헤더와 함께 매 요청마다 보낸다.
- 서버는 클라이언트에서 보낸 session id를 확인하여 session id가 sessionStore에 있는지 확인하고 존재하면 session id에 대응하는 session 객체를 가져와 필요한 정보를 클라이언트에 보낸다.
- 클라이언트를 구별하고 상태를 알 수 있다는 점에서 '연결되어 있다'라고 부른다.
- 클라이언트와 서버와 주고 받는 http패킷에 암호화된 문자열인 token을 주고 받는데, session과 달리 token안에 유저정보를 실어나른다. 서버에는 클라이언트에 대한 어떤 정보도 보관하지 않고 그저 보내준 token을 해석하여 로그인 여부를 판단하고 요청처리를 할 뿐이다.
JWT 구성요소 ( header.payload.signiture )
- 토큰에 대한 기본정보(header) : 토큰타입, 해싱알고리즘 등
- 전달할 정보(payload) : userId, username과 같은 유저정보들
- 토큰 검증 증명(signiture)
- 서버는 response body에 token을 전달하고 클라이언트는 token을 localStorage에 저장한다. 이후 매 요청마다 authorization 헤더에 token을 담아 보낸다.
- stateless 특성 유지하면서 클라이언트 상태를 파악할 수 있다.
app.post('/auth/login', async (req, res) => {
const {
body: { email, password },
} = req;
// DB에서 email과 password 존재 여부 파악하는 과정 생략
try {
// email을 바탕으로 토큰 생성, 두 번째 인자는 salt 값
const token = jwt.sign({ email }, 'secret');
// body에 token을 담아 보낸다.
res.status(200).json({ message: 'login success', token });
} catch (err) {
console.log(err);
res.sendStatus(500);
}
});
app.get('/auth/check', async (req, res) => {
// authorization 헤더에서 token을 추출한다.
const token = req.headers['authorization'].split('Bearer ')[1];
if (!token) {
res.status(403).send('not logged in');
return;
}
try {
// token과 salt값으로 암호화된 token을 해석
const decoded = jwt.verify(token, 'secret');
res.status(200).json(decoded);
} catch (err) {
console.log(err);
res.sendStatus(500);
}
});
- 미들웨어 express-session
은 session id를 자동으로 암호화하여 생성하고 session객체 관리를 도와준다. 사용법은 아래와 같다.
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: 'keyboard cat', // salt 값 지정
resave: false, // touch 메서드(비활성화된 session제거 과정에서 제외시킬 때 사용)를 사용하지 않거나
//session에 만료기한을 지정했을 경우 true로 사용
saveUninitialzed : false, // true를 지정할 시 더미 sid를 부여하고
// false로 지정하면 login한 경우와 같이 선택적으로 sid 부여
});
app.use(express.json());
// session 모듈을 거치면 req에 session객체를 담아서 보내준다.
app.post('/login', function(req,res,next){
// DB에서 email, password 검증 과정 생략
req.session.username = req.body.username; // 세션 객체에 data를 보관
req.session.save(function(err){
// session 객체가 확실하게 저장되었음을 보장하기 위해
// 여기서 response 처리
if(err){
next(err);
return;
}
res.sendStatus(200);
};
app.get('/logout', function(req,res,next){
// 세션객체 삭제
res.session.destory(function(err){
if(err){
next(err);
return;
}
res.redirect('/');
});
});
// server error handling
app.use(function(err,req,res){
console.log(err);
res.sendStatus(500);
});
- 다른 소셜사이트(구글,깃헙,네이버,카카오 등)에 인증처리와 유저정보 보관 및 관리를 맡기고 서버는 접근권한만 신경쓸 수 있도록 해주는 매커니즘
- 깃헙을 예시로 들면, 먼저 OAuth 서비스를 이용하기 위해 애플리케이션을 등록한다. 애플리케이션은 OAuth를 이용하는 client로서 clientId와 secret 값을 부여받는다.
- 서버는 이제 유저가 인증절차(깃헙에 가입되어있는가)를 거치고 애플리케이션에 깃헙에 등록한 유저정보에 대한 접근 권한을 부여에 승인하면 리다이랙트할 path /callback
에 대한 라우터를 지정해야 한다.
app.get('/callback', async function(req,res){
// OAuth 서비스를 제공하는 웹사이트에서 인증코드를 전달해준다.
const { query : { code } } = req;
// 인증코드와 clientId, secret을 url에 전달하여
// 엑세스토큰을 받는다.
await {
data : {
access_token
}
} = axios({
method:'post',
url:`https:github.com/login/oauth/access_token?clientId=${clientId}&client_secret=${clientSecret}&code=${code}`,
headers:{
accept:'application/json',
}
});
// access_token을 session에 저장하거나 jwt token에 담는 과정이 이뤄짐
res.status(200).json({ token: access_token });
});
app.get('/info', async function(req,res){
const token = req.headers['authorization'].split('Bearer ')[1];
try {
const res = await axios('//api.github.com/user',{
headers:{
Authorization: 'token' + token,
},
});
const { email } = await res.json();
const record = await userModel.findOne({ where: { email }});
res.status(200).json(record);
}catch(err){
console.log(err);
res.sendStatus(500);
}
});
크 sequelize 마이그레이션 파일에서 foreignkey 지정하는 방법이 많이 헷갈렸었는데 역시... bb
감사합니다 :)