Jeju Air — 프로토콜·전문
module-jejuair pattern-protocol api-rest config-encryption
한 줄 요약
Jeju Air는 순수 REST + JSON 공급사다. SOAP envelope도 NDC XML도 없다. 모든 호출은
POST + JSON body이며, 응답은JejuairResponse<T>라는 단일 봉투(envelope)로 감싸진다. 세션 상태는 HTTP 헤더로 흐르는PssToken으로 표현되고, 카드 정보는 RSA 공개키(PKCS#1) 로 암호화해 Hex 문자열로 전송한다.
관련 노트: 오퍼레이션 · 지뢰·함정 · 인터페이스 DTO · Jeju Air 개요
1. 프로토콜: LCC / REST / JSON
GDS/NDC 공급사와의 결정적 차이
amadeus·sabre·galileo는 SOAP envelope(XML)를, koreanair·lufthansa·singaporeair는 NDC XML 스키마를 다룬다. Jeju Air는 둘 다 아니다. Spring
RestTemplate도 아닌 OkHttp + Jackson 으로 평범한 REST JSON을 주고받는다. 따라서 이 모듈에는 XSD 스키마 파일이 존재하지 않으며(src/test/schema/jejuair없음), mockData 샘플 전문도 없다(src/test/resources/mockData/jejuair없음). 전문 구조의 유일한 출처는 request/response 데이터 클래스 자체다.
1.1 전송 방식
전송 계층은 공통 베이스 클래스 ClientSupport(support/web/ClientSupport.kt)를 통한 OkHttp 호출이다. JejuairClient는 이를 상속한다.
// JejuairClient.kt:37-46
@Component
class JejuairClient(
private val jejuairProperties: JejuairProperties,
objectMapper: ObjectMapper,
supplierLoggingProperties: SupplierLoggingProperties,
) : ClientSupport(
objectMapper = objectMapper,
searchTimeout = 30000, // 검색 전용 클라이언트 타임아웃 30초
defaultTimeout = 60000, // 그 외 모든 호출 60초
)호출 패턴은 모든 메서드가 동일한 DSL 체인이다(ClientSupport.kt:50-60의 String.post() 확장):
// 예: JejuairClient.kt:296-302 (createBooking)
"${apiProperties.endpoint}/booking/createBook/v1.0"
.post(request) // POST + JSON 직렬화
.header(headerMap[...]!![...]!! .plus("PssToken" to pssToken))
.execute<JejuairResponse<RetrieveRS>>(jejuairDeserializerOf(logger, objectMapper)).post(request)→ClientSupport.kt:53. 바디는OkHttpRequestBuilder.execute()(ClientSupport.kt:151-154)에서objectMapper.writeValueAsString(requestBody).toRequestBody()로 직렬화 → 항상 JSON.- 검색만
.client(searchClient)(JejuairClient.kt:211)로 30초 타임아웃 클라이언트를 명시 지정한다. 나머지는defaultClient(60초).
1.2 엔드포인트 카탈로그
모든 URL은 ${apiProperties.endpoint} 베이스 + 경로 + 버전 접미사(/v1.0) 구조다. 베이스 URL은 설정(JejuairApiProperties.endpoint)에서 funnel별로 주입된다(코드에 하드코딩된 호스트 없음).
| 오퍼레이션 | 메서드(코드) | HTTP 경로 | 요청 모델 | 응답 모델 |
|---|---|---|---|---|
| 검색 | search / flightSearch (L203) | POST /booking/getAvailability/v1.0 | AvailabilityRQ | JejuairResponse<AvailabilityRS> |
| 운임확정(가격) | pricing / getTripSell (L454) | POST /booking/getTripSell/v1.0 | TripSellRQ | JejuairResponse<RetrieveRS> |
| 운임규정 | getFareRules (L252) | POST /booking/getFareRule/v1.0 | FareRuleRQ | JejuairResponse<List<FareRuleRS>> |
| 예약생성 | createBooking (L285) | POST /booking/createBook/v1.0 | CreateBookRQ | JejuairResponse<RetrieveRS> |
| 예약조회 | retrieve (L329) | POST /reservation/retrievePNR/v1.0 | RetrieveRQ | JejuairResponse<RetrieveRS> |
| 결제·발권 | paymentAndIssue/paymentAndReissue (L440) | POST /payment/requestApprovalPay/v1.0 | PaymentRQ | JejuairResponse<PaymentRS> |
| 취소수수료 | calculateCancelFee (L464) | POST /reservation/cancel/searchCancelPenaltyFeeInfo/v1.0 | CancelFeeRQ | JejuairResponse<CancelFeeRS> |
| 취소실행 | cancel (L518) | POST /reservation/cancel/executeCancel/v1.0 | ExecuteCancelRQ | JejuairResponse<Any> |
| 변경확정 | confirm (L544) | POST /reservation/change/agreeUntkChange/v1.0 | AgreeUntkChargeRQ | JejuairResponse<RetrieveRS> |
| 변경실행 | executeChange (L569) | POST /reservation/change/executeChange/v1.0 | ExecuteChangeRQ | JejuairResponse<Any> |
| 변경수수료 | calculateChange (L622) | POST /reservation/change/searchChangePenaltyFeeInfo/v1.0 | ChangeFeeRQ | JejuairResponse<ChangeFeeRS> |
| PNR 분리 | divide (L599) | POST /reservation/dividePNR/v1.0 | DividePnrRQ | JejuairResponse<RetrieveRS> |
URL 명명 규칙
경로는
{도메인}/{오퍼레이션}/v1.0형태이고, 예약 변경/취소는reservation/{change|cancel}/...로 한 단계 더 들어간다. 이 URL 구조 자체가 Jeju Air의 SEE(Sales Engine Engine) 내부 API 분류를 그대로 반영한다.
1.3 gzip 압축 & 인터셉터
요청 헤더에 Accept-Encoding: gzip,deflate,sdch(JejuairClient.kt:55)를 지정한다. 실제 압축 해제와 요청/응답 로깅은 공통 LoggingAndCompressionInterceptor가 OkHttp 인터셉터로 붙어 처리한다(ClientSupport.kt:35,43). 즉 압축 처리는 공급사별이 아니라 어댑터 전역 공통이다.
2. 인증·세션 — PssToken 과 채널 헤더
Jeju Air의 "세션"은 stateful PNR 세션이 아니다
Amadeus처럼 서버측 stateful 세션을 잡는 게 아니라, 응답 HTTP 헤더로 받은
PssToken문자열을 다음 요청 헤더에 그대로 다시 실어 보내는 토큰 릴레이 방식이다. 토큰은 검색 결과가 아니라 가격확정(getTripSell) 응답부터 발급되고, 이후 예약·발권·취소·변경 호출 내내 같은 값을 전달한다.
2.1 채널·퍼널 인증 헤더
모든 요청에 붙는 정적 인증 헤더는 채널/퍼널 조합으로 미리 빌드된 맵(headerMap)에서 꺼낸다(JejuairClient.kt:49-62):
mapOf(
HttpHeaders.CONTENT_TYPE to "application/json",
HttpHeaders.ACCEPT to "application/json",
HttpHeaders.ACCEPT_ENCODING to "gzip,deflate,sdch",
"x-client-id" to funnel.clientId,
"x-client-secret" to funnel.clientSecret,
"x-channel-code" to funnel.channelCode,
"agentId" to funnel.agencyId
)| 헤더 | 의미 | 출처 |
|---|---|---|
x-client-id | API 클라이언트 ID | JejuairApiProperties.clientId |
x-client-secret | API 시크릿 | JejuairApiProperties.clientSecret |
x-channel-code | 판매 채널 코드 | JejuairApiProperties.channelCode |
agentId | 대리점 ID | JejuairApiProperties.agencyId |
PssToken | 세션 토큰(동적) | getTripSell 응답 헤더 |
헤더 조회 키는 요청 스레드의 MDC에서 가져온다: headerMap[MDCHolder.SalesChannel.get()]!![apiProperties.funnel]!!. 즉 같은 클라이언트 인스턴스라도 호출 스레드의 SalesChannel/SalesFunnel 값에 따라 다른 인증정보를 쓴다.
!!가 곳곳에 박혀 있다헤더 조회는
headerMap[채널]!![퍼널]!!처럼 더블뱅(!!)으로 강제 언랩한다. 설정에 없는 채널/퍼널이 MDC에 들어오면 즉시 NPE다. (단, 정상 경로에서는JejuairProperties.getApiProperties()가 먼저 호출되어NOT_SUPPORTED_SALES_CHANNEL/NOT_SUPPORTED_SALES_FUNNEL예외로 거른다 →Properties.kt:626-645.) 자세한 함정은 jejuair-pitfalls.
2.2 PssToken 의 흐름 (라이프사이클)
flowchart TD A["search · getAvailability<br/>응답 AvailabilityRS<br/>PssToken 없음 (journeyKey, fareAvailabilityKey 만 보유)"] B["pricing · getTripSell<br/>응답 HTTP 헤더 PssToken 발급<br/>세션 시작점"] C["book · createBook<br/>응답 RetrieveRS (pnrNo, bookingKey)"] D["retrieve · retrievePNR<br/>응답 RetrieveRS (헤더로 PssToken 재발급 가능)"] E["issue · requestApprovalPay<br/>응답 PaymentRS"] F["cancel · searchCancelPenaltyFeeInfo / executeCancel"] G["change · searchChangePenaltyFeeInfo / executeChange / agreeUntkChange / dividePNR"] A -->|"사용자가 운임 선택"| B B -->|"이후 모든 호출 헤더에 PssToken 재전송"| C C --> D D --> E E --> F F --> G
getTripSell응답 헤더에서PssToken발급 = 세션 시작점.JejuairClient.pricing()이response.pssToken추출(JejuairClient.kt:240,244).- 이후 모든 호출 헤더에
.plus("PssToken" to pssToken)로 토큰을 재전송한다. - 토큰은 응답 본문이 아니라 HTTP 응답 헤더로 온다.
토큰이 응답 본문이 아니라 HTTP 응답 헤더로 온다는 점이 핵심이다. 이를 봉투 객체에 끌어올리는 게 커스텀 디시리얼라이저다:
// JejuairClient.kt:653-670 jejuairDeserializerOf
inline fun <reified T : Any> jejuairDeserializerOf(...) = { content, response ->
val jejuairResponse = mapper.readValue<JejuairResponse<T>>(content, ...)
response.headers("PssToken").firstOrNull() // 응답 헤더에서 토큰 추출
?.let { jejuairResponse.copy(pssToken = it) } // 봉투에 주입
?: jejuairResponse
}검색은 일반 deserialization, 나머지는 커스텀
flightSearch만.execute<JejuairResponse<AvailabilityRS>>()로 인자 없이 호출한다(JejuairClient.kt:213) → Jackson 기본 역직렬화(ClientSupport.kt:176). 헤더 토큰을 끌어올릴 필요가 없기 때문. 반면 가격확정·예약·결제·취소·변경 등은 모두jejuairDeserializerOf(...)를 넘겨 헤더 토큰을 봉투로 합친다. 검색 응답에는 PssToken이 없다는 사실을 코드가 이렇게 구분한다.
2.3 retrieveWithToken — 토큰 보장 헬퍼
조회 응답에는 토큰이 안 올 수도 있어, 토큰이 필수인 후속 작업 전에 retrieveWithToken으로 보장한다:
// JejuairClient.kt:364-370
fun retrieveWithToken(pnr: String): Booking {
return retrieve(pnr).also {
if (it.reference.pssToken == null) {
throw InternationalAdapterException(..., "pssToken is null").retry() // 재시도 유발
}
}
}pssToken is null을 .retry()로 던져 @Retryable(L359-363)이 재호출하게 만든다. 이는 재시도가 곧 토큰 재발급 시도라는 패턴으로, 취소수수료 조회의 OTAUSV900(JejuairClient.kt:485-489)에서도 동일하게 쓰인다.
3. 결제 카드정보 RSA 암호화
가장 중요한 보안 디테일
결제 카드 필드는 평문으로 전송하지 않는다.
@Encrypt(cipher = JejuairCipher::class)어노테이션이 붙은 필드를 Jackson 직렬화 시점에 RSA 공개키로 암호화한다. 신입이 결제 디버깅 시 “카드번호가 왜 16진수 난수로 찍히지?”라고 헤매는 첫 함정이다.
3.1 어노테이션 기반 필드 암호화
CreditCardInfo(infrastructure/request/CreditCardInfo.kt)의 민감 필드 8개에 @Encrypt가 붙어 있다:
// CreditCardInfo.kt
data class CreditCardInfo(
val installments: Int,
val identificationFlag: String, // 개인/법인 (JJ, BB)
val cardIssuedFlag: String = "I", // 국내(D)/해외(I)
@Encrypt(cipher = JejuairCipher::class) val accountNumber: String, // 카드번호
@Encrypt(cipher = JejuairCipher::class) val expiredDate: String,
@Encrypt(cipher = JejuairCipher::class) val identificationNumber: String, // 생년월일/사업자번호
@Encrypt(cipher = JejuairCipher::class) val password: String, // 카드 비밀번호 앞 2자리
@Encrypt(cipher = JejuairCipher::class) val cvv: String? = null,
@Encrypt(cipher = JejuairCipher::class) val cardHolderName: String? = null,
@Encrypt(cipher = JejuairCipher::class) val telNo: String? = null,
@Encrypt(cipher = JejuairCipher::class) val emailAddr: String? = null,
)3.2 동작 원리 — CryptoSerializer
@Encrypt는 메타 어노테이션 @JsonSerialize(using = CryptoSerializer::class)를 품은 합성 어노테이션이다(support/util/CryptoUtils.kt:29-35). Jackson이 해당 필드를 직렬화할 때 CryptoSerializer가 가로채 암호화한다:
// CryptoUtils.kt:45-55
class CryptoSerializer(...) : JsonSerializer<String>(), ContextualSerializer {
override fun createContextual(...) =
CryptoSerializer(cipher = property.getAnnotation(Encrypt::class.java).cipher)
override fun serialize(value, gen, ...) {
value ?: return gen.writeNull()
gen.writeString(cipher!!.createInstance().encrypt(value)) // 암호화 후 String 으로 출력
}
}cipher.createInstance() → JejuairCipher를 매번 새로 생성한다(Spring 빈 아님). 즉 암호화 키는 공급사 코드 안에 박혀 있다.
3.3 JejuairCipher — RSA/ECB/PKCS1, Hex 출력
// JejuairCipher.kt
class JejuairCipher : CipherHandler {
companion object JejuairSecureKey {
private val publicKey: RSAPublicKey // 코드에 Base64 하드코딩된 2048bit X.509 공개키
init { /* "MIIBIjANBgkqhkiG9w0B..." Base64 디코딩 → X509EncodedKeySpec → RSA */ }
}
private val cipher = Cipher.getInstance("RSA") // JDK 기본 = RSA/ECB/PKCS1Padding
override fun encrypt(text: String): String {
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
return Hex.encodeHexString(cipher.doFinal(text.encodeToByteArray())) // ★ Base64 아님, Hex!
}
@Deprecated("Jejuair not supported decrypt algorithm")
override fun decrypt(...) = throw Exception("...") // 복호화 불가 (공개키만 보유)
}출력 인코딩 주의: Jeju Air는 Hex, 어댑터 공통은 Base64
공통
CryptoUtils.kt:70의Cipher.encrypt()확장은Base64.encodeBase64String(...)을 쓴다. 그러나JejuairCipher는 이 공통 확장을 쓰지 않고 자체encrypt()에서Hex.encodeHexString(...)을 사용한다. 인코딩 방식이 다른 이유는 Jeju Air API 명세가 Hex를 요구하기 때문이다. 다른 공급사(tway의 SEED 등) 코드를 참고할 때 인코딩을 혼동하지 말 것.
| 항목 | 값 |
|---|---|
| 알고리즘 | RSA (= RSA/ECB/PKCS1Padding, JDK 기본) |
| 키 종류 | RSA 공개키만 보유 (복호화 불가, decrypt는 @Deprecated + throw) |
| 키 위치 | JejuairCipher.kt:18 에 Base64 X.509 문자열로 소스 하드코딩 |
| 출력 인코딩 | Hex (Hex.encodeHexString) — Base64 아님 |
| 적용 시점 | Jackson 직렬화 (@Encrypt 필드 한정) |
4. 응답 봉투 — JejuairResponse<T> 와 에러 코드
4.1 단일 봉투 구조
모든 응답은 제네릭 봉투로 통일된다(infrastructure/response/JejuairResponse.kt):
data class JejuairResponse<T>(
val code: String, // "0000" = 성공, 그 외 = 에러코드
val message: String,
val data: T?, // 실제 페이로드 (성공 시)
val pssToken: String? = null // 본문이 아니라 응답 헤더에서 주입됨 (3.2 참조)
) {
fun checkError(callback: (code, message) -> Unit) {
if (code != "0000") callback(code, message) // 0000 외엔 콜백으로 위임
}
}checkError는 성공 판단을 HTTP status가 아니라 봉투의 code == "0000"으로 한다. HTTP 200이어도 code가 다르면 에러다. 각 메서드는 checkError { code, message -> ... } 블록에서 코드별로 예외를 분기한다.
검색의 "정상적인 에러" 무시 처리
검색(
searchL83-96)은 일부 에러코드를 예외가 아니라logger.warn으로만 처리하고 빈 리스트를 반환한다 — 검색이 “결과 없음”으로 끝나는 게 정상인 경우이기 때문.
코드 의미 OTAUSV113구매 가능한 판매노선 구간이 아님 OTAUSV116현재일 기준 330일 이내 출발편만 예약 가능 SEEUSV002항공권 예매는 금일기준 330일 출발편까지 가능 SEEUSV003일자 확인 필요 SEEUSV004유효하지 않은 구간
4.2 주요 에러 코드 → 예외 매핑
| 코드 | 발생 위치 | 처리 |
|---|---|---|
0000 | 전체 | 성공 |
COMESV504 | pricing(L234), createBook(L307) | StatusInvalidException(SOLD_OUT) — 매진 |
OTAUSV719 | calculateCancelFee(L479) | StatusInvalidException(CANCEL_UNABLE) |
OTAUSV900 | calculateCancelFee(L485) | .retry() — 토큰 재발급 후 재시도 |
OTAUSV51*,OTAUSV53*,OTAUSV55*,PAYESV006* | 결제(L382,L416) | PaymentError 매칭 후 MethodArgumentInvalidException |
PAYESV010 | 결제(L390,L424) | MethodArgumentInvalidException(...).capture(silence=true) |
PaymentError(support/util/PaymentError.kt)는 결제 거절 메시지 문자열을 사용자용 에러 메시지로 매핑하는 enum이다. 자세한 에러 흐름은 error-handling 참조.
retrieve 실패만 SOAP fault 처리 흔적이 있다
retrieve의 실패 분기(JejuairClient.kt:351)는it.handleSoapFaultException(...)를 호출한다. Jeju Air는 REST 공급사지만, Jeju Air 백엔드가 내부적으로 SEE/SOAP 시스템을 게이트웨이 뒤에 두고 있어 일부 장애 시 SOAP fault XML이 그대로 흘러나올 수 있기 때문으로 보인다. 이 헬퍼는ClientSupport.kt:62-74의 공통 메서드로, fault 파싱 실패 시 원본 예외로 폴백한다.
5. 요청/응답 전문 모델 — 핵심 필드 매핑
패턴
모든 모델은
data class+@field:JsonProperty("외부필드명")으로 매핑한다. 코틀린 프로퍼티명과 JSON 키가 다른 경우가 많다(예: 코틀린passengerType→ JSONpaxType). 디버깅 시 와이어 상의 키는 반드시@JsonProperty값으로 확인할 것.
5.1 검색 요청 — AvailabilityRQ
| 코틀린 필드 | JSON 키 | 비고 |
|---|---|---|
tripRoutes | tripRoutes | List<TripRoute> (구간) |
passengerCount | paxTypeCount | PassengerCount |
languageCode | cultureCode | 기본 ko-KR |
currencyCode | currencyCode | 기본 KRW |
promotionCode | promotionCode | preference의 첫 프로모션 코드 |
TripRoute(request/TripRoute.kt):origin/destination/departureDate(YYYY-MM-DD). 주석에 “공항코드, 도시코드 X” 명시 — 반드시 공항 IATA여야 함.PassengerCount(request/PassengerCount.kt):adultCount/childCount/infantCount.
5.2 가격확정 요청 — TripSellRQ
tripInfos(List<TripInfo>) + paxTypeCount. TripInfo(request/TripInfo.kt)는 검색 응답에서 받은 journeyKey와 fareAvailabilityKey 를 다시 실어 “이 운임을 팔겠다”고 지정한다. 이 두 키가 검색→가격확정을 잇는 상태 전달 매개체다.
5.3 예약생성 요청 — CreateBookRQ
CreateBookRQ
├─ contacts: List<Contact> // 대표 연락처 1건만 (ADT 중 첫 승객)
├─ address: Address? // 체류지. ★문서엔 Array지만 단건만 허용
├─ passengerInfos: List<PassengerInfo> // 유아 제외, 유아는 부모의 infantInfo로 중첩
└─ cultureCode: "ko-KR"
전문 모델 주석에 박힌 운영 함정들
CreateBookRQ.address(L15): “체류지 정보는 문서에는 Array로 되어있지만 Array로 보내면 에러, 하나만 보내도 전체 승객 적용”.PassengerInfo.mobile(L69-70): 대표 예약번호가 들어간 탑승객에 mobile을 또 넣으면 Contact 중복 에러 발생 →isPrimaryContactPassenger면 null 처리.Contact.phones(Contact.kt:27):MOBILE타입을 넣어도 입력 안 됨 → 그래도HOME+MOBILE두 건을 보낸다. 모두 jejuair-pitfalls에서 재정리.
PassengerInfo(request) 핵심 매핑:
| 코틀린 필드 | JSON 키 |
|---|---|
passengerSequenceKey | passengerKey |
passengerType | paxType |
name | name (Name 객체) |
dateOfBirth | dateOfBirth |
infantInfo | infantInfo (유아 중첩) |
emergencyContact | emergencyPhone |
mobile | phone |
documentInfo | documentInfo (DocumentInfo = 여권) |
DocumentInfo(request/DocumentInfo.kt)는 여권을 documentType="P"로 매핑, expirationDate는 YYYY-MM-DD.
5.4 결제 요청 — PaymentRQ
| 코틀린 필드 | JSON 키 | 비고 |
|---|---|---|
pnr | pnrNo | |
bookingKey | bookingKey | RetrieveRS에서 획득 |
paymentInfos | paymentInfos | List<PaymentInfo> |
productTypeCode | productTypeCode | RSV(예약 발권) / EXC(변경/재발행) / EMD(부가서비스) |
PaymentInfo(request/PaymentInfo.kt): payType 기본 "CA"(현금/계좌), netAmount(=amount), 선택적 creditCardInfo(3장의 RSA 암호화 대상). 발권은 ofIssue가 전체 운임 합산액으로, 재발행은 ofReissue가 balanceDue(차액)로 금액을 세팅한다(PaymentRQ.kt:26-50).
5.5 예약조회 응답 — RetrieveRS (가장 큰 모델)
검색을 제외한 대부분 오퍼레이션의 응답 data가 이 타입이다(예약/조회/변경확정/분리).
| 코틀린 필드 | JSON 키 | 의미 |
|---|---|---|
pnr | pnrNo | PNR |
bookingKey | bookingKey | 결제 시 필요 |
pnrStatus | pnrStatus | Closed/HoldCanceled 등 |
paidStatus | paidStatus | PaidInFull/OverPaid 등 |
scheduleChanged | untkStatus | 스케줄 변경 여부 |
createdAt | createdDate | ZonedDateTime (…Z) |
expirationAt | expirationDate | 발권시한 (LocalDateTime, +09:00 가정) |
balanceDue | balanceDue | 잔액(재발행 차액/환불 시 음수) |
journeys | journeys | List<RetrieveJourney> |
passengerInfos | passengerInfos | 승객 |
passengerFares | passengerFares | 승객별 운임 |
fopInfos / refundFopInfos | fopInfos/refundFopInfos | 결제수단 |
scheduleChangeInfos | untkInfomation | 스케줄 변경 상세(오타 그대로) |
RetrieveRS는 단순 DTO가 아니라 변환 로직을 품은 풍부한 모델이다: canceled(L71), flattenPassengers(L89), schedules(L102), toBooking()(L124)가 도메인 모델로 매핑한다. untkInfomation은 Jeju Air 측 철자 그대로 매핑한 것이니 오타로 고치면 안 된다.
5.6 검색 응답 — AvailabilityRS
AvailabilityRS
├─ tripInfos: List<TripInfo> // 구간별
│ └─ journeys: List<AvailabilityJourney> (journeyKey, origin, destination, stops, flightType, fares, segments)
│ ├─ fares: List<Fare> (fareAvailabilityKey, fareAmountTotal, fareSegments)
│ └─ segments → legs (operatingCarrier, flightNumber, terminal, equipmentType)
└─ currencyCode
AvailabilityJourney.flightType 코드값(주석, Journey.kt:25): 0:None 1:NonStop 2:Through 3:Direct 4:Connect 5:All. toFareItineraries()(L20)가 카테시안 곱·체류시간 검증(최소 2시간, L168) 등으로 FareItinerary 도메인 객체를 만든다.
6. 날짜·시간 파싱 — 커스텀 디시리얼라이저
Jeju Air의 timezone 처리는 필드마다 다르다
- 대부분의 일시 필드는
LocalDateTime(AvailabilityJourney.departureAt,RetrieveRS.expirationAt) → timezone 없는 현지시간. 어댑터가 공항 zoneId로 별도 UTC 변환한다(Journey.kt:39의toUTC(zoneId)).ZonedDateTime필드(createdDate,modifiedDate)는Z접미사를 가진 UTC다.FarePenaltyFeeInfo.createDate만 커스텀ZonedDateTimeDeserializer를 명시한다(response/FarePenaltyFeeInfo.kt:22).
// support/util/ZonedDateTimeDeserializer.kt
class ZonedDateTimeDeserializer : JsonDeserializer<ZonedDateTime>() {
override fun deserialize(...): ZonedDateTime {
val s = jsonParser.valueAsString
return if (s.endsWith("Z")) ZonedDateTime.parse(s, ISO_ZONED_DATE_TIME)
else ZonedDateTime.parse("${s}+09:00", ISO_ZONED_DATE_TIME) // Z 없으면 KST 보정
}
}Z가 없으면 강제로 +09:00(KST)을 붙인다. 이는 Jeju Air의 일부 페널티 시간 필드가 timezone 없이 KST로 오는 비일관성을 보정하기 위한 것이다. 이 디시리얼라이저는 FarePenaltyFeeInfo에만 적용되어 있다(전역 등록 아님). 다른 ZonedDateTime 필드는 Jackson 기본 파싱.
7. 비동기 — 클라이언트는 동기, 서비스만 fire-and-forget
인프라(client) 계층은 전부 동기 블로킹
JejuairClient의 모든 메서드는 OkHttpexecute()(ClientSupport.kt:167)를 쓰는 동기 블로킹 호출이다.suspend도 코루틴도 없다. “취소 코루틴 흔적”은 클라이언트가 아니라 application 서비스 계층에 있다:
JejuairBookingService.kt:53,59,JejuairTicketingService.kt:115,JejuairFareRuleService.kt:86,92:CoroutineScope(Dispatchers.IO).withLaunch { ... }— 본 응답과 무관한 부수효과(로깅/슬랙/캐시 등)를 별도 코루틴으로 발사(fire-and-forget).JejuairFlightSearchService.kt:51:withBlocking(Dispatchers.IO) { ... }— 검색은 블로킹 컨텍스트로 감싸 실행.
withLaunch/withBlocking은 공통 [async-coroutines|코루틴 확장]이고, 코루틴 예외는 공통 핸들러(error-handling)가 받는다. 이 노트(프로토콜) 범위에서는 전송 자체가 동기라는 점만 기억하면 된다. 자세한 취소 플로우는 jejuair-operations 참조.
8. 설정·시크릿
JejuairProperties(configuration/Properties.kt:616-655)는 supplier.jejuair 프리픽스로 바인딩된다:
supplier.jejuair
└─ channels: List<JejuairChannelProperties>
├─ channel: String // TRIPLE / YANOLJA / INTERPARK ...
└─ funnels: List<JejuairApiProperties>
├─ funnel, endpoint
├─ clientId, clientSecret // → x-client-id / x-client-secret 헤더
├─ channelCode // → x-channel-code
└─ agencyId // → agentId
- 시크릿은 AWS Secrets Manager에서 주입된다:
application-local.yml이 환경별로aws-secretsmanager:{env}/air-intl-adapter/jejuair를 import(application-local.yml:1-31). 정적jejuair.yml(resources/supplier/jejuair.yml)은 비어 있고(시크릿 import 트리거 역할만), 실제 값은 시크릿에서 온다. - 검색은 Resilience4j 서킷브레이커 인스턴스
jejuairSearch(application.yml:69-70,baseConfig: search)로 보호된다. 상세는 resilience-and-events. - RSA 공개키는 시크릿이 아니라
JejuairCipher.kt에 하드코딩되어 있다(3.3 참조) — 환경 무관 동일 키.
전체 설정/인프라 맥락은 configuration-and-infra 참조.
9. 정리 — 신입이 꼭 기억할 5가지
핵심 5
- 프로토콜은 REST + JSON. SOAP/NDC/XSD 없음. mockData 샘플도 없음(전문 출처 = data class).
- 응답은
JejuairResponse<T>단일 봉투. 성공은 HTTP가 아니라code == "0000"로 판단.- 세션 =
PssToken헤더 릴레이. 가격확정 응답 헤더에서 발급 → 이후 모든 호출 헤더에 재전송. 검색엔 토큰 없음.- 카드정보는
@Encrypt→ RSA 공개키 → Hex (Base64 아님). 복호화 불가.- JSON 키 ≠ 코틀린 필드명.
paxType,pnrNo,untkStatus,untkInfomation(오타 그대로) 등은@JsonProperty값으로만 확인.
자가 점검 퀴즈
Q1. 검색(getAvailability) 응답에는 PssToken이 없다. 그 이유를 코드 근거로 설명하라.
정답 보기
검색은
flightSearch(JejuairClient.kt:213)에서 커스텀 디시리얼라이저 없이.execute<JejuairResponse<AvailabilityRS>>()를 호출한다. PssToken을 봉투로 끌어올리는jejuairDeserializerOf(L653-670, 응답 헤더PssToken추출)를 검색만 쓰지 않는다. 세션은 운임을 선택해getTripSell(가격확정)을 호출하는 시점부터 시작되며, 그 응답 헤더에서 비로소 토큰이 발급되기 때문이다.
Q2.
CreditCardInfo.accountNumber를 로그로 찍었더니 긴 16진수 문자열이 나왔다. 무슨 일이 일어난 것인가?정답 보기
@Encrypt(cipher = JejuairCipher::class)어노테이션 때문. Jackson 직렬화 시CryptoSerializer(CryptoUtils.kt:51)가 가로채JejuairCipher.encrypt()로 RSA 공개키 암호화 후Hex.encodeHexString한 결과다(JejuairCipher.kt:29-33). Base64가 아니라 Hex이며, 공개키만 있어 복호화는 불가능(decrypt는@Deprecated+ throw)하다.
Q3. 예약생성 시 체류지(address)를 승객 수만큼 배열로 보냈더니 에러가 났다. 왜인가?
정답 보기
CreateBookRQ.address는 단건(Address?)이다. 코드 주석(CreateBookRQ.kt:15)에 명시: “체류지 정보는 문서에는 Array로 되어있지만 Array로 보내면 에러이며 하나만 보내도 전체 승객에 적용”. 그래서of()(L38)는 대표 승객의stayInfo하나만 매핑한다.
교차 참조
- jejuair-operations — 검색→가격확정→예약→발권→취소→재발행 오퍼레이션 상세
- jejuair-pitfalls — 전문/암호화/세션 관련 함정 모음
- interfaces-dtos — 어댑터 공통 인터페이스 DTO 계층
- jejuair-overview — Jeju Air 모듈 전체 개요
- error-handling · async-coroutines · resilience-and-events · configuration-and-infra — 공통 메커니즘