트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized

2025. 1. 3. 00:46·etc

들어가기 전..

 

Spring @Transactional with synchronized keyword doesn't work

Let's say I have a java class with a method like this (just an example) @Transactional public synchronized void onRequest(Request request) { if (request.shouldAddBook()) { if (database.

stackoverflow.com

해당 스택오버플로우 질문을 참고하여 글을 작성하였습니다.

 

프로젝트를 진행하면서 마주친 동시성 문제의 해결을 위해 자료를 찾아보던 중 `@Transactional`과 `synchronized`를 함께 사용하면 문제가 발생한다는 것을 알게 되었는데요.

 

이유가 궁금해 관련 내용들을 파고들다 보니, 단순히 두 키워드에 관한 내용을 넘어 트랜잭션 격리 수준과 동시성 제어에 관해 생각해 볼 수 있는 좋은 계기가 될 듯하여 관련 내용을 정리해 보게 되었습니다.

 

따라서 제 호기심의 흐름에 따라 궁금한 주제들에 대해 이것저것 이야기해 보는.. 다소 갈대 같은 포스팅이 될 것 같습니다 🙇‍♀️

 

포스팅 목표

동시성 제어가 필요한 환경에서 `@Transactional`과 `synchronized` 키워드를 함께 사용했을 경우 발생하는 문제와 그 원인에 대해 알아보고, 해결 과정에서 트랜잭션 격리 수준(주로 `SERIALIZABLE` 관련 이야기)과 동시성 제어 방법에 대해 고민하고 학습한 내용을 정리합니다.

 

먼저 각 키워드의 간단한 개념에 대해 알아보겠습니다.

 

@Transactional

`@Transactional`은 타겟의 작업이 하나의 트랜잭션으로 실행되도록 하며, 이를 통해 데이터베이스 커넥션 작업에서 ACID를 보장합니다.

내부 구현방식의 간단한 흐름

Spring에서는 내부적으로 AOP와 프록시를 이용하여 트랜잭션 로직을 구현하고, 프록시를 통해 타겟 전후에 트랜잭션 시작, 트랜잭션 완료 로직을 주입하는 방식으로 동작합니다. Spring Boot에서는 이를 추가 설정 없이 `@Transactional`어노테이션을 붙이는 것만으로 간편하게 사용할 수 있습니다.

이 글에서도 필요 시, 스택오버플로우 질문에서처럼 어노테이션 적용 흐름을 |-B-|-I-|-C-→ 와 같이 간단히 표기하겠습니다.

  • |--Spring begins transaction--|-----invoke target----- |--Spring commits transaction --→
  • 간단하게 : |-B-|-I-|-C-→

 

synchronized

`synchronized`를 추가함으로써 타겟 메서드를 동기화할 수 있습니다. (특정 메서드뿐 아니라 코드블록을 지정해서 코드블록 내부에 `synchronized`를 적용할 수도 있습니다)

 

멀티스레드 환경에서 한 스레드가 메서드를 실행 중이라면, 해당 메서드 작업이 종료되기 전까지 메서드를 호출하는 다른 모든 스레드는 block 됩니다. (한 번에 하나의 스레드만 메서드에 접근할 수 있게 해 줌)

Lost Update 문제 상황 확인해 보기

동시성 문제를 테스트해 보기 위해서 하나의 레코드에 여러 스레드들이 접근해서 count값을 1씩 소모시키는 아주 간단한 코드를 예시로 진행해 보겠습니다. (선착순 티켓 문제나 재고 소진 문제와 유사하게요)

여기서 TestEntity는 MySQL innoDB에 저장되어 있는 테이블로, 앞으로의 모든 예시는 MySQL 9.1을 가지고 진행합니다.

 

