#nodejs

Building node API server based on DDD architecture

Domain Layer

Domain 레이어는 각 서브 도메인 마다 model, service, repository를 가지며 다음과 같은 역할을 수행한다.

- Model

​ 해당 도메인의 모델링이 정의되고, 해당 도메인 내에서의 항상성과 유효성을 담당한다.

- Service

​ 주로 여러 도메인을 아우르는 로직을 처리한다.

- Repository

​ 도메인 단위로 DB 입출력을 담당하며, 실제 구현은 infra layer의 repository implementation에서 이루어 지며, interface만 정의되어 있다.

API Layer

도메인의 서비스들을 조합하며 엔드 유저 레벨에서의 기능수행이 이루어 진다.

가령 특정 재화를 구매함에 따라 결제를 수행하는 어플리케이션이 있다면 본 api 레이어에는 ‘결제’와 같은 엔드 유저 레벨에서의 함수가 들어가며, 결제 데이터 생성, 구매자의 감소 등과 같은 로직은 도메인의 모델 및 서비스 레벨에서 처리된다.

Infra layer

도메인의 비즈니스 로직들이 잘 수행될 수 있도록 DB, http 라우터 등 외부 환경을 다루는 실제 구현이 이루어 진다.

본 레이어에서 도메인의 repository interface에 정의된 함수들을 실제로 구현하며, express router를 활용하여 외부 요청에 대한 라우팅을 수행한다.

또한 본 레이어에는 middleware, auth guard, errorHandler 처럼 도메인에 접근하기 전에 전처리되거나 비즈니스 로직 이후 클라이언트에게 반환할 반환값에 대한 후처리 로직이 들어간다.

auth guard 는 decorator 를 사용하여 제작되었다.

Branching Model

모든 작업 내용은 feature/[브랜치 명] 에서 작업하고, dev 서버에 배포하기 위한 버전을 develop 브랜치에 푸시한다.

Release 브랜치에서 develop 브랜치를 pull 하면서 코드리뷰를 진행하고 모든 코드리뷰 사항이 반영되면 release 브랜치에 머지된다.

release 브랜치에서 스프린트 및 일정관리, 테스트 작업을 진행하며 안정화되면 master 브랜치로 push 하고 서비스 배포한다.

- Master: 실제 서비스 배포를 위한 브랜치

- Release: 개발 진행의 경과가 되는 브랜치로 QA 및 일정관리의 지표가 된다.

- Develop: 각 개발자들이 실시간으로 머지하는 브랜치로 테스트 서버의 소스코드가 보관된다.

- Feature: 각 개발자들이 임의로 자신들의 작업량을 작업한다.

Input validation

본 프로젝트에서는 클라이언트의 요청값을 검증함에 있어 모든 요청의 형태를 DTO 형태로 정의하고 해당 DTO 에 값을 넣기 전에 Joi 라이브러리를 사용하여 요청값에 대한 검증을 수행한다.

tsconfig setting

각 파일에서 다른 파일을 import 함에 있어서 보다 가독성을 높이기 위해 @domain과 같이 태그를 하여 손쉽게 import를 할 수 있습니다.

tsconfig.json 파일의 예

1
2
3
4
5
6
7
8
9
10
11
12
13
{

"compilerOptions": {

"paths": {

"@domain/": ["server/domain/"]

}

}

}

위처럼 옵션을 설정하면 복잡한 filepath 대신 위의 태그네임을 통해 import 할 수 있습니다.

이때 위의 태그네임이 잘 반영이 되지 않는 경우가 있는데, 이는 웹팩에서 트랜스파일링을 하는 과정에서 webpack.config.js 세팅을 바꾸어 줌으로써 해결이 가능합니다.

태그 네이밍을 하기 위해서 tsconfig-paths-webpack-plugin 을 사용하는데 해당 플러그인은 다음과 같이

루트 plugin이 아닌 resolve 내의 plugin 내에 설정되어야 합니다.

