들어가기 전에
로드 밸런서를 통해 다중 서버를 구성하는 경우에는 아래 포스팅을 확인해 주세요 :)
https://hojun-dev.tistory.com/entry/JAVA-JPA-다중-서버-환경-DB-동시성-문제-해결하기
개요
서비스 로직을 구성하다 보면 고유번호를 제외하고도 고유한 값이 존재하는 컬럼을 구성하기 마련이다. (ex. 로그인 아이디)
이런 경우 해당 테이블에 그 고유한 값이 없으면 insert, 있으면 update 하거나 throw를 던지는 로직을 구성하는 경우가 많은데,
이때 서비스를 운영하다 보면 가끔 위의 로직을 구성하였는데도 2개 이상의 행이 insert가 되는 경우를 직면할 수 있다.
이때 그 고유한 컬럼에 Unique Key를 걸어 강제로 2개 이상이 되지 못하게 막는 방법 외에도 JPA에서 동시성 제어 메커니즘을 통해 중복 Insert를 방지하는 방법을 알아보자.
Service
아래의 예시를 보면 이메일이 중복되는 경우 에러를 호출하고, 없으면 생성하여 저장한다.
@Transactional
public void test(String userEmail) {
if (userJpaRepository.findByUserEmail(userEmail).isPresent())
throw new AlreadyDataException("이미 등록된 이메일 주소입니다.");
userJpaRepository.save(new User(userEmail));
}
Controller
아래와 같이 스레드를 두 개 구성하고 테스트를 진행해 보자.
@PostMapping(value = "/test", name = "테스트")
public void test() {
String userEmail = "test@naver.com";
Thread thread1 = new Thread(() -> {
testService.test(userEmail);
});
Thread thread2 = new Thread(() -> {
testService.test(userEmail);
});
thread1.start();
thread2.start();
}
테스트를 진행해 보면 2개의 row가 동시에 생성됨을 확인할 수 있다. 이메일로 조회를 진행할 때 각 스레드 모두에서 저장이 되기 전에 조회를 진행하고 없다고 판단하여 if문을 통과했기 때문이겠다.
해결 방안
DB 트랜잭션 격리 수준 확인
먼저 현재 사용하고 있는 DB에서 아래 쿼리를 통해 Default 격리 수준을 확인해 보자.
SELECT @@GLOBAL.transaction_isolation;
SELECT @@SESSION.transaction_isolation;
두 쿼리의 결과가 모두 REPEATABLE-READ
혹은 SERIALIZABLE
인 경우에는 이후 나올 synchronized 블럭은 생략해도 좋다.
참고로 MySQL의 경우 기본적으로 REPEATABLE-READ
라고 하는데,
DB 서비스를 제공하는 곳에서 다르게 설정해두었을 수 있으니 꼭 확인하는 것이 좋다.
필자의 경우 ncloud에서 제공하는 Cloud DB for MySQL 서비스가 READ-COMMITTED
를 기본 설정으로 사용하고 있었다.
비관적 쓰기 잠금 설정
조회하는 JPA 쿼리 메소드에 비관적 쓰기 잠금을 걸어서 해결한다.
트랜잭션 격리 수준이 낮은 경우 조회와 삽입 부분을 synchronized 블록을 통해 순차적으로 진행하도록 구성하여 해결할 수 있다.
참고로 Lock을 설정한 JPA 쿼리 메소드를 사용하는 곳은 모두 @Transactional이 걸려있어야 한다.
// JPA
@Lock(LockModeType.PESSIMISTIC_WRITE)
User findByUserEmail(String userEmail);
// Service
@Transactional
public void test(String userEmail) {
synchronized (this) {
if (userJpaRepository.findByUserEmail(userEmail).isPresent())
throw new AlreadyDataException("이미 등록된 이메일 주소입니다.");
userJpaRepository.save(new User(userEmail));
}
// ...
}
원리를 간단하게 설명하면 조회와 수정을 synchronized 블록으로 묶어 단독으로 실행되게 하고,
다음 스레드에서 조회하려고 할 때 Lock을 통해 저장이 된 이후 조회하도록 잠금을 설정한 것이라고 할 수 있겠다.
insert의 특성상 저장 부분까지 실행이 되어야 조회 쿼리에서 비관적 잠금이 실행되는 것으로 보인다.
다시 테이블의 기존 데이터를 삭제한 후 테스트를 진행하면 1개의 row만 생성되고 하나는 차단되는 것을 확인할 수 있다.
추가) @Transactional(readOnly = true) 해제
비관적 쓰기 잠금이 걸린 조회 쿼리 실행 시 @Transactional(readOnly = true)
이 걸려있는 경우 읽기 전용과 맞지 않는 Lock 생성으로 인해 오류가 발생한다.
따라서 @Lock(LockModeType.PESSIMISTIC_WRITE)
이 설정된 쿼리를 포함하는 경우 꼭 readOnly 속성을 해제해 줘야 한다.
마무리
단일 서버의 경우 synchronized를 통해 동기화하여 해당 방법으로 동시성 문제를 해결할 수 있으나,
로드 밸런서를 통해 여러 대의 서버를 운영하는 서비스에서는 synchronized가 적용되지 않아 중복으로 insert 하는 경우가 발생할 수 있다.
해당 부분 대응 및 테스트 방법은 아래 포스팅에서 확인할 수 있다.
https://hojun-dev.tistory.com/entry/JAVA-JPA-다중-서버-환경-DB-동시성-문제-해결하기
'Java & Spring' 카테고리의 다른 글
[JAVA] Calendar로 한국식 월 주차 구하기 (0) | 2023.07.24 |
---|---|
[JAVA] Lombok Builder, SuperBuilder와 Generic 사용하기 (0) | 2023.07.21 |
[JAVA] poi에서 SXSSFWorkbook 사용 시 NullPointerException이 발생하는 경우 (0) | 2023.04.28 |
[JAVA] poi에서 엑셀을 다룰 때 발생하는 여러가지 오류 (1) | 2023.04.05 |
[JAVA] JSON Array 형식의 문자열을 List 형식으로 변환하는 방법 (0) | 2023.04.04 |