MASSIVCODE.LOG

Node.js + passport.js 를 이용한 소셜 로그인 구현 (페이스북, 깃허브, 구글)

기존 [마크다운 에디터 리뉴얼 관련 포스트](https://massivcode.com/8) 에서 1차 리뉴얼 때 사용했던 passport.js 관련 포스트. 로그인 프로세스는 다음과 같다. 1. 로그인 페이지 진입 2. 특정 소셜 로그인 엘리먼트 클릭 (페이스북, 깃허브, 구글) 3. 해당하는 라우트로 이동 및 passport 미들웨어 호출 4. 각 로그인 플랫폼에 대응하는 콜백 라우트 정의 및 진입시 미들웨어 호출 5. 로그인 성공시 처리 ## 1. npm 종속성 --- ```npm { "name": "markdown-editor", "version": "0.0.0", "private": true, "scripts": { "start": "pm2-dev start ecosystem.config.js", "deploy": "npm install && pm2 start ecosystem.config.js --env production" }, "dependencies": { "bcryptjs": "^2.4.3", "body-parser": "~1.18.2", "cookie-parser": "~1.4.3", "debug": "~2.6.9", "ejs": "~2.5.7", "express": "~4.15.5", "express-session": "^1.15.6", "jsonwebtoken": "^8.1.0", "morgan": "~1.9.0", "mysql2": "^1.5.1", "passport": "^0.4.0", "passport-facebook": "^2.1.1", "passport-github": "^1.1.0", "passport-google-oauth": "^1.0.0", "request-ip": "^2.0.2", "sequelize": "^4.28.6", "serve-favicon": "~2.4.5" }, "devDependencies": { "babel-cli": "^6.26.0", "babel-preset-env": "^1.6.1", "babel-preset-es2015": "^6.24.1" } } ``` ## 2. app.js - passport.js 관련 설정 --- ```javascript "use strict"; const express = require('express'); const path = require('path'); const favicon = require('serve-favicon'); const logger = require('morgan'); const cookieParser = require('cookie-parser'); const bodyParser = require('body-parser'); const SESSION_SECRET_KEY = require('./config/SecureConfig').SESSION_SECRET_KEY; const session = require('express-session'); const passport = require('passport'); ... const app = express(); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); // uncomment after placing your favicon in /public app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); app.use(logger('dev')); app.use(bodyParser.json({limit: '50mb'})); app.use(bodyParser.urlencoded({extended: false, limit: '50mb'})); app.use(cookieParser()); app.use('/markdown', express.static(path.join(__dirname, 'public'))); app.use(session({ secret: SESSION_SECRET_KEY, resave: false, saveUninitialized: true })); require('./config/passport')(passport); app.use(passport.initialize()); app.use(passport.session()); //로그인 세션 유지 ... module.exports = app; ``` ## 3. passport.js - passport 설정 모듈 --- ```javascript const FacebookStrategy = require('passport-facebook').Strategy; const GithubStrategy = require('passport-github').Strategy; const GoogleStrategy = require('passport-google-oauth').OAuth2Strategy; const userService = require('../service/userService'); module.exports = (passport) => { passport.serializeUser((user, done) => { done(null, user); }); passport.deserializeUser((user, done) => { done(null, user); }); passport.use(new FacebookStrategy({ clientID: "", clientSecret: "", profileFields: ['id', 'displayName', 'photos'], callbackURL: 'http://localhost:3000/markdown/auth/facebook/callback' }, function (accessToken, refreshToken, profile, done) { const socialId = profile.id; const nickname = profile.displayName; const profileImageUrl = profile.photos[0].value; onLoginSuccess('Facebook', socialId, nickname, profileImageUrl, done); } )); passport.use(new GithubStrategy({ clientID: "", clientSecret: "", callbackURL: 'http://localhost:3000/markdown/auth/github/callback' }, function (accessToken, refreshToken, profile, done) { const socialId = profile.id; const nickname = profile.username; const profileImageUrl = profile.photos[0].value; onLoginSuccess('Github', socialId, nickname, profileImageUrl, done); } )); passport.use(new GoogleStrategy({ clientID: "", clientSecret: "", callbackURL: 'http://localhost:3000/markdown/auth/google/callback', scope: ['https://www.googleapis.com/auth/plus.me'] }, function (accessToken, refreshToken, profile, done) { const socialId = profile.id; const nickname = profile.displayName; const profileImageUrl = profile.photos[0].value; onLoginSuccess('Google', socialId, nickname, profileImageUrl, done); } )); function onLoginSuccess(platformName, socialId, nickname, profileImageUrl, done) { userService.findOrCreate(platformName, socialId, nickname, profileImageUrl) .spread((user, created) => { if (user.state === 1) { userService.updateUserToJoinedState(user) .then(result => { done(null, user); }) .catch(err => { done(null, user); }) } else { done(null, user); } }); } }; ``` 클라이언트 ID, 클라이언트 시크릿, 콜백 URL (리다이렉트 URL) 은 로그인 플랫폼에서 발급받은 데이터를 넣어준다. onLoginSuccess 에서 로그인 성공한 사용자 정보를 db 에 넣어준다. 각 콜백 메소드의 역할 및 기초적인 사용법은 [passport.js 문서](http://www.passportjs.org/docs/downloads/html/) 와 [생활코딩](https://opentutorials.org/course/2136/12134) 을 참고하면 된다. ## 4. auth.js - 인증 관련 라우터 --- ```javascript "use strict"; const express = require('express'); const router = express.Router(); const passport = require('passport'); const userService = require('../service/userService'); // 로그인 페이지 진입 router.get('/login', function (req, res) { let redirectUrl = req.query.redirectUrl; if (redirectUrl) { res.cookie("redirectUrl", redirectUrl, { expires: new Date(Date.now() + (60 * 1000 * 2)), httpOnly: true }); } res.render('login', {title: '마크다운 에디터 - 로그인', isLogin: false}); }); // 로그아웃 router.get('/logout', (req, res) => { req.logout(); res.redirect('/markdown'); }); // 페이스북 로그인 시작 router.get('/facebook', passport.authenticate('facebook')); // 페이스북 로그인 결과 콜백 router.get('/facebook/callback', passport.authenticate('facebook', { failureRedirect: '/markdown/auth/login' }), (req, res) => { loginSuccessHandler(req, res); }); // 깃허브 로그인 시작 router.get('/github', passport.authenticate('github')); // 깃허브 로그인 결과 콜백 router.get('/github/callback', passport.authenticate('github', { failureRedirect: '/markdown/auth/login' }), (req, res) => { loginSuccessHandler(req, res); }); // 구글 로그인 시작 router.get('/google', passport.authenticate('google')); // 구글 로그인 결과 콜백 router.get('/google/callback', passport.authenticate('google', { failureRedirect: '/markdown/auth/login' }), (req, res) => { loginSuccessHandler(req, res); }); // 로그인 성공시 처리 function loginSuccessHandler(req, res) { let successRedirectUrl = "/markdown"; if (req.cookies.redirectUrl) { successRedirectUrl = req.cookies.redirectUrl; res.clearCookie("redirectUrl"); } return res.redirect(successRedirectUrl); } module.exports = router; ``` 로그인 성공시 loginSuccessHandler 가 호출되는데, 이렇게 따로 분리해놓은 이유는 로그인 성공 후 로그인 요청시의 URL 로 리다이렉트 해주기 위함이다. 만약 이런 부분이 필요 없고, 나는 무조건 특정 페이지로 이동시키고 싶다면 다음과 같이 코드를 수정한다. ```javascript router.get('/google/callback', passport.authenticate('google', { successRedirect: '/', failureRedirect: '/markdown/auth/login' })); ```

Node.js 2018.01.30 6:15
Node.js + ejs 기반 마크다운 에디터를 Angular 로 컨버팅 하며

# 1. 서론 기존에 운영하던 [마크다운 에디터](http://choisroyalfamily.com/markdown) 는 내가 자주 사용하던 [kobito](https://kobito.qiita.com/win) 라는 마크다운 에디터 (데스크톱 애플리케이션)의 데이터 백업 및 OS 간 비호환 문제 때문에 만들게 되었다. 백업의 경우 드롭박스나 구글 드라이브에 연동하여 코비토의 db 폴더를 심볼릭 링크 생성해서 사용했는데, PC 간 동기화 되며 db 파일이 깨진다거나, OS 간 호환되지 않는 점에서 많은 불편함이 있었다. 위의 작업이 별로 복잡하지도 않고 어렵지도 않지만 문제는 데이터가 깨질 수 있거나 (그래서 첫 1년간의 데이터 중 일부를 망실했다), OS 간 코비토의 데이터가 호환되지 않으며 매번 PC 가 달라질 때마다 이 짓을 하는건 상당히 짜증났다. 물론 코비토에서 백업 기능을 제공하긴 했지만, 모든 게시글이 kobito 웹 페이지에 포스트 형식으로 공유 되는 문제가 있었다 (지금은 어떨지 모름). 그래서 개발을 진행했는데, 내가 개발일을 입문하면서 가장 간단한 첫 목표로 삼은 것이 온라인 마크다운 에디터를 만드는 것이었다. 나는 웹 쪽에 전혀 프로페셔널한 지식도 없고, HTML 은 간단해서 쉽게 쉽게 배울 수 있었지만 CSS 가 너무나도 어려워서 많은 난관에 봉착했다. 크롬에서는 잘 동작하던 스타일이 IE 에서는 안된다던가, 브라우저별 호환이 심한 것도 안드로이드를 주로 개발해오던 나로서는 상당히 신기하고 짜증났던 상황이었다. 전에도 웹 프론트엔드 개발 경험이 있었지만, 그때는 회사에서 기존에 사용하던 템플릿을 이용하여 제작했고, 무엇보다 서비스의 관리자 페이지라 일반 사용자용 페이지의 UI 로는 전혀 알맞지도 편리하지도 않았다. 이때까지만 해도 부트스트랩이 뭔지 제이쿼리가 뭔지도 몰라서 처음에는 부트스트랩으로 진행하다가 머테리얼에 한참 빠져있던 상황이였는지라 [머테리얼라이즈 CSS](http://materializecss.com/) 를 이용하여 레이아웃을 갈아엎었다. 이메일도 개인정보에 해당되었던지라 완전 구시대적인 닉네임, 비밀번호 의 데이터만을 사용자로부터 받아서 계정관리를 진행했고 (이메일을 받으면 임시 비밀번호 발급이라던가 여러가지 장점이 있었지만, 개인정보를 받으면서 시작되는 수많은 고통과 그것을 대응하는 프로세스가 너무나도 번거로웠다) 따라서 비밀번호를 잊어버렸을 경우 관리자인 내게 요청하는 방법밖에 없었다. 또, 비회원과 회원의 차이를 따로 두기도 귀찮아서 무조건 모든 기능을 수행하려면 로그인을 했어야 했다. 그래서 기존 버전인 위의 링크를 클릭하면 인덱스 페이지가 로그인/회원가입 페이지다. 그러다보니까 당연히 컨텐츠 공유(그래서 별도의 공유 기능도 없었다)가 불가능한 수준에 이르렀고, 쿠키에 JWT 토큰을 넣어 보관하다가 사용자가 접속하면 자동 로그인을 하던 것도 미들웨어 코딩을 이상하게 해놔서 매번 이상한 alert 창을 띄우는 것도 꼴보기 싫었다. 대세가 되는 소셜로그인을 적용했다면 모든 문제가 해결되었겠지만, 그 당시만해도 웹에서는 소셜 로그인을 적용해본 경험도 없었고 막연한 두려움만이 존재해 닉네임-비밀번호 기반의 구조를 고집했다. # 2. 1차 리뉴얼 작업 (2018.01.02 - 2018.01.16) 지난 1년간 동업자와 운영하던 스타트업인 [최씨세가](http://choisroyalfamily.com/) (주로 SI를 진행했다) 의 운영을 계속하는게 도저히 불가능하다고 판단하며 폐업하기로 마음먹었고 따라서 내게는 시간이 생겼다. 1년간 SI 외주 프로젝트를 진행했던 기록을 포트폴리오로 만드는 과정에서 기존의 마크다운 에디터를 리뉴얼하고 싶은 욕심이 생겼고, 나는 작업에 착수했다. 가장 우선적으로 생각한 것은 기존의 로그인 시스템을 갈아엎는 것으로 시작되었고, 로컬 기반이 아닌 소셜 로그인 기반으로 전환했다. 대상이 되는 플랫폼은 3가지로 각각 페이스북, 깃허브, 구글이었다. 개발자들이 많이 쓸법한, 그리고 대중적으로 이용을 많이 할법한 플랫폼이 위의 3가지였고, node.js 진영에서 인증 관련으로 유명한 라이브러리 였던 [passport.js](http://www.passportjs.org/) 도 기존부터 많은 관심이 있었기 때문에 문서를 보고 적용해보았다. mysql 관련 코딩도 기존처럼 [mysql](https://www.npmjs.com/package/mysql) 라이브러리를 쓰기 않고 [sequelize.js](http://docs.sequelizejs.com/) 로 전환했다. sequelize 를 적용하고 내가 가장 감동받은 부분은 별개의 cli 가 제공되어 그것을 통하여 설정 파일을 만들고 테이블간의 관계를 정의하며 데이터의 생성 및 수정 시간을 자동으로 처리해주고, DATE 가 UTC 로 저장되어 별도의 작업을 하지 않아도 되었던 것과 mysql 의 트리거 처럼 쿼리의 라이프사이클 콜백에 따라 지정된 작업을 수행하던 것이 놀라웠다. 소셜 로그인을 진행하니까 기존처럼 댓글도 자체 구현으로 진행했으며 css 는 위의 머테리얼라이즈 css 를 이용했다. 거기에 좀더 개방적인 컨텐츠 소모를 위해 비회원과 회원간의 차이를 두고 별도의 공유기능 (트위터 트윗, 페이스북 공유, URL 복사) 등을 추가했다. letsencrypt 을 이용하여 https 를 제공하기 위해 certbot 을 통하여 관련 작업을 진행했고, http 접근을 https 로 리다이렉트 하는 경험도 해봤다. 본래 letsencrypt 가 최대 3개월마다 만료되어 매번 인증서를 갱신해야 했던 부분을 certbot 을 이용하면 자동갱신이 되어서 많은 편리함을 경험했다. 이렇게 1차 리뉴얼 작업이 완료되었고, 일시적으로 나는 행복함을 느꼈으나 문제는 마크다운 이었다. # 3. 2차 리뉴얼 작업 (2018.01.18 - 2018.01.30) 이 블로그 또한 그렇지만 1차 리뉴얼의 결과물도 code mirror 와 markdownit 을 이용하여 마크다운 텍스트를 HTML 로 파싱하여 실시간으로 화면에 렌더링 해줬는데, 문제는 다음의 2가지였다. 1. 파싱하기 위해서 html element 의 value 나 data 따위로 마크다운 텍스트를 지정해야 한다. 2. 마크다운 관련 내용이 변경될 때마다 URL 에 지속적으로 append 된다. 성능은 둘째치고 위의 문제가 상당히 꼴보기 싫어서 결국엔 앵귤러로 컨버팅하는 작업을 시작했다. 앵귤러는 이전에 2버전이 나오고 입수했던 앵귤러 책을 보며 살짝 공부했고, 범용 관리자 페이지를 만들면서 기초적인 부분을 맛봤다. 기존의 서버사이드 템플릿 렌더링과의 결정적인 차이점이 바로 컴포넌트 개념이었다. ejs 템플릿에서도 컴포넌트 까지는 아니더라도 반복되는 구문 (head 태그의 정보라던가 css, js 로딩, 푸터 헤더 html 로딩)은 어느정도 대체할 수 있었지만 Spring 으로 프로젝트를 진행하면서 사용해봤던 [Thymeleaf](http://www.thymeleaf.org/index.html) 의 상속 개념보다는 살짝 적용가능한 범위가 적었고, 무엇보다도 중요한 것 스크립트렛이 상당히 불편했다. 반복문이나 조건문 자체를 이용하는 것은 둘째치고, 특정 상황일 때 특정 HTML 코드를 생성하는 부분을 작성하려면 상당히 지저분했으니까. 또 인텔리센스도 적용되지 않았고, 따라서 밑줄로 경고 메세지가 나오는 부분도 짜증났다. 추가적으로 아예 앵귤러 자체가 말 그대로 웹 애플리케이션을 개발하기 위한 프레임워크 인지라 안드로이드 앱을 개발하는 것 같은 느낌이 들어서 적응에도 많은 도움이 되었다. (마치 안드로이드의 Fragment 를 이용하는 듯한 느낌이 들었다) 타입스크립트 기반이라 OOP 적인 개발하기도 쉬웠고, 자체적인 라우팅이 지원되는 것도 아름다웠으며 AuthGuard 을 이용하여 페이지별 권한 체크하는 것도 간단했다. UI 는 [앵귤러 머테리얼](https://material.angular.io/) 를 이용하여 만들었다. 앵귤러로 옮기면서 가장 고통스러웠던 부분이 소셜 로그인 관련 이었는데, 기존에는 node.js 에서 passport 를 이용하여 모든 프로세스가 규격화되어 있어서 작업하기 편했지만 앵귤러에선 이용할 수 없었다. 그래서 오만가지 방법을 다 시도해보다가 잘 안되어서 포기해보려던 찰나, [예제](http://cuppalabs.github.io/ng2-social-login/documentation/) 를 참조하여 구현에 성공했다. 그 로그인 절차는 다음과 같다. 1. 로그인 페이지에서 특정 소셜 로그인 버튼 클릭 2. 컴포넌트에서 클릭된 로그인 서비스의 프로바이더 값 확인 3. 별도 구현한 AuthService 에 해당 프로바이더 값을 전달하고, 로그인 프로바이더 별로 지정된 키라던가 클라이언트 아이디를 포함하여 해당 서비스의 OAuth 페이지로 이동 (window.location.href 이용) 4. 지정된 내 웹 페이지 중 리다이렉트 페이지로 지정한 곳으로 code 값이 넘어오는데, 이를 node.js 서버로 전달 5. 관련 OAuth API 를 호출하여 사용자 프로필 획득 6. 전달받은 사용자 프로필 이용 위의 과정중 3번에서 url safe encoding 하지 않고 값들을 넘겼다가 실패해서 고통받은 것이 무려 2일. 인코딩 하고 보내니까 성공해서 허탈함에 빠졌다가 기뻐서 울뻔했다. 인증 절차는 어찌저찌 구현이 끝났지만, 그 다음 문제는 바로 마크다운 파싱. 이 부분은 [marked.js](https://github.com/chjj/marked) 와 [highlight.js](https://highlightjs.org/) 를 이용하여 쉽게 쉽게 구현했다. 이때 Post 컴포넌트 의 하위 컴포넌트로 마크다운 에디터 컴포넌트와 마크다운 프리뷰 컴포넌트를 배치하여 실시간 렌더링과 신택스 하이라이팅이 정상적으로 수행되던 것에서 1차적으로 감동, 이후 게시글 뷰어 컴포넌트 제작시 마크다운 프리뷰 컴포넌트를 정상적으로 재활용할 수 있었던 부분에서 2차적으로 감동 대폭발. 추가적으로 호스트 리스너를 이용하여 화면 너비의 변화를 실시간으로 콜백받아 툴바의 메뉴들을 펼쳐놓는 다던지, 메뉴 버튼으로 대체하여 드롭다운 식으로 배치하는 것이 상당히 좋았다. 무엇보다도 앵귤러로 컨버팅하는 작업중 가장 감명깊었던 것이 아래의 내용이다. 1. 백엔드는 오로지 API 서버의 역할만 담당. 불필요하게 세션따위를 이용하지 않아도 되니 서버 리부팅시 웹 페이지의 로그인 정보가 날아가지 않아도 되니 관련된 부담감 감소. 로그인 또한 JWT 기반으로 진행되니 모바일 애플리케이션이나 앵귤러에서 동일한 API 를 이용하여 관련 작업 수행가능. 템플릿 렌더링 로직을 작성할 필요가 없어서 서버의 라우터 관련 코드가 많이 간소화 됨. 2. 컴포넌트의 재활용으로 마치 안드로이드 애플리케이션을 개발하는 것 처럼 생산성의 엄청난 향상. 특정 엘리먼트의 참조라던가 모델 변경시 관련된 뷰의 자동적인 갱신, 각종 이벤트 리스너의 쉽고 간편한 구현. 3. 불필요한 페이지 전체 렌더링이 필요하지 않아 실질적인 페이지 로딩 속도 향상. 4. 쉽고 간단한 실시간 폼 검증. 5. 다이얼로그의 쉬운 구현 (라이브러리의 도움을 받더라도) 및 스낵바의 이용 6. enviroment 와 enviroment.prod 를 이용한 개발/배포 간 별도의 상수 데이터 제공 물론 고통스러웠던 것은 수많은 jquery 기반 템플릿을 이용할 수 없었던 것이라던가, 실제로 작업해야 하는 분량이 기존 템플릿 엔진을 이용한 분량보다 많아졌다는 부분이지만 위의 장점이 너무나도 강력해서 그렇게까지 힘들지도 않았다. # 4. 결과 및 반성 그 결과로 [앵귤러 기반의 마크다운 에디터](https://massivcode.com/markdown/) 가 탄생했다. 반성 및 개선해야 할 부분은 다음과 같다. 1. 앵귤러에 대한 깊이가 얕아서 보다 효율적이고 고도화된 작업을 할 수 없었다. 2. 정밀한 테스팅의 부재. 3. 리퀘스트 인터셉터의 미구현으로 API 호출시 반복코드의 생성. 4. 댓글 출력이 조금 부자연스러움. 5. 스크롤 업 관련 기능의 구현 실패 (앱 컴포넌트 하위에 자식 컴포넌트 들이 위치하고 있는데, 호스트 리스너나 다른 방법을 이용해서 현재 스크롤된 페이지의 y 값을 얻어올 수 없어서 구현할 수 없었다.)

Angular 2018.01.30 5:53
JitPack.io 와 Github 를 이용한 안드로이드 오픈소스 라이브러리 배포하기

안드로이드 앱 개발을 하다보면 특정 작업을 수행하기 위한 코드가 반복되는 경향이 있다. 너무나도 간단하거나 일반화하기 힘든 경우엔 어쩔 수 없이 매번 코딩을 하지만, 그 반대의 경우에는 해당 코드들을 갈무리 해뒀다가 재사용한다. 하지만 재사용도 한 두번이지 그 횟수가 늘어나면 귀찮아진다. 가장 중요한 것은 귀찮다는 것. 또한 해당 코드에 유지보수가 필요하다면 그러한 귀찮음은 배가된다. 처음에는 문제가 없었는데 다른 프로젝트를 진행하다보면 이 부분은 이렇게 바꾸면 더 좋을 것 같고, 이건 어떻게 바꾸고. 매번 클래스를 복붙하는 것에 스트레스를 받기 시작한다. 안드로이드에서 가장 많이 사용되는 통신 및 데이터 파싱 라이브러리인 레트로핏을 이런 식으로 사용해야 한다면 그 고통은 더욱 더 커질 것이다. 클래스가 한 두가지도 아니고... 다행스럽게도 이러한 고통에서 벗어나기 위한 방법이 있다. [JitPack.io](https://jitpack.io/) 와 GitHub 를 이용하여 내가 자주 사용하는 코드들을 라이브러리화 하는 것이다. 이 포스트의 방법을 이용하면 아래와 같은 방식으로 내 프로젝트에서 모듈을 사용할 수 있다. ```gradle compile or implementation 'com.github.깃허브사용자이름:레포지터리이름:버전' ``` ## 1. GitHub Public Repository 생성하기 가장 중요한 것은 이름이다. 저장소를 생성했다면 1단계는 끝이다. ## 2. Android Project 생성하기 보통 GitHub 에서 볼 수 있는 오픈소스 라이브러리의 구조는 다음과 같다. * ... * app or sample * library * ... ![repository tree](/images/gallery/1512968473907_repository_tree.png) app 또는 sample 폴더에는 이 라이브러리를 사용하기 위한 방법이 담겨있는 샘플 프로젝트 코드가 담겨있고, library 폴더에는 이 라이브러리를 구성하는 코드가 담겨있다. 우선은 안드로이드 프로젝트를 생성하는데, 앱 개발을 위해 프로젝트를 생성하는 것처럼 동일한 방법으로 진행한다. 이 부분에서 생성되는 부분이 바로 이 라이브러리의 샘플 코드 역할을 수행하는 모듈이다. ![make project](/images/gallery/1512968497276_make_project_1.png) 개인적으로 여기서의 패키지 네임은 다음과 같이 지정한다. com.xxx.example.라이브러리 이름 ## 3. 생성한 Project 에 모듈 추가하기 ![make module 1](/images/gallery/1512968576799_new_module_1.png) ![make module 2](/images/gallery/1512968587219_new_module_2.png) ![make module 3](/images/gallery/1512968597202_new_module_3.png) 개인적으로 패키지 네임은 다음과 같이 지정한다. com.xxx.라이브러리 이름 ## 4. Project Level gradle 설정하기 JitPack.io 와 GitHub 를 통하여 라이브러리를 배포하려면 다음과 같은 코드를 추가해줘야 한다. ```gradle buildscript { ... dependencies { ... classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5' } } allprojects { repositories { ... maven { url "https://jitpack.io" } } } ``` ## 5. App Level Gradle 설정하기 (라이브러리 모듈) ```gradle ... dependencies { ... } apply plugin: 'com.github.dcendents.android-maven' group='com.github.깃허브 사용자 이름' ``` dependencies 내부에는 내가 배포할 라이브러리에서 사용할 종속성을 지정한다. 예를들어 RecyclerView 관련 라이브러리를 배포할 것이라면 dependencies 내부에 해당 내용을 적어줘야 한다. (라이브러리에서 관련 클래스를 사용할 것이므로) 또한 이렇게 배포할 라이브러리 모듈에 종속성을 쭉쭉 추가해주면, 나중에 이 라이브러리를 다른 프로젝트에서 사용할 때 app 모듈에서도 그대로 이용할 수 있다. 즉, 예를들어 Glide 라이브러리를 배포할 라이브러리의 종속성으로 추가하면 굳이 Glide 를 App Level 종속성에 추가하지 않아도 Glide 를 이용할 수 있다는 것이다. 만약 App Level 에 Glide 를 추가했는데, App Level 에서도 Glide 를 추가하여 빌드하면 문제의 소지가 있을 수 있다. 지금까지 경험상 버전이 동일하다면 문제가 발생하지 않았지만, 그렇지 않다면 Gradle 관련 에러메세지가 출력되었다. ## 6. App Level Gradle 설정하기 (샘플 프로젝트 모듈) ```gradle ... dependencies { ... compile project(':library') ... } ``` 샘플 프로젝트 모듈의 Gradle 설정에 위의 코드를 넣어준다. 싱크하게 되면 library 모듈에 정의된 코드를 이용할 수 있다. 어차피 동일한 프로젝트에 앱 모듈과 라이브러리 모듈이 포함되어 있으므로, 샘플 앱을 작성할 때 굳이 외부에서 종속성을 땡겨올 필요는 없다. ## 7. 코드 작성 및 push 라이브러리 코드를 작성하고 샘플 프로젝트에서 해당 코드를 테스트 한 뒤 이상이 없으면 1번에서 생성한 저장소에 코드를 push 해야 한다. push 할 때 스테이지에 add 할 파일들은 이것저것 따지지도 말고 다음과 같이 지정한다. ![git add](/images/gallery/1512968649360_git_add.png) Android Studio 오래된 버전에서는 별도의 플러그인을 통하여 .gitignore 파일을 작성하거나 추가해야 했는데 최근부터는 기본적으로 쓸모없는 파일이 걸러지기 때문에 위의 이미지처럼 모든 파일을 add 하면 된다. 보통 왼쪽 탐색기에서 빨간색으로 출력되는 파일들이 모두 추가될 것이다. ## 8. GitHub 에서 Release Tag 지정하기 ![release tag 1](/images/gallery/1512968677259_github_release_tag_1.png) ![release tag 2](/images/gallery/1512968692071_github_release_tag_2.png) ![release tag 3](/images/gallery/1512968703433_github_release_tag_3.png) JitPack.io 를 통하여 라이브러리를 배포하려면 JitPack 에서 라이브러리를 원격 빌드해야 하는데, 여기서 버전을 인식할 수 있는 부분이 GitHub 저장소의 Release Tag 이다. 처음은 물론이고 추후 라이브러리의 코드가 변경되어 업데이트가 필요할 때도 Release Tag 를 갱신해야 한다. ## 9. [JitPack.io](https://jitpack.io/) 에서 GitHub 레포지터리 검색 및 빌드 ![jitpack search result](/images/gallery/1512968724566_jitpack_search_result.png) 위의 순서대로 모든 작업을 진행했다면 JitPack 에서 깃허브 사용자 이름/배포할 라이브러리의 저장소 이름으로 검색했을 경우 위와 같은 화면을 볼 수 있다. Status 탭의 Get it 버튼을 누르면 원격 빌드가 진행되는데 빌드 종료까지 다소 시간이 걸릴 수 있다. 빌드가 종료되었고, 에러가 있다면 Log 탭의 아이콘이 빨간색으로 나오고 에러가 없다면 초록색으로 나온다. 이때 해당 페이지의 하단을 보면 다음과 같은 화면을 확인 할 수 있다. ![jitpack how to](/images/gallery/1512968740154_jitpack_how_to.png) JitPack 을 이용하여 배포한 라이브러리를 사용하려면 Project Level 에 jitpack 저장소를 추가하고 App Level 에서 종속성 부분에 기존 라이브러리를 이용하던 것 처럼 추가하여 사용할 수 있다. ## 10. 기타 9번까지의 작업이 정상적으로 완료되었다면 그때부터 라이브러리를 이용할 수 있다. 추가적으로 해당 라이브러리 저장소의 ReadMe.md 를 작성하여 라이브러리 추가 방법과 JitPack 배지를 넣어줄 수 있다.

Android 2017.12.11 5:16
Realm 과 Android Architecture Components (AAC) 를 이용한 간단한 메모장 만들기

AAC 에 대해 평소 호기심이 있어 해당 라이브러리를 이용하여 토이 프로젝트를 하나 만들어 봤다. 2016년 부터 이슈가 되기 시작한 MVP, MVVM 패턴이 있는데 구글에서는 MVVM 을 쉽게 구현할 수 있는 AAC 를 제공해줘서 이와 관련된 모듈들의 간단한 사용 예제를 정리해보았다. * MVP 패턴이나 MVVM 패턴에 대한 고찰은 담겨있지 않다. # 1. 프로젝트 관련 환경 및 종속성 ## A. 프로젝트 환경 * Android Studio 3.0 ## B. project level build.gradle ```gradle buildscript { repositories { google() jcenter() } dependencies { classpath 'com.android.tools.build:gradle:3.0.1' classpath 'io.realm:realm-gradle-plugin:4.1.1' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } allprojects { repositories { google() jcenter() } } task clean(type: Delete) { delete rootProject.buildDir } ``` ## C. app level build.gradle ```gradle ... apply plugin: 'realm-android' ... dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'com.android.support:appcompat-v7:27.0.1' implementation 'com.android.support.constraint:constraint-layout:1.0.2' testImplementation 'junit:junit:4.12' implementation 'com.android.support:recyclerview-v7:27.0.1' implementation 'com.android.support:cardview-v7:27.0.1' implementation 'android.arch.lifecycle:extensions:1.0.0' annotationProcessor 'android.arch.lifecycle:compiler:1.0.0' androidTestImplementation 'com.android.support.test:runner:1.0.1' androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1' } ... ``` # 2. LiveRealmData - Realm 을 위해 LiveData 를 상속받은 클래스 ```java /** * https://github.com/ericmaxwell2003/android-persistence/blob/master/app/src/main/java/com/example/android/persistence/codelab/realmdb/utils/LiveRealmData.java */ public class LiveRealmData<T extends RealmModel> extends LiveData<RealmResults<T>> { private RealmResults<T> results; private final RealmChangeListener<RealmResults<T>> listener = new RealmChangeListener<RealmResults<T>>() { @Override public void onChange(RealmResults<T> results) { setValue(results); } }; public LiveRealmData(RealmResults<T> realmResults) { results = realmResults; setValue(realmResults); } @Override protected void onActive() { System.out.println("LiveRealmData.onActive(): " + results); results.addChangeListener(listener); } @Override protected void onInactive() { System.out.println("LiveRealmData.onInactive()"); results.removeChangeListener(listener); } } ``` 이 프로젝트는 [Room Persistence Library](https://developer.android.com/topic/libraries/architecture/room.html) 를 이용하지 않고 Realm 을 이용하기 때문에 특정 RealmResults 에 데이터 변동시 쓰라고 Realm 에서 제공해주는 RealmChangeListener 를 사용하여 구성된 코드를 발견할 수 있다. 이 클래스가 상속하고 있는 LiveData 는 Activity 나 Fragment 따위의 수명주기를 인식할 수 있는 데이터 홀더 클래스이다. Realm 에서는 질의 결과를 계속해서 관찰하기 위해 리스너를 연결하는데, Lifecycle 에 따라 이 리스너를 제거해줘야 한다. 다행스럽게도 LiveData 에서는 이러한 시점을 onActive 와 onInActive 를 통해 알려준다. onActive 메소드는 LiveData 인스턴스를 옵저빙하는 리스너의 수가 0에서 1이 되었을 때 호출되며, onInActive 메소드는 LifeCycle 이 데이터를 출력하기에 유효하지 않은 상태 (액티비티가 백스택에 들어갔거나 화면에 안보일 때 등등) 에 호출된다. # 3. BaseDao ```java public class BaseDao<T extends RealmModel> { protected Realm mRealm; public BaseDao(Realm mRealm) { this.mRealm = mRealm; } // https://github.com/ericmaxwell2003/android-persistence/blob/master/app/src/main/java/com/example/android/persistence/codelab/realmdb/utils/Realm%2BDao.kt protected LiveRealmData<T> asLiveData(RealmResults<T> data) { return new LiveRealmData<>(data); } } ``` 이 클래스는 모든 DAO 클래스가 상속받는 클래스인데 Realm 의 질의 결과를 LiveRealmData 로 감싸서 뱉어주는 asLiveData 메소드를 포함하고 있다. # 4. LifecycleListener - LifecycleObserver 맛보기 클래스 ```java public class LifecycleListener implements LifecycleObserver { private Context mContext; public LifecycleListener(Context mContext) { this.mContext = mContext; } @OnLifecycleEvent(Event.ON_CREATE) void onCreate() { Toast.makeText(mContext, "LifecycleListener.onCreate()", Toast.LENGTH_SHORT).show(); } @OnLifecycleEvent(Event.ON_RESUME) void onResume() { Toast.makeText(mContext, "LifecycleListener.onResume()", Toast.LENGTH_SHORT).show(); } @OnLifecycleEvent(Event.ON_STOP) void onPause() { Toast.makeText(mContext, "LifecycleListener.onPause()", Toast.LENGTH_SHORT).show(); } @OnLifecycleEvent(Event.ON_START) void onStart() { Toast.makeText(mContext, "LifecycleListener.onStart()", Toast.LENGTH_SHORT).show(); } @OnLifecycleEvent(Event.ON_STOP) void onStop() { Toast.makeText(mContext, "LifecycleListener.onStop()", Toast.LENGTH_SHORT).show(); } @OnLifecycleEvent(Event.ON_DESTROY) void onDestroy() { Toast.makeText(mContext, "LifecycleListener.onDestory()", Toast.LENGTH_SHORT).show(); } } ``` 이 클래스는 LifecycleObserver 를 사용하는 맛보기 클래스인데, 이 클래스가 관찰하고 있는 컴포넌트(LifecycleOwner)의 라이프사이클 변화에 따라 특정 메소드를 자동으로 호출해준다. 구글의 개발자 페이지의 [라이프사이클 핸들링 관련 문서](https://developer.android.com/topic/libraries/architecture/lifecycle.html) 에서는 LocationListener 를 제어하는 모습을 볼 수 있으나, 위의 코드에선 단순히 Toast 메세지만 띄워준다. 이 클래스는 MainActivity 의 Lifecycle 을 관찰하는데, MainActivity 는 AppCompatActivity 를 상속받고 있다. 서포트 라이브러리 26.1.0 부터는 프래그먼트와 액티비티가 이미 LifecycleOwner 인터페이스를 구현하고 있는데, 예시로 든 AppCompatActivity 는 다음과 같은 상속구조를 가진다. ![상속도](/images/gallery/1511418846256_hierarchy.png) 위로 쭉 올라가다보면 SupportActivity 가 있는데 해당 클래스의 선언부는 다음과 같다. ```java public class SupportActivity extends Activity implements LifecycleOwner ``` 따라서 일반적인 경우에는 Activity 나 Fragment 따위가 LifecycleOwner 를 추가적으로 구현할 필요는 없다. 일반적이지 않은, 커스텀이 필요한 경우는 위의 문서를 참고하기 바란다. # 5. MainActivity ```java public class MainActivity extends AppCompatActivity { // 메모 RecyclerAdapter private MemoAdapter mMemoAdapter; // 뷰 모델 private MainViewModel mMainViewModel; // 라이프사이클 변화에 따른 토스트 메시지를 팝업해주는 리스너 private LifecycleListener mLifecycleListener; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 인자로 LifecycleOwner 를 넣어 생성한다. mLifecycleListener = new LifecycleListener(this); // 옵저버로 위의 리스너 인스턴스를 지정한다. getLifecycle().addObserver(mLifecycleListener); mMemoAdapter = new MemoAdapter(mOnMemoItemClickListener, mOnMemoItemUpdateClickListener, mOnMemoItemDeleteClickListener); RecyclerView recyclerView = findViewById(R.id.rv); recyclerView.setLayoutManager(new LinearLayoutManager(getApplicationContext())); recyclerView.setAdapter(mMemoAdapter); findViewById(R.id.confirmBtn).setOnClickListener(mOnMemoAddClickListener); // 뷰 모델 생성 mMainViewModel = ViewModelProviders.of(this).get(MainViewModel.class); // 뷰 모델에서 LiveData 획득 후 관측 시작 mMainViewModel.getMemos().observe(this, new Observer<RealmResults<Memo>>() { @Override public void onChanged(@Nullable RealmResults<Memo> memos) { System.out.println("RealmResults<Memo>.onChanged: " + memos); mMemoAdapter.onUpdate(memos); } }); } private View.OnClickListener mOnMemoAddClickListener = new OnClickListener() { @Override public void onClick(View view) { MemoActionDialog.show(null, MainActivity.this, new Callback() { @Override public void onItemAdd(Memo memo) { mMainViewModel.addMemo(memo); } @Override public void onItemUpdate(Memo oldOne, Memo newOne) { } }); } }; private View.OnClickListener mOnMemoItemClickListener = new OnClickListener() { @Override public void onClick(View view) { } }; private View.OnClickListener mOnMemoItemUpdateClickListener = new OnClickListener() { @Override public void onClick(View view) { Memo oldOne = (Memo) view.getTag(); MemoActionDialog.show(oldOne, MainActivity.this, new Callback() { @Override public void onItemAdd(Memo memo) { } @Override public void onItemUpdate(Memo oldOne, Memo newOne) { mMainViewModel.onItemUpdateClick(oldOne, newOne); } }); } }; private View.OnClickListener mOnMemoItemDeleteClickListener = new OnClickListener() { @Override public void onClick(View view) { Memo clickedItem = (Memo) view.getTag(); mMainViewModel.onItemDeleteClick(clickedItem); } }; } ``` 위의 코드 중 mMainViewModel.getMemos().observe(...); 이 코드 덕분에 메모를 추가하거나 수정하거나 삭제하여 데이터가 변동되면 바로 알 수 있다. # 6. MainViewModel ```java public class MainViewModel extends ViewModel { private Realm mRealm; private MemoDao mMemoDao; private LiveData<RealmResults<Memo>> mMemos; public MainViewModel() { System.out.println("MainViewModel.constructor()"); mRealm = Realm.getDefaultInstance(); mMemoDao = new MemoDao(mRealm); subscribeMemos(); } private void subscribeMemos() { System.out.println("MainViewModel.subscribeMemos()"); mMemos = mMemoDao.findAllMemos(); } public LiveData<RealmResults<Memo>> getMemos() { System.out.println("MainViewModel.getMemos(): " + mMemos.getValue()); return mMemos; } public void addMemo(Memo memo) { mMemoDao.add(memo); } public void onItemDeleteClick(Memo memo) { mMemoDao.delete(memo); } public void onItemUpdateClick(Memo oldOne, Memo newOne) { mMemoDao.update(oldOne, newOne); } @Override protected void onCleared() { System.out.println("MainViewModel.onCleared()"); mRealm.close(); super.onCleared(); } } ``` 위의 뷰 모델에서는 최초 생성시 Realm 에 저장되어 있는 모든 메모 목록을 LiveData 로 불러와서 옵저빙을 할 수 있게 제공해주며, 데이터의 추가, 수정, 삭제 기능들을 Dao 클래스를 통해 제공한다. # 7. 프리뷰 ![preview](/images/gallery/1511420747863_realm_with_aac_preview.png) # 샘플 프로젝트 * [깃허브](https://github.com/prChoe/RealmMemoWithAAC) # 관련 링크 * [AAC 관련 개발자 문서](https://developer.android.com/topic/libraries/architecture/index.html) * [구글 안드로이드 개발자 블로그](https://developers-kr.googleblog.com/2017/06/android-and-architecture.html) * [Realm과 함께하는 안드로이드 아키텍쳐 컴포넌트](https://academy.realm.io/kr/posts/android-architecture-components-and-realm/)

Android 2017.11.23 7:27
Node.js 의 프로세스 관리자인 PM2 모듈 사용하기

Java 웹 애플리케이션을 배포할 때는 Tomcat 따위의 WAS를 이용하여 배포한다. 정적 파일을 요청자에게 제공하거나, 로직상의 문제가 생겨 예외가 발생하면 애플리케이션을 재시작해주기도 한다. Node.js 의 경우 자체적인 웹서버 기능이 존재하여 별도의 WAS 나 웹서버를 이용하여 배포할 필요는 없다. ```text node app.js ``` 일반적으로 위의 명령어를 이용하여 애플리케이션을 시작하게 된다. 물론 express.js 를 이용하여 제작했을 경우 bin 폴더의 www 파일을 실행하면 애플리케이션이 시작되는 차이는 있다. 하지만 위의 명령어를 이용하여 애플리케이션을 시작하게 되면 에러가 발생하여 서버가 뻗어버렸을 때 서버를 자동으로 재시작해준다던가 하는 편리함은 없다. node.js 애플리케이션은 위와 같은 불편함을 극복하기 위해 별도의 프로세스 관리자 모듈을 통해 애플리케이션을 배포한다. ## 1. PM2 설치하기 node.js 관련 모듈은 npm 이라는 패키지 매니저를 이용하여 설치한다. 안드로이드나 Java 애플리케이션을 개발할 때 Maven 이나 Gradle 따위를 이용하여 외부 모듈을 추가하는 것처럼 node.js 에서도 npm 을 이용하여 관련 작업을 진행할 수 있다. ```text // pm2 모듈의 최신 버전을 전역설치한다. // 전역설치를 하게 된다면 최초 1회만 설치하면 이후에도 사용할 수 있다. // 이후에 bash 에서 pm2 xxx 따위로 관련 기능을 진행할 수 있다. npm install pm2@latest -g ``` ## 2. PM2 기본 명령어 ### 1. 프로세스 실행 ```text // xxx 파일을 pm2 를 이용하여 실행한다. // 주로 xxx 파일은 express.js 미사용시 app.js 를, 사용시에는 bin/www 파일을 실행한다 pm2 start xxx.js ``` ![pm2 start](/images/gallery/1510718826400_pm2_start.png) ```text // xxx 파일을 쌍따옴표 안에 지정한 이름을 지정하여 실행한다. // pm2 를 이용하여 애플리케이션을 여러개 실행하게 될건데 별도의 이름을 지정하지 않을 경우 실행한 파일의 이름이 프로세스 이름으로 지정되어 문제가 생길 수 있다. pm2 start --name "앱 이름" xxx.js ``` ![pm2 start with name](/images/gallery/1510718817207_pm2_start_naming.png) ### 2. 실행중인 프로세스 목록 확인 ```text // pm2 를 이용하여 실행한 프로세스의 목록을 확인할 수 있다. pm2 list ``` 아래의 이미지는 bash 상에서 pm2 list 를 실행한 결과이다. ![pm2 list](/images/gallery/1510718764400_pm2_list.png) ### 3. 실행중인 프로세스 정지 ```text // id(숫자) 또는 name 에 해당되는 실행중인 프로세스를 정지한다. pm2 stop id|name ``` ![pm2 stop by id](/images/gallery/1510718837754_pm2_stop_id.png) ### 4. 실행중인 프로세스 재시작 ```text // id(숫자) 또는 name 에 해당되는 프로세스를 재시작한다. pm2 restart id|name ``` ![pm2 restart by name](/images/gallery/1510718794440_pm2_restart_name.png) ### 5. 실행중인 프로세스 제거 ```text // id(숫자) 또는 name 에 해당되는 프로세스를 제거한다. // PM2 의 프로세스 관리 목록에서 제거되며, 실제 프로젝트 파일은 삭제되지 않는다. pm2 delete id|name ``` ![pm2 delete by id](/images/gallery/1510718735552_pm2_delete_id.png) ### 6. 특정 프로세스에 대한 상세정보 출력 ```text // id(숫자) 또는 name 에 해당되는 프로세스의 상세 정보를 출력한다. pm2 show id|name ``` ![pm2 show](/images/gallery/1510718804558_pm2_show_id.png) ### 7. 특정 프로세스의 실시간 로그 확인 ```text // id(숫자) 또는 name 에 해당되는 프로세스의 실시간 로그를 출력한다. pm2 logs id|name ``` ![pm2 logs](/images/gallery/1510718776617_pm2_logs_id.png) ### 8. PM2 셧다운 ```text // PM2 를 셧다운한다. // PM2 에서 관리중인 모든 프로세스가 delete 된다. pm2 kill ``` ![pm2 kill](/images/gallery/1510718747017_pm2_kill.png) ## 3. PM2 의 ecosystem.config.js 를 활용한 개발/배포 모드 실행 ### 1. ecosystem.config.js ```js module.exports = { apps: [ { // pm2로 실행한 프로세스 목록에서 이 애플리케이션의 이름으로 지정될 문자열 name: "PM2Exam", // pm2로 실행될 파일 경로 script: "./bin/www", // 개발환경시 적용될 설정 지정 env: { "PORT": 3000, "NODE_ENV": "development" }, // 배포환경시 적용될 설정 지정 env_production: { "PORT": 8080, "NODE_ENV": "production" } } ] }; ``` ### 2. ecosystem.config.js 를 활용한 실행 ```text // 개발모드로 실행 pm2 start ecosystem.config.js // 배포모드로 실행 pm2 start ecosystem.config.js --env production ``` ### 3. npm script 를 활용한 실행 ```json { "name": "pm2exam", "version": "0.0.0", "private": true, "scripts": { "start": "pm2-dev start ecosystem.config.js", "deploy" : "npm install && pm2 start ecosystem.config.js --env production" }, "dependencies": { "body-parser": "~1.16.0", "cookie-parser": "~1.4.3", "debug": "~2.6.0", "ejs": "~2.5.5", "express": "~4.14.1", "morgan": "~1.7.0", "serve-favicon": "~2.3.2" } } ``` 위의 내용에서 scripts 부분을 보면 ```json ... "scripts": { "start": "pm2-dev start ecosystem.config.js", "deploy" : "npm install && pm2 start ecosystem.config.js --env production" } ... ``` start 와 deploy 가 정의되어 있다. start 에는 pm2 가 아닌 pm2-dev 로 명령어가 시작되는데, pm2-dev 로 애플리케이션을 실행할 경우 프로젝트의 파일이 변경될 때마다 서버가 재시작된다. 단, 웹 페이지 개발시 페이지가 수정되어도 브라우저가 자동으로 새로고침 되지는 않는다. deploy 에서는 우선 package.json 에 존재하는 모든 모듈을 설치하고 pm2 를 이용하여 ecosystem.config.js 를 실행하는데 뒤에 --env production 이 붙어있다. --env production 이 붙어있어서 ecosystem.config.js 내부에 존재하는 env_production 에 해당되는 설정대로 서버가 시작된다. ### 4. 샘플 프로젝트 링크 및 실행 https://github.com/prChoe/PM2Exam 프로젝트 clone 이나 download 후 프로젝트 루트 단에서 터미널로 npm install 을 타이핑하여 연관 모듈들을 다운받고 npm 스크립트를 실행하여 샘플 프로젝트를 테스트할 수 있다. ``` // start 스크립트 실행 - 개발모드 npm run start // deploy 스크립트 실행 - 배포모드 npm run deploy ```

Node.js 2017.11.15 4:32