먼저 정할 것 — 정말 나눠야 하는가
마이크로서비스와 Docker 컨테이너는 이제 큰 회사만의 이야기가 아니다. 서비스를 어느 정도 키운 팀이라면 한 번쯤 모놀리식을 나누고 컨테이너로 묶어 배포하는 고민을 한다. 중요한 질문은 «마이크로서비스로 가느냐»가 아니라 «어디까지 나눌 것인가», 그리고 «나눈 뒤 늘어나는 운영 일을 누가 감당하는가»다.
핵심은 독립 배포다. 서비스마다 따로 빌드·배포·확장할 수 있어야 마이크로서비스라고 부를 수 있다. 코드를 여러 저장소로 나눠 놓고 결국 한꺼번에 배포한다면 그건 분산 모놀리식일 뿐이고, 네트워크 호출과 장애 지점만 늘어난다.
| 판단 기준 | 모듈러 모놀리식이 맞는 쪽 | 마이크로서비스가 맞는 쪽 |
|---|---|---|
| 팀 구성 | 한두 팀이 같은 코드를 함께 고침 | 팀마다 맡은 도메인이 분명하고 배포 일정이 다름 |
| 배포 빈도 | 모든 기능이 같은 주기로 나감 | 일부 기능만 자주 바뀜 |
| 확장 요구 | 부하가 고르게 늘어남 | 특정 기능(검색·결제 등)만 크게 늘어남 |
| 운영 역량 | 분산 추적·서비스별 모니터링 준비 전 | CI/CD·관측·온콜 체계가 이미 있음 |
| 데이터 | 여러 기능이 한 트랜잭션을 자주 공유 | 기능별로 데이터를 나눠 가질 수 있음 |
나누기 전에 모놀리식 안에서 모듈 경계(패키지·의존 방향)를 먼저 정리해 두면, 나중에 서비스로 떼어 낼 때 그 경계가 그대로 서비스 경계가 된다. 분산 시스템의 복잡성은 공짜가 아니다.
경계와 데이터, 장애 격리
서비스 경계는 도메인으로 긋는다
가장 흔한 실패는 기술 계층(컨트롤러·서비스·DB)으로 나누는 것이다. 기준은 도메인(비즈니스 능력)이다. 주문·결제·알림처럼 따로 바뀌는 단위로 나누고, 서비스마다 자기 데이터를 소유하게 한다(Database per Service). 다른 서비스의 테이블을 직접 읽기 시작하면 스키마 하나를 바꿀 때 여러 서비스를 같이 배포해야 해서 독립 배포가 깨진다.
여러 서비스에 걸친 일 — Saga
서비스가 각자 DB를 가지면 여러 서비스에 걸친 작업을 한 트랜잭션으로 커밋할 수 없다. 그래서 Saga 패턴으로 로컬 트랜잭션을 차례로 잇고, 중간에 실패하면 앞 단계를 되돌리는 보상 트랜잭션을 실행해 최종 일관성을 맞춘다. 주문 → 결제 → 재고 차감 순서라면 «재고 부족» 때 결제 취소가 보상 단계다. 각 단계는 같은 메시지를 두 번 받아도 결과가 같도록(멱등) 만든다.
장애가 번지지 않게 — Resilience4j
네트워크 너머의 호출은 언제든 느려지거나 실패한다. 한 서비스의 장애가 전체로 번지지 않게 서킷 브레이커·재시도·타임아웃·벌크헤드를 조합한다. Netflix Hystrix 저장소는 «더 이상 활발히 개발하지 않고 유지 보수 모드»라고 밝히고 새 프로젝트에는 resilience4j를 쓰겠다고 적었다. 자바 진영에서 Resilience4j가 기본 선택이 된 배경이다.
// Resilience4j: 서킷 브레이커 + 재시도 조합
CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율 50% 넘으면 open (기본값도 50)
.slowCallDurationThreshold(Duration.ofSeconds(2)) // 기본 60초 → 서비스에 맞게 줄임
.waitDurationInOpenState(Duration.ofSeconds(10)) // 기본 60초
.slidingWindowSize(20) // 기본 100
.minimumNumberOfCalls(20) // 기본 100 — 창 크기와 맞춤
.build();
RetryConfig retryConfig = RetryConfig.custom()
.maxAttempts(3)
.intervalFunction(IntervalFunction.ofExponentialBackoff(
Duration.ofMillis(200), 2.0)) // 200ms → 400ms
.build();
CircuitBreaker breaker = CircuitBreaker.of("payment", cbConfig);
Retry retry = Retry.of("payment", retryConfig);
Supplier<PaymentResult> decorated = Decorators
.ofSupplier(() -> paymentClient.charge(order))
.withCircuitBreaker(breaker)
.withRetry(retry)
.decorate(); // open 상태면 CallNotPermittedException → 폴백(결제 보류) 처리Resilience4j 문서의 기본값은 실패율 임계 50%, 느린 호출 기준 60초, open 상태 유지 60초, 슬라이딩 창 100회(횟수 기준), 최소 호출 수 100회, half-open에서 허용 호출 10회다. 기본값 그대로면 호출이 100번 쌓이기 전에는 회로가 열리지 않으므로, 트래픽이 적은 서비스는 창 크기와 최소 호출 수를 함께 줄인다. 재시도는 결제처럼 두 번 실행되면 안 되는 호출에는 멱등 키가 있을 때만 건다.
Docker 이미지 최적화 — BuildKit·멀티스테이지·distroless
서비스 수만큼 이미지가 생기므로 운영 비용은 이미지에서부터 쌓인다. 이미지가 크면 빌드가 느리고, 레지스트리 저장이 늘고, 노드가 내려받는 시간이 길어지고, 들어 있는 패키지만큼 취약점 점검 대상도 늘어난다.
BuildKit — 지금 기본 빌더
Docker 문서 기준으로 BuildKit은 Docker Desktop과 Docker Engine의 기본 빌더다(Windows 컨테이너 빌드는 예외). 서로 무관한 빌드 단계를 병렬로 돌리고, 쓰지 않는 단계는 건너뛰고, 빌드 캐시를 관리한다. 여기에 두 가지 마운트를 쓴다.
- 캐시 마운트 —
RUN --mount=type=cache,target=/root/.npm npm ci처럼 패키지 매니저 캐시를 빌드 사이에 유지한다. 레이어가 다시 빌드돼도 새로 바뀐 패키지만 내려받는다. - 시크릿 마운트 —
RUN --mount=type=secret,id=npmrc로 토큰 파일을 그 명령이 도는 동안만/run/secrets/<id>에 붙인다. 빌드 인자·환경 변수와 달리 최종 이미지에 남지 않는다. 값은docker build --secret id=npmrc,src=$HOME/.npmrc .로 넘긴다.
멀티스테이지 + distroless
빌드 도구와 소스는 빌드 단계에만 두고, 실행 단계에는 산출물과 런타임만 복사한다. distroless 이미지는 셸과 패키지 매니저가 없어 실행에 필요한 것만 남는다. distroless 저장소 기준으로 지금 이미지는 Debian 13(trixie) 기반이고, Node.js는 nodejs22·nodejs24·nodejs26-debian13을 제공하며, 태그 :nonroot는 루트가 아닌 사용자로, :debug는 busybox 셸을 넣어 실행한다. 저장소는 판 접미사(-debian13)를 명시하라고 권한다.
# syntax=docker/dockerfile:1 # ---------- 1단계: 빌드 ---------- FROM node:24-slim AS build WORKDIR /app COPY package*.json ./ # 캐시 마운트: npm 캐시를 빌드 간 재사용 RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN npm run build # 실행에 필요 없는 개발 의존성 제거 RUN npm prune --omit=dev # ---------- 2단계: 실행 (distroless, 루트 아님) ---------- FROM gcr.io/distroless/nodejs24-debian13:nonroot AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist EXPOSE 8080 CMD ["dist/server.js"]
예전 예제에서 자주 보이던 실수가 빌드 단계에서 npm ci --omit=dev로 개발 의존성을 뺀 뒤 npm run build를 돌리는 것이다. 빌드 도구(TypeScript 컴파일러 등)가 개발 의존성에 있으면 빌드가 실패한다. 위처럼 전부 설치해 빌드한 뒤 npm prune으로 줄이는 순서가 맞다. distroless Node.js 이미지는 진입점이 이미 node라 CMD에는 스크립트 경로만 적는다.
레이어 순서가 캐시 효율을 좌우한다. Docker 문서 권고대로 자주 바뀌는 단계(COPY . .)는 뒤쪽에, 거의 안 바뀌는 의존성 설치는 앞쪽에 둔다. 코드만 고쳤을 때 의존성 레이어를 다시 만들지 않는다. 여러 컨테이너를 로컬에서 함께 띄우는 구성은 Docker Compose 멀티 컨테이너 운영 가이드에 정리했다.
쿠버네티스 배치 — 서비스 찾기와 자원 설정
서비스 찾기는 Service와 DNS로
쿠버네티스 위에서는 별도 서비스 레지스트리 서버를 두지 않아도 된다. Service 오브젝트가 고정 이름과 가상 IP를 주고, 클러스터 DNS로 user-service.production.svc.cluster.local(같은 네임스페이스에서는 user-service)처럼 찾는다. 파드가 바뀌어도 이름은 그대로라 호출하는 쪽 설정을 고칠 일이 없다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
namespace: production
spec:
replicas: 3
selector:
matchLabels: { app: user-service }
template:
metadata:
labels: { app: user-service }
spec:
containers:
- name: user-service
image: ghcr.io/my-org/user-service:2.1.0
ports: [{ containerPort: 8080 }]
resources:
requests: { cpu: "250m", memory: "256Mi" } # 배치·HPA 계산 기준
limits: { memory: "512Mi" } # 메모리 상한(넘으면 OOMKill)
readinessProbe:
httpGet: { path: /healthz/ready, port: 8080 }
livenessProbe:
httpGet: { path: /healthz/live, port: 8080 }
---
apiVersion: v1
kind: Service
metadata:
name: user-service
namespace: production
spec:
selector: { app: user-service }
ports: [{ port: 80, targetPort: 8080 }]requests·limits와 확장
requests는 스케줄러가 노드를 고르는 기준이자 HPA가 CPU 사용률을 계산하는 분모다. 너무 높으면 노드가 놀고, 너무 낮으면 한 노드에 몰려 서로 자원을 다툰다. 메모리 limits를 넘으면 컨테이너가 강제 종료(OOMKill)되므로 실제 최대 사용량을 재고 정한다. readiness 프로브는 준비되지 않은 파드에 트래픽이 가지 않게, liveness 프로브는 멈춘 프로세스를 재시작하게 한다. 둘을 같은 엔드포인트로 두면 일시적인 의존 서비스 장애에도 재시작이 연쇄로 일어날 수 있어 나눠 둔다. 확장 기준(HPA·KEDA)은 실시간 성능 모니터링과 자동 스케일링에서 자세히 다뤘다.
서비스 메시와 제로 트러스트 — Istio 앰비언트
서비스가 늘면 서비스 간 통신의 암호화·인증·트래픽 제어를 애플리케이션마다 구현하기 어려워진다. 이 공통 관심사를 인프라 계층으로 떼어 낸 것이 서비스 메시다. Istio 문서(현재 1.31) 기준으로 앰비언트 모드는 이 일을 두 층으로 나눈다.
| 구성 요소 | 위치 | 맡는 일 |
|---|---|---|
| ztunnel | 노드마다 하나 | mTLS·인증·L4 권한·L4 텔레메트리 (HTTP는 해석하지 않음) |
| waypoint | 애플리케이션 파드 밖, 필요할 때만 | Envoy 기반 L7 기능(경로 기반 라우팅, L7 권한 등) |
네임스페이스(또는 파드)에 istio.io/dataplane-mode=ambient 라벨을 붙이면 메시에 들어가고, 문서 설명대로 애플리케이션을 재시작할 필요가 없고 파드에 컨테이너가 더 붙지도 않는다. L7 기능이 필요한 서비스만 waypoint를 두고 istio.io/use-waypoint 라벨로 연결한다. 파드마다 프록시를 붙이던 사이드카 방식과 비교해 무엇이 줄고 무엇이 남는지는 자기 클러스터에서 재 보고 정한다.
# 네임스페이스를 앰비언트 메시에 넣기 (재시작 불필요)
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
istio.io/dataplane-mode: ambient
---
# 메시 안 트래픽은 mTLS 만 허용
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT
---
# user-service 는 api-gateway 서비스 계정에서 온 GET/POST /api/v1/users/* 만 허용
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: user-service-policy
namespace: production
spec:
selector:
matchLabels: { app: user-service }
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/production/sa/api-gateway"]
to:
- operation:
methods: ["GET", "POST"]
paths: ["/api/v1/users/*"]AuthorizationPolicy에서 흔한 실수가 from과 to를 서로 다른 규칙(- from:, - to:)으로 나누는 것이다. Istio 문서 기준으로 한 규칙 안의 from·to·when은 모두 맞아야(AND) 하고, 규칙끼리는 하나만 맞아도(OR) 된다. 둘을 나누면 «게이트웨이에서 온 모든 요청» 또는 «어디서 왔든 GET/POST 요청»이 허용돼 의도와 달라진다. 또 ALLOW 정책이 하나라도 붙은 워크로드는 어느 규칙에도 맞지 않는 요청을 거부한다. 경로·메서드 같은 L7 조건은 앰비언트에서 waypoint가 있어야 적용되므로, ztunnel만 둔 상태에서는 서비스 계정 같은 L4 조건으로 정책을 짠다.
배포와 관측 — GitOps·OpenTelemetry
GitOps로 여러 서비스 배포하기
서비스가 많아질수록 «지금 운영에 어떤 판이 떠 있는가»를 Git 한 곳에서 보는 것이 중요해진다. Argo CD 같은 GitOps 도구는 Git의 매니페스트와 클러스터 상태를 비교해 맞춘다.
# Argo CD Application — production 매니페스트를 자동 동기화
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: microservices-production
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/my-org/k8s-manifests
targetRevision: main
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true # Git 에서 지운 리소스는 클러스터에서도 지움
selfHeal: true # 손으로 바꾼 값은 Git 상태로 되돌림
syncOptions:
- CreateNamespace=true
---
# overlays/production/kustomization.yaml — 서비스별 이미지 판을 한 곳에서
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base/user-service
- ../../base/order-service
- ../../base/payment-service
patches:
- path: replicas-production.yaml
images:
- name: ghcr.io/my-org/user-service
newTag: "2.1.0"
- name: ghcr.io/my-org/order-service
newTag: "1.8.3"selfHeal을 켜면 장애 중 손으로 넣은 임시 조치도 Git 상태로 되돌아간다. 임시 조치는 매니페스트 PR로 넣는 습관을 함께 들인다. 테스트부터 이미지 태그 갱신까지의 CI 쪽은 자동화된 테스트·배포 파이프라인에서 이어진다.
관측 — 요청 하나를 끝까지 따라가기
마이크로서비스에서 «느리다»는 신고를 받으면 어느 서비스에서 시간이 걸렸는지부터 찾아야 한다. OpenTelemetry로 트레이스 ID를 서비스 경계 너머로 전파하면 한 요청이 거친 서비스와 구간별 시간을 한 화면에서 본다. 메트릭·로그·트레이스가 같은 트레이스 ID와 service.name을 공유하게 맞추는 것이 핵심이다. 서비스 메시를 쓰면 메시가 L4·L7 텔레메트리를 더해 주지만, 애플리케이션 안쪽 구간(DB 호출, 외부 API)은 여전히 애플리케이션 계측이 필요하다.
정리 — 도입 순서
마이크로서비스와 컨테이너 최적화는 한 번에 갖추는 것이 아니라 운영 부담을 감당할 수 있는 만큼 순서대로 넓히는 일이다.
| 순서 | 할 일 | 확인 |
|---|---|---|
| 1 | 모듈 경계 정리 → 떼어 낼 도메인 하나 고르기 | 그 도메인이 데이터를 혼자 소유하는지 |
| 2 | 멀티스테이지 + distroless 이미지 | 최종 이미지에 셸·빌드 도구·시크릿 없음 |
| 3 | Service·프로브·requests/limits | readiness와 liveness가 나뉘어 있음 |
| 4 | 타임아웃·서킷 브레이커·멱등 재시도 | 창 크기·최소 호출 수가 트래픽에 맞음 |
| 5 | 메시 mTLS STRICT + 최소 허용 정책 | from·to가 한 규칙 안에 있음 |
| 6 | GitOps + 분산 추적 | 운영 판이 Git에서 보이고, 요청 하나를 끝까지 따라갈 수 있음 |
쿠버네티스 기본 오브젝트부터 차례로 익히려면 Kubernetes 입문 실전 배포 가이드를 함께 본다.
자주 묻는 것
작은 팀도 마이크로서비스로 나눠야 하나요?
독립 배포가 필요한 도메인이 분명하고 CI/CD·관측·온콜 체계가 갖춰졌을 때 이득이 큽니다. 한두 팀이 같은 주기로 배포한다면 모듈러 모놀리식으로 경계부터 정리하고, 떼어 낼 도메인 하나를 골라 옮기는 편이 운영 부담이 적습니다.
distroless 이미지는 어떻게 디버깅하나요?
distroless 이미지에는 셸이 없습니다. 저장소가 제공하는 :debug 태그(busybox 셸 포함)나 debug-nonroot 태그로 같은 이미지를 띄워 확인하고, 운영에는 셸 없는 태그를 씁니다. 지금 이미지는 Debian 13 기반이라 판 접미사를 명시하는 편이 안전합니다.
Istio 앰비언트 모드로 바꾸면 파드를 재시작해야 하나요?
Istio 문서 기준으로 네임스페이스나 파드에 istio.io/dataplane-mode=ambient 라벨을 붙이면 메시에 들어가고, 애플리케이션을 재시작할 필요가 없습니다. 경로 기반 라우팅 같은 L7 기능이 필요한 서비스만 waypoint 프록시를 따로 둡니다.
AuthorizationPolicy에서 from과 to를 따로 적으면 안 되나요?
한 규칙 안의 from·to·when은 모두 맞아야 하고 규칙끼리는 하나만 맞아도 허용됩니다. from과 to를 서로 다른 규칙으로 나누면 둘 중 하나만 맞는 요청도 허용되므로, 같은 규칙 안에 함께 적습니다.
참고 자료
- Docker Docs. BuildKit — 기본 빌더(Windows 컨테이너 예외)·병렬 단계·안 쓰는 단계 건너뛰기(2026-10-30 확인). docs.docker.com
- Docker Docs. Optimize cache usage · Build secrets — npm 캐시 마운트·레이어 순서·시크릿 마운트 /run/secrets·최종 이미지에 안 남음(2026-10-30 확인). docs.docker.com
- GoogleContainerTools distroless README — Debian 13 기반·nodejs22/24/26-debian13·nonroot·debug 태그(2026-10-30 확인). github.com
- Netflix Hystrix README — 유지 보수 모드·resilience4j 권고(2026-10-30 확인). github.com
- Resilience4j Docs. CircuitBreaker — 기본값(실패율 50%·느린 호출 60초·open 60초·창 100·최소 호출 100·half-open 10)(2026-10-30 확인). resilience4j.readme.io
- Istio Docs(1.31). Ambient overview · Add workloads — ztunnel·waypoint 역할, dataplane-mode 라벨, 재시작 불필요, use-waypoint(2026-10-30 확인). istio.io
- Istio Docs. Authorization Policy — security.istio.io/v1, 규칙 안 AND·규칙끼리 OR, ALLOW 정책이 있으면 나머지 거부(2026-10-30 확인). istio.io