webpack.config.js의 예

\

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

{

​ resolve: {

​ plugins: [

new TsconfigPathsPlugin({

​ configFile: './tsconfig.json' // setup tsconfig path

​ })

​ ];

​ }

}

\

Author

frontalnh(Namhoon Lee)

nodejs 에서 sequelize 사용하기

sequelize 에서 객체를 기반으로 데이터 입출력을 하기 위해 model을 정의한다.

설치하기

1
npm install -- save sequelize

Connection 연결하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const Sequelize = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'mysql'|'sqlite'|'postgres'|'mssql',
operatorsAliases: false,

pool: {
max: 5,
min: 0,
acquire: 30000,
idle: 10000
},

// SQLite only
// storage: 'path/to/database.sqlite'
}

모델 정의하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const User = sequelize.define('user', {
firstName: {
type: Sequelize.STRING
},
lastName: {
type: Sequelize.STRING
}
});

// force: true will drop the table if it already exists
User.sync({force: true}).then(() => {
// Table created
return User.create({
firstName: 'John',
lastName: 'Hancock'
});
});

query

1
2
3
User.findAll().then(users => {
console.log(users)
})

Express란?

Express는 자체적인 최소한의 기능을 갖춘 라우팅 및 미들웨어 웹 프레임워크이며, Express 애플리케이션은 기본적으로 일련의 미들웨어 함수 호출이다.

Installation

npm install express --save

개요

클라이언트에서 웹페이지의 버튼 클릭 등으로 이미 구축해 놓은 server의 url에 페이스북 토큰과 함께 로그인 요청을 보내면, 서버는 passport 모듈을 통해 받은 토큰을 facebook에 보내어 인증을 거치고 페이스북에서 거친 인증값을 담아 서버의 callback 라우터에 요청을 보낸다.
서버는 요청을 받고 accessToken을 저장하며 accessToken과 유저정보들을 jwt 암호화를 거쳐 jwt를 발급하여 클라이언트에게 다시 보내어 준다.
클라이언트는 받은 jwt를 localStorage에 저장하고 앞으로 자동 로그인시에 해당 jwt 토큰을 사용한다.

passport 설치

1
2
npm install --save passport
npm install --save passport-facebook

모듈 import 하기

1
2
3
var NaverStrategy = require('passport-naver').Strategy;
var FacebookStrategy = require('passport-facebook').Strategy;
var KakaoStrategy = require('passport-kakao').Strategy;

필요한 모듈을 import 해준다.

개발자 정보를 비롯한 기밀 정보들 별도 파일에 저장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module.exports = {
'secret' : '',
'db_info': {
local: { // localhost
...
},
real: { // real
...
},
dev: { // dev
...
}
},
'federation' : {
'naver' : {
'client_id' : '',
'secret_id' : '',
'callback_url' : '/auth/login/naver/callback'
},
'facebook' : {
'client_id' : '',
'secret_id' : '',
'callback_url' : '/auth/login/facebook/callback'
},
'kakao' : {
'client_id' : '',
'callback_url' : '/auth/login/kakao/callback'
}
}
};

federation 객체에 네이버, 카카오, 페이스북 Developers 정보를 입력한다.
secret.js 민감한 정보를 갖고있는 파일들은 프로젝트 디렉토리에 포함시키지 않는 것이 바람직하다.

서버코드 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
var secret_config = require('../commons/secret');
var secret_config = require('../_commons/secret');
var FacebookStrategy = require('passport-facebook').Strategy;
passport.serializeUser(function(user, done) {
done(null, user);
});
passport.deserializeUser(function(user, done) {
done(null, user);
});
passport.use(new FacebookStrategy({
clientID: secret_config.federation.facebook.client_id,
clientSecret: secret_config.federation.facebook.secret_id,
callbackURL: secret_config.federation.facebook.callback_url,
profileFields: ['id', 'emails', 'name'] //This
},
function (accessToken, refreshToken, profile, done){
console.log("profile", profile);
console.log("accessToken:", accessToken);
var _profile = profile._json;
loginByThirdparty({
'auth_type': 'facebook',
'auth_id': _profile.id,
'auth_name': _profile.first_name+" "+_profile.last_name,
'auth_email': _profile.email,
'auth_token': accessToken
}, done);
}
));



