[SpringBoot] 동시성 제어하기

연유라떼·2025년 8월 10일
post-thumbnail

DB 트랜잭션에서 동시성의 문제가 발생할 때가 많습니다

특히 멀티스레드 기반인 SpringBoot에서는 여러 요청이 들어온다면 요청을 수행하면서 스레드가 생성될텐데, 이 때 동시성의 문제가 발생한다면 DB 제약 조건에 어긋나거나 일관성이 깨지는 문제가 발생합니다.

그래서 사용하는 것이 Lock으로, 대표적으로는

  1. 비관적 락

  2. 낙관적 락

  3. 네임드 락

이렇게 세 가지가 존재합니다.

세 가지 락(lock)은 데이터베이스에서 여러 사용자가 동시에 데이터를 수정하려 할 때 데이터의 일관성을 유지하기 위한 기술입니다

비관적 락 (Pessimistic Lock)

비관적 락은 여러 트랜잭션이 동시에 같은 데이터를 수정하려 할 것이라고 비관적으로 가정하고, 먼저 데이터에 접근하는 트랜잭션이 명시적으로 락을 걸어 다른 트랜잭션의 접근을 차단하는 방식입니다.

  • 특징:
    • 선점 방식: 트랜잭션이 시작될 때부터 종료될 때까지 락을 유지합니다.
    • 안전성: 데이터 충돌 가능성을 원천 차단하므로 데이터 무결성이 높습니다.
    • 성능: 락이 해제될 때까지 다른 트랜잭션은 대기해야 하므로 동시성 처리 성능이 저하될 수 있습니다.
    • 사용 예시: 잔액을 처리하는 금융 거래처럼 충돌이 발생하면 큰 문제가 생기는 상황에 적합합니다.
  • 구현: SQL의 SELECT ... FOR UPDATE 구문을 사용하여 구현할 수 있습니다. Spring Data JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션으로 적용할 수 있습니다.

낙관적 락 (Optimistic Lock)

낙관적 락은 여러 트랜잭션이 동시에 같은 데이터를 수정할 일이 드물다낙관적으로 가정합니다. 트랜잭션이 데이터를 읽을 때 락을 걸지 않고, 업데이트 시점에 충돌이 발생했는지 확인하여 충돌이 없다면 업데이트를 진행하는 방식입니다.

  • 특징:
    • 비선점 방식: 락을 명시적으로 걸지 않습니다.
    • 충돌 감지: 일반적으로 버전(version) 컬럼을 사용하여 데이터 변경 여부를 확인합니다.
    • 성능: 락으로 인한 대기 시간이 없어 동시성 처리 성능이 뛰어납니다. 하지만 충돌이 자주 발생하면 롤백(rollback)이 빈번해져 성능이 저하될 수 있습니다.
    • 사용 예시: 게시판 조회수처럼 충돌이 발생해도 큰 문제가 없는 상황에 적합합니다.
  • 구현: JPA에서 엔티티에 @Version 어노테이션을 추가하여 버전 컬럼을 관리합니다.

네임드 락 (Named Lock)

네임드 락은 데이터베이스 자체에 특정 이름을 가진 락을 생성하여 사용하는 방식입니다. 특정 데이터나 테이블이 아닌, 임의의 이름에 락을 걸기 때문에 여러 트랜잭션 간에 동일한 이름의 락을 공유하여 동시성을 제어할 수 있습니다.

  • 특징:
    • 전역 락: 특정 데이터베이스 세션이 아닌, 전체 데이터베이스에 적용되는 전역적인 락입니다.
    • 독립적 사용: 특정 테이블이나 레코드가 아닌, 이름을 기반으로 락을 걸기 때문에 특정 엔티티에 국한되지 않습니다.
    • 사용 예시: 특정 배치 작업이나 분산 환경에서 여러 서버가 하나의 자원에 접근하는 것을 제어할 때 유용합니다.
  • 구현: MySQL의 경우 GET_LOCK('lock_name', timeout)과 같은 함수를 사용하여 구현합니다. Spring Data JPA에서는 네이티브 쿼리를 통해 호출하거나 별도의 라이브러리를 사용해 구현할 수 있습니다.
비교 기준비관적 락낙관적 락네임드 락
작동 방식데이터에 락을 걸어 다른 접근 차단버전 컬럼으로 충돌 감지 후 처리이름을 가진 락으로 동시성 제어
충돌 처리충돌 원천 차단 (대기)충돌 시 롤백 (재시도)충돌 원천 차단 (대기)
성능동시성 성능 저하 가능성 높음동시성 성능 뛰어남 (충돌 적을 때)동시성 성능 저하 가능성 높음
사용 사례금융 거래, 재고 관리게시판 조회수, 단순 데이터 업데이트분산 환경, 배치 작업, 전역 자원 제어
구현 방법SQL FOR UPDATE, JPA @LockJPA @VersionSQL GET_LOCK, 네이티브 쿼리


