Changsoon's Note Backend Developer

트랜잭션 안의 lock

@Transactional 메서드 안에서의 lock이 정상작동 하지 않은 경우가 있어서 조사해보았다.

예제 코드

테스트 엔티티에 1000개의 quantity를 가지고 있는 테이블 로우가 있다.

API에 요청을 하면 이 quantity의 기존 값에 -1 이 된다.

값이 0이면 아무것도 하지 않는다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class TestService {

    private final TestRepository testRepository;
    private static final Long TEST_ID = 1L;

    @Transactional
    public void execute() {
        TestEntity entity = testRepository.findById(TEST_ID)
                .orElseThrow(RuntimeException::new);
        if (entity.getQuantity() == 0) return;
        entity.consume();
    }
}

1000명의 유저가 동시에 이 API에 요청을 하면 어떻게 될까?

postman pre-request 기능을 이용해서 요청해보자.

// 요청을 보내는 함수 정의
const sendRequest = async (i) => {
    const url = 'http://localhost:8080/test'; // API 엔드포인트
    const method = 'POST'; // HTTP 메서드
    const headers = {
        'Content-Type': 'application/json'
    };

    // 요청 옵션 설정
    const requestOptions = {
        url: url,
        method: method,
        header: headers,
    };

    // 요청 보내기
    try {
        const response = await pm.sendRequest(requestOptions);
        console.log(`Request ${i + 1} completed:`, response.json());
    } catch (err) {
        console.error(`Request ${i + 1} failed:`, err);
    }
};

// 1000개의 비동기 요청 보내기
for (let i = 0; i < 1000; i++) {
    sendRequest(i); // 비동기로 요청 실행
}

내가 원하는 결과는 1000명이 요청을 하고 수량도 1000개이니 남은 quantity는 0개가 되어야 한다.

하지만 결과는 엉뚱한 값이 나온다. (884개가 나온다.)

image.png

여러 스레드가 하나의 메서드에 접근하여 흔하게 생기는 동시성 문제가 생긴 것이다.

그렇기에 해당 메서드에 lock을 걸어 제어를 해보자.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
@RequiredArgsConstructor
public class TestService {

    private final TestRepository testRepository;
    private static final Long TEST_ID = 1L;
    private final Lock lock = new ReentrantLock(true);

    @Transactional
    public void execute() {
        lock.lock();
        try {
            TestEntity entity = testRepository.findById(TEST_ID)
                    .orElseThrow(RuntimeException::new);
            if (entity.getQuantity() == 0) return;
            entity.consume();
        } finally {
            lock.unlock();
        }
    }
}

임계 영역에 lock을 걸고 로직이 끝나면 lock도 반납한다.

그리고 ReentrantLock에 공정모드도 설정했으니, 순서대로 요청이 될 것이다.

임계 영역을 잘 제어했으니, 결과가 0이 나와야 할 것이다.

image.png

결과가 예상과 달리 780이 나왔다. 왜 이럴까?

답은 임계 영역을 잘 제어한 것이 아닌 것이다.

트랜잭션과 lock의 흐름

그림으로 트랜잭션과 lock의 흐름을 알아보자.

먼저 lock을 적용하기 전의 메서드의 흐름이다.

유저가 request를 하면 트랜잭션이 적용된 메서드가 실행이 되고 그 안에서 엔티티 조회, 변경감지를 이용한 엔티티 수정이 이루어진다.

로직이 전부 실행되고 메서드가 끝나고 난 뒤 트랜잭션은 커밋이 되어 해당 내용이 DB에 저장된다.

image.png

여기서 락을 적용을 하면 엔티티 조회, 엔티티 수정 단계를 잠가서 다른 스레드가 접근하지 못하게 막는다.

하지만 락이 해제되고 트랜잭션이 커밋되기 전의 잠깐의 순간에 다른 스레드가 접근할 수 있는 것이다.

즉, 트랜잭션 범위와 락의 범위가 맞지 않는 것이다. 결국 임계 영역을 잘못 설정한 것이다.

image.png

이것을 수정하려면 트랜잭션 시작과 종료를 lock으로 감싸야 한다.

기존 : 트랜잭션 시작 → lock 획득 → 엔티티 조회 → 엔티티 수정 → lock 반납 → 트랜잭션 커밋/종료

수정 : lock 획득 → 트랜잭션 시작 → 엔티티 조회 → 엔티티 수정 → 트랜잭션 커밋/종료 → lock 반납

이렇게 수정을 한다면 해당 메서드의 모든 작업이 하나의 스레드만 들어올 수 있게 되어, 동시성 문제가 해결될 것이다.

주의할 점은 트랜잭션을 호출할 때 다른 클래스에서 호출해야 트랜잭션이 적용된다.

트랜잭션 AOP는 프록시 기반으로 동작한다.

같은 클래스 내부에서 한 메서드가 다른 메서드를 호출하는 경우 프록시를 거치지 않고 직접 호출되기 때문에 트랜잭션이 적용되지 않는다.

여기서는 static 클래스로 간단하게 만들었다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

@Service
@RequiredArgsConstructor
public class TestService {

    private final TestRepository testRepository;
    private static final Long TEST_ID = 1L;

    @Component
    @RequiredArgsConstructor
    static class ExecuteProxy {
    
        private final TestService testService;
        private final Lock lock = new ReentrantLock(true);
        
        public void execute() {
            lock.lock();
            try {
                testService.process();
            } finally {
                lock.unlock();
            }
        }
    }

    @Transactional
    public void process() {
        TestEntity entity = testRepository.findById(TEST_ID)
                .orElseThrow(RuntimeException::new);
        if (entity.getQuantity() == 0) return;
        entity.consume();
    }
}

image.png

원하는 결과가 잘 나온 것을 볼 수 있다.