본문 바로가기
JPA

JPA 비관적 락

by sangyunpark99 2025. 2. 7.
데이터베이스 READ COMMITE 격리 수준에서 발생하는
NON-REPEATABLE READ와 PHANTOM READ 문제는 어떻게 해결할까?

 

 

이번글은 데이터베이스 READ COMMITED 격리 수준에서 발생하는 NON-REPEATABLE 문제와, 이를 해결하는 방법중 하나인 JPA에서 제공하는 비관적 락에 대해 알아보겠습니다.

 

"NON-REPEATABLE READ 문제는 뭔가요?"

 

한 트랜잭션 내에서 같은 쿼리를 두 번 실행했을 때 결과가 다르게 오는 문제입니다.

 

"PHANTOM READ 문제는 뭔가요?"

 

한 트랜잭션이 같은 쿼리를 두 번 실행할 때, 첫 번째 쿼리에는 없던 유령 레코드가 두 번째 쿼리에서 나타는 문제입니다.

 

"NON-REPEATABLE READ & PHANTOM READ 문제가 발생하는 예시는 어떤게 있을까요?"

 

임의의 상황을 하나 가정해보겠습니다. 가정한 상황은 다음과 같습니다.

UREACA 회사에서 "김민수" 대리는 3개월 전 입사한 신입사원 정보를 조회하려고 합니다.신입사원 정보를 가져오기 위해 신입사원 테이블과 사원 정보 테이블을 조인해야 하지만,
카카오 출신인 "김민수" 대리는 불필요한 조인 연산을 최소화하기 위해먼저 신입사원 테이블에서 신입사원이 존재하는지 확인한 후,존재할 경우에만 조인 쿼리를 실행하도록 구현했습니다.
3개월전부터 지금까지 입사한 신입사원은 한명도 존재하지 않기에, "김민수"대리는 신입사원 정보가 아무것도 나오지 않을거라고 생각하고 있습니다.
하지만, 조회 결과를 확인한 순간, 예상치 못한 상황이 벌어졌습니다.
"어? 이건 뭐야? 신입사원 "홍정기"가 누구야?"
"김민수" 대리는 신입사원 테이블을 다시 확인했지만, "홍정기"가 추가되어 있었습니다.
"이게 말이 돼? 조인 전에 분명 존재 여부를 확인했는데?"
백엔드 개발팀에 찾아간 "김민수" 대리는 따져 물었습니다."DB 제대로 관리하고 있는 거 맞아요? 분명 비어있어야 하는 신입사원 테이블에 왜 "홍정기"라는 이름이 보이는거죠?"
이에 백엔드 개발자가 차분히 설명했습니다.
"김 대리님, 그건 팬텀 리드(Phantom Read) 현상입니다.""트랜잭션이 진행되는 동안 다른 트랜잭션에서 신입사원 이 추가되었을 가능성이 있습니다. 그 때문에 최종 조회 시 결과가 달라진 거죠."

 

"이 상황을 코드로 한번 나타내 볼까요?"

 

먼저 테이블 구조는 다음과 같습니다.

 

RecruitService

package com.example.spring_study_test.service;

import com.example.spring_study_test.entity.Person;
import com.example.spring_study_test.entity.Recruit;
import com.example.spring_study_test.repository.jpa.RecruitJpaRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class RecruitService {

    private final RecruitJpaRepository recruitRepository;

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void checkAndJoinRecruits(Long personId) {

        System.out.println("신입사원 조회중...");
        Recruit recruit = recruitRepository.findByPersonId(personId); // 신입 사원 목록 첫 조회
        System.out.printf("신입사원 존재 여부 : %s\n", recruit != null ?"존재✅" : "존재❌");

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {

        }

        if(getRetry(personId)) { // 신입 사원 목록 다시 조회
            List<Recruit> recruits = recruitRepository.fetchJoinPerson(personId); // 신입 사원 목록 fetchJoin
            printPerson(recruits);
            return;
        }

        printNotExistRecruit();
    }

    private boolean getRetry(Long personId) {
        System.out.println("신입사원 다시 조회중...");
        Recruit recruit = recruitRepository.findByPersonId(personId);
        System.out.printf("신입사원 존재 여부 : %s\n", recruit != null?"존재✅" : "존재❌");

        return recruit != null;
    }

	@Transactional
    public void addRecruit(Person person) {
        Recruit recruit = new Recruit(person);
        recruitRepository.save(recruit);
        System.out.println("\"홍정기\"가 신입사원으로 추가되었습니다.");
    }

    private void printPerson(List<Recruit> recruits) {
        System.out.println("========== 신입 사원 목록 ==========");
        for(Recruit recruit: recruits) {
            Person person = recruit.getPerson();
            System.out.printf("이름 : %s, 나이 : %s\n",person.getName(), person.getAge());
        }
    }

    private void printNotExistRecruit() {
        System.out.println("신입 사원이 없습니다.");
    }
}

 

