목록으로

하나의 코드베이스로 두 개의 서비스를 운영하는 법

6

하나의 코드베이스로 두 개의 서비스를 운영하는 법

2025년 11월 24일 — DB별 분리, profile 분리 초기 구현 2026년 1월 8일 — 프로필 완전 분리, 개별 배포 워크플로우 추가 2026년 1월 9일 — GitHub Actions flow 분리, Swagger 프로필 구분 2026년 3월 12일 — 배포 스크립트 개선

상황: 같은 시스템인데 고객사가 다르다

우리 결제 시스템은 두 고객사(A, B)에 동시에 서비스한다. 비즈니스 로직은 99% 같지만, 다른 부분이 있다.

  • 토큰 이름과 심볼이 다르다
  • 블록체인 컨트랙트 주소가 다르다
  • DB가 분리되어 있다
  • 외부 SaaS API 에러 전파 정책이 다르다
  • 각각 별도 도메인으로 운영된다

처음에는 브랜치를 나눠서 관리할까 했다. A용 브랜치, B용 브랜치. 하지만 이러면 버그 수정할 때마다 양쪽 브랜치에 적용해야 하고, 시간이 지나면 코드가 점점 달라진다.

해결: Spring Profile로 분리

→ Spring Profile은 하나의 애플리케이션에서 환경(개발/운영/테스트)이나 고객사에 따라 다른 설정을 적용할 수 있게 해주는 기능이다. 코드를 바꾸지 않고 설정 파일만 교체해서 동작을 바꿀 수 있다.

코드는 하나, 설정만 다르게.

@Configuration
@ConfigurationProperties(prefix = "app") // → yml 파일에서 "app:" 아래의 설정값들을 이 클래스의 필드에 자동으로 매핑해주는 어노테이션이다
class AppProperties {
    var symbol: Symbol = Symbol.BNK  // 프로필에 따라 BNK 또는 JBW
}

프로필별 yml 파일로 모든 차이를 흡수한다.

# application-a.yml (A 고객사)
app:
  symbol: A_TOKEN

avalanche:
  token-contract:
    address: "0x..."       # A 고객사 토큰 컨트랙트
  payment-manager:
    address: "0x..."       # A 고객사 결제 컨트랙트

external-saas:
  propagate-errors: false  # 결제 가용성 우선

# application-b.yml (B 고객사)
app:
  symbol: B_TOKEN

avalanche:
  token-contract:
    address: "0x..."       # B 고객사 토큰 컨트랙트
  payment-manager:
    address: "0x..."       # B 고객사 결제 컨트랙트

external-saas:
  propagate-errors: true   # 컴플라이언스 우선

각 프로필이 가진 설정 차이:

항목A 프로필B 프로필
토큰 심볼A_TOKENB_TOKEN
토큰 컨트랙트별도 주소별도 주소
결제 컨트랙트별도 주소별도 주소
DB별도 스키마별도 스키마
SaaS 에러 전파falsetrue
시스템 지갑별도 지갑별도 지갑

로컬 개발: 프로필로 전환

# A 고객사 환경으로 로컬 실행
./gradlew bootRun --args='--spring.profiles.active=dev-a'

# B 고객사 환경으로 로컬 실행
./gradlew bootRun --args='--spring.profiles.active=dev-b'

개발환경(dev-*)은 로컬 DB를 사용하고, 운영환경은 클라우드 DB를 사용한다. 프로필 4개:

  • dev-a: A 로컬 개발
  • dev-b: B 로컬 개발
  • a: A 운영
  • b: B 운영

Docker: 환경변수 하나로 프로필 결정

ENV SPRING_PROFILE=a

ENTRYPOINT ["sh", "-c", "\
  java $JAVA_TOOL_OPTIONS \
    -Duser.timezone=Asia/Seoul \
    -Dspring.profiles.active=${SPRING_PROFILE} \
    -jar /app.jar"]

같은 Docker 이미지를 빌드해도 SPRING_PROFILE 환경변수에 따라 완전히 다른 서비스가 된다. → Docker 이미지는 애플리케이션과 실행 환경을 하나로 묶은 패키지다. 한번 만들어두면 어디서든 동일하게 실행할 수 있다.

GitHub Actions: 고객사별 독립 배포

main 브랜치에 푸시하면 A, B 두 서비스가 동시에 배포된다. 각각 별도의 워크플로우 파일로 분리했다.

# deploy-a.yml
name: Deploy A to Prod

on:
  push:
    branches: [ "main" ]

