이번 프로젝트에서는 명확한 API 정의서나 가이드 조차 없는, 제약 사항이 많은 외부 API 를 활용하며 발생했던 동시성 이슈, DB 커넥션 고갈 문제, 그리고 이를 해결하기 위한 RabbitMQ → SQS 로 리펙토링 과정을 정리한다.
1. 기존 아키텍처 및 프로세스
시스템의 기본 구조는 메인 서버와 수집 서버가 메시지 큐를 사이에 두고 통신하는 형태다.
[Architecture Flow]

- 메인 서버는 수집에 필요한 정보를 담아 메시지를 발행한다.
- 수집 서버는 해당 메시지를 수신하여 외부 API를 호출해 정보를 조회한다.
- 조회 결과를 저장하고, 그 결과를 다시 메시지로 발행해 메인 서버에 반환한다.
2. 문제점
서비스를 운영하며 외부 API의 특성과 트래픽 증가로 인해 다음과 같은 문제들이 발생 하였다.
2.1. 동시성 제어와 인증 키 만료 문제
현재는 메인 서버에서 필요한 정보를 수집하기 위해 사용자 당 10~100 개의 메시지를 발행하고, 수집 서버(Worker)들이 이를 수신해 병렬로 외부 API 를 통해 정보를 수집하고 처리하는 구조다.
이 과정에서 외부 API 특유의 인증 방식으로 인해 심각한 동시성 문제가 발생했다.
[ 외부 API 의 제한 사항 ]
- 사전 발급 및 재발급 불가
인증 Key는 수집 프로세스가 시작되기 전, 사용자가 본인 인증을 완료하는 이전 프로세스에서 발행된다.
이러한 인증 Key 는 시스템이 임의로 Key를 재발급할 수 없고, 무조건 사용자가 직접 재인증을 진행해주어야만 한다.
- 매 요청/응답으로 관리되는 인증 KEY
모든 API 요청에는 인증 Key 를 포함해야 하며, 값이 비정상적이거나 만료되면 즉시 오류가 반환된다.
또, 외부 API 의 모든 Response 에는 현재 사용 가능한 인증 Key 값이 항상 포함되어 반환 된다.
- 간헐적으로 변경되는 인증 KEY
응답 값에 포함되는 인증 Key 는 간헐적으로 값이 변경된다.
이때, 인증 Key 의 변경 시점은 알 수없으며 실제 응답을 받은 이후 기존 Key 와 직접 비교해보기 전까지 변경 여부를 알 수 없다.
- 단일 유효성
한명의 사용자는 하나의 인증 Key 만 가질 수 있는 1:1 구조이며, 새로운 Key 가 발급되는 즉시 이전 Key 는 만료되어 사용할 수 없다.
- 긴 응답 시간
와부 API 응답은 평균 2~10초, 상황에 따라 분 단위까지 지연된다
- 간헐적 오류와 불확실성
외부 API 로 과도한 병렬 요청을 하거나, 특별한 이유 없이도 오류가 반환되는 경우가 빈번히 발생한다.
이러한 경우, 일정 시간 이후 재시도 하면 성공하는 케이스도 있으나, 실제 재시도 하기 전까지 성공 여부를 미리 알 수 없다.
[ 병렬 처리 시의 경쟁 상태 ]
이러한 제약 사항들로인해 일반적인 방법으로는 병렬 처리를 할 수 없었다.

