동시성 제어하기 1 : synchronized

Woody·2024년 8월 30일

TIL

목록 보기
9/19

예제 상황

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개의 티켓이 발급되지 않는 것을 볼 수 있다. 이는 @Transactionalsynchronized를 동시에 사용할 때 발생하는 문제이기 때문이다.

스레드 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 를 통해 동시성을 제거하는 것은 아래와 같은 아쉬운 점이 있다.

  1. 모든 티켓 요청에 대해 잠금을 건다.

    예를 들어, 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초 정도 걸리는 것을 볼 수 있다.

  1. 분산 환경에서 관리할 수 없다.

    분산 환경에서는 요청이 각각 다른 서버로 전달될 수 있기 때문에, 단일 서버 내에서의 동기화만으로는 문제가 해결되지 않는다.

따라서, synchronized만으로는 동시성 문제를 완벽히 해결할 수 없다. 분산 환경에서도 문제를 해결하기 위해서는 분산락 방식을 사용하는 등의 방법이 필요하다.

다음 글에서 데이터베이스의 네임드 락을 이용한 분산락 방식에 대해 알아보겠다.

참고
[Java] 혼동되는 synchronized 동기화 정리
[10분 테코톡] 알렉스, 열음의 멀티스레드와 동기화 In Java

0개의 댓글