100개 한정인 영화 티켓을 300명이 동시에 요청한 상황이다. 테스트 코드는 아래와 같다.
@Test
fun `티켓 발급을 동시에 300번 요청했을 때, 100개의 티켓만 생성되어야 한다`() {
val numberOfThread = 300
val executorService = Executors.newFixedThreadPool(numberOfThread)
val latch = CountDownLatch(numberOfThread)
repeat(numberOfThread) { idx ->
executorService.submit {
try {
ticketService.issueTicket(1L, "USER $idx")
} finally {
latch.countDown();
}
}
}
latch.await()
val result = ticketService.countTicket(1L)
assertThat(result).isEqualTo(100)
}
@Service
class TicketService(
private val ticketRepository: TicketRepository,
private val ticketUserRepository: TicketUserRepository
) {
@Transactional
fun issueTicket(ticketId: Long, name: String) {
val ticket = ticketRepository.findById(ticketId).orElseThrow { IllegalStateException("Ticket not found") }
val ticketCount = countTicket(ticketId)
if (ticketCount >= 100) {
throw IllegalStateException("티켓 소진")
}
val ticketUser = TicketUser(ticket = ticket, name = name)
ticketUserRepository.save(ticketUser)
}
fun countTicket(ticketId: Long): Int {
return ticketUserRepository.countByTicketId(ticketId)
}
}

위 서비스 코드는 동시성을 고려하지 않았기 때문에 100개의 티켓보다 더 많이 발급된 것을 볼 수 있다.
간단하게 이를 해결하기 위해서 synchronized를 적용하는 것이 있다.
synchronized 적용synchronized는 특정 스레드가 작업을 수행하는 동안 다른 스레드는 작업을 대기하도록 만들어 주는 블로킹 동기화 방식이다. 메서드 또는 특정 영역에 락을 걸 수 있고, 인스턴스를 기준으로 락을 건다는 것을 유의해야 한다.
class A {
// 메서드에 synchronized를 붙이는 방법
@Synchronized
fun print1() { ... }
// 특정 영역에 synchronized를 붙이는 방법
fun print2() {
...
synchronized(this) {
...
}
...
}
}
// 인스턴스가 다를 시, 2개의 함수가 동시에 실행된다.
val a1 = A()
val a2 = A()
thread { a1.print1() }
thread { a2.print1() }
코틀린에서는 @Synchronized 를 통해서 자바의 synchronized를 메서드에 적용할 수 있다.

하지만 synchronized를 적용한다 해도 정확하게 100개의 티켓이 발급되지 않는 것을 볼 수 있다. 이는 @Transactional 과 synchronized를 동시에 사용할 때 발생하는 문제이기 때문이다.
| 스레드 1 | 스레드 2 | |
|---|---|---|
| 1 | 트랜잭션 시작 | |
| 2 | 서비스 로직 시작 | |
| 3 | 서비스 로직 종료 | |
| 4 | 트랜잭션 시작 | |
| 5 | 서비스 로직 시작 | |
| 6 | 트랜잭션 커밋 | |
| 7 | 서비스 로직 종료 | |
| 8 | 트랜잭션 커밋 |
@Transactional 은 AOP를 통해 서비스 로직이 끝난 후 커밋이나 롤백을 진행한다.
하지만 synchronized는 서비스 로직이 끝나는 즉시 락을 해제하기 때문에, 트랜잭션이 커밋되기 전에 락이 풀려서 여전히 동시성 문제가 발생한다.
현재는 1. 트랜잭션 시작 → 2. 잠금 획득 → 3. 서비스 로직 시작 → 4. 서비스 로직 종료 → 5. 잠금 반납 → 6. 트랜잭션 종료 순으로 진행되고 있어 문제가 발생했다.
이를 1. 잠금 획득 → 2. 트랜잭션 시작 → 3. 서비스 로직 시작 → 4. 서비스 로직 종료 → 5. 트랜잭션 종료 → 6. 잠금 반납 순으로 변경함으로써 문제를 해결하려 한다.
하지만, @Transactional 은 AOP로 동작하기 때문에 잠금 획득 후에 트랜잭션을 실행할 수 없다.
따라서 @Transactional을 사용한 선언적 트랜잭션 관리 방식에서, transactionTemplate을 사용한 프로그래밍 방식의 트랜잭션 관리 방식으로 아래와 같이 변경했다.
(스프링 트랜잭션 관리에 대한 자세한 내용은 해당 아티클을 확인하자)
@Service
class TicketService(
private val ticketRepository: TicketRepository,
private val ticketUserRepository: TicketUserRepository
) {
@Synchronized
fun issueTicketWithSynchronized(ticketId: Long, name: String) {
transactionTemplate.execute {
val ticket = ticketRepository.findById(ticketId).orElseThrow { IllegalStateException("Ticket not found") }
val ticketCount = countTicket(ticketId)
if (ticketCount >= 100) {
throw IllegalStateException("티켓 소진")
}
val ticketUser = TicketUser(ticket = ticket, name = name)
ticketUserRepository.save(ticketUser)
}
}
fun countTicket(ticketId: Long): Int {
return ticketUserRepository.countByTicketId(ticketId)
}
}

이를 통해서 테스트를 성공한 것을 볼 수 있다.
하지만 synchronized 를 통해 동시성을 제거하는 것은 아래와 같은 아쉬운 점이 있다.
모든 티켓 요청에 대해 잠금을 건다.
예를 들어, A 티켓은 100개로 한정되어 있고, B 티켓은 모두 발급할 수 있는 상황에서 [A,A,A,B,B,A,A,A]와 같이 요청이 들어온다면, 들어온 순서대로 요청을 처리한다.
이로 인해 동시성 관리가 필요 없는 B 티켓은 먼저 온 요청이 끝날 때까지 대기해야 하는 상황이 발생한다.
위 상황을 아래 테스트 코드처럼 재현할 수 있다.
@Test
fun `3종류의 티켓 요청을 동시에 900번 요청했을 때, 각 티켓 당 100개의 티켓만 생성되어야 한다`() {
val numberOfThread = 900
val executorService = Executors.newFixedThreadPool(numberOfThread)
val latch = CountDownLatch(numberOfThread)
repeat(numberOfThread) { idx ->
executorService.submit {
try {
ticketService.issueTicketWithSynchronized((idx % 3) + 1L, "USER $idx")
} finally {
latch.countDown();
}
}
}
latch.await()
val result1 = ticketService.countTicket(1L)
val result2 = ticketService.countTicket(2L)
val result3 = ticketService.countTicket(3L)
assertThat(result1).isEqualTo(100)
assertThat(result2).isEqualTo(100)
assertThat(result3).isEqualTo(100)
}

테스트는 성공하지만, 900개의 요청을 순차적으로 수행하기 때문에 3~4초 정도 걸리는 것을 볼 수 있다.
분산 환경에서 관리할 수 없다.
분산 환경에서는 요청이 각각 다른 서버로 전달될 수 있기 때문에, 단일 서버 내에서의 동기화만으로는 문제가 해결되지 않는다.
따라서, synchronized만으로는 동시성 문제를 완벽히 해결할 수 없다. 분산 환경에서도 문제를 해결하기 위해서는 분산락 방식을 사용하는 등의 방법이 필요하다.
다음 글에서 데이터베이스의 네임드 락을 이용한 분산락 방식에 대해 알아보겠다.
참고
[Java] 혼동되는 synchronized 동기화 정리
[10분 테코톡] 알렉스, 열음의 멀티스레드와 동기화 In Java