- config
- service discovery
- gateway
- open feign
- docker compose
- prometheus + grafana loki
- mashup (api aggregation)
- apis (member, task)
- web (controller)
- usecase
- port
- domain (service)
- infra (adapter)
- jpa
- r2dbc
- redis
- graphql
- router function
- handler function
- kms client
- s3 client
- cloudfront client
- dynamodb client
- producer
- consumer
- avro
- webmvc, webflux : auth-server 가 발급한 JWT 를 JWKS 로 검증 (
GET /users/me)
client ─▶ gateway ─▶ task 서비스(hexagonal) ─┬─ task ┐ 같은 트랜잭션
(eureka) └─ outbox_event ┘
outbox relay ─▶ kafka `task-events` (key = taskId)
│
consumer ◀───────────┘
└ 같은 트랜잭션: processed_event(eventId) + task_summary 갱신
└ 처리 실패: 지수 백오프 3회 재시도 후 `task-events.DLT`
- transactional outbox : task 변경과 이벤트 기록을 한 트랜잭션으로 묶어, DB 저장과 이벤트 발행 중 하나만 성공하는 일을 막는다.
- outbox relay :
outbox_event를 주기적으로 읽어 kafka 응답(ack)을 확인한 뒤published_at을 기록한다. postgresql advisory lock 으로 한 번에 한 인스턴스만 발행해 순서를 지킨다. - at-least-once : 발행 후
published_at기록 전에 장애가 나면 같은 이벤트가 다시 발행된다. consumer 는eventId로 멱등 처리한다. - 순서 : key 가 taskId 라서 같은 task 의 이벤트는 같은 partition 에서 순서대로 처리된다. 늦게 도착한 이벤트는
last_event_at비교로 최신 상태를 덮어쓰지 않는다. - topic :
task-eventspartitions 3, replicas 3,min.insync.replicas2 (camus.kafka.topics.*로 변경) - 대안 : 트래픽이 많으면 polling relay 대신 debezium CDC(kafka connect) 로 outbox 를 발행하는 방식을 검토한다.
로컬 실행
# 1. 인프라 (postgresql 15432, kafka 9092~9094, schema registry 8081, redis 6379)
docker compose up -d
# 2. 애플리케이션 (각각 별도 터미널)
./gradlew :auth-server:bootRun # auth-server 9400
./gradlew :spring-cloud:spring-cloud-service-discovery:bootRun # eureka 8761
./gradlew :hexagonal:bootRun # task 서비스 8084
./gradlew :kafka:kafka-consumer:bootRun # consumer 9091
./gradlew :spring-cloud:spring-cloud-gateway:bootRun # gateway 8000
# 3. 토큰을 받아 gateway 로 호출 (task.read: 조회, task.write: 변경)
TOKEN=$(curl -s -u camus-service:camus-service-secret -d grant_type=client_credentials \
-d 'scope=task.read task.write' localhost:9400/oauth2/token | jq -r .access_token)
curl -X POST localhost:8000/task-service/v1/tasks -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"hello","content":"world","priorityType":"HIGH"}'
# 4. consumer 조회 테이블 확인
docker exec camus-postgres psql -U camus -d camus_task_view -c 'select * from task_summary'OAuth2 / OIDC 인증 서버 (포트 9400). access token 은 RS256 JWT 이고, resource server 는 공개키(JWKS)만으로 검증한다.
| client | grant | 용도 |
|---|---|---|
camus-web |
authorization code + PKCE, refresh token | 사용자 로그인 (secret 을 가진 서버(BFF) 가 토큰을 다룬다) |
camus-service |
client credentials | 서비스 간 호출 |
-
gateway 는
/task-service/**의 JWT 를 검증하고(조회task.read, 변경task.write) 토큰을 그대로 하위 서비스로 넘긴다. 토큰이 없거나 잘못되면 401, scope 가 부족하면 403. rate limit 은 인증된 사용자 기준으로 센다. -
지금은 gateway 에서만 검증하고 내부망을 신뢰한다. 하위 서비스도 직접 검증하려면 jwt 예제처럼
spring-boot-starter-oauth2-resource-server와issuer-uri/jwk-set-uri를 설정한다. -
엔드포인트 :
/.well-known/openid-configuration,/oauth2/authorize,/oauth2/token,/oauth2/jwks,/oauth2/revoke,/oauth2/introspect,/userinfo -
access token 15분 (서비스 토큰 5분), refresh token 은 한 번 쓰면 새로 발급하고 이미 쓴 토큰은 거절한다.
-
발급한 인가 정보는 PostgreSQL(
camus_auth) 에 저장해 인스턴스를 여러 개 띄울 수 있다. -
서명 키는
AUTH_SIGNING_PRIVATE_KEY(PKCS#8 PEM) 로 넣는다. 비어 있으면 기동할 때마다 임시 키를 만든다 (로컬 전용). 키를 바꿀 때는AUTH_SIGNING_KEY_ID도 바꾼다. -
사용자(
camus.auth.users)와 클라이언트는 설정으로 등록한 예시다. 운영에서는 사용자 DB 나 외부 IdP 로 바꾼다. -
기존 postgresql volume 에는
camus_auth데이터베이스가 없으므로docker compose down -v후 다시 띄우거나 직접 만든다.
# 서비스 토큰 (client credentials, 로컬 기본 secret)
curl -u camus-service:camus-service-secret -d grant_type=client_credentials -d scope=task.read localhost:9400/oauth2/token- gateway 는 하위 서비스의 actuator 를 열지 않는다 (
/{route}/actuator/**는 403). gateway 자신은health,prometheus만 노출한다. - config server 는 기본 인증을 쓴다. client 계정(
CONFIG_SERVER_USERNAME/PASSWORD)은 설정 조회만, admin 계정(CONFIG_SERVER_ADMIN_USERNAME/PASSWORD)은/encrypt,/decrypt,busrefresh까지 쓸 수 있다. - 운영에서는
MANAGEMENT_SERVER_PORT로 actuator 를 별도 포트로 분리하고, 그 포트는 외부(ingress)에 열지 않는다. prometheus 는 내부망에서 관리 포트로 수집한다.
| 위치 | timeout | retry | circuit breaker | 그 밖에 |
|---|---|---|---|---|
| gateway → 하위 서비스 | connect 2s, response 5s | GET 만 2회 (502/503/504, 연결 오류) | 라우트별, 열리면 503 fallback | 클라이언트 IP 별 rate limit (redis, 초과 시 429) |
| mashup → member, task (feign) | connect 1s, read 2s | 3회 (5xx, 연결 오류/timeout), 지수 백오프 + jitter | 실패율·느린 호출 50% 초과 시 open 10s | task 장애 시 부분 응답 (degraded: true) |
- 4xx 는 요청 오류라서 재시도하지 않고 circuit breaker 실패로도 세지 않는다.
- 재시도는 한 곳에서만 한다 (feign 자체 재시도는 끈다). 여러 층에서 재시도하면 횟수가 곱해진다.
- gateway 에서 rate limit 은 라우트의 첫 필터로 둔다. 재시도보다 뒤에 두면 gateway 의 재시도까지 클라이언트 토큰을 쓴다.
- redis 가 응답하지 않으면 rate limiter 는 요청을 통과시킨다 (fail open).
- 동시 호출 수 제한이 필요하면 resilience4j bulkhead 를 추가한다.
project.camus.observability-conventions 를 적용한 서비스(gateway, example-api, hexagonal, kafka consumer, observability 3종)는 같은 방식으로 trace, 지표, 로그를 남긴다.
- trace : W3C
traceparent로 전파한다. outbox 에 요청의 traceparent 를 저장해 relay 가 이어 붙이므로 요청 → kafka → consumer 가 하나의 trace 가 된다. gateway 응답 헤더Trace-Id로 traceId 를 확인할 수 있다. - 지표 :
/actuator/prometheus(모든 지표에application태그, http 요청 지연 histogram)outbox_events_pending: 아직 발행하지 않은 outbox 이벤트 수outbox_events_published_total{result=success|failure}: outbox 발행 결과task_events_processed_total{result=applied|duplicate}: consumer 처리 결과task_events_dead_letter_total: DLT 로 보낸 이벤트 수kafka_consumer_fetch_manager_records_lag_max: consumer lag
- 로그 : traceId/spanId 가 로그에 함께 남는다.
LOGGING_STRUCTURED_FORMAT_CONSOLE=ecs면 JSON(ECS) 으로 출력하고,OTEL_LOGS_EXPORT_ENABLED=true면 OTLP 로도 보낸다. - 샘플링 : 기본은 전부(1.0) 수집한다. 운영에서는
TRACING_SAMPLING_PROBABILITY를 낮추거나 collector 에서 tail sampling 을 쓴다.
관측 스택 (로컬)
# 인프라 + otel collector(4317/4318), tempo(3200), loki(3100), prometheus(9090), grafana(3000)
docker compose --profile observability up -d
# 애플리케이션은 trace/로그 export 를 켜고 실행한다
export OTEL_TRACES_EXPORT_ENABLED=true OTEL_LOGS_EXPORT_ENABLED=true- grafana (
admin/GRAFANA_ADMIN_PASSWORD, 기본admin, 포트는GRAFANA_PORT로 바꿀 수 있다) →camus폴더의camus overview대시보드- 대시보드는
docker/observability/grafana/dashboards/<폴더>/*.json이고 디렉터리가 grafana 폴더가 된다 camus폴더에는 grafana.com 템플릿도 있다:Spring Boot Observability(17175, 로그 패널은 OTLP 로그에 맞게 고침),Spring Boot 3.x Statistics(19004)iandbook폴더는 운영 AWS(CloudWatch · X-Ray)를 본다. 읽기 전용 IAM 키를docker/observability/.env에 둬야 한다(커밋하지 않음)- 요청 수·5xx 비율·p95 지연, circuit breaker, outbox, consumer 처리·lag, DLT, 로그
- 로그의 traceId → tempo trace, trace 의 span → 같은 traceId 의 로그로 이동
- 대시보드는
- prometheus 알림 규칙 (
docker/observability/prometheus/alert-rules.yml) : InstanceDown, HighErrorRate, CircuitBreakerOpen, OutboxBacklog, DeadLetterEvents, ConsumerLag- 알림 전송(alertmanager, slack 등)은 환경에 맞게 붙인다.
모든 애플리케이션은 docker/app.Dockerfile 하나로 이미지를 만든다.
- spring boot layered jar 를 풀어 의존성/애플리케이션 layer 를 나눈다 (코드만 바뀌면 application layer 만 다시 받는다).
eclipse-temurin:21-jre-alpine(amd64, arm64), non-root(uid 10001) 로 실행한다.-XX:MaxRAMPercentage=75로 컨테이너 메모리 한도에 맞춰 힙을 잡고, OOM 이면 바로 종료해 재시작되게 한다.
# kubernetes 로 배포하는 앱 (auth-server, gateway, task-service, task-consumer)
./docker/build-images.sh [tag]
# 그 밖의 앱
docker build -f docker/app.Dockerfile --build-arg JAR_FILE=<module>/build/libs/<app>.jar -t <image> .k8s/
├── base/ # auth-server, gateway, task-service(hexagonal), task-consumer
│ # Deployment, Service, ConfigMap, PodDisruptionBudget, HPA(gateway, task-service), Ingress
└── overlays/
├── local/ # + postgresql, kafka(단일 브로커), schema registry, redis, 로컬 secret, replica 1, traefik
└── prod/ # 관리형 인프라 주소, 레지스트리, 도메인 (예시 값), otel collector, secret 은 외부에서 생성
- kubernetes 에서는 eureka, config server, cloud bus 를 끄고 service DNS(
http://task-service:8084등)와 ConfigMap/Secret 을 쓴다. 기존 spring cloud 구성은 로컬/VM 배포에서 그대로 쓸 수 있다. - actuator 는 관리 포트 8081 로 분리한다. startup/liveness/readiness probe 와 prometheus 수집(
prometheus.io/*annotation)은 이 포트를 쓰고, ingress 로는 열지 않는다. - 종료: preStop 5초 대기 후 spring graceful shutdown (terminationGracePeriodSeconds 45).
- 보안: non-root(uid 10001), readOnlyRootFilesystem(/tmp 는 emptyDir), capabilities drop ALL, seccomp RuntimeDefault,
enableServiceLinks: false. - 자원: memory request 512Mi / limit 768Mi (힙은 한도의 75%), cpu limit 은 두지 않는다.
- auth-server 의 issuer(
AUTH_ISSUER_URI)는 클라이언트가 보는 외부 주소여야 하고, gateway 는 같은 값으로 iss 를 검증한다. 공개키(JWKS)는 클러스터 내부 주소로 가져온다. - ConfigMap 은
configMapGenerator로 만든다. 내용이 바뀌면 이름의 hash 가 바뀌어 Deployment 가 다시 배포된다 (envFrom으로 붙인 일반 ConfigMap 은 바뀌어도 pod 가 재시작되지 않는다). - gateway, task-service 의 replicas 는 HPA 가 정한다. Deployment 에 replicas 를 두면
kubectl apply할 때마다 HPA 가 정한 값을 되돌린다. - HPA 는 JVM 기동 직후 CPU 에 반응해 과하게 늘리지 않도록 확장 60초 관찰 + 1분에 1개, 축소 5분 관찰로 둔다.
로컬 kubernetes (colima k3s)
colima start --kubernetes --cpu 4 --memory 8 # docker 런타임이라 빌드한 이미지를 바로 쓴다
helm install traefik traefik/traefik --version 41.6.1 -n traefik --create-namespace # ingress controller
./docker/build-images.sh local
kubectl apply -k k8s/overlays/local
kubectl port-forward -n traefik svc/traefik 18080:80 # auth.localtest.me / api.localtest.me → 127.0.0.1
./k8s/smoke-test.sh # 토큰 → gateway → task-service → kafka → consumer 확인*.localtest.me는 127.0.0.1 로 풀린다. issuer 가http://auth.localtest.me:18080이라 포트를 바꾸면k8s/base/*/*.env의 주소도 바꾼다.- 롤링 배포 확인: 요청을 계속 보내면서
kubectl rollout restart -n camus deploy/gateway deploy/task-service를 해도 실패 응답이 없어야 한다 (preStop + readiness + graceful shutdown + maxUnavailable 0).
KEY_STORE_LOCATION: spring cloud config 암호화 keystore 경로 (기본값file:.keystore/camusConfigEncKey.jks, git 추적 제외)AWS_KMS_KEY_ID: batch, spring cloud config 에서 사용하는 AWS KMS key idAWS_REGION: AWS region (기본값ap-northeast-2)TASK_DB_URL,TASK_DB_USERNAME,TASK_DB_PASSWORD: hexagonal(task 서비스) PostgreSQL 접속 정보 (기본값jdbc:postgresql://localhost:15432/camus,camus/camus)TASK_VIEW_DB_URL,TASK_VIEW_DB_USERNAME,TASK_VIEW_DB_PASSWORD: kafka consumer 조회용 PostgreSQL 접속 정보 (기본값jdbc:postgresql://localhost:15432/camus_task_view,camus/camus)KAFKA_BOOTSTRAP_SERVERS,SCHEMA_REGISTRY_URL: kafka 접속 정보 (기본값localhost:9092,localhost:9093,localhost:9094,http://localhost:8081)EUREKA_ENABLED,EUREKA_URL: hexagonal 의 eureka 등록 여부와 주소 (기본값true,http://127.0.0.1:8761/eureka)AUTH_ISSUER_URI: auth-server issuer. resource server(gateway, jwt 예제) 는 토큰의 iss 를 이 값과 비교한다 (기본값http://localhost:9400)AUTH_JWK_SET_URI: resource server 가 공개키를 가져오는 주소 (기본값http://localhost:9400/oauth2/jwks)AUTH_SIGNING_PRIVATE_KEY,AUTH_SIGNING_KEY_ID: access token 서명 키 (PKCS#8 PEM) 와 kidAUTH_SERVICE_CLIENT_SECRET,AUTH_WEB_CLIENT_SECRET: 클라이언트 secret ({bcrypt}..., 로컬 기본값은camus-service-secret,camus-web-secret)AUTH_WEB_REDIRECT_URIS: camus-web redirect uriAUTH_DB_URL,AUTH_DB_USERNAME,AUTH_DB_PASSWORD: auth-server PostgreSQL (기본값jdbc:postgresql://localhost:15432/camus_auth,camus/camus)CONFIG_SERVER_URI: config client(gateway, example-api) 가 접속할 config server (기본값http://127.0.0.1:8888)CONFIG_SERVER_USERNAME,CONFIG_SERVER_PASSWORD: config server client 계정 (로컬 기본값config-client/config-client-secret)CONFIG_SERVER_ADMIN_USERNAME,CONFIG_SERVER_ADMIN_PASSWORD: config server admin 계정 (로컬 기본값config-admin/config-admin-secret)MANAGEMENT_SERVER_PORT: actuator 를 별도 포트로 분리할 때 사용TASK_SERVICE_URI: gateway 의 task 서비스 주소 (기본값lb://HEXAGONAL, kubernetes 는http://hexagonal:8084처럼 service 주소)REDIS_HOST,REDIS_PORT: gateway rate limit 용 redis (기본값localhost,6379)GATEWAY_RATE_LIMIT_REPLENISH_RATE,GATEWAY_RATE_LIMIT_BURST_CAPACITY: 클라이언트별 초당 보충 토큰 수와 최대 버스트 (기본값10,20)OTEL_TRACES_EXPORT_ENABLED,OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: trace OTLP export 여부와 주소 (기본값false,http://localhost:4318/v1/traces)OTEL_LOGS_EXPORT_ENABLED,OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: 로그 OTLP export 여부와 주소 (기본값false,http://localhost:4318/v1/logs)TRACING_SAMPLING_PROBABILITY: trace 샘플링 비율 (기본값1.0)LOGGING_STRUCTURED_FORMAT_CONSOLE: 콘솔 로그 형식 (ecs,logstash,gelf중 하나면 JSON)