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-60String.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.0AvailabilityRQJejuairResponse<AvailabilityRS>
운임확정(가격)pricing / getTripSell (L454)POST /booking/getTripSell/v1.0TripSellRQJejuairResponse<RetrieveRS>
운임규정getFareRules (L252)POST /booking/getFareRule/v1.0FareRuleRQJejuairResponse<List<FareRuleRS>>
예약생성createBooking (L285)POST /booking/createBook/v1.0CreateBookRQJejuairResponse<RetrieveRS>
예약조회retrieve (L329)POST /reservation/retrievePNR/v1.0RetrieveRQJejuairResponse<RetrieveRS>
결제·발권paymentAndIssue/paymentAndReissue (L440)POST /payment/requestApprovalPay/v1.0PaymentRQJejuairResponse<PaymentRS>
취소수수료calculateCancelFee (L464)POST /reservation/cancel/searchCancelPenaltyFeeInfo/v1.0CancelFeeRQJejuairResponse<CancelFeeRS>
취소실행cancel (L518)POST /reservation/cancel/executeCancel/v1.0ExecuteCancelRQJejuairResponse<Any>
변경확정confirm (L544)POST /reservation/change/agreeUntkChange/v1.0AgreeUntkChargeRQJejuairResponse<RetrieveRS>
변경실행executeChange (L569)POST /reservation/change/executeChange/v1.0ExecuteChangeRQJejuairResponse<Any>
변경수수료calculateChange (L622)POST /reservation/change/searchChangePenaltyFeeInfo/v1.0ChangeFeeRQJejuairResponse<ChangeFeeRS>
PNR 분리divide (L599)POST /reservation/dividePNR/v1.0DividePnrRQJejuairResponse<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-idAPI 클라이언트 IDJejuairApiProperties.clientId
x-client-secretAPI 시크릿JejuairApiProperties.clientSecret
x-channel-code판매 채널 코드JejuairApiProperties.channelCode
agentId대리점 IDJejuairApiProperties.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:70Cipher.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 -> ... } 블록에서 코드별로 예외를 분기한다.

검색의 "정상적인 에러" 무시 처리

검색(search L83-96)은 일부 에러코드를 예외가 아니라 logger.warn으로만 처리하고 빈 리스트를 반환한다 — 검색이 “결과 없음”으로 끝나는 게 정상인 경우이기 때문.

코드의미
OTAUSV113구매 가능한 판매노선 구간이 아님
OTAUSV116현재일 기준 330일 이내 출발편만 예약 가능
SEEUSV002항공권 예매는 금일기준 330일 출발편까지 가능
SEEUSV003일자 확인 필요
SEEUSV004유효하지 않은 구간

4.2 주요 에러 코드 → 예외 매핑

코드발생 위치처리
0000전체성공
COMESV504pricing(L234), createBook(L307)StatusInvalidException(SOLD_OUT) — 매진
OTAUSV719calculateCancelFee(L479)StatusInvalidException(CANCEL_UNABLE)
OTAUSV900calculateCancelFee(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 → JSON paxType). 디버깅 시 와이어 상의 키는 반드시 @JsonProperty 값으로 확인할 것.

5.1 검색 요청 — AvailabilityRQ

코틀린 필드JSON 키비고
tripRoutestripRoutesList<TripRoute> (구간)
passengerCountpaxTypeCountPassengerCount
languageCodecultureCode기본 ko-KR
currencyCodecurrencyCode기본 KRW
promotionCodepromotionCodepreference의 첫 프로모션 코드
  • 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)는 검색 응답에서 받은 journeyKeyfareAvailabilityKey 를 다시 실어 “이 운임을 팔겠다”고 지정한다. 이 두 키가 검색→가격확정을 잇는 상태 전달 매개체다.

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 키
passengerSequenceKeypassengerKey
passengerTypepaxType
namename (Name 객체)
dateOfBirthdateOfBirth
infantInfoinfantInfo (유아 중첩)
emergencyContactemergencyPhone
mobilephone
documentInfodocumentInfo (DocumentInfo = 여권)

DocumentInfo(request/DocumentInfo.kt)는 여권을 documentType="P"로 매핑, expirationDateYYYY-MM-DD.

5.4 결제 요청 — PaymentRQ

코틀린 필드JSON 키비고
pnrpnrNo
bookingKeybookingKeyRetrieveRS에서 획득
paymentInfospaymentInfosList<PaymentInfo>
productTypeCodeproductTypeCodeRSV(예약 발권) / EXC(변경/재발행) / EMD(부가서비스)

PaymentInfo(request/PaymentInfo.kt): payType 기본 "CA"(현금/계좌), netAmount(=amount), 선택적 creditCardInfo(3장의 RSA 암호화 대상). 발권은 ofIssue가 전체 운임 합산액으로, 재발행은 ofReissuebalanceDue(차액)로 금액을 세팅한다(PaymentRQ.kt:26-50).

5.5 예약조회 응답 — RetrieveRS (가장 큰 모델)

검색을 제외한 대부분 오퍼레이션의 응답 data가 이 타입이다(예약/조회/변경확정/분리).

코틀린 필드JSON 키의미
pnrpnrNoPNR
bookingKeybookingKey결제 시 필요
pnrStatuspnrStatusClosed/HoldCanceled
paidStatuspaidStatusPaidInFull/OverPaid
scheduleChangeduntkStatus스케줄 변경 여부
createdAtcreatedDateZonedDateTime (…Z)
expirationAtexpirationDate발권시한 (LocalDateTime, +09:00 가정)
balanceDuebalanceDue잔액(재발행 차액/환불 시 음수)
journeysjourneysList<RetrieveJourney>
passengerInfospassengerInfos승객
passengerFarespassengerFares승객별 운임
fopInfos / refundFopInfosfopInfos/refundFopInfos결제수단
scheduleChangeInfosuntkInfomation스케줄 변경 상세(오타 그대로)

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:39toUTC(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의 모든 메서드는 OkHttp execute()(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

  1. 프로토콜은 REST + JSON. SOAP/NDC/XSD 없음. mockData 샘플도 없음(전문 출처 = data class).
  2. 응답은 JejuairResponse<T> 단일 봉투. 성공은 HTTP가 아니라 code == "0000"로 판단.
  3. 세션 = PssToken 헤더 릴레이. 가격확정 응답 헤더에서 발급 → 이후 모든 호출 헤더에 재전송. 검색엔 토큰 없음.
  4. 카드정보는 @Encrypt → RSA 공개키 → Hex (Base64 아님). 복호화 불가.
  5. JSON 키 ≠ 코틀린 필드명. paxType, pnrNo, untkStatus, untkInfomation(오타 그대로) 등은 @JsonProperty 값으로만 확인.

자가 점검 퀴즈

Q1. 검색(getAvailability) 응답에는 PssToken이 없다. 그 이유를 코드 근거로 설명하라.

Q2. CreditCardInfo.accountNumber를 로그로 찍었더니 긴 16진수 문자열이 나왔다. 무슨 일이 일어난 것인가?

Q3. 예약생성 시 체류지(address)를 승객 수만큼 배열로 보냈더니 에러가 났다. 왜인가?


교차 참조