[Spring] 트랜잭션과 동시성

2025. 6. 25. 20:26·Spring Framework/Spring
반응형

처음 Spring을 공부할 때 감명받은 기능 중 하나가 @Transcational이었다. 애너테이션만 붙여주면 일일히 커넥션을 열고 커서를 가져오고 작업하고 리소스를 닫는 등 번거로운 작업들이 알아서 적용되니 정말 편리했다. 그러다보니 트랜잭션이 필요한 곳에서는 관성적으로 @Transactional를 추가하고 모든 문제가 해결됐다고 생각했다. 하지만 서비스 환경에서 멀티 스레드 상황이라면 문제가 생길 수 있다.

트랜잭션, 격리 수준, 락킹, 동시성 제어 등 여러 용어들은 많이 들어보고, 또 알고 있다고 생각했지만 각각의 개념이 서로 연결되지 않은 느낌이 들었다. 동시성 제어에 대해 다시 공부를 시작하면서 차근차근 정리해나가려고한다. 우선은 가장 기본적인 트랜잭션과 @Transactional에 대해 포스팅을 했다.

문제 상황

예시로 쇼핑몰에서 포인트로 상품을 구매하는 기능을 개발하는 상황이다. 사용자가 보유한 포인트보다 적은 금액의 상품만 구매할 수 있어야하고 구매 후 사용자의 보유 포인트를 차감해야한다.

@Service
@Transactional
@Slf4j
public class PointService {

    @Autowired
    private MemberPointRepository memberPointRepository;

    public void deductPoints(Long memberId, int amount) {
        MemberPoint memberPoint = memberPointRepository.findById(memberId)
            .orElseThrow(() -> new MemberNotFoundException(memberId));

        if (memberPoint.getPoints() >= amount) {  // 1. 포인트 잔액 확인
            memberPoint.setPoints(memberPoint.getPoints() - amount);  // 2. 포인트 차감
            memberPointRepository.save(memberPoint);
            log.info("포인트 차감 완료: {}P", amount);
        } else {
            throw new InsufficientPointsException("포인트가 부족합니다");
        }
    }
}

동시 요청이 들어왔을 때의 문제

하지만 다음 상황에서 문제가 발생할 수 있다. Spring WebMVC는 요청당 하나의 스레드를 생성한다. 즉, 두 개의 HTTP 요청이 동시에 들어오면 두 개의 독립적인 스레드에서 각각의 트랜잭션이 실행된다.

사용자가 1000포인트를 보유한 상황에서 같은 시간에 두 번의 구매 요청이 들어온다고 가정해본다.

상황

  • 요청 1 : 600포인트 차감
  • 요청 2 : 700포인트 차감

원하는 결과

  • 요청 1 : 성공한다 - 잔액: 400포인트
  • 요청 2 : 포인트가 부족해 실패한다.

실제로 일어난 결과

  • 최종적으로 보유 포인트가 300포인트가 되었다.
  • 원래는 요청1만 성공해야하는데 두 요청이 모두 성공해서 사용자는 상품 2개를 구매하고 700 포인트를 소모했다.

왜 이런 일이 생길까?

문제의 핵심은 두 트랜잭션이 같은 데이터를 동시에 읽었다는 것이다.

  • 두 트랜잭션 모두 사용자의 보유 포인트를 1000으로 읽었다.
  • 각각 독립적으로 포인트 차감 계산 수행했다.
  • 나중에 커밋된 트랜잭션이 이전 결과를 덮어썼다.

실제 테스트로 확인해보기

이 문제를 실제로 재현해보겠습니다:

@SpringBootTest
class PointServiceTest {
    
    @Autowired
    private PointService pointService;
    
    @Test
    @DisplayName("동시 포인트 차감 시 갱신 손실 문제 재현")
    void testConcurrentPointDeduction() throws InterruptedException {
        *// Given: 사용자 생성 (1000포인트)*
        User user = new User("testUser", 1000);
        userRepository.save(user);
        
        ExecutorService executor = Executors.newFixedThreadPool(2);
        CountDownLatch latch = new CountDownLatch(2);
        
        *// When: 동시에 포인트 차감 요청*
        executor.submit(() -> {
            try {
                pointService.deductPoints(user.getId(), 600);
            } catch (Exception e) {
                System.out.println("요청 1 실패: " + e.getMessage());
            } finally {
                latch.countDown();
            }
        });
        
        executor.submit(() -> {
            try {
                pointService.deductPoints(user.getId(), 700);
            } catch (Exception e) {
                System.out.println("요청 2 실패: " + e.getMessage());
            } finally {
                latch.countDown();
            }
        });
        
        latch.await();
        
        *// Then: 결과 확인*
        User updatedUser = userRepository.findById(user.getId()).get();
        System.out.println("최종 포인트: " + updatedUser.getPoints());
        *// 예상: 400 또는 1000 (한 요청만 성공)// 실제: 300 (두 요청 모두 성공했지만 잘못된 결과)*
    }
}

