Published on
👁️

Spring Boot + JPA + MySQL의 Lock 메커니즘

Authors
  • avatar
    Name
    River
    Twitter

🌟 락의 분류

  • Lock 메커니즘의 범위별 분류
    • Table Lock, Page Lock, Record Lock(= Row-Level Lock)
    • Record Lock이 S-Lock, X-Lock 둘 다 될 수 있다. (일반 읽기 작업인 경우 S-Lock도 가능)
    • 하지만 MySQL의 경우, S-Lock을 사용하지 않고 락 없는 상태에서 MVCC를 이용한다.

  • 성격별 분류
    • 읽기 작업 : Shared Lock (S-Lock)
    • 쓰기 작업 : Exclusive Lock (X-Lock)

  • 전략별 분류
    • 낙관적 락
    • 비관적 락



🌟 MySQL InnoDB의 특징

  • Row-Level Locking 지원 (행 단위 락)
  • MVCC (Multi-Version Concurrency Control) 사용
  • REPEATABLE READ가 기본 격리 수준
  • Next-Key Locking 사용 (Phantom Read 방지)
    • 단, FOR UPDATE 등 쓰기 락이 필요한 상황에서만 발생 (단순 SELECT의 경우 발생 X)
  • 기본적으로 **Shared Lock (S-Lock)**을 사용하지 않음 (MVCC 사용)



🌟 Gap Lock과 Next-Key Lock

Gap Lock (갭 락)

  • 목적
    • 존재하지 않는 범위에 새로운 레코드 삽입 방지

  • 발생 조건
    • 범위 조건 + FOR UPDATE


Next-Key Lock (넥스트 키 락)

  • 구성
    • Record Lock + Gap Lock

  • 목적
    • Phantom Read 방지

  • 발생
    • REPEATABLE READ + 범위 스캔 + FOR UPDATE


예시

SELECT * FROM users WHERE age BETWEEN 20 AND 30 FOR UPDATE;

SELECT ... FOR UPDATE (명시적 락)
SELECT ... FOR SHARE
UPDATE ... WHERE 범위조건
DELETE ... WHERE 범위조건



🌟 MVCC (Multi-Version Concurrency Control)

핵심 개념

  • 각 트랜잭션마다 일관된 스냅샷 제공
  • 읽기 작업은 Non-blocking (락을 기다리지 않음)
  • 쓰기 락이 걸린 row도 이전 버전으로 읽기 가능


예시 시나리오

  • 트랜잭션 A

    @Transactional
    public void updateUser() {
        User user = userRepository.findById(1L).orElseThrow();
        user.setName("변경중...");   // X-Lock
        Thread.sleep(5000);
    }
    
    • user.setName("변경중..."); ⇒ X-Lock 발생


  • 트랜잭션 B (동시 실행)

    @Transactional(readOnly = true)
    public User getUser() {
        return userRepository.findById(1L).orElseThrow();  
    }
    
    • X-Lock이 걸려 있지만 ⇒ findById()로 이전 버전 조회 가능



🌟 JPA 기본 메서드별 Lock 동작

save() - Insert

User user = new User();
userRepository.save(user);
  • Lock
    • 일반적으로 없음

  • 예외
    • Unique Key 제약 위반 시 내부 락 발생 가능


save() - Update

User user = userRepository.findById(1L).orElseThrow();
user.setName("새이름");
userRepository.save(user);   // 또는 Dirty Checking
  • 실제 SQL
    • UPDATE users SET name = '새이름' WHERE id = 1

  • Lock
    • SQL 발생 시점에 해당 row에 X-Lock (배타 락) 발생

  • 다른 트랜잭션 영향
    • SELECT 가능 (X-Lock이지만 MVCC로 이전 버전 조회)
    • UPDATE/DELETE
    • SELECT ... FOR UPDATE


delete()

userRepository.deleteById(1L);
  • 실제 SQL
    • DELETE FROM users WHERE id = 1

    • 해당 row에 X-Lock 발생

  • 동작
    • UPDATE와 동일한 락 메커니즘


findById() 등 조회 메서드

User user = userRepository.findById(1L).orElseThrow();
  • Lock
    • 없음

  • MVCC
    • 다른 트랜잭션의 변경 중이어도 조회 가능

  • 예외
    • @Lock 어노테이션 사용 시 명시적으로 Lock 가능 (for update …)
    • 단, @Transactional(readOnly = true)인 경우 @Lock 어노테이션을 사용해도 실제로는 락이 걸리지 않는다. readOnly = true는 JPA 레벨에서 모든 쓰기 작업(락 포함)을 억제하기 때문
⚠️ 주의: @Transactional(readOnly = true) + @Lock 조합은 동작하지 않는다.
- readOnly = true: JPA가 모든 쓰기 관련 SQL 차단
- @Lock: FOR UPDATE SQL 생성 시도
- 결과: @Lock이 무시되고 일반 SELECT만 실행됨



🌟 트랜잭션 설정별 동작

@Transactional(readOnly = true)

@Transactional(readOnly = true)
public void readOnlyMethod() {
    User user = userRepository.findById(1L).orElseThrow();
    user.setName("변경");
}
  • flush 억제
    • user.setName("변경"); ⇒ 변경 사항을 DB에 반영하지 않음

  • 쓰기 방지
    • UPDATE/INSERT/DELETE SQL 생성 안 함

  • FOR UPDATE 무시
    • 명시적 락도 무시됨
    • 즉, readOnly = trueJPA 레벨에서 flush를 막아주는 힌트이다.
    • 직접 락을 걸고 싶다면 readOnly = false여야 하며 명시적으로 for update를 써야 한다. ⭐


