목차
1. [문제 상황] 비동기가 너무 느린 현상
2. [원인 분석] 동기로 작동하는 경우(케이스) 존재
3. 이체 입금 요청 API 처리 방법별 장단점
4. API 로직 선정 및 테스트
5. 고민해봐야할 것
✍️ 사전 지식
용어
- 당행 : 사용자로부터 이체 요청을 받고, 이체 입금을 진행하는 은행
- 타행 : 이체 과정에서 출금을 진행하는 은행
- 당행 이체 : 입금 은행과 출금 은행이 동일한 이체
- 타행 이체 : 입금 은행과 출금 은행이 다른 이체
- OutBox 데이터 : 이벤트나 메시지를 DB에 있는 아웃박스에 저장해서 DB 트랜잭션의 일부로 발행하는 패턴을 Transactinal OutBOx라고 합니다. 서로 다른 서버의 트랜잭션을 관리하고 할 때, eventual consistency를 보장하기 위해 DB 테이블을 메세지 큐로 사용하는 방식을 사용합니다. 이때 DB 테이블에 있는 이벤트 데이터를 OutBox 데이터라고 본 글에선 칭했습니다.
- OutBox polling : DB에 있는 아웃박스를 폴링해서 메시지를 발행하는 방식입니다. Transactional OutBox 패턴에 사용되는 Polling 발행기 패턴입니다.
Transactional OutBox 패턴 Ref : https://thebook.io/007035/0185/
Polling 발행기 패턴 Ref : https://thebook.io/007035/0186/
테스트 환경
- 구체적인 타행 이체 상황 : 타행 이체 중에서도 계좌 A에서 계좌 B로 연속 이체하는 상황을 테스트 했습니다. 주식 트레이드 시스템 처럼 단위 시간 동안 수많은 타행 이체가 일어나는 상황을 가정했습니다.
✍️ [문제 상황] 비동기가 너무 느린 현상
이전 글(#4 타행 이체 기능 성능 개선기, 속도(인프라 개선))에서 평균 이체 처리 시간이 너무 긴 문제가 있었습니다. 타행 이체 시 하나의 서버가 당행과 타행 역할을 모두하게 되어 로컬 호스트 호출이 일어나 동기가 비동기보다 빠른 현상은 없어졌지만, 비동기가 너무 느린 결과가 남았었죠. 이전에는 NIO(1,4번)가 문제로 생각해서 접근했었습니다. NIO로 인한 문제는 해결했으니, 그 다음으로 문제를 일으킬 소지가 있었던 DB I/O(2,3,5번)을 본 글에서 살펴볼 예정입니다.
✍️ [원인 분석] 동기로 작동하는 경우(케이스) 존재
이전에는 NIO(1,4번)가 문제로 생각해서 접근했었습니다. NIO로 인한 문제는 해결했으니, 그 다음으로 문제를 일으킬 소지가 있었던 DB I/O(2,3,5번)을 살펴보도록 하죠.
CPU, Memory 이상 확인
병목 구간이 있는지 먼저 모니터링 툴로 확인해봤습니다. CPU, Memory, 쿼리 등 이상 있어 보이는 곳은 없었습니다. 즉 현재 서버 과부화 등으로 인한 문제는 없다고 할 수 있었습니다. 대신 로직 상에서 제가 원하는 방식대로 동작을 안하는 것이라고 생각했습니다. 이에 이체 '입금 요청' 이벤트를 처라하는 스케줄러와 이체 '입금' 이벤트를 처리하는 스케줄러에 로그를 남겼습니다.
로그 확인
두 스케줄러에 로그를 남기고 직접 모니터링을 한 결과 병목 구간이 존재했습니다. <그림5>에서 보시다 싶이 이체 '입금 요청' 이벤트를 처리하는 스케줄러는 20개 이하로 이벤트를 일정 시간마다 처리를 하는 반면, 이체 '입금' 이벤트를 처리하는 스케줄러는 제 시간에 처리하지 못해 이체 입금 이벤트가 계속 쌓이고 있었습니다.
Kibanna 분석
Kibanna 분석 결과, 비관락을 오래 잡고 있는 것이 문제인 것을 확인했습니다. 이체 로직 처리 전체 시간의 95%가 비관락을 획득하는데 걸리는 시간이였습니다. 그 이유는 비관락을 잡고 외부 API 호출을 하기에 비관락을 더 오래 잡게 되는 것이였습니다. lock waiting을 하는 다른 스레드는 lock을 잡고 있는 스레드의 외부 API MTT까지 기다려야 했습니다.
코드 레벨 분석
이체 입금을 진행하는 코드 살펴봤습니다. 입금을 할 때 비관락을 잡고 입금을 하고 있었습니다. 즉 이체 입금 시, 비관 락으로 계좌를 점유한 후 이체 완료 응답 API를 호출한 후 그 결과를 받은 후에 commit을 하며 lock을 푸는 구조였습니다. 입금과 같이 돈이 오고 가는 로직에서 lock을 거는 것 큰 문제가 되지 않았지만, 제가 성능 테스트에서 실행하는 테스트 케이스에는 맞는 설계가 아니였습니다.
저는 계좌번호 1번에서 2번으로만 지속하여 타행 이체를 하고 있었습니다. 즉 타행(이체 입금 은행)에서 이체 입금 이벤트를 처리하는 메소드는 모두 2번 계좌에 접근해야 합니다. n개의 이체 입금 이벤트가 있으면, 1개만이 2번 계좌의 비관 락을 잡고 나머지 n-1개는 대기하게 됩니다. 이때 비관락을 점유한 스레드가 이체 입금 완료 응답 API를 호출(NIO)한 후 HTTP response까지 받으면 해당 lock을 풀게 되죠.이에 n개의 이체 입금 이벤트 각각은 다른 이벤트 들의 이체 입금 완료 응답 API(NIO)까지 끝나는 것을 차례로 기다려야 합니다. 비관락의 대기 큐에 n번째로 서있는 스레드는 n-1개의 스레드가 당행과 이체 입금 완료 API(NIO)를 주고 받는 것까지 기다리는 상황인거죠.
그럼 비관 lock을 잡지 말아야 할까요? 그렇지 않습니다. 데이터 일관성이 중요한 돈과 같은 경우엔 lock을 잡는 것이 좋습니다. 물론 낙관 lock을 잡을 수도 있습니다. 하지만 저는 이체가 일어날 때 자동 트레이딩 시스템 처럼 연속 이체 또한 일어날 수 있는 상황 또한 고려했습니다. 연속 이체가 일어날 수 있는 경우는, 경합이 많이 일어날 것으로 가정할 수 있는 경우와 같습니다. 이에 경합이 일어날 것으로 예상하지 않는 낙관 락 보다 비관 락이 요구 사항에 더 맞는 방식이라고 판단했습니다.
이체 입금 요청 API 처리 방식, 바로 입금
Lock을 잡은 상태에서 이체 입금 완료 응답 API(NIO)를 동기로 호출하는 것이 문제라고 판단했습니다. 이는 이체 입금 요청 API가 호출 됐을 때 이체 입금을 바로 실행하는 것으로 해결할 수 있다고 생각했습니다. lock을 잡은 상태에서 발생할 NIO를 최대한 줄이는 것이죠. 현 상황에서도 입금 시 비관 락을 잡기 때문에 이체 입금은 차례로 입금 될 것입니다. 하지만 타행이 당행과 소통하는 NIO가 없게 되는 것이죠. 물론 바로 이체 입금 하는 방식에게 단점이 없는 것은 아닙니다. 두 방식의 장단점에 대해 알아보죠.
✍️ 이체 입금 요청 API 처리 방법별 장단점
이체 입금 이벤트 적재 후 200 return | 바로 이체 입금 처리(계좌에 lock걸고 입금, 이체 입금 내역 적재) | |
출금-입금 시간 차 | 큼(느림) | 작음(빠름) |
Lock 책임 전파 | 실제 이체 입금 진행 시 lock을 사용해서 발생하는 다양한 문제들의 책임을 입금 은행에서만 담당 | 실제 이체 입금 진행 시 lock을 사용해서 발생하는 다양한 문제가 출금 은행으로도 전파됨(출금 은행과의 connection을 유지한 채로 계좌 row에 pessimistic lock을 걸기 때문) |
NIO | 이체 입금 완료 response API를 출금 은행으로 보내야함.(NIO 한번 더 사용) | 추가x |
DB IO | DB polling 부하(추후 다른 방식으로 해결 가능) | 추가x |
이벤트를 적재 후 200 return 하는 방식은 lock을 사용해서 발생하는 다양한 문제들의 책임을 입금 은행에서만 담당하여 유지 보수 면에 장점이 있었습니다. 실제로 <그림9> 오른쪽 케이스의 이체 입금 요청 API에서 타행(이체 입금 은행)은 insert만 하기 때문에 lock과 관련되있지 않습니다. 입금 계좌 관련하여 deadlock이 발생해도 당행(이체 출금 은행)은 영향을 받지 않게되죠. 그렇지만 이 방식은 스케줄러를 사용하고 NIO를 한번 더 사용하기 때문에 출금-입금 시간 차가 비교적 클 것으로 예상됐습니다.
그에 반해 바로 이체 입금 처리를 하는 방식은 출금-입금 시간 차가 작은 이점이 있지만 입금 시 비관 lock을 사용하여 발생하는 다양한 문제가 출금 은행으로도 전파될 수 있는 문제가 예상됐습니다. 예를 들어, 타행 앱에서 입금 계좌와 관련된 데드락이 발생 시, 그 lock으로 인한 문제가 당행(이체 출금 은행)까지 영향을 끼치게 되죠. 두 방식 모두 장단점이 존재하여 실제로 성능 테스트를 해 본 뒤 출금-입금 시간 차가 어느 정도 차이가 있는지 확인하기로 결정했습니다.
✍️ API 로직 선정 및 테스트
바로 이체 입금 처리 방식
테스트 결과 바로 이체 입금을 처리한 경우는 평균 이체 처리 시간이 0.470초가 걸려서, 이벤트를 적재하는 방식보다 77.317배 빨랐습니다. 바로 이체 입금을 하는 방식은 클라이언트와의 connection을 유지한 채 비관 락을 잡기 때문에 데드락 같은 상황이 발생 시 클라이언트로 문제가 전파될 소지가 있지만, 현재 저의 목표는 0.8초 이하의 평균 이체 처리 시간이기에 이 방법으로 구현하기로 결정했습니다. 추후 관련 문제가 생길 시 timeout 시간을 조정하는 등 문제 전파를 최소화 시킬 방향으로 해결해볼 예정입니다.
✍️ 고민해봐야할 것
1. 입금 계좌와 관련하여 deadlock이 걸리면?
2. TPS 79.6보다 더 높일 수는 없을까?
'TIL > 개발 칼럼' 카테고리의 다른 글
Resume 링크 (0) | 2023.04.23 |
---|---|
#6 타행 이체 기능 성능 개선기, 속도(gap lock으로 인한 insert 병목 해결) (1) | 2023.04.20 |
#4 타행 이체 기능 성능 개선기, 속도(인프라 개선) (0) | 2023.04.08 |
#3 타행 이체 기능 성능 개선기, 속도(아웃박스 스케줄러 전략 선정 및 구현) (0) | 2023.04.08 |
#2 타행 이체 기능 성능 개선기, 데이터 정합성(아웃박스 패턴) (1) | 2023.04.07 |