분산 트랜잭션(JTA)기반의 회원 시스템 개발
오랫동안 농익은 레거시 시스템은 현재 시점에서 뜯어볼수록 의문이 많은 설계와 코드들이 참 많습니다.
제가 속한 서비스의 회원 시스템도 마찬가지였습니다. 서비스를 이용하는 회원의 정보가 서로 다른 3개의 DB에 나뉘어 관리되고 있었습니다. 한명의 회원 정보가 여러 DB에 나뉘어 저장되는 것도 아니고, 유입된 채널에 따라 저장되는 DB가 물리적으로 분리되어 있었습니다.
즉 온라인에서 가입한 고객은 오프라인에서 확인할 수 없고, 오프라인에서 가입한 고객은 온라인 서비스에서 접근할 수 없는 구조였습니다.
이는 당연히 마케팅 측면이나 서비스의 확장성 측면에서 많은 어려움을 만들고 있었고, 이를 통합하기 위한 자칭 '통합회원 프로젝트'를 진행하게 되었습니다.
분리되어 있는 DB는 총 3개로 각각 온라인, 오프라인, 멤버십 DB로 분리되어 있었습니다.
이중 온라인, 오프라인은 MsSQL기반 그리고 멤버십 DB는 Oracle로 구성되어 있는 상황이었습니다.
3개의 서로 다른 DB를 하나의 회원 원장 DB로 통합하기에는 이미 너무 많은 시스템에서 해당 DB들을 바라보고 사용중이었으며, 우리가 파악한 시스템에서 개선작업을 해준다 할지라도 파악하지 못한 시스템에서 사이드이펙트가 발생할 수 있다고 판단했습니다. 더불어 회원 정보는 주문과 결제에 직업 영향을 미치고 있기에 어느정도의 사이드 이펙트를 감수하는 것조차 너무 큰 리스크라고 생각하였습니다.
결국 3개의 서로 다른 DB를 연결하는, 연결테이블(통합회원 원장 테이블)을 만들고 해당 테이블의 키값을 각 회원 원장 DB에 추가하여 3개의 DB를 통합하는 방식으로 설계하였습니다.
DB를 통합하게 됨으로, 이제 통합회원을 기준으로 회원의 신규가입 및 인증, 인가 처리가 될 수 있도록 API 개발이 이루어져야하고, 기존 회원 정보또한 통합회원에서 신규 적재 및 키값 채번 될 수 있도록 Migration 작업도 이루어져야 합니다.
여러 작업들 중 가장 큰 이슈가 되었던 부분은 한명의 회원이 등록 혹은 수정될때 3개의 DB에 데이터 트랜잭션을 보장해줘야 했습니다.
그럼 3개의 서로 다른 DB에 대해 어떻게 트랜잭션 처리를 해줄 수 있을까에 대해 고민하게 되었고 고를 수 있는 몇가지 옵션이 존재했습니다.
1. 트랜잭션 처리를 하지 않는다. ( DB 동작시의 오류는 간헐적이니깐 몇개의 데이터 오류는 무시해도 되지 않을까? )
2. 3개의 DB를 모두 하나의 트랜잭션으로 묶어서 처리하자.
3. 이벤트 기반 설계로 준실시간성으로 데이터를 처리하고, 이벤트 처리시 발생하는 오류는 경우에 따라 Retry 처리 및 오류 발생 얼럿을 개발자가 모니터링하여 수기 처리하자 ( 1번 방법의 보완책 )
1번 방안의 경우 적은 경우라도 데이터의 정합성이 흐트러지는건 결단코 안되기에 자연스럽게 넘어가게 되었고, 3번의 경우는 그 당시 결국 트랜잭션으로 묶어 처리하면 되는데 굳이 모니터링과 수기처리 그리고 이벤트 기반 설계에 대한 개발자들의 러닝 커브로 인해 다음 방안으로 넘어가게 되었습니다. 뒤에 후술하겠지만 만약 이때로 다시 돌아간다면 이벤트 기반 설계로 가져가는 방안을 좀더 적극적으로 추진했을 것 같습니다. (분산 트랜잭션 처리로 인한 부하와 관리 이슈)
2번 방안으로 선택된 3개의 DB를 하나로 묶는 작업은 다행히 자바에서 분산 트랜잭션 처리를 위한 표준 API(JTA)를 제공하고 있었고, 이를 이용하여 3개의 DB를 하나의 트랜잭션으로 묶어 회원 데이터에 대한 정합성을 보장할 수 있었습니다.
분산 트랜잭션 적용기는 다른글에서 자세히 적어보도록 하겠습니다.
예상치 못한 성능 이슈
서비스 오픈후 우리팀이 놓친 문제가 발생되기 시작했습니다.
MsSQL은 기본적으로 쿼리 실행시 쿼리당 단일 트랜잭션으로 처리하며, 이는 Insert, Update 뿐 아닌 Select에도 해당되었습니다.
이는 결국 API 요청의 대부분을 차지하는 조회 API의 성능이슈로 연결되었고, 쿼리 실행시 선행, 후행되는 트랜잭션 처리는 쿼리 실행시 소요되는 리소스보다 더 큰 부하를 발생시키게 되었습니다. ( 쿼리 실행 시간 대비 많게는 5배이상 )
약간의 트래픽만으로도 서버는 터져버리기 시작했고, 이벤트가 있는 날에는 어김없이 서버부하로 인한 에러를 뱉어냈습니다.
MsSQL에서는 쿼리에서 with(nolock) 구문을 통해 해당 쿼리가 동작할때 잠금을 걸지 않고(Read Uncommitted Level)로 동작하도록 하여 트랜잭션 처리에 대한 비용을 줄일 수 있도록 하고 있습니다.
다만 JPA에서는 MsSQL에 대한 with(nolock)을 인터페이스로 제공하지 않고 있으며, 해당 구문을 사용하기 위해서는 Native Query를 사용할 수 밖에 없었습니다. 이 방안은 JPA가 가지는 이점을 버려야한다는 점, 그리고 조회 쿼리 작성시마다 Native Query로 작성해야한다는 점에서 해결방안으로 선택되지 않았습니다.
이시점에 생각한 다른 몇가지 방안은 다음과 같습니다.
1. GET, POST 요청을 두개의 인스턴스로 분리하여 GET 요청에 대해서는 분산 트랜잭션 처리를 하지 않는다.
2. 지금이라도 이벤트 기반 처리로 설계를 변경하여, 분산 트랜잭션을 걷어낸다.
3. Propagation을 이용하여 특정 메소드에서만 분산트랜잭션이 동작되도록 처리한다.
우선 팀내에서는 1번방안으로 처리하여 하나의 서버에 두개의 인스턴스를 띄우고 Nginx의 라우팅을 통해 요청을 로드밸런싱하였습니다.
다만 해당 작업을 위한 Nginx의 추가적은 설정 작업과 하나의 서버에 두개의 인스턴스가 떠있어야한다는점, 분산트랜잭션을 관리하는 근본적인 해결책이 되지 못한다는 점에서 조금은 찝찝한 대응책으로 남아 있었습니다.
2번의 경우는 이미 개발이 마무리된 시점에 설계가 변경되면 우리팀이 만든 API를 사용하는 다른 서비스에서도 이벤트 기반 처리와 함께 많은 공수가 들어가기 때문에 현실적으로 불가능했습니다.
3번의 방안은 뒤늦게 알게 된 방법으로, @Transactional 어노테이션의 트랜잭션 전파 옵션으로 불리는 propagation 옵션을 이용하여 메서드별 트랜잭션 범위를 지정하도록 처리하였습니다.
@Transactional 어노테이션은 propagation을 통해서 선언된 메서드 혹은 클래스의 트랜잭션 전파 방식을 지정할 수 있도록 지원해주고 있습니다. 해당 옵션의 선택값은 다음과 같습니다.
- REQUIRED ( 기본값 )
- REQUIRED_NEW (항상 새로운 트랜잭션 생성)
- NESTED (현재 트랜잭션이 존재하면 중첩 트랜잭션 생성, 없으면 새 트랜잭션 시작)
- SUPPORTS (현재 트랜잭션이 존재하면 해당 트랜잭션을 사용, 없으면 트랜잭션 없이 실행)
- NOT_SUPPORTS (현재 트랜잭션이 존재하면 일시 정지하고, 트랜잭션 없이 실행)
- MANDATORY (트랜잭션이 존재하지 않으면 예외 발생)
- NEVER (트랜잭션이 존재하면 예외 발생)
우리 서비스에서 필요한 조건은 데이터 정합성 보장이 필요한 메서드에서는 트랜잭션 처리가 되어야하나, 단순 조회에서는 트랜잭션 처리를 하지 않으며, 트랜잭션이 적용된 메서드에서 조회메서드를 호출할 경우는 모두 묶어서 트랜잭션 처리가 되어야 하는 상황이었습니다.
이 상황에 적합한 Propagation 옵션은 SUPPORTS가 존재했고, 해당 옵션을 부여한 @Transactional 어노테이션 선언으로 그동안의 이슈는 깔끔하게 해결 되었습니다.
+ 분리되어 있던 서버 인스턴스 통합 및 성능 이점까지 모두 Get
회고
결과적으로 이번 프로젝트와 이슈를 해결하면서 다음과 같은 부분들에 좀더 신경써야겠다는 회고를 하였습니다.
1. 오픈전 코드레벨의 테스트와 부하테스트를 철저하게 진행하자.
2. 사용하고자 하는 기술에 대해서는 깊이있게 학습하자. ( 학습에 과함은 없다는걸 다시 한번 느낌 )
3. 초기 설계시 좀더 넓은 숲을 바라보고 설계해야한다.
'Java' 카테고리의 다른 글
[Java] Java 원격 디버깅(Java Remote Debugging) (0) | 2025.01.08 |
---|---|
[Java] JVM의 한계와 GraalVM 살펴보기 (1) | 2024.12.16 |
[Java] JVM Warm up - if(kakao)2022 (1) | 2024.11.11 |