router.get('/facebook',
passport.authenticate('facebook',{authType: 'rerequest', scope: ['public_profile', 'email']})
)

// facebook 로그인 연동 콜백
router.get('/facebook/callback', passport.authenticate('facebook',{failureRedirect: '/login/null/null'}),function(req,res){
console.log("REQ",req);
console.log("RES",res);
res.redirect('http://localhost/login/'+req.user.email+"/"+req.user.jwt)
}
);

passport.use를 통해 OAuth 요청을 만들어서 네이버 로그인 처리를 진행하고 요청결과를 callback url로 라우팅 시켜줌을 설정한다.
먼저 auth/login/facebook으로 요청이 오면 페이스북으로 로그인 요청을 보내고 결과값을 callback url로 보내준다.
callback url을 처리하는 라우터는 로그인이 성공할 경우 인덱스 페이지로 리다이렉트 시켜주고 실패할 경우에는 다시 로그인 페이지로 리다이렉트 시켜준다.
앵귤러의 경우 특정 페이지에 리다이렉트 되면 컴포넌트가 생성되는 시점에서 query의 뒷부분에 붙은 email 과 jwt를 가지고 jwt는 저장하고 email로 유저의 정보를 받아와 클라이언트에서 사용한다.

/auth/facebook/callback은 페이스북이 검증을 마치고 난 결과를 전송해주는 주소이다.

서버의 로그인 함수 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function loginByThirdparty(info, done) {
console.log('process : ' + info.auth_type);
var stmt_duplicated = 'select *from `user` where `user_id` = ?';
connection.query(stmt_duplicated, info.auth_id, function (err, result) {
if (err) {
return done(err);
} else {
if (result.length === 0) {
// 신규 유저는 회원 가입 이후 로그인 처리
var stmt_thridparty_signup = 'insert into `user` set `user_id`= ?, `nickname`= ?';
connection.query(stmt_thridparty_signup, [info.auth_id, info.auth_name], function (err, result) {
if(err){
return done(err);
}else{
done(null, {
'user_id': info.auth_id,
'nickname': info.auth_name
});
}
});
} else {
//기존유저 로그인 처리
console.log('Old User');
done(null, {
'user_id': result[0].user_id,
'nickname': result[0].nickname
});
}
}
});
}

auth.id 기반으로 신규 회원인지 기존 회원인지 판단한다.
신규 회원일 경우에는 user 테이블에 회원 정보를 저장 시키고 로그인 처리를 진행하고, 기존 유저일 경우에는 쿼리로 조회한 회원정보를 기반으로 로그인 처리를 진행합니다.
간단하게 말씀드리면 신규회원이든 기존 회원이든 원클릭으로 회원 가입 절차를 진행하게 할 수 있습니다.

개요

In this post, you will learn how to deploy a node.js server in a typescript environment.

기본적인 서버 및 데이터베이스의 구축에서 부터 실제 배포 및 개발환경 세팅에 이르는 실제 운영을 위한 여러 고려사항들을 검토하고 논의하며 ubuntu 운영체제를 기본 환경으로 사용한다.

Installation

At first, install node.js web server

먼저 홈페이지에 들어가서 노드js를 설치한다.
nodejs 홈페이지

nodejs를 설치했다면 apt-get의 로컬 패키지를 업데이트 해준다.

1
sudo apt-get update

리눅스계열 os의 경우 다음 code로 설치한다.
npm의 경우 원래는 nodejs 안에 포함되어 같이 깔리지만 커맨드가 안잡힌다면 아래 별도 명령어를 통해 다시 설치해 준다.

