아키텍처 온보딩 연습문제
arch-exercises pattern-layered api-internals
이 노트의 사용법
이 노트는
air-intl-adapter의 아키텍처를 “읽기”에서 “추적/디버깅/확장”으로 끌어올리기 위한 연습문제 모음이다. 각 문제는 trace(흐름 추적) / config(설정 이해) / debug(장애 진단) / extend(기능 확장) 중 하나의 유형이며, 신입이 혼자 풀 수 있도록 시작점 파일을 함께 준다. 푸는 법: ① 먼저 시작 파일을 열어 직접 추적해 본다. ② 답을 적어 본다. ③[!answer]- 정답 보기를 펼쳐 파일경로·단계까지 맞췄는지 대조한다. 단순히 결론만 맞추지 말고 “어느 파일 몇 번째 단계에서 그 일이 일어나는가”를 짚는 연습이 핵심이다.선행 학습: system-architecture → request-flow → caller-callee-map → common-operations. 횡단 개념은 async-coroutines, error-handling, resilience-and-events.
0. 이 연습이 단련하려는 6가지 근육
| # | 주제 | 유형 | 단련 포인트 | 관련 노트 |
|---|---|---|---|---|
| 1 | 요청 흐름 추적 | trace | HTTP 진입 → 필터 → 컨트롤러 → 서비스 → 클라이언트 → 외부 | request-flow |
| 2 | 레이어 경계 | config/trace | 어느 책임이 어느 레이어에 사는가, 의존 방향 | system-architecture, caller-callee-map |
| 3 | 공통 DTO 계약 | trace/extend | key/pnr이 단계를 잇는 끈, View 변환 위치 | common-operations |
| 4 | 비동기 / 코루틴 | debug | pmap 부분 실패, 디스패처, fire-and-forget 유실 | async-coroutines |
| 5 | 에러 전파 | debug | 예외 타입 → HTTP status, findRootCause, Sentry 조건 | error-handling |
| 6 | 회복탄력성 | config/extend | 서킷브레이커 OPEN, fallback, 재시도 라이브러리 구분 | resilience-and-events |
중앙 디스패처가 없다는 사실을 항상 기억하라
이 어댑터에는
POST /search?supplier=X같은 단일 진입점이 없다. Triple 예약 시스템이/internals/{SUPPLIER}/...경로로 공급사별 컨트롤러를 직접 부른다. 그래서 “요청 추적”은 항상 그 공급사의{Name}{Op}Controller에서 시작한다. → system-architecture
문제 1 — [trace] 검색 요청 한 건의 콜 스택을 끝까지 따라가기
POST /internals/AMADEUS/search 요청이 도착했다. HTTP 진입부터 외부 GDS 호출까지 거쳐 가는 모든 레이어의 진입 클래스·메서드를 순서대로 나열하라. (필터 체인 → 컨트롤러 → 서비스 → 코루틴 fan-out → 클라이언트 → OkHttp 전송)
시작점: supplier/amadeus/interfaces/controller/internals/AmadeusSearchController.kt
정답 보기
콜러/콜리 순서 (전체는 request-flow §1.1):
HTTP POST /internals/AMADEUS/search │ ▼ 서블릿 필터 체인 (configuration/WebMvcConfiguration.kt 의 @Order) (1) ContentCachingWrapperFilter (WebMvcConfiguration.kt:104, @Order 1) — body 재읽기 래핑 (2) MDCFilter (WebMvcConfiguration.kt:108, @Order 2) — 헤더→MDC (3) AdapterLoggingFilter (WebMvcConfiguration.kt:112, @Order 3) — POST body 로그 │ ▼ interfaces 1. AmadeusSearchController#search (AmadeusSearchController.kt:26) @CircuitBreaker(name="amadeusSearch", fallbackMethod="searchFallback") (:24) CacheKeyGenerator.generateSearchRequestKey(...) (:27, CacheKeyGenerator.kt:12) │ ▼ application 2. AmadeusFlightSearchService#search (AmadeusFlightSearchService.kt:39) findKey(requestKey) 캐시 조회 (:53) → 미스면 fan-out withBlocking(Dispatchers.IO) { ... } (CoroutineExtensions.kt:13) 3. cartesianProduct() (CollectionUtils.kt:26) → pmap { } (CoroutineExtensions.kt:36) │ ▼ infrastructure 4. AmadeusClient#search (infrastructure/AmadeusClient.kt) FareMasterPricerTravelBoardSearch.of(...) SOAP 빌드 5. OkHttpRequestBuilder#execute (support/web/ClientSupport.kt:146) LoggingAndCompressionInterceptor (RQ/RS 로그 + gzip 해제) │ ▼ 외부 6. TOPAS/Amadeus SOAP Fare_MasterPricerTravelBoardSearch (1A)응답은 역방향:
AmadeusClient의.fold(success=...)에서toFareItinerary매핑 →AmadeusFlightSearchService가 distinct/필터/캐시 저장 →AmadeusSearchController가FareItineraryView.of(...)로 View 변환 후ResponseEntity.ok(...). 핵심: 모든 공급사가Controller → Service → Client3단을 반복한다. 외부 프로토콜만 다르다. → caller-callee-map
문제 2 — [config/trace] 책임을 올바른 레이어에 배치하기
아래 5가지 책임은 각각 어느 레이어(interfaces / application / infrastructure / support)에 있어야 하는가? 그리고 그 근거가 되는 실제 클래스를 하나씩 대라.
- “이 요청 파라미터가 유효한가” 검증
- 검색결과를 Redis에 저장/복원
- SOAP 본문을 만들어 OkHttp로 실제 전송
- 여러 공급사가 공유하는 코루틴 래퍼·MDC 컨텍스트
- 서킷브레이커 부착 + View 변환
정답 보기
의존 방향은
interfaces → application → infrastructure → (외부)한 방향,support는 모두가 의존하는 공통 토대 (역방향 의존 없음 — system-architecture §5.1).
# 책임 레이어 근거 클래스 1 요청 검증 interfaces (DTO) + application SearchRequest.isSearchable(...),MethodArgumentInvalidException(400)2 캐시 저장/복원 application + domain repository AmadeusFlightSearchService#search(:53findKey,:101addKey),getFareItinerary(:117)3 SOAP 빌드/전송 infrastructure AmadeusClient#search,OkHttpRequestBuilder#execute(ClientSupport.kt:146)4 코루틴/MDC 래퍼 support (횡단) CoroutineExtensions.kt:13(withBlocking),MDCContext()주입5 서킷브레이커 + View interfaces AmadeusSearchController.kt:24(@CircuitBreaker),:50(FareItineraryView.of)흔한 실수 HTTP 경계(검증·CB·View), 서비스는 오케스트레이션(캐시·병렬화·에러 매핑), 클라이언트는 전송이다. 이 경계가 흐려지면 11개 공급사의 골격 일관성이 깨진다.
“캐시 조회”를 컨트롤러에 넣거나, “View 변환”을 서비스에 넣는 것. 컨트롤러는
문제 3 — [trace/extend] key와 pnr, 단계를 잇는 두 개의 끈
검색 → 예약 → 발권 → 취소로 이어지는 오퍼레이션에서, 각 단계는 key 또는 pnr 중 어떤 식별자를 입력으로 받는가? 그리고 “검색 결과를 골라 예약을 만들 때” 그 검색 운임이 코드 어디에서 어떻게 복원되는지 추적하라.
시작점: common-operations §1, supplier/amadeus/application/AmadeusBookingService.kt
정답 보기
두 끈의 역할 (common-operations “key와 PNR” 콜아웃):
key: SEARCH가 발급({SUPPLIER}_{UUID}). FARE-RULE / detail / BOOKING이 이 key로 “그때 그 운임”을 다시 찾는다.pnr: BOOKING이 발급. REPRICING / TICKETING-READY / TICKETING / CANCEL / QUEUE / CASH-RECEIPT가 이 pnr로 예약을 가리킨다.
단계 입력 식별자 SEARCH (없음) → key 발급 FARE-RULE / detail key BOOKING key → pnr 발급 REPRICING / READY / TICKETING / CANCEL / QUEUE / CASH-RECEIPT pnr 검색 운임 복원: 예약 진입 시
AmadeusBookingService#book이flightSearchService.getFareItinerary(key)(AmadeusFlightSearchService.kt:117)로 Redis에서 검색 시점FareItinerary를 복원한다. 이때fareItineraryRepository.getFareItinerary(key)(AmadeusFareItineraryRepository.kt)가 없으면CacheKeyInvalidException(INVALID_CACHE_KEY)를 던진다(AmadeusFareItineraryRepository.kt:35). → 문제 5와 연결.신입 디버깅 팁
AMADEUS_xxxx(key)인지 PNR(영숫자 6자리 등)인지만 봐도 지금 어느 단계인지 즉시 안다.로그/요청 본문에서 입력 식별자가
문제 4 — [debug] 검색 OD 조합 5개 중 2개가 타임아웃이면?
AmadeusFlightSearchService#search가 출발-도착(OD) 조합 5개를 pmap으로 병렬 호출하는데, 그중 2개가 SOAP 타임아웃이 났다. 클라이언트(Triple)는 무엇을 받는가? 만약 5개 전부 실패하면? 그리고 이 동작을 만드는 코드 위치를 짚어라.
시작점: support/util/CoroutineExtensions.kt, supplier/amadeus/application/AmadeusFlightSearchService.kt
정답 보기
2개 실패 → 성공한 3개의
FareItinerary만 정상 200으로 받는다. 부분 성공이 살아남는 이유:
pmap(CoroutineExtensions.kt:36~50)이 각 호출을withAsync안에서try/catch로 감싸 예외를 던지지 않고AsyncFail(e)“값”으로 변환한다(:43~46). 그래서awaitAll()이 첫 실패로 형제를 취소시키지 않는다.SupervisorJob()(CoroutineExtensions.kt:33,withAsync)이라 한 자식 실패가 형제에게 전파되지 않는다.- 검색 서비스는
.onFailure { exceptions, successes -> if (successes.isEmpty()) throw ... }(CoroutineExtensions.kt:52) 후getOrEmpty()(:62)로 성공분만 회수한다.5개 전부 실패 →
onFailure에서successes.isEmpty()가 참이 되어InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())를 throw →RestExceptionHandler가 500. (전체 흐름은 async-coroutines §3.2, error-handling)정책을 바꾸면 사고가 난다
getOrEmpty()(관대)지만, 현금영수증/환불은getOrThrow()(엄격)다(AmadeusCashReceiptService.kt:72등). 검색에getOrThrow()를 쓰면 노선 하나 실패에 전체 검색이 죽고, 돈 관련에getOrEmpty()를 쓰면 일부 건이 조용히 누락된다. 종결 헬퍼 선택 = 비즈니스 안전성 결정. → async-coroutines검색은
문제 5 — [debug] 같은 “검색 실패”인데 왜 어떤 건 500이고 어떤 건 410·200인가
운영 중 다음 3가지 응답을 관찰했다. 각각 어떤 예외/경로에서 나왔고, 호출자(Triple)는 어떻게 대응해야 하는지 진단하라.
(a) 200 OK + 빈 리스트 []
(b) 410 GONE, body {"code":"INVALID_CACHE_KEY"}
(c) 500, body {"code":"SEARCH_FAILED"}
시작점: supplier/amadeus/interfaces/controller/internals/AmadeusSearchController.kt, support/exception/RestExceptionHandler.kt, supplier/amadeus/domain/repository/AmadeusFareItineraryRepository.kt
정답 보기
응답 어디서 원인 / 호출자 대응 (a) 200 + []AmadeusSearchController#searchFallback(:73~79)서킷브레이커 OPEN. CallNotPermittedException만 이 fallback으로 와서emptyList()반환. 이 공급사만 결과 0건으로 보이고 다른 공급사 결과로 화면이 채워진다. RestExceptionHandler를 타지 않는다. → resilience-and-events(b) 410 GONE AmadeusFareItineraryRepository.kt:35→RestExceptionHandler캐시키 만료/소실. CacheKeyInvalidException(INVALID_CACHE_KEY)→@ExceptionHandler(CacheKeyInvalidException)가 410으로 매핑(RestExceptionHandler.kt). 호출자는 재검색 유도.(c) 500 SEARCH_FAILED AmadeusFlightSearchServiceonFailure(전부 실패 throw)실제 검색 로직 실패(예: 모든 OD 타임아웃). InternationalAdapterException(500).결정 규칙 HTTP status는
ErrorMessage(코드)가 아니라 "예외의 타입"이 정한다.@ControllerAdvice(RestExceptionHandler)가MethodArgumentInvalidException→400,CacheKeyInvalidException→410,InternationalAdapterException/StatusInvalidException/그 외→500 으로 분기한다. 같은SEARCH_FAILED코드라도 어떤 예외 클래스로 던졌느냐로 status가 달라진다. → error-handling §1, §5그리고 (a)는 fallback 시그니처가
CallNotPermittedException만 받기 때문에 일반 검색 예외(타임아웃 등)는 fallback을 타지 않고 그대로 (c)로 전파된다. “왜 어떤 실패는 빈 배열이고 어떤 건 500이지?”의 답이 여기 있다.
문제 6 — [config] 서킷브레이커를 정확히 이해했는지 점검
application.yml의 resilience4j.circuitbreaker.configs.search를 보고 답하라.
slidingWindowSize: 180은 “최근 180건”인가 “최근 180초”인가?- amadeus 서킷이 OPEN일 때 sabre 검색도 막히는가?
- 임계값(
failureRateThreshold: 35)을 prod에서만 바꾸려면 어느 파일을 고치는가? - 재시도(
@Retryable)도 같은resilience4j블록에서 설정하는가?
시작점: src/main/resources/application.yml, resilience-and-events §1·§2
정답 보기
180초 (
slidingWindowType: TIME_BASED). 호출 “건수”가 아니라 시간 윈도우다.minimumNumberOfCalls: 30(180초 내 최소 30건),failureRateThreshold: 35(실패율 35%↑),waitDurationInOpenState: 120s. → resilience-and-events §1.2아니다.
configs.search는 템플릿일 뿐, 실제 상태(CLOSED/OPEN/HALF_OPEN)는amadeusSearch,sabreSearch… 인스턴스별로 독립이다. 아마데우스가 OPEN이어도 세이버는 정상일 수 있다.트릭 질문 — 불가능에 가깝다.
resilience4j블록은application.yml한 곳에만 있고application-prod.yml/-staging.yml어디에도 오버라이드가 없다(dev=qa=prod 동일). prod만 바꾸려면 prod 프로파일 yml에 오버라이드를 새로 추가해야 한다.아니다. 재시도는 Spring Retry(
@Retryable+@EnableRetry—AirIntlAdapterApplication.kt:9)로, Resilience4j의@Retry가 아니다.application.yml의resilience4j블록에는retry:설정이 없고, 재시도 횟수/백오프는 각@Retryable인자에 하드코딩돼 있다.인스턴스명 미스매치 함정
singaporeSearch다(singaporeairSearch가 아님). 컨트롤러의name=과application.ymlinstance 키가 정확히 일치해야 설정이 매핑된다. 어긋나면 default config로 조용히 떨어진다. → resilience-and-events §1.1singaporeair만 서킷 인스턴스명이
문제 7 — [debug] fire-and-forget 코루틴은 어디로 사라지나
예약 실패 시 AmadeusBookingService가 cancelService.pnrCancelAsync(pnr)를 부른다. (a) 이 취소 작업의 예외는 어디로 가며 호출자에게 전파되는가? (b) pnrCancelAsync 안의 delay(5000) 도중 애플리케이션이 재배포되면 무슨 일이 생기는가? (c) 이 백그라운드 작업이 던진 예외는 Sentry에 잡히는가?
시작점: supplier/amadeus/application/AmadeusBookingService.kt:179, support/util/CoroutineExtensions.kt, support/exception/AdapterCoroutineExceptionHandler.kt
정답 보기
(a)
pnr?.run { cancelService.pnrCancelAsync(this) }; throw e(AmadeusBookingService.kt:179~180). 즉 사용자에겐 즉시 예외(throw e) 가 가고, PNR 취소만 백그라운드로 분리된다. 취소 코루틴의 예외는 호출자에게 전파되지 않는다(fire-and-forget).(b)
pnrCancelAsync는CoroutineScope(Dispatchers.IO).withLaunch { delay(5000); pnrCancelRepeat(pnr) }형태로, 어떤 부모 생명주기에도 묶이지 않은 분리 스코프다. 5초 타이머 만료 전에 프로세스가 죽으면 취소가 영영 실행되지 않아 고아 PNR이 남는다(GDS 동시세션/재고 낭비). 신뢰성이 필요하면 Redis 기반polling(PollingUtils.kt) 같은 영속 상태가 필요. → async-coroutines §4.1, §5(c) 잡힌다.
withLaunch(CoroutineExtensions.kt:20)는 컨텍스트에AdapterCoroutineExceptionHandler()를 주입한다. 미처리 예외가 나면 핸들러(AdapterCoroutineExceptionHandler.kt:15)가findRootCause().log().sentryLog()로 위임해 로깅 + Sentry(단 root cause가ApiException이면capturable조건 적용). 단withAsync에는 핸들러가 없으니 혼동 금지(pmap이 직접 try/catch). → error-handling §7흔한 오해: "치명 오류면 자동으로 Slack이 간다" — 아니다
AdapterCoroutineExceptionHandler/RestExceptionHandler는 Sentry만 자동 호출한다. Slack 경보는slackService.sendXxx(...)를 서비스 코드가 수동으로 부르는 지점에서만 나간다. → error-handling §5.3, resilience-and-events §3
문제 8 — [extend] 새 공급사 “팬텀항공(PHANTOM)” 추가 설계
가상의 LCC “팬텀항공”을 검색만 지원하도록 추가한다. 만들어야 할 파일/클래스와 등록 지점을 레이어 순서대로 나열하라. 그리고 검색 컨트롤러에 반드시 붙여야 하는 횡단 요소 2가지는?
시작점: supplier/groupair/(가장 작은 모듈, 골격 학습용), system-architecture §3.1
정답 보기
네이밍 규칙(
{Name}{Op}Controller/{Name}{Op}Service/{Name}Client)을 그대로 복제한다. groupair(34개 파일)가 가장 작아 골격 참고용이다.supplier/phantom/ ├── interfaces/controller/internals/PhantomSearchController.kt │ @RestController @RequestMapping("/internals/PHANTOM/search") │ @CircuitBreaker(name="phantomSearch", fallbackMethod="searchFallback") ← 횡단 ① │ CacheKeyGenerator.generateSearchRequestKey(supplier=Supplier.PHANTOM,...) │ 반환부: FareItineraryView.of(...) ← View 변환 ├── application/PhantomFlightSearchService.kt │ withBlocking(Dispatchers.IO){ ... pmap{ phantomClient.search(...) } } │ .onFailure{ ...전부 실패면 throw SEARCH_FAILED }.getOrEmpty() ├── infrastructure/PhantomClient.kt : ClientSupport │ OkHttp REST 호출 ├── domain/repository/PhantomFareItineraryRepository.kt │ getFareItinerary(key) 없으면 CacheKeyInvalidException(INVALID_CACHE_KEY) └── configuration/ (필요 시 전용 Bean/Redis)등록 지점:
support/enums/Supplier.kt에PHANTOMenum 추가.configuration/Properties.kt에 공급사별@ConfigurationProperties.application.yml에classpath:supplier/phantom.ymlimport +resilience4j ... instances.phantomSearch: { baseConfig: search }.- (검색 제약이 있으면)
SearchRequest.isSearchable(...)에 분기.검색 컨트롤러에 반드시 붙여야 하는 횡단 요소 2가지:
①
@CircuitBreaker(name="phantomSearch", fallbackMethod="searchFallback")— instance명이 yml 키와 정확히 일치해야 함(문제 6 함정).②
searchFallback(exception: CallNotPermittedException)— OPEN 시ResponseEntity.ok(emptyList())+ Datadog span 태그."하나를 이해하면 11개"
PhantomClient에서 다르고, 위/아래 레이어 골격은 동일하다. 필터·MDC·예외 핸들러·코루틴 래퍼는support/에 이미 있어 새로 만들 필요가 없다. → system-architecture, caller-callee-map외부 프로토콜(REST/SOAP/NDC)만
학습 포인트 요약 (꼭 가져갈 것)
주제 한 줄 핵심 근거 위치 요청 흐름 필터(3) → {Name}{Op}Controller→{Name}{Op}Service→{Name}Client→ OkHttp. 중앙 디스패처 없음request-flow, AmadeusSearchController.kt:26레이어 경계 interfaces=HTTP/검증/CB/View, application=오케스트레이션/캐시/병렬, infrastructure=전송, support=공통. 의존은 한 방향 system-architecture §5 공통 DTO key(검색 운임)와pnr(예약)이 단계를 잇는 두 끈. 입력 식별자로 단계 판별common-operations, getFareItinerary:117비동기 pmap은 예외를AsyncFail값으로 흡수 → 부분 성공 생존.getOrEmpty(관대) vsgetOrThrow(엄격)async-coroutines, CoroutineExtensions.kt:36에러 전파 HTTP status는 예외 타입이 결정(400/410/500). findRootCause→sentryLog,.capture()해야 Sentryerror-handling, RestExceptionHandler.kt회복탄력성 서킷=검색만, 인스턴스별 독립, TIME_BASED 180s/35%. fallback은 CallNotPermittedException만. 재시도=Spring Retry(별개)resilience-and-events, application.ymlfire-and-forget withLaunch는 핸들러로 Sentry 기록 O, 그러나 분리 스코프라 셧다운 시 유실(고아 PNR). Slack은 수동async-coroutines §4, error-handling §5.3 확장 groupair 골격 복제 + Supplierenum +Properties+application.yml+ CB instance 등록system-architecture §3.1
관련 노트
- system-architecture — 레이어·패키지 전체 지도 (문제 2·8)
- request-flow — End-to-End 콜러/콜리 추적 (문제 1)
- caller-callee-map — 11개 공급사 컨트롤러→서비스→클라이언트 매핑
- common-operations — Search/Booking/Ticketing 공통 생명주기, key/pnr (문제 3)
- async-coroutines —
pmap/withBlocking/withLaunch상세 (문제 4·7) - error-handling — 예외 타입→HTTP status, Sentry 조건 (문제 5·7)
- resilience-and-events — 서킷브레이커·재시도·Slack (문제 6)
- 공급사별 심화 연습은 exercises-suppliers, 디버깅 시나리오는 exercises-debugging