이 포스팅에서는 프로젝트에서 기존 비즈니스 로직의 문제점을 확인하고, Spring Events를 활용해 비즈니스 로직을 원자적으로 처리하는 내용을 다룹니다.
제목에 "모놀리식 아키텍처"를 붙인 이유는 MVP 개발부터 채택한 모놀리식 아키텍처를 그대로 유지하면서 최소한의 변경으로 문제를 해결하는 과정을 강조하기 위함입니다.
현재 서비스 규모가 작고, 이용자 수가 많지 않은 상황에서 오버 엔지니어링을 하지 않고 개선해 보겠습니다.
문제의 발단: 클로바 스튜디오 서비스의 장애
1월 13일 오후 9시 45분경 클로바 스튜디오에 장애가 발생했습니다.
우리 서비스의 첫 MVP였던 달토의 답장 서비스는 클로바 스튜디오에 의존하고 있었는데, 팀에서는 10시 30분이 지나서야 장애 발생을 인지했습니다.
문제 발단은 클로바 스튜디오에 장애가 발생해서는 맞는데, `"진짜 문제"`는 따로 있었습니다.
편지를 쓰면 클로바가 답장을 만들어주는데, 클로바를 사용하는 만큼 비용이 발생하다 보니 처리율 제한기로 사용 횟수에 제한을 뒀습니다.
여기서 말하는 `"진짜 문제"`는 외부 서비스에 장애가 발생해서 사용자가 답장을 못받았음에도 사용 횟수가 차감되었다는 것입니다.
서비스 흐름 확인하기
간략하게 응답이 성공하는 케이스를 나타내면 아래처럼 됩니다.
- 사용자 → 편지 서비스: 편지 작성
- [트랜잭션 A] 편지 서비스 → 처리율 제한기: 사용 횟수 선차감
- [트랜잭션 A] 편지 서비스 → 데이터베이스: 편지 저장
- 편지 서비스 → 답장 서비스: 답장 생성 요청
- [트랜잭션 B] 답장 서비스 → 클로바 서비스: 답장 생성 요청 위임
- [트랜잭션 B] 클로바 서비스 → 클로바 스튜디오 API: 외부 API 호출 및 응답 (← 문제가 되는 부분)
- [트랜잭션 B] 답장 서비스 → 데이터베이스: 답장 저장
- [트랜잭션 B] 답장 서비스 → 사용자: 응답
문제점 1. 외부 API 를 호출하는 로직이 트랜잭션에 포함되는 문제
클로바 스튜디오 API의 응답 시간은 호출하면 빠르면 3초, 늦으면 10초 정도 걸립니다.
위의 이미지는 클로바 스튜디오 API를 호출하는 FeignClient의 설정입니다.
리드 타임아웃(read-timeout)을 120초로 설정되어 있습니다.
(또 다른 우리 서비스의 일부인 분석 서비스에선 분석할 편지가 많을 땐 90초 정도 걸리기 때문입니다.)
외부 API 로직이 트랜잭션에 포함되면 심각한 문제를 초래할 수 있습니다.
- 외부 API 호출 로직이 `답장 서비스`의 트랜잭션에 포함되어 있습니다.
- 외부 API 응답이 지연되는 만큼 데이터베이스 커넥션 점유 시간이 늘어납니다.
(만약 재시도 로직까지 있다면 그만큼 더 점유하게 되겠죠) - 이는 커넥션 풀의 고갈을 초래해 나중엔 서비스 응답 불가까지 이어질 수 있습니다.
문제점 1 해결하기
아래 그림처럼 트랜잭션로부터 외부 API 호출을 분리하면 됩니다.
외부 API 호출을 트랜잭션 B에서 분리했기 때문에 커넥션 풀 고갈 위험을 제거할 수 있습니다.
문제점 2. 비즈니스 로직이 원자적으로 실행되지 않는 문제
드디어 이 포스팅을 작성한 이유입니다.
위의 문제점 1 해결하기에서 `편지 서비스`의 트랜잭션 A와 `답장 서비스`의 트랜잭션 B를 각각 별개로 사용하기 때문에 전체 비즈니스 로직이 원자적으로 실행되지 않는 문제가 있습니다.
외부 API에 장애가 발생했을 때는 이미 트랜잭션 A는 커밋된 상태기 때문에 롤백이 불가능합니다.
또한, 외부 API가 트랜잭션 사이에 존재하는 한 Facade로 각 트랜잭션을 묶을 수도 없습니다.
트랜잭션 내부에 외부 API 호출 로직을 넣으면 문제점 1에서 해결한 것을 되돌려 놓는 것과 다름없기 때문이죠.
이 문제를 해결하기 위해 분산 트랜잭션, Saga 패턴 등을 정리했지만 메시지 큐의 도입 등이 필요한 오버 엔지니어링이라 생각되어 사용하지 않았습니다. 대신 Spring에서 제공하는 Spring Event를 활용해 모놀리식 아키텍처 내에서 해결하고자 합니다.
Spring Event에 대해 자세한 내용은 Spring Event Deep Dive에 정리했습니다. (이 포스팅을 작성하다 Spring Event 내용이 길어져 분리..)
문제점 2 해결하기: 이벤트 기반으로 원자성 확보하기
롤백이
아래 그림은 비즈니스 로직을 원자적으로 실행하기 위해 개선한 흐름도입니다.
AS-IS
TO-BE
이전 흐름도와의 차이점은 트랜잭션 A와 트랜잭션 B를 트랜잭션 한 개로 묶었습니다. 데이터베이스 커넥션을 최소한으로 사용하도록 개선한 부분입니다.
[AS-IS의 문제점]
외부 API 호출을 트랜잭션 바깥으로 분리했어도 처리율 제한기 로직은 여전히 트랜잭션 범위를 벗어납니다. 외부 API에서 장애가 발생했을 때 롤백이 되지않아 의도한 상태가 아니게 됩니다. (데이터 불일치 상태)
사용 횟수 선차감은 의도된 부분이라 어쩔 수 없는 부분입니다.
사용 횟수를 선차감하는 이유는 커머스 도메인에서 재고 선차감을 통해 구매를 할 수있는지 없는지를 먼저 체크해서, 재고가 없음에도 불필요한 배송 정보 등을 입력하는 과정을 고객이 겪지 않도록 하기 위함과 동일합니다.
따라서 비즈니스 로직 전체를 `원자적으로` 처리할 수는 없지만, 어느 정도 감수해야 하므로 `Eventually Consistency`를 보장하도록 타협했습니다.
Eventually Consistency 알아보기
Eventually Consistency는 분산 환경에서 트랜잭션의 원자성을 완벽하게 보장하기 어렵기 때문에, 일시적으로 데이터가 불일치 상태에 있어도 최종적으로(Eventually) 일관된(Consistency) 상태로 수렴함을 보장하는 전략을 말합니다.
[AS-IS 문제점을 Spring Event로 개선하기]
트랜잭션 범위를 벗어나는 로직은 분산 트랜잭션 환경에서 사용하는 `보상 트랜잭션` 개념이 있습니다.
보상 트랜잭션 알아보기
`보상 트랜잭션`은 비즈니스 로직이 실패했을 때 이전 작업을 롤백하는 것처럼 보이도록 원래 상태로 복구하는 "보상 작업"을 말합니다. (이 글에서는 차감된 사용 횟수를 다시 원복하는 작업이 되겠죠?)
`보상 트랜잭션`이 실행되는 타이밍은 외부 API의 응답 코드가 `4xx`, `5xx`일 때 `응답 실패 이벤트`가 발행되어 처리율 제한기에서 이벤트를 수신했을 때입니다. 이벤트를 수신하면 사용자의 ID를 통해 사용 횟수를 원복합니다.
'Project' 카테고리의 다른 글
프로젝트 리팩토링 (1) - 객체 지향 설계 (0) | 2025.01.31 |
---|---|
모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (2) (1) | 2025.01.17 |
[올려올려 라디오] 신규 분석 기능 성능 테스트 (3) (1) | 2024.12.08 |
[올려올려 라디오] 신규 분석 기능 성능 테스트 (2) (0) | 2024.12.06 |
[올려올려 라디오] 신규 분석 기능 성능 테스트 (1) (1) | 2024.12.05 |