count는 100개로 세팅했고, 150개의 스레드를 생성하여 테스트합니다. 실행 후 count가 0이고, 예외는 50건이 발생하면 올바른 결과입니다.

    public void test() {
        TestEntity entity = testRepository.findById(1L).orElseThrow();

        int count = entity.getCount();
        if (count > 0) {
            entity.setCount(count - 1);
        } else {
            throw new IllegalStateException("숫자가 모두 소진되었습니다.");
        }

        testRepository.save(entity);
    }
    @Test
    void 동시에_150개_스레드가_100개_count를_차감하면_예외는_50번_count는_0이_된다() throws InterruptedException {
        int threadCount = 150;
        CountDownLatch latch = new CountDownLatch(threadCount);
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        List<Throwable> exceptions = new CopyOnWriteArrayList<>();
        SoftAssertions soft = new SoftAssertions();

        for(int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    test.test();
                } catch (Exception e) {
                    exceptions.add(e);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();

        TestEntity entity = testRepository.findById(1L).orElseThrow();

        soft.assertThat(entity.getCount()).isEqualTo(0);
        soft.assertThat(exceptions.size()).isEqualTo(50);
        soft.assertAll();
    }

1. Transactional 추가 후 확인

우선 단순히 `@Transactional`만을 추가하면 결과는 어떻게 될까요? 다음과 같이 변경하고 테스트를 해보겠습니다.

    @Transactional // 어노테이션 추가
    public void test() {
        TestEntity entity = testRepository.findById(1L).orElseThrow();

        int count = entity.getCount();
        if (count > 0) {
            entity.setCount(count - 1);
        } else {
            throw new IllegalStateException("숫자가 모두 소진되었습니다.");
        }

        testRepository.save(entity);
    }

남은 count는 83이고, 예외는 0으로 테스트가 실패한다.

결과를 살펴보면 count 값은 0이 아닌 83이고, 예외는 한건도 발생하지 않았습니다.

 

동시성 문제를 방지하기 위한 장치가 따로 없는 상태이기 때문에, 스레드끼리의 Race Condition으로 인해 Lost Update문제가 발생하게 되었는데요.

 

이를 해결하기 위해 가장 간단한 첫 번째 방법으로 `synchronized` 키워드를 생각해 볼 수 있습니다.

2. synchronized 추가 후 확인

먼저 `@Transcational` 없이 `synchronized`만 단독으로 사용하여 테스트해 보겠습니다.

    public synchronized void test() {
        TestEntity entity = testRepository.findById(1L).orElseThrow();

        int count = entity.getCount();
        if (count > 0) {
            entity.setCount(count - 1);
        } else {
            throw new IllegalStateException("숫자가 모두 소진되었습니다.");
        }

        testRepository.save(entity);
    }

테스트 성공

단일 서버 환경에서의 테스트이기 때문에, 스레드들이 순차적으로 메서드에 접근하여 문제없이 테스트가 성공합니다.

 

하지만 코드를 이 상태 그대로 사용하는 것은 위험한 일입니다. 메서드 안 DB 작업이 여러 개가 존재하기 때문에 우리는 이 작업들이 하나의 트랜잭션에서 실행되도록 처리를 해주어야만 합니다.

3. @Transactional & synchronized 동시 추가 후 확인

그렇다면 `@Transactional`과 `synchronized`를 동시에 사용해 봅시다! 결과는 어떻게 될까요?

언뜻 생각해 보면 문제없이 트랜잭션 작업(Transactional)이 동기적으로 진행(synchronized)되어 테스트가 성공할 것 같다는 생각이 듭니다.

    @Transactional
    public synchronized void test() {
        TestEntity entity = testRepository.findById(1L).orElseThrow();

        int count = entity.getCount();
        if (count > 0) {
            entity.setCount(count - 1);
        } else {
            throw new IllegalStateException("숫자가 모두 소진되었습니다.");
        }

        testRepository.save(entity);
    }

남은 count는 50이고 예외는 0건으로 실패

하지만 예상과는 달리 count 값은 50이고, 예외도 발생하지 않아 테스트가 실패하는 결과가 나옵니다.

즉, `@Transactional`과 `synchronized`를 함께 사용하면 예상한 대로 작동하지 않고, 마치 `@Transactional`만을 사용했을 때처럼 Lost Update 문제가 발생합니다.

이유가 무엇일까?

직관적으로 보았을 때 잘못될 이유가 없어보이는 상황이라 굉장히 당황스러운 결과가 아닐 수 없는데요. 왜 올바르게 작동하지 않는지를 이해하기 위해 트랜잭션 코드의 흐름으로 돌아가 보겠습니다.

 

우선 코드는 트랜잭션 시작, 타겟 메서드 실행, 트랜잭션 커밋으로 진행되고 다음과 같이 표현 가능합니다.

  • |-B-|-test()-|-C--|-->

주목해야 할 점은 이 흐름에서 `synchronized`이 적용되는 코드는 중간에 있는 `test()` 뿐이라는 것입니다. Spring이 추가한 앞 뒤의 transaction 작업코드는 `synchronized`이 적용되지 않은 코드로, 동기화된 메서드가 아닙니다!

 

따라서 1번 트랜잭션에서 `test()`를 빠져나가서 commit 코드가 실행 완료되기 전에, 다른 트랜잭션에서 곧바로 `test()`를 호출할 수 있게 됩니다.

  • T1: |-B-|-test()-|-C--|-->
  • T2: |-B---------|-test()-|-C-->

이처럼 T1에서의 변경사항이 반영되기 전에 T2가 트랜잭션을 시작하고 값을 읽어와서 변경작업을 할 수 있기 때문에, 결국 `synchronized`를 사용하지 않았을 때와 마찬가지로 문제가 발생할 가능성이 생깁니다.

# 다음과 같은 예시를 가정해보겠습니다

1. 트랜잭션 1의 작업 begin (count : 100)
2. 트랜잭션 2의 작업 begin (count : 100)
---- start T1 synchronized ---- (트랜잭션 2는 test 코드에 접근하지 못하고 대기)
3. 트랜잭션 1이 test 코드 접근하여 count : 100 값을 99로 변경
---- end T1 synchronized ----
---- start T2 synchronized ----
4. 트랜잭션 2가 test 코드 접근하여 count: 100 값을 99로 변경
---- end T2 synchronized ----
5. 트랜잭션 1의 작업 commit
6. 트랜잭션 2의 작업 commit

==> 최종 값은 98이 아닌 99 (Lost Update 문제 발생)

 

해결 방법에 대한 고민

이 문제를 어떤 방법으로 해결하면 좋을지에 대해 스택오버플로우 글의 답변과 코멘트들을 살펴보면, 트랜잭션의 격리 수준에 대한 이야기가 먼저 나오는데요.

 

우선 글에도 언급되어 있듯이 헷갈리지 말아야 할 점이 있는데, 당연하게도 트랜잭션 격리 수준과 동시성 제어 방법은 동일한 개념이 아닙니다.

  • 트랜잭션 격리 수준 : 작업의 ACID 보장을 위해 DB 트랜잭션끼리의 간섭, 고립(isolation) 수준을 어디까지 허용할 것인가와 관련
  • 동시성 제어 방법  : 여러 작업이 동시에 하나의 데이터에 접근, 작업 시 발생하는 충돌 문제를 해결하여 순차적으로 작업이 이루어져 결과가 일관성과 무결성을 가질 수 있도록 하는 것과 관련  

트랜잭션 격리 수준이 동시성과 아예 연관이 없다는 것은 아닙니다. 격리 수준 설정을 통해 트랜잭션들이 동시에 데이터에 접근하는 상황에서 어떻게 할 것인지를 결정할 수 있는데, 이것 역시 DB 데이터에 관한 동시성 제어이지요.

 

하지만 위와 같은 상황에서의 Lost Update 문제를 트랜잭션의 격리 수준만으로 해결하려는 것은 다음과 같은 이유로 적절하지 않다고 보았습니다. (Lost Update 뿐 아니라 Write Skew 문제 역시도 마찬가지라고 생각합니다)

  • 만일 격리 수준을 가장 엄격한 `SERIALIZABLE`로 변경한다면 코드에서 별다른 컨트롤 없이도 동시성 문제가 발생하지 않을 것이라 예상됩니다. 하지만 `SERIALIZABLE`는 쓰기 뿐 아니라 읽기 작업에서도 락이 걸리기 때문에 DB 전체에 영향을 미칩니다. 이는 하나의 트랜잭션이 특정 레코드를 쓰고 읽는 경우 해당 레코드 접근이 필요한 나머지 모든 트랜잭션들이 대기하게 되어 DB 성능이 심각하게 느려질 수 있음을 의미합니다.
  • 격리 수준만이 동시성 제어에 있어 유일한 방법이 아니라는 점입니다. 이를 개선하기 위해 `SERIALIZABLE` 대신 InnoDB 디폴트 수준인 `REPEATABLE READ`을 그대로 사용하되 원하는 특정 데이터에만 락을 걸도록 설정하는 등과 같이 동시성 문제를 예방하며 성능적으로 더 좋은 방법들이 다수 존재합니다.

물론 상황에 따라서는 격리 수준을 조정하는 것만으로 문제를 해결할 수도 있겠지만, 락과 같은 다른 동시성 제어 방법을 함께 적용한다면 좀 더 근본적이고 효율적으로 동시성 문제를 해결할 수 있을 것이라고 생각합니다.

 

마무리

이제 다음으로는 생각해 낸 해결방법들이 문제를 해결할 수 있을지 직접 확인을 해봐야 할 것 같은데요. 우선 첫 번째 방법부터 테스트를 해볼 계획입니다. 과연.. 예상대로 한 번에 테스트에 성공할 수 있을까요?!

 

이어지는 다음 포스팅에서 실제로 격리 수준의 변경으로 문제를 해결할 수 있는지 테스트하고, 그 과정에서 발생한 문제점의 이유와 해결 방법에 대해 알아보겠습니다.

 

 

트랜잭션 격리 수준과 동시성 제어 이야기 (2) : SERIALIZABLE과 Deadlock

포스팅 목표 트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized들어가기 전.. Spring @Transactional with synchronized keyword doesn't workLet's say I have a java class with a method like this (just an ex

seondays.tistory.com

 

 

'etc' 카테고리의 다른 글

트랜잭션 격리 수준과 동시성 제어 이야기 (2) : SERIALIZABLE과 Deadlock  (3) 2025.01.05
왜 내가 만든 서버는 서버가 먼저 요청을 끊는 걸까? 궁금증 해결기  (0) 2024.08.23
리눅스 scp 사용 시 Permission denied (publickey).lost connection 오류 해결기  (0) 2024.04.13
'etc' 카테고리의 다른 글
  • 트랜잭션 격리 수준과 동시성 제어 이야기 (2) : SERIALIZABLE과 Deadlock
  • 왜 내가 만든 서버는 서버가 먼저 요청을 끊는 걸까? 궁금증 해결기
  • 리눅스 scp 사용 시 Permission denied (publickey).lost connection 오류 해결기
seondays
seondays
  • seondays
    Maybe seondays
    seondays
  • 전체
    오늘
    어제
    • 분류 전체보기 (38)
      • python (5)
      • Java (16)
      • Dart (0)
      • 문제정리 (13)
      • etc (4)
  • 태그

    트러블슈팅
    Python
    buddyguard
  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
seondays
트랜잭션 격리 수준과 동시성 제어 이야기 (1) : @Transactional과 synchronized
상단으로

티스토리툴바