env:
  ECR_REPOSITORY: prod-poc-a-api
  ECS_SERVICE: ProdAApiService
  ECS_CLUSTER: ProdPocACluster
  SPRING_PROFILE: a

jobs:
  deploy:
    # A만 배포 스킵하고 싶을 때
    if: |
      !contains(github.event.head_commit.message, 'skip-deploy-all') &&
      !contains(github.event.head_commit.message, 'skip-deploy-a')

    steps:
      - name: 배포 시작 알림
        run: |
          curl -X POST "$ALARM_URL" -d '{"message": "A Production 배포 시작"}'

      - name: Build with Gradle
        run: ./gradlew bootJar

      - name: Build and Push Docker Image
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

      - name: Deploy to ECS
        # ECS Task Definition에서 SPRING_PROFILE 환경변수 주입
        env:
          SPRING_PROFILE: a

스킵 커밋 메시지가 편리했다. B 고객사만 관련된 수정을 할 때:

git commit -m "fix: B 전용 수정 [skip-deploy-a]"

이렇게 하면 A 배포는 건너뛰고 B만 배포된다.

  • skip-deploy-a: A만 스킵
  • skip-deploy-b: B만 스킵
  • skip-deploy-all: 전부 스킵

배포 알림

배포 시작/완료를 텔레그램 알림으로 받도록 했다.

- name: 배포 결과 알림
  if: always()
  run: |
    if [ "${{ job.status }}" == "success" ]; then
      STATUS="배포 완료"
    else
      STATUS="배포 실패"
    fi
    curl -X POST "$ALARM_URL" \
      -d "{\"message\": \"[A Production $STATUS] 커밋: $COMMIT_MSG\"}"

if: always()로 성공/실패 관계없이 알림이 온다. 배포 실패하면 즉시 알 수 있어서 대응이 빨라졌다.

AWS 인프라 구성

GitHub (main push)
├── deploy-a.yml → ECR (A 이미지) → ECS Cluster A → 서비스 A
└── deploy-b.yml → ECR (B 이미지) → ECS Cluster B → 서비스 B

→ ECR(Elastic Container Registry)은 Docker 이미지를 저장하는 AWS 서비스다. ECS(Elastic Container Service)는 Docker 컨테이너를 실행하고 관리하는 AWS 서비스다.

GitHub Actions에서 OIDC로 AWS 인증한다. IAM 키를 워크플로우에 넣지 않고, GitHub가 발급한 토큰으로 AWS 역할을 assume한다. → OIDC(OpenID Connect)는 신원을 인증하는 표준 프로토콜이다. GitHub가 "이 워크플로우는 진짜 이 레포에서 실행 중이다"라고 증명하면, AWS가 그걸 믿고 권한을 부여한다. 비밀 키를 코드에 저장하지 않아도 되어 안전하다. → IAM(Identity and Access Management)은 AWS에서 사용자와 권한을 관리하는 서비스다.

permissions:
  id-token: write  # OIDC 토큰 발급 허용
  contents: read

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}

이 구조에서 주의할 점

  1. 공통 코드 수정 시 양쪽 테스트 필수: 프로필은 분리되어 있지만 코드는 하나다. A에서 테스트했다고 B에서도 되는 건 아니다 (컨트랙트 주소, DB 스키마 차이)

  2. 프로필 설정 누락 주의: 새 설정 값을 추가할 때 application-a.yml에만 넣고 application-b.yml에 안 넣으면, B 서비스가 기본값으로 동작하거나 시작이 안 된다

  3. ECS Task Definition 동기화: 인프라 설정(환경변수, 메모리, CPU)을 변경할 때 양쪽 Task Definition을 모두 업데이트해야 한다 → Task Definition은 ECS에서 컨테이너를 어떻게 실행할지 정의하는 설정 파일이다. 사용할 Docker 이미지, CPU/메모리 할당량, 환경변수 등을 명시한다.

배운 점

  • 브랜치 분리보다 프로필 분리가 유지보수에 훨씬 유리하다. 코드 중복이 0이다
  • SPRING_PROFILE 환경변수 하나로 Docker 이미지를 재사용할 수 있다
  • 커밋 메시지에 skip-deploy-* 컨벤션을 두면 선택적 배포가 가능하다
  • GitHub Actions OIDC는 AWS 시크릿 키를 코드에 넣지 않는 안전한 인증 방식이다
  • 배포 알림은 "있으면 좋은 것"이 아니라, 운영 환경에서는 필수다
하나의 코드베이스로 두 개의 서비스를 운영하는 법 | KYUDORI