Skip to content

Conversation

@songsunkook
Copy link
Collaborator

@songsunkook songsunkook commented Dec 23, 2025

🔍 개요

매우 간헐적으로 발생하던 AB테스트 데드락 문제의 근본 원인을 해결합니다.


문제 상황

같은 사용자가 동시에 2번 이상 AB테스트 실험군 조회 api를 호출할 경우 간헐적으로 데드락이 발생한다.

발생 조건

  1. AB 테스트 참가 이력이 있는 사용자(has AccessHistoryId)의 요청
  2. 비로그인 사용자(userId = null)의 요청
  3. 로그인 후 실험군 조회 api 최초 요청
  4. 단일 페이지에 2개 이상의 AB테스트가 진행중인 곳에 접근한 경우
    1. 단일 페이지 내 2개 실험이 아닌, 따닥 이슈라고 볼 수도 있음

위 4가지 조건이 모두 참일 때 발생한다.

근본 원인

여러 스레드가 공유락을 쥔 상태에서 배타락으로 승격하기 위해 서로의 공유락 해제를 대기하는 현상이 발생했다.

원인

  1. FK 컬럼을 가진 레코드를 INSERT하면 FK 참조 대상 레코드에 공유락이 걸리는데, 이게 원인이 되었다.
    1. AccessHistory에 device 정보를 연결하기 위해 AccessHistory에 배타락이 필요한데 이미 다른 트랜잭션에 의해 공유락이 걸려있어서 데드락이 발생한다.
  2. update(배타락) 이후 자식 insert(공유락)는 문제없다. 하지만 자식 insert(공유락) 이후 update(배타락)는 데드락이 발생할 수 있다.
    1. 코드 상의 순서로는 update 이후 insert하지만, jpa sql 전송 순서 조정에 의해 insert 이후 update되어 문제가 발생한다

문제 발생 시나리오

데드락 다이어그램
  1. 기존 AccessHistory를 새로운 실험군과 바인딩한다.(AccessHistoryAbtestVariable 생성)
    1. AccessHistoryAbtestVariable에 fk로 AccessHistory가 들어가는데, 이 때 fk 대상 레코드(accessHistory)에 공유락이 걸린다.
  2. 기존 AccessHistory에 새로운 디바이스를 만들어 바인딩한다.
    1. 디바이스를 만들어 AccessHistory의 fk로 등록하는데, 여기서 AccessHistory를 수정하기 위해 배타락을 걸어야 한다.
    2. 그런데 다른 트랜잭션이 이미 AccessHistory에 공유락을 걸어둔 상태라서 배타락을 취득할 수 없고, 공유락이 풀리길 기다린다.
    3. 상대방 트랜잭션도 마찬가지로 공유락을 기다려서, 무한 대기에 빠져 데드락이 발생한다.

코드 상의 원인

assignOrGetVariable() 메서드에서 createDeviceIfNotExists 이후 accessHistory.addAbtestVariable를 진행한다.

  1. createDeviceIfNotExists는 device를 save하고 accessHistory에 연결하며,
  2. accessHistory.addAbtestVariable는 accessHistoryAbtestVariable을 save한다.

@GeneratedValue(IDENTITY)에 의해 device insert → flush → accessHistoryAbtestVariable insert → flush 가 일어난다.

1번 createDeviceIfNotExists 에서 device를 insert하면서 device_id를 accessHistory에 적용했는데, 이 update는 jpa sql 쓰기지연 때문에 바로 날아가지 않는다. 그리고 2번 accessHistoryAbtestVariable을 insert할 때 flush가 발생하면서 기존 쓰기지연 sql 저장소에 들어있던 accessHistory UPDATE 쿼리도 함께 날아간다. 그런데 이 때 JPA 내부 최적화에 의해 쿼리 발송 순서가 insert 이후 update로 변경된다.

결국 accessHistory에 공유락이 걸린 후 device_id 컬럼을 수정하고자 해서 배타락 취득이 필요해졌고, 동시에 같은 accessHistoryId를 가진 요청이 들어올 경우 서로가 가진 공유락 해제를 기다리다가 데드락이 발생한다.

해결 방안

  1. 공유락과 배타락 트랜잭션을 분리한다(적용했으나, 제대로 적용되지 않음)
    1. 실험군 배정 API와 토큰 등록 API를 분리했지만, 실제로는 디바이스 등록이 실험군 배정 안에 포함되어 있어서 지금도 데드락이 발생한다.
  2. 처음 조회부터 명시적 배타락으로 조회(가장 구현이 쉽고, 사이드이펙트도 거의 없음)
  3. 낙관적 락 구현
  4. 분산 락 사용
  5. 명시적 flush()
    1. 코드 순서만 변경해도, JPA 쿼리 순서는 변경되지 않고 고정되기 때문에, 명시적으로 flush()하여 배타락을 먼저 얻고, 이후에 공유락을 얻도록 로직을 수정한다.
  6. 외래키 제거
    1. 참조 레코드에 자동으로 공유락이 걸리는 것을 막는다.

