- Published on
- •👁️
Spring Boot + JPA + MySQL의 Lock 메커니즘
- Authors

- Name
- River
🌟 락의 분류
- 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()로 이전 버전 조회 가능
- X-Lock이 걸려 있지만 ⇒
🌟 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 = true는 JPA 레벨에서 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 발생
- 저장 시 version 체크 (
🌟 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)
해결책
- 낙관적 락
@Version사용
- 비관적 락
@Lock(PESSIMISTIC_WRITE)사용
- 비즈니스 로직
- 애플리케이션 레벨에서 제어
🌟 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-Lock | SELECT ... LOCK IN SHARE MODE | 쓰기만 차단 | 읽기 동시성 유지 |
| X-Lock | UPDATE, DELETE, FOR UPDATE | 읽기/쓰기 모두 차단 | 데이터 일관성 보장 |
| Gap Lock | 범위 + FOR UPDATE | 해당 범위 INSERT 차단 | Phantom Read 방지 |
| Next-Key | Gap + 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("다른 사용자가 수정했습니다.");
}
}
낙관적 락으로 충돌 감지
⇒ 재시도 로직이나 예외 처리가 필요할 수 있다.