동시성 문제의 두 가지 패턴

앞서 살펴본 상황은 동시성 환경에서 반복적으로 나타나는 전형적인 패턴이다. 동시성 문제는 크게 두 가지 핵심 패턴으로 분류할 수 있으며, 포인트 차감 예제에서 이 두 패턴을 모두 확인할 수 있다.

Read-Modify-Write

  • 데이터를 읽고 → 계산하고 → 결과를 저장하는 일련의 과정
  • 이 세 단계가 원자적으로 실행되지 않을 때 발생
public void deductPoints(Long userId, int amount) {
    MemberPoint memberPoint = memberPointRepository.findById(userId);  // Read
    int newPoints = memberPoint.getPoints() - amount;                   // Modify
    memberPoint.setPoints(newPoints);                                   // Write
    memberPointRepository.save(memberPoint);
}

문제 발생 과정:

  • 1300포인트를 차감했지만 실제로는 700포인트만 줄어들었다.

Check-Then-Act 패턴

  • 조건을 확인 → 그 결과에 따라 행동하는 패턴
  • 확인한 조건이 행동하는 시점에는 더 이상 유효하지 않을 수 있다.

포인트 차감에서의 예시

public void deductPoints(Long userId, int amount) {
    MemberPoint memberPoint = memberPointRepository.findById(userId);

    if (memberPoint.getPoints() >= amount) {  // Check
        // 포인트가 충분하다고 확인했지만...
        memberPoint.setPoints(memberPoint.getPoints() - amount);  // Act
        memberPointRepository.save(memberPoint);
    } else {
        throw new InsufficientPointsException("포인트가 부족합니다");
    }
}

 

  • 각 트랜잭션은 충분한 포인트가 있다고 판단했지만, 실제로는 초과 사용이 발생했다.

위 두 가지 상황 모두 다른 트랜잭션이 같은 데이터를 변경하지 않을 것이라는 잘못된 가정 때문에 발생했다. 데이터를 읽은 시점과 쓰는 시점의 간격이 있는데, 그 시간동안 다른 트랜잭션이 데이터를 변경해 문제가 된다. 이런 문제를 방지하기 위해 추가적인 동시성 제어 메커니즘이 필요하다.

@Transactional

분명 @Transactional을 붙였는데 왜 이런 문제가 생기는 걸까? 이를 이해하기 위해서는 @Transactional이 정확히 무엇을 보장하는지, 그리고 무엇을 보장하지 않는지 명확히 구분해야 한다. 먼저 트랜잭션의 속성에 대해 다시 정리했다.

ACID 속성

  • Atomicity(원자성) : 트랜잭션 내의 모든 작업이 전부 성공하거나 전부 실패한다. 중간에 예외가 발생하면 모든 변경사항이 롤백된다.
  • Consistency(일관성) : 데이터베이스의 제약 조건과 비즈니스 규칙이 항상 유지된다. 제약 조건을 위반하는 변경은 롤백된다.
  • Durability(지속성) : 커밋된 트랜잭션의 결과는 시스템 장애가 발생해도 영구 보존된다.
  • Isolation(격리성) : 동시 실행되는 트랜잭션들이 서로 영향을 주지 않아야 한다.

포인트 차감 문제는 격리성을 보장하지 않기 때문에 발생했다. 두 트랜잭션이 동시에 같은 memberPoint 데이터를 수정하는데, 나중에 커밋된 트랜잭션이 이전 트랜잭션의 변경사항을 덮어써버린 것이다. @Transactional은 왜 격리를 해주지 않았을까?

격리 수준

격리 수준(Isolation Level)은 동시에 실행되는 트랜잭션들 사이에서 얼마나 엄격하게 데이터를 격리할지를 결정하는 설정이다. 격리 수준이 높을수록 데이터 일관성은 보장되지만, 단위시간당 처리량은 떨어진다.

격리 수준 설명 발생 가능한 문제

READ UNCOMMITTED 커밋되지 않은 데이터도 읽을 수 있음 Dirty Read, Non-repeatable Read, Phantom Read
READ COMMITTED 커밋된 데이터만 읽을 수 있음 Non-repeatable Read, Phantom Read
REPEATABLE READ 트랜잭션 내에서 같은 데이터를 여러 번 읽어도 항상 같은 결과 Phantom Read
SERIALIZABLE 트랜잭션들이 순차적으로 실행되는 것과 같은 결과 없음

격리 수준별 발생하는 문제들

Dirty Read : 아직 커밋되지 않은 데이터를 읽는다.

 

Non-repeatable Read : 같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 다른 결과가 나온다.

 

