
이전 포스팅에서도 적었지만 위 사진은 우리의 큰 비즈니스 플로우이다. (다른 부분들은 다 제외하고 외부 API 활용 기준으로 그려봤다.) 우리 서비스는 사실상 브랜드가 이용하는 서비스 하나, 인플루언서가 이용하는 서비스 총 2개로 나눠져 있고, 이때 우리는 Cafe24라는 외부 API를 활용해서 보다 쉽게 둘을 연결해 주는데 도움을 준다.
우리 서비스에서 외부(Cafe24) API를 쓰는 3곳
- 상품 조회: 브랜드가 우리 서비스에서 자사 상품 목록을 보고 등록할 협찬 상품을 고를 수 있도록 Cafe24 상품 조회 API을 읽어온다.
- 할인 코드 생성: 협찬 승인 시 인플루언서별 할인 코드를 만든다.
- 주문 조회: 매일 00:10에 전일 주문을 모아서 동기화한다.
이처럼 서비스의 핵심 화면 다수에서 외부 API가 사용되고 있다. 만약 실제로 해당 호출이 막히면 곧바로 서비스 경험에 타격이 가게 된다.
실제 Cafe24 장애 사례

실제 Cafe24 기술지원 Slack에도 서버 오류 공지가 종종 올라온다. 최근에도 비슷한 이슈가 있었고, 이런 순간 우리 쪽 해당 기능들이 아예 동작하지 않으면서, 크게 서비스의 문제로 이어지게 됐다.
바로 여기서 외부 의존의 위험이 드러난다. 우리 서비스는 Cafe24 연동을 전제로 설계되어 의존을 완전히 없앨 수는 없다. 그렇지만 외부가 흔들려도 전체가 멈추지 않도록 영향 범위를 최소화하는 건 개발자의 몫이다. 클라이언트/서버 중 어디서, 어떻게 처리할 지에 대해서는 합의가 필요하지만, 어떤 선택이든 대안은 필수다.
실제 활용 예시
1. 상품 조회 API (실시간 조회)

기존 브랜드 상품 조회 플로우
이 API는 브랜드 사용자가 우리 서비스에서 협찬 상품을 고를 때 실시간으로 Cafe24 상품 목록을 불러오기 위해 사용된다. 따라서 호출이 지연되거나 실패하면 곧바로 화면이 멈추는 문제로 이어진다. 우리 비즈니스에 큰 의미 없는 필드까지 모두 보관하긴 비용이 크다 생각해서 해당 데이터를 우리 쪽에 전부 저장해두지 않았었다.
하지만 계속 이렇게 운영하다 보니 외부 API에 대한 의존성이 지나치게 강해지는 문제가 생겼다. 즉, 외부가 흔들리면 우리 화면도 곧바로 멈추는 구조가 되어버린 것이다. 그래서 결국 대체 수단이 필요하다고 판단했다.
캐시 고민 → RDB 기반 스냅샷으로
처음에는 Redis 같은 캐시 서버를 고려했다. 하지만 상품 데이터는 관계형 구조라 단순 Key-Value 캐시로 관리하기 애매했다. (물론 Redis Hash 구조를 쓰면 가능하긴 하지만, 관계형 데이터를 → NoSQL 구조로 바꿔 넣었다가 → 다시 관계형으로 매핑하는 과정이
우리 입장에서는 굳이 싶은 부분으로 느껴졌다.)
또한 TTL(유효시간)을 걸어둔다 해도 만료 시점에 외부 API가 죽어 있으면 결국 화면이 멈추는 문제는 여전히 남아 있었다. 즉, Redis 캐시는 속도 개선은 되지만 안정성 측면에서는 완전한 해결책이 아니었다
결국 우리는 ProductReplica라는 엔티티를 따로 만들어 DB에 저장하는 방식을 선택했다.
- 동기화 버튼이 눌러질 때만 외부 API를 조회해 DB에 저장한다.
- 이후 조회는 항상 우리 DB를 통해 이뤄진다.
즉, DB를 일종의 캐시 스냅샷처럼 활용하는 전략이다.
API 분리