checkAndJoinRecruits 메서드는 신입 사원 목록을 두 번 조회하며, 각 조회 시 존재 여부에 따라 ✅ 또는 ❌를 표시합니다.
또한, Thread.sleep(1000)을 사용해 1초 동안 대기함으로써, 다른 스레드가 해당 기간 동안 데이터를 수정할 수 있도록 합니다.

 

 

이제, 테스트 코드를 한번 보겠습니다.

 

테스트 코드는 다음과 같습니다.

NonRepeatableAndPhantomReadTest

package com.example.spring_study_test.phantomRead;

import com.example.spring_study_test.entity.Person;
import com.example.spring_study_test.service.RecruitService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@SpringBootTest
public class NonRepeatableAndPhantomReadTest {

    @Autowired
    private RecruitService recruitService;

    @Test
    void run() throws Exception{
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        CountDownLatch latch = new CountDownLatch(2);

        Person person = Person.builder()
                .id(1L)
                .name("홍정기")
                .age(27)
                .build();

        executorService.execute(() -> {
            recruitService.checkAndJoinRecruits(1L);
            latch.countDown();
        });

        executorService.execute(() -> {
            recruitService.addRecruit(person);
            latch.countDown();
        });

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

 

두 개의 스레드를 만들어서 하나는 신입사원 정보를 조회하고 조인하는 작업을 실행하고, 다른 하나는 새로운 신입사원을 추가하는 작업을 실행하는 방식입다.

이렇게 하면 첫 번째 스레드가 신입사원을 조회하는 도중에 두 번째 스레드가 새로운 신입사원을 추가할 수 있어서, 트랜잭션 격리 수준에 따라 Non-Repeatable read 문제와 Phantom Read 현상이 발생하도록 합니다.

 

실행 결과

Non-Repeatable read 문제가 발생하지 않는 경우엔 다음과 같이 나와야 합니다.

(Thread-A, Thread-B는 구분하기 쉽게 하기 위해 붙여주었습니다.)

신입사원 조회중...    ------- Thread A
"홍정기"가 신입사원으로 추가되었습니다. ------ Thread B
신입사원 존재 여부 : 존재❌ ------- Thread A
신입사원 다시 조회중...  ------- Thread A
신입사원 존재 여부 : 존재❌ ------- Thread A
신입 사원이 없습니다. ------- Thread A

 

테스트 코드를 돌린 결과는 다음과 같습니다.

 

 

pool-2-thread-1번의 트랜잭션 내에서 각각 조회한 결과가 다르게 나옵니다.

(신입사원 존재 여부 : 존재❌,신입사원 존재 여부 : 존재✅) 

 

주의할 점

@Transactional(isolation = Isolation.READ_COMMITTED)

 

Mysql 9.0버전 기준 트랜잭션 격리 수준은 Repeatable Read이므로,  isolation을 재설정 해주어야  non-repeatable read와 phantom read 문제를 발생시킬 수 있습니다. 

 

"이 문제를 어떻게 해결할 수 있을까요?"

 

문제를 해결하는 방법 중 JPA 비관적 락을 사용하는 방법을 통해 해결할 수 있습니다.

 

JPA의 비관적 락

비관적 락은 이름처럼 상황을 비관적으로 판단하고 항상 트랜잭션의 충돌이 발생한다가 가정하고 락을 거는 방법입니다.

JPA에서 @Lock 어노테이션을 통해 비관적 락을 사용할 수 있습니다.

 

비관적 락의 종류는 3가지가 있습니다.

 

PESSIMISTIC_READ : 다른 트랜잭션에서 읽기만 가능하게 합니다.

PESSIMISTIC_WRITE : 다른 트랜잭션에서 읽기와 쓰기를 못하게 합니다.

PESSIMISTIC_FORCE_INCREMENT : 다른 트랜잭션에서 읽기도 못하고 쓰기도 못함 + 추가적으로 버저닝을 수행합니다.

 

비관적 락의 LockModeType 중에서도 PESSIMISTIC_WRITE 옵션을 사용해서 문제를 해결해보도록 하겠습니다.

 

"비관적 락이 어떤 기능을 하길래 이 문제를 해결하나요?"

 

JPA에서 비관적 락(Pessimistic Lock)을 사용하면 해당 트랜잭션이 커밋될 때까지 다른 트랜잭션이 동일한 데이터를 읽거나 수정하지 못하게 막을 수 있습니다. 즉, 비관적 락은 특정 엔티티를 조회할 때 즉시 데이터베이스 레벨에서 락을 걸어서 다른 트랜잭션의 접근을 차단하는 방식입니다.

 

이전 테스트 상황에 비관적 락을 사용하게 되면, Thread-A의 트랜잭션이 실행되고 조회 쿼리를 날리는 즉시 데이터(🔒)에 락을 걸어서 Thread-B의 트랜잭션이 데이터(🔒) 수정을 못하게 합니다.

 

정말로 수정을 못하는지 코드로 직접 확인해 보겠습니다.

 

import com.example.spring_study_test.entity.Recruit;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface RecruitJpaRepository extends JpaRepository<Recruit, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Recruit findByPersonId(Long personId);

    @Query("select r from Recruit r join fetch r.person p where p.id = :personId")
    List<Recruit> fetchJoinPerson(Long personId);
}

 

비관적 락을 사용하고 싶은 경우, JPA기준 사용하고자 하는 메서드 위에 어노테이션 @Lock(LockModeType.PESSIMISTIC_WRITE)를 선언해주면 됩니다.

 

실행 결과를 확인해 보겠습니다.

 

 

이전에 Non-Repeatable Read와 Phantom Read가 발생하지 않는 경우엔 다음과 같이 출력된다고 했습니다.

신입사원 조회중...    ------- Thread A
"홍정기"가 신입사원으로 추가되었습니다. 신입사원 다시 조회중... ------ Thread B
신입사원 존재 여부 : 존재❌ ------- Thread A
신입사원 존재 여부 : 존재❌ ------- Thread A
신입 사원이 없습니다. ------- Thread A

 

테스트 실행결과와 위에 나온 출력이 동일합니다.

 

 

추가로, 이 문제는 Service 계층 코드에 추가했던 아래의 코드를 제거함으로서, Mysql(v9.0)의 기본 격리 수준인 Repeatable Read로도 해결할 수 있습니다 .

@Transactional(isolation = Isolation.READ_COMMITTED)

 

 

단점