- 10개의 워커가 동시에 **같은 Key(A)**를 들고 외부 API에 요청을 보낸다.
- 가장 먼저 처리된 요청의 응답에 **새로운 Key(B)**가 담겨 돌아온다.
- 이 시점에 외부 시스템에서 Key(A)는 만료되고 오직 Key(B)만 유효해진다.
- 이미 Key(A)를 들고 요청을 보냈거나 보내려던 나머지 9개 워커의 요청은 줄줄이 인증 실패(401 Unauthorized) 처리된다.
결국 한 명의 사용자에게 속한 요청들이 서로의 인증 정보를 만료시키는 경쟁 상태가 발생했고, 이는 빈번한 수집 실패로 연결 되었다.
인증 정보를 재발급할 수 없는 시스템 구조상, 인증 정보를 유실하는 것은 치명적인 문제가 될 수 있었고 때문에 이후로는 ‘속도’ 보다는 ‘안정성’을 최우선으로 인증 정보 유실을 방지하고 한 번의 요청을 정확하게 성공 시키는 것을 최우선 목표로 설정하기로 했다.
3. 기술적 의사결정 및 개선 과정
3.1. 동일 인증 정보 간 병렬 처리 방지 (RabbitMQ → SQS FIFO)
가장 시급한 과제는 인증 정보의 유실을 방지하고 안정성을 확보하는 것이엇다.
현재 발생하는 문제의 근본 원인은 인증 Key 의 변경 여부를 모른 채 여러 워커가 동시에 요청을 보내는 ‘병렬 처리’ 그 자체에 있다고 생각 되었다.
물론, 병렬 처리를 유지하며 인증 정보를 보호하는 것에는 여러 대안이 있겠지만 시스템의 복잡도를 크게 높이지 않으며 가장 확실하고 빠르게 문제를 해결할 수 있는 방법은 병렬 처리를 제한하고 요청을 순차적으로 처리하는 구조를 만드는 것이라 판단하게 되었다.
[ Redis 분산락 도입 ]
이를 위해 가장 먼저 떠올린 방법은 이미 사용 중인 Redis 의 ‘분산락’을 활용하는 것이었다.
사용자 ID 를 기준으로 락을 걸어, 동일 사용자가 외부 API 에 동시 요청을 보내지 못 하도록 차단하는 방식이다.
하지만, 분산락을 도입하게 될 경우 아래와 같은 문제가 예상 되었다.
- 리소스 낭비
예를 들어 한 명의 사용자가 10개의 메시지를 발행하면 10개의 리스너가 이를 각각 수신한다. 하지만 락 때문에 1개의 리스너만 API를 호출하고, 나머지 9개는 락이 풀릴 때까지 아무런 동작도 못한 채 점유만 하고 대기하게 되어 리소스를 낭비하게 된다.

