[MetalWallet] 동시성 이슈와 문제 해결(2) - Redis 분산 락
이번에는 같은 구역의 재고 수량에 대한 동시성 이슈를 확인할 예정이다.
📌 동시성 이슈 확인
🧑🤝🧑 🪑🪑 Scenario 2. 100명의 사용자가 100개의 좌석을 동시에 예매한다.
TicketServiceConcurrencyTest.java
접기/펼치기
@Test
@DisplayName("100명이 100개의 좌석을 예매할 때 재고가 정상적으로 감소")
void testBookTicket_multipleUsersMulripleSeatsSuccess() throws InterruptedException {
//given
int seatCount = 100;
for(long i=1L; i <=100L; i++) {
seatRepository.save(new Seat(
i,
(int) i,
section,
schedule,
true
));
}
ExecutorService executor = Executors.newFixedThreadPool(seatCount);
CountDownLatch latch = new CountDownLatch(seatCount);
List<Future<BookResult>> results = new ArrayList<>();
//when
for (int i = 1; i <= seatCount; i++) {
String email = "test" + i + "@gmail.com";
TicketRequest request = new TicketRequest();
request.setDeviceId("deviceID" + i);
request.setSeatId(Collections.singletonList((long) i));
results.add(executor.submit(() -> {
try {
ticketService.bookTicket(email, request);
return new BookResult(email, true, "success");
} catch (Exception e) {
return new BookResult(email, false, e.getMessage());
} finally {
latch.countDown();
}
}));
}
latch.await();
executor.shutdown();
//then
int availableSeats = sectionRepository.findById(testSectionId).get().getAvailableSeats();
assertEquals(0, availableSeats, "가용 좌석 수는 0이어야 합니다.");
long bookedSeatCount = seatRepository.findAll().stream().filter(seat -> !seat.isAvailable()).count();
assertEquals(seatCount, bookedSeatCount, "예약된 좌석 수는 총 " + seatCount + "이어야 합니다.");
long ticketCount = ticketRepository.count();
assertEquals(seatCount, ticketCount, "티켓 테이블에는 " + seatCount + "개의 데이터가 있어야 합니다.");
}
테스트 결과
Expected: 0
Actual: 50~80
우려했던 문제가 발생했다.
문제 원인
- Pessimistic Lock은 특정 레코드에만 적용되기 때문에 멀티스레드 환경에서 다른 seatId에 접근하는 트랜잭션은 Lock의 영향을 받지 않는다.
- 따라서 Pessimistic Lock 을 통해 Seat는 직렬화되지만, Section의 남은 좌석 수가 잘못 갱신될 가능성이 있었다.
다른 서비스와 차이점 분석
이를 해결하기 위해 Section에 대한 비관적 락을 적용해볼 예정이였지만 두 번의 Lock을 적용하는게 성능 측면에서 맞는건지 의문이 들었다. 그래서 몇몇의 다른 티켓팅 서비스를 찾아보니 차이점은 도메인 구조에 있었다.
도메인 구조
내가 찾아본 2가지 경우를 정리해봤다.
1️⃣ Ticket에서 수량을 관리하는 단일 테이블 구조이기 때문에 DB 락을 적용하면 문제가 없다.
2️⃣ 또는 성능이 좋지 않은 비관적 락을 사용하는 대신, Reservation에서 생성된 auto_increament pk를 통해 Ticket에서 ticketId와 reservationId 이하의 row 개수를 기준으로 예약된 티켓 개수를 조회해서 초과하면 예매를 방지하는 전략이다.
- 우리 서비스는 ERD를 설계하던 당시, 구역에 따라 수량과 가격을 구분하여 보여주는 좌석 지정이 가능한 구조로 설계했다. 이는 Seat 의 좌석 상태를 변경하고 Section에서 남은 좌석 수를 변경하는 각각 별도의 테이블로 관리하도록 분리돼있기 때문에 Lock을 2번 적용해야 한다.
비슷한 도메인 구조의 프로젝트가 잘 보이지 않아서 차이점을 비교하는데 조금 많은 시간을 헤멨다..
📌 동시성 문제 해결
다른 서비스는 Ticket 테이블에서 수량을 관리한다. 우리 서비스의 ERD 구조는 Reservation 테이블이 따로 존재하지 않아서 비즈니스 로직이 복잡해지고 확장성이 떨어지게끔 설계되어 느낌을 받았다. 그래서 2번의 DB Lock 적용 대신 1️⃣ ERD 구조를 수정하기로 결정했다.🎯 (이렇게 되면 비즈니스 로직이 많이 변경돼서 번거로움이 있다. 이 덕분에 데이터 모델링의 중요성을 깨닫는 계기가 됐지만.. 이런 일이 최대한 일어나면 안될 것 같다.)
또한 비관적 락을 적용한 결과 애플리케이션 수준의 DB Lock 방식은 Deadlock 발생 가능성이 증가하고, 디스크 I/O가 자주 발생하여 안정성이 보장되지 않을 것 같다고 생각했다.
이에 대한 해결책으로 비관적 락의 단점을 보완하면서 티켓팅 서비스 특성상 단일 서버에서 대량의 트래픽을 감당하기 어렵기 때문에 높은 tps를 처리할 수 있는 확장성을 고려하여 여러 프로세스에서 동시에 접근하는 문제를 해결하기 위한 2️⃣ Redis 기반의 분산 락을 적용할 것이다.🎯 이를 통해 사용자 경험을 개선하여 빠르고 안정적인 티켓팅 서비스를 제공한다.🔥
1️⃣ ERD 구조 개선
- 실체(Ticket)와 행위(Reservation 분리) 엔티티를 분리
- 재고 수량을 Section에서 관리하던 방식에서 Ticket으로 변경하여 좌석 상태 & 재고 수량에 대한 Race Condition 방지
2️⃣ Redisson 분산 락
- 비관적 락 개선
- 동시성 제어의 안정성
- 높은 tps를 위한 확장성
ERD 구조 개선
Reservation 테이블을 추가하여 행위/실체 Entity를 구분하고, 그에 따라 관계 표기를 재정의했다.
Redis
Redis(Remote Dictionary Server)는 메모리 기반(in-memory-database) 데이터 저장소이다.
- 빠른 성능: RAM을 사용하여 Disk I/O가 발생하지 않아 DB보다 훨씬 빠르다.
- 데이터 복구를 위해 디스크에 주기적으로 싱크를 맞출 수 있는데, 오퍼레이션을 수행하는 스레드를 방해하지 않고 별도의 스레드가 수행한다.
- 동기화 이슈 ❌: 싱글스레드로 오퍼레이션을 수행한다.
- 🔑 Key-Value 구조: seatId가 1001인 경우,
"LOCK:1001"
➝"{clientUUID}:threadId:expirationTime"
- 분산 환경에 강함: 여러 서버에서 공유된 자원을 동시에 접근할 때 유용. 수평 확장이 가능
Redission 라이브러리를 채택한 이유
- Redis의 싱글 스레드 특성만으로는 동시성 문제를 완전히 해결할 수 없다.
- Redis 자체는 싱글 스레드이지만, 여러 클라이언트가 동시에 Redis에 요청을 보낼 수 있다. 예를 들어,
GET
으로 데이터를 읽고SET
을 하는 작업이 순차적으로 실행되지 않으면 Race Condition이 발생할 수 있다. - 따라서 동시에 여러 요청이 들어와도 하나씩 순차적으로 처리하기 위해 분산 락이 필요하다.
Lettuce
- 스핀락 방식으로 동작한다. 분산락을 적용하기 위해서는 지속적으로 Redis에게 락이 해제되었는지 요청을 낼 수 있도록 직접 구현해야 한다.
retry
,timeout
같은 기능을 구현해 주어야 한다는 번거로움이 있다. - 락이 비정상적으로 종료되면 안전하게 해제하지 않아 락 해제 로직을 직접 구현해야 한다.
- 싱글스레드 환경에 적합하다.
Redission
- 별도의 Lock interface를 지원한다. 락이 해제되면 락을
subscribe
하는 클라이언트가 락이 해제되었다는 신호를 받고 락 획득을 시도한다. - 멀티스레드에서 안전하게 동작한다.
설정
1️⃣ Redis를 설치하고 redis-cil.exe 실행한다.
2️⃣ Redis를 적용하기 위해 build.gradle에 dependencies
를 추가
build.gradle
접기/펼치기
dependencies {
implementation 'org.springframework.data:spring-data-redis:2.5.2'
implementation 'org.redisson:redisson:3.16.6'
implementation 'org.redisson:redisson-spring-data-27:3.16.6'
implementation "org.springframework:spring-tx:${springVersion}"
}
3️⃣ RedisConfig 설정
- Redission Client 설정
- Redission + Spring Data Redis의 기능을 동시에 활용하기 위해
RedissionConnectionFactory
추가
RedissionConnectionFactory
Spring Data Redis와 Redisson을 통합하는 데 필요하다. Redission을 Spring Data Redis의 RedisConnectionFactory
로 등록하여 사용할 수 있다.
RedisConfig.java
접기/펼치기
package com.kb.wallet.global.config;
import java.util.HashMap;
import java.util.Map;
import org.redisson.Redisson;
import org.redisson.config.Config;
import org.redisson.spring.data.connection.RedissonConnectionFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class RedisConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
return Redisson.create(config);
}
@Bean
public RedissonConnectionFactory redisConnectionFactory(RedissonClient redissonClient) {
return new RedissonConnectionFactory(redissonClient);
}
}
분산 락 적용
티켓팅 서비스의 비즈니스 로직을 해치지 않도록 횡단 관심사를 분리하여 분산 락을 위한 AOP를 적용해보겠다.
AopForTransaction.java
접기/펼치기
package com.kb.wallet.lock;
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
: 기존 티켓팅 서비스 트랜잭션과 별개로 새로운 트랜잭션을 생성해 독립적으로 처리한다.
joinPoint.proceed()
를 호출하면 새로운 트랜잭션을 생성한 상태에서 원래 실행될 메서드가 실행됨
CustomSpringELParser.java
접기/펼치기
package com.kb.wallet.lock;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
public class CustomSpringELParser {
private CustomSpringELParser() {
}
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
: Spring EL을 이용한 Redis 분산 락에 필요한 동적 Key 값을 설정한다.
DistributedLock.java
접기/펼치기
package com.kb.wallet.lock;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락의 이름
*/
String key();
/**
* 락의 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 락을 기다리는 시간
* 락 획득을 위해 waitTime 만큼 대기한다
*/
long waitTime() default 200L;
/**
* 락 임대 시간
* 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
*/
long leaseTime() default 500L;
}
: 티켓팅 서비스 특성 상 동시 예매가 집중적으로 발생하여 waitTime
과 leaseTime
을 너무 길게 잡으면 비효율적일 수 있다는 점을 고려하여 설정했다.
DistributedLockAop.java
접기/펼치기
package com.kb.wallet.lock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK: ";
private static final long RETRY_DELAY = 2L;
private static final long MAX_RETRY_COUNT = 5L;
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(com.kb.wallet.lock.DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = joinPoint.getTarget()
.getClass()
.getMethod(signature.getMethod().getName(), signature.getMethod().getParameterTypes());
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
boolean available = rLock.tryLock(distributedLock.waitTime(), -1, distributedLock.timeUnit());
if (!available) {
return false;
}
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Redisson Lock Already UnLock {} {}",
method.getName(),
key
);
}
}
}
}
: 실행시간이 너무 오래 걸려서 재시도 로직은 일단 포함하지 않았다.
TicketServiceImpl.java
접기/펼치기
@DistributedLock(key = "#seatId")
@Transactional(rollbackFor = CustomException.class)
@Override
public List<TicketResponse> bookTicket(String email, TicketRequest ticketRequest) {
Member member = memberService.getMemberByEmail(email);
List<TicketResponse> responses = new ArrayList<>();
for (Long seatId : ticketRequest.getSeatId()) {
Ticket bookedTicket = bookTicketForSeat(seatId, ticketRequest.getDeviceId(), member);
responses.add(TicketResponse.toTicketResponse(bookedTicket));
}
return responses;
}
테스트 결과
분산 락 적용을 마치고 이제 비관적 락과 비교하여 성능 테스트를 진행해볼 것이다.
📌 Ngrinder를 활용한 성능 테스트
공통 테스트 환경
서버 장비: 삼성 노트북9 (로컬환경)
장비 스펙: 2코어 CPU / 내장 GPU / (Neural Engine 없음) / 8GB 메모리
1️⃣ redis cli 실행
2️⃣ ngrinder controller 실행
java -jar ngrinder-controller-3.5.9-p1.war --port=7070
3️⃣ ngrinder agent 실행
테스트 스크립트를 작성하고 검증하는데 익숙하지 않아 이해하는데 생각보다 시간이 걸렸다.
회원가입 → 로그인 → 티켓 예매 순서로 진행하며, 로그인 후 받은 JWT를 사용하여 티켓 예매 API를 호출하는 흐름이다. 회원가입은 성능에 영향을 미칠 수 있을 거 같아서 미리 사용자 데이터를 추가해놓고 기능은 주석처리하여 진행했다.
🧑🤝🧑 case 1️⃣. 100명의 사용자가 동시에 예매를 진행
테스트 결과
Pessimistic Lock
Distributed Lock
: Pessimistic Lock이 조금 더 높은 성능을 보인다. 그렇지만 Pessimistic Lock은 테스트 시간이 길어지는 것으로 보아 Vuser가 많아질수록 동시성 처리에서 제약이 생길 수 있을 거 같다.
반대로 Distributed Lock은 비교적 테스트 시간이 짧은 것으로 보아 Vuser가 적은 경우에는 Redis 락이 오히려 성능에서 불리할 수 있지만 분산 환경에서 더 나은 성능을 보일 것으로 예상했다.
그렇지만 결과가 크게 차이가 나지 않기 때문에 Vuser를 늘려 테스트를 좀 더 진행해보기로 했다.
🧑🤝🧑 case 2️⃣. 200명의 사용자가 동시에 예매를 진행
Distributed Lock은 테스트가 가능했지만 Pessimistic Lock은 실패했다. 🚨
실패 원인
nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
: 이 오류는 DB 트랜잭션을 생성하려고 했지만 JDBC Connection
을 할 수 없어 처리할 수 없다는 의미이다. 에러가 발생하는 원인을 추측해보면 다음과 같다.
❗ 트랜잭션 지속 시간이 길어짐
- Pessimistic Lock을 걸면 트랜잭션이 끝날 때까지 DB Connection을 유지해야 함
- JPA에서
@Transactional
이 끝나야 Connection이 반납됨. ➡ 나머지 요청들은 대기 - 동시 요청이 많아지면 Connection Pool이 고갈됨
❗ Deadlock 발생 가능
- 여러 스레드가 같은 row를 동시에 SELECT ... FOR UPDATE <코드> 로 잠그려 하면 서로 대기 상태에 빠짐
- Connection이 계속 유지됨 ➡ Connection Pool이 고갈됨
이로서 동시성 제어에 대한 DB 트랜잭션을 좀 더 안정적으로 운영하기 위해 분산 락을 도입해봤다. 그렇지만 비관적 락과 비교했을 때 단순 Lock 용도로만 쓴다면 새로운 기술 도입을 할 메리트가 있을 정도로 성능 차이가 크지 않다. 결국 모든 트랜잭션은 Redis를 한번 더 거쳤을 뿐 DB로 접근하기 때문이다.
그래서 다음은 Redis의 메모기 기반 빠른 데이터를 저장할 수 있는 캐싱 기능을 도입하여 DB 접근을 줄이는 방식으로 성능을 개선하고자 한다.
고려할 것
- Redis 단일 장애점(SPOF) 문제와 락 대기 시간 증가 가능성을 고려해야 함. (Redis 클러스터 활용 필요)
- 도메인 구조가 문제 없는지 확인
참고 사이트