그 외의 다양한 락킹 전략

락 종류특징사용 상황 예시
Pessimistic Lock"비관적 락" — 데이터를 가져올 때부터 다른 트랜잭션 접근 차단동시성 충돌 확률 매우 높은 경우
Optimistic Lock"낙관적 락" — 데이터 충돌 가능성을 낮게 보고, 수정 시점에 버전 체크충돌이 드문 경우, 성능 중요시
Named Lock (Database Lock)데이터베이스에 명시적인 락을 거는 방법 (MySQL GET_LOCK 등)분산 환경, DB 레벨 락 제어 필요시
Redis 기반 분산 락Redis 같은 외부 시스템을 이용해 분산 락 관리서버가 여러 대일 때




SpringBoot에 직접 적용하기

JPA Lock 사용하기

1. Pessimistic Lock (비관적 락)

  • LockModeType.PESSIMISTIC_WRITE
    • 해당 행(Row)에 대해 다른 트랜잭션이 읽거나 쓸 수 없도록 잠금
    • 락이 해제될 때까지 다른 트랜잭션은 대기(또는 타임아웃).

실제 예시
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));
}
  • 다음 SQL문을 Hibernate를 통하여 확인 가능
    SELECT * FROM member WHERE id = ? FOR UPDATE;
    
  • FOR UPDATEMySQL InnoDBrow-level exclusive lock (배타적 잠금) 을 걸게 됨
  • InnoDB는 이 쿼리를 통해 선택된 레코드에 대해 다른 트랜잭션의 읽기/쓰기 접근을 차단 (-> InnoDB의 레코드락)

따라서, Pessimistic Lock은 MySQL의 InnoDB가 제공하는 Row-Level Lock (행 수준 락) 과 연결됨(레코드락)


2. Optimistic 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;
}
  • 위와 같이 버전에 대한 체크가 필요하다는 것이 Optimistic의 특징
  • 데이터를 업데이트할 때, 버전 번호가 달라졌으면 예외 발생(OptimisticLockException)
  • 충돌 시 재시도 로직이 필요할 수도 있음

3. Pessimistic Read (FOR SHARE)

@Lock(LockModeType.PESSIMISTIC_READ)
  • MySQL(InnoDB) SELECT ... LOCK IN SHARE MODE로 번역되며 MySQL 8.0 이상에서는 FOR SHARE 사용. InnoDB의 Shared Lock(공유 잠금)으로 매핑됨
  • 다른 트랜잭션은 읽을 수 있지만, 쓸 수 없다는 것이 특징

JPA Lock과 MySQL Lock 매핑

JPA Lock TypeMySQL 엔진 락 (보통 InnoDB)설명
PESSIMISTIC_WRITEFOR UPDATE → Row-Level Exclusive Lock다른 트랜잭션은 읽기/쓰기 불가
PESSIMISTIC_READLOCK IN SHARE MODE or FOR SHARE읽기 가능, 쓰기 불가
OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT(락 없음, WHERE 절로 버전 체크)락 없이 버전 충돌로 동시성 제어




네임드락(Named Lock)

네임드락에 대한 인터페이스 생성해서 공통으로 처리하고 이에 대하여 걸 것들을 걸어도 될 것 같다

현재의 구조에서
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로 설정하여, 서비스 코드의 로직이 커밋되어 데이터베이스에 완전히 반영된 후에 락이 해제되도록 해야 한다!

결론적으로, 락은 다른 스레드가 접근하는 것을 막는 역할을 하고, 트랜잭션은 데이터 변경의 원자성을 보장하는 역할을 한다.
이 둘을 명확히 분리함으로써 락 해제 시점에 이미 데이터가 최종적으로 반영되었음을 보장하여 동시성 문제를 근본적으로 해결할 수 있다.

  • 추가 고려사항: 커넥션 분리
    트랜잭션이 분리되면 각 트랜잭션이 별도의 커넥션을 사용하게 된다. 이로 인해 커넥션 사용량이 증가할 수 있으므로, 비즈니스 로직과 네임드 락 획득 로직에 사용되는 DataSource를 분리하여 커넥션 풀을 효율적으로 관리하는 방법도 좋은 선택이 될 수 있다.
    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

profile
일단 공부해보겠습니다..

0개의 댓글