- 전체 시스템 병목 전이
락 해제 전까지 메세지 갯수만큼의 리스너가 대기 상태로 묶여버린 상황에서 리스너의 갯수보다 메세지 발행 수가 많아지는 순간, 시스템 전체의 병목 구간이 되어 장애가 발생할 수 있었다.
이를 방지하기 위해 어플리케이션 단에서 동시성을 제어할 수도 있겠지만, 고려해야 하는 예외 케이스가 많아 복잡도가 크게 증가될 것으로 예상이 되었다. 무엇보다 어플리케이션에서 이를 제어하기 위해서는 일단 메세지를 큐에서 어플리케이션으로 메세지를 이동 시켜야만 하는데, 이과정에서 발생하는 네트워크 및 CPU 등의 자원 소모는 불필요한 비용이라 판단했다.
결국 문제의 근본 원인인 ‘동일 사용자의 메세지 동시 발행’ 자체를 인프라 단에서 제어할 수 있는 방안을 모색하기 시작했다.
[ RabbitMQ → SQS 리펙토링 ]
가장 먼저 떠오른 것은 Kafka 와 같이 파티션 기반 구조를 통해 특정 Key 를 가진 메세지 간의 순서를 보장하는 것이었다.
하지만, 현재 사용 중인 RabbitMQ 는 기본적으로 이러한 기능을 지원하지 않아 별도의 커스텀 구현이 필요했고 이는 복잡도 증가로 이어질 것이었다.
그렇다고 Kafka 로 전환 하기에는 RabbitMQ 와 근본적인 동작 방식 및 구조가 다르기에 인프라 단은 물론 어플리케이션 단에서의 많은 작업이 필요 할 것으로 예상 되었다.
그러던 중, MessageGroupId 를 통해 사용자 별 순차 처리를 지원하는 SQS 도입을 검토하게 되었다. 이전부터도 아래와 같은 문제로 리펙토링을 고려하고 있었기에 이번 기회에 RabbitMQ 를 활용하는 것과 SQS 로의 리펙토링 중 어느 쪽이 비용 대비 효율적인지를 중점으로 비교 하게 되었다.
- 낮은 RabbitMQ 활용도
서비스 초기에는 서로 다른 네트워크상의 서버들이 별도의 API 노출 없이 통신할 수 있도록 RabbitMQ 의 Request-Reply 패턴을 도입했었다. 하지만 현재는 K8s 를 도입하며 동일 네트워크 환경으로 통합하며 RabbitMQ 의 Request-Reply 는 물론 복잡한 라우팅 기능 혹은 플러그인과 같은 기능을 거의 활용하지 않아 SQS 를 활용하여도 현재 시스템에 완벽 호환 되었다.
- 인프라 관리 효율
직접 호스팅하여 관리하던 RabbitMQ 는 로컬, 개발, 운영 서버 환경에 맞춰 지속적인 관리가 필요 했지만, SQS 는 AWS 에서 완전 관리되어 관리포인트를 줄일 수 있었다.
- 낮은 학습 곡선
SQS 는 기존 RabbitMQ 와 동작 방식이 유사함은 물론, 오히려 더 간단하게 활용할 수 있기에 단기간에 리펙토링함에 있어 큰 문제가 발생하지 않았다.
위와 같은 이유를 포함해 RabbitMQ 의 커스텀 비용보다 SQS 전환 비용이 현재로서도, 이후로서도 합리적이라는 판단이 되었고, 최종적으로 SQS 로의 리펙토링을 결정하게 되었다.
결과, 추가적인 관리 포인트 증가 없이 인프라 단에서 동일한 사용자 별 순차적 처리를 보장 할 수 있게 되었다.
3.2. DB 커넥션 풀 고갈 방지
사용자 별 요청을 순차적으로 변경한 이후, 시스템 전체의 병목 구간이 제거되며 DB 의 커넥션 풀이 고갈되는 문제가 발생하였다.
기존 로직은 외부 API 요청 직전, 최신 인증 정보를 확인하기 위해 RDB 에 접근하여 트랜잭션을 유지하는 구조였다.
하지만, 외부 API 의 요청이 계속해서 지연되며 트랜잭션을 종료하지 못해 커넥션 풀을 반환하지 못 하였고 이는 결국 어플리케이션 전체적인 오류로 이어지게 되었다.
이를 해결 하기 위해, DB 에 접근하는 트랜잭션과 외부 API 를 조회하는 트랜잭션을 물리적으로 분리하는 방안을 가장 먼저 떠올리게 되었다.
하지만 이는 하나의 기능을 위해 두 서비스 코드를 관리해야해 복잡도가 높아질 것이라 생각했고 단일 트랜잭션 내에서 처리할 수 있는 방안을 모색하던 중 Redis 를 활용 방안을 생각하게 되었다.
[ Redis 도입 ]
Redis 는 메모리 기반의 저장소로 RDS 와 비교해 압도적으로 빠르다는 장점이 있으며, 이는 RDS 의 부담을 덜어줄 뿐 아닌 수집 응답 속도에도 이점을 가져다 줄 것이었다.
또한, RDS 와 별도의 트랜잭션으로 관리되기에 현재 문제가 되는 커넥션 풀 고갈 문제를 해결 할 수 있었고, 추가적으로 아래와 같은 사항을 고려해 Redis 를 도입하기로 결정하였다.
별도 트랜잭션으로 인한 안전성 보장
별도의 트랜잭션으로 동작하는 특징 덕분에, 어플리케이션에서 예외 상황 발생으로 인해 트랜잭션이 롤백 되어도, Redis 는 이러한 정보를 롤백 시키지 않고 유지하여 인증 정보 유실을 방지할 수 있었다.
짧은 인증 정보 수명
인증 정보는 수명이 30분 내외로 매우 짧다. 또한 실제 비즈니스 로직에서의 활용은 1~10 분내로 종료되기에 Redis 의 TTL 을 활용해 인증 정보의 수명에 맞춰 메모리를 효율적으로 관리할 수 있었다.
동시성 제어 부담 감소
이전 SQS 를 도입하는 과정에서 사용자 별 순차 처리를 보장 시킨 만큼, Redis 접근 시 분산 락을 활용하는 등의 복잡한 동시성 제어 로직을 고려하지 않아도 되어 간단한 구조를 유지할 수 있었다.
[ Cache-Aside 패턴 ]
하지만, 어디까지나 Redis 는 휘발성 데이터인 만큼 Redis 의 장애 상황 혹은 캐시 미스 등의 상황에서도 인증 정보 유실 없이 수집 프로세스가 유지될 수 있도록 Redis 와의 결합도를 낮춘 Cache-Aside 패턴과 데이터 영속화 과정을 추가하기로 하였다.

