로그 미들웨어는 클라이언트의 요청을 기록하여 어플리케이션을 모니터링하고 문제가 발생할 때 빠르게 진단할 수 있다. 규모가 큰 프로젝트에서는 모든 로그를 일일이 확인하는 게 불가능에 가깝다. 따라서 morgan
, windston
과 같은 라이브러리를 사용하거나, AWS CloudWatch
, Datadog
와 같은 외부 모니터링 솔루션 서비스를 이용해 로그를 수집하고 관리한다.
# yarn을 이용해 winston을 설치합니다.
yarn add winston
// src/middlewares/log.middleware.js
import winston from 'winston';
const logger = winston.createLogger({
level: 'info', // 로그 레벨을 'info'로 설정합니다.
format: winston.format.json(), // 로그 포맷을 JSON 형식으로 설정합니다.
transports: [
new winston.transports.Console(), // 로그를 콘솔에 출력합니다.
],
});
export default function (req, res, next) {
// 클라이언트의 요청이 시작된 시간을 기록합니다.
const start = new Date().getTime();
// 응답이 완료되면 로그를 기록합니다.
res.on('finish', () => {
const duration = new Date().getTime() - start;
logger.info(
`Method: ${req.method}, URL: ${req.url}, Status: ${res.statusCode}, Duration: ${duration}ms`,
);
});
next();
}
// src/middlewares/log.middleware.js
import winston from 'winston';
const logger = winston.createLogger({
level: 'info', // 로그 레벨을 'info'로 설정합니다.
format: winston.format.json(), // 로그 포맷을 JSON 형식으로 설정합니다.
transports: [
new winston.transports.Console(), // 로그를 콘솔에 출력합니다.
],
});
export default function (req, res, next) {
// 클라이언트의 요청이 시작된 시간을 기록합니다.
const start = new Date().getTime();
// 응답이 완료되면 로그를 기록합니다.
res.on('finish', () => {
const duration = new Date().getTime() - start;
logger.info(
`Method: ${req.method}, URL: ${req.url}, Status: ${res.statusCode}, Duration: ${duration}ms`,
);
});
next();
}
로그 레벨은 로그의 중요도를 나타낸다. 여기선 info
레벨을 사용하지만, error
, warn
, debug
등 다양한 로그 레벨이 있으며, 특정 상황에 따라 출력하는 레벨을 다르게 구현할 수 있다.
// src/middlewares/error-handling.middleware.js
export default function (err, req, res, next) {
// 에러를 출력합니다.
console.error(err);
// 클라이언트에게 에러 메시지를 전달합니다.
res.status(500).json({ errorMessage: '서버 내부 에러가 발생했습니다.' });
}
// src/routes/users.router.js
/** 사용자 회원가입 API 에러 처리 미들웨어 **/
router.post('/sign-up', async (req, res, next) => {
try {
const { email, password, name, age, gender, profileImage } = req.body;
const isExistUser = await prisma.users.findFirst({
where: {
email,
},
});
if (isExistUser) {
return res.status(409).json({ message: '이미 존재하는 이메일입니다.' });
}
// 사용자 비밀번호를 암호화합니다.
const hashedPassword = await bcrypt.hash(password, 10);
// Users 테이블에 사용자를 추가합니다.
const user = await prisma.users.create({
data: {
email,
password: hashedPassword, // 암호화된 비밀번호를 저장합니다.
},
});
// UserInfos 테이블에 사용자 정보를 추가합니다.
const userInfo = await prisma.userInfos.create({
data: {
userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
name,
age,
gender: gender.toUpperCase(), // 성별을 대문자로 변환합니다.
profileImage,
},
});
return res.status(201).json({ message: '회원가입이 완료되었습니다.' });
} catch (err) {
next(err);
}
});
// src/app.js
import express from 'express';
import cookieParser from 'cookie-parser';
import LogMiddleware from './middlewares/log.middleware.js';
import ErrorHandlingMiddleware from './middlewares/error-handling.middleware.js';
import UsersRouter from './routes/users.router.js';
const app = express();
const PORT = 3018;
app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter]);
app.use(ErrorHandlingMiddleware);
app.listen(PORT, () => {
console.log(PORT, '포트로 서버가 열렸어요!');
});
에러 처리 미들웨어는 클라이언트의 요청이 실패했을 때, 가장 마지막에 실행되어야 하는 미들웨어다. 따라서 app.use
를 이용한 전역 미들웨어 중 가장 최하단에 위치한다.
서버 내부에서 발생한 에러를 상세하게 클라이언트에게 제공하면 악의적인 사용자의 표적이 될 수 있다. 따라서 "서버에서 에러가 발생하였습니다."와 같은 추상적인 내용을 클라이언트에게 전달하도록 한다.
사용자는 여러개의 게시글을 등록할 수 있다. 사용자와 게시글은 1:N 관계를 가진다.
// src/app.js
import express from 'express';
import cookieParser from 'cookie-parser';
import LogMiddleware from './middlewares/log.middleware.js';
import ErrorHandlingMiddleware from './middlewares/error-handling.middleware.js';
import UsersRouter from './routes/users.router.js';
import PostsRouter from './routes/posts.router.js';
const app = express();
const PORT = 3018;
app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter, PostsRouter]);
app.use(ErrorHandlingMiddleware);
app.listen(PORT, () => {
console.log(PORT, '포트로 서버가 열렸어요!');
});
// src/routes/posts.router.js
import express from 'express';
import { prisma } from '../utils/prisma/index.js';
import authMiddleware from '../middlewares/auth.middleware.js';
const router = express.Router();
/** 게시글 생성 API **/
router.post('/posts', authMiddleware, async (req, res, next) => {
const { userId } = req.user;
const { title, content } = req.body;
const post = await prisma.posts.create({
data: {
userId: +userId,
title,
content,
},
});
return res.status(201).json({ data: post });
});
export default router;
사용자 인증 미들웨어를 통해 로그인된 사용자인지 검증을 수행하였고, 전달된 userId
값을 이용해 사용자와 1:N 관계를 맺고있는 게시글을 생성하도록 구현하였다.
// src/routes/posts.router.js
/** 게시글 목록 조회 API **/
router.get('/posts', async (req, res, next) => {
const posts = await prisma.posts.findMany({
select: {
postId: true,
userId: true,
title: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc', // 게시글을 최신순으로 정렬합니다.
},
});
return res.status(200).json({ data: posts });
});
// src/routes/posts.router.js
/** 게시글 상세 조회 API **/
router.get('/posts/:postId', async (req, res, next) => {
const { postId } = req.params;
const post = await prisma.posts.findFirst({
where: {
postId: +postId,
},
select: {
postId: true,
userId: true,
title: true,
content: true,
createdAt: true,
updatedAt: true,
},
});
return res.status(200).json({ data: post });
});
댓글 기능은 게시글 기능과 부모-자식 간의 관계로 구성된다.
댓글 기능을 구현하기 위해선 거의 필수적으로 게시글이 필요하다. 댓글은 게시글에 종속되므로, 댓글 API는 게시글 API 하위에 구현되어야 한다.
예를 들어 댓글을 생성하는 API의 경우 POST /api/posts/:postId/comment
와 같이 작성할 수 있다.
댓글 생성 API 비즈니스 로직
1. 댓글을 작성하려는 클라이언트가 로그인된 사용자인지 검증합니다.
2. 게시물을 특정하기 위한postId
를 Path Parameters로 전달받습니다.
3. 댓글 생성을 위한content
를 body로 전달받습니다.
4. Comments 테이블에 댓글을 생성합니다.
사용자는 여러개의 댓글을 작성할 수 있다. 댓글은 1:N 관계를 가진다.
하나의 게시글은 여러개의 댓글이 작성될 수 있다. 게시글과 댓글은 1:N 관계를 가진다.
// src/app.js
import express from 'express';
import cookieParser from 'cookie-parser';
import LogMiddleware from './middlewares/log.middleware.js';
import ErrorHandlingMiddleware from './middlewares/error-handling.middleware.js';
import UsersRouter from './routes/users.router.js';
import PostsRouter from './routes/posts.router.js';
import CommentsRouter from './routes/comments.router.js';
const app = express();
const PORT = 3018;
app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter, PostsRouter, CommentsRouter]);
app.use(ErrorHandlingMiddleware);
app.listen(PORT, () => {
console.log(PORT, '포트로 서버가 열렸어요!');
});
// src/routes/comments.router.js
import express from 'express';
import authMiddleware from '../middlewares/auth.middleware.js';
import { prisma } from '../utils/prisma/index.js';
const router = express.Router();
/** 댓글 생성 API **/
router.post(
'/posts/:postId/comments',
authMiddleware,
async (req, res, next) => {
const { postId } = req.params;
const { userId } = req.user;
const { content } = req.body;
const post = await prisma.posts.findFirst({
where: {
postId: +postId,
},
});
if (!post)
return res.status(404).json({ message: '게시글이 존재하지 않습니다.' });
const comment = await prisma.comments.create({
data: {
userId: +userId, // 댓글 작성자 ID
postId: +postId, // 댓글 작성 게시글 ID
content: content,
},
});
return res.status(201).json({ data: comment });
},
);
export default router;
// src/routes/comments.router.js
/** 댓글 조회 API **/
router.get('/posts/:postId/comments', async (req, res, next) => {
const { postId } = req.params;
const post = await prisma.posts.findFirst({
where: {
postId: +postId,
},
});
if (!post)
return res.status(404).json({ message: '게시글이 존재하지 않습니다.' });
const comments = await prisma.comments.findMany({
where: {
postId: +postId,
},
orderBy: {
createdAt: 'desc',
},
});
return res.status(200).json({ data: comments });
});
일반적인 커뮤니티 사이트에서 게시글을 조회한다면, 하나의 게시글과 해당 게시글에 달린 댓글들을 함께 보여준다. 이를 구현하는 방법에는 크게 두 가지가 있다.
여기서는 2번 방법을 선택하여 게시글과 댓글이라는 서로 다른 도메인을 명확하게 분리하였고 각각이 명확한 역할과 책임을 가질 수 있도록 구현하였다.
트랜잭션은 작업의 완전성을 보장해주기 위해 사용되는 개념이다. 특정 작업을 전부 처리하거나, 전부 실패하게 만들어 데이터의 일관성을 보장해준다.
작업의 단위를 하나의 쿼리에 종속하는 것이 아닌, 여러개의 작업을 묶어 하나의 작업 단위로 그룹화하여 처리하는 작업을 뜻한다.
ACID는 트랜잭션의 네 가지 특징을 나타낸다.
원자성은 트랜잭션 내에서 실행되는 명령들을 하나의 묶음으로 처리하여, 내부에서 실행된 명령들이 전부 성공하거나, 아니면 모두 실패해야 한다는 특징이다.
여기서 원자성이란, 나눠질 수 없는 단일 작업이라는 의미이다.
원자성이라는 특징을 이용해 각각의 쿼리를 별도로 실행하는 것이 아니라, 동시에 실행해야 하는 여러개의 쿼리를 묶어서 관리할 수 있게 된다.
일관성은 작업이 성공할 경우 아무런 문제가 발생하지 않고, 실패하더라도 작업을 진행하던 도중 실패한 상태로 데이터를 방치하지 않는 특징이다.
격리성은 트랜잭션이 실행 중인 경우 다른 트랜잭션에 의해 데이터가 변경되는 것을 방지하는 특징이다. 트랜잭션이 완전히 수행되거나, 완전히 수행되지 않은 상태를 외부에서 참조할 수 있지만, 트랜잭션의 중간 과정이나 중간 겨로가를 볼 수 없도록 한다.
MySQL에서는 사용 중인 DB 오브젝트에 락(Lock)을 걸어 격리성을 구현한다. 락을 건 상태는 DB에 접속한 또 다른 클라이언트가 해당 DB 오브젝트를 읽거나, 사용할 수 없도록 방지하여 데이터의 무결성을 보장한다.
동시성은 여러 클라이언트가 동시에 하나의 데이터를 사용 및 공유하는 것을 뜻한다.
동시에 여러 트랜잭션이 동일한 데이터에 접근할 때는 데이터의 일관성을 유지하기 어려울 수 있다. 이를 해결하기 위해, 자원을 사용하는 하나의 클라이언트만 해당 자원을 점유할 수 있도록하여 다른 사용자가 접근할 수 없도록 만들어, 자원을 공유하는 원인을 제거하면 된다. 이를 자원 잠금이라고 하며, 락이라는 개념이 나오게 된 것이다.
지속성은 트랜잭션이 성공적으로 커밋된 후, 해당 트랜잭션에 의해 생성 또는 수정된 데이터가 어떠한 상황에서도 보존되는 특징이다.
즉, 트랜잭션이 완료되면 결과는 데이터베이스에 영구적으로 저장되며, 이후 시스템에 어떠한 문제가 생기더라도 데이터는 손상되지 않는다.
트랜잭션이 성공적으로 완료되면, 해당 트랜잭션에 의해 생성 또는 변경된 데이터는 데이터베이스에 COMMIT
명령을 통해 영구적을 저장된다. 하지만, 트랜잭션 수행 도중 시스템이 비정상 종료되더라도, 시스템은 트랜잭션 로그를 통해 아직 커밋되지 않은 트랜잭션을 복구할 수 있다.
-- 트랜잭션을 시작합니다.
START TRANSACTION;
-- 성공시 작업 내역을 DB에 반영합니다.
COMMIT;
-- 실패시 START TRANSACTION이 실행되기 전 상태로 작업 내역을 취소합니다.
ROLLBACK;
-- SPARTA 테이블을 생성합니다.
CREATE TABLE IF NOT EXISTS SPARTA
(
spartaId INT(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
spartaName VARCHAR(255) NOT NULL,
spartaAddress VARCHAR(255) NOT NULL
);
-- 1번째 트랜잭션을 실행합니다.
START TRANSACTION;
-- SPARTA 테이블에 더미 데이터 3개를 삽입합니다.
INSERT INTO SPARTA (spartaName, spartaAddress)
VALUES ('SPARTA1', 'SEOUL'),
('SPARTA2', 'BUSAN'),
('SPARTA3', 'DAEGU');
-- 1번째 트랜잭션을 DB에 적용합니다.
COMMIT;
-- 2번째 트랜잭션을 실행합니다.
START TRANSACTION;
-- SPARTA 테이블에 더미 데이터 3개를 삽입합니다.
INSERT INTO SPARTA (spartaName, spartaAddress)
VALUES ('SPARTA4', 'SEOUL'),
('SPARTA5', 'BUSAN'),
('SPARTA6', 'DAEGU');
-- 2번째 트랜잭션을 롤백합니다.
ROLLBACK;
-- 테이블의 상태를 확인합니다.
SELECT * FROM SPARTA;
두 번째 트랜잭션에서 수행하는 INSERT INTO
가 ROLLBACK
되었기 때문에 실제 SPARTA 테이블은 6개가 아닌, 3개의 데이터만 삽입되어 있는 상태로 존재하게 된다.
락은 동시성을 제어하기 위해 사용하는 기능이다. 해당하는 데이터를 점유하여 다른 트랜잭션의 접근을 막아 동시성과 일관성의 균형을 맞추기 위해 사용한다.
하나의 데이터를 여러 사용자들이 동시에 변경하려고 할 때, 한번에 여러번의 수정이 발생하게 되고, 최종 수정된 결과값을 인지할 수 없게 되는 상황으로 인해 데이터베이스의 일관성이 깨지게 된다.
READ
전용 락이라고 불리기도 하며, 해당 락을 사용하는 트랜잭션이 모든 작업을 수행하면 공유 락은 해제된다.# 트랜잭션을 시작합니다.
START TRANSACTION;
# SPARTA 테이블을 조회할 때, 해당 데이터들에 공유 락을 설정합니다.
SELECT * FROM SPARTA LOCK IN SHARE MODE;
WRITE
전용 락이라고도 불리며, 트랜잭션이 해당하는 데이터를 점유한 후 다른 트랜잭션이 해당 데이터에 접급할 수 없도록 만든다.# 트랜잭션을 시작합니다.
START TRANSACTION;
# SPARTA 테이블을 조회할 때, 해당 데이터들에 배타 락을 설정합니다.
SELECT * FROM SPARTA FOR UPDATE;
# 글로벌 락을 획득합니다.
# MySQL 서버에 존재하는 모든 테이블에 락을 겁니다.
FLUSH TABLES WITH READ LOCK;
# SPARTA 테이블에 테이블 락을 설정합니다.
LOCK TABLES SPARTA READ;
# sparta_name 문자열을 획득합니다.
# 만약, 10초 동안 획득 하지 못한다면, NULL을 반환합니다.
SELECT GET_LOCK('sparta_name', 10);
# 테이블 구조를 변경할 때, MySQL은 내부적으로 메타데이터 락을 설정합니다.
ALTER TABLE SPARTA ADD COLUMN Age Int;
교착 상태는 여러 테이블에 락을 적용하여, 다른 작업이 처리되지 못하게 점유하고 있는 작업이 있을 때, 다른 작업을 끝나는 것을 무한정 기다리는 것을 나타낸다.
1. A→ B 테이블을 순차적으로 사용하는 트랜잭션
2. B→ A 테이블을 순차적으로 사용하는 트랜잭션
1, 2가 각각 A, B 테이블을 점유하기 위해 락을 건다. 그러면 1은 B 테이블의 락이 풀리기를 기다리는 상태가 발생하고, 2 또한 A 테이블의 락이 풀리기를 기다리는 상태가 발생한다.
트랜잭션의 격리 수준은 여러 트랜잭션이 동시에 처리될 때 다른 트랜잭션에서 변경 및 조회하는 데이터를 읽을 수 있도록 허용하거나 거부하는 것을 결정하기 위해 사용한다.
중요한 점은 데이터의 일관성과 동시성 처리 성능 사이에서 균형을 잡는 것이다.
READ UNCOMMITTED
READ COMMITTED
SELECT
문을 실행할 때 공유 락을 건다.REPEATABLE READ
SERIALIZABLE
커밋되지 않은 읽기는 다른 트랜잭션에 의해 작업 중인 데이터를 읽게 되는 것을 나타낸다. 만약 커밋되지 않은 읽기가 발생할 경우, 의도치 않은 데이터를 참조하게 되어 데이터의 일관성이 깨지게 된다.
트랜잭션을 수행하던 중 다른 트랜잭션에 의해 삭제된 데이터를 팬텀행(Phantom Rows)이라고 한다. 여기서 팬텀행에 해당하는 데이터를 읽는 것을 팬텀 읽기라고 부른다.