이전 포스팅: 2024.11.26 - [Project] - [포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락 해결기 (3)
이전 포스팅에서 네임드 락을 이용해 데드락을 해결을 시도했습니다.
하지만 문제가 있었는데요, 데드락을 해결했지만 `INSERT INTO ...` 쿼리가 2번 발생했다는 부분입니다.
`INSERT` 쿼리가 2번 발생했다는 뜻은 이미 외부 API가 2번 호출됐다는 의미이므로 아직 동시성 문제는 해결되지 않았습니다.
이제부터 그 이유를 한 번 파헤쳐 보겠습니다.
긴 글 읽어주시기 전, 가독성을 위해
다크 모드보다 라이트 모드로 읽으시는 것을 추천드립니다.
문제 발생: 동시성 문제가 해결되지 않음
네임드 락을 이용해 데드락은 발생하지 않았지만 근본적인 동시성 문제가 해결되지 않았습니다.
데일리 리포트 생성이 2번 호출되어 2개의 INSERT 쿼리가 발생한 것을 통해 알 수 있었습니다.
첫 번째 INSERT 쿼리
두 번째 INSERT 쿼리
로그를 확인해보니 첫 번째 INSERT 쿼리는 `02:08:20.070` 에 발생했고, 두 번째 INSERT 쿼리는 `02:08:20.141` 에 발생했습니다.
그 짧은 `70 밀리초` 사이에 2번 호출이 됐습니다.
하지만 테스트 결과에 따르면 생성된 데일리 리포트는 1개입니다.
그렇다면 두 쿼리 중 어느 쿼리가 레코드로 저장이 됐을까요? 가장 먼저 실행한 첫 번째 쿼리일까요? 아니면 두 번째로 실행한 쿼리일까요?
스레드에서 발생한 예외들 확인
이럴 수가! 네임드 락을 획득하는데 실패한 예외들이 아닙니다!
예상대로라면 네임드 락 실패 예외인 `NamedLockAcquisitionException`가 99개 있어야 합니다.
의도치 않은 예외들이지만 다행히도 `데일리 리포트 중복 생성 확인` 부분에서 중복 생성을 검증해서 외부 API가 여러 번 호출되는 것은 막았습니다.
그런데.. 첫번째로 보이는 예외는 `AssertionFailure` 예외입니다.
여기서 또 다른 문제가 발생했다는 것을 알 수 있습니다.
잠시 동시성 문제는 보류하고 이 문제부터 잡아보겠습니다.
또 다른 문제: 첫 번째 INSERT 쿼리만 저장되는 문제
쿼리가 2번 발생한 것도 문제지만, 2번의 INSERT 쿼리에는 2개의 레코드가 저장돼야 합니다.
하지만 MySQL에 실제 저장된 레코드는 첫 번째 쿼리에 의한 레코드뿐이었습니다.
왜 이런 현상이 발생했을까요?
원인 분석을 해보겠습니다.
원인 분석 1: AssertionFailure 예외 확인
먼저 각 스레드에서 발생한 예외를 저장하는 `exceptions`를 확인해 봤습니다.
이미지에 있는 예외는 `AssertionFailure`였는데, 영속성 컨텍스트가 플러시 될 때 `FlushEntityListener`에서 엔티티의 id가 유효한지 확인하는 `checkId()`를 호출합니다.
이때 `감정 분석(LetterAnalysis)` Entity의 id가 null 이이서 `AssertionFailure` 예외가 발생했던 것입니다.
원인 분석 2: 비즈니스 로직 확인
비즈니스 로직 흐름:
1. 네임드 락 획득
2. 중복 저장 확인
// 클로바 요청 ~ 파싱 생략
3. 데일리 리포트 저장
4. 감정 분석 저장
5. 네임드 락 반납
로직 흐름상 영속성 컨텍스트가 플러시 되는 부분은 `감정 분석(LetterAnalysis)` 엔티티들이 커밋되는 시점에 발생할 텐데, 커밋되기 전에 왜 이런 일이 발생했을까요?
이유는 첫 번째 문제의 원인과 동일하게 `4. 감정 분석 저장`과 `5. 네임드 락 반납` 부분에서 동기화가 되지 않았기 때문입니다.
네임드 락과 JPA 영속성 컨텍스트를 연관 지어 살펴보겠습니다.
Entity id 생성 전략
데일리 리포트 Entity
- id 생성 전략이 `GenerationType.UUID`로 설정되어 있습니다.
이 전략은 spring boot 3 & hibernate 6.x 버전부터 사용할 수 있습니다. (hibernate 6.2부터 UUID 버전 4 생성)
이는 데이터베이스에 `INSERT` 쿼리를 보내 프라이머리 키 값을 받는 것이 아닌, JVM에서 UUID를 생성하는 전략입니다.
다시 말해, 데이터베이스에 위임하는 방식이 아닌, 애플리케이션에서 생성하는 방식입니다.
감정 분석 Entity
- id 생성 전략이 `GenerationType.IDENTITY`로 설정되어 있습니다. (기본 키 생성을 데이터베이스에게 위임)
- 따라서 트랜잭션이 커밋되지 않더라도 영속화되는 시점에 `INSERT`쿼리가 나갑니다. (아직 커밋되지 않음)
- 트랜잭션이 커밋되면 영속성 컨텍스트에 플러시가 발생합니다. (데이터베이스와 동기화 시도)
네임드 락
- 네임드 락은 데이터베이스에서 제공하는 `MySQL 엔진 수준의 잠금`입니다. 트랜잭션과 무관하다는 것이 핵심입니다.
따라서 트랜잭션 내에서는 사용할 수 있지만 동기화가 되지 못할 수도 있습니다. - 커밋이 되고 나서 플러시가 발생하지 않습니다. 네이티브 쿼리를 호출하기 전에 영속성 컨텍스트에 플러시가 발생합니다. (데이터베이스와 동기화 시도)
원인 분석 정리
원인은 영속성 컨텍스트의 플러시 타이밍에서 동기화가 되지 않아 발생했습니다.
순서대로 정리하면,
- 두 번째 트랜잭션을 시작합니다.
- (트랜잭션과 무관) 네임드 락을 획득하기 위해 2초간 대기하다 획득합니다.
- `1) 데일리 리포트`는 `UUID`
`2) 감정 분석`이 영속화되는 시점에 `INSERT`쿼리가 나갑니다. (아직 커밋되지 않아 id는 `null` 입니다.) - (트랜잭션과 무관) 네임드 락이 반납됩니다. (아직 데이터베이스로부터 `@Id` 값을 받지 못했습니다.)
이때 EntityManager에서 `createNativeQuery()`이 호출되어 영속성 컨텍스트에 플러시가 발생합니다.
플러시가 발생하면서 `checkId()`로 엔티티들의 id가 유효한지 확인하지만 아직 데이터베이스로부터 auto_increment 된 아이디를 받지 못해 엔티티의 id는 `null`입니다. - 위와 같은 이유로 `AssertionFailure` 예외가 발생했고, 이는 런타임 예외이기 때문에 롤백이 수행됩니다.
따라서 영속성 컨텍스트에 있는 데일리 리포트 엔티티가 비영속 상태로 롤백되며 데이터베이스에 반영되지 않습니다. - 런타임 예외가 발생했으므로 두 번째 트랜잭션은 취소(롤백)됩니다.
해결: 영속성 컨텍스트 플러시 타이밍 맞추기
EntityManager 의 플러시 모드 기본값
플러시는 영속성 컨텍스트와 데이터베이스를 동기화하는 것을 말합니다.
그리고 이 문제를 해결하기 위해 Entity Manager의 기본 플러시 모드(flush mode)를 알아야 합니다.
플러시 모드는 `jakarta.persistence` 패키지에 `enum` 타입으로 추상화가 되어있는데요, 2가지 타입으로 되어있습니다.
- COMMIT: 트랜잭션 커밋이 수행되고 나서 플러시를 수행합니다.
- (기본값) AUTO: 네이티브 쿼리를 호출하기 전에 플러시를 수행합니다.
플러시 모드 변경 적용
이제 Entity Manager의 플러시 모드를 `AUTO`에서 `COMMIT`으로 변경해 보겠습니다.
변경된 부분
- 네임드 락 획득: 잠금을 획득하기 전에 플러시 모드를 `COMMIT`으로 변경합니다.
- 네임드 락 반납: 잠금을 반납하고 나서 플러시 모드를 `AUTO`로 설정해 원복 시킵니다.
플러시 모드 변경 적용 후 테스트 결과 (해결 완료)
몇 번 테스트를 수행한 결과를 보면 `AssertionFailure` 예외에서 `DataIntegrityViolationException` 예외로 변한 것을 볼 수 있습니다!
이로써 영속성 컨텍스트 내의 플러시 타이밍이 동기화되지 않아 감정 분석(LetterAnalysis) 엔티티의 id가 `null` 인 상황은 피했습니다.
본격적인 동시성 문제 해결하기
플러시 타이밍 문제를 해결했으니 본론으로 돌아와 동시성 문제를 해결해 보겠습니다.
어떻게 두 번째 트랜잭션은 첫 번째 트랜잭션이 이후에 `데일리 리포트 중복 생성` 검증을 피한 걸까요?
이유를 알기 위해 MySQL에서 지원하는 트랜잭션의 격리 수준과 MVCC를 먼저 알아봅시다.
트랜잭션 격리 수준 (Isolation level)
트랜잭션의 격리 수준은 ACID에서 `I(Isolation)`에 해당합니다. 여러 트랜잭션이 동시에 실행될 때 어떤 트랜잭션이 다른 트랜잭션에서 읽거나 쓰고 있는 데이터를 볼 수 있게 허용할지에 따라 단계별로 나뉩니다.
DIRTY READ | NON-REPEATBLE READ | PHANTOM READ | |
READ UNCOMMITED | 발생 | 발생 | 발생 |
READ COMMITED | 없음 | 발생 | 발생 |
REPEATABLE READ | 없음 | 없음 | 발생(InnoDB는 없음) |
SERIALIZABLE | 없음 | 없음 | 없음 |
(대표적인 세 가지 부정합 문제 외에도 다른 부정합 문제까지 공부할 때 많은 도움이 됐던 쉬운 코드에서 쉽게 배울 수 있습니다.)
MySQL에서 기본값으로 제공하는 InnoDB 스토리지 엔진을 사용 중이며, InnoDB 스토리지 엔진의 기본 격리 수준은 `REPEATABLE READ`입니다.
MVCC (Multi Version Concurrency Control)
InnoDB 스토리지 엔진은 `ROLLBACK`에 대비해 변경 전 레코드를 언두(Undo) 영역에 백업합니다.
이런 방식을 `MVCC`라고 하는데, MVCC의 주요 목적은 잠금을 사용하지 않은 일관된 읽기 작업을 지원하는 데 있습니다.
멀티 버전(Multi Version)은 하나의 레코드가 여러 버전으로 동시에 관리된다는 의미입니다.
`REPEATABLE READ` 격리 수준에서 MVCC를 보장하기 위해 실행 중인 트랜잭션 중 가장 오래된 트랜잭션 번호보다 트랜잭션 번호가 빠른 언두 영역의 데이터는 조회할 수도, 삭제할 수도 없습니다. (심지어 앞선 트랜잭션에서 커밋되었다고 하더라도 이후 트랜잭션은 읽지 못합니다.)
백문이불여일견. 그림으로 보겠습니다.
- 편의상 트랜잭션 번호는 `TX-ID`로 표기하며 `TX-ID: 1(트랜잭션 번호 1)` 이전에는 `TX-ID: 0`가 있었다고 가정합니다.
- `TX-ID: 1`: 데일리 리포트 중복이 있는지 조회합니다. 레코드가 존재하지 않아 0건을 가져옵니다.
- `TX-ID: 1`: 데일리 리포트 테이블에 `INSERT`를 수행하기 전 언두 영역에 변경 전의 스냅샷을 저장합니다.
- `TX-ID: 2`: `TX-ID: 1`이 커밋되기 전에 시작되었습니다. 하지만 2초간 네임드 락을 대기합니다.
- `TX-ID: 1`: 네임드 락이 트랜잭션과 동기화되지 않아 `COMMIT` 하기 전에 네임드 락을 반납합니다.
- `TX-ID: 2`: 네임드 락이 해제되어 대기 중인 `TX-ID: 2`가 네임드 락을 획득합니다.
- `TX-ID: 1`: `COMMIT`을 수행합니다. 트랜잭션이 종료됩니다.
- `TX-ID: 2`: 데일리 리포트 중복이 있는지 조회합니다. `TX-ID: 1`이 `COMMIT`을 하기 전에 트랜잭션을 시작했으므로 언두 영역을 읽습니다. 레코드가 존재하지 않아 0건을 가져옵니다.
- `TX-ID: 2`: 데일리 리포트를 테이블에 `INSERT`를 수행합니다.
- 이후, 감정 분석 테이블에 저장 시 유니크 키 제약조건에 의해 `DataIntegrityViolationException` 예외가 발생합니다.
(`편지`와 `감정 분석`은 OneToOne 관계입니다. 연관관계 엔티티의 `@Id`로 유니크 키 제약조건을 생성합니다.) - `TX-ID: 2`: 예외 발생으로 `ROLLBACK`을 수행합니다.
원인 분석 정리
- `TX-ID: 1` 트랜잭션이 커밋하기 전 `TX-ID: 2` 트랜잭션이 시작되었습니다.
- `TX-ID: 2` 트랜잭션은 네임드 락을 2초간 대기하는데, `TX-ID: 1` 트랜잭션이 예상보다 빨리 커밋하기 전에 네임드 락을 반납하여 `TX-ID: 2` 트랜잭션이 네임드 락을 획득했습니다.
- `TX-ID: 2` 트랜잭션은 `TX-ID: 1` 트랜잭션이 중간에 커밋을 수행하더라도 REPEATABLE READ 격리 수준에서 MVCC를 보장하기 위해 레코드가 없는 데일리 리포트 테이블을 보게 됩니다.
- 따라서 `TX-ID: 2` 트랜잭션에서도 `데일리 리포트 중복 생성` 검증 부분을 통과한 것입니다.
해결 방법: 네임드 락 대기 시간 제거
지금까지 구현된 코드에는 네임드 락을 2초간 대기합니다.
만약 대기 시간을 0초로 설정하면, 네임드 락을 획득할 때까지 대기하지 않을 것이고, 즉시 예외가 발생할 것입니다.
동시성 테스트 결과 (최종)
네임드 락을 획득한 첫 번째 스레드에서만 데일리 리포트를 생성하고, 커밋을 마쳤기 때문에 멀티 스레드 환경에서도 안전하게 트랜잭션을 수행할 수 있게 되었습니다.
주의할 점으로는 네임드 락은 명시적인 해제가 이뤄지지 않으면 자동으로 해제가 되지 않으니 데이터베이스에 문제가 발생하지 않았는지 주기적인 모니터링이 필요합니다.
다른 해결 방법은 없을까?
새로운 트랜잭션에서 별도로 처리하기
- `TX-ID: 1` 트랜잭션 이후에 네임드 락을 획득하고 새로운 `TX-ID: 2` 트랜잭션이 시작될 때 `TX-ID: 1` 트랜잭션을 잠시 일시정지 시킵니다.
- `TX-ID: 2` 트랜잭션에서 비즈니스 로직을 처리하고 커밋을 합니다.
만약 롤백이 발생하면 `TX-ID: 2` 트랜잭션에만 영향이 미칩니다. - 이후 네임드 락을 반납하고 `TX-ID: 1` 트랜잭션에서 커밋을 통해 트랜잭션을 종료합니다.
새로운 트랜잭션을 이용하는 방법은 퍼사드 패턴을 이용해 해결하는 예제 코드로 보여드리겠습니다.
- 퍼사드 클래스에서 기존 서비스와 네임드 락 서비스를 주입받습니다.
- 기존 비즈니스 로직의 트랜잭션 전파 속성을 `REQUEIRE_NEW`로 설정해서 별도의 트랜잭션에서 커밋을 하도록 합니다.
새로운 트랜잭션에서 처리할 때 주의할 점 1. (커넥션 풀 고갈)
새로운 트랜잭션을 사용한다는 의미는 트랜잭션 매니저로부터 새 커넥션을 획득한다는 의미입니다.
즉, 커넥션 풀을 2배로 사용하기 때문에 외부 API 응답에 병목이 생기면 커넥션 풀이 금방 고갈되어 다른 서비스까지 병목을 전파할 가능성이 그만큼 높아지는 위험이 있습니다.
새로운 트랜잭션에서 처리할 때 주의할 점 2. (처리 속도 지연)
기존 트랜잭션을 일시 정지하고 새 트랜잭션을 실행하는 방식인 만큼, 새 트랜잭션이 종료됐을 때 기존 트랜잭션을 재개하는 과정에서 오버헤드가 발생합니다.
또, 커넥션 풀에서 커넥션을 가져올 때 커넥션 풀이 고갈된 상태라면 커넥션이 반환되기를 기다리기 때문에 지연이 발생할 수 있습니다.
처리 속도가 지연되어 트랜잭션이 길어질 경우 다른 서비스의 응답도 함께 지연될 수 있는 위험이 있습니다.
그래서 새로운 트랜잭션을 사용할 때는 커넥션 풀의 개수, 병목 지점 등 여러 요인들을 고려해서 주의 깊게 사용해야 합니다.
처리 속도 비교
`기존 해결 방법`과 `새로운 트랜잭션으로 해결한 방법(퍼사드)`의 처리 속도 차이를 비교한 결과입니다.
커넥션 풀의 최대 개수가 20개일 때
커넥션 풀의 개수가 20개일 때 테스트 결과를 보면 한 개의 트랜잭션에서 처리하는 방법이 새로운 트랜잭션으로 처리하는 방법보다 대략 12배 빠른 것을 알 수 있습니다.
커넥션 풀의 최대 개수가 40개일 때
커넥션 풀의 개수가 40개일 때는 기존 방식이 대략 6배 빠릅니다.
아무래도 새 트랜잭션을 생성해야 하기 때문에 커넥션 풀의 개수가 많을수록 커넥션을 대기하는 시간이 줄었기 때문입니다.
물론 실제 외부 API의 응답 속도가 배제되었고, 실제 운영 환경과 다르지만 로컬 테스트에서도 이 정도 차이면 실제 운영 환경에선 격차가 더 커질 수 있습니다.
긴 글 읽어주셔서 감사합니다.
참고
- Real MySQL 8.0 - 1권
- https://www.baeldung.com/java-hibernate-uuid-primary-key
'Project' 카테고리의 다른 글
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (5) (0) | 2024.11.28 |
---|---|
올려 올려 라디오 NCloud 활용 후기 (2) | 2024.11.27 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (3) (2) | 2024.11.26 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (2) (0) | 2024.11.26 |
[포텐데이 409-1pick] 분석 서비스 개발 중 만난 데드락과 동시성 문제 해결기 (1) (0) | 2024.11.25 |