Fastify 백엔드 서버를 구성해봅시다.
corepack enable
mkdir 폴더이름
cd 폴더이름
yarn init -2
.gitignore
파일을 생성하고 적절히 수정합니다.
.gitattributes
파일을 아래와 같이 생성합니다:
# Auto detect text files and perform LF normalization
* text eol=lf
# These files are binary and should be left untouched
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.pdf binary
*.otf binary
*.ttf binary
*.woff binary
*.woff2 binary
그외 바이너리로 취급할 파일 목록
package.json
파일을 수정합니다:
{
"name": "프로젝트 이름",
"version": "프로젝트 버전",
"description": "프로젝트 설명",
"homepage": "홈페이지 주소",
"bugs": {
"url": "버그 제보 주소",
"email": "버그 제보 이메일"
},
"license": "라이선스",
"author": "개발자",
"main": "프로그램 진입점 파일 경로",
"repository": "저장소 주소",
"scripts": {
// ...
},
"dependencies": {
// ...
},
"devDependencies": {
// ...
},
"engines": {
"node": ">=18.2.0"
},
"private": true,
"packageManager": "yarn@3.3.0"
}
https://www.typescriptlang.org/download \
https://stackoverflow.com/questions/72380007/what-typescript-configuration-produces-output-closest-to-node-js-18-capabilities/72380008#72380008
yarn add --dev typescript
yarn tsc --init
package.json
파일을 수정합니다:
{
// ...
"type": "module"
}
tsconfig.json
파일을 수정합니다:
{
"compilerOptions": {
// ...
"allowSyntheticDefaultImports": true,
"lib": ["ES2022"],
"module": "ES2022",
"moduleResolution": "node",
"target": "ES2022"
}
}
Prettier를 설치합니다.
yarn add --dev --exact prettier
echo {}> .prettierrc.json
.prettierrc.json
파일을 수정합니다:
{
"printWidth": 100,
"semi": false,
"singleQuote": true
}
.prettierignore
파일을 아래와 같이 생성합니다:
.yarn
.pnp.*
https://eslint.org/docs/latest/user-guide/getting-started \
https://github.com/standard/eslint-config-standard \
https://github.com/import-js/eslint-plugin-import \
https://github.com/weiran-zsd/eslint-plugin-node#readme \
https://github.com/xjamundx/eslint-plugin-promise \
npm init @eslint/config -- --config standard
✔ How would you like to use ESLint? · style
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · No / Yes
✔ Where does your code run? · node
✔ How would you like to define a style for your project? · guide
✔ Which style guide do you want to follow? · standard
✔ What format do you want your config file to be in? · JSON
Checking peerDependencies of eslint-config-standard@latest
Local ESLint installation not found.
The config that you've selected requires the following dependencies:
@typescript-eslint/eslint-plugin@latest eslint-config-standard@latest eslint@^8.0.1 eslint-plugin-import@^2.25.2 eslint-plugin-n@^15.0.0 eslint-plugin-promise@^6.0.0 @typescript-eslint/parser@latest
✔ Would you like to install them now? · No
yarn add --dev @typescript-eslint/eslint-plugin@latest eslint-config-standard@latest eslint@^8.0.1 eslint-plugin-import@^2.25.2 eslint-plugin-n@^15.0.0 eslint-plugin-promise@^6.0.0 @typescript-eslint/parser@latest
eslintrc.json
파일을 수정합니다:
{
"env": {
"es2022": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:n/recommended",
"plugin:promise/recommended",
"plugin:@typescript-eslint/recommended",
"standard"
]
// ...
}
https://github.com/prettier/eslint-config-prettier \
https://github.com/jest-community/eslint-plugin-jest
yarn add --dev eslint eslint-plugin-jest eslint-config-prettier
eslintrc.json
파일을 수정합니다:
{
"env": {
// ...
"jest/globals": true
},
"extends": [
// ...
// Make sure to put "prettier" last, so it gets the chance to override other configs
"prettier"
],
"overrides": [
{
"extends": ["plugin:jest/all"],
"files": ["test/**"],
"rules": {
"jest/prefer-expect-assertions": "off"
}
}
]
// ...
}
package.json
파일을 수정합니다:
{
"scripts": {
"lint": "eslint . --fix --ignore-path .gitignore",
"format": "prettier . --write"
// ...
}
// ...
}
https://yarnpkg.com/getting-started/editor-sdks \
https://yarnpkg.com/cli/upgrade-interactive
yarn dlx @yarnpkg/sdks vscode
yarn plugin import interactive-tools
https://typicode.github.io/husky/#/?id=automatic-recommended \
https://typicode.github.io/husky/#/?id=yarn-on-windows
Husky를 설치합니다.
yarn dlx husky-init --yarn2 && yarn
.husky/common.sh
파일을 생성합니다:
command_exists () {
command -v "$1" >/dev/null 2>&1
}
# Workaround for Windows 10, Git Bash and Yarn
if command*exists winpty && test -t 1; then
exec < /dev/tty
fi
.husky/pre-push
파일을 수정합니다:
#!/usr/bin/env sh
. "$(dirname -- "$0")/*/husky.sh"
. "$(dirname -- "$0")/common.sh"
yarn tsc
.vscode/settings.json
파일을 수정합니다:
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.insertSpaces": true,
"editor.tabSize": 2,
"files.autoSave": "onFocusChange",
"files.eol": "\n",
"sort-imports.default-sort-style": "module"
// ...
}
.vscode/recommendations.json
파일을 수정합니다:
{
"recommendations": [
"visualstudioexptteam.vscodeintellicode",
"christian-kohler.npm-intellisense",
"christian-kohler.path-intellisense",
"tabnine.tabnine-vscode",
"amatiasq.sort-imports",
"ms-azuretools.vscode-docker",
"bradymholt.pgformatter",
"foxundermoon.shell-format",
"ckolkman.vscode-postgres"
// ...
]
}
yarn add dotenv
아래 파일을 생성합니다:
파일 이름 | NODE_ENV | 환경 | 용도 |
---|---|---|---|
.env | production | 클라우드 | 실 서버 |
.env.dev | production | 클라우드 | 스테이징 서버 |
.env.local | production | 로컬 | 코드 축소 |
.env.local.dev | development | 로컬 | Fast refresh |
.env.local.docker | production | 로컬 | Docker 컨테이너 |
src/common/constants.ts
파일을 생성합니다:
// 자동
export const NODE_ENV = process.env.NODE_ENV as string
export const K_SERVICE = process.env.K_SERVICE as string // GCP에서 실행 중일 때
export const PORT = (process.env.PORT ?? '4000') as string
// 공통
export const PROJECT_ENV = (process.env.PROJECT_ENV ?? '') as string
export const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY as string
if (!PROJECT_ENV) throw new Error('`PROJECT_ENV` 환경 변수를 설정해주세요.')
if (!JWT_SECRET_KEY) throw new Error('`JWT_SECRET_KEY` 환경 변수를 설정해주세요.')
// 개별
export const LOCALHOST_HTTPS_KEY = process.env.LOCALHOST_HTTPS_KEY as string
export const LOCALHOST_HTTPS_CERT = process.env.LOCALHOST_HTTPS_CERT as string
if (PROJECT_ENV.startsWith('local')) {
if (!LOCALHOST_HTTPS_KEY) throw new Error('`LOCALHOST_HTTPS_KEY` 환경 변수를 설정해주세요.')
if (!LOCALHOST_HTTPS_CERT) throw new Error('`LOCALHOST_HTTPS_CERT` 환경 변수를 설정해주세요.')
}
yarn add --dev nodemon
nodemon.json
파일을 생성합니다:
{
"ext": "cjs",
"watch": ["out"]
}
https://esbuild.github.io/getting-started/#bundling-for-node \
https://esbuild.github.io/api/#metafile \
https://stackoverflow.com/a/35455532/16868717 \
yarn add --dev esbuild
esbuild.js
파일을 생성합니다:
import esbuild from 'esbuild'
const NODE_ENV = process.env.NODE_ENV
esbuild
.build({
bundle: true,
entryPoints: ['src/index.ts'],
loader: {
'.sql': 'text',
},
metafile: true,
minify: NODE_ENV === 'production',
outfile: 'out/index.cjs',
platform: 'node',
target: ['node18'],
treeShaking: true,
watch: NODE_ENV === 'development' && {
onRebuild: (error, result) => {
if (error) {
console.error('watch build failed:', error)
} else {
showOutfilesSize(result)
}
},
},
})
.then((result) => showOutfilesSize(result))
.catch((error) => {
throw new Error(error)
})
function showOutfilesSize(result) {
const outputs = result.metafile.outputs
for (const output in outputs) {
console.log(`${output}: ${(outputs[output].bytes / 1_000_000).toFixed(2)} MB`)
}
}
package.json
파일을 수정합니다:
{
"scripts": {
"dev": "NODE_ENV=development node esbuild.js & NODE_ENV=development nodemon -r dotenv/config out/index.cjs dotenv_config_path=.env.local.dev",
"build": "NODE_ENV=production node esbuild.js",
"start": "yarn build && NODE_ENV=production node -r dotenv/config out/index.cjs dotenv_config_path=.env.local"
// ...
}
// ...
}
src/global-env.d.ts
파일을 생성합니다:
declare module '*.sql' {
const content: string
export default content
}
https://www.fastify.io/docs/latest/Guides/Getting-Started/ \
yarn add fastify
src/routes/index.ts
파일을 생성합니다:
import Fastify from 'fastify'
import { K_SERVICE, NODE_ENV, PORT, PROJECT_ENV } from '../common/constants'
import productRoute from './product'
import userRoute from './user'
const fastify = Fastify({
logger: NODE_ENV === 'production',
})
fastify.register(productRoute)
fastify.register(userRoute)
export default async function startServer() {
try {
await fastify.listen({ port: +PORT, host: K_SERVICE ? '0.0.0.0' : 'localhost' })
} catch (err) {
fastify.log.error(err)
throw new Error()
}
}
.vscode/typescript.code-snippets
파일을 생성합니다:
{
"Fastify Routes": {
"prefix": "route",
"body": [
"import { FastifyInstance } from 'fastify'",
"",
"export default async function routes(fastify: FastifyInstance, options: object) {",
" fastify.get('/${TM_FILENAME_BASE}', async (request, reply) => {",
" return { hello: '${TM_FILENAME_BASE}' }",
" })",
"}",
""
],
"description": "Fastify Routes"
}
}
src/routes/product.ts
파일을 생성하고 route
단축어를 입력해 아래 코드를 자동 완성합니다:
import { FastifyInstance } from 'fastify'
export default async function routes(fastify: FastifyInstance, options: object) {
fastify.get('/product', async (request, reply) => {
return { hello: 'product' }
})
}
src/routes/user.ts
파일을 생성하고 route
단축어를 입력해 아래 코드를 자동 완성합니다:
import { FastifyInstance } from 'fastify'
export default async function routes(fastify: FastifyInstance, options: object) {
fastify.get('/user', async (request, reply) => {
return { hello: 'user' }
})
}
src/routes/index.ts
파일을 수정합니다:
import {
// ...
LOCALHOST_HTTPS_CERT,
LOCALHOST_HTTPS_KEY,
PROJECT_ENV,
} from '../common/constants'
const fastify = Fastify({
// ...
http2: true,
...(PROJECT_ENV.startsWith('local') && {
https: {
key: `-----BEGIN PRIVATE KEY-----\n${LOCALHOST_HTTPS_KEY}\n-----END PRIVATE KEY-----`,
cert: `-----BEGIN CERTIFICATE-----\n${LOCALHOST_HTTPS_CERT}\n-----END CERTIFICATE-----`,
},
}),
})
// ...
HTTPS 인증서를 생성합니다:
yarn add @fastify/cors
src/routes/index.ts
파일을 수정합니다:
import cors from '@fastify/cors'
// ...
fastify.register(cors, {
origin: [
'http://localhost:3000',
// ...
],
})
yarn add @fastify/rate-limit
src/routes/index.ts
파일을 수정합니다:
import rateLimit from '@fastify/rate-limit'
// ...
fastify.register(rateLimit, {
...(NODE_ENV === 'development' && {
allowList: ['127.0.0.1'],
}),
})
yarn add @fastify/jwt
src/routes/index.ts
파일을 수정합니다:
import fastifyJWT from '@fastify/jwt'
import {
// ...
JWT_SECRET_KEY,
} from '../common/constants'
// ...
fastify.register(fastifyJWT, {
secret: JWT_SECRET_KEY,
})
type QuerystringJWT = {
Querystring: {
jwt?: string
}
}
fastify.addHook<QuerystringJWT>('onRequest', async (request, reply) => {
const jwt = request.headers.authorization ?? request.query.jwt
if (!jwt) return
request.headers.authorization = jwt
try {
await request.jwtVerify()
} catch (err) {
reply.send(err)
}
})
https://www.fastify.io/docs/latest/Reference/Type-Providers/ \
https://github.com/sinclairzx81/typebox \
https://github.com/fastify/fastify-type-provider-typebox
yarn add @sinclair/typebox
yarn add --dev @fastify/type-provider-typebox
src/routes/index.ts
파일을 수정합니다:
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { Type } from '@sinclair/typebox'
// ...
const fastify = Fastify({
// ...
}).withTypeProvider<TypeBoxTypeProvider>()
const schema = {
schema: {
querystring: Type.Object({
foo: Type.Optional(Type.Number()),
bar: Type.Optional(Type.String()),
}),
response: {
200: Type.Object({
hello: Type.String(),
foo: Type.Optional(Type.Number()),
bar: Type.Optional(Type.String()),
}),
},
},
}
fastify.get('/', schema, async (request, _) => {
const { foo, bar } = request.query
return { hello: 'world', foo, bar }
})
yarn add @fastify/swagger
https://github.com/fastify/fastify-multipart \
https://github.com/googleapis/nodejs-storage#readme \
https://cloud.google.com/storage/docs/reference/libraries#client-libraries-install-nodejs \
https://cloud.google.com/storage/docs/uploading-objects-from-memory \
https://cloud.google.com/storage/docs/streaming-uploads#code-samples
yarn add @fastify/multipart @google-cloud/storage
src/routes/index.ts
파일을 수정합니다:
import multipart from '@fastify/multipart'
// ..
fastify.register(multipart, {
limits: {
fileSize: 10_000_000,
fieldSize: 1_000,
files: 10,
},
})
src/routes/upload.ts
파일을 생성합니다:
import { randomUUID } from 'crypto'
import path from 'path'
import { FastifyInstance } from 'fastify'
import { bucket } from '../common/google-storage'
type UploadResult = {
fileName: string
url: string
}
export default async function routes(fastify: FastifyInstance) {
fastify.post('/upload/images', async function (request, reply) {
// if (!request.userId) throw UnauthorizedError('로그인 후 시도해주세요')
const files = request.files()
const result: UploadResult[] = []
for await (const file of files) {
if (file.file) {
// if (!file.mimetype.startsWith('image/'))
// throw BadRequestError('이미지 파일만 업로드할 수 있습니다')
const timestamp = ~~(Date.now() / 1000)
const fileExtension = path.extname(file.filename)
const fileName = `${timestamp}-${randomUUID()}${fileExtension}`
bucket
.file(fileName)
.save(await file.toBuffer())
.then(() =>
result.push({
fileName: file.filename,
url: `https://storage.googleapis.com/${bucket.name}/${fileName}`,
})
)
.catch((error) => console.log(error))
}
}
reply.status(201).send(result)
})
}
src/common/constants.ts
파일을 수정합니다:
// ...
export const GOOGLE_CLOUD_STORAGE_BUCKET_NAME = process.env
.GOOGLE_CLOUD_STORAGE_BUCKET_NAME as string
https://node-postgres.com/ \
https://pgtyped.vercel.app/docs/cli \
https://stackoverflow.com/a/20909045/16868717 \
https://github.com/brianc/node-postgres/issues/2089
yarn add pg
yarn add --dev @types/pg @pgtyped/cli @pgtyped/query
src/common/constants.ts
파일을 수정합니다:
// ...
export const PGURI = process.env.PGURI as string
export const POSTGRES_CA = process.env.POSTGRES_CA as string
src/common/postgres.ts
파일을 생성합니다:
import pg from 'pg'
import { PGURI, POSTGRES_CA, PROJECT_ENV } from '../common/constants'
const { Pool } = pg
export const pool = new Pool({
connectionString: PGURI,
...((PROJECT_ENV === 'cloud-dev' ||
PROJECT_ENV === 'cloud-prod' ||
PROJECT_ENV === 'local-prod') && {
ssl: {
ca: `-----BEGIN CERTIFICATE-----\n${POSTGRES_CA}\n-----END CERTIFICATE-----`,
checkServerIdentity: () => {
return undefined
},
},
}),
})
pgtyped.config.json
파일을 생성합니다:
{
"transforms": [
{
"mode": "sql",
"include": "**/*.sql",
"emitTemplate": "{{dir}}/{{name}}.ts"
}
],
"srcDir": "src/"
}
package.json
파일을 수정합니다. dev
스크립트가 길어지기 때문에 아래처럼 sh 파일로 분리합니다:
{
"scripts": {
"dev": "src/dev.sh"
// ...
}
// ...
}
src/dev.sh
파일을 생성합니다:
#!/bin/sh
export $(grep -v '^#' .env.local.dev | xargs) && pgtyped --watch --config pgtyped.config.json &
sleep 2 && NODE_ENV=development node esbuild.js &
sleep 2 && NODE_ENV=development nodemon -r dotenv/config out/index.cjs dotenv_config_path=.env.local.dev
https://www.postgresqltutorial.com/postgresql-tutorial/export-postgresql-table-to-csv-file/ \
https://dba.stackexchange.com/q/137140 \
https://github.com/brianc/node-pg-copy-streams
yarn add pg-copy-streams
yarn add --dev @types/pg-copy-streams
database/index.ts
파일을 생성합니다:
import dotenv from 'dotenv'
import pg from 'pg'
const { Pool } = pg
// 환경 변수 설정
const env = process.argv[2]
export let CSV_PATH: string
if (env === 'prod') {
dotenv.config()
CSV_PATH = 'prod'
} else if (env === 'dev') {
dotenv.config({ path: '.env.development' })
CSV_PATH = 'dev'
} else {
dotenv.config({ path: '.env.development.local' })
CSV_PATH = 'local'
}
const PROJECT_ENV = process.env.PROJECT_ENV as string
const PGURI = process.env.PGURI as string
const POSTGRES_CA = process.env.POSTGRES_CA as string
if (!PROJECT_ENV) throw new Error('`PROJECT_ENV` 환경 변수를 설정해주세요.')
if (!PGURI) throw new Error('`PGURI` 환경 변수를 설정해주세요.')
if (!POSTGRES_CA) throw new Error('`POSTGRES_CA` 환경 변수를 설정해주세요.')
console.log(PGURI)
// PostgreSQL 서버 연결
export const pool = new Pool({
connectionString: PGURI,
...((PROJECT_ENV === 'cloud-dev' ||
PROJECT_ENV === 'cloud-prod' ||
PROJECT_ENV === 'local-prod') && {
ssl: {
ca: `-----BEGIN CERTIFICATE-----\n${POSTGRES_CA}\n-----END CERTIFICATE-----`,
checkServerIdentity: () => {
return undefined
},
},
}),
})
database/export.ts
파일을 생성합니다:
/* eslint-disable no-console */
import { createWriteStream, mkdirSync, rmSync } from 'fs'
import { exit } from 'process'
import pgCopy from 'pg-copy-streams'
import { CSV_PATH, pool } from './index.js'
const { to } = pgCopy
// 폴더 다시 만들기
rmSync(`database/data/${CSV_PATH}`, { recursive: true, force: true })
mkdirSync(`database/data/${CSV_PATH}`, { recursive: true })
let fileCount = 0
const client = await pool.connect()
const { rows } = await client.query('SELECT schema_name FROM information_schema.schemata')
for (const row of rows) {
const schemaName = row.schema_name
if (schemaName !== 'pg_catalog' && schemaName !== 'information_schema') {
const { rows: rows2 } = await client.query(
`SELECT tablename FROM pg_tables WHERE schemaname='${schemaName}'`
)
for (const row2 of rows2) {
const tableName = row2.tablename
fileCount += 1
console.log(`👀 - ${schemaName}.${tableName}`)
const csvPath = `database/data/${CSV_PATH}/${schemaName}.${tableName}.csv`
const fileStream = createWriteStream(csvPath)
const sql = `COPY ${schemaName}.${tableName} TO STDOUT WITH CSV DELIMITER ',' HEADER ENCODING 'UTF-8'`
const stream = client.query(to(sql))
stream.pipe(fileStream)
stream.on('end', () => {
fileCount -= 1
if (fileCount === 0) {
client.release()
exit()
}
})
}
}
}
database/import.ts
파일을 생성합니다:
/* eslint-disable no-console */
import { createReadStream, readFileSync } from 'fs'
import { exit } from 'process'
import { createInterface } from 'readline'
import pgCopy from 'pg-copy-streams'
import { CSV_PATH, pool } from './index.js'
const { from } = pgCopy
const client = await pool.connect()
try {
console.log('BEGIN')
await client.query('BEGIN')
const initialization = readFileSync('database/initialization.sql', 'utf8').toString()
await client.query(initialization)
// 테이블 생성 순서와 동일하게
const tables = [
// ...
]
// GENERATED ALWAYS AS IDENTITY 컬럼이 있는 테이블
const sequenceTables = [
// ...
]
for (const table of tables) {
console.log('👀 - table', table)
try {
const csvPath = `database/data/${CSV_PATH}/${table}.csv`
const columns = await readFirstLine(csvPath)
const fileStream = createReadStream(csvPath)
const sql = `COPY ${table}(${columns}) FROM STDIN WITH CSV DELIMITER ',' HEADER ENCODING 'UTF-8'`
const stream = client.query(from(sql))
fileStream.pipe(stream)
} catch (error) {
console.log('👀 - error', error)
}
}
for (const sequenceTable of sequenceTables) {
console.log('👀 - sequenceTable', sequenceTable)
client.query(`LOCK TABLE ${sequenceTable} IN EXCLUSIVE MODE`)
client.query(
`SELECT setval(pg_get_serial_sequence('${sequenceTable}', 'id'), COALESCE((SELECT MAX(id)+1 FROM ${sequenceTable}), 1), false)`
)
}
console.log('COMMIT')
await client.query('COMMIT')
} catch (error) {
console.log('ROLLBACK')
await client.query('ROLLBACK')
throw error
} finally {
client.release()
}
exit()
// Utils
async function readFirstLine(path: string) {
const inputStream = createReadStream(path)
// eslint-disable-next-line no-unreachable-loop
for await (const line of createInterface(inputStream)) return line
inputStream.destroy()
}
database/tsconfig.json
파일을 생성합니다:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2021"],
"module": "ES2020",
"moduleResolution": "node",
"outDir": "dist",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["./"]
}
package.json
파일의 스크립트를 수정합니다:
{
"scripts": {
"export": "tsc --project database/tsconfig.json && node database/dist/export.js",
"import": "tsc --project database/tsconfig.json && node database/dist/import.js"
// ...
}
// ...
}