Skip to content

Conversation

@songsunkook
Copy link
Collaborator

@songsunkook songsunkook commented Dec 31, 2025

🔍 개요


배경

데드락을 막기 위해 accessHistory에 배타락 조회를 했다.

문제상황

  1. trx_a: 트랜잭션 시작. mvcc readView 생성.
  2. trx_b: 트랜잭션 시작 및 device 생성, device_id를 accessHistory에 반영 후 커밋 완료
  3. trx_a: 2가 끝나길 기다렸다가(배타락), accessHistory 취득 성공. 락있는 조회.
    • mvcc가 아닌 실제 데이터를 읽음. 이 데이터에 한해 REPEATABLE_READ를 위반한다.
  4. trx_a: jpa 연관관계를 통해 device 정보가 있는지 조회. SELECT device 쿼리 날아감. 락없는 조회.
    • mvcc로 읽음. trx_a 시작 당시의 readView에 기반해 읽기에, trx_b의 변경사항이 반영되지 않은 데이터를 조회한다.
    • 즉, trx_b에서 생성한 device가 보이지 않는다.
  5. trx_a: accessHistory의 device_id에는 trx_b에서 생성한 device id가 들어있으나, 해당 device 레코드는 mvcc에 의해 가려진다. jpa는 데이터베이스 일관성(fk 참조 대상 레코드가 존재하지 않음) 위배 예외를 터트린다.

해결 방안

accessHistory를 배타락 조회하여 mvcc를 뚫어버렸기에, 락을 기다린 트랜잭션도 관련 데이터를 가져올 때 (mvcc에 막히지 않도록)락있는 조회가 필요하다. 따라서 device 조회 시에도 배타락을 걸어야 한다.

  • 방법 1) jpa 연관관계 대신 deviceRepository에서 조회하여 사용하도록 만들고, 여기에 락을 건다.(공유락 or 배타락)
  • 방법 2) 최초에 accessHistory 배타락 조회 시 device 정보를 fetch join으로 함께 가져온다.

결론

방법 2가 개발자의 실수를 줄일 수 있고 직관적인 방식으로 보여서 방법 2를 선택

검증

테스트 코드를 통해 예외 상황 재현 및 해결을 검증했습니다.
다만 동시성 제어를 위해 @Transactional 생략이 필요하여 테스트 코드는 커밋 대상에서 제외했습니다.

테스트 코드
// @Transactional << 테스트를 위해 주석처리 후 진행
public abstract class AcceptanceTest {
    // ...
public interface AccessHistoryRepository extends Repository<AccessHistory, Integer> {

    AccessHistory save(AccessHistory accessHistory);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
    // 주석 걸고 풀면서 테스트 진행
    // @Query("SELECT a FROM AccessHistory a JOIN FETCH a.device WHERE a.id = :id")
    Optional<AccessHistory> findById(Integer id);

    // ...
class AbtestApiTest extends AcceptanceTest {

    @Autowired
    private AccessHistoryRepository accessHistoryRepository;

    @Autowired
    private PlatformTransactionManager transactionManager;

    // ...

    @Test
    void 동시성_요청에서_실험군_편입시_JPA_예외가_발생하지_않는다() throws Exception {
        // given
        final Student student = userFixture.성빈_학생(department);
        final CountDownLatch trx_a_started = new CountDownLatch(1);
        final CountDownLatch trx_b_committed = new CountDownLatch(1);
        final AtomicReference<Device> deviceCreatedInTrxB = new AtomicReference<>();

        // when
        Thread threadB = new Thread(() -> {
            try {
                trx_a_started.await(5, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException(e);
            }

            // trx_b: device를 생성하고 커밋한다.
            Device device = transactionTemplate.execute(status ->
                deviceFixture.아이폰(student.getUser().getId())
            );
            deviceCreatedInTrxB.set(device);

            trx_b_committed.countDown();
        });
        threadB.start();

        // trx_a: REPEATABLE_READ 격리 수준에서 트랜잭션을 시작한다.
        // 이 트랜잭션의 read view는 trx_b가 커밋하기 전의 스냅샷을 사용한다.
        TransactionTemplate repeatableReadTemplate = new TransactionTemplate(transactionManager);
        repeatableReadTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);

        Exception exception = repeatableReadTemplate.execute(status -> {
            trx_a_started.countDown();

            // 스냅샷 고정용 더미 쿼리
            // 이 시점에 Read View를 생성해버려야, 이후 Trx B가 커밋한 내용을 못 보게 됩니다.
            entityManager.find(User.class, student.getUser().getId());
            // 또는 userRepository.findById(...) 등 아무거나 읽기

            try {
                if (!trx_b_committed.await(5, TimeUnit.SECONDS)) {
                    throw new RuntimeException("trx_b did not commit in time");
                }
            } catch (InterruptedException e) {
                // ...
            }

            Device deviceFromB = deviceCreatedInTrxB.get();
            Integer accessHistoryId = deviceFromB.getAccessHistory().getId();

            try {
                // 1. 여기서 Locking Read로 AccessHistory를 가져옴 (MVCC 뚫음 -> 성공)
                AccessHistory accessHistory = accessHistoryRepository.getById(accessHistoryId);

                // 2. 여기서 Lazy Loading으로 Device 접근
                // 아까 위에서 고정된 스냅샷(Trx B 커밋 전)을 보게 됨 -> Device 없음 -> 에러 발생!
                assertThat(accessHistory.getDevice()).isNotNull();
                // 실제 데이터를 로딩하도록 강제 (쿼리 나감 -> 스냅샷 걸림 -> 에러 발생!)
                assertThat(accessHistory.getDevice().getModel()).isNotNull();
                return null;
            } catch (Exception e) {
                return e;
            }
        });

        threadB.join();

        // then
        if (exception != null) {
            System.out.println("An exception was caught during the test, as expected without the fix:");
            exception.printStackTrace();
        }
        assertThat(exception).withFailMessage("Exception was thrown: " + exception).isNull();
    }
}

테스트 결과

  • fetch join 배타락 진행 시 예외 미발생
  • fetch join 없는 배타락 진행 시 EntityNotFoundException 예외 발생
image

✅ Checklist (완료 조건)

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

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

Unit Test Results

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

Results for commit c0e6bf8.

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.

[공통] AB테스트 JPA 문제 해결

2 participants