1
2
sudo apt-get install -y nodejs
sudo apt-get npm

cmd에서 node –version으로 설치가 잘 되었는지 확인하고 npm을 통해 다양한 모듈을 세팅한다.
만약 aws 서버의 경우 보안 문제 때문에 제대로 설치가 되지 않을 수 있다.
그럴때는 –unsafe-perm 을 붙여 설치를 진행해 본다.
대부분의 문제가 해결될 것이다.

npm의 버전이 맞지 않아 패키지 설치에 문제가 있는 경우 npm을 최신버전으로 업데이트 해야 한다.
하지만, npm을 업데이트 하기 전에 그에 상응하는 최신 nodejs를 설치해 주기 위해 nodejs 버전관리 모듈인 n을 설치해 주고 새로운 nodejs를 설치해 준다.

1
2
sudo npm install n -g
sudo n stable

설치가 완료된 뒤에 다음 명령어를 입력하여 제대로 버전이 바뀌었는지를 확인한다.

1
node -v

여기서 버전이 제대로 업데이트 되지 않았다면 다음과 같이 수동으로 입력해 준다.

1
sudo ln -sf /usr/local/n/versions/node/6.0.0/bin/node /usr/bin/node

npm을 최신으로 업데이트 해 준다.

1
sudo npm install npm@latest -g

shell을 재실행 시킨 뒤 npm의 버전이 변경되었는지 확인한다.

1
npm -v

Compile Typescript

typescript 컴파일러인 tsc 를 통해 다음과 같이 컴파일 할 수 있다.

1
tsc

