백엔드에서 DB에 접근해서 생성,수정,삭제를 하는 경우 Transaction을 이용해서 작업의 원자성을 보장하도록 코드를 작성하면, 동시성 문제에서 자유로울 수 있을까요?
트랜잭션을 걸어 작업의 원자성을 보장하더라도 동시성 문제를 발생할 수 있습니다. 이러한 문제는 데이터베이스 자원을 어플리케이션이 공유하는데, 공유할 때, 고립정책에 따라 공유하기 때문에 발생합니다.
제가 만드는 사이드 프로젝트에서 이러한 동시성 문제가 발생할 수 있는 부분을 한번 찾아보고 동시성 문제를 해결하는 방법도 살펴보도록 하겠습니다.
공간을 시간을 나눠서 예약할 수 있는 프로그램이 있습니다. 이러한 프로그램에서 두명의 사용자가 거의 동시에 예약을 진행한다고 할때 일반적으로 트랜잭션 유지를 한다면, 문제가 해결될 것이라고 생각할 수 있습니다. 과연 그럴까요?
여러 시나리오를 보고, 정말 그런지 한번 확인해보도록 하겠습니다.
1명의 사용자가 예약을 진행하는 경우
prisma:query BEGIN
// 중복된 예약을 조회 (없음)
prisma:query SELECT "public"."reserve"."id", ...
prisma:query INSERT INTO "public"."reserve" ...
prisma:query INSERT INTO "public"."reserve_log" ...
prisma:query COMMIT
2명의 유저가 시간차를 두고 예약을 진행하는 경우,
A - prisma:query BEGIN
// A - 중복된 예약을 조회 (없음)
A - prisma:query SELECT "public"."reserve"."id", ...
A - prisma:query INSERT INTO "public"."reserve" ...
A - prisma:query INSERT INTO "public"."reserve_log" ...
A - prisma:query COMMIT
B - prisma:query BEGIN
// B - 중복된 예약 발견 (예약 실패)
B - prisma:query SELECT "public"."reserve"."id",, ...
B - prisma:query ROLLBACK
위 두 경우에는 생각한대로, 잘 동작하고 있습니다. 그런데, 거의 동시에 들어온 요청에 대해서는 어떻게 될까요?
A유저와 B유저가 거의 동시에 예약을 하는 경우,
A - prisma:query BEGIN
// A - 중복된 예약을 조회 (없음)
A - prisma:query SELECT "public"."reserve"."id", ...
B - prisma:query BEGIN
A - prisma:query INSERT INTO "public"."reserve" ...
A - prisma:query INSERT INTO "public"."reserve_log" ...
// B - 중복된 예약을 조회 (현 시점 중복된 예약이 없음)
B - prisma:query SELECT "public"."reserve"."id",, ...
B - prisma:query INSERT INTO "public"."reserve" ...
B - prisma:query INSERT INTO "public"."reserve_log" ...
B - prisma:query COMMIT
A - prisma:query COMMIT
// 결국 둘다 예약
지금과 같이, 둘다 예약에 성공하게 됩니다.
해당하는 문제는, 고립정책을 Serializable로 변경하면 해결되지만 모든 작업이 동기적으로 처리되다보니 성능 저하가 발생하게됩니다.
데이터베이스의 고립 정책이 Read Committed, Repeatable Read 인 경우에는 해당 동시성 문제에서 자유로울 수 없습니다.
따라서 동시성 문제가 발생할 가능성이 있고, 시스템에 심각한 문제가 될 수 있는 부분은 동시성 제어를 통해서 해당 문제를 해결해야 합니다.
이러한 문제를 해결하는 방식은 API Server 임계구역 생성, Database Lock을 이용, 또 분산환경을 위한 외부 서비스 이용 등 다양한 방법으로 해결할 수 있습니다.
저는 Redis의 분산락을 이용해서 해당하는 문제를 해결했습니다. 그래서 다른 방법은 간단하게 설명하고 잘 설명된 링크를 남기고, Redis 분산락을 이용한 문제 해결을 하는 부분을 구체적으로 설명하겠습니다.
Backend API 서버에서 해당하는 문제를 해결해본다면, 서버 인스턴스가 하나라는 전제하에 해결할 수 있습니다.
nestjs를 기준으로 하면, nestjs는 이벤트 루프를 사용한 비동기 작업으로 처리를 하다보니, 자바와 같이 synchronized 키워드를 이용한 블록이나 메서드 접근 제한을 할수는 없습니다.
하지만, async-mutex 라이브러리를 이용해서 임계 구역에 대한 동기화된 접근을 구현할 수 있습니다. 이러한 방법으로 제한을 둔다면 Application에서 동시성 문제를 해결할 수 있습니다.
async-mutex 사용을 잘 정리한 블로그
하지만 이러한 방법은, 인스턴스를 여러 개 사용하게 된다면, 동시성 문제를 해결한다고 보장해줄 수 없습니다.
Database에서 Lock을 거는 방식으로 동시성 문제를 해결할 수 있습니다. 비관적 락 방식과 낙관적 락을 사용하는 방식이 있습니다.
비관적 락 방식은 데이터베이스에서 공유 자원에 대한 접근 시, Lock을 걸어서 다른 트랜잭션의 접근을 차단하는 방식이고, 낙관적 락 방식은 락을 걸지는 않고 동시성 문제를 인지하는 예외를 만들어서 사용자가 처리해주는 방식을 말합니다.
이러한 방법은 데이터베이스가 단일 인스턴스일 때에는 문제없이 동작할 수 있지만, 데이터베이스의 스케일 아웃이 발생해 여러개의 인스턴스가 사용되는 경우에는 락 상태의 동기화의 어려움, 데이터 일관성 보장이 어려움, 성능 저하 등의 문제가 발생할 수 잇습니다.
분산 락(Distributed Lock)은 분산 시스템에서 공유 리소스에 대한 동시 접근을 제어하기 위해 사용되는 방법입니다.
분산락을 구현하는 방법은 여러가지이지만, 접근 방식의 모두 유사합니다.
Server, Database에 종속되지 않은 채, 공유자원에 대한 동기화 방식을 제공한다는 것입니다.
저는 Redis를 이용한 분산 락을 구현했고 간단하게 소개해보도록 하겠습니다.
Redis를 이용해서 분산락을 구현할 수 있는데, SETNX 명령어를 이용해서, 이미 기존에 key가 존재하는 경우에는 작업 수행에 실패하여 false를 반환하도록 할 수 있습니다.
그리고 Redis는 단일 스레드로 작동하는 키-값 저장소이므로 모든 명령은 순차적으로 처리됩니다.
이러한 특성을 이용해서 분산락을 구현하는데 그림으로 표현하면 다음과 같습니다.
Redis를 이용한 분산 락 구현으로 처음 생각했던 동시성 문제를 해결하는 코드를 조금 단순하게 만들어서 보여드리자면 다음과 같습니다.
async sampleCreateReserve(dto, token): Promise<CreateReserveResponseDto> {
// 인증 확인
if (dto.memberId !== token.id) throw new ResourceUnauthorizedException();
// 락 획득
const lockStatus = await this.setLock(this.REDIS_LOCK_CREATE_RESERVE);
if (!lockStatus) throw new ResourceLockException("Create Reserve");
try {
// 트랜잭션 시작
const result = await this.prismaService.$transaction(async (tx) => {
// 중복 예약 확인
const duplicatedReserve = await this.reserveRepository.findReserveForDuplicateReserve(
dto.spaceId, dto.startTime, dto.endTime, tx.reserve
);
if (duplicatedReserve.length !== 0) throw new DuplicateException("Reserve");
// 예약 저장
const reserve = new ReserveEntity(dto);
return await this.reserveRepository.saveReserve(reserve);
});
return {
id: result,
};
} finally {
// 락 해제
await this.delLock(this.REDIS_LOCK_CREATE_RESERVE);
}
}
이처럼 Redis를 이용한 분산락 구현을 통해서 동시성 문제를 해결할 수 있었습니다.
Redis를 이용한 분산락 방식에서 완전히 자유롭지 않습니다.
만약 Redis도 스케일 아웃이 일어난다면, 위에서 설명한 데이터베이스와 같은 문제점들이 발생하게 됩니다.
이때 사용할 수 있도록, Redis RedLock이라는 개념이 있습니다.
이 방법도 한계점이 분명히 존재합니다.
Redis로 분산락 구현시 명확한 한계점을 인지하고, 분산락을 완벽하게 구현하고 싶다면 다른 분산락 구현 시스템을 사용해보는 것이 좋을 것 같습니다.
지금 예시 코드는 락 획득 실패 시 실패처리를 했지만, 실패 처리가 아니라 순차적으로 락을 받아서 처리하는 경우도 분명히 존재합니다.
그러면 순차 처리를 위한 방법을 생각해보아야 합니다.
가장 간단한 방법은 lock이 풀릴 때 까지 spinlock을 하는 방법이 있습니다. 이 방법은 간단하지만 많은 부하가 발생하고, 제한 조건을 반드시 명시해야 합니다.
Spring 진영에서는 Redisson 라이브러리를 이용해서 pubsub 방식으로 스핀락 부하 없이 락이 풀리면, 다음 처리를 이어서 할 수 있다고 합니다.
Nest.js 진영에서는 아직 해당하는 라이브러리 나오지는 않았습니다. 나왔으면 좋겠군요.
그래서 스핀락과 스레드 일시중지, 제한조건을 섞어서 부하를 줄이는 방법을 사용할 수 있습니다.
추가 내용 ✚
bull을 이용하면 nestjs에서도 스핀락의 부하 없이 사용할 수 있다고 합니다. 해당 부분에 대해서는 학습이 많지 않아서 추후 블로그 포스팅을 하도록 하겠습니다
Redis Docs - Distributed Locks with Redis
우아한 형제들 기술블로그 - 프로모션 시스템 엿보기: 파일럿 프로젝트
망나니개발자님 블로그 - Redis Redlock의 특징과 한계
miintto.log님 블로그 - Redis 분산 락을 활용한 동시성 처리
Jeongkuk Seo님 블로그 - Redis가 제공하는 RedLock을 알아보자
내용에 틀린 부분이 있거나, 추가되어야 하는 부분이 있다면 덧글로 말해주시면 감사하겠습니다.
읽어주셔서 감사합니다.