
DB 트랜잭션에서 동시성의 문제가 발생할 때가 많습니다
특히 멀티스레드 기반인 SpringBoot에서는 여러 요청이 들어온다면 요청을 수행하면서 스레드가 생성될텐데, 이 때 동시성의 문제가 발생한다면 DB 제약 조건에 어긋나거나 일관성이 깨지는 문제가 발생합니다.
그래서 사용하는 것이 Lock으로, 대표적으로는
비관적 락
낙관적 락
네임드 락
이렇게 세 가지가 존재합니다.
세 가지 락(lock)은 데이터베이스에서 여러 사용자가 동시에 데이터를 수정하려 할 때 데이터의 일관성을 유지하기 위한 기술입니다
비관적 락은 여러 트랜잭션이 동시에 같은 데이터를 수정하려 할 것이라고 비관적으로 가정하고, 먼저 데이터에 접근하는 트랜잭션이 명시적으로 락을 걸어 다른 트랜잭션의 접근을 차단하는 방식입니다.
SELECT ... FOR UPDATE 구문을 사용하여 구현할 수 있습니다. Spring Data JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션으로 적용할 수 있습니다. 낙관적 락은 여러 트랜잭션이 동시에 같은 데이터를 수정할 일이 드물다고 낙관적으로 가정합니다. 트랜잭션이 데이터를 읽을 때 락을 걸지 않고, 업데이트 시점에 충돌이 발생했는지 확인하여 충돌이 없다면 업데이트를 진행하는 방식입니다.
@Version 어노테이션을 추가하여 버전 컬럼을 관리합니다. 네임드 락은 데이터베이스 자체에 특정 이름을 가진 락을 생성하여 사용하는 방식입니다. 특정 데이터나 테이블이 아닌, 임의의 이름에 락을 걸기 때문에 여러 트랜잭션 간에 동일한 이름의 락을 공유하여 동시성을 제어할 수 있습니다.
GET_LOCK('lock_name', timeout)과 같은 함수를 사용하여 구현합니다. Spring Data JPA에서는 네이티브 쿼리를 통해 호출하거나 별도의 라이브러리를 사용해 구현할 수 있습니다. | 비교 기준 | 비관적 락 | 낙관적 락 | 네임드 락 |
|---|---|---|---|
| 작동 방식 | 데이터에 락을 걸어 다른 접근 차단 | 버전 컬럼으로 충돌 감지 후 처리 | 이름을 가진 락으로 동시성 제어 |
| 충돌 처리 | 충돌 원천 차단 (대기) | 충돌 시 롤백 (재시도) | 충돌 원천 차단 (대기) |
| 성능 | 동시성 성능 저하 가능성 높음 | 동시성 성능 뛰어남 (충돌 적을 때) | 동시성 성능 저하 가능성 높음 |
| 사용 사례 | 금융 거래, 재고 관리 | 게시판 조회수, 단순 데이터 업데이트 | 분산 환경, 배치 작업, 전역 자원 제어 |
| 구현 방법 | SQL FOR UPDATE, JPA @Lock | JPA @Version | SQL GET_LOCK, 네이티브 쿼리 |
| 락 종류 | 특징 | 사용 상황 예시 |
|---|---|---|
| Pessimistic Lock | "비관적 락" — 데이터를 가져올 때부터 다른 트랜잭션 접근 차단 | 동시성 충돌 확률 매우 높은 경우 |
| Optimistic Lock | "낙관적 락" — 데이터 충돌 가능성을 낮게 보고, 수정 시점에 버전 체크 | 충돌이 드문 경우, 성능 중요시 |
| Named Lock (Database Lock) | 데이터베이스에 명시적인 락을 거는 방법 (MySQL GET_LOCK 등) | 분산 환경, DB 레벨 락 제어 필요시 |
| Redis 기반 분산 락 | Redis 같은 외부 시스템을 이용해 분산 락 관리 | 서버가 여러 대일 때 |
LockModeType.PESSIMISTIC_WRITE실제 예시
Repository에서의 Lock 설정 추가
public interface StoreRepository extends JpaRepository<Store, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Store s WHERE s.id = :id")
Store findByIdForUpdate(@Param("id") Long id);
}
서비스 코드에서의 실행
@Transactional
public void likeStore(Long memberId, Long storeId) {
Store store = storeRepository.findByIdForUpdate(storeId);
boolean alreadyLiked = storeLikeRepository.existsByMemberIdAndStoreId(memberId, storeId);
if (alreadyLiked) {
throw new IllegalStateException("이미 찜한 가게입니다.");
}
storeLikeRepository.save(new StoreLike(memberId, storeId));
}
SELECT * FROM member WHERE id = ? FOR UPDATE;
FOR UPDATE는 MySQL InnoDB의 row-level exclusive lock (배타적 잠금) 을 걸게 됨따라서, Pessimistic Lock은 MySQL의 InnoDB가 제공하는 Row-Level Lock (행 수준 락) 과 연결됨(레코드락)
@Version
private Long version;
이 방식은 락을 직접 걸지 않고, 엔티티에 버전 필드를 두고 변경 시 버전 충돌을 검사
```sql
UPDATE member SET name = ?, version = version + 1 WHERE id = ? AND version = ?;
```
위와 같은 Hibernate 로그를 확인해보자
MySQL에서 특별한 락을 사용하지 않고, 애플리케이션 차원에서 충돌을 제어함
충돌 시에는 OptimisticLockException이 발생
Optimistic Lock은 MySQL의 락과 직접 대응하지 않으며, JPA 내부 로직과 쿼리 조건을 통해 동시성 제어를 수행
@Entity
public class Store {
@Id
private Long id;
@Version
private Long version;
}
OptimisticLockException)FOR SHARE)@Lock(LockModeType.PESSIMISTIC_READ)
SELECT ... LOCK IN SHARE MODE로 번역되며 MySQL 8.0 이상에서는 FOR SHARE 사용. InnoDB의 Shared Lock(공유 잠금)으로 매핑됨| JPA Lock Type | MySQL 엔진 락 (보통 InnoDB) | 설명 |
|---|---|---|
PESSIMISTIC_WRITE | FOR UPDATE → Row-Level Exclusive Lock | 다른 트랜잭션은 읽기/쓰기 불가 |
PESSIMISTIC_READ | LOCK IN SHARE MODE or FOR SHARE | 읽기 가능, 쓰기 불가 |
OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT | (락 없음, WHERE 절로 버전 체크) | 락 없이 버전 충돌로 동시성 제어 |
네임드락에 대한 인터페이스 생성해서 공통으로 처리하고 이에 대하여 걸 것들을 걸어도 될 것 같다
현재의 구조에서
1:N 구조의 테이블에서 이에 대하여 날짜 별로 하나의 레코드만 생성하도록 제약을 걸어두었는데,동시에 요청을 하면 이러한 제약성이 깨져버린다
그래서 JDBC를 통하여 직접 네임드락에 대하여 코드를 작성( 중요 코드만 작성)
private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";
private void getLock(String lockName) {
Object result = entityManager.createNativeQuery(GET_LOCK)
.setParameter(1, lockName)
.setParameter(2, 10) // 무한대기 방지
.getSingleResult();
}
private void releaseLock(String lockName) {
Object result = entityManager.createNativeQuery(RELEASE_LOCK)
.setParameter(1, lockName)
.getSingleResult();
}
우선은 getLock과 이를 해지하는 releaseLock 메서드를 설정하고 다음과 같은 메서드를 설정하였다
public <T> T executeWithLock(String lockName, Supplier<T> action) {
try {
getLock(lockName);
return action.get();
} catch (Exception e) {
throw new GeneralException(ErrorCode.TRANSACTION_FAILED);
} finally {
releaseLock(lockName); // 데드락 방지
}
}
이렇게 작성하면 락에 걸리고 이에 대하여 데드락 현상을 방지 할 수 있다
네임드락의 장점 중 하나는 타임아웃 설정이 가능한 것으로 무한 대기도 방지할 수 있다는 점을 사용
서비스 코드에 적용할 것이라면
락을 거는 커넥션 풀과 transaction을 동일한 흐름에 두면 안된다
가장 핵심적인 이유는 데이터의 일관성 문제를 해결하고 동시성을 안전하게 보장하기 위해서입니다.
네임드 락 해제 시점과 트랜잭션 커밋 시점의 불일치:
만약 네임드 락 설정/해제와 비즈니스 로직이 하나의 트랜잭션으로 묶여있다면, 락은 비즈니스 로직이 완료된 직후에 해제되지만, 트랜잭션 커밋은 그 후에 발생
-> 이 짧은 시간 동안 다른 스레드가 락을 획득하고 데이터를 조회할 수 있기에 락을 건 이유가 없어진다
데이터 정합성 문제 발생:
예를 들어, 스레드 A가 트랜잭션 로직을 수행하고 락을 해제했다고 가정해 보자!
이 시점에 아직 스레드 A의 트랜잭션이 커밋되지 않아 코드의 결과가 데이터베이스에 반영되지 않았다. 이때 스레드 B가 락을 획득하고 DB에 접근해 조회하면, 스레드 A가 변경하기 전의 이전을 조회를 통해 잃게 된다
이후 스레 B가 로직을 수행하면, 스레드 A의 변경 사항을 덮어쓰거나 무시하게 되어 재고 감소 누락과 같은 동시성 문제가 다시 발생
따라서 네임드 락을 사용할 때는 별도의 트랜잭션을 통해 락을 획득하고 해제해야한다. 비즈니스 로직 트랜잭션의 전파 수준을 REQUIRES_NEW로 설정하여, 서비스 코드의 로직이 커밋되어 데이터베이스에 완전히 반영된 후에 락이 해제되도록 해야 한다!
결론적으로, 락은 다른 스레드가 접근하는 것을 막는 역할을 하고, 트랜잭션은 데이터 변경의 원자성을 보장하는 역할을 한다.
이 둘을 명확히 분리함으로써 락 해제 시점에 이미 데이터가 최종적으로 반영되었음을 보장하여 동시성 문제를 근본적으로 해결할 수 있다.
public Response create(Request request) {
//생략...
// 우선 DB에서 조회부터 먼저해 정합성 검증
if (repository.findByRecordDate(member.getId(), requestDto.getRecordDate()).isPresent()) {
log.info("{} 날짜의 레코드 발견...", requestDto.getRecordDate());
throw new GeneralException(ErrorCode.ALREADY_RECORDED);
}
// 락 획득 시작
String lockName = "lock:" + member.getId() + ":" + requestDto.getRecordDate();
// 락 획득 후 Transaction 시행
Response response = namedLockTemplate.executeWithLock(lockName, () ->
// 저장
createTransactional(member, requestDto)
);
// 생략...
}
@Transactional
public Response createTransactional(Member member, RequestDto requestDto) {
//생략...
repository.save(...);
그리고 해당 Transactional 안에서 다른 N+1 조회 및 접근하면 LazyInitializationException이 일어날 수 있으니 별도의 DB 접근 세션 확보가 필요함
즉 DB에 락을 거는 시점과 접근 시점이 중요하다는 것을 깨달았습니다
스레드를 10개를 생성해 동시에 DB에 영향을 주는 POST요청을 주기 실시
초반의 DB의 제약만 걸었을 경우에는 오류 메세지가 서버 상의 오류로만 분리됨

락을 획득한 뒤에 실시할 경우 DB conflicts를 통한 예외로 이어지는 것 확인 가능

서비스 로직 실시할 때마다 해당 로그 확인 가능

Reference