최종 선택: 명시적 배타락으로 조회하기

왜 명시적 배타락으로 해소했는가?
리소스가 가장 적게 들기 때문. 동일 accessHistoryId로 동시성 요청이 꾸준히 발생하는 환경이라면 배타락이 성능 상 안좋을 수 있다. 하지만 지금 환경은 해당 상황이 거의 연출되지 않기 때문에 큰 문제가 없다고 판단함.

검증용 테스트코드

제가 세운 데드락 발생 가설을 검증하기 위해 테스트를 진행했습니다. 다만 가설 검증일 뿐, 커밋은 불필요한 내용이기에 커밋하지 않고 PR 본문에 명시합니다.

테스트 코드
package in.koreatech.koin.admin.abtest.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

import in.koreatech.koin.admin.abtest.exception.AccessHistoryNotFoundException;
import in.koreatech.koin.admin.abtest.model.AccessHistory;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;

public interface AccessHistoryRepository extends Repository<AccessHistory, Integer> {

    Optional<AccessHistory> findById(Integer id);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
    @Query("select ah from AccessHistory ah where ah.id = :id")
    Optional<AccessHistory> findByIdForUpdate(@Param("id") Integer id);

    void saveAndFlush(AccessHistory history);
}
public interface AccessHistoryAbtestVariableRepository extends Repository<AccessHistoryAbtestVariable, Integer> {

    AccessHistoryAbtestVariable save(AccessHistoryAbtestVariable accessHistoryAbtestVariable);

    void saveAndFlush(AccessHistoryAbtestVariable build);
}
// @Transactional 주석처리(여러 트랜잭션의 동시성 테스트를 위함)
public abstract class AcceptanceTest { ... }
class DeadlockReproductionTest extends AcceptanceTest {

    @Autowired private AccessHistoryRepository accessHistoryRepository;
    @Autowired private DeviceRepository deviceRepository;
    @Autowired private UserRepository userRepository;
    @Autowired private PlatformTransactionManager transactionManager;
    @Autowired private UserAcceptanceFixture userAcceptanceFixture;
    @Autowired private AbtestRepository abtestRepository;
    @Autowired private AbtestVariableRepository abtestVariableRepository;
    @Autowired private AccessHistoryAbtestVariableRepository accessHistoryAbtestVariableRepository;

    private AbtestVariable variable1;
    private AbtestVariable variable2;

    @BeforeEach
    void setUp() {
        clear();
        Abtest test = Abtest.builder()
            .title("deadlock-test")
            .displayTitle("데드락 테스트")
            .status(AbtestStatus.IN_PROGRESS)
            .creator("test")
            .team("test")
            .build();

        test.getAbtestVariables().add(
            AbtestVariable.builder().abtest(test).name("A").displayName("A안").rate(50).build()
        );
        test.getAbtestVariables().add(
            AbtestVariable.builder().abtest(test).name("B").displayName("B안").rate(50).build()
        );
        Abtest savedTest = abtestRepository.save(test);

        this.variable1 = savedTest.getAbtestVariables().stream().filter(v -> v.getName().equals("A")).findFirst().get();
        this.variable2 = savedTest.getAbtestVariables().stream().filter(v -> v.getName().equals("B")).findFirst().get();
    }

    private static Stream<Arguments> deadlockScenarios() {
        return Stream.of(
            arguments("데드락 재현 (Lock 없음)", false, 1, 1),
            arguments("데드락 방지 (Lock 사용)", true, 2, 0)
        );
    }