사실상 기존에는 1 → 2 → 3 → 4 흐름이 전부 한 API에서 동시에 처리되었다. 하지만 외부 API 장애 시 전체 흐름이 멈추는 문제가 있었기 때문에, 1 → 2 → 3(동기화 및 저장)과 4(조회 및 등록)를 서로 다른 API로 분리했다. 이제는 저장된 데이터 기반으로 등록을 진행하고, 필요할 때만 동기화를 시도하므로 외부 API에 대한 의존성을 크게 줄일 수 있었다.
브랜드 입장에서의 경험
여기서 브랜드가 불편하지 않을까?라는 우려가 있을 수 있다. 하지만 실제로는 크게 문제 되지 않았다.
- 브랜드는 상품 정보를 매일 수십 번 바꾸지 않는다. 대부분 정해진 시점에 묶어서 업데이트한다.
- 따라서 실시간 최신성은 절대적인 요구사항이 아니다.
- 오히려 동기화 버튼을 통해 원하는 시점에 명시적으로 최신화할 수 있다는 점은 브랜드 입장에서 더 예측 가능한 경험이 된다.
- 또한 이전 데이터가 최소한 안전하게 남아있으니, 갑자기 Cafe24가 죽어도 협찬 흐름이 멈추지 않는다.
즉, 우리 입장에서는 외부 API 의존성을 줄이고, 브랜드 입장에서는 더 예측 가능하고 안정적인 협찬 상품 등록 경험을 제공할 수 있게 됐다.
하지만… 여전히 남는 문제
이렇게 분리해도 동기화 버튼을 눌렀을 때 외부 API가 죽어 있다면 여전히 실패할 수 있다. 그래서 재시도(Exponential Backoff + Jitter) 전략을 추가해서 장애에 대응할 수 있도록 했다.
@Retryable(
include = { Exception.class },
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2, random = true)
)
public ProductReplica syncProducts(String mallId, String token) {
Cafe24ProductListRes res = cafe24ProductClient.getProducts(mallId, token);
return productReplicaRepository.saveAll(res.toEntities());
}
@Recover
public void recover(Exception e, String mallId) {
// 재시도 실패 시 슬랙 알림
slackNotifier.send("[상품 동기화 실패] mallId=" + mallId + ", error=" + e.getMessage());
}
- 최대 3회까지 재시도하면서 잠깐의 일시 장애는 스스로 복구한다.
- 그래도 실패하면 슬랙 알림을 보내 운영자가 즉시 인지할 수 있게 했다.
여기서 중요한 건, 실시간성이 아니라 “외부 서버가 죽었다는 사실을 빨리 파악하는 것”이다. 즉, 브랜드는 여전히 이전 데이터를 볼 수 있어 UX는 유지되고, 우리는 장애 상황을 빠르게 감지해 대응할 수 있다. 물론 더 나아가서 Kafka 같은 이벤트 큐를 활용해 실패 건을 큐에 적재하고 나중에 재처리하는 방법도 있다. 하지만 현재 스펙과 리소스에서는 오버엔지니어링이라 판단해, 우선은 재시도 + 알림 구조로 안정성을 확보했다.
Spring Retry
Spring Retry 라이브러리에서 제공하는 어노테이션이다. 메서드 실행 중 예외가 나면, 조건(예외 타입/횟수/간격)에 맞춰 자동 재시도해주는 기능이다.
- include = {...}: 재시도할 예외 지정 (보통 네트워크/5xx/429 등)
- maxAttempts: 총 시도 횟수
- backoff = @Backoff(...)
- delay: 첫 재시도 지연(예: 2000ms)
- multiplier: 지연 배수(지수 백오프)
- random = true: 지터로 동시 재시도 폭주 완화
실제로 동작은 다음과 같이 된다.
- syncProducts() 실행
- 예외 발생 → 2초 후 재시도
- 다시 실패 → 4초 후 재시도
- 모두 실패 → @Recover 호출(알림/폴백 처리)
include = { Exception.class } 보다는 더 좁히는 게 안전하며, 400/401/404와 같은 Exception에 대해서는 재시도해도 절대 성공하지 않기 때문에 재시도에 넣지 않는 것이 좋다. 지수 백오프 + 지터 + 상한(횟수/총 시간)은 외부 쪽 레이트 리밋과 우리 서버 자원을 함께 보호하기 위해 사용한다.
Retry 말고 다른 방법은?
- Resilience4j Retry : 설정(yml/프로퍼티)으로 제어가 편하고, CircuitBreaker / RateLimiter / TimeLimiter와 조합이 쉽다. 보통 TimeLimiter → CircuitBreaker → Retry 순으로 감싸서 사용한다.
- RetryTemplate (프로그래밍 방식): 요청/상황에 따라 동적으로 정책 변경이 필요할 때 유용하다.
- 큐 기반 재처리(Kafka/Rabbit) : 사용자 응답은 빨리 주고, 실패 건은 후단에서 재시도나 보상 트랜잭션을 통해 한다.
- 배치 재처리 : 즉시성이 덜 중요한 영역(ex. 주문 동기화)은 실패 테이블에 남겼다가 주기적으로 재실행할 수 있다.
2. 할인 코드 생성 API (쓰기 작업)