  • 하나의 트랜잭션이 끝날 때까지 다른 트랜잭션은 블로킹되므로 성능이 저하될 수 있습니다.
  • 여러 트랜잭션이 동시에 비관적 락을 걸려고 하면 데드락이 발생할 가능성이 있습니다.
  • 비관적 락은 트랜잭션이 종료될 때까지 락을 유지하므로, DB에서 락 관리 비용이 증가하고 리소스가 낭비될 수 있습니다.
  • 비관적 락을 사용하면 한 트랜잭션이 끝날 때까지 다른 트랜잭션이 대기해야 하므로,예상치 못한 응답 지연이 발생할 수 있습니다.

 

정리

  • 데이터베이스 READ COMMITED 격리 수준에서 발생하는 NON-REPEATABLE READ와 PHANTOM READ 문제는 JPA에서 제공하는 비관적 락을 사용해서 해결할 수 있습니다.
  • MySql(v9.0)은 DB 격리 수준이 Repeatable Read이므로 NON-REPEATABLE READ와 PHANTOM READ 문제가 발생하지 않습니다.

 

'JPA' 카테고리의 다른 글

JPA 낙관적 락  (0) 2025.02.06
JPA와 읽기 전용 쿼리  (0) 2025.02.05
find 메서드와 1차 캐시의 관계  (0) 2025.02.04
IDENTITY 식별자 생성 전략과 persist()의 관계  (0) 2025.02.03
동시성 문제 해결방법(Update Query편)  (0) 2025.01.29