    @DisplayName("데드락 시나리오 테스트")
    @ParameterizedTest(name = "[{index}] {0}")
    @MethodSource("deadlockScenarios")
    void deadlockTest(String testName, boolean useLock, int expectedSuccess, int expectedDeadlock)
        throws InterruptedException {
        // [1] 데이터 세팅
        User user = userAcceptanceFixture.코인_유저();
        AccessHistory targetHistory = accessHistoryRepository.save(AccessHistory.builder().build());
        Integer historyId = targetHistory.getId();
        Integer userId = user.getId();

        // [2] 동시성 제어
        ExecutorService executor = Executors.newFixedThreadPool(2);
        final CyclicBarrier barrier = new CyclicBarrier(2);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger deadlockCount = new AtomicInteger(0);

        // [3] 데드락 유발 또는 회피 로직
        Function<Integer, Runnable> task = (variableId) -> () -> {
            TransactionTemplate tm = new TransactionTemplate(transactionManager);
            try {
                tm.execute(status -> {
                    // 1. useLock 값에 따라 분기하여 부모(AccessHistory) 조회
                    AccessHistory history = useLock
                        ? accessHistoryRepository.findByIdForUpdate(historyId).orElseThrow()
                        : accessHistoryRepository.findById(historyId).orElseThrow();

                    AbtestVariable variable = abtestVariableRepository.getById(variableId);

                    // 2. 자식(AccessHistoryAbtestVariable) Insert
                    accessHistoryAbtestVariableRepository.saveAndFlush(
                        AccessHistoryAbtestVariable.builder()
                            .accessHistory(history)
                            .variable(variable)
                            .build()
                    );

                    // 3. 데드락 재현 시나리오일 때만 스레드 타이밍 제어
                    if (!useLock) {
                        try {
                            barrier.await(5, TimeUnit.SECONDS);
                        } catch (Exception e) {
                            throw new RuntimeException(e);
                        }
                    }

                    // 4. 부모(AccessHistory) Update
                    Device newDevice = deviceRepository.save(
                        Device.builder().user(userRepository.findById(userId).get()).model("test").type("mobile").build()
                    );
                    history.connectDevice(newDevice);
                    accessHistoryRepository.saveAndFlush(history);

                    return null;
                });
                successCount.incrementAndGet();
            } catch (DeadlockLoserDataAccessException | CannotAcquireLockException e) {
                System.out.println("💥 데드락 또는 락 충돌 발생! " + Thread.currentThread().getName());
                deadlockCount.incrementAndGet();
            } catch (Exception e) {
                // 데드락 재현 시나리오에서 발생하는 예외(Timeout, BrokenBarrier)는 무시
                if (useLock) {
                    e.printStackTrace();
                }
            }
        };

        // [4] 두 스레드 동시 실행
        executor.submit(task.apply(variable1.getId()));
        executor.submit(task.apply(variable2.getId()));

        executor.shutdown();
        executor.awaitTermination(10, TimeUnit.SECONDS);

        // [5] 검증
        System.out.printf("테스트: %s -> 성공: %d, 데드락/충돌: %d%n", testName, successCount.get(), deadlockCount.get());

        assertThat(successCount.get()).isEqualTo(expectedSuccess);
        assertThat(deadlockCount.get()).isEqualTo(expectedDeadlock);
    }
}

테스트 결과

  • 데드락 재현 시나리오(현안)
    • 💥 데드락 또는 락 충돌 발생! pool-8-thread-2
      테스트: 데드락 재현 (Lock 없음) -> 성공: 1, 데드락/충돌: 1
  • 데드락 방지 시나리오(개선안)
    • 테스트: 데드락 방지 (Lock 사용) -> 성공: 2, 데드락/충돌: 0
image

기존의 API 분리로 인해 데드락 발생 빈도가 줄어든 이유

API가 분리되며 하나의 API에서 수행하는 작업량이 줄어들었다. 비록 데드락이 발생하는 공유락과 배타락을 분리하지는 못했지만, API 작업량이 줄어들면서 트랜잭션의 활성시간도 함께 줄어들어 경합이 발생할 확률이 줄어들었다.

조건 중에 “로그인 후 실험군 조회 api 최초 요청”이 있는데, 이게 시간이 지날수록 발생할 확률을 낮추는 요인이라고 볼 수도 있다.

💬 참고 사항


✅ Checklist (완료 조건)

  • 코드 스타일 가이드 준수
  • 테스트 코드 포함됨
  • Reviewers / Assignees / Labels 지정 완료
  • 보안 및 민감 정보 검증 (API 키, 환경 변수, 개인정보 등)

@songsunkook songsunkook self-assigned this Dec 23, 2025
@songsunkook songsunkook added the 버그 정상적으로 동작하지 않는 문제상황입니다. label Dec 23, 2025
@github-actions
Copy link

github-actions bot commented Dec 23, 2025

Unit Test Results

672 tests   669 ✔️  1m 18s ⏱️
165 suites      3 💤
165 files        0

Results for commit 92aff06.

♻️ This comment has been updated with latest results.

Copy link
Collaborator

@BaeJinho4028 BaeJinho4028 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실상 2줄 PR이지만, 많은 고민이 담겨있네요.
잘 봤습니다 🙇

Copy link
Collaborator

@Soundbar91 Soundbar91 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

작성해주신 내용 잘 확인했습니다 유익했습니다 !
한 가지 사례 공유 코멘트 남깁니답

최근에 발생한 데드락 에러 로그입니다. 요청이 한 번 왔는데 500 에러가 발생해서 문제 상황과 불일치한 거 같습니다.
image

의문이 들어서 로그를 볼 수 있나 찾아보던 중, MySQL에서 데드락 로그를 볼 수 있다고해서 가져왔습니다.

