락을 활용해서 동시성 문제를 해결해보자
Careerbee에 신기능을!
CareerBee 서비스에 곧 포인트 상점이 들어옵니다!
포인트를 사용해 응모권(티켓)을 구매할 수 있는데요
티켓은 매주 100개씩 충전되며, 유저들은 한정된 티켓수 안에서 선착순으로 구매해야 합니다.
이를 위해 백엔드 개발자인 저는 열심히 티켓 구매 기능을 만들고 있습니다..
문제 발생
기능은 만들었습니다. 바로 아래와 같이 말이죠
@Override
public void purchaseTicket(TicketPurchaseReq ticketPurchaseReq, Long accessMemberId) {
Member member = memberQueryService.findById(accessMemberId);
Ticket ticket = storeQueryService.findTicketByType(ticketPurchaseReq.ticketType());
member.minusPoint(ticket.getPrice());
ticket.use();
purchaseHistoryRepository.save(PurchaseHistory.of(member, ticket));
}
POSTMAN을 활용해서 테스트도 진행해 봤고, 단위 테스트코드를 통해서도 검증을 완료한 코드입니다.
이제 통합테스트를 통해 재고 감소 테스트를 진행만 하면 실제 상점 서비스를 열 수 있겠으니 얼른 테스트코드를 작성해 봅시다.
@Test
void 재고_100개에_100명이_동시_구매하면_재고가_0이_돼야함 throws InterruptedException {
int initialStock = 100;
int requestCount = 100;
// given
ticket = createTicket(initialStock, 100, "test.url", RED);
ticketRepository.save(ticket);
ExecutorService executorService = Executors.newFixedThreadPool(20);
CountDownLatch latch = new CountDownLatch(requestCount);
TicketPurchaseReq request = new TicketPurchaseReq(ticket.getType());
for (long i = 0; i < requestCount; i++) {
final long idx = i;
executorService.execute(() -> {
try {
Member m = createMember("nick" + idx, "email" + idx + "@test.com", idx);
m.plusPoint(200_000);
memberRepository.saveAndFlush(m);
storeCommandService.purchaseTicket(request, m.getId());
} catch (Exception e) {
System.out.println("예외 발생: " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await();
Ticket updated = ticketRepository.findById(ticket.getId()).orElseThrow();
long successCount = purchaseHistoryRepository.count();
System.out.println("남은 재고 = " + updated.getQuantity());
System.out.println("구매 성공 수 = " + successCount);
assertThat(updated.getQuantity()).isEqualTo(0);
assertThat(successCount).isEqualTo(initialStock);
}
테스트코드를 실행해서 결과를 지켜봅시다.
실패했습니다. 포스트맨을 통한 테스트에선 잘 됐는데 뭐가 문제일까요?
동시성 문제 (Race Condition)
재고 감소 문제가 발생한 원인은 바로 동시성 문제가 발생했기 때문입니다.
위에서 살펴봤던 비즈니스 로직의 도식화를 통해 동시성 문제가 왜 발생했는지 다뤄보겠습니다.
- 공유자원(티켓 수량)에 동시에 접근 및 업데이트
Thread A와 Thread B가 동시에 Ticket의 수량을 감소시켰고, 기대했던 98이 아닌 99가 반환되는 문제가 발생합니다.
동시성 문제가 발생한 것이며, 이를 어떻게 방지할 수 있는지를 아래에서 살펴보겠습니다.
동시성 문제를 해결하는 세 가지 방법
1. 비관적 락 (Pessimistic Lock)
비관적 락은 데이터를 읽을 때부터 락을 걸어서 다른 트랜잭션에서 읽거나 수정하는 것을 막는 방식입니다.
코드로는 아래와 같이 표현할 수 있겠습니다.
// JPA Repository
public interface TicketRepository extends
JpaRepository<Ticket, Long>, TicketCustomRepository {
...
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from Ticket t where t.type = :type")
Ticket findWithPessimisticLockByType(@Param("type") TicketType type);
}
이를 서비스 코드에 적용한 후 테스트코드를 실행해 봅시다.
정확히 100개가 감소했으며, 테스트가 성공했습니다!
이를 도식화해 보면 아래와 같이 표현할 수 있습니다.
비관적 락은 다음과 같은 장점이 있습니다.
- 동시에 여러 트랜잭션이 접근하더라도 Lock으로 무결성을 유지
- 락이 선점되므로 버전 충돌이나 중복 업데이트 없음
- 낙관적 락과 달리 실패 후 재시도 로직을 작성할 필요 없음
비관적 락은 다음과 같은 단점이 있습니다.
- 락을 잡는 동안 다른 트랜잭션은 대기 → 병목 가능
- 여러 트랜잭션에서 서로 여러 데이터를 요청하면 데드락 발생 가능
따라서 비관적 락은 다음과 같은 상황에 적절합니다
- 실시간 정합성이 중요할 때
- 충돌이 잦은 경우
2. 낙관적 락 (Optimistic Lock)
낙관적 락은 락을 미리 걸지 않고, 수정 시점에 버전(Version)을 비교해서 충돌을 감지하는 기법입니다.
JPA를 사용 중이라면 @Version을 활용하여 엔티티의 버전관리를 통해 충돌을 감지할 수 있습니다.
이를 위해 Ticket 엔티티에 코드를 추가해 줍시다.
public class Ticket extends BaseEntity {
...
@Version
private Long version;
...
}
그리고 테스트코드를 실행해 보죠.
예외 발생: could not execute batch [Deadlock found when trying to get lock; try restarting transaction] [update ticket set img_url=?,modified_at=?,price=?,quantity=?,type=?,version=? where id=? and version=?]; SQL [update ticket set img_url=?,modified_at=?,price=?,quantity=?,type=?,version=? where id=? and version=?]
예외 발생: could not execute batch [Deadlock found when trying to get lock; try restarting transaction] [update ticket set img_url=?,modified_at=?,price=?,quantity=?,type=?,version=? where id=? and version=?]; SQL [update ticket set img_url=?,modified_at=?,price=?,quantity=?,type=?,version=? where id=? and version=?]
예외 발생: could not execute batch [Deadlock found when trying to get lock; try restarting transaction] [update ticket set img_url=?,modified_at=?,price=?,quantity=?,type=?,version=? where id=? and version=?]; SQL [update ticket set img_url=?,modified_at=?,price=?,quantity=?,type=?,version=? where id=? and version=?]
예외 발생: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update ticket set img_url=?,modified_at=?,price=?,quantity=?,type=?,version=? where id=? and version=?
테스트의 결과로 두 가지 예외가 발생하고 있음을 알 수 있습니다.
- Deadlock
- 낙관적 락 충돌
데드락의 경우 현재 batch_size를 30으로 설정해 두었는데, 이는 ticket 엔티티에 대한 update 쿼리가 모여있다가 batch_size 만큼 쌓였을 때 flush를 수행하며 이 순간에 발생하고 있습니다.
현재 테스트에선 낙관적 락 충돌을 확인하고 싶으므로 잠시 batch_size를 0으로 바꿔서 실행해 보겠습니다.
2025-07-07T20:47:43.425+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 1회
2025-07-07T20:47:43.487+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 2회
2025-07-07T20:47:43.543+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 3회
2025-07-07T20:47:43.602+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 4회
2025-07-07T20:47:43.656+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 5회
2025-07-07T20:47:43.712+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 6회
2025-07-07T20:47:43.768+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 7회
2025-07-07T20:47:43.825+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 8회
2025-07-07T20:47:43.884+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 9회
2025-07-07T20:47:43.944+09:00 WARN 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌 발생 - 재시도 10회
2025-07-07T20:47:44.003+09:00 ERROR 22043 --- [ool-3-thread-12] o.c.c.d.s.s.c.StoreCommandServiceImpl : 충돌로 인한 실패 - 재시도 초과
낙관적 락 충돌의 경우 JPA flush/commit 시점에서 version 값이 이미 변경된 상태라 충돌이 발생했다고 에러를 내뱉는 것이며, 애플리케이션 단에서 충돌에 대한 재시도를 직접 다뤄줘야 하며, 위에 로그가 이에 해당합니다.
→ 현재 실행 결과는 동시성 문제를 해결하지 못했다고 나옵니다. 이를 위해 스레드 수를 줄여 경합을 줄이거나, 재시도 횟수를 늘리는 방식을 택할 수 있겠습니다.
동작 흐름은 다음과 같습니다.
낙관적 락의 장점은 다음과 같습니다.
- 락이 없기 때문에 병렬 처리 성능이 높음
- DB 락을 사용하지 않기 때문에 데드락 위험이 없음
- 여러 트랜잭션이 동시에 읽을 수 있음
낙관적 락의 단점은 다음과 같습니다.
- 동시에 update시 version 충돌로 인해 예외 발생
- 충돌이 발생하면 재시도 로직을 직접 작성해야 함
낙관적 락이 적절한 상황은 다음과 같습니다.
- 실시간 정합성을 덜 중요하고, 충돌이 적은 상황
3. 분산 락 (Distributed Lock)
분산 락은 여러 서버에서 공유 자원에 동시에 접근하지 못하도록 막는 방식입니다.
락을 위해 synchronized 키워드를 사용하거나, ReentrantLock을 사용할 수 있지만 이는 JVM 내에서만 유효하기 때문에 여러 서버 (분산 시스템)에서는 유효하지 않습니다.
따라서 이런 분산 환경에서 공유자원에 안전히 접근할 수 있도록 중앙 제어락이 필요한데 이때 사용할 수 있는 방식이 분산 락입니다.
현재 프로젝트에서 사용 중인 Redisson을 활용하여 분산락을 적용해 보겠습니다.
@Transactional
public class StoreCommandServiceImpl implements StoreCommandService {
...
private final RedissonClient redissonClient;
@Override
public void purchaseTicket(TicketPurchaseReq ticketPurchaseReq, Long accessMemberId) {
String lockKey = "lock:ticket:" + ticketPurchaseReq.ticketType();
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS); // wait 5s, lease 10s
if (!isLocked) {
throw new IllegalStateException("티켓 락 획득 실패");
}
Member member = memberQueryService.findById(accessMemberId);
Ticket ticket = storeQueryService.findTicketByType(ticketPurchaseReq.ticketType());
member.minusPoint(ticket.getPrice());
ticket.use();
purchaseHistoryRepository.save(PurchaseHistory.of(member, ticket));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("티켓 락 획득 중 인터럽트 발생", e);
} finally {
if (isLocked) {
lock.unlock();
}
}
}
}
위와 같이 코드를 구성했고, 테스트코드를 실행해 봅시다.
남은 재고 = 50
구매 성공 수 = 98
org.opentest4j.AssertionFailedError:
expected: 0
but was: 50
Expected :0
Actual :50
결과를 보면 동시성 문제가 전혀 해결되지 않았습니다.
이 문제는 트랜잭션의 시작 시점에 문제가 있기 때문에 발생했습니다.
현재 클래스 레벨로 @Transactional 이 적용됨을 볼 수 있는데요.
이 때문에 purchaseTicket() 메서드에서 락 획득 전에 트랜잭션이 시작됐기 때문입니다.
이 말은 락이 걸리기 전에 DB connection이 열리고, 이 커넥션이 경쟁에 들어간다는 의미입니다.
이러면 분산락의 의미가 없어집니다.
이를 위해 @Transactional의 위치를 클래스가 아닌 락 획득 후 트랜잭션이 시작될 수 있도록 아래와 같이 수정할 수 있겠습니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class StoreCommandServiceImpl implements StoreCommandService {
private final PurchaseHistoryRepository purchaseHistoryRepository;
private final MemberQueryService memberQueryService;
private final StoreQueryService storeQueryService;
private final RedissonClient redissonClient;
@Override
public void purchaseTicket(TicketPurchaseReq ticketPurchaseReq, Long accessMemberId) {
String lockKey = "lock:ticket:" + ticketPurchaseReq.ticketType();
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!isLocked) {
throw new IllegalStateException("티켓 락 획득 실패");
}
executePurchase(ticketPurchaseReq, accessMemberId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("티켓 락 획득 중 인터럽트 발생", e);
} finally {
if (isLocked) {
lock.unlock();
}
}
}
@Transactional
public void executePurchase(TicketPurchaseReq ticketPurchaseReq, Long accessMemberId) {
Member member = memberQueryService.findById(accessMemberId);
Ticket ticket = storeQueryService.findTicketByType(ticketPurchaseReq.ticketType());
member.minusPoint(ticket.getPrice());
ticket.use();
purchaseHistoryRepository.save(PurchaseHistory.of(member, ticket));
}
}
코드도 수정했으니 테스트를 다시 실행해 봅시다.
남은 재고 = 100
구매 성공 수 = 100
org.opentest4j.AssertionFailedError:
expected: 0
but was: 100
Expected :0
Actual :100
골 때립니다. 구매 성공 수와 남은 재고수가 모두 100으로 같습니다.
이는 다음을 뜻합니다.
- PurchaseHisotry 엔티티는 100개가 잘 삽입됨.
- Ticket 엔티티는 재고 업데이트가 제대로 되지 않음.
이런 문제가 발생한 이유는 다음과 같습니다.
- PurchaseHistory는 명시적인 save() 호출로 인해 DB에 100개의 row가 insert 됨
- Ticket 엔티티는 영속성 컨텍스트에 로드되었지만 Dirty Checking이 동작하지 않아서 재고 수량이 제대로 반영되지 않음
Ticket엔티티가 영속성 컨텍스트에 로드됐지만 더티체킹이 동작하지 않은 이유가 뭘까요?
이는 @Transactional이 제대로 동작하지 않음을 시사합니다.
트랜잭션이 없기 때문에 변경이 감지되지 않았고, flush 되지 않았기 때문에 update 쿼리가 날아가지 않은 것입니다.
이는 Spring AOP 기반 애너테이션을 사용할 때 항상 주의해야 할 점을 잊어서 발생한 문제였습니다.
Spring AOP는 프록시 기반으로 동작합니다. 여기서 주의해야 할 점은 다음과 같습니다.
- 같은 클래스 내의 메서드를 호출할 시 AOP 적용이 안된다.
위의 코드를 보면 StoreCommandServiceImpl 클래스의 purchaseTicket 메서드에서 executePurchase 메서드를 직접 호출하고 있으며, 실제로 다음과 같이 실행됩니다.
this.executePurchase(ticketPurchaseReq, accessMemberId);
즉, 프록시 객체를 사용하지 원본 객체에 직접 접근하게 됩니다.
따라서 AOP 적용이 되지 않고 이어서 @Transactional 이 적용이 안된 것입니다.
이를 해결하기 위한 가장 간단한 방법은 새로운 Bean을 등록하고 이를 호출하여 프록시 객체가 호출되도록 만드는 것입니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class StoreCommandServiceImpl implements StoreCommandService {
private final RedissonClient redissonClient;
// 트랜잭션이 적용될 메서드를 클래스 분리!
private final StoreTransactionService storeTransactionService;
@Override
public void purchaseTicket(TicketPurchaseReq ticketPurchaseReq, Long accessMemberId) {
String lockKey = "lock:ticket:" + ticketPurchaseReq.ticketType();
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!isLocked) {
throw new IllegalStateException("티켓 락 획득 실패");
}
storeTransactionService.executePurchase(ticketPurchaseReq, accessMemberId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("티켓 락 획득 중 인터럽트 발생", e);
} finally {
if (isLocked) {
lock.unlock();
}
}
}
}
@Component
@RequiredArgsConstructor
public class StoreTransactionService {
private final MemberQueryService memberQueryService;
private final StoreQueryService storeQueryService;
private final PurchaseHistoryRepository purchaseHistoryRepository;
@Transactional
public void executePurchase(TicketPurchaseReq ticketPurchaseReq, Long accessMemberId) {
Member member = memberQueryService.findById(accessMemberId);
Ticket ticket = storeQueryService.findTicketByType(ticketPurchaseReq.ticketType());
member.minusPoint(ticket.getPrice());
ticket.use();
purchaseHistoryRepository.save(PurchaseHistory.of(member, ticket));
}
}
이렇게 클래스 파일을 분리해 주었고, 따라서 트랜잭션도 잘 적용될 것입니다.
테스트 코드를 실행해 보겠습니다.
드디어 분산락을 통한 동시성 문제가 제대로 해결된 것을 볼 수 있습니다.
분산락의 동작 흐름은 다음과 같습니다.
분산락의 장점은 다음과 같습니다.
- 멀티 인스턴스 환경에서도 데이터 정합성 보장 가능
- 비즈니스 로직 차원에서 락 제어 가능
- 이는 DB의 행 레벨 락을 거는 비관적 락 방식에 비해 성능이 좋음
- 락 범위의 유연성
분산락의 단점은 다음과 같습니다.
- 락 서버(Redis) 장애 시 전체 시스템에 영향
- 락 서버가 단일 인스턴스인 경우 단일 실패지점(SPOF)이 될 수 있어 위험
- 락 획득 후 락 해제 누락 시 데드락 발생
- 락 해제 누락된 경우 한 인스턴스에서 락을 계속 소유하기 때문에 데드락 발생 가능
분산락은 이런 상황에 적절합니다.
- 멀티 서버 환경에서 공유 자원에 대한 접근이 필요한 경우
- DB 락으로는 컨트롤이 어려운 도메인 락이 필요할 때
- DB에 락 부담을 주고 싶지 않은 경우
회고
이전에 비관적 락, 낙관적 락, 분산 락 등 개념 정도만 알고 있었고 실제 사용은 간단하게 분산락 정도만 사용해 봤습니다.
하지만 티켓 구매 시 발생할 수 있는 동시성 문제를 세 가지 락으로 모두 해결해 보며 각 락 방식의 장단점을 깨달을 수 있었습니다.
또한, 락을 활용하여 동시성 문제를 해결해 가는 과정 속에서 Spring AOP, 트랜잭션의 동작방식과 동시성 관련 테스트코드 작성법등을 배울 수 있었습니다.