동시성 테스트(Flaky Test 삽질기)

2025. 8. 9. 17:33·Loopers/테크니컬 라이팅
더보기

한줄요약 :
'좋아요 취소' 기능의 동시성 테스트가 간헐적으로 실패하는 'Flaky Test' 현상을 겪으며,
'조회 후 삭제(Find-Then-Delete)' 패턴의 레이스 컨디션(Race Condition) 문제를 발견하고,
이를 해결할 방법으로 '소프트 삭제(Soft Delete)'와 낙관적 락을 찾은 일기 입니다.

문제의 시작: 변덕스러운 테스트

프로젝트에 동시성 제어 로직을 추가하고 자신 있게 전체 테스트를 실행했다. 하지만 결과는 당혹스러웠다.

분명 방금 전까지 단독으로 실행했을 땐 성공했던 '좋아요 취소' 동시성 테스트가, 전체 테스트에서는 실패했다.

더 이상한 건,  전체 테스트를 몇 번을 다시 돌려보면 어떨 땐 또 성공한다는 점이었다.

// 문제가 발생했던 테스트 코드
@Test
@DisplayName("낙관적 락: 동일한 '좋아요'에 대해 동시에 여러 취소 요청이 발생하면, 단 한 번만 처리된다.")
void optimisticLock_preventsConcurrentUnlike() throws InterruptedException {
    // ... arrange: '좋아요' 1개 생성 ...

    // act: 10개의 스레드가 동시에 '좋아요 취소' 요청
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                likeAppService.unlike(USER_ID, PRODUCT_ID, LikeType.PRODUCT);
                successCount.incrementAndGet();
            } catch (ObjectOptimisticLockingFailureException e) {
                // 낙관적 락 충돌이 발생하면 이곳으로!
                failureCount.incrementAndGet();
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    // assert: 성공 1번, 실패 9번을 기대!
    assertThat(successCount.get()).isEqualTo(1);
    assertThat(failureCount.get()).isEqualTo(9); // 👈 여기서 실패!
}

 

찾아보니 테스트 시에 결과가 매번 달라지는 문제를 Flaky Test(변덕스러운 테스트) 문제라고 한다.

그리고 Flaky Test는 단순한 테스트의 문제가 아니라, 코드에 잠재된 심각한 동시성 문제를 알려주는 위험 신호다.


원인 분석: '조회 후 삭제'의 미세한 틈

문제의 원인은 unlike 메소드의 find-then-delete 구조에 있었다.

@Transactional
public void unlike(Long userId, Long productId, LikeType likeType) {
    // 1. 먼저 '좋아요' 데이터를 DB에서 찾아온다. (find)
    likeRepository.findByUserIdAndTargetIdAndType(userId, productId, likeType)
            // 2. 찾았으면, 그 객체를 삭제한다. (delete)
            .ifPresent(likeRepository::delete);
}

 

이 로직은 1번(find)과 2번(delete) 사이에 아주 짧지만 시간적 틈(Time Gap)을 가지고 있었다.

이 틈이 시스템의 부하 상태, 즉 스레드들의 실행 타이밍에 따라 다른 결과를 만들어냈다.

마치 경마장의 사진 판독과 같았다.

  • 테스트 성공 시 (단독 실행)
    • 10개의 스레드가 거의 동시에 도착했다.
      첫 번째 스레드가
      DELETE에 성공하자, JPA는 나머지 9개 스레드가 삭제하려던 데이터의 버전(@Version)이
      맞지 않음을 감지하고, 우리가 예상했던
      ObjectOptimisticLockingFailureException을 정확히 발생시켰다.
  • 테스트 실패 시 (전체 테스트 실행)
    • 시스템이 바빠지자 타이밍이 꼬였다.
      첫 번째 스레드가
      DELETE를 실행하고 DB에서 데이터가 완전히 사라진 후,
      뒤늦게 도착한 다른 스레드가 삭제를 시도했다.
      JPA는 "버전이 안 맞네?"가 아니라 "삭제할 데이터가 아예 없는데?"라고 판단했고,
      우리가 예상치 못한 다른 종류의 예외를 던졌다.
      결국
      catch 블록이 이를 잡지 못해 failureCount가 오르지 않았고, 테스트는 실패했다.

해결을 위한 고민: 비관적 락 vs 낙관적 락