typescript compile 할 때에 필요한 다양한 옵션을 tsconfig.json 파일에 명실 할 수 있다. 필자의 경우는 file import 시에 다음과 같이 alias 를 사용하였기 때문에 해당 alias 의 위치를 tsc 가 찾을 수 있도록 tsconfig.json 에 paths를 등록해 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"outDir": "./dist/",
"noImplicitAny": false,
"lib": ["es2015"],
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": "./src",
"paths": {
"@domain/*": ["server/domain/*"],
"@common/*": ["server/common/*"],
"@utils/*": ["server/utils/*"],
"@infra/*": ["server/infra/*"],
"@api/*": ["server/api/*"],
"@interfaces/*": ["server/interfaces/*"]
}
},
"include": ["./src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

이처럼 path의 경우 단지 컴파일러에게 해당 파일이 어디에 있는지에 대한 위치정보만을 가르쳐줄 뿐이며, 이는 컴파일되어도 alias의 값이 그대로 들어가게 된다.

이러한 module-alias 를 실행하기 위해서는 다음과 같이 module-alias 를 설치하고 메인 파일에서 이를 등록해 주어야 한다.

1
npm install --save module-alias

index.ts

1
require('module-alias/register');

이러한 alias의 위치를 실행시점에서 지정해 주어야 하며, 그는 package.json 파일에 다음과 같이 지정해 주어야 node 엔진이 해당 파일을 찾아서 실행이 가능하다.

package.json

1
2
3
4
5
6
7
8
9
10
11
12
{
"_moduleAliases": {
"@domain": "dist/domain",
"@infra": "dist/infra",
"@util": "dist/util",
"@common": "dist/common",
"@interfaces": "dist/interfaces",
"@utils": "dist/utils",
"@api": "dist/api",
"@root": "dist"
}
}

Paths 뿐만 아니라 컴파일 시에 타겟 언어를 지정하고, 모듈관리, 소스맵 등의 다양한 옵션을 설정해 준다.

How to manage environment variables

서버를 운용함에 있어 개발 환경과 실제 운영서버에 따라 달라지는 다양한 환경변수들을 관리하는 것은 보안 및 시스템의 관리 측면에서 매우 중요하다.

본 프로젝트에서는 docker container 기술을 사용하여 운영서버를 배포하며, 개발 서버의 경우 로컬 머신을 사용한다.

여기서 운영서버의 경우 docker image 를 생성하는 시점에서 사용되는 Dockerfile 에 운영시에 사용되는 environment variable 을 저장

서버를 여러 배포환경에서 배포하기 위해 dotenv 라이브러리를 사용하여 .env 파일 내의 환경 변수를 프로세스 환경변수로 사용할 수 있다.

여기서 만약 .env 파일 내의 변수가 기존의 환경 변수와 충돌이 난다면 .env 파일에서 설정한 변수는 무시된다.

기본 개념

JWT란?

Jason Web Token

토큰 데이터의 구조

실제 토큰=>

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0MTIzMTIzNTQzNTM0NTY3ODkwIiwibmFtZSI6IkpvaDM0NTM0NTM0NW4gRG9lIiwiYWRtaW4iOnRydWV9.gUpPTlD6M3F264lbRyXa6lat7t1tqoP3MHOwFX1qies

각 부분은 .를 기준으로 3 파트로 나뉜다.

첫번째 파트 => 서명키 생성 방식에 대한 정보 (header)

1
2
3
4
5
{
"alg":"HS256",
"type":"JWT"
}
=> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

두번째 파트 => 실제 데이터 정보 (payload)

1
2
3
4
5
6
{
"sub": "1234123123543534567890",
"name": "Joh345345345n Doe",
"admin": true
}
=> eyJzdWIiOiIxMjM0MTIzMTIzNTQzNTM0NTY3ODkwIiwibmFtZSI6IkpvaDM0NTM0NTM0NW4gRG9lIiwiYWRtaW4iOnRydWV9

세번째 파트 => 데이터에 대한 무결성 / 변조 방지를 위한 HMAC
HMACSHA256(base64UrlEncode(header) + “.” +base64UrlEncode(payload),ServerkeyData)
=> gUpPTlD6M3F264lbRyXa6lat7t1tqoP3MHOwFX1qies

준비사항

  1. jsonwebtoken 설치
    npm install jsonwebtoken

Work Flow

  1. 회원가입 요청 => 서버에 id와 password 전달
  2. 서버에서 받은 email을 토대로 중복여부 판별
  3. 중복되지 않았다면 db에 jwt토큰을 복호화 하기 위한 secret key를 저장(random generate)
  4. 위의 secret key를 db에 저장
  5. 사용자가 로그인 시도하면 해당 secret key를 검색하여 토큰을 복호화하여 전달한 토큰과 일치하는지 확인하여 토큰을 전달한다.
  6. 받은 토큰을 사용자는 로컬에 저장한다.

Process

모듈 불러오기

const jwt = require('jsonwebtoken')

jasonwebtoken으로 토큰 발급하기

jwt.sign(payload, secret, options, [callback])
만약에 callback 이 전달되면 비동기적으로 작동하며, 콜백함수의 파라미터는 (err, token) 입니다.
전달되지 않을시엔 동기적으로 작동하며, JWT 를 문자열 형태로 리턴합니다.
payload 는 객체, buffer, 혹은 문자열형태로 전달 될 수있습니다.
secret 은 서명을 만들 때 사용되는 알고리즘에서 사용되는 문자열 혹은 buffer 형태의 값 입니다.

1
2
3
4
var token = jwt.sign(payLoad,tokenKey,{
algorithm : 'HS256', //"HS256", "HS384", "HS512", "RS256", "RS384", "RS512" default SHA256
expiresInMinutes : 1440 //expires in 24 hours
});

토큰 복호화 하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var jwt      = require('jsonwebtoken');
var tokenKey = "TEST_KEY11"; //토큰키 서버에서 보관 중요
var token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjE0NTU0LCJpYXQiOjE0MzUxMzA4NzMsImV4cCI6MTQzNTIxNzI3M30.EWNUjnktCWxlqAAZW2bb0KCj5ftVjpDBocgv2OiypqM';

//비동기처리
jwt.verify(token,tokenKey,function(err,decoded){
console.log("sync : ", decoded);
});

//동기처리
try {
var decoded = jwt.verify(token,tokenKey);
console.log("async : ", decoded);
} catch(err){
console.log(err);
}

참조

nodejs에서 jwt를 사용하기 위한 npm package를 설치하고 이용한다.

NPM 참고자료


개요

먼저 multiparty로 req를 받아 파일과 필드값으로 나누어 준 뒤에 노드 썸네일을 통해 변환을 해 준다.

설치

1
npm install node-thumbnail

서버 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
var multiparty = require('multiparty');
var thumb = require('node-thumbnail').thumb;

router.post('/thumbnail',function(req,res){
console.log("썸네일 변경!");
var form = new multiparty.Form();
form.parse(req,function(err,fields, files){
if(err){
console.log(err);
}
Object.keys(fields).forEach(function(name) {
console.log('got field named ' + name);
});
console.log("files:", files);

var fileName=fields.fileName[0];
var oldPath = files.imageFile[0].path;
console.log(fileName);
// 서버에서 돌리는 부분
var newPath = '/root/imageServer/public/images';

thumb({
basename:fileName.split(".")[0],
suffix:'',
source: oldPath,
destination: newPath,
width: 300
}).then(function() {
console.log('Success');
res.sendStatus(200)
}).catch(function(e) {
console.log('Error', e.toString());
res.sendStatus(500);
});

})
})

default options

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
defaults = {
prefix: '',
suffix: '_thumb',
digest: false,
hashingType: 'sha1', // 'sha1', 'md5', 'sha256', 'sha512'
width: 800,
concurrency: <num of cpus>,
quiet: false, // if set to 'true', console.log status messages will be supressed
overwrite: false,
basename: undefined, // basename of the thumbnail. If unset, the name of the source file is used as basename.
ignore: false, // Ignore unsupported files in "dest"
logger: function(message) {
console.log(message);
}
};

server.js 작성

express 에서 socket.io를 실행하기 위해

express.io를 설치한다.

노드 http 서버와 결합시킬 socket.io에 http를 전달하여 initialize한다.

connection 이벤트가 발생하면 특정 동작을 한다.

io.emit을 통해 데이터를 전달한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var io = require('socket.io').listen(server);

io.on('connection', (socket) => {

console.log('user connected');

socket.on('disconnect', function() {
console.log('user disconnected');
});

socket.on('add-message', (message) => {
io.emit('message', { type: 'new-message', text: message });
// Function above that stores the message in the database
databaseStore(message)
});
});

클라이언트에서 socket.io 설치 및 임포트

소켓 io를 라우터에 전달한다.

1
2
3
4
5
6
npm i socket.io-client --save
npm install socket.io --save

const server = http.createServer(app);
var io = require('socket.io').listen(server);
const all = require('./server/routes/all')(mysql, conn, express, io);

angular service

io()는 socket을 반환한다.

socket.emit을 통해 이벤트를 발생시킨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';
import * as io from 'socket.io-client';

@Injectable()
export class ChatService {
private url = 'http://localhost:5000';
private socket;

sendMessage(message){
this.socket.emit('add-message', message);
}

getMessages() {
let observable = new Observable(observer => {
this.socket = io(this.url);
this.socket.on('message', (data) => {
observer.next(data);
});
return () => {
this.socket.disconnect();
};
})
return observable;
}
}

angular component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
messages = [];
connection;
message;

sendMessage(){
this.chatService.sendMessage(this.message);
this.message = '';
}

ngOnInit() {
this.connection = this.chatService.getMessages().subscribe(message => {
this.messages.push(message);
})
}

ngOnDestroy() {
this.connection.unsubscribe();
}

참조
use with router

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×