Phantom Read : 같은 조건으로 조회했는데 이전에 없던 레코드가 나타난다.

 

READ COMMITTED와 포인트 차감 문제

포인트 차감 예시에서 사용한 PostgreSQL에서는 기본 격리 수준으로 READ COMMITTED를 사용한다. 이 격리 수준에서는 Dirty Read는 방지되지만 Non-repeatable Read, Phantom Read가 발생할 수 있다.

sequenceDiagram
    participant T1 as 트랜잭션1 (600P 차감)
    participant DB as 데이터베이스
    participant T2 as 트랜잭션2 (700P 차감)

    Note over DB: READ COMMITTED 격리 수준

    T1->>DB: memberPoint 조회
    DB-->>T1: 1000P (커밋된 데이터)

    T2->>DB: memberPoint 조회
    DB-->>T2: 1000P (커밋된 데이터)

    Note over T1,T2: 두 트랜잭션 모두 같은 시점의<br/>커밋된 데이터를 읽었으므로<br/>READ COMMITTED 규칙은 지켜짐

    T1->>DB: 400P로 업데이트 후 커밋
    T2->>DB: 300P로 업데이트 후 커밋

    Note over DB: 결과: Lost Update 발생

READ COMMITTED로 커밋된 데이터만 읽는다는 것을 보장했지만, 같은 데이터를 동시에 수정하는 것은 막지못했다. 이것이 바로 @Transactional만으로는 동시성 문제가 해결되지 않는 이유였다.

정리

@Transactional은 개별 작업의 완전성을 보장해주는 도구이지만, 여러 실행 단위간의 충돌 방지를 위해서는 별도의 메커니즘이 필요하다. 포인트 차감 같은 비즈니스 로직에서 동시성 문제를 해결하려면 @Transactional 외에 추가적인 동시성 제어 방법이 필요하다. 각 해결 방법마다 고유한 장단점과 적용 시나리오가 있으며 충돌 빈도, 성능 요구사항, 시스템 아키텍처에 따라 적절한 방법을 선택해야한다.

  • 트랜잭션 격리 수준 조정 : 더 엄격한 격리 수준으로 문제를 원천 차단
  • 락(Lock) 메커니즘 활용 : 데이터를 읽거나 수정하기 전 다른 트랜잭션의 접근을 차단
  • 애플리케이션 레벨 동시성 제어 : 데이터베이스 외부에서 동시성을 제어

포스팅을 작성하며 정리하면서 트랜잭션과 동시성 제어를 구분하는게 뭔가 낯설게 느껴졌다. 평소 그러려니하고 넘어갔던 부분들이 머리속에 들어오는 느낌이다. 앞으로 관련 포스팅을 더 정리해보면서 깊이 있게 학습해볼 예정이다.

반응형
저작자표시 비영리 변경금지 (새창열림)
'Spring Framework/Spring' 카테고리의 다른 글
  • [Spring Batch] 메타 데이터 테이블 정리
  • [Spring Batch] skip / retry
  • [Spring Boot] 자동 설정(Auto Configuration)
  • [JPA] 연관 관계
덴마크초코우유
덴마크초코우유
IT, 알고리즘, 프로그래밍 언어, 자료구조 등 정리
    반응형
  • 덴마크초코우유
    이것저것끄적
    덴마크초코우유
  • 전체
    오늘
    어제
    • 분류 전체보기 (127)
      • Spring Framework (11)
        • Spring (6)
        • JPA (3)
        • Spring Security (0)
      • Language (52)
        • Java (12)
        • Python (10)
        • JavaScript (5)
        • NUXT (2)
        • C C++ (15)
        • PHP (8)
      • DB (16)
        • MySQL (10)
        • Reids (3)
        • Memcached (2)
      • 개발 (3)
      • 프로젝트 (3)
      • Book (2)
      • PS (15)
        • 기타 (2)
        • 백준 (2)
        • 프로그래머스 (10)
      • 딥러닝 (8)
        • CUDA (0)
        • Pytorch (0)
        • 모델 (0)
        • 컴퓨터 비전 (4)
        • OpenCV (1)
      • 기타 (16)
        • 디자인패턴 (2)
        • UnrealEngine (8)
        • ubuntu (1)
        • node.js (1)
        • 블로그 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 미디어로그
    • 위치로그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    게임
    map
    C++
    CPP
    Unreal Engine
    FPS
    게임 개발
    MySQL
    PS
    mscoco
    select
    언리얼엔진4
    클래스
    Unreal
    NUXT
    JavaScript
    php
    웹
    memcached
    프로그래머스
    자바
    파이썬
    블루프린트
    redis
    알고리즘
    pytorch
    C
    Python
    JS
    딥러닝
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
덴마크초코우유
[Spring] 트랜잭션과 동시성
상단으로

티스토리툴바