TIL/개발 칼럼

#3 타행 이체 기능 성능 개선기, 속도(아웃박스 스케줄러 전략 선정 및 구현)

김민석(갈레, 페퍼) 2023. 4. 8. 16:40
반응형

목차

1. 문제 상황

2. 요구 사항 분석

3. 스케줄링 전략 선정

  • 테스트 환경
  • 스케줄러 전략 간단 요약
  • 스케줄러 전략별 장단점
  • 스케줄러 전략 상세 설명 및 테스트 결과
  • 스케줄러 전략 3번 선택

4. 개선 필요 사항

 


✍️ 사전 지식

용어

  • 당행 : 사용자로부터 이체 요청을 받고, 이체 입금을 진행하는 은행 
  • 타행 : 이체 과정에서 출금을 진행하는 은행
  • 당행 이체 : 입금 은행과 출금 은행이 동일한 이체
  • 타행 이체 : 입금 은행과 출금 은행이 다른 이체
  • 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로 연속 이체하는 상황을 테스트 했습니다. 주식 트레이드 시스템 처럼 단위 시간 동안 수많은 타행 이체가 일어나는 상황을 가정했습니다.

✍️ 문제 상황

 이전 글(#2 타행 이체 기능 성능 개선기, 데이터 정합성)에서 DB polling을 구현하는 방식으로 스프링의 @Scheduled 메소드를 이용했습니다. 메소드 안에서는 매 timedelay마다 1건의 이벤트를 처리했습니다. 성능과 무관하게 원하는 대로 작동하는 로직을 만드는 것이 저의 목표였어서 이와 같이 구현했지만, 구현 후에는 성능에 대해 의심해볼 필요가 있었습니다. 

 

 실제로 nGrinder로 성능 테스트를 한 결과 이체 출금-입금 시간 간격이 33.3초가 나왔습니다. 출금 후 적어도 33.3초 후에 입금이 된다는 뜻이였죠. 목표 VUser보다 훨씬 적은 4명으로 했음에도 이렇게 적은 시간이 나왔다면, 목표 VUser가 100명일 때는 훨씬 느릴 것입니다.

 

<그림1. 평균 이체 처리 시간>
<그림2. 성능 테스트>


✍️ 요구 사항 분석

타행 이체 서비스에서 사용되는 '이체 입금 요청 API'와 '이체 입금 완료 응답 API'는 멱등성 있게 작동해야 한다는 은행간 규약이 존재했습니다(참조: #2 타행 이체 기능 성능 개선기, 데이터 정합성). 또한 추후 유지 보수성을 생각하면 scale out에 잘 맞아야 하며, 이체 출금-입금 시간 간격을 0.8초 이내로 가져오는게 좋습니다(목표로 설정한 MTT는 0.8였습니다. 하지만 이 MTT는 이체를 요청한 client 입장에서의 데이터이므로 이체 출금-입금 시간과 다릅니다. 그럼에도 사용자 관점에서 0.8초가 사용자 경험에 좋은 영향을 주는 응답 속도이므로 이 또한 0.8초가 되는 것이 좋을 것이라고 판단했습니다).

 

스케줄러가 고려해야할 사항

  1. 멱등성 : 이체 입금 요청 API와 이체 입금 완료 응답 API는 멱등성 있게 작동해야함.
  2. Scale out : 유지 보수성
  3. 0.8초 : 이체 출금-입금 시간 간격을 0.8초 이내로 가져오기.

✍️ 스케줄링 전략 선정

 스케줄링 전략에는 크게 4가지가 있었습니다. 각 스케줄링 전략의 특징에 대해 먼저 분석한 뒤 저희 요구 사항과 맞는 스케줄링 방법을 선정하겠습니다.

ps. 노션으로 만든 스케줄링 전략 분석 표가 이뻐서 한번 가져왔습니다 ㅎㅎ

 

<그림4. 스케줄링 전략 4가지>

테스트 환경

<그림5. 테스트 인프라>

  • VPC
  • 앱 서버 : Naver Cloud Platform, [Standard] 2vCPU, 8GB Mem [g2], 1대
  • DB : mysql 8.0.22
  • nGrinder agent : Naver Cloud Platform, [Standard] 2vCPU, 8GB Mem [g2]
  • nGrinder controller : Naver Cloud Platform, [Standard] 2vCPU, 8GB Mem [g2]

 

스케줄러 전략 간단 요약

  1. ReadOnceSendOnce : @Transactional가 붙은 스케줄러 메서드에서 이벤트 하나만 락 걸고 읽어와서 처리
  2. ReadMultipleSendOnce: @Transactional이 붙지 않은 스케줄러 메서드에서 여러 row에 읽어온 후 각 row를 비동기로 처리함. 비동기 처리 시 락걸고 처리.
  3. ReadOnceSendMultiple: @Transactional가 붙은 스케줄러 메서드에서 여러 row에 락 걸고 읽어와서 차례로(동기) row에 해당하는 로직 처리
  4. ReadOnceSendMultipleV2: @Transactional가 붙은 스케줄러 메서드, 여러 row에 락 걸고 읽어와서 각 row 관련 로직 처리(비동기)

 

스케줄러 전략별 장단점

스케줄러 전략 종류 장점 단점
ReadOnceSendOnce
  • 호출할 API가 멱등성 보장 해도 되고 안해도 됨
  • 구현이 굉장히 간단함
  • 동시간 대, 한 스레드만이 하나의 이벤트를 처리(스케일 아웃에 적합)
  • 테스트 결과, 이체 처리 평균 시간이 33.3초(TPS = 64.0)
ReadMultipleSendOnce
  • 호출할 API가 멱등성 보장 해도 되고 안해도 됨
  • ReadOnceSendOnce보다 빠른 이체 처리 평균 시간
  • m*n개의 컨텍스트 스위칭 but n개의 스레드만 로직 처리.
  • 테스트 결과, 이체 처리 평균 시간이 6.9초(TPS=51.0)
  • 동시간대, 여러 스레드가 하나의 이체 이벤트 처리 시도(스케일 아웃에 부적합)
ReadOnceSendMultiple
  • 테스트 결과, 이체 처리 평균 시간이 0.231초(TPS=38.0)
  • 동시간 대, 한 스레드만이 하나의 이벤트를 처리(스케일 아웃에 적합)
  • n개 읽어온 후 n-1번째 까지 처리 성공했는데 n번째에서 처리 실패하면 1~n까지 다시 처리해야하는 문제 발생
  • NIO를 동반하는 각 이체 이벤트를 동기로 처리하기에 이체 성능이 안좋을 것으로 예상
  • 호출할 API가 멱등성을 꼭 보장해야함
ReadOnceSendMultipleV2
  • 테스트 결과, 이체 처리 평균 시간이 0.332초(TPS=45.0)
  • 동시간 대, 한 스레드만이 하나의 이벤트를 처리
  • NIO를 동반하는 각 이체 이벤트를 비동기로 처리하기에 이체 성능이 비교적 좋을 것으로 예상
  • n개 읽어온 후 n-1번째 까지 처리 성공했는데 n번째에서 처리 실패하면 1~n까지 다시 처리해야하는 문제 발생
  • 호출할 API가 멱등성을 꼭 보장해야함

 

스케줄러 전략 상세 설명 및 테스트 결과

1. (현재) TransferReadOnceSendOnceScheduler

[특징]

  • @Transactional가 붙은 스케줄러 메서드에서 하나만 락 걸고 읽어와서 처리
  • 스케줄러 : 동기
  • 이체 메소드 : 동기
  • Read once, send once 전략
  • 상대 API가 멱등성 보장 해도 되고 안해도 됨
  • 1초에 몇개의 이벤트를 평균적으로 처리할 수 있는가 : (Async 스레드 개수 * was 개수) / max(로직 처리 시간, fixedDelay)
[성능 측정 결과]
<설정>
    - schduler timeDelay = 10ms
<결과>
    - tps : 64.0
    - 이체 처리 평균 시간 33.3초(그림1, 2 참조)

<그림5. TransferReadOnceSendOnceScheduler 코드>
<그림6. 평균 이체 처리 시간>
<그림7. 성능 테스트>

2. TransferReadMultipleSendOnceScheduler

[특징]

  • @Transactional이 붙지 않은스케줄러 메서드에서 여러 row에 읽어온 후 각 row를 비동기로 처리함. 비동기 처리 시 락걸고 처리. 이미 lock이 걸려있다면 다른 스레드가 처리중이므로 스레드는 자원 반납.
  • 스케줄러 : 동기
  • 이체 메소드 : 비동기
  • Read multiple, send Once 전략
  • 상대 API가 멱등성 보장 해도 되고 안해도 됨
  • n개의 이벤트 row가 있고, was가 m개 있다면 스케줄러가 m개 있기 때문에 최대 한번에 mn번 row를 읽고 mn번 스레드 컨텍스트 스위칭이 일어남. 하지만 n개의 row만 관련 로직을 처리하기 때문에 (m-1)*n개의 row는 관련 로직을 처리하지 못하고 그 결과 성능이 낭비되는 문제가 발생.
  • 애플리케이션 코드에서 병목이 일어남
[성능 측정 결과]
<설정>
    - threadPoolSize = 10
    - schduler timeDelay = 1000ms
<결과>
    - tps : 51.0
    - 이체 처리 평균 시간 6.9초

 

<그림8. TransferReadMultipleSendOnceScheduler코드>
<그림9. executeAllRequestProcess 코드>

 

3. TransferReadOnceSendMultipleScheduler

[특징]

  • @Transactional가 붙은 스케줄러 메서드여러 row에 락 걸고 읽어와서 차례로 row에 해당하는 로직 처리(동기)
  • 스케줄러 : 동기
  • 이체 메소드 : 동기
  • Read multiple sometimes, send at least Once 전략
  • 상대 API가 멱등성을 보장해줘야함
  • n개 읽어온 후 n-1번째 까지 처리 성공했는데 n번째에서 처리 실패하면 1~n까지 다시 처리해야하는 문제 발생
  • 각 입금 요청 이벤트 처리(NIO 동반)이 동기로 작동하기에 이체 완료 소요 시간이 비교적 클 것으로 예상
  • 동기로 각 outbox를 처리하기 때문에 cpu 효율이 낮아져서 tps가 낮게 나옴. 대신 이체 처리 속도는 빨라짐.
[성능 측정 결과]
<설정>
    - schduler timeDelay = 100ms
<결과>
    - tps : 38.0
    - 이체 처리 평균 시간 0.231초

<그림10. TransferReadOnceSendMultipleScheduler코드>

 

<그림11. 평균 이체 처리 시간>

 

<그림12. 평균 이체 처리 시간>

 

 

4. TransferReadOnceSendMultipleSchedulerV2

[특징]

  • @Transactional가 붙은 스케줄러 메서드, 여러 row에 락 걸고 읽어와서 각 row 관련 로직 처리(비동기).
  • 스케줄러 : 동기
  • 이체 메소드 : 비동기
  • Read multiple sometimes, send at least Once 전략
  • 상대 API가 멱등성을 보장해줘야함
  • n개 읽어온 후 n-1번째 까지 처리 성공했는데 n번째에서 처리 실패하면 1~n까지 다시 처리해야하는 문제 발생
[성능 측정 결과]
<설정>
    - schduler timeDelay = 100ms
<결과>
    - tps : 45.0
    - 이체 처리 평균 시간 0.332초

<그림13. TransferReadOnceSendMultipleSchedulerV2 코드>

 

<그림14. 평균 이체 처리 시간>
<그림15. 평균 이체 처리 시간>

스케줄링 전략 4번 선택

4가지 스케줄링 전략 중 4번을 선택했습니다. 1,2번 전략은 상대 API가 멱등성을 보장해도 되고 안해도 되는 send only once 전략이어서 현 설계에 사용될 수 있지만 이체 완료 소요 속도가 느린 것이 큰 단점이였습니다. 3번과 4번은 모두 상대 API가 멱등성을 보장해야하만 하는 전략이기에 현 설계와 부합했으며, 이체 완료 소요 속도 또한 0.231, 0.332초로 목표 기준치인 0.8초 보다 낮았습니다.  

 물론 스케줄링 전략 4번은 'n개 읽어온 후 n-1번째 까지 처리 성공했는데 n번째에서 처리 실패하면 1~n까지 다시 처리해야하는 문제 발생' 문제가 있었습니다. 하지만 이는 은행 서비스에서 큰 문제가 아니라고 판단했습니다. 실제로 이체 입금을 진행할 때 입금 은행에 문제가 생겨서 k번째 입금이 실패되는 일은 비교적 적다고 판단했습니다. 또한 실패가 일어나더라도 이체 입금 요청 API가 멱등성을 보장하기에 데이터 일관성 문제도 없을 것으로 결론 지었습니다.


✍️ 개선 필요 사항 

인프라 개선 필요

  • 스케줄러 내 각 이체 입금 이벤트 건이 동기로 작동하는 3번 스케줄링 방식이 비동기로 작동하는 4번 스케줄링 방식 보다 빠름. (0.231s < 0.332s)  NIO가 일어나기에 비동기가 더 빨라야 하지만 동기 방식이 더 빨랐음. 더 나은 테스트 데이터를 얻기 위해 인프라 개선이 필요하다고 판단함.

 

반응형