이 API는 협찬 승인 시 인플루언서별 할인 코드를 Cafe24에 발급하기 위해 호출된다. 단순 조회가 아닌 쓰기이므로 더 민감하다.
가장 문제가 되는 경우는 외부 API는 성공했는데, 우리 DB 업데이트가 실패하는 상황이다. 이때 외부에는 쿠폰이 생성됐지만 우리 서비스에는 기록이 없음 → 데이터 불일치로 이어지기 때문이다. 그래서 이 흐름은 재시도 + 보상 트랜잭션으로 다뤘다.
재시도 (Retry)
우선 일시적인 네트워크 장애나 타임아웃이라면 단순히 재시도만으로도 성공할 수 있다. 그래서 상품 조회 API와 마찬가지로 Spring Retry를 적용했다. 이전과 동일하게 설정했기 때문에 생략하겠다!
보상 트랜잭션 (Compensation Transaction)
만약 그래도 실패한다면 보상 트랜잭션을 적용했다. 이때 선택지는 두 가지다.
- DB에 상태를 "발급 대기"로 남기고, 후속 Job/운영자가 다시 처리하도록 한다.
- 외부 API 쿠폰 삭제 호출을 통해 정합성을 맞춘다.
우리의 경우는 2번을 채택했다. 왜냐하면 쿠폰이 만들어지는 것 자체는 아주 즉시 필요한 건 아니기 때문이다. 쿠폰이 실제로 사용되려면 상품이 배송되고 → 인플루언서가 콘텐츠를 제작해 업로드하는 시점까지 시간이 걸린다. 따라서 발급이 조금 늦어도 브랜드/인플루언서 경험에 큰 문제는 없다.
오히려 중요한 건 중복 발급 방지다. DB 저장에 실패했을 경우, 외부에 잘못 생성된 쿠폰을 삭제해 주면 나중에 다시 발급할 때 중복을 막을 수 있다. 따라서 발급 자체가 실패하면 Slack 알림만 있어도 충분하다.
@PostMapping("/approve")
public ResponseEntity<String> approve(@RequestBody ApproveCouponReq dto) {
String externalCode = null;
try {
// 1) 외부 쿠폰 발급 API 호출
externalCode = cafe24CouponClient.createCoupon(dto.mallId(), dto.token(), dto);
// 2) 로컬 DB 저장
couponRepo.save(new Coupon(dto.mallId(), dto.influencerId(), externalCode, CouponStatus.ISSUED));
} catch (Exception e) {
log.error("쿠폰 발급 실패 :: ", e);
// 외부는 성공했는데 DB 저장이 실패했다면 → 보상 트랜잭션 수행 (쿠폰 삭제)
if (externalCode != null) {
try {
cafe24CouponClient.deleteCoupon(dto.mallId(), dto.token(), externalCode);
} catch (Exception ex) {
// 쿠폰 삭제 실패 시 슬랙 알림
slackNotifier.send("[쿠폰 삭제 실패] mallId=" + mallId + ", error=" + e.getMessage());
}
}
slackNotifier.send("[쿠폰 발급 실패] mallId=" + mallId + ", error=" + e.getMessage());
throw new CouponApiException("쿠폰 발급 중 오류");
}
return ResponseEntity.ok("SUCCESS_COUPON_ISSUE");
}
위 코드를 제가 직접 작성한 것은 아니지만, 실제 서비스에서 적용했던 방식과 유사하게 블로그 예시용으로 각색한 것이다. 중요한 건 외부 API 성공 ↔ 내부 DB 실패라는 불일치 상황에서 재시도 + 보상 트랜잭션을 어떻게 적용했는지 보여주기 위함이다.
이것과 다르게 만약 발급 즉시성이 정말 중요하다면, DB 트랜잭션 + 아웃박스 패턴을 활용하는 방법을 고려해 볼 수 있다.
말 그대로 쿠폰 발급 이벤트를 DB 테이블(Outbox)에 같이 기록해 두고, 커밋 이후 별도 워커가 이 이벤트를 읽어 외부 API 호출을 처리한다. 만약 호출이 실패하면 Outbox에 남아있는 이벤트를 기반으로 자동 재시도할 수 있다. 즉, 이벤트를 안전하게 큐에 적재한 뒤 비동기로 재처리함으로써, 유실 없이 발급 즉시성을 보장할 수 있는 설계다.
3. 주문 조회 API (배치 동기화)