동일한 access_history에 대해 서로 다른 device들이 업데이트를 치는 과정에서 데드락이 발생한 것으로 해석했습니다. 해당 로그에서 확인이 되는 device_id 중 하나는 DB 상에 남아있지 않고, device 테이블에 가보면 동시간에 동일한 user_id에 대해서 device가 2개가 생성됨을 확인할 수 있었습니다.

device가 두 개 생기는 것을 막는다면, 락 문제도 해결되지 않을까 ? 라는 생각이 들어서 의견 남겨봅니다..!

틀린 부분이 있다면 피드백 주시면 감사하겠습니다 (__)

@songsunkook
Copy link
Collaborator Author

@Soundbar91
최근 데드락 로그를 찾아볼 수 없었는데, 마침 얼마전에 데드락이 발생했군요! 로그까지 가져와주셔서 감사합니다 😄

요청이 한 번 왔는데 500 에러가 발생해서 문제 상황과 불일치한 거 같습니다.

데이터독은 샘플링이 적용되어 성공한 요청에 대해서는 일정 비율만큼만 제공하고, 실패한 요청만 전부 제공한다고 합니다. 실제로 애플리케이션 로그를 확인해보니 동시에 들어온 요청을 확인할 수 있었습니다. 제공해주신 DB 로그에서도 트랜잭션이 2개임을 알 수 있구요.


말씀해주신 시나리오는 적절하고, 실제로 제가 제시한 문제 상황과 일치합니다.

데드락 발생 조건은 다음과 같습니다.(본문과 동일)

  1. AB 테스트 참가 이력이 있는 사용자(has AccessHistoryId)의 요청
  2. 비로그인 사용자(userId = null)의 요청
  3. 로그인 후 실험군 조회 api 최초 요청
  4. 단일 페이지에 2개 이상의 AB테스트가 진행중인 곳에 접근한 경우
    • 단일 페이지 내 2개 실험이 아닌, 따닥 이슈라고 볼 수도 있음

동일한 access_history에 대해 서로 다른 device들이 업데이트를 치는 과정에서 데드락이 발생한 것으로 해석했습니다.

맞습니다. (조건 1번)AB테스트 참가 이력이 있는 사용자라면, (조건 4번)동일한 access_history에 2번 이상의 요청이 동시에 수행됩니다.
device는 로그인 사용자에 한해 정보를 관리하는데요,
실험군 조회 API 로직을 확인해보면, (조건 2번)비로그인 사용자의 (조건 3번)로그인 후 최초 api 호출 시, 로그인 사용자임에도 device 정보가 비어있어 이를 삽입해줘야 합니다. 서로 다른 device를 업데이트하는 이유는 동일한 access_history 요청이 동시에 들어왔을 때, 각 트랜잭션 입장에서는 연관된 device가 없어서 각자 새로운 device를 만들어주는 것입니다. 그리고 이 때 access_history.device_id를 수정하는 과정에서 동일한 access_history에 대해 서로 다른 device들이 업데이트를 시도하게 됩니다.

해당 로그에서 확인이 되는 device_id 중 하나는 DB 상에 남아있지 않고, device 테이블에 가보면 동시간에 동일한 user_id에 대해서 device가 2개가 생성됨을 확인할 수 있었습니다.

이것도 맞습니다. 로그를 확인해보면, 트랜잭션 1, 2가 동시에 공유락을 쥔 상태로 배타락 승격을 대기하는데요, 여기서 DB는 교착 상태를 감지하고 트랜잭션 2를 강제 롤백시켰습니다. 그럼 트랜잭션 1만 커밋이 될텐데, 각 트랜잭션이 device를 insert하고 accesshistory를 update하려다 실패한 것이기 때문에, device insert 기록은 남아있습니다. 하지만 실제로 영속화된 device는 트랜잭션 1의 device뿐입니다.(해당 로그에서 확인이 되는 device_id 중 하나는 DB 상에 남아있지 않습니다)

device가 두 개 생기는 것을 막는다면, 락 문제도 해결되지 않을까?

맞습니다. 하지만 device가 2개 생기는 것을 막기 위해서는 결국 원자적 삽입을 위한 동시성 제어 매커니즘이 필요하고, 이는 또다른 락이나 복잡한 로직으로 연결됩니다. 단일 access_history에 대해 여러 요청이 동시에 들어오지 않는 현재 상황에서는 간단하게 access_history에 배타락을 걸어 해결하는 것이 가장 효율적으로 보입니다.

@songsunkook songsunkook merged commit e8db618 into develop Dec 30, 2025
5 checks passed
@songsunkook songsunkook deleted the fix/abtest-deadlock branch December 30, 2025 08:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

버그 정상적으로 동작하지 않는 문제상황입니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

데드락 이슈를 해결한다

4 participants