@Transactional (readOnly = false, 기본값)

@Transactional
public void writeMethod() {
    User user = userRepository.findById(1L).orElseThrow();
    user.setName("변경");  // 더티 체킹으로 UPDATE 발생
}
  • 쓰기 허용
    • UPDATE/INSERT/DELETE 가능

  • 락 발생
    • SQL 실행 시 자동으로 락 발생

  • AUTO_COMMIT
    • 메서드 종료 시 자동 커밋



🌟 명시적 락 사용

JPA에서 비관적 락

  • 방법 1 : Repository 메서드에 @Lock

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT u FROM User u WHERE u.id = :id")
    User findByIdWithLock(@Param("id") Long id);
    


  • 방법 2 : Native Query 사용

    @Query(value = "SELECT * FROM users WHERE id = :id FOR UPDATE", nativeQuery = true)
    User findByIdForUpdate(@Param("id") Long id);
    


  • 사용

    @Transactional
    public void updateWithLock(Long id) {
        User user = userRepository.findByIdWithLock(id);   // X-Lock 획득
        user.setName("안전한 변경");
    }
    
    • 트랜잭션 끝날 때까지 다른 트랜잭션 접근 차단



JPA에서 낙관적 락

@Entity
public class User {
    @Id
    private Long id;

    @Version
    private Long version;

    private String name;
}
  • 사용

    @Transactional
    public void updateWithOptimisticLock(Long id, String newName) {
        User user = userRepository.findById(id).orElseThrow();
        user.setName(newName);
    }
    
    • 저장 시 version 체크 (WHERE id = ? AND version = ?)
      • version이 다르면 OptimisticLockException 발생



🌟 JPA 기본 락 정책

기본 JPA 동작

  • 중요 : JPA 기본값은 "락 없음"@Version을 명시해야 낙관적 락이 동작

    @Transactional
    public void updateUser(Long id, String name) {
        User user1 = userRepository.findById(id).orElseThrow();// 트랜잭션 A
        User user2 = userRepository.findById(id).orElseThrow();// 트랜잭션 B (동시)
    
        user1.setName("A");// 트랜잭션 A 커밋
        user2.setName("B");// 트랜잭션 B 커밋
    }
    
    • 결과 : 마지막 커밋(B)이 승리 (Lost Update)


해결책

  1. 낙관적 락
    • @Version 사용
  2. 비관적 락
    • @Lock(PESSIMISTIC_WRITE) 사용
  3. 비즈니스 로직
    • 애플리케이션 레벨에서 제어



🌟 Flush 메커니즘

Flush란?

  • 정의
    • 영속성 컨텍스트의 변경 사항을 DB에 동기화
    • JPA가 1차 캐시(EntityManager 내부 메모리)에 있는 변경 사항들을 SQL로 변환하여 DB에 반영하는 것


  • 시점
    • 트랜잭션 커밋, 쿼리 실행 전, 명시적 호출


Flush와 락의 관계

@Transactional(readOnly = true)
public void noFlushExample() {
    User user = userRepository.findById(1L).orElseThrow();
    user.setName("변경");
}
  • user.setName("변경"); ⇒ 변경은 되지만... flush가 억제 ⇒ SQL 생성 안 됨 ⇒ 락 발생 안 함



@Transactional
public void flushExample() {
    User user = userRepository.findById(1L).orElseThrow();
    user.setName("변경");
}
  • user.setName("변경"); ⇒ Dirty Checking ⇒ 트랜잭션 커밋 시 flush ⇒ UPDATE SQL ⇒ X-Lock 발생

  • Spring이 락을 거는 것이 아니라, Spring이 SQL을 실행하도록 허용하는 것이다.

    즉, 트랜잭션과 락은 직접적으로 연결되어 있지 않다.

    ⇒ SQL이 DB에서 락을 유발한다.




🌟 락 비교표

락 종류발생 시점차단 범위사용 목적
No Lock기본 조회없음일반적인 읽기
S-LockSELECT ... LOCK IN SHARE MODE쓰기만 차단읽기 동시성 유지
X-LockUPDATE, DELETE, FOR UPDATE읽기/쓰기 모두 차단데이터 일관성 보장
Gap Lock범위 + FOR UPDATE해당 범위 INSERT 차단Phantom Read 방지
Next-KeyGap + Record Lock (+ FOR UPDATE)범위 전체 차단완전한 격리




🌟 실무 시 적용 사항

1. Service Layer 트랜잭션 설정

@Transactional(readOnly = true)
@Service
public class UserService {

    public User getUser(Long id) {
        return userRepository.findById(id).orElseThrow();
    }

    @Transactional
    public void updateUser(Long id, String name) {
        User user = userRepository.findById(id).orElseThrow();
        user.setName(name);
    }
}
  • Service 클래스 레벨에 @Transactional(readOnly = true) 설정
  • 쓰기 메서드는 Override를 통해 @Transactional로 전환



2. 동시성 제어가 중요한 경우

@Transactional
public void updateAccountBalance(Long accountId, BigDecimal amount) {
    Account account = accountRepository.findByIdWithLock(accountId);
    account.updateBalance(amount);
}
  • 비관적 락으로 동시 수정 방지



3. 성능이 중요한 경우

@Entity
public class User {
    @Version
    private Long version;// 
}

@Transactional
public void updateUserOptimistic(Long id, String name) {
    try {
        User user = userRepository.findById(id).orElseThrow();
        user.setName(name);
    } catch (OptimisticLockException e) {
        throw new ConcurrentModificationException("다른 사용자가 수정했습니다.");
    }
}
  • 낙관적 락으로 충돌 감지

    재시도 로직이나 예외 처리가 필요할 수 있다.