이 파트는 자세한 스케줄러 설정은 예전 포스팅(스케줄러 편)에서 이미 다뤘고, 여기서는 운영 전략만 짧게 정리하려 한다.
현재는 매일 00:10에 전일 범위를 끊어 Cafe24에서 주문을 동기화한다. 다만 대량 동기화 특성상 실패 시 무조건 여러 번 재시도하는 것은 레이트 리밋이나 DB 부하 측면에서 부담이 크다. 그래서 이 구간은 재시도보다는 서킷 브레이커를 우선 적용하는 것이 중요하다고 판단했다. 실제로는 장애가 길어지면 즉시 실패로 전환하고 알림을 발송해 운영자가 빠르게 인지할 수 있도록 했으며, 백오피스에서 오류 표시 및 Retry 할 수 있도록 마련해 두었다.
장기적으로는 이벤트 기반 복구가 더 적합하다고 본다. 예를 들어 실패 구간을 이벤트로 발행(Kafka 등)하고, 워커가 백오프를 두고 안전하게 재처리하거나 재동기화 큐에 적재해 순차적으로 처리하는 방식이다. 현재는 주문 트래픽이 크지 않아 단순 알림 + 백오피스 재실행으로 운영하고 있지만, 향후 트래픽이 늘어나면 Kafka 같은 메시지 큐 기반 구조로 확장할 계획이다. 구체적으로는 전일 범위를 더 잘게 쪼개어 커서 기반으로 수집하고, 실패한 청크만 이벤트로 재처리하는 방향으로 개선할 수 있을 것 같다.
마무리
사실 이번 프로젝트는 짧은 시간 안에 빠르게 개발해야 하는 상황이었다. 그래서 처음부터 무겁고 복잡한 기술 스택을 들여오기보다는, 당장 필요한 수준에서 어떻게 안정성을 담보할 수 있을까 에 집중했다. 당연히 지금 구조에는 부족한 부분도 많다.
하지만 현재 트래픽 규모와 서비스 우선순위를 고려했을 때는, 적절한 재시도와 알림 체계만으로도 충분히 운영 가능하다고 판단했다. 다만 데이터가 쌓이고, 서비스가 비즈니스적으로 더 중요한 역할을 하게 된다면 큐 기반 이벤트 처리, 서킷브레이커, 아웃박스 패턴 같은 더 정교한 방법을 순차적으로 도입해야 할 것이다. 결국 중요한 건 완벽한 설계 보단 현재 단계에서 합리적인 선택을 하고, 상황이 변하면 그에 맞춰 유연하게 개선해 나가는 것이라고 생각한다.
추가로, 앞으로 서비스가 커지고 MSA 환경으로 확장된다면 이런 장애 대응 패턴과 데이터 동기화 전략은 더 중요한 주제가 될 것이다.(특히나 돈과 관련된 도메인일 경우!!) 따라서 단순 구현을 넘어서, 분산 시스템에서의 안정성과 복구 전략을 더 깊게 공부해 보는 것도 의미 있는 방향이라 생각한다.. 조만간 더 공부해서 포스팅으로 나타나겠다😀
'Spring' 카테고리의 다른 글
| [Spring] 외부 API 사용 시 주의해야할 점 (외부 서버 장애 상황에서 대비 - 예외처리, 재시도 등..) - 1편 개념 정리 (0) | 2025.08.19 |
|---|---|
| [Spring] 외부 API 활용하면서 데이터 동기화 (Webhook, On-Demand, 스케줄링) + Cafe 24 API 활용 (11) | 2025.08.13 |
| [Spring] Java/Spring에서 외부 API 호출할 때, 무엇을 써야 할까? (RestTemplate vs WebClient vs RestClient vs FeignClient) (10) | 2025.08.07 |
| [Spring] Tech Spec 작성부터 리팩토링 과정 (5) | 2025.06.29 |
| [Spring] 험난한 리팩토링과 구조 개선의 과정 회고 (3) | 2025.06.05 |