도입부 맥락에서 “11개 공급사 모듈”이라 했지만 이건 Gradle 멀티모듈이 아니라 패키지 단위 모듈이다. settings.gradle.kts에는 rootProject.name 한 줄뿐이고 include(...)가 없다. 즉 11개 공급사는 전부 하나의 jar로 빌드되어 한 프로세스에서 함께 뜬다. 공급사 하나만 따로 배포/스케일링하는 건 구조상 불가능하다. (아키텍처의 “중앙 디스패처 없음”과 연결해서 이해할 것)
# JDK 21 (Amazon Corretto 권장 — 운영 이미지와 동일 벤더)java -version # openjdk 21.x 확인git clone <air-intl-adapter repo>cd air-intl-adapter
Gradle은 별도 설치 불필요하다. Wrapper(./gradlew)가 gradle-wrapper.properties:3에 명시된 8.11을 자동으로 내려받는다.
단계 3: AWS 자격증명 (가장 잘 막히는 곳)
local/dev 프로파일은 부팅 시 optional:aws-secretsmanager:dev/air-intl-adapter를 읽는다(application-local.yml:5-7). 그래서 dev 계정에 접근 가능한 AWS 자격증명이 로컬 환경(~/.aws/credentials 또는 SSO)에 있어야 공급사 키가 채워진다.
접두사 optional: 덕분에 Secrets Manager 호출이 실패해도 앱은 죽지 않는다. 다만 해당 비밀값에 의존하는 공급사 호출만 인증 실패한다.
즉, AWS 없이도 “기동은 된다”. 실제 공급사 검색/예약을 테스트하려면 자격증명 필요.
단계 4: 빌드
./gradlew build # 컴파일 + 테스트 + jar 생성./gradlew --exclude-task test build # 테스트 건너뛰고 빌드 (CI/Docker와 동일)
산출물: build/libs/air-intl-adapter.jar
단계 5: 실행
./gradlew localBootRun # 권장: local,my 프로파일 자동 적용# 또는 프로파일을 직접 지정./gradlew bootRun --args="--spring.profiles.active=dev,my"
localBootRun은 커스텀 태스크다(build.gradle.kts:117-125). 내부적으로 bootRun에 --spring.profiles.active=local,my 인자를 붙여 실행한다.
단계 6: 테스트 / 문서
./gradlew test # 전체 테스트./gradlew test --tests "com...amadeus.application.AmadeusTest" # 특정 테스트# Swagger UI: local/dev/qa 에서만 활성(springdoc.api-docs.enabled=true)# → http://localhost:8000/swagger-ui.html
my 프로파일은 직접 만들어야 한다 (레포에 없음)
local,my 또는 dev,my의 my는 개인 로컬 오버라이드 프로파일이다. .gitignore:38에 *-my.*가 있어서 application-my.yml은 git에 올라가지 않는다 → 레포 어디에도 없다. 본인 PC에 src/main/resources/application-my.yml을 직접 만들어 개인 설정(예: 특정 공급사 엔드포인트 모킹, 로컬 redis 주소 등)을 넣는 용도다. 파일이 없어도 동작한다 (Spring은 없는 프로파일 yml을 무시). 처음엔 만들 필요 없다.
3. build.gradle.kts 정밀 해부
3.1 버전 핀(extra 변수)
val sentryVersion: String by extra("6.30.0") // 에러 추적val awsVersion: String by extra("1.12.261") // aws-java-sdk-s3val resilience4jVersion: String by extra("2.2.0") // 서킷브레이커/리트라이/벌크헤드val redissonVersion: String by extra("3.39.0") // Redis 클라이언트val opentracingVersion: String by extra("0.33.0") // Datadog 연동val awsSecretsManager: String by extra("2.4.4") // Secrets Manager 부트스트랩val slackVersion: String by extra("1.44.2") // Slack 경보
build.gradle.kts:5-11. 라이브러리 버전을 상단에 모아두는 관용 패턴. 나머지(jackson, kotlin-coroutines 등)는 Spring Boot BOM이 버전을 관리한다.
spring-boot-starter-data-redis는 기본 클라이언트로 Lettuce(또는 Jedis)를 끌고 온다. 이 프로젝트는 Redisson을 쓰므로 충돌·중복을 막으려 두 클라이언트를 명시적으로 배제한다(build.gradle.kts:37-38). Redis 설정은 redisson/*.yml로 따로 관리한다.
Java 라이브러리(Spring 등)의 @Nullable/@NonNull 어노테이션을 Kotlin이 엄격하게 해석한다. 즉 Java API가 @NonNull을 선언했는데 null을 넘기면 컴파일 에러가 난다. NDC/SOAP의 방대한 자동생성 DTO를 다룰 때 이 옵션 때문에 플랫폼 타입이 더 까다로워진다. 컴파일 에러가 null 관련으로 났다면 이 옵션을 떠올릴 것.
3.4 기타
runtimeOnly netty-resolver-dns-native-macos:...:osx-aarch_64 (build.gradle.kts:98): Apple Silicon Mac 로컬 개발용 Netty DNS 네이티브 리졸버. 운영(리눅스)엔 영향 없음.
gradle.properties: kotlin.daemon.jvmargs=-Xmx2g — Kotlin 컴파일 데몬에 2GB 힙. 빌드가 OOM이면 이 값을 본다.
버전 미선언 → 아티팩트가 air-intl-adapter.jar로 떨어짐. Spring Boot의 bootJar는 프로젝트 version이 없으면 ${rootProject.name}.jar를 만들고, Dockerfile:30이 정확히 이 이름을 복사한다. rootProject.name을 바꾸면 Dockerfile도 같이 고쳐야 한다.
4. libs/TwaySEED.jar — 벤더 동봉 jar
T’way Air는 카드정보 등 민감 필드를 SEED(한국 표준 블록암호)로 암호화해 전송해야 한다. T’way가 제공한 jar를 libs/에 직접 넣고 implementation(files("libs/TwaySEED.jar"))로 참조한다(build.gradle.kts:67).
jar 내용(zipfile로 확인한 클래스):
com/twayair/security/seed/SeedUtil.class ← 코드에서 import 하는 진입점
com/twayair/security/seed/SEED_KISA.class ← KISA SEED 알고리즘 구현
com/twayair/security/seed/Base64.class ← 자체 Base64
SeedEncryptor.kt:3은 org.bouncycastle.jce.provider.BouncyCastleProvider를 쓴다. 그런데 BouncyCastle은 build.gradle.kts에 직접 선언돼 있지 않다 → wss4j/xmlsec 등의 전이 의존성으로 클래스패스에 들어온다. 이 암묵적 의존은 landmines에 기록해 둘 만한 깨지기 쉬운 지점이다.
libs/TwaySEED.jar는 빌드 컨텍스트에 반드시 포함돼야 한다
Dockerfile:11이 COPY libs ./libs로 jar를 컨테이너 빌드 스테이지에 복사한다. 이 줄이 없거나 .dockerignore로 libs가 제외되면 컴파일 단계에서 com.twayair.security.seed 미해결로 빌드 실패한다. 또한 이 jar는 Maven Central에 없으므로 레포에서 삭제하면 어디서도 다시 받을 수 없다.
5. 프로파일 체계: 누가 무엇을 켜고 끄나
5.1 베이스 application.yml (모든 프로파일 공통)
application.yml이 항상 먼저 로드된다.
포트 8000 (server.port, application.yml:6)
10개 공급사 yml을 spring.config.import로 합침 (application.yml:10-20) — amadeus/sabre/singaporeair/tway/lufthansa/jinair/galileo/groupair/koreanair/jejuair
spring.threads.virtual.enabled: true — 가상 스레드(Java 21 Loom) 사용
ErrorMvcAutoConfiguration 제외 → 커스텀 에러 처리 (error-handling)
Resilience4j search 서킷브레이커 기본설정 + 11개 인스턴스 정의 (application.yml:37-70)
Slack 채널/대리점 전화번호 기본값
groupairSearch는 import 목록엔 없지만 서킷브레이커엔 있다
application.yml:10-20의 공급사 import는 amadeusndc를 포함하지 않지만, Resilience4j 인스턴스에는 amadeusndcSearch와 groupairSearch가 모두 정의돼 있다(application.yml:51,65). amadeusndc 설정은 amadeus.yml에 합쳐져 있거나 코드에서 직접 다룬다는 신호. 설정 import 목록과 실제 활성 공급사 수(11)가 1:1로 안 맞을 수 있으니 주의.
application-local.yml:7과 application-dev.yml:7 모두 dev/air-intl-adapter를 가리킨다. 즉 로컬 개발자는 dev 계정의 공급사 비밀값을 그대로 쓴다. local과 dev의 실질 차이는 (1) 로깅 설정, (2) Slack 비활성(local), (3) city-api가 local은 https proxy를 본다는 점 정도다.
5.3 비밀값 주입 흐름 (AWS Secrets Manager)
flowchart TD
Boot["부팅 시 spring.config.import 가 순서대로 실행"]
PYml["application-{profile}.yml"]
SvcSecret["optional aws-secretsmanager env/air-intl-adapter<br/>서비스 공통 비밀"]
BaseYml["application.yml 이 import 한 supplier/{name}.yml"]
SupSecret["optional aws-secretsmanager env/air-intl-adapter/name<br/>공급사별 비밀"]
Inject["Secrets Manager의 JSON 키들이<br/>Spring Environment 프로퍼티로 평탄화되어 주입"]
Boot --> PYml
PYml --> SvcSecret
Boot --> BaseYml
BaseYml --> SupSecret
SvcSecret --> Inject
SupSecret --> Inject
Inject --> Bind["예: amadeus.officeId, tway.seedKey 등<br/>코드의 @Value 또는 @ConfigurationProperties 가 바인딩"]
공급사 yml은 4개 문서(---)로 나뉘어 프로파일별 시크릿 경로를 분기한다. 예: supplier/amadeus.yml:1-31은 dev,local / qa / staging / prod 각각에 <env>/air-intl-adapter/amadeus를 import.
전부 optional: 접두사 → Secrets Manager 미접속 시에도 부팅은 성공(해당 비밀만 비어있게 됨). configuration-and-infra에서 바인딩 대상 키를 더 다룬다.
인증서 파일은 git에 들어있다
Secrets Manager가 아니라 레포에 직접 있는 비밀성 파일도 있다: resources/supplier/jinair/aiRES_PYM_Cert.cer, payment.dev.cer. Jin Air 결제 인증서다. 민감 파일이 클래스패스로 패키징된다는 점을 인지할 것.
5.4 Redisson (Redis) 설정
redisson/redisson-<profile>.yml은 ElastiCache serverless 클러스터를 가리킨다.
rediss://(TLS) + sslEnableEndpointIdentification: true. 커넥션 풀 master/slave 각각 min 24 / max 64.
로컬에서도 실제 dev Redis(ElastiCache)에 붙는다
redisson-local.yml은 로컬 Redis가 아니라 dev ElastiCache 클러스터를 본다. 따라서 로컬 기동 시 dev 네트워크(VPN/프록시) 접근 권한이 없으면 Redis 연결이 실패할 수 있다. 완전 오프라인 개발을 원하면 application-my.yml로 redisson 파일을 로컬 주소로 오버라이드해야 한다.
6. Docker 이미지 (멀티스테이지)
FROM amazoncorretto:21 AS base # ① 빌드 컨텍스트 준비COPY build.gradle.kts settings.gradle.kts gradlew gradlew.bat gradle.properties ./COPY gradle ./gradleFROM base AS build # ② 실제 빌드COPY src ./srcCOPY libs ./libs # ★ TwaySEED.jar 포함 (4절 참조)RUN ./gradlew --exclude-task test build # ★ 테스트 제외하고 빌드FROM titicacadev/amazoncorretto:21-ddtrace-latest AS release # ③ 런타임ARG environmentARG short_shaENV DD_ENV=${environment} DD_VERSION=${short_sha} DD_SERVICE=air-intl-adapter# ... DD_* 추가 트레이싱 태그들 ...COPY --from=build /app/build/libs/air-intl-adapter.jar .EXPOSE 8080CMD ["-jar", "air-intl-adapter.jar"]
Dockerfile:1-33.
세 가지 빌드 디테일
테스트 제외(--exclude-task test): 이미지 빌드 시 테스트를 안 돈다. 테스트는 CI(PR 단계)에서가 아니라… 사실 CI도 테스트를 제외한다(7절). → 테스트 회귀는 누군가 로컬에서 ./gradlew test를 돌려야만 잡힌다. landmines에 기록할 사항.
런타임 베이스가 ...-ddtrace-latest: Datadog 트레이싱 에이전트(-javaagent)가 베이스 이미지에 내장돼 있다. 그래서 CMD가 java가 아니라 ["-jar", ...]로 시작한다 — 베이스 이미지의 ENTRYPOINT가 java -javaagent:dd-java-agent.jar로 추정되며 거기에 인자를 덧붙이는 구조다.
포트 불일치: EXPOSE 8080인데 앱은 server.port: 8000을 리슨(application.yml:6). EXPOSE는 문서용 메타데이터일 뿐 실제 바인딩과 무관하다. ECS task-definition/ALB target group이 8000을 가리켜야 트래픽이 통한다. 혼동 주의.
테스트 미실행. (setup-node@v2/setup-java@v1 등 일부 액션 버전이 오래됨)
7.2 tag.yml — master → QA 자동 태깅
트리거: master 브랜치 push (tag.yml:3-5)
release-qa태그 객체를 만들고, 이미 있으면 force:true로 그 태그를 최신 커밋으로 이동(tag.yml:47-57)
이 태그 갱신이 다시 cd.yml(아래)을 트리거 → master에 머지되면 자동으로 QA 배포되는 흐름
브랜치 이름이 master다
tag.yml:5는 master를 본다(현재 우리 환경의 git 기본 브랜치 main과 다름). air-intl-adapter 레포의 통합 브랜치는 master임을 기억할 것.
7.3 cd.yml — 태그 → 환경별 배포
트리거: release-** 태그 push (브랜치 push는 전부 무시, cd.yml:3-7)
태그 이름으로 환경/계정 분기 (cd.yml:48-75):
태그
ENVIRONMENT
TAG_SUFFIX
AWS 계정
tfstate 경로
release-prod*
production
-prod
107243714588 (PROD)
production/…/seoul.tfstate
release-qa
qa
-qa
927056181394 (DEV)
qa/…
release-staging
staging
-staging
107243714588 (PROD)
staging/…
release-dev
dev
-dev
107243714588 (PROD)
development/…
이미지 태그: <short_sha><suffix> (예: abc123-prod), ECR: <account>.dkr.ecr.ap-northeast-2.amazonaws.com/air-intl-adapter:<tag> (cd.yml:79-82)
배포 실행: ops.inpk.io(Triple 내부 OPS API) v2 ecs-deploy-tfstate 엔드포인트에 POST → ECS 서비스가 새 이미지로 롤링 (cd.yml:101-107)
성공/실패 시 GitHub Deployment 상태 갱신 (cd.yml:138-152)
qa만 DEV 계정, 나머지(dev/staging/prod)는 PROD 계정
cd.yml을 보면 환경 이름과 AWS 계정이 직관과 다르게 매핑돼 있다. release-dev와 release-staging이 PROD 계정(107243714588)에 배포되고, release-qa만 DEV 계정(927056181394)에 배포된다(cd.yml:55-71). 배포 사고를 피하려면 “환경 이름 ≠ AWS 계정”임을 반드시 숙지할 것. landmines 핵심 항목.
scheduled-task 블록은 현재 비활성
cd.yml:110-136의 “Update scheduled-tasks”는 모든 환경에서 SCHEDULE_RULES=(빈 값)라 if [[ $SCHEDULE_RULES ]] 조건이 거짓 → 아무 것도 안 한다. 향후 배치/스케줄 태스크를 붙일 자리(placeholder)다.
7.4 거버넌스 파일
.github/CODEOWNERS: 전체(*)를 @eugene-triple @lucas-triple @hans-triple @triple-young이 소유 → 이들 중 1명 리뷰 승인 필요 (CODEOWNERS:1)
Q1. ./gradlew localBootRun을 쳤을 때 실제로 어떤 프로파일이 켜지고, my는 어디서 오나?
정답 보기
local,my 두 프로파일이 켜진다(build.gradle.kts:121). local은 application-local.yml. my는 개인 오버라이드 프로파일로, application-my.yml은 .gitignore:38(*-my.*)에 의해 레포에 없으며 본인이 만들어야 한다. 없으면 그냥 무시되고 local 설정만 적용된다.
Q2. 로컬에서 AWS 자격증명 없이 앱이 부팅에 성공하는 이유는?
정답 보기
모든 Secrets Manager import가 optional: 접두사를 갖기 때문(application-local.yml:7, supplier/*.yml). Secrets Manager 호출이 실패해도 부팅은 진행되고, 해당 비밀값만 비어 있게 된다. 단 실제 공급사 인증이 필요한 호출은 실패한다.
Q3. release-staging 태그를 푸시하면 어느 AWS 계정에 배포되는가?
정답 보기
**PROD 계정 107243714588**이다(cd.yml:66). 환경 이름은 staging이지만 계정은 prod와 동일하다. 오직 release-qa만 DEV 계정(927056181394)을 쓴다. 이 비대칭이 사고의 단골 원인.