서킷브레이커는 실패하는 I/O 가까이에 두세요
서킷브레이커, 타임아웃, 캐시 폴백 같은 외부 호출 정책은 I/O 구현체 가까이에 두고, 도메인 코드는 그 결과가 비즈니스에서 무엇을 뜻하는지 결정합니다.
출처 및 AI 안내: 이 글은 제미니의 개발실무 유튜브를 기반으로 작성되었습니다.
gpt-5.6-sol모델을 사용해 생성·편집했습니다.
서킷브레이커와 캐시 폴백 코드가 도메인 서비스 여기저기에 박혀 있다면 위치를 다시 볼 필요가 있습니다. 이 정책들은 외부 통신의 방법과 실패를 다룹니다. 실제 통신을 수행하는 구현체에 최대한 가깝게 두는 편이 좋습니다.
거창한 모듈 구조가 있어야 적용할 수 있는 이야기는 아닙니다. 멀티모듈 프로젝트라면 클라이언트나 영속성 모듈 안에 정책을 둘 수 있습니다. 패키지로만 나눈 프로젝트라면 구체 클라이언트 또는 리포지토리 클래스 곁에 두면 됩니다. 중요한 것은 Gradle 모듈의 개수가 아니라 책임의 위치입니다.
서킷브레이커는 외부 의존성으로 나가는 호출을 관찰합니다. 설정한 실패 조건을 만족하면 서킷이 열리고 일정 시간 이후 호출을 단락시킬 수 있습니다. 라이브러리와 정책에 따라 애플리케이션은 오류를 반환하거나 별도로 정의한 폴백을 실행합니다. 어떤 경우를 호출 실패로 볼지, 어떤 타임아웃을 적용할지, 외부에서 무슨 응답을 받았는지는 구체 클라이언트가 압니다. 서킷을 설정하기 가장 자연스러운 자리입니다.
원격 호출 정책을 클라이언트에 모읍니다
도메인 서비스가 다른 시스템에서 고객 정보를 가져온다고 해봅시다. 도메인은 서킷브레이커 애너테이션이나 실패 횟수, 타임아웃 라이브러리가 아니라 findCustomer 같은 작업에 의존해야 합니다. findCustomer를 구현한 어댑터가 HTTP 호출을 수행합니다. 이 구현체가 커넥션 타임아웃, 리드 타임아웃, 서킷브레이커, 원격 오류를 애플리케이션 수준 결과로 바꾸는 일을 맡을 수 있습니다.
서킷브레이커 라이브러리를 쓰지 않아도 같습니다. 직접 만든 폴백도 원격 호출 정책입니다. 타임아웃을 잡아 로컬 기본값을 돌려주는 코드는 그 타임아웃을 만난 클라이언트 가까이에 있어야 합니다.
이렇게 모으면 운영할 때 편합니다. 느리거나 실패하는 호출을 조사하는 개발자는 I/O 옆에서 타임아웃과 폴백을 함께 찾을 수 있습니다. 라이브러리를 바꿔도 비즈니스 서비스까지 수정이 퍼지지 않습니다. 클라이언트 테스트에서는 관련 없는 도메인 흐름을 만들지 않고 실패 변환을 검증할 수 있습니다. 무엇보다 도메인 코드가 한 공급자의 통신 방식에 묶이지 않습니다.
물론 한계는 있습니다. 원격 클라이언트가 신용 조회에 실패했을 때 주문을 거절할지, 처리 중으로 남길지, 낮은 한도로 계속할지 결정할 수는 없습니다. 그것은 비즈니스 판단입니다. 클라이언트는 unavailable, timed out, stale처럼 의미 있는 결과를 알려주고, 유즈케이스가 현재 작업에서 그 결과를 어떻게 볼지 결정해야 합니다.
그러니 배치 원칙은 “모든 실패를 인프라에 숨기자”가 아닙니다. I/O 실패를 감지하고 확산을 막는 방법은 I/O 구현체가 맡고, 그 실패가 비즈니스 흐름에 미치는 결과는 비즈니스 흐름이 맡자는 뜻입니다.
캐시도 외부 경계입니다
캐시 처리도 같은 방식으로 볼 수 있습니다.
리포지토리는 먼저 캐시를 조회하고 값이 없으면 데이터베이스를 읽을 수 있습니다. 캐시 자체가 장애일 때 DB로 폴백할 수도 있습니다. 둘 다 데이터를 가져오는 방법에 관한 동작이므로 보통 리포지토리 구현체 안이나 그 가까이에 둡니다.
간단한 read-through 흐름은 이렇습니다.
- 캐시에서 고객 정보를 읽습니다.
- 값이 있으면 돌려줍니다.
- 없으면 데이터베이스에서 읽습니다.
- 필요하면 캐시에 저장한 뒤 값을 돌려줍니다.
실패는 별도로 구분해야 합니다. 캐시 미스는 예상할 수 있는 데이터 상태입니다. 캐시 타임아웃이나 연결 오류는 의존성 장애입니다. 둘 다 DB 조회로 이어질 수 있지만 운영과 관측에서는 다르게 보여야 합니다. 그렇지 않으면 죽은 캐시가 모든 요청을 조용히 DB로 보내다가 데이터베이스까지 쓰러뜨릴 수 있습니다.
두 저장 경로를 모두 아는 리포지토리 구현체가 이 정책을 맡기 좋습니다. 캐시를 우회할지, 서킷을 열지, 사용 불가 결과를 반환할지 결정할 수 있습니다. 도메인 서비스마다 캐시 시도 -> 예외 처리 -> DB 조회를 반복해서 쓸 이유가 없습니다.
폴백 데이터의 계약도 정직해야 합니다. 오래된 로컬 데이터는 상품 설명에는 괜찮을 수 있지만 결제 잔액에는 허용할 수 없습니다. 데이터 경계는 fresh, stale, unavailable을 알려주고, 유즈케이스가 그 품질을 받아들일 수 있는지 선택해야 합니다.
유즈케이스마다 동작이 다를 때
한 유즈케이스는 폴백을 원하고 다른 유즈케이스는 즉시 실패해야 하는 경우가 애매합니다. 이 차이 때문에 도메인 코드에 서킷 사용 여부를 가르는 분기가 가득 생기기도 합니다.
한 가지 방법은 일반 클라이언트와 폴백이 있는 클라이언트처럼 별도 구현이나 작업을 노출하는 것입니다. 도메인은 명시적인 포트를 통해 필요한 동작을 고릅니다. 비즈니스 선택이 드러나는 이름의 정책을 넘길 수도 있습니다. 라이브러리 애너테이션 때문에 구현 클래스를 계속 늘리는 방식을 특별히 선호하지는 않지만, 모든 서비스 메서드에 서킷 로직을 넣는 것보다는 나을 수 있습니다.
맥락마다 폴백 동작이 달라야 한다면 실제 코드를 보지 않고 그 차이를 어디에 둘지 단정하기 어렵습니다. 한 가지 방법은 구현이나 동작을 나눠 호출자가 명시적으로 고르게 하는 것입니다. 공통 클라이언트는 여전히 타임아웃 감지와 실패 분류를 소유할 수 있습니다.
피하고 싶은 것은 모든 호출자에게 그럴듯한 기본값을 조용히 반환하는 클라이언트입니다. 폴백은 계약의 일부입니다. 특히 맥락마다 결과가 달라진다면 호출자는 권위 있는 데이터인지, 오래된 데이터인지, 데이터가 없는지 알아야 합니다.
필요한 정보를 가진 곳에 결정을 둡니다
서킷브레이커 위치는 두 질문을 나누면 단순해집니다.
구현체는 의존성이 어떻게 실패했는지 압니다. 연결 정보, 타임아웃, 오류 변환, 서킷 상태, 기술적인 폴백 조회를 소유합니다. 도메인은 이 작업으로 무엇을 이루려는지 압니다. 현재 흐름을 계속할지, 기다릴지, 오래된 정보를 쓸지, 멈출지를 결정합니다.
각 책임을 필요한 정보 가까이에 둬야 합니다. 애너테이션을 붙이기 쉽다는 이유로 인프라 정책을 도메인 전체에 흩뿌리지 않습니다. 기본값을 반환할 수 있다는 이유로 비즈니스 판단을 범용 클라이언트 안에 숨기지도 않습니다.
일반적인 경우라면 서킷브레이커, 타임아웃, 캐시 폴백을 구체 클라이언트나 리포지토리 가까이에 둡니다. 도메인은 명확한 작업을 호출하고 명확한 결과에 반응합니다. 그러면 코드에서 두 가지가 함께 보입니다. 실패를 어디서 차단하는지, 그 실패의 의미는 누가 결정하는지 말입니다.