Jin Air — 프로토콜·전문
module-jinair pattern-soap-in-json api-rest config-and-infra
한 줄 요약
Jin Air(LJ)는 “LCC/REST”로 분류되지만, 실제 전송 계층은 IBS Software iRes(SI:
http://www.ibsplc.com/iRes/simpleTypes/)의 SOAP 메시지를 JSON 봉투(bodyXml) 안에 문자열로 감싸 HTTP POST하는 독특한 하이브리드 구조다. GDS처럼 stateful 세션은 없고(토큰 발급 단계 없음),x-api-key헤더 + 매 요청AgencyCode로 인증한다. FareRule·Seatmap만 SOAP 없이 순수 REST JSON을 쓴다.
관련 노트: jinair-operations · jinair-pitfalls · interfaces-dtos · jinair-overview
1. 프로토콜 전체 그림 — “SOAP-in-JSON” 게이트웨이
Jin Air의 가장 중요한 특징은 단일 REST 엔드포인트가 아니라 두 가지 직렬화 방식이 한 어댑터 안에 공존한다는 점이다. 둘을 가르는 기준은 요청 래퍼 클래스(JinairXmlBodyRequest vs JinairJsonBodyRequest)이며, 이는 infrastructure/request/JinairRequest.kt에 정의돼 있다.
// JinairRequest.kt:6-10
private interface JinairRequest<T> {
val officeId: String // = AgencyCode
val service: String // iRes 오퍼레이션명 (예: "getAirAvailability")
val body: T?
}전송되는 바깥 봉투는 항상 JSON이다. 차이는 body 필드(@JsonProperty("bodyXml"))가 어떻게 직렬화되느냐다.
| 래퍼 클래스 | body 직렬화 | 적용 오퍼레이션 | 정의 위치 |
|---|---|---|---|
JinairXmlBodyRequest<T> | @SoapBody → SOAP Envelope XML 문자열 | 검색/예약/발권/취소/조회/분리/수하물/대리점크레딧 | JinairRequest.kt:12-33 |
JinairJsonBodyRequest<T> | @JsonBody(요청은 일반 JSON) | FareRule, Seatmap | JinairRequest.kt:35-45 |
flowchart TD A["Adapter"] -->|"POST endpoint/availability"| G["iRes 진에어 게이트웨이"] A --> H["요청 헤더<br/>Content-Type application/json<br/>x-api-key ****"] A --> B["요청 바디 JSON 봉투<br/>officeId AgencyCode<br/>service getAirAvailability<br/>bodyXml SOAP Envelope XML 문자열"] B --> X["bodyXml 안에 SOAP-ENV Envelope / Body가 들어가고<br/>그 안에 sim AirAvailabilityRQ<br/>xmlns sim http www.ibsplc.com iRes simpleTypes<br/>XML이 통째로 문자열로 인코딩됨"]
- 바깥 봉투는 항상 JSON,
bodyXml필드 값으로 SOAP Envelope XML 전체가 이스케이프된 문자열로 들어감. - 헤더:
Content-Type: application/json,x-api-key: ****. - SOAP 본문 루트:
sim:AirAvailabilityRQ(xmlns:sim="http://www.ibsplc.com/iRes/simpleTypes/").
service값은 body 타입으로 자동 결정된다
JinairXmlBodyRequest.service는when (body) { is AirAvailabilityRQ -> "getAirAvailability" ... }의 분기로 산출된다(JinairRequest.kt:18-32). 즉 요청 DTO 타입 하나가 iRes 오퍼레이션명·엔드포인트·직렬화 방식을 모두 결정한다. 매핑이 안 되는 타입은IllegalArgumentException("Unsupported request body type").
service ↔ 엔드포인트 ↔ 어댑터 메서드 대응표
JinairClient.kt의 각 메서드는 "${endpoint}/{path}"로 POST한다. (모든 SOAP 계열은 Content-Type: application/json + x-api-key)
| 어댑터 메서드 | HTTP 경로 | service | 요청/응답 DTO | 직렬화 |
|---|---|---|---|---|
search | /availability | getAirAvailability | AirAvailabilityRQ / AirAvailabilityRS | SOAP-in-JSON |
reissueSearch | /availability | getEnhancedAirAvailability | EnhancedAirAvailabilityRQ / EnhancedAirAvailabilityRS | SOAP-in-JSON |
doPricing | /price | confirmPrice | ConfirmPriceRQ / ConfirmPriceRS | SOAP-in-JSON |
markSeat | /reservation | adjustFlightInventory | AdjustFlightInventoryRQ / AdjustFlightInventoryRS | SOAP-in-JSON |
createBooking | /reservation | saveCreateBooking | CreateBookingRQ / CreateBookingRS | SOAP-in-JSON |
confirmPrice(예약 후 운임확정) / reissueConfirmPrice | /reservation | modifyBooking | ModifyBookingRQ / ModifyBookingRS | SOAP-in-JSON |
issue / cancelBooking / changeApis / reissue | /reservation | saveModifyBooking | SaveModifyBookingRQ / SaveModifyBookingRS | SOAP-in-JSON |
retrieve | /reservation | retrieveBooking | RetrieveBookingRQ / RetrieveBookingRS | SOAP-in-JSON |
getCancelInfo | /reservation | cancelBooking | CancelBookingRQ / CancelBookingRS | SOAP-in-JSON |
divide | /reservation | splitReservation | SplitPnrRQ / SplitPnrRS | SOAP-in-JSON |
searchBaggageAvail / searchBaggage | /ancillary | listBaggageServices | ListBaggageServicesRQ / ListBaggageServicesRS | SOAP-in-JSON |
getAgencyCredit | /reservation | retrieveAgencyCredit | RetrieveAgencyCreditRQ / RetrieveAgencyCreditRS | SOAP-in-JSON |
getFareRule | /information | fareRegulationData | List<FareRuleRQ> / List<FareRuleRS> | 순수 JSON |
searchSeatmap | /seatmap | showSeatMap | ShowSeatmapRQ / ShowSeatmapRS | 순수 JSON |
registerDsr | {dsr.endpoint}/ota/dsrMake | (없음) | form 파라미터 | form-urlencoded |
getTicketingErrorMessage | {payment.endpoint}/rest/trans/authFailRs | (없음) | GET 쿼리 logUid | REST GET |
/reservation경로 하나에 6개 오퍼레이션이 몰려 있다
createBooking·confirmPrice·issue·retrieve·getCancelInfo·cancelBooking·changeApis·divide·reissue·getAgencyCredit가 모두/reservation을 친다. 어떤 동작인지는 경로가 아니라service값(=body 타입) 으로만 구분된다. 디버깅 시 경로만 보고 오퍼레이션을 추정하면 안 된다. → jinair-pitfalls
2. SOAP 봉투 직렬화 메커니즘 (support/util/SoapExtensions.kt)
bodyXml 필드에 붙은 @SoapBody(SoapExtensions.kt:192-197)가 Jackson 커스텀 (역)직렬화기를 트리거한다.
직렬화(요청 보낼 때, SoapBodySerializer, SoapExtensions.kt:215-232)
- 스프링 컨텍스트에서
xmlMapper빈을 꺼낸다(BeanUtils.getBean("xmlMapper")). - 요청 DTO를
xmlMapper로 XML 바이트로 변환(INDENT_OUTPUT비활성 → 한 줄). soap { body(...) }헬퍼로 그 XML을 SOAPEnvelope/Body안에 끼워넣어 완성된 SOAP 문자열을 만든다.- 그 문자열 전체를 JSON 필드 값으로
gen.writeString(...)한다.
역직렬화(응답 받을 때, SoapBodyDeserializer, SoapExtensions.kt:199-213)
bodyXmlJSON 문자열을 다시 SOAP 메시지로 파싱.soapBody.firstChild(= 실제*RS루트 엘리먼트)만 떼어내xmlMapper.readValue(..., genericType)로 응답 DTO로 매핑.
// SoapBodyDeserializer.deserialize (SoapExtensions.kt:206-212)
val soapMessage = MessageFactory.newInstance()
.createMessage(MimeHeaders(), parser.valueAsString.byteInputStream())
return xmlMapper.readValue(soapMessage.soapBody.firstChild.asString().byteInputStream(), genericType)왜 SOAP을 직접 안 쓰고 JSON으로 감쌌나
응답 봉투(
JinairXmlBodyResponse,JinairResponse.kt:40-51)에는errorMessage/errorType/stackTrace/officeId/service/CLIENT_SESSION_ID같은 게이트웨이 메타데이터가 JSON으로 함께 온다. 즉 진에어 측이 IBS iRes의 SOAP API 앞에 JSON 게이트웨이를 한 겹 올려둔 형태이며, 어댑터는 이 게이트웨이 스펙(officeId/service/bodyXml)에 맞춘 것이다. 어댑터가 직접 SOAP 엔드포인트를 호출하는 것이 아니다.
응답 봉투(JinairResponse) 공통 필드
infrastructure/response/JinairResponse.kt
| 필드 | JSON 키 | 의미 |
|---|---|---|
errorMessage | errorMessage | 게이트웨이 레벨 오류 메시지("timeout" 포함 시 타임아웃 판정) |
errorType | errorType | "Error"면 게이트웨이 레벨 실패 |
stackTrace | stackTrace | 게이트웨이 스택트레이스 |
officeId / service | 동일 | 에코백된 AgencyCode·오퍼레이션명 |
clientSessionId | CLIENT_SESSION_ID | 결제 실패 메시지 재조회 키 (대문자 키 주의) |
body | bodyXml | @SoapBody/@JsonBody로 디코딩된 실제 응답 DTO |
// JinairResponse.isTimeoutError (JinairResponse.kt:19-20)
val isTimeoutError: Boolean
get() = errorMessage?.lowercase()?.contains("timeout") ?: false2단계 오류 체크 구조
오류는 봉투 레벨(
errorType == "Error")과 body 레벨(AirAvailabilityRS.errorType: ErrorType,ConfirmPriceRS.errorType등) 두 군데에서 난다.JinairResponse.checkError(bodyErrorType, callback)(JinairResponse.kt:22-37)가 먼저 봉투errorType=="Error"를 검사(단"Please check departure/arrival airport code"는 무시)한 뒤, body의ErrorType{errorCode, errorValue}를 콜백으로 넘긴다. 오류코드 해석은 jinair-operations·jinair-pitfalls 참조.
3. 인증·세션 모델
stateful 세션 없음 (GDS와 결정적 차이)
Amadeus/Sabre/Galileo 같은 GDS와 달리 진에어는 로그인/토큰 발급/세션 시작-종료 단계가 없다. 매 요청이 독립적이며, 인증은 다음 둘로 끝난다.
| 인증 요소 | 위치 | 비고 |
|---|---|---|
x-api-key 헤더 | 모든 SOAP/JSON 요청의 .header(...) | 값 = apiProperties.key (JinairApiProperties.key) |
AgencyCode / officeId | 봉투 officeId + 각 RQ 본문 AgencyCode | 값 = apiProperties.agencyCode. funnel별로 다른 대리점 코드 |
DSR API_KEY 헤더 | registerDsr | 별도 엔드포인트(dsr.endpoint)·별도 키(dsr.key) |
| 결제 키 (인증서) | getTicketingErrorMessage 등 | payment.endpoint/payment.key(X.509 인증서 파일명) |
채널/퍼널 분기는 MDC 기반이다.
// Properties.kt:229-239 JinairProperties.getApiProperties
fun getApiProperties(
salesChannel: String = MDCHolder.SalesChannel.get(),
salesFunnel: String = MDCHolder.SalesFunnel.get(),
): JinairApiProperties =
channels.find { it.channel == salesChannel }?.get(funnel = salesFunnel)
?: throw InternationalAdapterException(NOT_SUPPORTED_SALES_CHANNEL, salesChannel)// Properties.kt:408-419 funnel별 설정 단위
data class JinairApiProperties(
override val funnel: String,
val endpoint: String,
val agencyCode: String,
val key: String, // x-api-key
val payment: JinairPaymentProperties, // endpoint, key(X.509 cert 파일명)
val dsr: JinairDsrProperties, // endpoint, key
) : FunnelProperties설정은 application.yml이 classpath:supplier/jinair.yml을 import하며, prefix는 supplier.jinair(@ConfigurationProperties(prefix = "supplier.jinair"), Properties.kt:193). 자세한 구성은 configuration-and-infra 참조.
결제 카드 암호화 — JinairCipher (RSA+AES 하이브리드)
발권/재발권 시 카드 정보는 평문으로 보내지 않는다. GuestPaymentInfo(SaveModifyBookingRQ 내부)의 카드 필드에 @Encrypt(cipher = JinairCipher::class)가 붙어 직렬화 직전에 암호화된다.
// SaveModifyBookingRQ.kt (GuestPaymentInfo)
@Encrypt(cipher = JinairCipher::class)
@JacksonXmlProperty(localName = "paymentTypeNumber") val paymentTypeNumber: String? // 카드번호
@Encrypt(cipher = JinairCipher::class)
@JacksonXmlProperty(localName = "ExpirationMonth") val expirationMonth: String?
@Encrypt(cipher = JinairCipher::class)
@JacksonXmlProperty(localName = "ExpirationYear") val expirationYear: String?
@Encrypt(cipher = JinairCipher::class)
@JacksonXmlProperty(localName = "cvv2Number") val cvv2Number: String? // 항상 "999" 고정
@Encrypt(cipher = JinairCipher::class)
@JacksonXmlProperty(localName = "CardHolderName") val cardHolderName: String?암호화 알고리즘(supplier/jinair/support/util/JinairCipher.kt):
flowchart TD N["1. nonce 40자 랜덤 0-9a-z<br/>generateNonce"] --> EN["2. encryptedNonce 는 RSA public nonce<br/>ECB PKCS1Padding funnel별 X.509 인증서"] N --> SK["3. secretKey 는 SHA-256 nonce 앞 16자<br/>AES-128 key, generateSecretKey"] SK --> ET["4. encryptedText 는 AES secretKey 평문"] ET --> FIN["5. 최종값 은 encryptedText 더하기 DELIMITER 더하기 encryptedNonce"] EN --> FIN
- 최종값 구분자 DELIMITER 문자열:
%~~`%~~~~~~~%^**(%$#% encryptedNonce는 RSA(ECB/PKCS1Padding, funnel별 X.509 인증서),secretKey는 SHA-256(nonce)의 앞 16바이트로 만든 AES-128 키.
JinairSecureKey(@Component)가 부팅 시 classpath:/supplier/jinair/{payment.key} X.509 인증서를 읽어 funnel별 publicKeyMap을 만든다. decrypt는 의도적으로 미지원(@Deprecated, 예외 throw) — 단방향 전송 전용.
cvv2Number는 항상 "999" 하드코딩
GuestPaymentInfo.ofCard에서cvv2Number = "999"로 고정한다(SaveModifyBookingRQ.kt). 실 CVV를 보내지 않는 진에어 게이트웨이 규약이며, 추가 카드 정보(AdditionalCreditCardInfo)로 보완한다. 결제 실패 시clientSessionId로getTicketingErrorMessage를 비동기 호출해 원인 메시지를 로깅한다. → jinair-pitfalls
4. 요청 전문 모델 — 핵심 필드 매핑
4-1. 검색 AirAvailabilityRQ (request/AirAvailabilityRQ.kt)
@JacksonXmlRootElement(localName = "sim:AirAvailabilityRQ") + xmlns:sim = http://www.ibsplc.com/iRes/simpleTypes/
| XML 엘리먼트 | Kotlin 필드 | 값/규칙 |
|---|---|---|
AirlineCode | airlineCode(private) | 고정 "LJ" |
FareLevels | fareLevels(private) | 고정 "IS" |
PointOfPurchase | pointOfPurchase(private) | 고정 "KR" |
BookingChannel | bookingChannel | ChannelType=API, Channel=OII, Locale=ko_KR (BookingChannel.kt) |
AvailabilitySearches | availabilitySearches[] | Origin/Destination/TravelDate(yyyy-MM-dd) |
PaxCountDetails | paxCountDetails[] | PaxType/PaxCount (count>0만 생성, PaxCountDetail.kt) |
TripType | tripType | OW/RT/MC (origin-dest 개수·왕복 판정) |
TravelAgencyCode | travelAgencyCode | 프로모션 코드 있을 때만 AgencyCode 세팅(AirAvailabilityRQ.kt:79) |
@JacksonXmlElementWrapper(useWrapping = false)의 의미iRes는
<AvailabilitySearches>를 래퍼 없이 반복 엘리먼트로 평탄하게 기대한다.useWrapping=false가 없으면<availabilitySearches><AvailabilitySearch>...처럼 한 겹 더 감싸져 스키마 위반이 된다. 진에어 RQ/RS DTO 전반에서 이 패턴이 반복된다.
4-2. 운임확정 ConfirmPriceRQ / 좌석마킹 AdjustFlightInventoryRQ
둘 다 sim: 루트 + AirlineCode="LJ". AdjustFlightInventoryRQ의 성공 응답에서 pnrSessionId를 받아(AdjustFlightInventoryRS.pnrSessionId) CreateBookingRQ.PnrSessionId로 넘기는 세션 ID 릴레이가 예약 흐름의 핵심이다(상세 흐름 jinair-operations).
4-3. 예약 CreateBookingRQ (request/CreateBookingRQ.kt)
@JacksonXmlRootElement(localName = "sim:CreateBookingRQ"). 고정 상수: AirlineCode="LJ", PointOfSale="KR", PnrType="NORMAL", pnrOnHoldIndicator=true.
| 엘리먼트 | 필드 | 비고 |
|---|---|---|
AgencyCode/OriginalAgentID/CurrentAgentID | 모두 agencyCode 동일값 | |
BookerDetails | bookerDetail | 대표 성인 GivenName/SurName |
PnrSessionId | pnrSessionId | markSeat 응답에서 받은 세션 |
PaxCountDetails/ItineraryDetails/FareInfo/GuestDetails/TravelDocuments | 승객·여정·운임·여권 | |
PnrContact | pnrContact | CellNumberCountryCode="+82", PrefferedLanguage="Korean" 고정 |
필드명 오타가 스펙이다
iRes 스펙 자체의 철자 오류를 그대로 따라야 한다:
IsPrefferedContact,PrefferedLanguage(Address/PnrContact),RecieptNumber(GuestPaymentInfo). 이를 “고치면” 전문이 매핑되지 않는다. → jinair-pitfalls
4-4. 발권/취소/APIS/재발권 — SaveModifyBookingRQ (다목적, request/SaveModifyBookingRQ.kt)
하나의 DTO를 5개 팩토리로 재사용한다. service는 모두 saveModifyBooking이며, 채워지는 필드 조합으로 동작이 갈린다.
| 팩토리 | 용도 | 핵심 채움 필드 |
|---|---|---|
ofTicketing | 발권 | GuestPaymentInfo[](카드/현금) |
ofCancel | 취소 | isCancelPnr=true |
ofApis | 여권/APIS 변경 | TravelDocumentChangeType[] |
ofReissue | 재발행 | GuestPaymentInfo[] + 일정변경 |
4-5. FareRule FareRuleRQ — 순수 JSON (request/FareRuleRQ.kt)
여기만 @JacksonXmlProperty가 없고 @JsonProperty(camelCase·iRes 약어 키)를 쓴다. → JSON 직렬화 확정.
| JSON 키 | 필드 | 값 |
|---|---|---|
fareTypeCdList | fareTypes | 운임타입 리스트 |
itnTypCd | tripDirection | OW/RT |
rbd | bookingClass | 예약클래스 |
departureAirportCd/arrivalAirportCd | 공항 | |
departureDt | departureDate | yyyyMMdd |
currencyCd | currency | 기본 "KRW" |
langCd | languageCode | 고정 "KOR" |
FareRule은 List를 통째로 보낸다
getFareRule은requests.wrapJsonBody(...)로List<FareRuleRQ>를 보내고,JinairJsonBodyRequest.service는body is List<*> && body.firstOrNull() is FareRuleRQ → "fareRegulationData"로 판정한다(JinairRequest.kt:41). 응답도List<FareRuleRS>(@JsonBody).
5. 응답 전문 모델 — 핵심 매핑
5-1. 검색 AirAvailabilityRS (response/AirAvailabilityRS.kt)
iRes 검색 응답은 OriginDestinationInfo → TripInfo(여정/세그먼트) + PricingInfo(운임) 가 분리된 정규화 구조다. 둘은 인덱스로 연결된다(TripIndex == TripRefIndex, SegmentIndex == SegmentRefIndex, BookingClass 일치).
flowchart TD Root["AirAvailabilityRS"] --> Err["ErrorType errorCode, errorValue<br/>body 레벨 오류"] Root --> ODI["OriginDestinationInfo 배열<br/>attr Origin Destination PricingUnitID"] ODI --> Trip["TripInfo 배열<br/>attr TripIndex, Route"] Trip --> Seg["SegmentInfo 배열<br/>attr SegmentIndex, Stops, JourneyTime, DayChange"] Seg --> FID["FlightIdentifierInfo<br/>CarrierCode, FlightNumber, FlightSuffix"] Seg --> DA["DepartureInfo ArrivalInfo<br/>AirportCode, DateTime, TimeZoneOffset"] Seg --> AC["AircraftInfo Type"] Seg --> SA["SegmentAvailability 배열<br/>BookingClass, SeatAvailablity, InventoryStatus, CabinClass"] ODI --> PI["PricingInfo 배열<br/>attr TripRefIndex"] PI --> PPI["PaxPricingInfo 배열<br/>PaxType, AppliedFare Tax Surcharge DisplayFare Discount"] PI --> PCI["PricingComponentInfo<br/>FareLevel, FareBasis, FareType, FareId"] PI --> SRI["SegmentReferenceInfo 배열<br/>SegmentRefIndex, BookingClass"]
- TripInfo와 PricingInfo는 인덱스로 연결된다:
TripIndex == TripRefIndex,SegmentIndex == SegmentRefIndex,BookingClass일치. SeatAvailablity는 iRes 스펙의 오타를 그대로 따른 필드명이다.
좌석 매칭은
isAvailable()로 판정
SegmentAvailability.isAvailable(seatCount)=inventoryStatus == "AV" && seatAvailability >= seatCount(AirAvailabilityRS.kt:108-110). 또SeatAvailablity(오타 그대로),avail = min(9, seatAvailability)로 9석 캡을 건다. 운임 선택은 cabin별 그룹에서minByOrNull { adultPrice }(JinairClient.kt:182-194).
GUM 노선 무료 수하물 하드코딩
SegmentInfo.toSegment에서 출발/도착 한쪽이GUM이면 무료 수하물 23kg, 아니면 15kg로 고정(AirAvailabilityRS.kt:195). 스키마가 아니라 코드가 들고 있는 비즈니스 규칙이다.
5-2. 발권/조회 응답 → 결제 매핑 GuestPaymentInfo(response)
응답측 GuestPaymentInfo(response/GuestPaymentInfo.kt)는 PaymentElementType으로 FARE/FEE를 구분하고 toPayment()로 도메인 Payment(승인번호 AuthorisationCode, 승인시각 TransactionTime)로 변환한다. 영국식 철자 AuthorisationCode에 주의.
5-3. FareRule FareRuleRS (response/FareRuleRS.kt)
JSON 응답. iRes 약어 키 → 한국어 운임규정 텍스트로 가공한다.
| JSON 키 | 의미 |
|---|---|
changeData/refundData/noshowData | 변경/환불/노쇼 안내 문자열 배열 |
feeItemList | 위약금 상세(FeeItem: mark/feeAmount/적용 시작·종료일) |
advancePurchase+advancePurchaseCd | 사전구매조건(M=월,D=일) → purchasePeriod |
nabiInfo | ”가이드에 없는 필드” 진에어(나비)포인트 — 코드 주석 명시 |
result | null=규정 매칭, "-"=매칭 없음 |
toFareRules()가 사전발권·변경·취소/환불·노쇼·마일리지·여행일정순위·공통규정을 FareRule로 조립하며, 일부 약관 문구는 코드에서 replace로 제거/치환한다.
6. XSD 스키마 위치와 핵심 타입
원본 IBS iRes XSD가 테스트 리소스에 동봉돼 있다(총 369개 .xsd).
- 위치:
/workspace/nol/nol-triple/air-intl-adapter/src/test/schema/jinair/ - 공통 타입:
SimpleTypes.xsd,CommonTypes.xsd(<xs:include>로 참조) - targetNamespace:
http://www.ibsplc.com/iRes/simpleTypes/(= 코드의sim:네임스페이스와 일치)
어댑터가 실제 사용하는 오퍼레이션의 RQ/RS XSD:
| 오퍼레이션 | XSD 파일 |
|---|---|
| 검색 | AirAvailabilityRQ.xsd / AirAvailabilityRS.xsd |
| 재발행 검색 | EnhancedAirAvailabilityRQ.xsd / EnhancedAirAvailabilityRS.xsd |
| 운임확정 | ConfirmPriceRQ.xsd / ConfirmPriceRS.xsd |
| 좌석마킹 | AdjustFlightInventoryRQ.xsd / AdjustFlightInventoryRS.xsd |
| 예약 | CreateBookingRQ.xsd / CreateBookingRS.xsd |
| 운임변경 | ModifyBookingRQ.xsd / ModifyBookingRS.xsd |
| 발권/취소/변경 | SaveModifyBookingRQ.xsd / SaveModifyBookingRS.xsd |
| 조회 | RetrieveBookingRQ.xsd / RetrieveBookingRS.xsd |
| 취소정보 | CancelBookingRQ.xsd / CancelBookingRS.xsd |
| PNR 분리 | SplitPnrRQ.xsd / SplitPnrRS.xsd |
| 수하물 | ListBaggageServicesRQ.xsd / ListBaggageServicesRS.xsd |
| 좌석맵 | ShowSeatMapRQ.xsd / ShowSeatMapRS.xsd |
| 대리점크레딧 | RetrieveAgencyCreditRQ.xsd / RetrieveAgencyCreditRS.xsd |
<!-- AirAvailabilityRQ.xsd 발췌 — 코드의 고정값과 정확히 대응 -->
<xs:schema targetNamespace="http://www.ibsplc.com/iRes/simpleTypes/"
xmlns:urn="http://www.ibsplc.com/iRes/simpleTypes/" ...>
<xs:include schemaLocation="SimpleTypes.xsd"/>
<xs:include schemaLocation="CommonTypes.xsd"/>
<xs:element name="AirAvailabilityRQ">
<xs:complexType><xs:sequence>
<xs:element name="AirlineCode" type="xs:string" nillable="false"/> <!-- Max Length: 6 -->
<xs:element name="AvailabilitySearches" type="urn:NearBySearchType" maxOccurs="unbounded"/>
<xs:element name="PaxCountDetails" type="urn:PaxCountType" maxOccurs="unbounded"/>
<xs:element name="TripType" type="urn:TripType"/>
...XSD는 매핑 검증의 1차 사료
코드 필드명이 의심스러우면 동일 이름의
*RQ.xsd/*RS.xsd에서 엘리먼트명·maxOccurs(배열 여부)·타입을 확인하면 된다.CreateBookingMigRQ.xsd·RetrieveBookingArcRQ.xsd처럼 어댑터가 안 쓰는 변형 스키마도 함께 들어있으니 어댑터 DTO가 실제 쓰는 오퍼레이션(5절 표)만 신뢰하라.
7. 실제 전문 예시
mockData 없음
src/test/resources/mockData/jinair/디렉터리 및 진에어용 샘플 XML/JSON 전문은 저장소에 존재하지 않는다. 따라서 실 응답 캡처 대신, 위 DTO 어노테이션으로부터 재구성한 형태로 예시를 제시한다(필드명·고정값은 코드 검증 기반).
요청(개념도, search 기준):
{
"officeId": "<AgencyCode>",
"service": "getAirAvailability",
"bodyXml": "<SOAP-ENV:Envelope xmlns:SOAP-ENV=\"http://schemas.xmlsoap.org/soap/envelope/\"><SOAP-ENV:Body><sim:AirAvailabilityRQ xmlns:sim=\"http://www.ibsplc.com/iRes/simpleTypes/\"><AirlineCode>LJ</AirlineCode><AvailabilitySearches><Origin>ICN</Origin><Destination>NRT</Destination><TravelDate>2026-06-10</TravelDate></AvailabilitySearches><PaxCountDetails><PaxType>ADT</PaxType><PaxCount>1</PaxCount></PaxCountDetails><TripType>OW</TripType><FareLevels>IS</FareLevels><PointOfPurchase>KR</PointOfPurchase><BookingChannel><ChannelType>API</ChannelType><Channel>OII</Channel><Locale>ko_KR</Locale></BookingChannel></sim:AirAvailabilityRQ></SOAP-ENV:Body></SOAP-ENV:Envelope>"
}응답(개념도):
{
"errorMessage": null,
"errorType": null,
"service": "getAirAvailability",
"CLIENT_SESSION_ID": "....",
"bodyXml": "<SOAP-ENV:Envelope ...><SOAP-ENV:Body><AirAvailabilityRS>...<OriginDestinationInfo Origin=\"ICN\" Destination=\"NRT\" PricingUnitID=\"1\">...</OriginDestinationInfo></AirAvailabilityRS></SOAP-ENV:Body></SOAP-ENV:Envelope>"
}FareRule(순수 JSON 봉투):
{ "officeId": "<AgencyCode>", "service": "fareRegulationData",
"bodyXml": [ { "fareTypeCdList": ["..."], "itnTypCd": "OW", "rbd": "Y",
"departureAirportCd": "ICN", "arrivalAirportCd": "NRT",
"departureDt": "20260610", "currencyCd": "KRW", "langCd": "KOR" } ] }8. 타임아웃·예외 전파(전송 계층 관점)
- 타임아웃:
JinairClient은ClientSupport(searchTimeout = 15000, defaultTimeout = 40000). 검색만 별도searchClient(JinairClient.kt:97). - 발권/재발권 타임아웃 복구: 응답이
isTimeoutError면 즉시 실패 처리하지 않고retrieve(pnr)로 PNR을 재조회해 실제 발권 여부를 확인한다(JinairClient.kt:478-480,1010-1012). 전송 실패가 곧 비즈니스 실패가 아니라는 LCC 결제 특유의 멱등성 처리. - 취소 계열은
@Retryable(maxAttempts=3/2, Backoff 5s, exceptionExpression="@jinairClient.shouldCancelRetryable(#root)")—ApiException.retryable가 true일 때만 재시도(BKG_CONCURRECY_01등 잠금 충돌). 메시지큐가 아닌 예외 전파 + Spring Retry가 상태 회복 메커니즘이다. → resilience-and-events
9. 다른 공급사와의 비교 (전송 관점)
| 구분 | Jin Air | T’way(tway-protocol) | 순수 GDS(Amadeus/Sabre/Galileo) |
|---|---|---|---|
| 분류 | LCC/REST | LCC/REST | GDS/SOAP |
| 실제 전송 | SOAP-in-JSON(bodyXml) + 일부 순수 JSON | REST + SEED 암호화 인증 | 직접 SOAP Envelope |
| 세션 | 없음(stateless) | 없음 | stateful 세션/토큰 |
| 인증 | x-api-key + AgencyCode | TwaySEED.jar SEED | EPR/PCC/WS-Security |
| 카드 암호화 | RSA(nonce)+AES(SHA-256 파생키) | SEED | (해당 없음) |
| 백엔드 플랫폼 | IBS Software iRes | 자체 | GDS |
더 보기
- Jin Air — 오퍼레이션 : 검색→마킹→예약→발권→재발행 흐름과 오류코드 처리
- Jin Air — 지뢰밭 : 철자 오타 스펙·CVV 999·세션 릴레이·타임아웃 멱등성
- 인터페이스·DTO : 컨트롤러 노출 구조
- 설정·인프라 :
supplier.jinairfunnel 설정 - 회복탄력성·이벤트 : Retry/예외 전파 기반 상태 회복