동시성 문제에 대해 얘기하기 전에 환경은 다음과 같습니다.
프로젝트의 스펙은 Nodejs, Express, TypeScript, Typeorm, MySQL로 구성되어 있습니다.
유저 관리, 게시글(CRUD) 일반적인 게시판 프로젝트로 한 단계씩 기능을 추가 업그레이드 중입니다.
초반에는 빨리 API 구현을 해서 프로젝트가 실행만 되도록 했고, 동시성 문제에 대해 확인하고 해결하는 내용을 정리해보려고 합니다.
✔ 동시성 테스트
동시성 테스트를 위해서 Apache JMeter 를 통해 진행합니다.
- Number of Threads (users) : 한 번에 생성할 스레드의 수. 즉 동시에 접속하는 유저의 수
- Ramp-up period (seconds) : 전체 스레드가 전부 실행되는 데까지 걸리는 시간
- Loop Count : 반복하고자 하는 횟수(Infinite 체크 시 계속 접속)
"50명의 사용자가 6번째 게시글에 1번씩 접속 이를 실행하는데 1초가 걸린다"
동시성 테스트
해당 동시성 테스트는 6번째 게시글을 상세보기 하면 게시글이 존재하는지 조회하고 조회한 게시글의 조회수를 하나씩 증가시키고 다시 조회한 게시글의 정보를 응답해주는 로직이다. 하지만 동시성 테스트를 하면 게시글의 조회수가 생각했던 데로 나오지 않는다.
위의 콘솔 기록에 보다시피 "GET /api/posts/6" 라우트가 접근하고 UPDATE 하기 전에 다시 새로운 접근을 해서 "4", "5"가 여러 번 찍히는 것을 볼 수 있고 마지막에도 "GET /api/posts/6"가 3번 연속으로 나오고 제대로 된 게시글의 조회수를 증가시켜주지 않는 것을 볼 수 있다. 또한 2번째 그림에서 "id 6"인 "views 칼럼"의 데이터가 50이 아닌 36으로 조회되고 있다.
한 마디로 동시성 제어가 엉망인 셈이다....
✔ 문제점 파악
const postRepository = await AppDataSource.getRepository(Post);
const exPost = await postRepository.findOne({ where: { id } });
if (!exPost) {
return new CustomeError(httpStatus.NOT_FOUND, '해당 게시물을 찾을 수 없습니다');
}
const views = exPost.views + 1;
const result = await postRepository.update(id, { views });
if (result.affected === 0) {
return new CustomeError(httpStatus.NOT_FOUND, '존재하지 않는 게시글입니다');
}
const post = await postRepository
.createQueryBuilder('post')
.innerJoinAndSelect('post.user', 'user')
.select(['post.id', 'post.createdAt', 'post.title', 'post.content', 'post.views', 'user.name'])
.getOne();
if (!post) {
return new CustomeError(httpStatus.NOT_FOUND, '해당 게시물을 찾을 수 없습니다');
}
return post;
현재 코드는 위와 같이 되어 있고, 동시성의 문제는 코드를 보면 바로 이해가 간다... 조회한 "exPost"에 "views(조회수)" 값에 그냥 1 증가시킨 값을 update 하고 있다. 그래서 데이터베이스 쿼리 수행 시간 동안 다른 동시 접속자가 있다면 update 전 값을 가져와서 이전 값에 다시 증가시키고 update 하는 것이다. 그러면 동시에 여러 요청이 오면 순서를 어떻게 보장해야 할까?
✔ 동시성 제어 처리
현재까지 알아본 결과 해결 방법
- 데이터베이스의 격리 수준(Isolation level) 조정으로 락(lock) 걸기
- Redis 라이브러리를 사용하여 큐(Queue)를 구현하여 선입선출 방식 이용
격리 수준(Isolation level) 알아보기
1. DB Locking
데이터베이스에서 제공하는 잠금에는 두 가지가 있습니다. 공유 락(Shared Lock), 배타 락(Exclusive Lock)
공유 락은 트랜잭션이 읽기를 할 때 사용되는 락의 종류이고 트랜잭션이 데이터를 조회할 때 공유 락을 걸게 되면, 다른 트랜잭션은 그 데이터를 읽을 수 있지만 변경이나 삭제는 불가능합니다.
배타 락은 수정, 삭제를 할 때 사용되는 락의 종류이고 트랜잭션이 배타 락을 걸게 되면, 다른 트랜잭션은 그 데이터에 읽기, 수정, 삭제 연산을 할 수 없고, 어떠한 락도 걸 수 없습니다.
현재 상황에서는 데이터를 업데이트 전에 읽기를 해서 접근이 되면 동일한 값을 가지고 조회수를 증가할 수 있다고 생각해서 배타 락을 사용하도록 했습니다.
또한 락(Lock)에는 비관적 락(Pessimistic Lock), 낙관적 락(Optimistic Lock)이 있는데 데이터베이스에서 사용하는 락이 아닌 JPA 혹은 ORM 라이브러리에서 사용하는 내용인 것 같습니다. 사용하고 있는 Typeorm에서는 Pessimistic Lock(비관적 락 / 선점 락)을 지원하고 있습니다.
비관적 락은 동일한 데이터를 동시에 수정할 가능성이 높다는 비관적인 전제로 잠금을 거는 방식입니다.
비관적 락의 여러 모드 중에 "Pessimistic_write" 모드는 DB Query 중 SELECT FOR UPDATE 를 사용하는 배타 락 방식입니다. 동시에 한 명만 읽어가서 사용하도록 하는 방식으로 조회수를 처리하였습니다.
자세한 내용은 Typeorm 공식 문서를 참조하시기 바랍니다.
"100명의 사용자가 15번째 게시글에 1번씩 접속 이를 실행하는데 1초가 걸린다" 라고 지정했습니다.
100명의 동시 접속자가 15번 게시글에 접근했을 때 정상적으로 조회수가 100이 된 것을 볼 수 있습니다.
const queryRunner = AppDataSource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const postRepository = await queryRunner.manager.getRepository(Post);
const exPost = await postRepository.findOne({
where: { id },
select: ['views'],
lock: { mode: 'pessimistic_write' },
});
if (!exPost) {
return new CustomError(httpStatus.NOT_FOUND, '해당 게시물을 찾을 수 없습니다');
}
const views = exPost.views + 1;
const result = await postRepository.update(id, { views });
if (result.affected === 0) {
return new CustomError(httpStatus.NOT_FOUND, '존재하지 않는 게시글입니다');
}
const post = await postRepository
.createQueryBuilder('post')
.select([
'post.id',
'post.createdAt',
'post.title',
'post.content',
'post.views',
'user.name',
])
.innerJoin('post.user', 'user')
.where('post.id = :id', { id })
.getOne();
if (!post) {
return new CustomError(httpStatus.NOT_FOUND, '해당 게시물을 찾을 수 없습니다');
}
await queryRunner.commitTransaction();
return post;
} catch (err: any) {
await queryRunner.rollbackTransaction();
return new CustomError(httpStatus.INTERNAL_SERVER_ERROR, err);
} finally {
await queryRunner.release();
}
코드는 위와 같습니다.
connect 생성 후 트랜잭션을 시작해서 게시글을 조회하는데 find options에 "pessimistic_write" lock을 사용하고 게시글이 존재하면 게시글의 조회수를 가져와서 1 증가시킨 후 업데이트하고 업데이트 한 게시글의 필요한 정보만 조회하고 커밋하고 응답했습니다.
처음에는 connect, release 하지 않아서 30/50/100명 동시 접속자를 요청했을 때 10명에서 대기만 하고 진행하지 않는 이유를 몰라서 계속 의아해하다가 커넥션 풀(Connection Pool)이 생각나서 확인해본 결과 디폴트가 10으로 지정된 것을 확인했고, 그리고 connect, release를 알게 되었습니다.
connect()는 createQueryRunner()로 독립된 커넥션을 생성해서 커넥션 풀 연결에 사용하고, release()는 connect로 커넥션 풀에서 하나의 접근 권한을 가져와서 사용하는 중이기 때문에 다른 사람이 사용할 수 없어서 10명까지 처리하고 그다음 접속자들은 사용을 할 수 없는 상태였습니다. 그러므로 연산이 끝난 사용자들은 다른 사용자들이 사용할 수 있게 커넥션을 반납해야 하는데 이때 사용하는 게 release()입니다.
이렇게 동시성 처리 문제를 해결할 수 있었습니다.
하지만 위와 같이 처리하는 방법에 대해 조사했을 때 다른 문제의 의견도 있었습니다. 현재 게시판 프로젝트는 규모가 매우 매우 작은 프로젝트이고 테이블도 몇 개 없기 때문에 성능적으로 크게 문제가 없을 수 있지만 큰 규모라면 성능적 이슈가 발생할 수도 있다는 것입니다. 한 명이 접근했을 때 락을 걸어서 다른 사용자들은 일단 대기해야 한다는 것... 또한 "100만 명의 요청이 오면 최소 100만 번의 데이터베이스 콜과 업데이트가 이루어지게 된다는 것이 비효율적이다"라는 내용이 있었습니다. 그래서 레디스(Redis Cache) 캐시, 큐를 활용하는 방법, 정해진 시간에 한 번에 업데이트하는 방법 등등 여러 가지 방법이 있는 것 같았습니다.
위와 같은 내용은 조금 더 공부해서 추후에 다시 글을 남기도록 하겠습니다.
🔗 Reference
'JavaScript & TypeScript' 카테고리의 다른 글
BigDecimal Type 사용하는 이유 (0) | 2022.10.26 |
---|
댓글