개요
서비스를 운영하다 보면 동시성 문제에 의해 DB에 잘못된 업데이트가 발생하거나 중복 insert 등의 경우를 종종 마주칠 수 있다.
단일 서버라면 Java에서 제공하는 synchronized 블록을 이용해서 대응을 할 수 있겠지만,
synchronized 블록은 하나의 Application 내에서만 동작하기 때문에 로드 밸런서를 통해 여러 대의 서버를 구성하였다면 완전한 동기화를 적용할 수 없다.
따라서 다중 서버 환경과 비슷하게 테스트해 보고 DB 동시성 문제를 해결해 보자.
만약 단일 서버라면 아래와 같은 방식으로 해결할 수도 있다. (synchronized + JPA)
https://hojun-dev.tistory.com/entry/JAVA-JPA-중복-Insert-방지하기
Service
아래의 예시를 보면 이메일이 중복되는 경우 에러를 호출하고, 없으면 생성하여 저장한다.
@Transactional
public void test(String userEmail) {
if (userJpaRepository.findByUserEmail(userEmail).isPresent())
throw new AlreadyDataException("이미 등록된 이메일 주소입니다.");
userJpaRepository.save(new User(userEmail));
}
Controller
아래와 같이 간단하게 API를 구성하고 테스트를 진행한다.
@PostMapping(value = "/test", name = "테스트")
public void test() {
String userEmail = "test@naver.com";
testService.test(userEmail);
}
테스트
여기에서는 다중 서버 환경을 테스트하기 위해 IntelliJ에서 로컬에서 포트를 달리하여 Application을 2개 띄우고,
디버깅 기능을 통해 원하는 위치에서 멈췄다 실행했다 하면서 테스트를 진행할 것이다.
먼저 서비스 로직에서 디버그 중단점을 아래와 같이 설정한다.
IntelliJ의 실행/디버그 구성에서 Spring Boot Application을 2개 생성하고,
VM 옵션에서 포트 설정을 추가하여 포트를 다르게 적용한다.
필자의 경우 8080, 8081 포트를 사용했다.
-Dserver.port=8080
이후 두 개의 Application 모두 디버그 모드로 실행한다.
두 Application이 다 실행되었다면 Postman 등의 툴로 각각의 Application에 동시에 요청을 보낸다.
그러면 처음에 디버그 중단점에서 두 Application이 멈추고,
테스트하고 싶은 부분에 디버그 중단점을 지정하여 각 서비스 하나씩 재개 버튼을 누르며 테스트하면 된다.
우리는 조회 쿼리에 첫 디버그 중단점을 지정했으니 해당 쿼리가 실행되기 직전에 각 Application이 멈춘다.
두 Application 다 재개를 한 번씩 누르면 각 Application이 조회 쿼리만 보내고 save 메소드에서 멈출 것이다.
저장을 하지 않았으니 두 Application 모두 이메일 주소 검증에 성공하고,
이후 다시 재개하면 각각 저장하니 중복으로 Insert 됨을 확인할 수 있다.
위에 첨부한 이전 포스팅에서는 synchronized 방식으로 하나의 Application 기준으로 동시성 이슈를 해결했는데,
위와 같이 구성하고 해당 방법을 테스트해 보면 다른 Application이므로 중복 Insert가 발생함을 확인할 수 있다.
해결 방안
각 서버에서 Application이 독립적으로 실행되므로,
synchronized가 아닌 DB에서 Transaction 격리 수준을 설정하고,
Lock을 이용해 동시성을 제어해야 한다.
DB 트랜잭션 격리 수준 확인
먼저 현재 사용하고 있는 DB에서 아래 쿼리를 통해 Default 격리 수준을 확인해 보자.
SELECT @@GLOBAL.transaction_isolation;
SELECT @@SESSION.transaction_isolation;
두 쿼리의 결과가 모두 REPEATABLE-READ
혹은 SERIALIZABLE
인 경우 격리 수준 설정 부분은 지나가도 좋다.
참고로 MySQL의 경우 기본적으로 REPEATABLE-READ
라고 하는데, DB 서비스를 제공하는 곳에서 다르게 설정해두었을 수 있으니 꼭 확인하는 것이 좋다.
필자의 경우 ncloud에서 제공하는 Cloud DB for MySQL 서비스가 READ-COMMITTED
를 기본 설정으로 사용하고 있었다.
DB 트랜잭션 격리 수준 설정
아래의 예시에서는 Spring에서 자체적으로 격리 수준을 조정하는 방식으로 진행한다.
DB 자체적으로 격리 수준을 적용할 수 있지만,
이미 운영중인 서비스의 경우 격리 수준을 변경하면 예상치 못한 많은 오류가 발생할 수 있다.
먼저 JPA에서 격리 수준을 적용하기 위해 application.yml 설정에서 아래 설정을 추가하자.
spring.application.jpa.properties.connection.release_mode: on_close
위와 같은 설정을 추가하지 않으면 아래와 같이 격리 수준을 지정할 수 없다는 에러가 발생한다.
이후 서비스의 @Transactional
어노테이션에 REPEATABLE-READ
혹은 SERIALIZABLE
격리 수준을 적용한다. 아래 예시에서는 REPEATABLE-READ
을 사용한다.
// Service
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void test(String userEmail) {
if (userJpaRepository.findByUserEmail(userEmail).isPresent())
throw new AlreadyDataException("이미 등록된 이메일 주소입니다.");
userJpaRepository.save(new User(userEmail));
}
비관적 쓰기 잠금 설정
격리 수준 확인이 끝나면 호출할 쿼리에 비관적 쓰기 잠금을 설정한다.
하나의 Transaction이 끝나기 전(저장 전)에는 중복 조회를 진행하면 안 되므로 읽기 권한도 막기 위해 비관적 쓰기로 설정해야 한다.
만약 읽기에 대한 잠금을 설정하지 않는 경우 Dead Lock이 발생한다.
// JPA
@Lock(LockModeType.PESSIMISTIC_WRITE)
User findByUserEmail(String userEmail);
다시 테이블의 기존 데이터를 삭제한 후 위 순서대로 테스트를 진행하면 1개의 row만 생성되고 하나는 차단되는 것을 확인할 수 있다.
추가) @Transactional(readOnly = true) 해제
비관적 쓰기 잠금이 걸린 조회 쿼리 실행 시 @Transactional(readOnly = true)
이 걸려있는 경우 읽기 전용과 맞지 않는 Lock 생성으로 인해 오류가 발생한다.
따라서 @Lock(LockModeType.PESSIMISTIC_WRITE)
이 설정된 쿼리를 포함하는 경우 꼭 readOnly 속성을 해제해 줘야 한다.
마무리
다중 서버가 구성되는 경우 위처럼 Application 기준으로만 작업할 수 없으므로 단일 서버로 작업할 때보다 고려할 사항이 많으므로 항상 주의하며 개발해야 한다.
또한 DB도 공부하면 할수록 신경 쓸 부분이 정말 많다. 특히 위에서 잠깐 언급한 Dead Lock에 대해서는 추후 포스팅할 기회가 있을 것 같다.
'Java & Spring' 카테고리의 다른 글
[Spring] eventListener, transactionalEventListener 예외 및 트랜잭션 전파 총정리 (0) | 2023.12.29 |
---|---|
[Spring] QueryDsl transform 및 SqmCaseSearched 오류 해결방법 with Hibernate 6.x (0) | 2023.10.05 |
[JAVA] 내부 resource 파일 활용하기 (0) | 2023.07.29 |
[JAVA] LocalDate로 한국식 월 주차 구하기 (6) | 2023.07.27 |
[JAVA] Calendar로 한국식 월 주차 구하기 (0) | 2023.07.24 |