Ch 10. NoSQL과 MongoDB
01. NoSQL의 정의, RDB와의 비교점
NoSQL 은 Not Only SQL 이다.
스키마 없이 데이터를 표현하는 것이 주된 특징인 일련의 데이터베이스들을 의미한다.
NoSQL 은 공유점이 없으므로 DB 종류에 따라 다르다.
정해진 스키마란 ?
정해진 데이터가 들어가야하는 틀이 없다.
사용자에는 가입일, 포스트 등의 그 틀이 있는 것이 아니다.
NoSQL 은 그 틀이 없으므로 집어넣는데로 들어간다.
장점
- 높은 수평 확장성
- 초기 개발의 용이성
- 스키마 설계의 유연성
단점
- 표준의 부재
- SQL 에 비해 약한 query capability
- data consistency 를 어플리케이션 레벨에서 보장해야 함.
수직확장 vs 수평확장
RDB 처럼 일정한 구조일 필요가 없으므로 샤딩 (여러곳에 나눠서) 이 유리
복잡한 쿼리를 짤 수가 없다.
데이터베이스 능력이 부족하다.
단순성이 올라가 그 만큼 혜택이 올라간다.
02. NoSQL의 현재 상황
NoSQL 데이터베이스 종류
- key-value
- Redis, AWS DynamoDB
- 모든 레코드는 key-value 페어입니다.
- value 는 어떤 값이든 될 수 있습니다.
- NoSQL DB 의 가장 단순한 형태입니다.
- Document
- 몽고DB 는 BSON 으로 저장한다.
- 가장 균형잡힌 NoSQL 이라고 할 수 있다.
03. 모델링과 액세스 해보기 - 1
몽고 DB 에 접속해보자.
몽고DB: https://www.mongodb.com/
atlas 를 이용해 몽고 DB 서버를 띄우고 바로 접속할 수 있다.
무료일 경우 512MB 까지 가능하다.
atlas 를 쓰지 않고 내 pc 에서 하고 싶다면
몽고 DB 설치방법: https://www.mongodb.com/docs/manual/administration/install-community/
자, 이제 시작해보자.
가입부터 해보자.
https://www.mongodb.com/cloud/atlas/register
대쉬보드에서 클러스터를 만들고 시작할 수 있다.
강사님은 한국과 제일 가까운 싱가폴로 고르셨다.
하지만 지금은 한국이 있는 것을 볼 수 있다.
IP 제한이 있으므로 현재 IP 를 등록해야 한다.
1. Add Your Current IP Address 를 누른다.
2. 데이터베이스 계정정보를 입력한다.
- Connect with the MongoDB shell
- Connect your application
- Connect using MongoDB compass: 몽고 DB GUI 툴이다.
2. Connect your application 를 선택하자.
const { MongoClient, ServerApiVersion } = require('mongodb');
const uri = "mongodb+srv://admin:<password>@cluster0.vrgvebb.mongodb.net/?retryWrites=true&w=majority";
const client = new MongoClient(uri, { useNewUrlParser: true, useUnifiedTopology: true, serverApi: ServerApiVersion.v1 });
client.connect(err => {
const collection = client.db("test").collection("devices");
// perform actions on the collection object
client.close();
});
Replace with the password for the admin user. Ensure any option params are URL encoded.
라고 잘 나와있다.
04. 모델링과 액세스 해보기 - 2
- mongo.js
// @ts-check
const { MongoClient, ServerApiVersion } = require('mongodb')
const uri = "mongodb+srv://admin:<password>@cluster0.vrgvebb.mongodb.net/?retryWrites=true&w=majority"
const client = new MongoClient(uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
접속하는 부분만 있다.
URI 는 아래와 같은 규칙으로 작성한다.
const uri = `mongodb+srv://${MONGO_USER}:${MONGO_PASSWORD}@${MONGO_CLUSTER}/${MONGO_DBNAME}?retryWrites=true&w=majority`
- 몽고 DB 설치
npm install mongodb
강의는 3.6.7 버전이지만
mongodb 를 require 할 때 문제 때문에
"mongodb": "^4.8.1",
버전을 가져왔다.
- MongoClient 타입 보기
npm install --save-dev @types/mongodb
타입이 나오지 않는다면 설치하도록 한다.
- mongo.js
// @ts-check
const { MongoClient, ServerApiVersion } = require('mongodb')
const uri = `mongodb+srv://admin:${process.env.MONGO_PASSWORD}@cluster0.vrgvebb.mongodb.net/?retryWrites=true&w=majority`
const client = new MongoClient(uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
// serverApi: ServerApiVersion.v1
})
async function main() {
const c = await client.connect()
console.log(c)
console.log('OK!')
client.close()
}
main()
쉘 환경변수에
process.env.MONGO_PASSWORD 를 저장해야 한다.
변수를 저장하는 세 가지 방법
- 노드를 실행할 때 변수 입력
- 터미널에 변수 저장
- .zshrc 에 저장
1. 노드를 실행할 때 변수 입력
2. 터미널에 변수 저장
3. .zshrc 에 저장
.zshrc 에 변수를 저장할 때에는 터미널을 재실행할 때 변수가 저장된다.
변수를 저장했다면 DB 연결을 확인해보자.
node src/mongo.js
컬렉션은 도큐먼트의 집합이다.
최소단위는 도큐먼트이다.
컬렉션을 지정해서 어떤 컬렉션에서 작업할 지 지정해야 한다.
- mongo.js
// @ts-check
const { MongoClient } = require('mongodb')
const uri = `mongodb+srv://admin:${process.env.MONGO_PASSWORD}@cluster0.vrgvebb.mongodb.net/?retryWrites=true&w=majority`
const client = new MongoClient(uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
async function main() {
const c = await client.connect()
const users = client.db('fc21').collection('users')
await users.deleteMany({})
users.insertMany([
{
name: 'Foo',
},
{
name: 'Bar',
},
{
name: 'Baz',
},
])
const cursor = users.find({})
await cursor.forEach(console.log)
await client.close()
}
main()
컬렉션을 지정하면 새로운 컬렉션이 생기고 값이 들어간다.
DB 또한 똑같다.
- 업데이트
await users.updateOne(
{
name: 'Baz'
},
{
$set: {
name: 'Boo',
}
},
)
const cursor = users.find({
birthYear: {
$gte: 1995,
}
})
- gte: greater than equal. 이상을 의미한다.
- 정렬
const cursor = users.find(
{
birthYear: {
$gte: 1990,
},
},
{
sort: {
birthYear: -1,
},
},
)
1 이면 오름차순, -1 이면 내림차순을 의미한다.
- 삭제
await users.deleteOne({
name: 'Baz',
})
연락처는 한 사람에게 할당된다.
One to Many 는 무조건 임베딩된다.
- 몽고 DB 의 한계점
BSON Documents
https://www.mongodb.com/docs/manual/reference/limits/
- Nested Depth 는 100 까지이다.
User Contacs 에 접근할 일이 많으면 다른 컬렉션으로 하는게 맞다.
부가적인 정보에 맞다면 네스팅하는게 맞다.
정답은 없고 상황에 맞게 스키마를 짜면 된다.
네스팅으로 모델링한 경우에 쿼리하는 방법
- mongo.js
await users.insertMany([
{
name: 'Foo',
birthYear: 2000,
contacts: [
{
type: 'phone',
number: '+821000001111'
},
{
type: 'home',
number: '+82023334444'
},
]
},
{
name: 'Bar',
birthYear: 1995,
},
{
name: 'Baz',
birthYear: 1990,
},
{
name: 'Poo',
birthYear: 1993,
},
])
const cursor = users.find({
'contacts.type': 'phone',
})
await cursor.forEach(console.log)
- Many to Many
await users.insertMany([
{
name: 'Foo',
birthYear: 2000,
contacts: [
{
type: 'phone',
number: '+821000001111'
},
{
type: 'home',
number: '+82023334444'
},
],
city: {
name: '서울',
population: 1000,
},
},
{
name: 'Bar',
birthYear: 1995,
contacts: [
{
type: 'phone',
number: '+821000001111'
},
],
city: {
name: '부산',
population: 350,
},
},
{
name: 'Baz',
birthYear: 1990,
city: {
name: '부산',
population: 350,
},
},
{
name: 'Poo',
birthYear: 1993,
city: {
name: '부산',
population: 350,
},
},
])
컬렉션을 따로 만드는게 낫다.
어그리게이션 이란 ?
조인과 비슷한 역할이다.
- aggregate
// @ts-check
const { MongoClient } = require('mongodb')
const uri = `mongodb+srv://admin:${process.env.MONGO_PASSWORD}@cluster0.vrgvebb.mongodb.net/?retryWrites=true&w=majority`
const client = new MongoClient(uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
async function main() {
const c = await client.connect()
const users = client.db('fc21').collection('users')
const cities = client.db('fc21').collection('cities')
// Reset
await users.deleteMany({})
await cities.deleteMany({})
// Init
await cities.insertMany([
{
name: '서울',
population: 1000,
},
{
name: '부산',
population: 350,
},
])
await users.insertMany([
{
name: 'Foo',
birthYear: 2000,
contacts: [
{
type: 'phone',
number: '+821000001111'
},
{
type: 'home',
number: '+82023334444'
},
],
city: '서울'
},
{
name: 'Bar',
birthYear: 1995,
contacts: [
{
type: 'phone',
number: '+821000001111'
},
],
city: '부산'
},
{
name: 'Baz',
birthYear: 1990,
city: '부산'
},
{
name: 'Poo',
birthYear: 1993,
city: '서울'
},
])
const cursor = users.aggregate([
{
$lookup: {
from: 'cities',
localField: 'city',
foreignField: 'name',
as: 'city_info',
},
}
])
users.find({
'contacts.type': 'phone'
})
await cursor.forEach(console.log)
await client.close()
}
main()
const cursor = users.aggregate([
{
$lookup: {
from: 'cities',
localField: 'city',
foreignField: 'name',
as: 'city_info',
},
}
])
city 와 같은 값을 cities 컬렉션에서 name 에서 찾는다.
그리고 city_info 라고 담는다.
const cursor = users.aggregate([
{
$lookup: {
from: 'cities',
localField: 'city',
foreignField: 'name',
as: 'city_info',
},
},
{
$match: {
'city_info.population': {
$gte: 500,
}
}
}
])
서울만 나오게 된다.
const cursor = users.aggregate([
{
$lookup: {
from: 'cities',
localField: 'city',
foreignField: 'name',
as: 'city_info',
},
},
{
$match: {
$and: [
{
'city_info.population': {
$gte: 500,
},
},
{
birthYear: {
$gte: 1995,
},
},
]
}
}
])
- count
const cursor = users.aggregate([
{
$lookup: {
from: 'cities',
localField: 'city',
foreignField: 'name',
as: 'city_info',
},
},
{
$match: {
$or: [
{
'city_info.population': {
$gte: 500,
},
},
{
birthYear: {
$gte: 1995,
},
},
]
}
},
{
$count: 'num_users',
}
])
언어별 드라이버
Ch 11. 프로젝트: 웹소켓을 통한 실시간 인터랙션 구현
01. 요구사항 설정과 프로젝트 설계
- UI
웹소켓은 서버와 클라이언트가 실시적으로 정보를 주고받을 수 있는 프로토콜이다.
2015년 부터 완벽히 사용할 수 있도록 되었다.
그 이전에는 소켓 IO 의 방식으로 웹소켓의 방식을 흉내냈다.
koa 는 express 를 만들었던 팀이 훨씬 더 미니멀한 웹 프레임워크를 만들었다.
현대적인 문법으로 프로미스 등이 잘 되어있다.
Koa: https://koajs.com/
Frontend
- Template engine: Pug
- CSS framework: TailwindCSS
Backend
- Web framework: Koa
- Live networking: koa-websocket
- Database: MongoDB
TailwindCSS
유틸리티 클래스로만 이루어진 새로운 형태의 CSS 프레임워크이다.
부트스트랩보다 더 CSS 에 근접하다.
각 클래스가 많은 기능을 갖고있지 않다.
미니멀하고 스케일하기 좋다.
02. 프론트엔드 UI 그리기
서버쪽도 간단히 짜보자.
- main.js
// @ts-check
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
- 코아 설치
npm install koa@2.13.1
- 노드몬 설치
npm install --save-dev nodemon@2.0.7
// @ts-check
const Koa = require('koa')
const app = new Koa()
app.use(async (ctx, next) => {
ctx.body = 'Hello World!!'
await next()
})
app.use(async (ctx) => {
ctx.body = `<${ctx.body}>`
})
app.listen(5000)
// @ts-check
const Koa = require('koa')
const app = new Koa()
app.use(async (ctx, next) => {
ctx.body = 'Hello World!!'
await next()
ctx.body = `[${ctx.body}]`
})
app.use(async (ctx) => {
ctx.body = `<${ctx.body}>`
})
app.listen(5000)
Pug 와 TailwindCSS 를 셋업 해보자.
A Pug middleware for Koa: https://github.com/chrisyip/koa-pug
- koa-pug 설치
npm install koa-pug@5.0.0
- main.js
// @ts-check
const Koa = require('koa')
const Pug = require('koa-pug')
const path = require('path')
const app = new Koa()
// @ts-ignore
new Pug({
viewPath: path.resolve(__dirname, './views'),
app: app // Binding `ctx.render()`, equals to pug.use(app)
})
app.use(async (ctx) => {
await ctx.render('main')
})
app.listen(5000)
- main.pug
html
head
body
div Hello, Pug!
viewPath: path.resolve(__dirname, './views'),
path.resolve 는 __dirname 을 사용해서 어디서 실행하던지 간에 화면을 보여준다.
테일윈드 독스: https://tailwindcss.com/docs/installation
https://tailwindcss.com/docs/editor-setup#intelli-sense-for-vs-code
tailwindcss config: https://tailwindcss.com/docs/configuration
- tailwind.config.js
// tailwind.config.js
module.exports = {
purge: [],
darkMode: false,
theme: {
extend: {},
},
variants: {},
plugins: [],
}
npm install tailwindcss@2.1.2
- tailwindCSS 설치 (npm install)
- tailwind.config.css 파일 생성
- main.pug 에 링크 추가
- settings.json 에 추가
- tailwind CSS 플러그인 설치
- main.js
// @ts-check
const Koa = require('koa')
const Pug = require('koa-pug')
const path = require('path')
const app = new Koa()
// @ts-ignore
new Pug({
viewPath: path.resolve(__dirname, './views'),
app, // Binding `ctx.render()`, equals to pug.use(app)
})
app.use(async (ctx) => {
await ctx.render('main')
})
app.listen(5000)
- main.pug
html
head
link(href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet")
body
div.mx-4.my-4.bg-gray-200 Hello, Pug!
- mx: x 축으로 margin
- my: y 축으로 margin
- bg: back ground
이제 UI 를 짜보자.
Cmd Shift P 를 누르고 zen mode 를 검색해보자.
이 모드를 켜면 더 집중할 수 있다.
- main.pug
html
head
link(href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet")
body
h1.bg-gradient-to-r.from-purple-100.to-gray-200.p-16.text-4xl.font-bold 나의 채팅 서비스
div.p-16.space-y-8
div.bg-gray-100.p-2 채팅 목록
form.space-x-4.flex
input.flex-1.border.border-gray-200.p-4.rounded(placeholder="채팅을 입력해 보세요.")
button.bg-blue-600.text-white.p-2.rounded 보내기
03. 웹소켓 미들웨어 사용해 프론트엔드와 연결하기
서버 기능을 붙여 온전한 서비스를 만들어보자.
예시 코드: https://github.com/kudos/koa-websocket/blob/master/examples/simple.js
const app = websockify(new Koa());
app.ws.use(function(ctx, next) {
return next(ctx)
})
app.ws.use(route.all('/test/:id', function (ctx) {
ctx.websocket.send('Hello World')
ctx.websocket.on('message', function(message) {
console.log(message)
})
}))
- koa-websocket 설치
npm install koa-websocket@6.0.0
npm install koa-route@3.2.0
웹 소켓을 위해 필요하다.
- koa-static 설치
npm install koa-static@5.0.0
주소의 충돌을 막기 위해 mount 라는 미들웨어를 사용한다.
koa-mount: https://github.com/koajs/mount
- koa-mount
npm install koa-mount@4.0.0
- main.js
// @ts-check
const Koa = require('koa')
const Pug = require('koa-pug')
const path = require('path')
const route = require('koa-route')
const serve = require('koa-static')
const websockify = require('koa-websocket')
const mount = require('koa-mount')
const app = websockify(new Koa())
// @ts-ignore
new Pug({
viewPath: path.resolve(__dirname, './views'),
app, // Binding `ctx.render()`, equals to pug.use(app)
})
app.use(mount('/public', serve('src/public')))
app.use(async (ctx) => {
await ctx.render('main')
})
app.ws.use(
route.all('/test/:id', (ctx) => {
ctx.websocket.send('Hello World')
ctx.websocket.on('message', (message) => {
console.log(message)
})
})
)
app.listen(5000)
- public/client.js
alert('Client.js loaded!')
- main.pug
html
head
link(href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet")
body
h1.bg-gradient-to-r.from-purple-100.to-gray-200.p-16.text-4xl.font-bold 나의 채팅 서비스
div.p-16.space-y-8
div.bg-gray-100.p-2 채팅 목록
form.space-x-4.flex
input.flex-1.border.border-gray-200.p-4.rounded(placeholder="채팅을 입력해 보세요.")
button.bg-blue-600.text-white.p-2.rounded 보내기
script(src="/public/client.js")
이제 웹소켓을 사용해 연결해보자.
${window.location.host}
localhost:5000 을 나타낸다.
- .eslintrc.js
module.exports = {
root: true,
env: {
node: true,
},
extends: ['airbnb-base', 'plugin:node/recommended', 'prettier'],
rules: {
'import/prefer-default-export': ['off'],
},
}
브라우저에서만 가능한데 노드에서 하려고 해서 에러가 나게된다.
것을 막으려면
client.js 와 같은 뎁스에 eslintrc.js 를 또 만든다.
- src/public/.eslintrc.js
module.exports = {
env: {
browser: true,
}
}
client.js 에 선언된 변수 socket 이
// @ts-check
// IIFE
;(() => {
const socket = new WebSocket(`ws://${window.location.host}/ws`)
})()
IIFE 를 사용하면 소켓 변수를 숨길 수 있다.
- client.js
// @ts-check
// IIFE
;(() => {
const socket = new WebSocket(`ws://${window.location.host}/ws`)
socket.addEventListener('open', () => {
socket.send('Hello, WebSocket!')
})
})()
이제 새로고침을 하면 서버에 Hello, WebSocket! 이 찍힌다.
이제 Hello client, Hello server 를 찍어보자.
- main.js
// @ts-check
const Koa = require('koa')
const Pug = require('koa-pug')
const path = require('path')
const route = require('koa-route')
const serve = require('koa-static')
const websockify = require('koa-websocket')
const mount = require('koa-mount')
const app = websockify(new Koa())
// @ts-ignore
new Pug({
viewPath: path.resolve(__dirname, './views'),
app, // Binding `ctx.render()`, equals to pug.use(app)
})
app.use(mount('/public', serve('src/public')))
app.use(async (ctx) => {
await ctx.render('main')
})
app.ws.use(
route.all('/ws', (ctx) => {
ctx.websocket.on('message', (message) => {
console.log(message)
ctx.websocket.send('Hello, client')
})
})
)
app.listen(5000)
- client.js
// @ts-check
// IIFE
;(() => {
const socket = new WebSocket(`ws://${window.location.host}/ws`)
socket.addEventListener('open', () => {
socket.send('Hello, server!')
})
socket.addEventListener('message', event => {
alert(event.data)
})
})()
- main.pug
html
head
link(href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet")
body
h1.bg-gradient-to-r.from-purple-100.to-gray-200.p-16.text-4xl.font-bold 나의 채팅 서비스
div.p-16.space-y-8
div.bg-gray-100.p-2 채팅 목록
form#form.space-x-4.flex
input#input.flex-1.border.border-gray-200.p-4.rounded(placeholder="채팅을 입력해 보세요.")
button#send.bg-blue-600.text-white.p-2.rounded 보내기
script(src="/public/client.js")
element 에 id (#) 를 지정해준 후
- client.js
// @ts-check
// IIFE
;(() => {
const socket = new WebSocket(`ws://${window.location.host}/ws`)
const formEl = document.getElementById('form')
/** @type {HTMLInputElement | null} */
// @ts-ignore
const inputEl = document.getElementById('input')
if (!formEl || !inputEl) {
throw new Error('Init failed!')
}
formEl.addEventListener('submit', event => {
event.preventDefault()
socket.send(inputEl.value)
inputEl.value = ''
})
socket.addEventListener('message', event => {})
})()
submit 동작이 수행됐을 때 값을 서버로 넘겨보자.
이제 JSON 으로 넘겨보자.
- client.js
formEl.addEventListener('submit', event => {
event.preventDefault()
socket.send(
JSON.stringify({
nickname: '멋진 물범',
message: inputEl.value,
})
)
inputEl.value = ''
})
메시지를 서버로 보냈다가 다시 받아보자.
- main.js
app.ws.use(
route.all('/ws', (ctx) => {
ctx.websocket.on('message', (data) => {
if (typeof data !== 'string') {
return
}
const { message, nickname} = JSON.parse(data)
ctx.websocket.send(
JSON.stringify({
message,
nickname,
})
)
})
})
)
- client.js
socket.addEventListener('message', event => {
alert(event.data)
})
- main.pug
html
head
link(href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet")
body
h1.bg-gradient-to-r.from-purple-100.to-gray-200.p-16.text-4xl.font-bold 나의 채팅 서비스
div.p-16.space-y-8
div#chats
div.bg-gray-100.p-2 채팅 목록
form#form.space-x-4.flex
input#input.flex-1.border.border-gray-200.p-4.rounded(placeholder="채팅을 입력해 보세요.")
button#send.bg-blue-600.text-white.p-2.rounded 보내기
script(src="/public/client.js")
- client.js
// @ts-check
// IIFE
;(() => {
const socket = new WebSocket(`ws://${window.location.host}/ws`)
const formEl = document.getElementById('form')
const chatsEl = document.getElementById('chats')
/** @type {HTMLInputElement | null} */
// @ts-ignore
const inputEl = document.getElementById('input')
if (!formEl || !inputEl || !chatsEl) {
throw new Error('Init failed!')
}
/**
* @typedef Chat
* @property {string} nickname
* @property {string} message
*/
/**
* @type {Chat[]}
*/
const chats = []
formEl.addEventListener('submit', event => {
event.preventDefault()
socket.send(
JSON.stringify({
nickname: '멋진 물범',
message: inputEl.value,
})
)
inputEl.value = ''
})
socket.addEventListener('message', event => {
chats.push(JSON.parse(event.data))
chatsEl.innerHTML = ''
chats.forEach(({message, nickname}) => {
const div = document.createElement('div')
div.innerHTML = `${nickname}: ${message}`
chatsEl.appendChild(div)
})
})
})()
- main.js
// @ts-check
const Koa = require('koa')
const Pug = require('koa-pug')
const path = require('path')
const route = require('koa-route')
const serve = require('koa-static')
const websockify = require('koa-websocket')
const mount = require('koa-mount')
const app = websockify(new Koa())
// @ts-ignore
new Pug({
viewPath: path.resolve(__dirname, './views'),
app, // Binding `ctx.render()`, equals to pug.use(app)
})
app.use(mount('/public', serve('src/public')))
app.use(async (ctx) => {
await ctx.render('main')
})
app.ws.use(
route.all('/ws', (ctx) => {
ctx.websocket.on('message', (data) => {
if (typeof data !== 'string') {
return
}
const { message, nickname} = JSON.parse(data)
ctx.websocket.send(
JSON.stringify({
message,
nickname,
})
)
})
})
)
app.listen(5000)
브로드캐스트가 아니라 유닛 캐스트이다.
메시지를 보낸 사람한테만 보내준다.
서버에 물려있는 모든 소켓에 뿌려야 한다.
- 유닛캐스트
ctx.websocket.send(
JSON.stringify({
message,
nickname,
})
)
- 브로드캐스트
server.clients.forEach(client => {
client.send(
JSON.stringify({
message,
nickname,
})
)
})
이것을 데이터베이스에 저장해야 영구적으로 남는다.
- client.js
// @ts-check
// IIFE
;(() => {
const socket = new WebSocket(`ws://${window.location.host}/ws`)
const formEl = document.getElementById('form')
const chatsEl = document.getElementById('chats')
/** @type {HTMLInputElement | null} */
// @ts-ignore
const inputEl = document.getElementById('input')
if (!formEl || !inputEl || !chatsEl) {
throw new Error('Init failed!')
}
/**
* @typedef Chat
* @property {string} nickname
* @property {string} message
*/
/**
* @type {Chat[]}
*/
const chats = []
const adjectives = ['멋진', '훌륭한', '친절한', '새침한']
const animals = ['물범', '사자', '사슴', '돌고래', '독수리']
/**
* @param {string[]} array
* @returns {string}
*/
function pickRandom(array) {
const randomIdx = Math.floor(Math.random() * array.length)
const result = array[randomIdx]
if (!result) {
throw new Error('array length is 0.')
}
return result
}
const myNickname = `${pickRandom(adjectives)} ${pickRandom(animals)}`
formEl.addEventListener('submit', event => {
event.preventDefault()
socket.send(
JSON.stringify({
nickname: myNickname,
message: inputEl.value,
})
)
inputEl.value = ''
})
socket.addEventListener('message', event => {
chats.push(JSON.parse(event.data))
chatsEl.innerHTML = ''
chats.forEach(({message, nickname}) => {
const div = document.createElement('div')
div.innerHTML = `${nickname}: ${message}`
chatsEl.appendChild(div)
})
})
})()
이제 이것을 DB 에 저장만 하면 이 챕터는 마무리 될 것 같다.
04. MongoDB에 인터랙션 정보 저장하고 활용하기
- mongodb 설치
npm install mongodb@3.6.7
dangling 이란 ?
- mongo.js
// @ts-check
const { MongoClient} = require('mongodb')
const uri = `mongodb+srv://admin:${process.env.MONGO_PASSWORD}@cluster0.vrgvebb.mongodb.net/?retryWrites=true&w=majority`
const client = new MongoClient(uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
module.exports = client
- main.js
// @ts-check
const Koa = require('koa')
const Pug = require('koa-pug')
const path = require('path')
const route = require('koa-route')
const serve = require('koa-static')
const websockify = require('koa-websocket')
const mount = require('koa-mount')
const mongoClient = require('./mongo')
const app = websockify(new Koa())
/**
* @typedef Chat
* @property {string} nickname
* @property {string} message
*/
// @ts-ignore
new Pug({
viewPath: path.resolve(__dirname, './views'),
app, // Binding `ctx.render()`, equals to pug.use(app)
})
app.use(mount('/public', serve('src/public')))
app.use(async (ctx) => {
await ctx.render('main')
})
const _client = mongoClient.connect()
async function getChatsCollection() {
const client = await _client
return client.db('chat').collection('chats')
}
app.ws.use(
route.all('/ws', async (ctx) => {
const chatsCollection = await getChatsCollection()
const chatsCursor = chatsCollection.find(
{},
{
sort: {
createdAt: 1,
},
}
)
const chats = await chatsCursor.toArray()
ctx.websocket.send(JSON.stringify({
type: 'sync',
payload: {
chats,
},
})
)
ctx.websocket.on('message', async (data) => {
if (typeof data !== 'string') {
return
}
/** @type {Chat} */
const chat = JSON.parse(data)
await chatsCollection.insertOne({
...chat,
createdAt: new Date(),
})
const { message, nickname} = chat
const { server } = app.ws
if (!server) {
return
}
server.clients.forEach(client => {
client.send(
JSON.stringify({
type: 'chat',
payload: {
message,
nickname,
},
})
)
})
})
})
)
app.listen(5000)
- client.js
// @ts-check
// IIFE
;(() => {
const socket = new WebSocket(`ws://${window.location.host}/ws`)
const formEl = document.getElementById('form')
const chatsEl = document.getElementById('chats')
/** @type {HTMLInputElement | null} */
// @ts-ignore
const inputEl = document.getElementById('input')
if (!formEl || !inputEl || !chatsEl) {
throw new Error('Init failed!')
}
/**
* @typedef Chat
* @property {string} nickname
* @property {string} message
*/
/**
* @type {Chat[]}
*/
const chats = []
const adjectives = ['멋진', '훌륭한', '친절한', '새침한']
const animals = ['물범', '사자', '사슴', '돌고래', '독수리']
/**
* @param {string[]} array
* @returns {string}
*/
function pickRandom(array) {
const randomIdx = Math.floor(Math.random() * array.length)
const result = array[randomIdx]
if (!result) {
throw new Error('array length is 0.')
}
return result
}
const myNickname = `${pickRandom(adjectives)} ${pickRandom(animals)}`
formEl.addEventListener('submit', event => {
event.preventDefault()
socket.send(
JSON.stringify({
nickname: myNickname,
message: inputEl.value,
})
)
inputEl.value = ''
})
const drawChats = () => {
chatsEl.innerHTML = ''
chats.forEach(({message, nickname}) => {
const div = document.createElement('div')
div.innerHTML = `${nickname}: ${message}`
chatsEl.appendChild(div)
})
}
socket.addEventListener('message', event => {
const { type, payload } = JSON.parse(event.data)
if (type === 'sync') {
const { chats: syncedChats } = payload
chats.push(...syncedChats)
} else if (type === 'chat') {
const chat = payload
chats.push(chat)
}
drawChats()
})
})()
- main.pug
html
head
link(href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet")
body
h1.bg-gradient-to-r.from-purple-100.to-gray-200.p-16.text-4xl.font-bold 나의 채팅 서비스
div.p-16.space-y-8
div#chats
div.bg-gray-100.p-2 채팅 목록
form#form.space-x-4.flex
input#input.flex-1.border.border-gray-200.p-4.rounded(placeholder="채팅을 입력해 보세요.")
button#send.bg-blue-600.text-white.p-2.rounded 보내기
script(src="/public/client.js")
Ch 12. 프로젝트: 이미지 리사이징 서버 만들기
01. 외부 소스에서 원하는 이미지 가져오기
이미지 사이트: https://unsplash.com/
unsplash API: https://unsplash.com/developers
이미지 리사이징: https://github.com/lovell/sharp
unsplash 에서 개발자계정을 만든다.
my application 을 만든다.
https://github.com/unsplash/unsplash-js
- 설치
npm install unsplash-js@7.0.11 node-fetch@2.6.1
require 로 가져오면 node 모듈일 것이라고 생각한다.
그러므로 node 모듈이 아니라면 default 로 가져온다.
- main.js
// @ts-check
const { createApi } = require('unsplash-js')
const { default: fetch} = require('node-fetch')
const { default: convertLayerAtRulesToControlComments } = require('tailwindcss/lib/lib/convertLayerAtRulesToControlComments')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
})
async function main() {
const result = await unsplash.search.getPhotos({ query: 'mountain'})
console.log(result)
}
main()
// @ts-check
const { createApi } = require('unsplash-js')
const { default: fetch} = require('node-fetch')
const { default: convertLayerAtRulesToControlComments } = require('tailwindcss/lib/lib/convertLayerAtRulesToControlComments')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
})
/**
* @param {string} query
* @returns
*/
async function searchImage(query) {
const result = await unsplash.search.getPhotos({ query})
if (!result.response) {
throw new Error('Failed to search image.')
}
const image = result.response.results[0]
if (!image) {
throw new Error('No image found.')
}
return {
description: image.description || image.alt_description,
url: image.urls.regular,
}
}
async function main() {
const result = await searchImage('mountin')
console.log(result)
}
main()
- fetch 로 이미지 받아오기
// @ts-check
const fs = require('fs')
const http = require('http')
const { createApi } = require('unsplash-js')
const { default: fetch} = require('node-fetch')
const { default: convertLayerAtRulesToControlComments } = require('tailwindcss/lib/lib/convertLayerAtRulesToControlComments')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
})
/**
* @param {string} query
* @returns
*/
async function searchImage(query) {
const result = await unsplash.search.getPhotos({ query})
if (!result.response) {
throw new Error('Failed to search image.')
}
const image = result.response.results[0]
if (!image) {
throw new Error('No image found.')
}
return {
description: image.description || image.alt_description,
url: image.urls.regular,
}
}
const server = http.createServer((req, res) => {
async function main() {
const result = await searchImage('mountin')
const resp = await fetch(result.url)
resp.body.pipe(res)
}
main()
})
const PORT = 5000
server.listen(PORT, () => {
console.log('The server is listening at port', PORT)
})
pipe 를 하면 스트림으로 내려줄 수 있다.
fetch 를 사용하면 브라우저 API 와 비슷하게 사용할 수 있다.
02. 이미지 요청대로 리사이징해서 돌려주기
언스플래쉬에서 이미지를 받아오는 것 까지 해보았다.
리사이징까지 해보자.
- sharp 설치
npm install sharp@0.28.3
Path variable 인 이미지를 뿌려주기
// @ts-check
const fs = require('fs')
const http = require('http')
const { createApi } = require('unsplash-js')
const { default: fetch} = require('node-fetch')
const sharp = require('sharp')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
})
/**
* @param {string} query
* @returns
*/
async function searchImage(query) {
const result = await unsplash.search.getPhotos({ query})
if (!result.response) {
throw new Error('Failed to search image.')
}
const image = result.response.results[0]
if (!image) {
throw new Error('No image found.')
}
return {
description: image.description || image.alt_description,
url: image.urls.regular,
}
}
/**
*
* @param {string} url
*/
function convertURLToQueryKeyword(url) {
return url.slice(1)
}
const server = http.createServer((req, res) => {
async function main() {
if (!req.url) {
res.statusCode = 400
res.end('Needs URL.')
return
}
const query = convertURLToQueryKeyword(req.url)
const result = await searchImage(query)
const resp = await fetch(result.url)
resp.body.pipe(res)
}
main()
})
const PORT = 5000
server.listen(PORT, () => {
console.log('The server is listening at port', PORT)
})
캐쉬에 저장
cloud 로 바꿨을 때 여전히 문제가 있다.
pipeline 를 promise 로 사용하려면 util 에서 promise 를 가져와야 한다.
resp.body.pipe(fs.createWriteStream(imageFilePath))
위 코드를 아래와 같이 바꾼다.
await promisify(pipeline) (
resp.body,
fs.createWriteStream(imageFilePath),
)
- main.js
// @ts-check
const fs = require('fs')
const path = require('path')
const http = require('http')
const { createApi } = require('unsplash-js')
const { default: fetch} = require('node-fetch')
const { pipeline } = require('stream')
const { promisify } = require('util')
// const sharp = require('sharp')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
})
/**
* @param {string} query
* @returns
*/
async function searchImage(query) {
const result = await unsplash.search.getPhotos({ query})
if (!result.response) {
throw new Error('Failed to search image.')
}
const image = result.response.results[0]
if (!image) {
throw new Error('No image found.')
}
return {
description: image.description || image.alt_description,
url: image.urls.regular,
}
}
/**
* @param {string} query
*/
async function getCachedImageOrSearchedImage(query) {
const imageFilePath = path.resolve(__dirname, `../images/${query}`)
if (fs.existsSync(imageFilePath)) {
return {
message: `Returning cached image: ${query}`,
stream: fs.createReadStream(imageFilePath)
}
}
const result = await searchImage(query)
const resp = await fetch(result.url)
await promisify(pipeline) (
resp.body,
fs.createWriteStream(imageFilePath),
)
return {
message: `Returning new image: ${query}`,
stream: fs.createReadStream(imageFilePath)
}
}
/**
* 이미지를 Unsplash 에서 검색하거나, 이미 있다면 캐시된 이미지를 리턴합니다.
* @param {string} url
*/
function convertURLToQueryKeyword(url) {
return url.slice(1)
}
const server = http.createServer((req, res) => {
async function main() {
if (!req.url) {
res.statusCode = 400
res.end('Needs URL.')
return
}
const query = convertURLToQueryKeyword(req.url)
try {
const {message, stream} = await getCachedImageOrSearchedImage(query)
stream.pipe(res)
console.log(message)
} catch {
res.statusCode = 400
res.end()
}
}
main()
})
const PORT = 5000
server.listen(PORT, () => {
console.log('The server is listening at port', PORT)
})
이미지 리사이징 라이브러리 sharp: https://github.com/lovell/sharp
stream 함수를 보자.
중간에 트랜스포머를 끼워넣어 준다.
이미지를 리사이징 해보자.
- main.js
// @ts-check
const fs = require('fs')
const path = require('path')
const http = require('http')
const { createApi } = require('unsplash-js')
const { default: fetch} = require('node-fetch')
const { pipeline } = require('stream')
const { promisify } = require('util')
const sharp = require('sharp')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
})
/**
* @param {string} query
* @returns
*/
async function searchImage(query) {
const result = await unsplash.search.getPhotos({ query})
if (!result.response) {
throw new Error('Failed to search image.')
}
const image = result.response.results[0]
if (!image) {
throw new Error('No image found.')
}
return {
description: image.description || image.alt_description,
url: image.urls.regular,
}
}
/**
* @param {string} query
*/
async function getCachedImageOrSearchedImage(query) {
const imageFilePath = path.resolve(__dirname, `../images/${query}`)
if (fs.existsSync(imageFilePath)) {
return {
message: `Returning cached image: ${query}`,
stream: fs.createReadStream(imageFilePath)
}
}
const result = await searchImage(query)
const resp = await fetch(result.url)
await promisify(pipeline) (
resp.body,
fs.createWriteStream(imageFilePath),
)
return {
message: `Returning new image: ${query}`,
stream: fs.createReadStream(imageFilePath)
}
}
/**
* 이미지를 Unsplash 에서 검색하거나, 이미 있다면 캐시된 이미지를 리턴합니다.
* @param {string} url
*/
function convertURLToImageInfo(url) {
const urlObj = new URL(url, 'http://localhost:5000')
const widthStr = urlObj.searchParams.get('width')
const width = widthStr ? parseInt(widthStr, 10) : 400
return {
query: urlObj.pathname.slice(1),
width,
}
}
const server = http.createServer((req, res) => {
async function main() {
if (!req.url) {
res.statusCode = 400
res.end('Needs URL.')
return
}
const {query, width} = convertURLToImageInfo(req.url)
try {
const {message, stream} = await getCachedImageOrSearchedImage(query)
console.log(message)
await promisify(pipeline) (stream, sharp().resize(width).png(), res)
} catch {
res.statusCode = 400
res.end()
}
}
main()
})
const PORT = 5000
server.listen(PORT, () => {
console.log('The server is listening at port', PORT)
})
높이까지 변경
- main.js
// @ts-check
const fs = require('fs')
const path = require('path')
const http = require('http')
const { createApi } = require('unsplash-js')
const { default: fetch} = require('node-fetch')
const { pipeline } = require('stream')
const { promisify } = require('util')
const sharp = require('sharp')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
})
/**
* @param {string} query
* @returns
*/
async function searchImage(query) {
const result = await unsplash.search.getPhotos({ query})
if (!result.response) {
throw new Error('Failed to search image.')
}
const image = result.response.results[0]
if (!image) {
throw new Error('No image found.')
}
return {
description: image.description || image.alt_description,
url: image.urls.regular,
}
}
/**
* @param {string} query
*/
async function getCachedImageOrSearchedImage(query) {
const imageFilePath = path.resolve(__dirname, `../images/${query}`)
if (fs.existsSync(imageFilePath)) {
return {
message: `Returning cached image: ${query}`,
stream: fs.createReadStream(imageFilePath)
}
}
const result = await searchImage(query)
const resp = await fetch(result.url)
await promisify(pipeline) (
resp.body,
fs.createWriteStream(imageFilePath),
)
return {
message: `Returning new image: ${query}`,
stream: fs.createReadStream(imageFilePath)
}
}
/**
* 이미지를 Unsplash 에서 검색하거나, 이미 있다면 캐시된 이미지를 리턴합니다.
* @param {string} url
*/
function convertURLToImageInfo(url) {
const urlObj = new URL(url, 'http://localhost:5000')
/**
*
* @param {string} name
* @param {number} defaultValue
* @returns
*/
function getSearchParam(name, defaultValue) {
const str = urlObj.searchParams.get(name)
return str ? parseInt(str, 10) : defaultValue
}
const width = getSearchParam('width', 400)
const height = getSearchParam('height', 400)
return {
query: urlObj.pathname.slice(1),
width,
height
}
}
const server = http.createServer((req, res) => {
async function main() {
if (!req.url) {
res.statusCode = 400
res.end('Needs URL.')
return
}
const {query, width, height} = convertURLToImageInfo(req.url)
try {
const {message, stream} = await getCachedImageOrSearchedImage(query)
console.log(message)
await promisify(pipeline) (
stream,
sharp().resize(width, height).png(),
res
)
} catch {
res.statusCode = 400
res.end()
}
}
main()
})
const PORT = 5000
server.listen(PORT, () => {
console.log('The server is listening at port', PORT)
})
fit: "cover",
- contain: 백그라운드 늘리기
- cover: 다 채울 것
- fill
- inside
- outside
노드 이미지 사이즈
npm install image-size@1.0.0
이미지 사이즈를 구해보자.
// @ts-check
const fs = require("fs");
const path = require("path");
const http = require("http");
const { createApi } = require("unsplash-js");
const { default: fetch } = require("node-fetch");
const { pipeline } = require("stream");
const { promisify } = require("util");
const sharp = require("sharp");
const {default: imageSize} = require('image-size')
const unsplash = createApi({
accessKey: process.env.UNSPLASH_API_ACCESS_KEY,
// @ts-ignore
fetch,
});
/**
* @param {string} query
* @returns
*/
async function searchImage(query) {
const result = await unsplash.search.getPhotos({ query });
if (!result.response) {
throw new Error("Failed to search image.");
}
const image = result.response.results[0];
if (!image) {
throw new Error("No image found.");
}
return {
description: image.description || image.alt_description,
url: image.urls.regular,
};
}
/**
* @param {string} query
*/
async function getCachedImageOrSearchedImage(query) {
const imageFilePath = path.resolve(__dirname, `../images/${query}`);
if (fs.existsSync(imageFilePath)) {
return {
message: `Returning cached image: ${query}`,
stream: fs.createReadStream(imageFilePath),
};
}
const result = await searchImage(query);
const resp = await fetch(result.url);
await promisify(pipeline)(resp.body, fs.createWriteStream(imageFilePath));
const size = imageSize(imageFilePath)
return {
message: `Returning new image: ${query}, width: ${size.width}, height: ${size.height}`,
stream: fs.createReadStream(imageFilePath),
};
}
/**
* 이미지를 Unsplash 에서 검색하거나, 이미 있다면 캐시된 이미지를 리턴합니다.
* @param {string} url
*/
function convertURLToImageInfo(url) {
const urlObj = new URL(url, "http://localhost:5000");
/**
*
* @param {string} name
* @param {number} defaultValue
* @returns
*/
function getSearchParam(name, defaultValue) {
const str = urlObj.searchParams.get(name);
return str ? parseInt(str, 10) : defaultValue;
}
const width = getSearchParam("width", 400);
const height = getSearchParam("height", 400);
return {
query: urlObj.pathname.slice(1),
width,
height,
};
}
const server = http.createServer((req, res) => {
async function main() {
if (!req.url) {
res.statusCode = 400;
res.end("Needs URL.");
return;
}
const { query, width, height } = convertURLToImageInfo(req.url);
try {
const { message, stream } = await getCachedImageOrSearchedImage(query);
console.log(message);
await promisify(pipeline)(
stream,
sharp()
.resize(width, height, {
fit: "contain",
background: '#ffffff',
})
.png(),
res
);
} catch {
res.statusCode = 400;
res.end();
}
}
main();
});
const PORT = 5000;
server.listen(PORT, () => {
console.log("The server is listening at port", PORT);
});
Ch 13. 프로젝트: Github 관리 CLI 만들기
01. 요구사항 설정과 설계
CLI 를 지원하는 인터페이스들이 몇가지 있다.
CLI 를 어떻게 만드는지 경험을 해보자.
이슈, 풀 리퀘스트 등의 라벨 관리
node src/main.js 처럼 인자를 넣어보자.
node src/main.js list-bugs 와 같은 명령어를 만들어보자.
tj 의 커맨더: https://github.com/tj/commander.js
공식 깃허브 API: https://github.com/octokit
02. commander로 CLI 뼈대 구축하기
커맨더로 뼈대를 짜보자.
console.log(process.argv)
라이브러리는 직접 공식문서 보면서 따라해보는 것이다.
더 나아가면 소스코드를 보는 것들이 있지만 공식문서를 보는 것 만으로도 충분하다.
npm install commander@7.2.0
옵션은 불린이다.
- main.js
// @ts-check
console.log(process.argv)
const { program } = require('commander')
program.version('0.0.1')
program
.option('-d, --debug', 'output extra debugging')
.option('-s, --small', 'small pizza size')
.option('-p, --pizza-type <type>', 'flavour of pizza')
program.parse(process.argv)
const options = program.opts()
if (options.debug) console.log(options)
console.log('pizza details:')
if (options.small) console.log('- small pizza size')
if (options.pizzaType) console.log(`- ${options.pizzaType}`)
이제 서브 커맨드를 해보자.
const { program } = require('commander')
program.version('0.0.1')
program
.command('List-bugs')
.description('List issues with bug label')
.action(() => {
console.log('List bugs!');
})
program.parse()
명령어는 대소문자를 구분한다 !
action 을 async 로 해보자.
- main.js
// @ts-check
const { program } = require('commander')
const fs = require('fs')
program.version('0.0.1')
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
console.log('Before readFile...')
const result = await fs.promises.readFile('.prettierrc')
console.log('readFile result: ', result)
console.log('List bugs!');
})
program
.command('check-prs')
.description('Check pull request status')
.action(async () => {
console.log('Check PRs!')
})
program.parseAsync()
03. 토큰 발급받고 dotenv를 통해 프로젝트에서 사용하기
GitHub 에 접근해보자.
git remote
git remote -v
repo 는 꼭 체크하자.
환경변수를 .zshrc 나 .bash_profile 에 저장해도 되지만
.env 라는 버전관리가 되지 않는 파일을 사용해도 된다.
.env 파일로 환경변수를 설정해보자.
- .env
GITHUB_ACCESS_TOKEN=ghp_GMqxtHx2nBaf3OsELe*************
- .gitignore
node_modules
.env
- main.js
const { GITHUB_ACCESS_TOKEN } = process.env
또는 환경변수를 dotenv 라는 라이브러리를 사용해도 된다.
dotenv: https://github.com/motdotla/dotenv
const { GITHUB_ACCESS_TOKEN } = process.env
console.log('TOKEN: ', GITHUB_ACCESS_TOKEN)
- dotenv 설치
npm install dotenv@10.0.0
- main.js
require('dotenv').config()
04. octokit 활용해 저장소에 접근해보기
npm install octokit@1.0.5
- main.js
// @ts-check
require('dotenv').config()
const { GITHUB_ACCESS_TOKEN } = process.env
const { program } = require('commander')
const { Octokit } = require('octokit')
program.version('0.0.1')
const octokit = new Octokit({ auth: GITHUB_ACCESS_TOKEN })
program
.command('me')
.description('Check my profile')
.action(async () => {
const {
data: { login },
} = await octokit.rest.users.getAuthenticated()
console.log("Hello, %s", login)
})
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
console.log('List bugs!');
})
program
.command('check-prs')
.description('Check pull request status')
.action(async () => {
console.log('Check PRs!')
})
program.parseAsync()
이제 이슈, 풀리퀘스트 등의 관리를 해보자.
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
const result = await octokit.rest.issues.listForRepo({
owner: 'dev-connor',
repo: 'fc21-cli-study',
})
console.log(result);
})
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
const result = await octokit.rest.issues.listForRepo({
owner: 'dev-connor',
repo: 'fc21-cli-study',
})
result.data.forEach(issue => {
console.log(issue.number, issue.labels)
})
})
- main.js
// @ts-check
require('dotenv').config()
const { GITHUB_ACCESS_TOKEN } = process.env
const { program } = require('commander')
const { Octokit } = require('octokit')
const { isLabeledStatement } = require('typescript')
program.version('0.0.1')
const octokit = new Octokit({ auth: GITHUB_ACCESS_TOKEN })
program
.command('me')
.description('Check my profile')
.action(async () => {
const {
data: { login },
} = await octokit.rest.users.getAuthenticated()
console.log("Hello, %s", login)
})
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
const result = await octokit.rest.issues.listForRepo({
owner: 'dev-connor',
repo: 'fc21-cli-study',
})
const issuesWithBugLabel = result.data.filter(
(issue) =>
issue.labels.find((label) => label.name === 'bug') !== undefined
)
const output = issuesWithBugLabel.map(issue => ({
title: issue.title,
number: issue.number
}))
console.log(output)
})
program
.command('check-prs')
.description('Check pull request status')
.action(async () => {
console.log('Check PRs!')
})
program.parseAsync()
버그 레이블만 붙은 이슈를 잘 가져온다.
05. 커스텀 규칙으로 저장소 관리하기_1
풀 리퀘스트를 모두 검사해서, 만약 너무 diff 가 큰 풀 리퀘스트가 있으면 `too-big` 이라는 레이블을 붙인다.
새로운 브랜치 build-cli 를 생성 후 깃허브로 푸시한다.
구글에 github api compare commits 라 검색하면
compare commit 이라고 문서가 있다.
https://docs.github.com/en/rest/repos
추가된 줄 수, 삭제된 줄 수, 변경된 줄 수를 가져온다.
program
.command('check-prs')
.description('Check pull request status')
.action(async () => {
const result = await octokit.rest.pulls.list({
owner: OWNER,
repo: REPO,
})
result.data.map(pr => {
})
const diffs = await Promise.all(
result.data.map(async pr => ({
number: pr.number,
compare: await octokit.rest.repos.compareCommits({
owner: OWNER,
repo: REPO,
base: pr.base.ref,
head: pr.head.ref,
}),
}))
)
console.log(diffs.map(diff => diff.compare.data.files))
})
레이블이 없는 PR 에 대해서 생성
- main.js
// @ts-check
require('dotenv').config()
const { GITHUB_ACCESS_TOKEN } = process.env
const { program } = require('commander')
const { Octokit } = require('octokit')
const { default: processTailwindFeatures } = require('tailwindcss/lib/processTailwindFeatures')
const { isLabeledStatement } = require('typescript')
program.version('0.0.1')
const octokit = new Octokit({ auth: GITHUB_ACCESS_TOKEN })
const OWNER = 'dev-connor'
const REPO = 'fc21-cli-study'
const LABEL_TOO_BIG = 'too-big'
program
.command('me')
.description('Check my profile')
.action(async () => {
const {
data: { login },
} = await octokit.rest.users.getAuthenticated()
console.log("Hello, %s", login)
})
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
const result = await octokit.rest.issues.listForRepo({
owner: OWNER,
repo: REPO,
labels: 'bug',
})
const issuesWithBugLabel = result.data.filter(
(issue) =>
issue.labels.find((label) => label.name === 'bug') !== undefined
)
const output = issuesWithBugLabel.map(issue => ({
title: issue.title,
number: issue.number
}))
console.log(output)
})
// 풀 리퀘스트를 모두 검사해서, 만약 너무 diff 가 큰 (100줄) 풀 리퀘스트가 있으면 `too-big` 이라는 레이블을 붙인다.
program
.command('check-prs')
.description('Check pull request status')
.action(async () => {
const result = await octokit.rest.pulls.list({
owner: OWNER,
repo: REPO,
})
result.data.map(pr => {
})
const prsWithDiff = await Promise.all(
result.data.map(async pr => ({
labels: pr.labels,
number: pr.number,
compare: await octokit.rest.repos.compareCommits({
owner: OWNER,
repo: REPO,
base: pr.base.ref,
head: pr.head.ref,
}),
}))
)
await Promise.all(prsWithDiff
.map(({compare, ...rest}) => {
const totalChanges = compare.data.files?.reduce(
(sum, file) => sum + file.changes
, 0
)
return {
compare,
totalChanges,
...rest,
}
})
.filter(
(pr) =>
pr && typeof pr.totalChanges === 'number' && pr.totalChanges > 100
)
.map(async ({ labels, number, totalChanges }) => {
console.log('PR', number, 'totalChanges:', totalChanges)
if (!labels.find(label => label.name === LABEL_TOO_BIG)) {
console.log(`Adding ${LABEL_TOO_BIG} label to PR ${number}...`)
return octokit.rest.issues.addLabels({
owner: OWNER,
repo: REPO,
issue_number: number,
labels: [LABEL_TOO_BIG]
})
}
return undefined
})
)
})
program.parseAsync()
프롬프팅 ?
실행하기 전에 되묻는 것
프롬프츠: https://github.com/terkelg/prompts
- 설치
npm install prompts@2.4.1
- main.js
// @ts-check
require('dotenv').config()
const { GITHUB_ACCESS_TOKEN } = process.env
const { program } = require('commander')
const { Octokit } = require('octokit')
const prompts = require('prompts')
program.version('0.0.1')
const octokit = new Octokit({ auth: GITHUB_ACCESS_TOKEN })
const OWNER = 'dev-connor'
const REPO = 'fc21-cli-study'
const LABEL_TOO_BIG = 'too-big'
program
.command('me')
.description('Check my profile')
.action(async () => {
const {
data: { login },
} = await octokit.rest.users.getAuthenticated()
console.log("Hello, %s", login)
})
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
const result = await octokit.rest.issues.listForRepo({
owner: OWNER,
repo: REPO,
labels: 'bug',
})
const issuesWithBugLabel = result.data.filter(
(issue) =>
issue.labels.find((label) => label.name === 'bug') !== undefined
)
const output = issuesWithBugLabel.map(issue => ({
title: issue.title,
number: issue.number
}))
console.log(output)
})
// 풀 리퀘스트를 모두 검사해서, 만약 너무 diff 가 큰 (100줄) 풀 리퀘스트가 있으면 `too-big` 이라는 레이블을 붙인다.
program
.command('check-prs')
.description('Check pull request status')
.action(async () => {
const result = await octokit.rest.pulls.list({
owner: OWNER,
repo: REPO,
})
result.data.map(pr => {
})
const prsWithDiff = await Promise.all(
result.data.map(async pr => ({
labels: pr.labels,
number: pr.number,
compare: await octokit.rest.repos.compareCommits({
owner: OWNER,
repo: REPO,
base: pr.base.ref,
head: pr.head.ref,
}),
}))
)
await Promise.all(prsWithDiff
.map(({compare, ...rest}) => {
const totalChanges = compare.data.files?.reduce(
(sum, file) => sum + file.changes
, 0
)
return {
compare,
totalChanges,
...rest,
}
})
.filter(
(pr) =>
pr && typeof pr.totalChanges === 'number' && pr.totalChanges > 1
)
.map(async ({ labels, number, totalChanges }) => {
console.log('PR', number, 'totalChanges:', totalChanges)
if (!labels.find(label => label.name === LABEL_TOO_BIG)) {
console.log(`Adding ${LABEL_TOO_BIG} label to PR ${number}...`)
const response = await prompts(
{
type: 'confirm',
name: 'shouldContinue',
message: `Do you really want to add label ${LABEL_TOO_BIG} to PR #${number}?`
},
)
if (response.shouldContinue) {
return octokit.rest.issues.addLabels({
owner: OWNER,
repo: REPO,
issue_number: number,
labels: [LABEL_TOO_BIG]
})
}
console.log('Cancelled!')
}
return undefined
})
)
})
program.parseAsync()
프롬프트로 CLI 를 간편하게 만들 수 있다.
터미널 색상 chalk: https://github.com/chalk/chalk
npm install chalk@4.1.1
06. 커스텀 규칙으로 저장소 관리하기_2
버그이슈를 누군가가 만들었다면
버그레이블에 스크린샷이 없을 때 needs-screenshot 레이블 달아주기
마크다운 파싱 패키지: https://github.com/markedjs/marked
npm install marked@2.0.6
패키지를 깔 때 악성코드도 있으므로 철자를 틀리지 말자.
- main.js
program
.command('parse-md').action(() => {
const parsed = marked.Lexer(`
# Title
**Hello**, world!
`)
console.log(parsed)
})
needs screent shot 이 있으면 반응하지 않는다.
- main.js
// @ts-check
require('dotenv').config()
const { GITHUB_ACCESS_TOKEN } = process.env
const { program } = require('commander')
const { Octokit } = require('octokit')
const prompts = require('prompts')
const chalk = require('chalk')
const marked = require('marked')
program.version('0.0.1')
const octokit = new Octokit({ auth: GITHUB_ACCESS_TOKEN })
const OWNER = 'dev-connor'
const REPO = 'fc21-cli-study'
const LABEL_TOO_BIG = 'too-big'
const LABEL_BUG = 'bug'
const LABEL_NEEDS_SCREENSHOT = 'needs-screenshot'
program
.command('me')
.description('Check my profile')
.action(async () => {
const {
data: { login },
} = await octokit.rest.users.getAuthenticated()
console.log("Hello, %s", login)
})
/**
*
* @param {Array<*>} labels
* @param {string} labelName
* @return {boolean}
*/
function hasLabel(labels, labelName) {
return labels.find((label) => label.name === labelName) !== undefined
}
program
.command('list-bugs')
.description('List issues with bug label')
.action(async () => {
const result = await octokit.rest.issues.listForRepo({
owner: OWNER,
repo: REPO,
labels: 'bug',
})
const issuesWithBugLabel = result.data.filter(
(issue) => hasLabel(issue.labels, LABEL_BUG)
)
const output = issuesWithBugLabel.map(issue => ({
title: issue.title,
number: issue.number
}))
console.log(output)
})
// 풀 리퀘스트를 모두 검사해서, 만약 너무 diff 가 큰 (100줄) 풀 리퀘스트가 있으면 `too-big` 이라는 레이블을 붙인다.
program
.command('check-prs')
.description('Check pull request status')
.action(async () => {
const result = await octokit.rest.pulls.list({
owner: OWNER,
repo: REPO,
})
result.data.map(pr => {
})
const prsWithDiff = await Promise.all(
result.data.map(async pr => ({
labels: pr.labels,
number: pr.number,
compare: await octokit.rest.repos.compareCommits({
owner: OWNER,
repo: REPO,
base: pr.base.ref,
head: pr.head.ref,
}),
}))
)
await Promise.all(prsWithDiff
.map(({compare, ...rest}) => {
const totalChanges = compare.data.files?.reduce(
(sum, file) => sum + file.changes
, 0
)
return {
compare,
totalChanges,
...rest,
}
})
.filter(
(pr) =>
pr && typeof pr.totalChanges === 'number' && pr.totalChanges > 1
)
.map(async ({ labels, number, totalChanges }) => {
if (!hasLabel(labels, LABEL_TOO_BIG)) {
console.log(
chalk.greenBright(
`Adding ${LABEL_TOO_BIG} label to PR ${number}...`
)
)
const response = await prompts(
{
type: 'confirm',
name: 'shouldContinue',
message: `Do you really want to add label ${LABEL_TOO_BIG} to PR #${number}?`
},
)
if (response.shouldContinue) {
return octokit.rest.issues.addLabels({
owner: OWNER,
repo: REPO,
issue_number: number,
labels: [LABEL_TOO_BIG]
})
}
console.log('Cancelled!')
}
return undefined
})
)
})
/**
*
* @param {string} md
* @return {boolean}
*/
function isAnyScreenshotInMarkdownDocument(md) {
const tokens = marked.lexer(md)
let didFind = false
marked.walkTokens(tokens, (token) => {
if (token.type === 'image') {
didFind = true
}
})
return didFind
}
// bug 레이블이 달려 있으나, 스크린샷이 없는 이슈에 대해서 needs-screenshot 레이블을 달아주기
program
.command('check-screenshots')
.description('check if any issue is missing screenshot event if it has bug label on it'
)
.action(async() => {
const result = await octokit.rest.issues.listForRepo({
owner: OWNER,
repo: REPO,
labels: 'bug',
})
const issuesWithBugLabel = result.data
// 1. bug 레이블이 있고, 스크린샷이 없음 => needs-screentshot
const issuesWithoutScreenshot = issuesWithBugLabel.filter(
issue =>
!issue.body || !isAnyScreenshotInMarkdownDocument(issue.body) &&
!hasLabel(issue.labels, LABEL_NEEDS_SCREENSHOT)
)
await Promise.all(
issuesWithoutScreenshot.map(async (issue) => {
const shouldContinue = prompts({
type: 'confirm',
name: 'shouldContinue',
message: `Add ${LABEL_NEEDS_SCREENSHOT} to issue #${issue.number}?`,
})
if (shouldContinue) {
await octokit.rest.issues.addLabels({
owner: OWNER,
repo: REPO,
issue_number: issue.number,
labels: [LABEL_NEEDS_SCREENSHOT],
})
}
})
)
// 2. bug 레이블이 있고, needs-screenshot 있는데 스크린샷 있음 => needs-screenshot
const issuesResolved = issuesWithBugLabel.filter(
issue =>
issue.body &&
isAnyScreenshotInMarkdownDocument(issue.body) &&
hasLabel(issue.labels, LABEL_NEEDS_SCREENSHOT)
)
await Promise.all(
issuesResolved.map(async (issue) => {
const shouldConfirm = prompts({
type: 'confirm',
name: 'shouldConfirm',
message: `Remove ${LABEL_NEEDS_SCREENSHOT} from issue #${issue.number}`,
})
if (shouldConfirm) {
await octokit.rest.issues.removeLabel({
owner: OWNER,
repo: REPO,
issue_number: issue.number,
name: LABEL_NEEDS_SCREENSHOT,
})
}
})
)
})
program.parseAsync()
레이블을 지우는 동작은 작동하지 않는다.
나중에 확인해보자.
Ch 14. 나만의 npm 패키지 만들어 사용해보기
01. package.json 다시 살펴보기
npm 패키지는 대부분 오픈소스이다.
누구나 만들고 등록할 수 있다.
강사님 버전은 6.14.12 이다.
npm: https://www.npmjs.com/signup
회원가입을 하자.
package.json 의 구조를 알아보자.
공식문서: https://docs.npmjs.com/cli/v8/configuring-npm/package-json
name
name 이라는 필드가 있다.
패키지의 이름이다.
npm install 뒤에 나온다.
소문자로 작성하면 문제가 생기지 않는다.
name 은 유일해야 한다.
version
package.json 의 메타데이터로 Docs 홈페이지를 구성한다.
이름중복을 피하기 위해 scope 규정을 적용한다.
유저이름이 스코프이다.
{
"name": "@connor_/fc21-test-pkg",
"version": "0.1.0"
}
files
npm 에 어떤 파일만 포함되어야 할지
scripts
scripts 에서 자주 사용하는 명령어를 등록하는 것 외에도 패키지가 설치가 된 후의 스크립트 등을 넣을 수 있다.
homepage
깃허브를 보통 등록한다.
bug
funding
후원도 받을 수 있다.
02. npm 세팅하기
private 을 주면 실수로 나가는 경우가 없게 된다.
npm install ./fc21-test-pkg
내부 npm 은 바로가기로 생성된다.
npm config
userconfig 와 globalconfig 가 있다.
vi /Users/connor/.npmrc
똑같은 설정이 있는 것을 볼 수 있다.
03. 패키지 만들어 게시하고 내 프로젝트에 사용해보기
- 설치
npm install ./fc21-test-pkg
패키지를 만들어 사용해보자.
- fc21-test-pkg/package.json
{
"name": "@connor_/fc21-test-pkg",
"version": "0.1.0",
"main": "main.js"
}
- fc21-test-pkg/main.js
const fn = require('@connor_/fc21-test-pkg')
console.log(fn())
- main.js
const fn = require('@connor_/fc21-test-pkg')
console.log(fn())
- 패키지
async function sleep(ms) {
return new Promise((resolve) => {
setTimeout(() => {
resolve()
}, ms);
})
}
module.exports.sleep = sleep
- main.js
const { sleep } = require('@connor_/fc21-test-pkg')
async function main() {
console.log('before sleep')
await sleep(1000)
console.log('after sleep')
}
main()
이제 npm 에 등록해보자.
배포는 간단하게 npm publish 라고 입력하면 된다.
이제 uninstall 후 다시 설치해보자.
npm upgrade @connor_/fc21-test-pkg
CH15. RDB
01. RDB의 정의
- 널리 알려진 DB 모델
- 스키마와 각종 제약조건 정하고 데이터 저장
- SQL 로 상호작용
02. Postgres 소개와 데이터베이스 셋업
PostgreSQL 은 역사가 깊은 오픈소스 관계형 데이터베이스이다.
30년 가까이 되었다.
다양한 방법으로 다운로드할 수 있다.
다운로드: https://www.postgresql.org/download/
wget 이란 ?
- \d: 데이터베이스 내 관계 확인
- \l or \list: 데이터베이스 조회
- \dt: 테이블
- \df: 함수
- \c DB이름: 다른 DB 접속
- \e: 쿼리 실행
CREATE DATABASE fc21;
유저 생성
CREATE USER myuser WITH ENCRYPTED PASSWORD 'mypass';
권한 부여
GRANT ALL PRIVILEGES ON DATABASE fc21 TO myuser;
꼭 대문자로 작성하고 세미콜론을 붙이자.
비밀번호에는 꼭 작은따옴표를 붙이자.
맥에 설치
brew install postgresql
postgres -V # 버전확인
brew services restart postgresql
참조
명령어: https://kwomy.tistory.com/8
03. pg로 데이터베이스 액세스하기
노드로 postgreSQL 로 접근해보자.
pg 라는 패키지로 접근하면 된다.
pg: https://node-postgres.com/
npm install pg@8.6.0
- main.js
// @ts-check
const { Client } = require('pg')
const client = new Client({
user: 'myuser',
password: 'mypass',
database: 'fc21',
})
async function main() {
await client.connect()
const res = await client.query('SELECT $1::text as message', ['Hello world!'])
console.log(res.rows[0].message) // Hello world!
await client.end()
}
main()
psql -U myuser -d fc21 -h localhost --password
- user
- database
- host
CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR NOT NULL);
INSERT INTO users (name) VALUES ('Coco');
이제 똑같은 작업을 pg 로 해보자.
커맨더와 프롬프츠를 설치해 커맨드라인 명령어를 만들어보자.
npm install commander@7.2.0 prompts@2.4.1
- main.js
// @ts-check
const { Client } = require('pg')
const program = require('commander')
const prompts = require('prompts')
async function connect() {
const client = new Client({
user: 'myuser',
password: 'mypass',
database: 'fc21',
})
await client.connect()
return client
}
program.command('add').action(async () => {
const client = await connect()
const userName = await prompts({
type: 'text',
name: 'userName',
message: 'Provide a user name to insert.',
})
const query = `INSERT INTO users (name) VALUES ('${userName.userName}')`
await client.query(query)
await client.end()
})
program
.command('remove')
.action(async () => {
const client = await connect()
await client.end()
})
program.parseAsync()
- main.js
program
.command('remove')
.action(async () => {
const client = await connect()
const userName = await prompts({
type: 'text',
name: 'userName',
message: 'Provide a user name to delete.',
})
const query = `DELETE FROM users WHERE name = ('${userName.userName}')`
await client.query(query)
await client.end()
})
delete 도 작성해주자.
program.command('list').action(async () => {
const client = await connect()
const query = `SELECT * FROM users`
const result = await client.query(query)
console.log(result)
})
result.rows 를 반환하도록 수정해보자.
const query = `DELETE FROM users WHERE name = '${userName.userName}'`
이렇게 작성하면 SQL injection 이 가능하게 된다.
' OR '' = '
라고 입력하면 모든 유저를 지워버리게 된다.
await client.query(`DELETE FROM users WHERE name = $1::text`, [
userName.userName,
])
- 전체코드
// @ts-check
const { Client } = require('pg')
const program = require('commander')
const prompts = require('prompts')
async function connect() {
const client = new Client({
user: 'myuser',
password: 'mypass',
database: 'fc21',
})
await client.connect()
return client
}
program.command('list').action(async () => {
const client = await connect()
const query = `SELECT * FROM users`
const result = await client.query(query)
console.log(result.rows)
})
program.command('add').action(async () => {
const client = await connect()
const userName = await prompts({
type: 'text',
name: 'userName',
message: 'Provide a user name to insert.',
})
const query = `INSERT INTO users (name) VALUES ($1::text)`
await client.query(query, [userName.userName])
await client.end()
})
program
.command('remove')
.action(async () => {
const client = await connect()
const userName = await prompts({
type: 'text',
name: 'userName',
message: 'Provide a user name to delete.',
})
await client.query(`DELETE FROM users WHERE name = $1::text`, [
userName.userName,
])
await client.end()
})
program.parseAsync()
04. 스키마 관리와 마이그레이션
이번엔 스키마 관리를 ORM 을 통해 해보자.
Sequelize 를 사용해보자.
마이그레이션도 어느정도 제공해준다.
npm install sequelize@6.6.2
// @ts-check
const { Sequelize } = require('sequelize')
async function main() {
const sequelize = new Sequelize({
database: 'fc21',
username: 'myuser',
password: 'mypass',
dialect: 'postgres',
host: 'localhost',
})
await sequelize.authenticate()
await sequelize.close()
}
main()
// @ts-check
const { createPrivateKey } = require('crypto')
const { Sequelize, DataTypes } = require('sequelize')
const { Z_DEFAULT_STRATEGY } = require('zlib')
async function main() {
const sequelize = new Sequelize({
database: 'fc21',
username: 'myuser',
password: 'mypass',
dialect: 'postgres',
host: 'localhost',
})
const User = sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
},
{
timestamps: false,
}
)
await sequelize.sync({
alter: true,
})
await sequelize.authenticate()
await sequelize.close()
}
main()
// @ts-check
const { createPrivateKey } = require('crypto')
const { Sequelize, DataTypes } = require('sequelize')
const { Z_DEFAULT_STRATEGY } = require('zlib')
async function main() {
const sequelize = new Sequelize({
database: 'fc21',
username: 'myuser',
password: 'mypass',
dialect: 'postgres',
host: 'localhost',
})
const User = sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
age: {
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
timestamps: false,
}
)
await sequelize.sync({
alter: true,
})
await sequelize.authenticate()
await sequelize.close()
}
main()
// @ts-check
const { createPrivateKey } = require('crypto')
const { Sequelize, DataTypes } = require('sequelize')
const { Z_DEFAULT_STRATEGY } = require('zlib')
async function main() {
const sequelize = new Sequelize({
database: 'fc21',
username: 'myuser',
password: 'mypass',
dialect: 'postgres',
host: 'localhost',
})
const User = sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
age: {
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
timestamps: false,
}
)
const City = sequelize.define('city',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
}
)
User.belongsTo(City)
await sequelize.sync({
force: true,
})
await sequelize.authenticate()
await sequelize.close()
}
main()
- alter: 스키마가 변경되면 바꿔라.
- force: 모든 테이블을 떨구고 새로 만들어라.
const newUser = User.build({
name: 'Coco',
age: '20'
})
await newUser.save()
// @ts-check
const { createPrivateKey } = require('crypto')
const { Sequelize, DataTypes } = require('sequelize')
const { Z_DEFAULT_STRATEGY } = require('zlib')
async function main() {
const sequelize = new Sequelize({
database: 'fc21',
username: 'myuser',
password: 'mypass',
dialect: 'postgres',
host: 'localhost',
})
const User = sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
age: {
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
timestamps: false,
}
)
const City = sequelize.define('city',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
}, {
timestamps: false,
}
)
User.belongsTo(City)
await sequelize.sync({
force: true,
})
const newCity = City.build({
name: 'Seoul',
}).save()
console.log(newCity)
await User.build({
name: 'Coco',
age: 24,
cityId: (await newCity).getDataValue('id'),
}).save()
await sequelize.authenticate()
await sequelize.close()
}
main()
sync 대신에 마이그레이션을 사용해볼 것이다.
npm install sequelize-cli@6.2.0
migrations 문서: https://sequelize.org/docs/v6/other-topics/migrations/
npm run seq init
- config.json
{
"development": {
"username": "myuser",
"password": "mypass",
"database": "fc21",
"host": "localhost",
"dialect": "postgres"
}
}
migrations 파일을 여러개 만드는 것 부터 시작된다.
up, down callback 을 정의한다.
업 쿼리들을 모두 실행하면 migrations 이 끝난 것이다.
- migrations/현재날짜-initialize.js
// @ts-check
module.exports = {
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import('sequelize')} Sequelize
*/
up: async (queryInterface, Sequelize) => {
/**
* Add altering commands here.
*
* Example:
*/
await queryInterface.createTable('users', { id: Sequelize.INTEGER });
},
down: async (queryInterface, Sequelize) => {
/**
* Add reverting commands here.
*
* Example:
*/
await queryInterface.dropTable('users');
}
};
npm run seq db:migrate
postgres 에서 대소문자가 섞여있는 것을 조회할 때에는 큰따옴표로 감싸야 한다.
npm run seq db:migrate:undo
프로덕션을 마이그레이션에 넣어 항상 통제된 상황에 두도록 한다.
CI 등에 의해 up query 가 실행된다.
훨씬 안전하게 migrations 의 기능을 사용한다.
복잡한 경우를 적용해보자.
Sequelize 가 생성된 파일이름의 날짜로 어떤 파일부터 읽어야 할 지 알 수 있다.
- initialize.js
// @ts-check
module.exports = {
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import('sequelize')} Sequelize
*/
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('users', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: Sequelize.STRING,
allowNull: false,
}
})
},
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import('sequelize')} Sequelize
*/
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('users');
}
}
- add-cities.js
// @ts-check
module.exports = {
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import('sequelize')} Sequelize
*/
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('cities', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
})
await queryInterface.addColumn('users', 'cityId', {
type: Sequelize.DataTypes.INTEGER,
references: {
model: 'cities',
key: 'id',
},
})
},
/**
*
* @param {import('sequelize').QueryInterface} queryInterface
* @param {import('sequelize')} Sequelize
*/
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('users', 'cityId')
await queryInterface.dropTable('cities');
}
}
칼럼명이 안나온다면 \t 명령어로 tuples only 를 끄자.
장기간 수업을 듣지 못해 로그아웃이 되버렸다.
다시 DB 에 접속해보자.
- 데이터베이스 접속
psql -U myuser -d fc21 -h localhost --password
\d cities
\d users
npm run seq db:migrate:undo
DB 구조를 롤백 등의 작업이 가능했다.
production 에서는 migration 을 꼭 하는게 좋을 것 같다.
production 과 local 스키마가 갈라질 수 있다.
CH16. GraphQL
01. GraphQL의 기초 개념 살펴보기
- 신규 API 규격이다.
- type system 을 기본적으로 갖추고 있어 REST 보다 훨씬 개발과정이 안정적이다.
- Apollo, Prisma 등 방대하고 강력한 오픈소스 툴들로 양질의 개발자 경험 개선을 기대할 수 있다.
- 쿼리의 형태가 매우 자유롭기 때문에 클라이언트 개발 시 매우 편리합니다.
Query
- Query 는 데이터 요청에 사용됩니다.
- REST 의 GET 과 같습니다.
Mutation
- Mutation 은 변경에 사용됩니다.
- REST 의 POST, DELETE, UPDATE 등과 같습니다.
GraphQL SDL (Schema Definition Language)
02. GraphQL JS:node 구현체 살펴보기
오픈소스는 어떤게 있나 확인해보자.
공식 사이트: https://graphql.org/
메인이 되는 언어는 자바스크립트이다.
자바스크립트 레퍼런스: https://github.com/graphql/graphql-js
GraphQL 라이브러리를 설치하고 사용하면 된다.
쿼리를 파싱해서 처리하는 것이 라이브러리에 다 들어있다.
표준 구현체이다.
아폴로 서버가 있다.
아폴로는 GraphQL 은 클라이언트, 서버에서 쓸 수 있도록 도와주는 방대한 라이브러리이다.
서버 구현을 위한 패키지이다.
아폴로 레퍼런스: https://github.com/apollographql/apollo-server
타입 definition 을 문자열 형태로 한다.
아폴로가 더 편하다.
우리는 아폴로를 사용할 것이다.
prisma: https://www.prisma.io/
데이터베이스와 한 몸뚱이로 되어있는 GraphQL 서버이다.
migration 도 프리즈마를 통해, 쿼리도 가능하다.
하나의 솔루션이 된다.
PostGraphile: https://www.graphile.org/
DB 의 정보를 다 읽어내서 GraphQL 서버로 바꿔준다.
DB 만 있으면 GraphQL 서버를 만들 수 있다.
보안처리는 신경써야 한다.
빠른속도로 개발할 수 있도록 도와준다.
awesome-graphql: https://github.com/chentsulin/awesome-graphql
graphql 생태계가 거대하다는 것을 볼 수 있다.
이 리스트만 봐도 graphql 을 잘 사용할 수 있다.
03. GraphQL JS:node 구현체 살펴보기
아폴로 서버로 만들어보자.
아폴로 공식문서: https://www.apollographql.com/docs/
문서를 따라 서버를 만들어보자.
https://www.apollographql.com/docs/apollo-server/getting-started
npm install apollo-server@2.25.0 graphql@15.5.0
- .gql.js
// @ts-check
const { ApolloServer, gql } = require('apollo-server')
// A schema is a collection of type definitions (hence "typeDefs")
// that together define the "shape" of queries that are executed against
// your data.
const typeDefs = gql`
# Comments in GraphQL strings (such as this one) start with the hash (#) symbol.
# This "Book" type defines the queryable fields for every book in our data source.
type Book {
title: String
author: String
}
# The "Query" type is special: it lists all of the available queries that
# clients can execute, along with the return type for each. In this
# case, the "books" query returns an array of zero or more Books (defined above).
type Query {
books: [Book]
}
`
플러그인을 설치해보자.
노드몬이 설치되있지 않다면 설치하자.
npm install nodemon@2.0.7
- package.json
"scripts": {
"seq": "sequelize-cli",
"server": "nodemon src/gql.js"
},
- gql.js
// @ts-check
const { ApolloServer, gql } = require('apollo-server')
// A schema is a collection of type definitions (hence "typeDefs")
// that together define the "shape" of queries that are executed against
// your data.
const typeDefs = gql`
# Comments in GraphQL strings (such as this one) start with the hash (#) symbol.
# This "Book" type defines the queryable fields for every book in our data source.
type Book {
title: String
author: String
}
# The "Query" type is special: it lists all of the available queries that
# clients can execute, along with the return type for each. In this
# case, the "books" query returns an array of zero or more Books (defined above).
type Query {
books: [Book]
}
`
const books = [
{
title: 'The Awakening',
author: 'Kate Chopin',
},
{
title: 'City of Glass',
author: 'Paul Auster',
},
]
// Resolvers define the technique for fetching the types defined in the
// schema. This resolver retrieves books from the "books" array above.
const resolvers = {
Query: {
books: () => books,
},
}
// const {
// ApolloServerPluginLandingPageLocalDefault
// } = require('apollo-server-core')
// The ApolloServer constructor requires two parameters: your schema
// definition and your set of resolvers.
const server = new ApolloServer({
typeDefs,
resolvers,
// csrfPrevention: true,
// cache: 'bounded',
/**
* What's up with this embed: true option?
* These are our recommended settings for using AS;
* they aren't the defaults in AS3 for backwards-compatibility reasons but
* will be the defaults in AS4. For production environments, use
* ApolloServerPluginLandingPageProductionDefault instead.
**/
// plugins: [
// ApolloServerPluginLandingPageLocalDefault({ embed: true }),
// ],
})
// The `listen` method launches a web server.
server.listen(5000).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
})
localhost:5000 으로 접속하면
GraphQL playground 라는 페이지가 떠있다.
오른쪽 스키마를 누르면 스키마를 확인할 수 있다.
Document 도 볼 수 있다.
서버가 resolvers 라는 개념으로 되었다.
type Query 가 대응된다.
resolvers 를 아래 코드에서
const resolvers = {
Query: {
books: () => books,
},
}
아래코드로 변경한다면
const resolvers = {
Query: {
books: () => [
{
title: 'Test Book',
author: 'Test Author',
}
],
},
}
GraphQL 도 argument 를 받을 수 있다.
- 전체코드
// @ts-check
const { ApolloServer, gql } = require('apollo-server')
// A schema is a collection of type definitions (hence "typeDefs")
// that together define the "shape" of queries that are executed against
// your data.
const typeDefs = gql`
# Comments in GraphQL strings (such as this one) start with the hash (#) symbol.
# This "Book" type defines the queryable fields for every book in our data source.
type Book {
title: String
author: String
}
# The "Query" type is special: it lists all of the available queries that
# clients can execute, along with the return type for each. In this
# case, the "books" query returns an array of zero or more Books (defined above).
type Query {
books(search: String): [Book]
}
`
const books = [
{
title: 'The Awakening',
author: 'Kate Chopin',
},
{
title: 'City of Glass',
author: 'Paul Auster',
},
{
title: 'Mastering NodeJS',
author: 'JH',
},
{
title: 'Mastering JS',
author: 'HJ',
},
]
// Resolvers define the technique for fetching the types defined in the
// schema. This resolver retrieves books from the "books" array above.
const resolvers = {
Query: {
books: (_, {search}) =>
books.filter(({title}) => title.includes(search)),
},
}
// const {
// ApolloServerPluginLandingPageLocalDefault
// } = require('apollo-server-core')
// The ApolloServer constructor requires two parameters: your schema
// definition and your set of resolvers.
const server = new ApolloServer({
typeDefs,
resolvers,
// csrfPrevention: true,
// cache: 'bounded',
/**
* What's up with this embed: true option?
* These are our recommended settings for using AS;
* they aren't the defaults in AS3 for backwards-compatibility reasons but
* will be the defaults in AS4. For production environments, use
* ApolloServerPluginLandingPageProductionDefault instead.
**/
// plugins: [
// ApolloServerPluginLandingPageLocalDefault({ embed: true }),
// ],
})
// The `listen` method launches a web server.
server.listen(5000).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
})
- addBook API
// @ts-check
const { ApolloServer, gql } = require('apollo-server')
// A schema is a collection of type definitions (hence "typeDefs")
// that together define the "shape" of queries that are executed against
// your data.
const typeDefs = gql`
# Comments in GraphQL strings (such as this one) start with the hash (#) symbol.
# This "Book" type defines the queryable fields for every book in our data source.
type Book {
title: String
author: String
}
# The "Query" type is special: it lists all of the available queries that
# clients can execute, along with the return type for each. In this
# case, the "books" query returns an array of zero or more Books (defined above).
type Query {
books(search: String): [Book]
}
type Mutation {
addBook(title: String!, author: String!): Book
}
`
const books = [
{
title: 'The Awakening',
author: 'Kate Chopin',
},
{
title: 'City of Glass',
author: 'Paul Auster',
},
{
title: 'Mastering NodeJS',
author: 'JH',
},
{
title: 'Mastering JS',
author: 'HJ',
},
]
// Resolvers define the technique for fetching the types defined in the
// schema. This resolver retrieves books from the "books" array above.
/**
* @type {import('apollo-server').IResolvers}
*/
const resolvers = {
Query: {
books: (_, {search}) =>
search ? books.filter(({title}) => title.includes(search)) : books,
},
Mutation: {
addBook: (_, {title, author}) => {
const newBook = {
title,
author,
}
books.push(newBook)
return newBook
},
},
}
// const {
// ApolloServerPluginLandingPageLocalDefault
// } = require('apollo-server-core')
// The ApolloServer constructor requires two parameters: your schema
// definition and your set of resolvers.
const server = new ApolloServer({
typeDefs,
resolvers,
// csrfPrevention: true,
// cache: 'bounded',
/**
* What's up with this embed: true option?
* These are our recommended settings for using AS;
* they aren't the defaults in AS3 for backwards-compatibility reasons but
* will be the defaults in AS4. For production environments, use
* ApolloServerPluginLandingPageProductionDefault instead.
**/
// plugins: [
// ApolloServerPluginLandingPageLocalDefault({ embed: true }),
// ],
})
// The `listen` method launches a web server.
server.listen(5000).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
})
httpie 로 책을 추가해보자.
http localhost:5000/?query={books{title}}
난 잘 나오지 않네..
http localhost:5000/?query={books{title,author}}
로 사용하고 싶지만 , 는 URL safe 하지 않으므로 인식하지 못한다.
node 를 실행해 다음 명령어를 입력해보자.
encodeURIComponent('{books{title,author}}')
encodeURIComponent('{books{title%2Cauthor}}')
로 요청을 보내면 정상적으로 간다.
기본적으로 POST 요청만 보낸다.
http post :5000 query="{books{title}}" --print=hHbB
query 라는 key 로 보냈다.
value 는 GraphQL 도큐먼트로 보낸다.
POST 와 GET 으로 보낼 수 있다.
엔드포인트가 하나이다.
GraphQL 의 아폴로 클라이언트를 잘 선택해서 하면 된다.
테스트가 필요하면 HTTP 요청을 보내면 된다.
04. GraphQL JS:node 구현체 살펴보기
뒷단 DB 까지 물려보자.
main.js 를 sequelize.js 로 파일명을 변경하자.
// @ts-check
const { createPrivateKey } = require('crypto')
const { Sequelize, DataTypes } = require('sequelize')
const { Z_DEFAULT_STRATEGY } = require('zlib')
const sequelize = new Sequelize({
database: 'fc21',
username: 'myuser',
password: 'mypass',
dialect: 'postgres',
host: 'localhost',
})
const User = sequelize.define('user', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
age: {
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
timestamps: false,
}
)
const City = sequelize.define('city',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
}, {
timestamps: false,
}
)
User.belongsTo(City)
module.exports = {
sequelize,
User,
City,
}
나에게 age 칼럼이 없었다.
migration 을 생성해보자.
npm run seq -- migration:generate --name add-age-column
- 서버 실행 시 기본 테이블 설정 - gql.js
async function main() {
await sequelize.sync({ force: true })
const seoul = await City.build({
name: 'Seoul'
}).save()
await User.build({
age: 26,
name: 'Coco',
cityId: seoul.getDataValue('id'),
}).save()
await User.build({
age: 30,
name: 'Eoeo',
}).save()
const server = new ApolloServer({ typeDefs, resolvers })
server.listen(5000).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
})
}
main()
type resolver 를 만들어줘야 한다.
아래 코드를 추가하니 city 가 잘 나온다.
User: {
city: async (user) => {
console.log(user)
return City.findOne({
where: {
id: user.cityId
}
})
}
}
- gql.js 전체코드
// @ts-check
const { ApolloServer, gql } = require('apollo-server')
const { ParameterDescriptionMessage } = require('pg-protocol/dist/messages')
const {sequelize, User, City} = require('./sequelize')
const typeDefs = gql`
type User {
id: Int!
name: String!
age: Int!
city: City
}
type City {
id: Int!
name: String!
}
type Query {
users: [User]
}
`
/**
* @type {import('apollo-server').IResolvers}
*/
const resolvers = {
Query: {
users: async () => User.findAll()
},
User: {
city: async (user) => {
console.log(user)
return City.findOne({
where: {
id: user.cityId
}
})
}
}
}
async function main() {
await sequelize.sync({ force: true })
const seoul = await City.build({
name: 'Seoul'
}).save()
await User.build({
age: 26,
name: 'Coco',
cityId: seoul.getDataValue('id'),
}).save()
await User.build({
age: 30,
name: 'Eoeo',
}).save()
const server = new ApolloServer({ typeDefs, resolvers })
server.listen(5000).then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
})
}
main()
- city 안에 users 를 넣어보자.
const typeDefs = gql`
type User {
id: Int!
name: String!
age: Int!
city: City
}
type City {
id: Int!
name: String!
users: [User]
}
type Query {
users: [User]
}
`
/**
* @type {import('apollo-server').IResolvers}
*/
const resolvers = {
Query: {
users: async () => User.findAll()
},
User: {
city: async (user) => {
console.log(user)
return City.findOne({
where: {
id: user.cityId
}
})
}
},
City: {
users: async (city) => {
return User.findAll({
where: {
cityId: city.id,
}
})
}
}
}
PostgresQL 과 GraphQL 을 같이 해보았다.
CH17. OAuth & JWT
01. OAuth 개요
기본적인 계정정보를 가져올 뿐만 아니라 글 쓰기 등도 할 수 있다.
02. Facebook OAuth 플로우를 따라 직접 짜보기
페이스북 계정으로 로그인을 해보자.
페이스북에 개발자로 등록하자.
페이스북 개발자: https://developers.facebook.com/async/registration
내 앱에 들어가면 아래와 같은 화면을 볼 수 있다.
- 사이트 URL: http://localhost:5000/
아래 레포지토리를 클론한다.
https://github.com/JeongHoJeong/node-oauth-example
- 라이브러리를 설치하자.
npm install
index.pug 의
script(src="/public/fb.js")
로 클라이언트 코드로 사용하고 있다.
- 페이스북 코드
<script>
window.fbAsyncInit = function() {
FB.init({
appId : '{your-app-id}',
cookie : true,
xfbml : true,
version : '{api-version}'
});
FB.AppEvents.logPageView();
};
(function(d, s, id){
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) {return;}
js = d.createElement(s); js.id = id;
js.src = "https://connect.facebook.net/en_US/sdk.js";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));
</script>
- public/fb.js
window.fbAsyncInit = () => {
FB.init({
appId : '{your-app-id}',
cookie : true,
xfbml : true,
version : '{api-version}'
})
FB.AppEvents.logPageView()
}
((d, s, id) => {
const fjs = d.getElementsByTagName(s)[0]
if (d.getElementById(id)) {
return
}
const js = d.createElement(s); js.id = id
js.src = "https://connect.facebook.net/en_US/sdk.js"
fjs.parentNode.insertBefore(js, fjs)
})(document, 'script', 'facebook-jssdk')
클라이언트 단이라 환경변수가 넘어올 수 없다.
window.fbAsyncInit = () => {
FB.init({
appId : APP_CONFIG.FB_APP_ID,
cookie : true,
xfbml : true,
version : '{api-version}'
})
npm install dotenv@10.0.0
로그인 해서 access token 과 display name 이 필요하다.
페이스북 문서: https://developers.facebook.com/docs/facebook-login/web?locale=ko\_KR
FB.login(function(response) {
// handle the response
}, {scope: 'public_profile,email'});
두 가지 권한을 가진 액세스 토큰 발급
- fb.js
window.fbAsyncInit = () => {
FB.init({
appId : APP_CONFIG.FB_APP_ID,
cookie : true,
xfbml : true,
version : 'v10.0',
})
FB.login(
(response) => {
// handle the response
console.log(response)
},
{scope: 'public_profile,email'}
)
}
((d, s, id) => {
const fjs = d.getElementsByTagName(s)[0]
if (d.getElementById(id)) {
return
}
const js = d.createElement(s); js.id = id
js.src = "https://connect.facebook.net/en_US/sdk.js"
fjs.parentNode.insertBefore(js, fjs)
})(document, 'script', 'facebook-jssdk')
- index.pug
a#fb-login.rounded.inline-block.bg-blue-500.text-white.text-base.p-4.cursor-pointer Login with Facebook
- fb.js
document.getElementById('fb-login').addEventListener('click', () => {
FB.login(
(response) => {
// handle the response
console.log(response)
},
{scope: 'public_profile,email'}
)
})
}
ngrok: https://ngrok.com/
ngrok 을 이용해 https 를 사용하자.
- 설치
brew install ngrok/ngrok/ngrok
- 계정 연결
ngrok config add-authtoken 2EzEzwN19kmf6CBuU3VTPsKvwmb_KF**********
터미널에 다음 명령어를 입력한다.
- 포워딩
ngrok http {포트번호}
- 에러
appId 를 인식하지 못해서 그렇습니다.
- main.js
// @ts-check
require('dotenv').config()
const app = require('./app')
const PORT = 5000
app.listen(PORT, () => {
console.log(`The Express server is listening at port: ${PORT}`)
})
- fb.js
window.fbAsyncInit = () => {
FB.init({
appId : APP_CONFIG.FB_APP_ID,
// cookie : true,
// xfbml : true,
version : 'v14.0',
})
document.getElementById('fb-login').addEventListener('click', () => {
FB.login(
(response) => {
// handle the response
console.log(response)
},
{scope: 'public_profile,email'}
)
})
}
((d, s, id) => {
const fjs = d.getElementsByTagName(s)[0]
if (d.getElementById(id)) {
return
}
const js = d.createElement(s)
js.id = id
js.src = "https://connect.facebook.net/en_US/sdk.js"
fjs.parentNode.insertBefore(js, fjs)
})(document, 'script', 'facebook-jssdk')
서버에 userID 를 저장하면 된다.
우리 서비스 액세스 토큰과 페이스북 액세스 토큰이 있다.
- fb.js
디버그 토큰 문서:
https://developers.facebook.com/docs/graph-api/reference/v10.0/debug_token
- fb.js
const FB_CLIENT_SECRET = process.env.FB_CLIENT_SECRET
앱 시크릿 코드를 주입해준다.
- access token 생성
curl -X GET "https://graph.facebook.com/oauth/access_token
?client_id={your-app-id}
&client_secret={your-app-secret}
&grant_type=client_credentials"
- 디버그 토큰
GET /v15.0/debug_token?input_token={input-token} HTTP/1.1
Host: graph.facebook.com
- public.fb.js
document.getElementById('fb-login').addEventListener('click', () => {
FB.login(
(response) => {
// handle the response
console.log(response)
fetch(
`/users/auth/facebook?access_token=${response.authResponse.accessToken}`,
{
method: 'POST',
}
)
},
{scope: 'public_profile,email'}
)
})
- src/fb.js
/* eslint-disable prefer-destructuring */
const {default: fetch} = require('node-fetch')
const { flagEnabled } = require('tailwindcss/lib/featureFlags')
/** @type {string} */
const FB_APP_ID = process.env.FB_APP_ID
/** @type {string} */
const FB_CLIENT_SECRET = process.env.FB_CLIENT_SECRET
/**
* @param {string} facebookId
* @returns {Promise<string>}
*/
async function createUserWithFacebookIdAndGetId(facebookId) {
// TOOD: implement it
}
/**
* @param {string} accessToken
* @returns {Promise<string>}
*/
async function getFacebookIdFromAccessToken(accessToken) {
// TODO: implement the function using Facebook API
// https://developers.facebook.com/docs/facebook-login/access-tokens/#generating-an-app-access-token
// https://developers.facebook.com/docs/graph-api/reference/v10.0/debug_token
const appAccessTokenReq = await fetch(
`https://graph.facebook.com/oauth/access_token?${FB_APP_ID}&client_secret=${FB_CLIENT_SECRET}&grant_type=client_credentials`
)
const appAccessToken = (await appAccessTokenReq.json()).accessToken
console.log(appAccessToken)
const debugReq = await fetch(
`https://graph.facebook.com/debug_token?input_token=${accessToken}&access_token=${appAccessToken}`
)
const debugResult = await debugReq.json()
console.log(debugResult)
return debugResult.data.user_id
}
/**
* @param {string} facebookId
* @returns {Promise<string | undefined>}
*/
async function getUserIdWithFacebookId(facebookId) {
// TODO: implement it
}
/**
* facebook 토큰을 검증하고, 해당 검증 결과로부터 우리 서비스의 유저를 만들거나,
* 혹은 이미 있는 유저를 가져와서, 그 유저의 액세스 토큰을 돌려줍니다.
* @param {string} token
*/
async function getUserAccessTokenForFacebookAccessToken(token) {
// TODO: implement it
getFacebookIdFromAccessToken(token)
}
module.exports = {
FB_APP_ID,
FB_CLIENT_SECRET,
getFacebookIdFromAccessToken,
getUserIdWithFacebookId,
getUserAccessTokenForFacebookAccessToken,
}
안되서 일단 넘어간다.
03. 쿠키와 JWT를 활용한 유저 인증
DB 는 저번에 mongo DB 를 사용해보자.
uuid: https://www.npmjs.com/package/uuid
깃허브: https://github.com/uuidjs/uuid/actions?query=workflow%3ABrowser
- 설치
npm install uuid
- src/fb.js
/* eslint-disable prefer-destructuring */
const {default: fetch} = require('node-fetch')
const {v4: uuidv4 } = require('uuid')
const { flagEnabled } = require('tailwindcss/lib/featureFlags')
const { getUsersCollection } = require('./mongo')
/** @type {string} */
const FB_APP_ID = process.env.FB_APP_ID
/** @type {string} */
const FB_CLIENT_SECRET = process.env.FB_CLIENT_SECRET
/**
* @param {string} facebookId
* @returns {Promise<string>}
*/
async function createUserWithFacebookIdAndGetId(facebookId) {
// TOOD: implement it
const users = await getUsersCollection()
const userId = uuidv4()
const user = await users.insertOne({
id: userId,
facebookId,
})
return userId
}
- src/fb.js
/* eslint-disable prefer-destructuring */
const {default: fetch} = require('node-fetch')
const {v4: uuidv4 } = require('uuid')
const { flagEnabled } = require('tailwindcss/lib/featureFlags')
const { getUsersCollection } = require('./mongo')
const { getAccessTokenForUserId } = require('./auth')
const { use } = require('passport')
/** @type {string} */
const FB_APP_ID = process.env.FB_APP_ID
/** @type {string} */
const FB_CLIENT_SECRET = process.env.FB_CLIENT_SECRET
/**
* @param {string} facebookId
* @returns {Promise<string>}
*/
async function createUserWithFacebookIdAndGetId(facebookId) {
// TOOD: implement it
const users = await getUsersCollection()
const userId = uuidv4()
const user = await users.insertOne({
id: userId,
facebookId,
})
return userId
}
/**
* @param {string} accessToken
* @returns {Promise<string>}
*/
async function getFacebookIdFromAccessToken(accessToken) {
// TODO: implement the function using Facebook API
// https://developers.facebook.com/docs/facebook-login/access-tokens/#generating-an-app-access-token
// https://developers.facebook.com/docs/graph-api/reference/v10.0/debug_token
const appAccessTokenReq = await fetch(
`https://graph.facebook.com/oauth/access_token?client_id=${FB_APP_ID}&client_secret=${FB_CLIENT_SECRET}&grant_type=client_credentials`
)
const appAccessToken = (await appAccessTokenReq.json()).accessToken
console.log(appAccessToken)
const debugReq = await fetch(
`https://graph.facebook.com/debug_token?input_token=${accessToken}&access_token=${appAccessToken}`
)
const debugResult = await debugReq.json()
console.log(debugResult)
if (debugResult.data.app_id !== FB_APP_ID) {
throw new Error('Not a valid access token.')
}
return debugResult.data.user_id
}
/**
* @param {string} facebookId
* @returns {Promise<string | undefined>}
*/
async function getUserIdWithFacebookId(facebookId) {
// TODO: implement it
const users = await getUsersCollection()
const user = users.findOne({
facebookId,
})
if (user) {
return user.id
}
return undefined
}
/**
* facebook 토큰을 검증하고, 해당 검증 결과로부터 우리 서비스의 유저를 만들거나,
* 혹은 이미 있는 유저를 가져와서, 그 유저의 액세스 토큰을 돌려줍니다.
* @param {string} token
*/
async function getUserAccessTokenForFacebookAccessToken(token) {
// TODO: implement it
const facebookId = await getFacebookIdFromAccessToken(token)
const existingUserId = await getUserIdWithFacebookId(facebookId)
// 2. 해당 Facebook ID 에 해당하는 유저가 데이터베이스에 있는 경우
if (existingUserId) {
return getAccessTokenForUserId(existingUserId)
}
// 1. 해당 Facebook ID 에 해당하는 유저가 데이터베이스에 없는 경우
const userId = await createUserWithFacebookIdAndGetId(facebookId)
return getAccessTokenForUserId(userId)
}
module.exports = {
FB_APP_ID,
FB_CLIENT_SECRET,
getFacebookIdFromAccessToken,
getUserIdWithFacebookId,
getUserAccessTokenForFacebookAccessToken,
}
jwt: https://www.npmjs.com/package/jsonwebtoken
깃허브: https://github.com/auth0/node-jsonwebtoken
- 설치
npm install jsonwebtoken
- auth.js
// @ts-check
const jwt = require('jsonwebtoken')
const {SERVER_SECRET} = process.env
async function signJWT(value) {
return new Promise((resolve, reject) => {
jwt.sign(value, SERVER_SECRET, {algorithm: 'RS256'}, (err, encoded) => {
if (err) {
reject(err)
} else {
resolve(encoded)
}
})
})
}
async function verifyJWT(token) {
return new Promise((resolve, reject) => {
jwt.verify(value, SERVER_SECRET, (err, value) => {
if (err) {
reject(err)
} else {
resolve(value)
}
})
})
}
/**
* @param {string} userId
*/
async function getAccessTokenForUserId(userId) {
return signJWT(userId)
}
module.exports = {
getAccessTokenForUserId,
}
httpOnly: true 를 해줘야 자바스크립트로 접근할 수 있다.
- app.js
// @ts-check
const express = require('express')
const cookieParser = require('cookie-parser')
const app = express()
app.use(express.json())
app.set('views', 'src/views')
app.set('view engine', 'pug')
const userRouter = require('./routers/user')
const mainRouter = require('./routers/main')
const setupPassportFBAuth = require('./passport-auth-fb')
const { verifyJWT } = require('./jwt')
const { getUsersCollection } = require('./mongo')
app.use(cookieParser())
app.use(async (req, res, next) => {
/* eslint-disable camelcase */
const { access_token } = req.cookies
if (access_token) {
/* @type {string} */
try {
const userId = await verifyJWT(access_token)
if (userId) {
const users = await getUsersCollection()
const user = await users.findOne({
id: userId,
})
if (user) {
// @ts-ignore
req.userId = user.id
}
}
} catch (e) {
console.log('Invalid token', e)
}
// TODO: implement here
}
next()
})
setupPassportFBAuth(app)
app.use('/users', userRouter)
app.use('/public', express.static('src/public'))
app.use('/', mainRouter)
// @ts-ignore
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.statusCode = err.statusCode || 500
res.send(err.message)
})
module.exports = app
- 로그아웃 - main.js
router.get('/logout', (req, res) => {
// TODO: implement
res.clearCookie('access_token')
res.redirect('/')
})
04. 프로필 사진 저장하기
페이스북 문서: https://developers.facebook.com/docs/graph-api/reference/v10.0/profile
참고하면 좋을 것 같다.
https://developers.facebook.com/docs/graph-api/overview
Ch 17-04. 4:43 까지 보고 홀딩...
05. Passport로 구현해보기
Ch18. 프로젝트 만들기
01. 요구사항 설정하기
블로그 포스팅 앱
웹 앱을 만들어 볼 것 이다.
- 이메일 인증
- 비밀번호 초기화
02. API와 아키텍처 설계, 데이터베이스 모델링
플로우 별 처리 과정
- OAuth flow
- Naver
- Kakao
- OAuth provider 에 개발자 계정을 만들고 설정을 해야 함
- access token -> (platformUserId, platform) -> 회원가입/로그인 처리
- HTTPS 적용 안되어 있으면 OAuth 잘 안 된다. -> ngrok 으로 일단 처리
이메일 가입
- 인증 상태를 확인할 필요가 있음 -> 유저 정보에 'verified' 라는 필드가 있어야 함
- 'verified' 필드가 'false' 이면 정상적인 활동 불가능
- 이메일 인증은 특수한 코드와 함께 이메일을 보내서 그 링크로 접속했을 때만 인증이 되게 처리
- 다음 링크로 들어와 인증을 마무리해주세요: https://somewhere.com/verify\_email?code=abcde-defsd-123-sdsd
- 위 링크로 'GET' 해서 들어오게 하면 'verified' 를 'true' 로 바꿔줌
- AWS (Amazon Web Services) -> SES (Simple Email Service) 로 인증 메일을 보낼 것.
- 비밀번호 초기화
- 비밀번호 찾기는 지원하지 않습니다. 찾기가 가능하다는 것은 양방향 반환 (복호화, 암호화) 이 가능하거나, plain text 로 저장이 되어 있다는 뜻. 보안 상 위험하다. 해시 펑션 (one-way function) 을 사용해 원래 암호는 매우 알기 어려운. 사용해 해시된 값을 데이터베이스에 저장.
- 유저가 처음 가입한 해당 이메일로 인증 메일과 비슷하게 초기화 링크를 담은 메일을 보냅니다.
- 해당 링크를 타고 들어오면 그때 등록해두었던 비밀번호 (바꾸고자 하는 비밀번호) 로 기존 비밀번호를 갱신시킵니다.
배포
AWS 를 사용해서 배포합니다.
- EC2 (server)
- git 레포지토리를 clone 해서 배포
- HTTPS 지원 - Amazon 인증서 (단말간 암호화 방식)
- ELB (Elastic Load Balancer) 를 사용해 여기에 인증서를 물리고, ELB 가 뒤의 EC2 를 바라보게 함.
- SES 를 통해 메일 처리
- 데이터베이스 MongoDB 를 사용
03. 프로젝트 셋업
예제 레포지토리: https://github.com/JeongHoJeong/fastcampus-2021-node-project
다음을 클론하자.
- 설치
npm install
라이브러리도 설치하자.
- ESLint
- Prettier
- Tailwind CSS
- 서버 실행
npm run server
ngrok http 5000
04. 네이버, 카카오 OAuth 설정하기
네이버 개발자: https://developers.naver.com/main/
kakao developers: https://developers.kakao.com/
페이스북 디벨로퍼스: https://developers.facebook.com/
public/kakao.js 가 클라이언트 쪽 코드이다.
- Mongo DB 연결
05. 유저 이메일 로그인, 인증과 기초 보안
06. 게시글 CRUD
07. 웹 사이트 UI 작업하기
08. 프로덕션 웹에 배포해서 마무리하기
'Backend > 노트' 카테고리의 다른 글
Gin (0) | 2022.10.15 |
---|---|
Hands-On Full stack development with Go (1) | 2022.10.07 |
Tucker 의 Go 언어 프로그래밍 (0) | 2022.08.14 |
한 번에 끝내는 Node.js 웹 프로그래밍 초격차 패키지 Online (0) | 2022.07.06 |
🍀스프링부트 (0) | 2022.03.29 |