이 불안정함을 해결할 방법은 두 가지였다.

  1. 비관적 락(Pessimistic Lock)
    가장 간단하고 확실한 방법.
    조회하는 순간부터
    SELECT ... FOR UPDATE로 로우에 락을 걸어, 다른 스레드의 접근을 원천 차단한다.
    하지만 '좋아요'처럼 빈번하지만 덜 중요한 기능에 DB 락을 거는 것은 과하게 느껴졌고
    이미 다른 도메인을 비관적 락으로 진행했기 때문에 낙관적 락으로 진행하길 원했다.

  2. 낙관적 락(Optimistic Lock) 유지
    낙관적 락의 장점을 유지하면서 문제를 해결하고 싶었다.
    핵심은
    DELETE 연산을 안정적인 UPDATE 연산으로 바꾸는 것이었다. 바로 '소프트 삭제(Soft Delete)' 패턴이다.

💭 해결?

다른 방법이 있는지 피드백을 받기 위하여 실제 코드를 바꾸진 않았지만
만약 소프트 딜리트를 이용한 낙관적 락으로 수정 시 어떤식으로 진행해야 할지 적어보겠다.

 

1. Like 엔티티에서 데이터를 실제로 삭제하는 대신, 상태를 변경하도록 로직을 수정했다.

// Like.java
public enum LikeStatus { ACTIVE, CANCELED }

@Entity
public class Like extends BaseEntity {
    // ...
    
    // Like 엔티티에 상태 필드 추가 👈
    @Enumerated(EnumType.STRING)
    private LikeStatus status;

    @Version 👈 낙관적 락 적용을 위한 필드 추가
    private Long version;

    public void cancel() {
        this.status = LikeStatus.CANCELED;
    }
    // ...
}

 

2. 기존 unlike 메소드를 이에 맞게 수정

// LikeApplicationService.java
@Transactional
public void unlike(Long userId, Long productId, LikeType likeType) {
    try {
        likeRepository.findByUserIdAndTargetIdAndTypeAndStatus(...)
                .ifPresent(like -> {
                    like.cancel(); // 상태 변경
                });
    } catch (ObjectOptimisticLockingFailureException e) {
        // 동시 UPDATE 충돌 시 예외를 잡아서 정상 처리
        System.out.println("낙관적 락 충돌 발생 (정상 처리)");
    }
}

 

이 수정을 통해, 여러 스레드가 동시에 unlike를 호출해도 가장 먼저 커밋하는 스레드만 UPDATE에 성공하고 
version을 증가시킨다.

나머지 스레드들은 변경된 version 때문에 ObjectOptimisticLockingFailureException을 만나게 되어, 

테스트는 어떤 상황에서도 항상 일관된 결과를 보장하게 된다고 한다.


배우고 느낀 점

이번 경험을 통해 Flaky Test라는 문제를 알게되었다
동시성 문제는 종종 미묘한 타이밍의 문제로 나타나며, 가장 견고한 해결책은 레이스 컨디션이 발생할 수 있는
'시간적 틈' 자체를 없애는 설계라는 것을 배울 수 있었다.
처음엔 테스트가 실패하는 이유를 몰라 막막했지만, 그 원인을 깊이 파고들어 더 나은 설계로 코드를 개선할 수 있는 방법을
찾아 나가는 과정은 정말 값진 경험이었다

'Loopers > 테크니컬 라이팅' 카테고리의 다른 글

Kafka 찍먹해보기! : Kafka통한 서비스 경계를 넘는 이벤트 파이프라인을 구축기  (0) 2025.09.05
Spring 이벤트를 처음 써보며 깨달은 것들  (1) 2025.08.28
DB 조회 및 정렬 성능 개선하기(비정규화, 인덱스, 캐시(Redis))  (1) 2025.08.15
다들 이해하지?! 설계의 청사진 시퀀스 다이어그램  (0) 2025.07.25
TDD, 실패하는 테스트가 알려준 것들: 아래에서 내려다보는 TDD  (0) 2025.07.18
'Loopers/테크니컬 라이팅' 카테고리의 다른 글
  • Spring 이벤트를 처음 써보며 깨달은 것들
  • DB 조회 및 정렬 성능 개선하기(비정규화, 인덱스, 캐시(Redis))
  • 다들 이해하지?! 설계의 청사진 시퀀스 다이어그램
  • TDD, 실패하는 테스트가 알려준 것들: 아래에서 내려다보는 TDD
KBroJ9210
KBroJ9210
  • KBroJ9210
    개발일기
    KBroJ9210
  • 전체
    오늘
    어제
    • 분류 전체보기 (25)
      • 토스 러너스하이 2기 (11)
        • 회고 (1)
        • 기술 (10)
      • Loopers (9)
        • 테크니컬 라이팅 (6)
        • WIL(What I Learned) (3)
      • 두리두리넋두리 (5)
        • 개발일기 (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
KBroJ9210
동시성 테스트(Flaky Test 삽질기)
상단으로
목차

    티스토리툴바