프로젝트를 하던 도중 동시성 문제에 대해 생각을 해보고 해결해보는 시간을 가졌었습니다.
해당 프로젝트에서는 데이터베이스에 제약조건을 걸어서 문제를 해결하였지만 다른 문제상황의 경우 해결방법에 대해서도 공부해보고 싶어서 포스트를 남깁니다
동시성 문제 이론
데이터베이스 조건 걸기
synchronized 사용
public synchronized void increment() {
count++;
}
- 메서드 앞에 synchronized 키워드를 붙여주면 현재 접근하고 있는 메소드에 하나의 쓰레드만 접근할 수 있도록 보장해줍니다. 하지만 이는 단일 JVM환경에서만 적절합니다
- 여러 서버 인스턴스가 각각의 JVM에서 동작하면, 각 JVM은 다른 JVM이 무엇을 하고 있는지 알 수 없습니다.
따라서 한 서버 인스턴스에서의 synchronized 블록은 다른 서버 인스턴스에 영향을 주지 않습니다.
Jpa lock 활용
public interface UserRepository extends JpaRepository<User, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<User> findWithPessimisticLockById(Long id);
}
LockModeType
- 엔티티 인스턴스에 대한 락을 지정하는 데 사용되는 열거형(enum)
- NONE:
- 아무런 락도 적용되지 않습니다.
- 이 모드를 사용할 때, 동시성 문제에 대한 보호는 데이터베이스에서 제공하는 기본 동시성 제어 메커니즘에만 의존합니다.
- OPTIMISTIC(=READ)
- 버전 기반의 낙관적 락을 사용합니다.
- 엔티티에 @Version 어노테이션이 달린 필드가 있어야 합니다. 이 필드의 값은 엔티티가 업데이트 될 때마다 증가합니다.
- 두 트랜잭션이 거의 동시에 같은 데이터를 읽을 경우, 둘 중 하나만 해당 데이터를 성공적으로 업데이트할 수 있습니다. 나머지 하나는 OptimisticLockException이 발생합니다.
- OPTIMISTIC_FORCE_INCREMENT(=WRITE)
- OPTIMISTIC과 유사하지만, 해당 엔티티를 조회할 때마다 버전이 증가합니다.
- 이는 연관된 엔티티의 락 상태를 전파할 필요가 있을 때 유용합니다.
- OPTIMISTIC으로는 문제가 발생하지만 OPTIMISTIC_FORCE_INCREMENT으로 해결할 수 있는 동시성 문제에 대해 알아봅시다
- 예시: 항공편 예약 시스템
- Flight 엔티티: 항공편 정보와 현재 예약된 좌석 수를 포함합니다.
- Booking 엔티티: 사용자의 예약 정보와 그에 연관된 Flight 정보를 포함합니다.
- 예약 과정에서 다음과 같은 동시성 이슈가 발생할 수 있습니다:
- 사용자 A와 사용자 B가 거의 동시에 같은 Flight에 대한 좌석을 예약하려 합니다.
- 사용자 A는 Flight의 현재 예약된 좌석 수와 버전을 확인합니다 (예: version = 1).
- 사용자 A는 그 후 Booking 엔티티를 생성합니다.
- 사용자 B도 동시에 Flight의 현재 예약된 좌석 수와 버전을 확인합니다 (여전히 version = 1로 볼 것입니다).
- 사용자 A의 Booking 엔티티가 저장되면서 연관된 Flight의 예약된 좌석 수를 증가시키려 합니다.
- 이 때, Flight 엔티티에 OPTIMISTIC 락만 걸려 있다면, Booking 엔티티 생성 시 버전 변경이 발생하지않아, 사용자 B도 Booking을 성공적으로 생성할 수 있을 것입니다. 그러나 이후 Flight 엔티티의 예약된 좌석 수를 변경하려 할 때, 버전 불일치 문제가 발생합니다.
- 이 상황에서, Booking 엔티티 생성 시 Flight 엔티티의 버전을 OPTIMISTIC_FORCE_INCREMENT를 사용해 강제로 증가시키면, 사용자 B는 이미 Flight 엔티티의 버전이 변경되었기 때문에 즉시 OptimisticLockException을 경험하게 됩니다. 따라서, 사용자 B는 Booking 엔티티 생성을 시도하기 전에 문제를 인지할 수 있게 됩니다.
- 이렇게 OPTIMISTIC_FORCE_INCREMENT는 연관된 엔티티에 대한 변경이 중요할 때 락 상태를 전파하여 동시성 문제를 빨리 감지할 수 있게 도와줍니다. -> 자원절약/빠른 피드백 가능
- PESSIMISTIC_READ:
- 비관적 읽기 락을 사용합니다.
- 데이터에 대한 읽기는 허용되지만, 락이 해제될 때까지 다른 트랜잭션에서 해당 데이터를 수정하는 것은 금지됩니다.
- 일반적으로, 다른 트랜잭션에서 해당 데이터를 읽을 수는 있지만 수정은 할 수 없습니다.
- PESSIMISTIC_WRITE:
- 비관적 쓰기 락을 사용합니다.
- 락이 해제될 때까지 다른 트랜잭션에서 해당 데이터를 읽거나 수정하는 것은 금지됩니다.
- PESSIMISTIC_FORCE_INCREMENT:
- PESSIMISTIC_WRITE와 유사하지만, 락을 얻을 때마다 연관된 엔티티의 버전 번호가 증가합니다.
- 이는 연관된 엔티티의 락 상태를 전파할 필요가 있을 때 유용합니다
Redis 분산 lock 활용
- 경쟁 상황이 발생할때, 하나의 공유자원에 접근할때 데이터에 결함(의도한 데이터 결과가 아닌 문제)이 발생하지 않도록 *원자성(atomic) 을 보장하는 기법입니다.
- *원자성: 데이터베이스의 ACID 트랜잭션 속성 중 하나로, 트랜잭션 내에서의 연산들이 전부 실행되거나, 전부 실행되지 않는 두 가지 상태만을 보장하는 것
Lock이란?
- 락을 획득해야 자원을 사용할 수 있습니다.
- 만약 데이터에 락이 걸려있으면 락이 풀릴때까지 대기해야합니다.
Lock을 획득하는 방법
- 만약에 다른 작업자가 lock을 걸고 있어서 접근하지 못하고 있다면 어떤 방식으로 lock이 풀렸는지 확인할까요?
- 직접적으로 락의 상태를 확인하는 것은 안전하지 않을 수 있기 때문에, 주의가 필요합니다.
락의 상태를 확인한 후 락을 획득하는 시간 사이에 다른 쓰레드가 락의 상태를 변경할 수 있기 때문입니다. - Spin lock
- lock을 얻기 위해 계속 서버에 요청을 보내서 lock을 얻으려고 시도하는 기법입니다
- 서버에 부하를 줍니다
- Blocking lock
- 블로킹 락은 락을 획득할 수 없을 경우, 쓰레드를 대기 상태로 만듭니다.
- CPU는 해당 쓰레드에 대한 실행을 일시 중지하고 다른 작업을 수행합니다.
락이 해제되면, 대기 중인 쓰레드가 깨어나서 락을 다시 획득하려고 시도합니다. - 자바의 synchronized 도 Blocking lock을 사용합니다 (일반적으로 Blocking lock이 사용됩니다)
Redis Client(Lettuce)
- Redis Client인 Lettuce는 Lock을 SETNX명령어로 획득합니다
- SETNX 명령어는 아래 두 연산이 atomic 하게 작동합니다
- 락이 존재하지는지 확인한다"
- "존재하지 않는다면 락을 획득한다"
- 이 SETNX 를 이용하여 레디스에 값이 존재하지 않으면 세팅하게 하고, 값이 세팅 되었는지 여부를 리턴 값으로 받아 락을 획득하는데 성공합니다.
- 이 방식을 통해 애플리케이션에서 스핀 락(spin lock)을 구현할 수 있습니다. 즉, 레디스 서버에 지속적으로 SETNX 명령을 보내어 임계 영역(Critical Section) 진입 여부를 확인하는 기법이죠.
Redis Client(Redisson)
- 스핀락을 사용하지 않고 pubsub 기능을 사용합니다. 그래서 서버에게 주는 트래픽을 줄일 수 있습니다
- lock을 획득할 수가 있게 되면 클라이언트에게 알림을 보냅니다
Redisson 문제상황(race condition)
만약 동시에 여러 클라이언트에게 알림을 보냈을때 하나의 클라이언트만 lock을 얻는데 성공하고 나머지는 실패하게 되는데 이 경우에 문제는 없을까요?(락의 상태를 확인한 후 락을 획득하는 시간 사이에 다른 쓰레드가 락의 상태를 변경하는 상황)
- 하지만 Redisson의 RLock 구현은 이러한 문제에 대비해서 대기열이라는 개념을 추가하였습니다.
- 다음과 같은 방식으로 작동합니다:
1. 클라이언트가 락을 요청합니다.
2. 락이 사용 가능한 경우, 해당 클라이언트에게 락이 부여됩니다.
3. 만약 락이 이미 다른 클라이언트에 의해 보유되고 있다면, 이 요청은 대기열에 추가됩니다.
4. 보유 중인 클라이언트가 락을 해제하면, 대기 중인 다음 클라이언트에게 알림이 전송됩니다.
5. 알림을 받은 클라이언트는 락을 획득하려고 시도합니다.
MYSQL을 사용한 분산 락 관리
Redis를 활용한 분삭 락도 좋은 방식이지만 만약 Redis를 이미 사용하고 있는 프로젝트가 아니라면 추가적인 인프라 구축에 대한 부담이 있을 수 있습니다. 이 때 MYSQL을 활용하여서도 분삭 LOCK을 관리할 수 있는데요.
// 이 클래스는 MySQL의 USER-LEVEL LOCK 기능을 JdbcTemplate을 이용하여 구현한 것입니다.
public class UserLevelLockWithJdbcTemplate {
// MySQL의 GET_LOCK 함수를 호출하기 위한 쿼리
private static final String GET_LOCK = "SELECT GET_LOCK(:userLockName, :timeoutSeconds)";
// MySQL의 RELEASE_LOCK 함수를 호출하기 위한 쿼리
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(:userLockName)";
// 락 관련 작업 중 오류가 발생했을 때 사용할 예외 메시지
private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다.";
// JdbcTemplate 인스턴스. SQL 쿼리를 실행하는데 사용합니다.
private final NamedParameterJdbcTemplate jdbcTemplate;
// 생성자. jdbcTemplate을 주입받습니다.
public UserLevelLockWithJdbcTemplate(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
// 외부에서 락을 얻고, 주어진 작업(supplier)을 수행한 후 락을 해제하는 메서드
public <T> T executeWithLock(String userLockName,
int timeoutSeconds,
Supplier<T> supplier) {
try {
// 락을 얻습니다.
getLock(userLockName, timeoutSeconds);
// 락을 얻은 후 주어진 작업을 수행합니다.
return supplier.get();
} finally {
// 락을 해제합니다.
releaseLock(userLockName);
}
}
// 주어진 이름과 시간 동안 락을 얻기 위한 메서드
private void getLock(String userLockName,
int timeoutSeconds) {
// GET_LOCK 함수에 전달할 파라미터를 설정합니다.
Map<String, Object> params = new HashMap<>();
params.put("userLockName", userLockName);
params.put("timeoutSeconds", timeoutSeconds);
// 로그로 락 획득 시도를 기록합니다.
log.info("GetLock!! userLockName ], timeoutSeconds ]", userLockName, timeoutSeconds);
// GET_LOCK 함수를 호출하고 결과를 받아옵니다.
Integer result = jdbcTemplate.queryForObject(GET_LOCK, params, Integer.class);
// 결과를 체크하여 문제가 있다면 예외를 발생시킵니다.
checkResult(result, userLockName, "GetLock");
}
// 주어진 이름의 락을 해제하는 메서드
private void releaseLock(String userLockName) {
// RELEASE_LOCK 함수에 전달할 파라미터를 설정합니다.
Map<String, Object> params = new HashMap<>();
params.put("userLockName", userLockName);
// 로그로 락 해제 시도를 기록합니다.
log.info("ReleaseLock!! userLockName ]", userLockName);
// RELEASE_LOCK 함수를 호출하고 결과를 받아옵니다.
Integer result = jdbcTemplate.queryForObject(RELEASE_LOCK, params, Integer.class);
// 결과를 체크하여 문제가 있다면 예외를 발생시킵니다.
checkResult(result, userLockName, "ReleaseLock");
}
// 락 관련 함수의 결과를 체크하는 메서드. 문제가 있으면 예외를 발생시킵니다.
private void checkResult(Integer result,
String userLockName,
String type) {
if (result == null) {
log.error("USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = [], userLockName ]", type, userLockName);
throw new RuntimeException(EXCEPTION_MESSAGE);
}
if (result != 1) {
log.error("USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다. type = [], result ] userLockName ]", type, result, userLockName);
throw new RuntimeException(EXCEPTION_MESSAGE);
}
}
}
이 과정에서 다음과 같은 시나리오가 문제가 될 수 있습니다.
- LOCK을 걸기위해 Connection을 pool에서 가져온다
- 최소한의 연결시간만을 사용해야하기 때문에 할 일이 끝나면 반납한다.
- LOCK을 해제할때 Connection을 다시 pool에서 가져온 것과 다른 Connection을 가져옵니다. 그러면 LOCK 설정과 해제 시 다른 Connection이기 때문에 LOCK 해제하는 작업이 실패하게 됩니다.
그래서 하나의 트랜잭션으로 LOCK을 얻고 해제하는 과정을 한번에 묶는 방법을 선택할 수 있습니다.
public class UserLevelLockFinal {
// SQL 쿼리 상수: 이름 기반의 락을 얻습니다.
private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
// SQL 쿼리 상수: 락을 해제합니다.
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";
// 예외 메시지 상수: 락 연산 중 오류 발생 시 사용됩니다.
private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다.";
// 데이터베이스 연결을 위한 DataSource 객체
private final DataSource dataSource;
// 생성자: DataSource를 주입받습니다.
public UserLevelLockFinal(DataSource dataSource) {
this.dataSource = dataSource;
}
// 락을 얻고, 제공된 supplier를 실행하며, 락을 해제하는 메서드
public <T> T executeWithLock(String userLockName, int timeoutSeconds, Supplier<T> supplier) {
// 데이터베이스 연결을 시도합니다.
try (Connection connection = dataSource.getConnection()) {
try {
// 락을 얻는 작업 시작을 로깅합니다.
log.info("start getLock=[], timeoutSeconds ], connection=[]", userLockName, timeoutSeconds, connection);
// 락 획득 메서드 호출
getLock(connection, userLockName, timeoutSeconds);
// 락을 성공적으로 얻었음을 로깅합니다.
log.info("success getLock=[], timeoutSeconds ], connection=[]", userLockName, timeoutSeconds, connection);
// 실제 작업을 수행합니다.
return supplier.get();
} finally {
// 락을 해제하는 작업 시작을 로깅합니다.
log.info("start releaseLock=[], connection=[]", userLockName, connection);
// 락 해제 메서드 호출
releaseLock(connection, userLockName);
// 락을 성공적으로 해제했음을 로깅합니다.
log.info("success releaseLock=[], connection=[]", userLockName, connection);
}
} catch (SQLException | RuntimeException e) {
// 예외가 발생하면 RuntimeException을 던집니다.
throw new RuntimeException(e.getMessage(), e);
}
}
// 데이터베이스에 락을 요청하는 private 메서드
private void getLock(Connection connection, String userLockName, int timeoutseconds) throws SQLException {
// PreparedStatement를 사용하여 SQL 쿼리를 실행합니다.
try (PreparedStatement preparedStatement = connection.prepareStatement(GET_LOCK)) {
preparedStatement.setString(1, userLockName); // 첫 번째 ?에 userLockName 값을 설정합니다.
preparedStatement.setInt(2, timeoutseconds); // 두 번째 ?에 timeout 값을 설정합니다.
// 결과를 확인합니다.
checkResultSet(userLockName, preparedStatement, "GetLock_");
}
}
// 데이터베이스에서 락을 해제하는 private 메서드
private void releaseLock(Connection connection, String userLockName) throws SQLException {
// PreparedStatement를 사용하여 SQL 쿼리를 실행합니다.
try (PreparedStatement preparedStatement = connection.prepareStatement(RELEASE_LOCK)) {
preparedStatement.setString(1, userLockName); // 첫 번째 ?에 userLockName 값을 설정합니다.
// 결과를 확인합니다.
checkResultSet(userLockName, preparedStatement, "ReleaseLock_");
}
}
// SQL 쿼리의 결과를 검사하는 private 메서드
private void checkResultSet(String userLockName, PreparedStatement preparedStatement, String type) throws SQLException {
// 쿼리를 실행하고 결과를 받아옵니다.
try (ResultSet resultSet = preparedStatement.executeQuery()) {
if (!resultSet.next()) { // 결과가 없으면 예외를 던집니다.
log.error("USER LEVEL LOCK 쿼리 결과 값이 없습니다. type = [], userLockName ], connection=[]", type, userLockName, preparedStatement.getConnection());
throw new RuntimeException(EXCEPTION_MESSAGE);
}
int result = resultSet.getInt(1);
// 결과가 1이 아니면 예외를 던집니다.
if (result != 1) {
log.error("USER LEVEL LOCK 쿼리 결과 값이 1이 아닙니다. type = [], result ] userLockName ], connection=[]", type, result, userLockName, preparedStatement.getConnection());
throw new RuntimeException(EXCEPTION_MESSAGE);
}
}
}
}
위와 같은 코드로 문제를 해결할 수 있지만, 이 해결방식의 경우도 데이터베이스 종류에 의존한다는 점이 단점이 될 수 있습니다.
Statement대신 주로 PreparedStatement 사용하는 이유
- PreparedStatement를 사용하면, 사용자가 입력한 값을 쿼리의 일부로 바로 넣지 않습니다.
대신에, 쿼리에는 ?라는 특별한 기호를 넣어두고, 이 ? 자리에 나중에 안전하게 사용자의 입력값을 넣어주는 방식을 사용합니다.
이 방법으로 인해, 악의적인 사용자가 SQL 명령을 입력해도 그 명령이 실행되지 않고, 그냥 일반 문자열로 취급됩니다. 이렇게 해서, 데이터베이스가 공격을 받는 것을 방지하게 됩니다. - 일반 Statement는 매번 실행할 때마다 쿼리를 파싱, 최적화 및 컴파일해야 합니다.
반면에 PreparedStatement는 처음 실행될 때만 이러한 작업이 수행되며, 그 후에는 데이터베이스에서 이를 캐시하여 재사용합니다. 따라서 동일한 구조의 쿼리를 여러 번 실행할 때 PreparedStatement가 더 효율적입니다. - PreparedStatement는 값을 바인딩할 때 사용할 수 있는 메서드가 다양합니다.
예를 들어, 문자열 값을 바인딩하려면 setString(), 정수 값을 바인딩하려면 setInt() 등의 메서드를 사용합니다.
이러한 메서드들은 적절한 데이터 타입에 따라 값을 안전하게 바인딩하므로, 데이터 변환 오류나 포맷 문제를 예방할 수 있습니다.일반 Statement를 사용할 때는 이러한 타입별 메서드를 사용할 수 없으므로, 개발자가 직접 값의 타입을 확인하고 변환하는 작업이 필요합니다.
Redis vs Mysql
두 방식의 장단점을 비교해보겠습니다.
- Reddison을 사용한 Redis 분산 락
- 장점:
- Redis 락은 분산된 환경에서도 잘 동작합니다. 여러 서버나 인스턴스에서 동시에 같은 리소스에 접근하는 것을 방지할 수 있습니다.
- Redis는 인메모리 데이터베이스로, 디스크 I/O가 없기 때문에 빠른 성능을 제공합니다.
- 락의 만료 기능을 제공하고 단일스레드 모형인 점을 고려하면 데드락 상황을 비교적 피할 수 있습니다.
- 단점:
- Redis 인스턴스가 필요하며, 높은 가용성을 위해서는 클러스터링 등의 구성이 필요합니다.
- 직접 구현한 데이터베이스 레벨의 락:
- 장점:
- 단순성: 별도의 인프라나 서버 구성 없이 기존의 데이터베이스 연결만으로 락을 구현할 수 있습니다.
지속성: 데이터베이스는 지속성이 보장되므로 락 상태에 대한 정보를 안전하게 저장할 수 있습니다.
- 단순성: 별도의 인프라나 서버 구성 없이 기존의 데이터베이스 연결만으로 락을 구현할 수 있습니다.
- 단점:
- 데이터베이스 락은 디스크 I/O가 필요할 수 있으므로, Redis와 같은 인메모리 방식에 비해 상대적으로 느릴 수 있습니다.
- 데드락 발생 가능성이 있으며, 이를 관리하려면 추가 로직이 필요합니다.
- 분산된 환경에서 락을 동기화하는 것이 복잡할 수 있습니다.
- 분산 시스템에서 빠른 성능과 유연성이 필요하고, 이미 Redis를 도입한 상태였기 때문에 Reddison 방식의 Redis을 도입하려고 합니다.
결론
동시성 문제를 해결하는 다양한 방법에 대해 알아보았습니다.
다음에는 Redis의 Client를 활용해서 동시성 문제를 해결하는 방법을 코드를 작성하여 구현해보려고 합니다.
'데이터베이스' 카테고리의 다른 글
| 커밋이 완료되었다고 정말 디스크에 적혔을까? (1) (0) | 2026.02.22 |
|---|---|
| PostgreSQL MVCC와 VACUUM: Index Bloat (0) | 2026.02.21 |
| 쿼리 튜닝으로 getConnection()의 긴 응답시간 해결 (0) | 2023.09.19 |
| 쿼리 성능 개선 (0) | 2023.09.05 |
| 캐시 도입 (0) | 2023.09.05 |