어플리케이션은 사용자 인증 정보가 필요할 시점에 우선적으로 Redis 를 조회한다.
- 캐시 히트 : Redis 에 인증 정보가 존재한다면 이를 활용한다.
- 캐시 미스 : Redis 에 인증 정보가 존재하지 않으면 RDS 를 조회한다.
2-1. 캐시 갱신 : RDS 의 인증 정보를 Redis 에 저장하고 이를 활용한다.
이러한 구조를 통해 일반적인 상황에서는 DB 조회 과정이 생략되어, 커넥션 풀을 점유하지 않으며 덕분네 수집 프로세스의 응답 속도 또한 감소 시킬 수 있었다.
SQS 를 활용한 인증 정보 영속화
하지만, 캐시가 없는 상황에서는 RDS 에서 인증 정보를 직접 조회 해야해 RDS 에 이를 저장하는 과정이 추가가 되어야만 했다.
가장 간단한 것은 모든 외부 API 요청 이후 인증 정보를 RDS 에 저장하는 로직을 추가하는 것이었지만 이는 앞서 문제가 되었던 트랜잭션 롤백시 인증 정보가 유실되는 문제를 해결하지 못 했다.
결국, 아래의 사항을 고려하여 별도의 트랜잭션으로 동작하며 메세지 큐 구조로 안전성이 보장되는 SQS 를 통한 메세지 발행 방식을 택하게 되었다.
- 시스템 결합도 분리
Redis, SQS, RDS 가 각각 별도의 트랜잭션 영역에서 동작하여 특정 인프라에 일시적인 장애가 발생하더라도 인증 정보가 완전히 유실되지 않도록 할 수 있었다.
- 데이터 정합성 보장
SQS 에서는 이전 활용했던 messageGroupId 기능으로, 동일 사용자에 대한 정보를 순차적으로 처리할 수 있어 혹시 모를 데이터 정합성 문제 또한 사전에 방지할 수 있었다.
- 관리 포인트 통합
하나의 리스너에서 모든 인증 정보를 관리함에 따라 유연한 변경이 가능하게 되었다.
- 메시지 큐를 통한 시스템 안정성 확보
SQS 에서는 정상적으로 처리되지 않은 메세지에 대해 재시도 횟수, 지연 시간 등 정책을 자유롭게 커스텀할 수 있어 일시적인 오류 상황에 대한 유연한 대처가 가능하다.
( 관련 내용은 4번에서 확인할 수 있다. )
Interceptor 로 외부 API 로깅
그럼에도, 예상치 못한 모든 예외 상황에 대비하기 위해 Interceptor 를 활용해 외부 API 와 연결되는 모든 요청/응답 등의 정보를 로깅하여 문제 발생 시의 추적 방안을 마련하였다.
- 다중 서버 로깅 관리 통합
로깅 정보는 Grafana + Loki 를 통해 다중 서버의 정보를 통합해서 관리할 수 있도록 하였다.
- SQS 를 활용한 로그 정보 영속화
이러한 로그 정보는 이후 추적 및 분석을 위해 메세지로도 발행해 RDS 에 영속화 하도록 하였으며, 별도의 트랜잭션으로 동작 시켜 수집 프로세스에 영향이 가지 않도록 하였다.
3.3. 재수집 자동화
외부 API는 간헐적으로 원인을 알 수 없는 오류를 반환하곤 한다. 이러한 오류는 일정 시간 이후 재수집 시 성공하는 케이스와, 재수집 이후에도 성공하지 못 하는 케이스 두 가지로 나뉜다.
여기서 문제는, 실제 재수집을 시도하기 전까지는 해당 오류가 어느 케이스에 해당하는지 사전에 판단할 수 없다는 점이다. 때문에 유실되는 데이터를 최소화 하기 위해, 오류 발생 시 즉시 수집 실패로 처리하지 않고 자체적으로 재수집을 시도하는 프로세스를 구축하기로 하였다.
[ SQS 를 활용한 재수집 ]
우선, 재수집 대상 여부를 판별하기 위해서는 어플리케이션이 메세지를 수신 후 실제 요청을 보낸 뒤, 반환되는 에러 메세지를 확인해야만 한다.
때문에, 어플리케이션 단에서 에러 메세지를 확인하는 즉시 내부에서 재수집 로직을 수행하는 방법이 가장 간단하지만, 시스템의 안정성을 위해 아래 사항들을 고려해야 했다.
- 내부 서버 장애에 대한 대응
수집 실패의 원인이 특정 서버의 자체적인 문제(리스소 고갈 등) 일 경우, 해당 서버에서 즉시 재시도 하는 것은 의미가 없어진다. 때문에 이러한 경우 메세지를 정상 동작 중인 다른 서버에 위임할 수 있는 방안 마련이 필요했다.
- 대기 처리 시의 고려 사항
일정 시간 이후 재처리를 위해 어플리케이션 단에서 Thread.sleep() 등으로 대기할 경우, 커넥션 풀을 불필요하게 점유하게 된다. 또, 대기 중 서버가 중단되는 경우 요청이 유실되는 사항에 대한 조치들이 필요했고 이는 복잡도를 높이게 될 것이었다.
이러한 사항 때문에, 재처리는 어플리케이션이 아닌 인프라 단에서 처리하는 것으로 하였다.
어플리케이션 단에서는 에러를 확인하는 순간 예외를 던져 처리 중이던 메세지를 다시 SQS 로 돌려보내고 현재 정상 동작 중인 리스너가 이를 수신하여 재수집을 처리하는 방식으로 위의 사항들을 해결할 수 있었다.
다만, 이러한 재수집 시에는 재수집 시 성공 여부를 판별할 수 없는 만큼, 무한 루프를 방지하기 위해 아래와 같은 정책을 마련하였다.
- 최대 재시도 횟수 제한
AOP 에서 SQS 메세지 헤더의 Retry 횟수를 추출하여 재수집을 5회 이상 시도할 시, 해당 데이터는 수집 불가한 데이터로 간주하고 메세지를 실패 처리하도록 하였다.
- 지연 큐를 통한 일시적 장애 대응
처리 중인 메세지를 예외를 던져 다시 동일한 큐로 반환 시키면 이는 즉시 다른 리스너가 수신하여 일정 시간 대기를 위해서는 어플리케이션 단에서의 처리가 불가피했다.
때문에 SQS 의 지연 큐를 활용해 메세지를 발행하는 방식으로, 어플리케이션에서 별도의 처리 없이 일정 시간 대기 후에 메세지를 처리하도록 보장하였다.
이러한 재시도 전략을 통해 불안정한 외부 API 를 활용하면서도 별도의 수동 작업 없이 수집 성공 케이스를 증가 시킬 수 있었다.
'기술적 고민' 카테고리의 다른 글
| RabbitMQ 다중 서버에서, 특정 컨슈머에게 메세지 큐 우선 전달하기 (3) | 2025.08.02 |
|---|---|
| (MSA) 주문 및 결제에 이벤트 기반 SAGA 패턴을 활용한 이유 (0) | 2025.04.26 |
| 동시성 제어에 캐시와 분산락 Redisson 을 도입한 이유 (1) | 2025.04.09 |
| 예약 관리 서비스에 RabbitMQ 를 도입하게 된 이유 (0) | 2025.03.26 |
| 데이터 유실 방지 - 외부 저장소(S3) 장애 대응 (0) | 2025.03.04 |