공모전 준비 중에 서버 비용이 문제가 됐다. 개발 기간에는 start.sh랑 stop.sh로 작업할 때만 켜고 껐더니 청구서가 상시 가동의 절반 이하로 나왔는데, 이제 공모전 출품도 해야 하고 서비스 운영이나 포트폴리오 용도로 쓰려면 결국 24시간 켜둬야 한다. 이대로라면 원래 비용인 월 15만 원이 그대로 지출될 판이다. ㅠㅠ
처음엔 서비스 두 개를 빼고 인스턴스를 반으로 줄이면 되겠다고 생각했다. 문서에 적힌 메모리 예산표를 보고 “그래도 안 들어간다”고 판단했는데, 실제로 재 보니 예산표가 900MB 넘게 과산정돼 있었다. 이 글은 MSA 서버 비용 절감을 추정 대신 실측으로 다시 풀어서, 서비스를 하나도 잃지 않고 월 15만원을 9만 2천원으로 내린 기록이다
비용이 새로 생긴 게 아니라, 피할 수단이 사라진 것이다
EC2와 RDS를 함께 내리는 스크립트를 만들어 두고 하루 몇 시간만 켰다. 인프라 구축 회의록에도 “24시간 상시 ~$94/월, 야간 정지 운용 시 ~$55-60″이라고 적어 뒀고 실제로 그렇게 운영했다. 공모전 제출 이후에는 그 모델을 쓸 수 없다고 생각했다. 심사위원이 언제 접속할지 모르고, 앱을 설치한 사람이 언제 열어볼지 모른다. 서버가 꺼져 있으면 그 순간이 곧 실패다. 게다가 이 서비스는 매출이 없다. 개인이 자기 돈으로 내는 월 15만원이다. 정지/시작 스크립트는 개발 단계에서 합리적인 도구였지만 상시 가동이 요구되는 순간 아무 도움이 안 된다. 그래서 이번에는 “덜 켜기”가 아니라 켜져 있는 상태의 비용을 구조적으로 낮춰야 했다
출발 상태는 이랬다. 모노레포 멀티모듈 MSA이고 실제 배포는 8개다
discovery Eureka 서비스 디스커버리 :8761 gateway Spring Cloud Gateway 5 WebFlux :8080 auth 회원·소셜로그인·JWT 발급 :8081 chat 채팅·친구 :8082 community 게시판·모임·포인트·공지/FAQ :8083 jobs 자격·공고·지원·매칭·공공구인정보 :8085 notification 알림함·푸시·가족 살핌 :8086 tour 관광·기상·의료 공공API 조회 :8087
전부 EC2 t3.large(8GB) 한 대에 systemd 유닛으로 올렸고, Kafka·Redis·Caddy만 컨테이너로 띄웠다. DB는 RDS(db.t4g.micro)로 분리했다. 환율 1,400원/$ 기준 비용은 EC2 $75.9, EBS $2.7, EIP $3.65, RDS $21.6으로 합계 약 $104, 즉 145,000원이었다
예산표를 믿고 “안 된다”고 판단한 게 첫 실수였다
처음 세운 안은 community와 jobs를 운영에서 빼고 t3.medium(4GB)으로 내리는 것이었다. 그런데 구축 당시 회의록의 메모리 예산표를 보니 안 될 것 같았다
| 항목 | 예산 |
|---|---|
JVM 8개 (-Xmx320m, RSS ~450-550MB/개) | ~4.0-4.5GB |
Kafka 컨테이너 (-Xmx512m) | ~0.7-1.0GB |
| Redis·Caddy·OS | ~0.5GB |
| 합계 | 4.8-5.9GB |
이 숫자대로면 두 개를 빼도 4GB에 들어가지 않는다. 그런데 실제로 재 보니 달랐다
$ free -m
total used free shared buff/cache available
Mem: 7823 3894 2539 1 1389 3696
Swap: 4095 0 4095
used가 3,894MB였다. 예산표 하한보다도 900MB 낮았고, 4GB 할당된 스왑은 한 바이트도 쓰지 않았다. 실측치로 다시 계산하면 두 서비스를 빼면 약 3,194MB라 t3.medium에 들어간다. 원래 안이 맞았고, 예산표를 근거로 “안 된다”고 한 쪽이 틀렸다
구축 시점의 추정은 구축을 위한 것이지 축소 판단을 위한 것이 아니다. 예산표는 안전 마진을 얹어 잡는 게 정상이고, 그 마진을 실제 여유로 착각하면 반대 방향의 오판이 나온다. 축소는 재측정으로 시작해야 한다
RSS 합계가 used보다 큰 이유를 알아야 절감량을 계산할 수 있다
총량만으로는 무엇을 줄일지 알 수 없어 프로세스별로 쟀다
| 서비스 | RSS | 가동 시간 |
|---|---|---|
| chat | 477MB | 29시간 |
| tour | 437MB | 22시간 |
| notification | 432MB | 18시간 |
| community | 432MB | 29시간 |
| jobs | 426MB | 29시간 |
| auth | 397MB | 3시간 |
| gateway | 363MB | 29시간 |
| discovery | 358MB | 29시간 |
| 합계 | 3,322MB |
여기에 Kafka 503MiB, Redis 11.5MiB, Caddy 11.8MiB, dockerd 83MB, journald 118MB를 더하면 4,050MB다. 그런데 used는 3,894MB다. 부분의 합이 전체보다 크다
원인은 RSS의 정의다. RSS는 프로세스 주소 공간에 매핑된 물리 페이지를 세는데, 여러 프로세스가 공유하는 페이지도 각자 자기 몫으로 센다. JVM 8개는 같은 JDK를 쓰므로 libjvm.so나 클래스 데이터 공유 아카이브 같은 read-only 매핑을 공유하고, 그게 8번 중복 계상된다. 역산하면 JVM 하나의 실질 점유는 약 375MB로, 표시값 358~477MB보다 낮다
이 구분이 중요한 이유는 “JVM 하나를 없애면 얼마가 회수되는가”의 답이 RSS 표시값이 아니라 실질 점유값이기 때문이다
이 과정에서 삽질을 하나 했다. ps에 java 프로세스가 9개 잡혔는데 배포한 서비스는 8개였다. 실패한 재기동의 잔류 프로세스라고 판단하고 “정리하면 공짜로 400MB”라고 잠깐 기대했는데, 확인해 보니 Docker 컨테이너 안에서 도는 Kafka였다. 컨테이너 프로세스도 호스트의 ps에 그대로 보인다. 이미 docker stats로 따로 세고 있었으니 같은 것을 두 번 센 셈이었다
CPU가 놀고 있다는 건 튜닝 예산이 있다는 뜻이다
CloudWatch 14일치를 확인했다
- CPU 일 평균 3.2~6.4%
- 일 최대 100% — 배포할 때 8개 JVM이 동시 기동하는 스파이크뿐
CPUSurplusCreditsCharged0, 크레딧 모드unlimited
t3.large의 vCPU당 베이스라인은 30%다. 평균 5% 내외로 6분의 1만 쓰고 있었다. 다운사이징에 CPU 리스크가 없다는 뜻이고, 더 중요하게는 t3.large를 쓰는 이유가 순전히 메모리였다는 뜻이다. 8GB가 필요해서 8GB를 샀고 딸려 온 CPU는 놀고 있었다
이 관찰이 뒤에 나오는 JVM 튜닝의 근거가 된다. 처리량을 메모리와 맞바꾸려면 바꿔 줄 처리량이 남아 있어야 하는데, 여기서는 95%가 남아 있었다
무엇을 끌지는 제품 감각이 아니라 호출 그래프가 정했다
서비스를 끄는 판단을 기능 중요도로 하려면 제품 감각이 필요하다. 그보다 먼저 좁힐 수 있는 게 있다. 어떤 서비스를 끄면 다른 서비스가 함께 망가지는지는 코드로 답이 나온다. Feign 클라이언트를 전수 조사했다
chat → community (/internal/gatherings/{id}/chat-access, /participation)
community → tour, chat, notification
jobs → auth, notification
(아무도) → jobs
community는 끄기 비쌌다. 이름은 “커뮤니티”인데 실제로는 허브였다. chat이 community에서 모임 채팅방 접근 권한과 참여자를 확인하므로, community를 끄면 chat이 살아 있어도 모임 채팅은 접근이 불가능해진다. 포인트 원장도 community 소유라 tour의 산책 기록·코스 완주 적립 동선이 끊긴다. 공지사항·FAQ·1:1문의도 여기 있는데, 스토어 심사에서 문의 채널을 확인한다
jobs는 끄기 쌌다. jobs가 auth와 notification을 호출하지만 jobs를 호출하는 서비스는 하나도 없다. 완전한 리프 노드다
처음 제안한 두 서비스 중 하나는 안전하고 하나는 위험했다. 그리고 이 판단은 제품 우선순위를 따지기 전에 코드만 보고 나왔다. 모듈명이 소유 범위를 정직하게 말해 주지 않으므로, 끌 대상은 호출 그래프로 고른다
기능을 잃지 않는 절감처 1 — 단일 노드에서 Eureka는 값을 못 한다
EC2 한 대에 전부 올라가 있으면 서비스 디스커버리가 자기 자신 안에서만 돈다. 등록·헬스체크 대상이 모두 localhost의 고정 포트 8개다. 그 일을 위해 JVM 하나가 실질 375MB, used의 10%를 쓰고 있었다.
걸리는 건 주소 체계였다. 게이트웨이 라우팅이 lb://{service-name} + StripPrefix=1 패턴이고 서비스 간 호출도 @FeignClient(name = "auth-service")다. 이걸 http://localhost:8081 직결로 바꾸면 서비스마다 손봐야 하고 되돌리기도 비싸다.
그래서 주소 체계는 두고 해석 주체만 바꿨다. Spring Cloud LoadBalancer는 DiscoveryClient 구현으로 이름을 푸는데, spring-cloud-commons에 정적 목록을 읽는 구현이 들어 있다.
# 운영 외부 설정으로만 주입 — /opt/app/config/common/application.yaml
eureka:
client:
enabled: false
spring:
cloud:
discovery:
client:
simple:
instances:
auth-service:
- uri: http://127.0.0.1:8081
chat-service:
- uri: http://127.0.0.1:8082
community-service:
- uri: http://127.0.0.1:8083
notification-service:
- uri: http://127.0.0.1:8086
tour-service:
- uri: http://127.0.0.1:8087
게이트웨이의 uri: lb://auth-service도, Feign의 @FeignClient(name = "auth-service")도 그대로다. 애플리케이션 코드 변경이 0이다. 로컬 개발은 Eureka를 계속 쓰고 운영 설정 파일에서만 덮어썼다. discovery 모듈도 레포에서 지우지 않고 배포 목록에서만 뺐다.
조용히 깨지면 전면 장애라서 회귀 테스트로 고정했다
Spring Cloud Gateway 5는 WebFlux 기반이라 reactive DiscoveryClient가 필요하고 Feign은 blocking 쪽을 쓴다. 이 조합이 실제로 되는지 문서만으로는 확인이 안 됐다. 먼저 spring-cloud-commons 5.0.2 jar에서 blocking·reactive 구현이 다 들어 있는 것과 프로퍼티 키 타입을 확인했고, 그다음 실제로 부팅해서 확인한 뒤 테스트로 남겼다
요청 경로를 그대로 돌려주는 JDK HttpServer 스텁을 띄우고 그 포트를 정적 목록에 주입한 뒤 게이트웨이를 통해 호출한다
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = ["eureka.client.enabled=false"],
)
class StaticDiscoveryRoutingTest(
@param:Value("\${local.server.port}") private val port: Int,
) {
@Test
fun `Eureka 없이 정적 인스턴스 목록으로 lb 라우팅과 StripPrefix가 동작한다`() {
// /*/actuator/health는 permitAll이라 토큰 없이 라우팅 전 구간을 탄다
WebTestClient
.bindToServer()
.baseUrl("http://127.0.0.1:$port")
.build()
.get()
.uri("/auth-service/actuator/health")
.exchange()
.expectStatus()
.isOk
// 스텁이 돌려준 경로 = StripPrefix=1로 /auth-service가 떨어진 결과
.expectBody(String::class.java)
.isEqualTo("/actuator/health")
}
companion object {
private val stub: HttpServer =
HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
createContext("/") { exchange ->
val body = exchange.requestURI.path.toByteArray()
exchange.sendResponseHeaders(200, body.size.toLong())
exchange.responseBody.use { it.write(body) }
}
start()
}
@JvmStatic
@DynamicPropertySource
fun staticInstances(registry: DynamicPropertyRegistry) {
registry.add("spring.cloud.discovery.client.simple.instances.auth-service[0].uri") {
"http://127.0.0.1:${stub.address.port}"
}
}
}
}
응답 본문이 /actuator/health로 돌아온다는 것이 두 가지를 동시에 증명한다. 정적 목록으로 lb://auth-service가 해석됐고, StripPrefix=1이 접두사를 떼어 냈다는 것이다
필수라고 믿은 플래그가 필수가 아니었다
eureka.client.enabled=false가 반드시 있어야 한다고 생각했다. Eureka 클라이언트가 살아 있으면 그쪽이 DiscoveryClient를 제공하고 정적 목록은 무시될 거라고 봤기 때문이다. 확실히 하려고 플래그를 true로 바꿔 같은 테스트를 돌렸는데 통과했다
이유는 CompositeDiscoveryClient다. Spring Cloud는 여러 DiscoveryClient 빈이 있으면 합성해서 순서대로 조회한다. Eureka 서버가 없으니 그쪽은 빈 목록을 돌려주고 정적 목록 쪽이 인스턴스를 돌려준다. 둘이 경쟁하지 않고 합쳐진다
이 발견은 실무적으로 크다. 정적 목록을 넣는 것만으로 해석이 되므로 일부 서비스만 먼저 전환하는 점진 이행이 안전하다. 전환 도중 실패해서 절반만 새 설정으로 떠도 통째로 죽지 않는다
그럼에도 운영 설정에는 false를 넣었다. Eureka 서버가 없는데 클라이언트가 계속 등록을 재시도하면 로그 소음과 스레드·메모리가 낭비된다. 해석에 필수는 아니지만 넣는 게 맞다
기능을 잃지 않는 절감처 2 — 처리량을 메모리와 맞바꿨다
기존 기동 옵션은 -Xms128m -Xmx320m인데 RSS가 358~477MB다. 힙 상한보다 40~160MB 많은 건 힙 밖에서 나간다. G1의 리전 메타데이터와 remembered set, GC 워커 스레드, JIT 코드캐시, 메타스페이스 같은 것들이다
CPU 평균이 3~6%인 워크로드에 세대별 병렬 GC와 C2 최적화 컴파일러는 값을 못 한다
JAVA_OPTS=-Xms128m -Xmx320m -XX:+UseSerialGC -XX:TieredStopAtLevel=1
UseSerialGC는 힙이 작을 때 적절하다. G1은 리전 관리 메타데이터와 remembered set을 유지하고 코어 수만큼 GC 워커를 띄우는데, 320MB 힙에서는 그 관리 비용이 이득보다 크다. 대가는 full GC가 단일 스레드 stop-the-world라는 것인데, 320MB면 수백 밀리초 수준이라 시연 서버에서는 수용 가능하다고 봤다
TieredStopAtLevel=1은 C1까지만 쓰고 C2를 끈다. C2 컴파일러 스레드가 사라지고 코드캐시 커밋량이 준다. 대가는 최대 처리량 저하이고 핫 루프가 2~5배 느려질 수 있다. 앞서 CPU가 95% 남아 있음을 확인했기 때문에 감당할 수 있다고 판단했고, 지연이 문제가 되면 이 두 옵션을 먼저 빼도록 스크립트 주석에 적었다
-Xmx는 건드리지 않았다. 320MB를 256MB로 내리면 서비스당 64MB가 직접 절감돼 GC·JIT 튜닝보다 효과가 클 수도 있다. 하지만 실제 힙 사용량을 재지 않은 상태에서 낮추는 건 추측이고, OOM은 되돌리기가 비싸다
이 시점의 추정 절감은 서비스당 50~80MB였다. 이 추정도 나중에 틀렸다
컨텍스트 하나를 옮겨 JVM을 하나 더 줄였다
출시 시점에 jobs에서 살릴 기능은 공공 구인정보 조회뿐이었다. 자격증 등록, 돌봄 수요 공고, 지원·매칭은 실사용자와 기관 확보가 전제라 출시 범위 밖이다. 컨텍스트 하나를 위해 JVM 한 대(375MB)를 계속 낼 수는 없어서, 그 컨텍스트를 옮길 수 있는지 감사했다
| 검사 항목 | 결과 |
|---|---|
| DB 사용(jOOQ·DSLContext·Repository) | 없음. Flyway 마이그레이션 7개 중 관련 테이블 0개 |
| 서비스 간 호출(Feign) | 없음 |
| 같은 모듈 내 다른 컨텍스트 참조 | 없음 |
| 외부 의존 | 공공 API 하나 + Caffeine 캐시뿐 |
완전히 자립한 무상태 외부조회 컨텍스트였다. 처음엔 community로 옮기려 했다. 그런데 그 컨텍스트의 캐시 설정 주석이 답을 이미 적어 두고 있었다
// 공공 구인정보는 무DB 조회 — Caffeine 캐시로 개발계정 쿼터(10,000/일)를 보호한다 (tour ADR 0003 이식)
tour의 패턴을 복제한 코드였다. tour는 이미 관광정보·기상청·의료기관 공공 API를 Caffeine으로 감싼다. 무DB 외부조회, 일일 쿼터 보호, TTL 정책, 기동 시 캐시 워밍까지 같은 문제를 같은 방식으로 풀고 있었다. 반면 community는 DB 중심 허브이고, 무엇보다 가장 죽으면 안 되는 서비스의 장애 반경을 넓히는 일이다. 옮기려는 컨텍스트가 호출하는 공공 API는 호출당 7~9초짜리 상류다. 그래서 tour로 옮겼다
게이트웨이 라우트는 경로를 유지하고 대상만 바꿨다. StripPrefix=1이 첫 세그먼트를 떼므로 /jobs-service/public-jobs는 tour의 /public-jobs로 도달한다. 앱은 호출 주소를 바꾸지 않아도 되고, jobs를 되살릴 때는 uri 한 줄만 원복하면 된다
- id: jobs-service
uri: lb://tour-service # 기존: lb://jobs-service
predicates:
- Path=/jobs-service/**
filters:
- StripPrefix=1
빈 충돌은 정의 쪽이 아니라 소비 쪽에서 났다
이관 전에 위험을 예측해 뒀다. “SenuriClientConfig가 정의하는 HTTP 클라이언트 빈이 tour의 빈과 타입 충돌하지 않는지 확인 필요”라고 적었다. 충돌은 있었는데 위치가 달랐다
예측한 대로 걸린 건 cacheManager 빈 이름 중복이었다. 양쪽이 같은 이름으로 SimpleCacheManager를 만드는데, Spring Boot는 빈 정의 오버라이딩이 기본 비활성이라 BeanDefinitionOverrideException으로 기동 자체가 실패한다. tour의 CacheConfig에 캐시 목록을 합치고 이관하는 쪽 설정 클래스를 지웠다. 캐시 엔트리 상한도 tour가 5,000, 이관 대상이 2,000이라 그냥 합치면 상한이 2.5배가 된다. 헬퍼가 캐시별 상한을 받도록 확장해 2,000을 유지했다
예상 못 한 폭탄은 RestClient 주입이었다. 정의 쪽은 안전했다. 빈 이름이 senuriRestClient로 명확히 붙어 있었다. 문제는 소비 쪽이었다
@Component
class SenuriClient(
private val restClient: RestClient, // ← 타입으로 주입
private val xmlMapper: XmlMapper,
private val properties: SenuriProperties,
)
원래 모듈에는 RestClient 빈이 하나뿐이라 타입 해석이 성공했다. 그런데 tour에는 이미 courseGpxRestClient가 있다. 후보가 둘이 되고 Spring은 이름으로 폴백하는데 restClient라는 이름의 빈이 없다. NoUniqueBeanDefinitionException으로 tour 기동이 실패한다. 이관 전에 발견하지 못했으면 배포 시점에 터졌다. 해법은 파라미터명을 빈 이름에 맞추는 것이었고, 그 프로젝트가 이미 쓰던 관례였다
// 파라미터명이 빈 이름과 같아야 한다 — tour에는 courseGpxRestClient도 있어 RestClient 후보가 둘이다
@Component
class SenuriClient(
private val senuriRestClient: RestClient,
private val senuriXmlMapper: XmlMapper,
private val properties: SenuriProperties,
)
@Bean fun senuriRestClient()처럼 이름을 잘 붙여도 소비자가 타입으로 주입받으면 결과가 대상 컨텍스트에 같은 타입 빈이 몇 개 있느냐에 따라 갈린다. 모듈 간 컨텍스트 이관에서는 정의뿐 아니라 주입 지점을 전수 확인해야 한다
아예 예상하지 못한 것도 있었다. tour의 기존 @RestControllerAdvice가 basePackages를 서비스 루트로 잡고 있어서 이관된 컨텍스트까지 덮었는데, 양쪽 advice가 모두 IllegalArgumentException을 처리하고 @Order가 없었다. 같은 순서면 어느 쪽이 먼저 조회되는지가 빈 등록 순서에 따라 갈리므로 비결정적이다. 지금은 양쪽 응답이 같아서 동작상 문제가 없지만, 한쪽이 갈라지면 조용히 바뀐다. 이관이 만든 모호성이라 순서를 못박았다
// TourExceptionHandler가 tour 전역을 스코프로 잡아 publicjob까지 덮으므로 순서를 못박는다 — // 둘 다 IllegalArgumentException을 처리해서, 명시하지 않으면 어느 쪽이 잡을지 비결정적이다 @Order(Ordered.HIGHEST_PRECEDENCE) @RestControllerAdvice(basePackages = ["com.example.app.tour.publicjob"]) class PublicJobExceptionHandler
그리고 아무것도 지우지 않았다. jobs 모듈, DB, Flyway 마이그레이션 7개, 나머지 4개 컨텍스트를 전부 레포에 남기고 deploy.sh의 배포 목록에서만 뺐다. 지운 게 아니라 안 띄운 것이다
코드보다 배포에서 더 걸렸다
멱등 스크립트가 반영을 막았다. EC2 셋업 스크립트가 “멱등”을 기존 파일 보존으로 구현해 뒀다. 신규 프로비저닝에는 맞다. 사용자가 기입한 시크릿을 재실행이 덮어쓰면 안 되니까. 그런데 이미 뜬 서버에 새 설정을 넣을 수단이 없다. 이번엔 env 파일에 공용 설정 경로와 JVM 옵션을 둘 다 넣어야 했고, 특히 앞의 것이 누락된 채 Eureka를 끄면 전 서비스가 503이다. 보존 원칙을 유지하면서 필요한 줄만 덧대는 루프를 추가했다
for f in /opt/app/env/*.env; do
# 공용 설정 경로가 없으면 앞에 덧댄다
if ! grep -q '/config/common/' "$f"; then
sed -i 's#^SPRING_CONFIG_ADDITIONAL_LOCATION=#SPRING_CONFIG_ADDITIONAL_LOCATION=optional:file:/opt/app/config/common/,#' "$f"
fi
# 기동 옵션은 기본값 그대로인 것만 갱신한다 — 직접 손댄 값은 보존
if grep -q '^JAVA_OPTS=-Xms128m -Xmx320m$' "$f"; then
sed -i "s#^JAVA_OPTS=-Xms128m -Xmx320m\$#JAVA_OPTS=$JVM_OPTS#" "$f"
fi
done
JAVA_OPTS는 값이 정확히 기본값일 때만 갱신한다. 누군가 특정 서비스의 힙을 늘려 뒀다면 의도된 변경이므로 보존해야 한다
SSM 터널은 대량 전송에 느리다. 보안 그룹의 22번을 닫고 SSM Session Manager로 접속하는 구조인데, fat jar 6개 570MB를 보내니 실측 870KB/s, 전송만 11분이 걸렸다. scp -q라 출력이 완전히 멎어서 5분쯤 지나면 멈춘 건지 판단이 안 된다. 서버 쪽에서 도착 중인 파일 크기를 두 번 찍으면 된다
ssh <host> 'ls -l /opt/app/jars/*.jar.new; sleep 15; echo ---; ls -l /opt/app/jars/*.jar.new'
전송 단계는 .jar.new로 staging만 하고 실제 교체와 재기동은 그다음이다. 전송 중에 중단해도 돌고 있는 서비스에는 영향이 없다
최초 기재본이 두 군데 틀렸다. 하나는 순서다. 처음 적은 절차는 배포를 먼저 하고 셋업을 나중에 뒀다. 그러면 서비스가 옛 env로 한 번 뜨고, 셋업이 env를 고친 뒤 또 재기동해야 한다. systemd는 EnvironmentFile을 프로세스 시작 시점에 읽으므로 파일이 바뀌어도 돌고 있는 프로세스에는 반영되지 않는다. 다른 하나는 누락이다. 부트스트랩 업로드 단계가 통째로 빠져 있었다. 실제로 통한 순서는 이렇다
# ① 새 부트스트랩 업로드 (scp -r은 대상이 있으면 중첩되므로 먼저 지운다)
ssh <host> 'rm -rf ~/bootstrap/ec2 ~/bootstrap/systemd ~/bootstrap/sql'
scp -r ec2 systemd sql ec2-user@<host>:~/bootstrap/
# ② 공용 설정 생성 + env 마이그레이션 — 배포보다 먼저여야 재기동이 1회로 끝난다
ssh -t <host> 'bash ~/bootstrap/ec2/setup.sh <rds-host>'
# ③ 이관에 따라 옮겨진 시크릿 이동 (④ 뒤로 가면 tour를 한 번 더 재기동해야 한다)
ssh <host> 'V=$(grep "^API_KEY=" /opt/app/env/jobs.env)
sed -i "/^API_KEY=$/d" /opt/app/env/tour.env
echo "$V" >> /opt/app/env/tour.env'
# ④ 빌드·배포·순차 재기동 (6개)
./deploy.sh <host>
# ⑤ 옛 유닛 정지 — 여기서 실제로 메모리가 회수된다
ssh <host> 'sudo systemctl disable --now app@discovery app@jobs'
⑤를 마지막에 둔 건 의도적이다. ④가 중간에 실패하면 옛 jar들이 아직 Eureka로 서로를 찾고 있어야 롤백 경로가 남는다
⑤가 없으면 절감이 0이다. 이건 배포하다가 발견한 게 아니라 배포 전에 ADR에 적어 둔 것이다
배포 목록에서 빼도 이미 떠 있는 유닛은 살아 있다. 전환 시
sudo systemctl disable --now app@discovery를 한 번 실행해야 실제로 메모리가 회수된다.
적어 둔 대로였다. ④까지 마친 시점에 유닛은 8개가 running이었고, ⑤ 이후 6개만 남았다
⑤ 전에 env 마이그레이션 반영 여부를 확인해야 한다. 안 걸린 채로 Eureka를 끄면 전 서비스가 503이다
ls -l /opt/app/config/common/application.yaml grep -l "/config/common/" /opt/app/env/*.env | wc -l ps -eo args | grep "[a]pp/jars" | grep -c "UseSerialGC"
세 번째가 좋은 대리 지표다. JAVA_OPTS와 SPRING_CONFIG_ADDITIONAL_LOCATION이 같은 env 파일에서 오므로, 프로세스에 새 GC 옵션이 붙었다는 건 그 프로세스가 마이그레이션된 env를 읽고 시작했다는 뜻이다. 결과는 파일 존재, env 8개, 프로세스 6개였다. 6이 맞는 수다. discovery와 jobs는 재기동되지 않아 옛 환경을 쥐고 있다
404가 성공 신호다
배포 후 라우팅을 확인해야 하는데 게이트웨이가 대부분 경로에 JWT를 요구한다. 보안 설정을 보니 /*/actuator/health가 permitAll이었다. 이 경로는 라우팅과 StripPrefix를 다 타면서 토큰이 필요 없고, 이 프로젝트는 actuator 의존성을 넣지 않았으므로 대상 서비스에 도달하면 404가 난다
GET https://api.example.com/auth-service/actuator/health → 404 GET https://api.example.com/jobs-service/actuator/health → 404 GET https://api.example.com/tour-service/actuator/health → 404
라우팅이 실패했으면 인스턴스를 못 찾아 503이 나온다. 404와 503의 구분이 그대로 판정 기준이 된다. 첫 줄은 Eureka 프로세스가 죽은 상태에서 게이트웨이가 auth를 찾아냈다는 실증이고, 둘째 줄은 jobs가 정지된 상태에서 그 경로가 tour에 도달했다는 증거다
43% 줄었다. 여기서 결론을 내면 틀린다
used가 3,894MB에서 2,239MB로 떨어졌다. 43% 감소다. 서비스당 138~170MB가 줄었으니 추정했던 50~80MB의 2배다
여기서 “JVM 튜닝이 예상의 2배 효과를 냈다”고 결론 냈다면 틀렸을 것이다. 여섯 서비스가 방금 재기동됐기 때문이다. 메타스페이스는 클래스가 로드되면서 커지고 코드캐시는 JIT 컴파일이 진행되면서 커진다. 둘 다 실제 요청이 코드 경로를 밟아야 진행되므로 트래픽이 적은 시연 서버에서는 시간이 걸린다
이전 측정에서 그 증거를 이미 보고 있었다. auth만 가동 시간이 3시간이고 나머지는 18~29시간이었다. 즉 이전 측정도 완전한 정상상태가 아니었고, 배포 직후 측정은 여섯 개가 모두 차가운 상태였다. 두 상태를 비교하면 튜닝 효과와 워밍업 효과가 섞인다. 배분을 모른 채 다운사이징을 결정하면 차가운 상태의 여유를 실제 여유로 착각하는 것이다
그래서 48시간 기다렸다. 재기동하면 워밍업이 리셋돼 측정이 무효가 되므로 서버를 건드리는 작업은 전부 미뤘다. 문서에도 “측정 중이라면 이 작업을 미룬다”로 적어 뒀다
커밋 로그가 그 48시간을 그대로 보여준다
07-27 15:45 배포 결과 기록 (25cdd59) 07-27 21:28 CI 설정 변경 (ecdcf8e) ← 서버를 안 건드리는 작업 07-28 커밋 0건 (전 브랜치) 07-29 09:50 재측정 결과 기록 (b5a574e)
대기 직전 마지막으로 한 일이 “문서만 바뀐 푸시를 CI에서 제외”인 것도 같은 이유다. EC2를 건드리지 않는 일만 골라서 했다. 07-28은 하루 종일 커밋이 0건이다
48시간 후 — 두 추정이 반대 방향으로 틀렸다
| 이전(warm·미튜닝) | 배포 직후(cold) | 48시간(warm) | |
|---|---|---|---|
used | 3,894MB | 2,239MB | 2,431MB |
| chat | 477 | 307 | 339 |
| community | 432 | 288 | 328 |
| notification | 432 | 294 | 315 |
| tour | 437 | 270 | 311 |
| auth | 397 | 258 | 295 |
| gateway | 363 | 209 | 233 |
워밍업이 되돌린 양은 서비스당 +32MB, 총 195MB였다. 나는 80~120MB로 예상했다. 이번엔 반대 방향으로 틀렸다
그 추정은 chat(29시간, 477MB)과 auth(3시간, 397MB)의 차이 80MB를 워밍업 몫으로 본 것이었다. 그런데 그 둘은 다른 서비스다. chat은 WebSocket 세션을 유지하고 community는 배치를 돈다. 서비스 간 차이를 시간 차이로 읽었다
이 시점에야 같은 서비스의 warm 대 warm 비교가 가능해졌다. 이전도 warm(auth 제외), 지금도 warm이므로 차이가 곧 튜닝 효과다
| 서비스 | 튜닝 효과(warm → warm) |
|---|---|
| chat | −138MB |
| gateway | −130MB |
| tour | −126MB |
| notification | −117MB |
| community | −104MB |
| auth | −102MB |
| 평균 | −120MB/서비스 |
추정 50~80MB, 실측 120MB로 1.5~2배다. tour는 이관으로 클래스 22개와 캐시 2개를 새로 받고도 126MB가 줄었으니 순수 튜닝 효과는 더 크다. 최종 배분은 JVM 두 개 제거로 −784MB, JVM 튜닝으로 −717MB로 두 몫이 비슷했다
회의록에 적어 둔 정상상태 예측은 2,966~3,156MB였는데 실제는 2,431MB였다. 500~700MB 과대 예측이고, 원인은 워밍업 보정을 한 서비스에서 외삽한 것이다
측정이 끝나자 Graviton 계획이 뒤집혔다
작업 초반에 비용 구조를 보고 “최대 레버는 인스턴스 축소가 아니라 Graviton 전환과 1년 약정”이라고 결론 냈다. 그리고 순서를 이렇게 정했다. x86에서 arm64로 가려면 인스턴스를 재생성해야 하지만 t4g.large → t4g.medium은 정지 후 타입 변경으로 끝난다. 따라서 아키텍처 변경을 먼저 한 번만 하고 크기 조정은 그 뒤에 하는 게 재생성 횟수를 최소화한다
정상상태가 2,431MB로 확정되면서 최종 크기가 medium으로 정해지자 이 논리가 무너졌다
| 경로 | 아키텍처 변경 | 절차 |
|---|---|---|
| t3.large → t3.medium | 없음 | 정지 → 타입 변경 → 시작. EBS·EIP·설정·인증서 전부 그대로 |
| t3.large → t4g.medium | 필요 | 인스턴스 재생성. x86 EBS 루트 볼륨은 arm64에서 부팅 불가 |
“아키텍처 변경을 먼저 한 번만”은 t4g로 간다는 전제에서만 성립한다. t4g를 아예 하지 않으면 재생성이 0회고, 크기가 medium으로 정해진 뒤에는 t4g.large를 경유할 이유도 사라졌다
| EC2 | 월 총액 | 145,000원 대비 | |
|---|---|---|---|
| t3.large (전환 전) | $75.9 | 145,000원 | — |
| t3.medium | $38.0 | 92,000원 | −53,000원 |
| t3.medium + 1년 약정 | $27.4 | 78,000원 | −67,000원 |
| t4g.medium + 1년 약정 | $21.9 | 70,000원 | −75,000원 |
t3.medium 하나로 전체 절감 가능액의 73%를 위험 0으로 확보한다. t4g가 더 얹는 건 마지막 월 8,000원이다
그 8,000원의 대가는 이렇다. 인스턴스 재생성이므로 시연 서버에 다운 창이 생긴다. RDS 비밀번호, 내부 인증 토큰, 소셜 로그인 시크릿, 공공 API 키 두 종, 푸시 서비스 계정 JSON을 손으로 옮겨야 한다. ~/.ssh/config의 SSM ProxyCommand가 HostName에 인스턴스 ID를 쓰고 있어서 갱신하지 않으면 ssh·scp·deploy.sh가 전부 막힌다. Caddy의 Let’s Encrypt 인증서도 옮기거나 재발급받아야 한다. 공모전 준비 기간에 월 8,000원을 위해 치를 위험이 아니라고 판단했다
대신 재검토 트리거를 문서에 적었다. 서비스 지속이 확정될 때, 메모리가 다시 4GB를 압박할 때, 그리고 1년 약정을 사기 전이다. 세 번째가 순서 함정이다. Savings Plan은 시간당 금액에 거는 약정이라 t4g로 갈 생각이 있으면 약정보다 먼저 가야 한다. 약정 구매 시점이 t4g 판단의 데드라인이다
“20% 절감”이라는 비율은 절대액으로 환산해야 판단이 된다. 20%는 크게 들리지만 이 경우 8,000원이었다
t3.medium 전환은 3~5분이었다
기존 stop.sh는 쓰지 않았다. 그 스크립트는 EC2와 RDS를 함께 내리는데, 이번엔 타입 변경이 목적이라 EC2만 내리면 된다. RDS까지 내리면 복귀할 때 db-instance-available을 5~10분 기다린다
aws ec2 stop-instances --region <region> --instance-ids i-0abc123def456789 aws ec2 wait instance-stopped --region <region> --instance-ids i-0abc123def456789 aws ec2 modify-instance-attribute --region <region> \ --instance-id i-0abc123def456789 --instance-type Value=t3.medium aws ec2 start-instances --region <region> --instance-ids i-0abc123def456789 aws ec2 wait instance-running --region <region> --instance-ids i-0abc123def456789
EIP는 정지·시작으로 끊기지 않으므로 DNS A 레코드도 ~/.ssh/config도 그대로다. 인스턴스 ID가 바뀌지 않으니 SSM 접속도 그대로다. 이게 t4g와의 결정적 차이다. instance-running은 하이퍼바이저가 켰다는 뜻일 뿐이라 OS 부팅과 서비스 기동에 2~4분이 더 걸리는데, 컨테이너는 restart: unless-stopped, systemd 유닛은 enabled라 자동 복구된다. 되돌리기는 같은 절차에 Value=t3.large를 넣으면 된다
total used free shared buff/cache available Mem: 3839 2137 567 1 1134 1486 Swap: 4095 0 4095
total 3,839MB는 7,823MB의 절반이니 타입 변경이 반영됐다. used 2,137MB는 전체의 56%인데 콜드 상태이므로, 앞서 잰 워밍업 복귀분 195MB를 더하면 정상상태는 약 2,330MB, 61%로 예상된다.
CPU 크레딧 모드는 unlimited로 뒀다. t3.medium은 베이스라인이 20%라 여섯 개 JVM 동시 기동 스파이크가 넘을 게 분명해서 standard로 바꿀지 고민했다. 재 보니 기동 스파이크가 CPU 최대 64.7%, 5분 평균 18.95%를 쳤는데도 CPUSurplusCreditsCharged가 0이었다. 시간당 24크레딧을 적립하고 평소 CPU 3%면 3.6크레딧만 쓰니 잔고가 상한(576크레딧)에 붙어 있다. 기동 스파이크는 6.5크레딧 정도라 잔고에서 나가고 남는다.
놓친 것 — 실패 경로의 상태 코드도 계약이다
라우트를 /jobs-service/** → tour로 넘길 때, 살아 있는 경로만 보고 나머지 경로의 실패 의미가 바뀌는 걸 놓쳤다. 문서에 “클라이언트 계약 보존”이라고 적었는데, 그건 공공 구인정보 경로에 대해서만 맞는 말이었다
- 이전: jobs가 정지되면 인스턴스를 못 찾아 503 Service Unavailable
- 이후: 라우트가 tour로 가고 tour에 그 경로가 없으니 404 Not Found
문제는 앱이 404를 다르게 읽는다는 것이다
Future<SeekerProfileResponse?> seekerProfile() async {
try {
final data = (await _api.seeker()).data;
...
} on DioException catch (exception) {
if (exception.response?.statusCode == 404) return null; // "프로필 미등록"으로 해석
rethrow;
}
}
프로필 조회에서 404는 “아직 등록하지 않았다”는 정상 응답이라 화면이 빈 폼으로 시작한다. 서비스가 없어진 상황인데 정상 흐름처럼 보인다. 저장을 누르면 PUT도 404가 나고 그때는 rethrow돼서 에러가 뜬다. 사용자 눈에는 “저장이 안 되는 버그”다. 서비스 중단이 정상 동작으로 위장되는 게 깨끗한 503보다 나쁘다
처방은 라우트를 쪼개는 것이다. 살아 있는 경로만 tour로 보내고 나머지는 인스턴스가 없는 서비스명으로 남겨 503이 나게 한다. 앞선 경로가 먼저 평가되므로 선언 순서가 중요하다
- id: jobs-public uri: lb://tour-service predicates: [Path=/jobs-service/public-jobs/**] filters: [StripPrefix=1] - id: jobs-service uri: lb://jobs-service # 정적 목록에 인스턴스 없음 → 503 predicates: [Path=/jobs-service/**] filters: [StripPrefix=1]
곁가지에서 나온 측정 버그가 더 무서웠다
GitHub Actions 무료 할당량이 빡빡해서 문서만 바뀐 푸시에는 CI를 돌리지 않으려고 커밋 빈도를 셌다. 세다 보니 몇 주 전에 같은 검토를 하고 기각한 기록이 있었다
paths-ignore로 docs 커밋 제외 — 기각. 7월 dev 커밋 403건 중 docs-only는 6건(1.5%)뿐이다.
내가 같은 기간을 세니 41%가 나왔다. 20배 차이였다
원인은 git이 기본 설정(core.quotepath=true)에서 비ASCII 경로를 따옴표로 감싸 출력한다는 것이다
$ git diff --name-only HEAD^ HEAD "docs/2026-07-25-actions-\355\225\240\353\213\271\353\237\211-\354\240\210\352\260\220/README.md"
그러면 grep -E '^docs/'가 첫 글자 "에 막혀 매칭되지 않는다. 이 프로젝트는 작업 폴더명에 한글을 쓰므로 거의 전량이 “코드 변경”으로 오분류된다. 재현해 보니 기본값에서 8건, core.quotepath=false에서 172건이었다. 옛 기록의 “6건”이 이 버그였음이 확정됐다. 그리고 나도 첫 집계에서 같은 함정에 빠져 11건을 1건으로 셌다. 같은 실수를 두 번 했다.
# 반드시 이렇게 git -c core.quotepath=false diff --name-only "$h^" "$h"
정정된 숫자로 다시 보니 main 승격 77건 중 11건(14%)이 문서 전용이었고, 승격 1회당 4.6분이라 누적 50분을 태우고 있었다. paths-ignore를 넣었다. 이건 바뀐 파일이 전부 무시 대상일 때만 건너뛰므로 코드와 문서가 섞인 푸시는 정상 실행되고 검증 공백이 생기지 않는다
집계 스크립트가 내는 오답은 조용히 결정을 바꾼다. “docs-only가 1.5%뿐”은 충분히 있을 법한 숫자라 아무도 의심하지 않았다. 결정의 근거가 되는 집계는 소수 표본을 손으로 검산해야 한다
최종 결과
| 전환 전 | 최종 | |
|---|---|---|
| 배포 서비스 | 8개 | 6개 |
used / total | 3,894 / 7,823MB | 2,431 / 3,839MB |
| 인스턴스 | t3.large (2 vCPU, 8GB) | t3.medium (2 vCPU, 4GB) |
| 스왑 사용 | 0 | 0 |
| 월 비용 | 145,000원 | 92,000원 |
처음 세운 안과 같은 인스턴스에 도달했지만 서비스를 하나도 잃지 않았다. community는 그대로 돌고, jobs에서 출시에 필요한 기능은 tour가 담고 있다. 사라진 건 단일 노드에서 값을 못 하던 Eureka와, 출시 범위 밖이던 jobs의 나머지 컨텍스트들이다. 후자는 지운 게 아니라 안 띄운 것이므로 되살릴 수 있다
이 방법이 통하지 않는 조건
이 결과는 단일 노드라는 전제에 크게 기댄다. Eureka를 뺄 수 있었던 건 인스턴스가 하나라 서비스 디스커버리가 자기 자신 안에서만 돌았기 때문이다. 노드가 둘 이상이면 정적 목록은 답이 아니고, 지금 방식은 서비스나 포트를 추가할 때마다 설정 파일을 손으로 갱신해야 한다. 누락하면 그 서비스만 조용히 503이 된다. 실패가 시끄럽지 않다는 게 위험해서 정본 문서와 세션 메모리 양쪽에 “서비스 추가 시 이 파일 갱신 필수”를 박아 뒀다
JVM 튜닝도 마찬가지다. TieredStopAtLevel=1은 처리량을 내주고 메모리를 받는 거래이므로 CPU가 놀고 있는 워크로드에서만 성립한다. 트래픽이 붙으면 가장 먼저 뺄 옵션이다
로컬은 Eureka, 운영은 정적 목록이라 구성이 갈린 것도 비용이다. “로컬에선 되는데 운영에서 안 되는” 부류의 위험이 생겼고, 완화책으로 운영 설정 파일을 레포 관리 대상에 두고 회귀 테스트 두 건으로 정적 해석을 고정했다
그리고 Kafka 503MB는 손대지 않았다. 단일 최대 프로세스로 JVM 1.3개 몫인데, 알림 파이프라인 전체가 Kafka 컨슈머 기반이라 공사가 크다. -Xmx도 내리지 않았다. 실제 힙 사용량을 재지 않은 상태에서 낮추는 건 추측이기 때문이다
정리
이 작업에서 내가 재기 전에 세운 추정 중 맞은 게 거의 없다
뺄 서비스로 community와 jobs를 골랐는데 호출 그래프를 보니 community는 허브였다. 예산표를 근거로 “안 들어간다”고 했는데 실측은 900MB 낮았다. 튜닝 효과를 50~80MB로 봤는데 120MB였다. 워밍업 복귀분을 80~120MB로 봤는데 32MB였다. 정상상태를 2,966~3,156MB로 예측했는데 2,431MB였다. 빈 충돌이 설정 클래스에서 날 줄 알았는데 소비 쪽에서 났다. “아키텍처 변경을 먼저 한 번만”이라는 순서 논리를 세워 놓고 측정 후에 스스로 뒤집었다
공통점이 있다. 틀린 건 대부분 재기 전에 정한 것들이고, 맞은 건 재고 나서 정한 것들이다. 그리고 틀린 방향이 일정하지 않았다. 어떤 건 보수적으로, 어떤 건 낙관적으로 틀렸다. “안전하게 여유를 두고 추정했다”는 말도 믿을 수 없다는 뜻이다
한 줄로 요약하면, 서버 비용을 줄일 때 가장 먼저 할 일은 인스턴스 타입을 고르는 게 아니라 free -m을 찍는 것이다. 문서에 적힌 예산표가 아무리 최근 것이라도 축소 판단의 근거로는 쓰지 않는다. 추정으로 내려야만 하는 결정에는 “언제 다시 재서 뒤집을지”를 함께 박아 둔다. 그리고 측정이 끝나기 전에는 결론을 내지 않는다. 07-28에 커밋이 0건인 건 할 일이 없어서가 아니라 그 하루가 측정 시간이었기 때문이다. 이번에 실제로 값을 한 습관은 그 셋이었다
용어 정리
본문에 나온 용어를 필요한 만큼만 정리했다
메모리와 JVM
RSS (Resident Set Size) — 프로세스가 실제로 물리 메모리에 올려 둔 양이다. 여러 프로세스가 공유하는 페이지도 각자 자기 몫으로 세기 때문에, JVM 여러 개의 RSS를 더하면 실제 사용량보다 커진다
free -m의 used — 커널이 집계한 실제 사용량이다. 공유분을 한 번만 세므로 총량 판단은 이 값으로 한다. 같은 표의 available은 지금 새 프로세스에 내줄 수 있는 양이고, buff/cache는 회수 가능한 페이지 캐시라 부족의 신호가 아니다
스왑(Swap) — 물리 메모리가 부족할 때 디스크로 밀어내는 영역이다. 스왑 사용량이 0이면 메모리가 압박받지 않았다는 뜻이다
힙과 -Xms/-Xmx — 힙은 애플리케이션 객체가 사는 영역이고 -Xms는 초기 크기, -Xmx는 상한이다. 프로세스가 쓰는 메모리는 힙만이 아니라서 -Xmx320m인데도 RSS가 400MB를 넘을 수 있다
메타스페이스 — 로드된 클래스의 정의를 담는 힙 밖 영역이다. 코드 경로가 새로 밟힐수록 클래스가 로드되면서 커진다
JIT와 코드캐시 — JVM은 자주 실행되는 바이트코드를 기계어로 컴파일해 코드캐시에 담아 둔다. 실행이 진행되면서 커지므로, 방금 뜬 프로세스는 코드캐시가 거의 비어 있다
워밍업 — 클래스 로딩과 JIT 컴파일이 실제 요청을 따라 진행되면서 메모리 점유가 올라가다 평탄해지는 과정이다. 방금 재기동한 프로세스(cold)와 며칠 돌아간 프로세스(warm)를 비교하면 안 되는 이유다
OOM (OutOfMemoryError) — 힙 상한 안에서 객체를 더 만들 수 없을 때 나는 오류다. 프로세스가 죽으므로 -Xmx를 추측으로 낮추면 되돌리는 비용이 크다
G1 GC — 최근 JDK의 기본 가비지 컬렉터다. 힙을 리전으로 쪼개 관리하고, 리전 간 참조를 추적하는 remembered set과 CPU 코어 수만큼의 GC 워커 스레드를 유지한다. 힙이 큰 서버에서는 이득이지만 320MB 힙에서는 그 관리 구조 자체가 비용이다
SerialGC — 단일 스레드로 도는 가장 단순한 GC다. 리전 관리 구조가 없어 메모리를 덜 쓰는 대신, full GC가 애플리케이션을 전면 정지시킨 채(stop-the-world) 한 스레드로 돈다
C1 / C2와 TieredStopAtLevel=1 — JVM에는 컴파일러가 두 개 있다. C1은 빠르게 컴파일하고 최적화가 약하며, C2는 느리게 컴파일하고 최적화가 강하다. TieredStopAtLevel=1은 C1까지만 쓰고 C2를 끄는 옵션으로, 컴파일러 스레드와 코드캐시를 줄이는 대신 최대 처리량을 내준다
AWS
EBS — 인스턴스에 붙는 네트워크 블록 스토리지다. 인스턴스 타입을 바꿔도 그대로 유지되지만, x86용 루트 볼륨은 arm64 인스턴스에서 부팅되지 않는다
EIP (Elastic IP) — 인스턴스를 정지·시작해도 유지되는 고정 공인 IP다. 연결돼 있어도 요금이 붙는다
RDS — AWS가 관리하는 관계형 DB 서비스다. 인스턴스와 별도로 과금되고, 정지 후 다시 켜면 사용 가능 상태가 되기까지 몇 분이 걸린다
CloudWatch — AWS 지표 수집 서비스다. CPU 사용률이나 크레딧 소비 같은 값을 기간별로 조회한다
CPU 크레딧과 베이스라인 — t 계열 인스턴스는 vCPU당 정해진 비율(t3.large 30%, t3.medium 20%)까지가 기본이다. 그 아래로 쓰면 크레딧이 쌓이고 넘게 쓰면 쌓인 크레딧을 소비한다
unlimited / standard — 크레딧이 바닥났을 때의 동작 모드다. unlimited는 초과분을 과금하고 성능을 유지하며, standard는 과금 없이 베이스라인으로 성능을 제한한다
CPUSurplusCreditsCharged — unlimited 모드에서 실제로 과금된 초과 크레딧 지표다. 이 값이 0이면 스파이크가 있었어도 쌓인 크레딧 안에서 처리됐다는 뜻이다
Graviton, t3 / t4g, arm64 — t4g는 AWS가 만든 arm64 프로세서(Graviton) 기반 인스턴스로 t3보다 20% 정도 싸다. CPU 아키텍처가 다르므로 옮기려면 인스턴스를 새로 만들어야 한다
Savings Plan — 1년 또는 3년 사용을 약정해 시간당 단가를 낮추는 요금제다. 약정이 시간당 금액에 걸리므로, 아키텍처를 바꿀 계획이 있으면 약정보다 먼저 바꿔야 한다
SSM Session Manager — SSH 포트를 열지 않고 AWS 에이전트를 경유해 서버에 접속하는 방식이다. 보안에는 좋지만 웹소켓 채널이라 대용량 전송이 느리다
Caddy — 리버스 프록시 웹서버다. Let’s Encrypt 인증서를 자동으로 발급·갱신해 HTTPS를 종단한다
Spring과 MSA
MSA와 모노레포 멀티모듈 — 기능을 여러 서비스로 쪼개 각각 독립 프로세스로 띄우는 구조가 MSA다. 이 프로젝트는 그 서비스들의 소스를 한 저장소의 모듈로 두고 배포만 나눈다
서비스 디스커버리와 Eureka — 서비스가 자기 주소를 등록해 두고 서로를 이름으로 찾게 해 주는 장치다. Eureka는 Spring Cloud에서 쓰는 구현이며, 서버가 별도 프로세스로 뜬다
DiscoveryClient — “이 서비스 이름의 인스턴스 주소를 알려 달라”에 답하는 인터페이스다. Eureka에 물어보는 구현도 있고 설정 파일의 정적 목록을 읽는 구현(SimpleDiscoveryClient)도 있다
Spring Cloud LoadBalancer와 lb:// — lb://auth-service 같은 주소를 만나면 DiscoveryClient로 실제 주소 목록을 받아 그중 하나를 고른다. 이름을 푸는 주체가 DiscoveryClient라서, 구현만 바꾸면 주소 표기는 그대로 둘 수 있다
CompositeDiscoveryClient — DiscoveryClient 빈이 여럿이면 Spring Cloud가 이들을 묶어 순서대로 조회하게 만드는 래퍼다. 하나가 빈 목록을 돌려줘도 다음 것이 답하므로 Eureka와 정적 목록이 공존할 수 있다
Spring Cloud Gateway 5와 WebFlux — 게이트웨이는 모든 외부 요청이 먼저 도착하는 진입점이다. Gateway 5는 스레드를 요청마다 붙잡지 않는 비동기 방식(WebFlux)으로 동작한다. 그래서 같은 기능이라도 동기(blocking)용과 비동기(reactive)용 구현이 따로 필요하고, DiscoveryClient도 SimpleDiscoveryClient와 SimpleReactiveDiscoveryClient가 별도로 존재한다
Feign — 인터페이스에 애노테이션만 붙이면 HTTP 호출 코드를 만들어 주는 클라이언트다. 동기 방식이라 blocking 쪽 DiscoveryClient를 쓴다
RestClient — Spring 6.1부터 쓰는 동기 HTTP 클라이언트다. 빈으로 등록해 두고 주입받아 쓴다
StripPrefix=1 — 게이트웨이가 요청 경로의 첫 세그먼트를 떼고 대상 서비스에 넘기는 필터다. /auth-service/login이 /login으로 도달한다
호출 그래프와 리프 노드 — 어떤 서비스가 어떤 서비스를 부르는지 그린 방향 그래프다. 리프 노드는 다른 서비스를 부르기만 하고 아무도 부르지 않는 서비스여서, 꺼도 다른 서비스가 깨지지 않는다
빈(Bean)과 타입 주입 — 스프링이 관리하는 객체가 빈이다. 생성자 파라미터의 타입으로 빈을 찾는 것이 타입 주입이고, 후보가 여러 개면 파라미터 이름과 같은 빈을 고른다
BeanDefinitionOverrideException — 같은 이름의 빈이 두 번 정의될 때 나는 기동 실패다. Spring Boot는 빈 덮어쓰기가 기본 비활성이라 조용히 넘어가지 않는다
NoUniqueBeanDefinitionException — 타입으로 주입받으려는데 그 타입의 빈이 여러 개고 이름으로도 못 고를 때 나는 기동 실패다
@RestControllerAdvice — 컨트롤러에서 던진 예외를 잡아 응답으로 바꾸는 전역 핸들러다. basePackages로 적용 범위를 정하며, 같은 예외를 처리하는 advice가 둘이고 @Order가 없으면 어느 쪽이 잡을지 정해지지 않는다
Caffeine 캐시 — JVM 안에서 도는 로컬 캐시 라이브러리다. 외부 API 응답을 TTL(유효 기간) 동안 들고 있어 호출 쿼터를 아낀다
헥사고날 아키텍처 — 도메인을 가운데 두고 외부 연동을 어댑터로 감싸는 구조다. domain / application / adapter 방향으로만 의존이 흐르므로, 외부 의존이 없는 컨텍스트는 다른 모듈로 옮기기 쉽다
503과 404 — 503은 요청을 넘길 대상 자체를 못 찾았다는 뜻이고, 404는 대상에 도달했지만 그 경로가 없다는 뜻이다. 클라이언트가 이 둘을 다르게 해석하므로 실패 경로의 상태 코드도 계약이다
배포와 도구
fat jar — 애플리케이션과 의존 라이브러리를 한 파일에 담은 실행 가능한 jar다. 배포는 단순해지지만 파일이 70~140MB로 커진다
systemd 유닛과 템플릿 유닛 — 리눅스가 서비스를 기동·감시하는 단위다. app@auth처럼 @ 뒤에 이름을 넣는 템플릿 유닛을 쓰면 같은 정의로 서비스 여러 개를 띄울 수 있다
EnvironmentFile — 유닛이 기동할 때 읽어 오는 환경변수 파일이다. 프로세스 시작 시점에 한 번만 읽으므로, 파일을 고쳐도 재기동하지 않으면 반영되지 않는다
멱등(idempotent) 스크립트 — 몇 번 실행해도 결과가 같은 스크립트다. 다만 “기존 파일 보존”으로 구현하면 이미 뜬 서버에 새 설정을 넣을 수단이 사라진다
staging 파일(.jar.new) — 새 파일을 임시 이름으로 먼저 올려 두고 마지막에 이름만 바꿔 교체하는 방식이다. 전송이 중간에 끊겨도 돌고 있는 서비스에 영향이 없다
Testcontainers — 테스트가 실행될 때 DB나 브로커를 컨테이너로 띄워 주는 라이브러리다. 실제 구현체로 통합 테스트를 돌릴 수 있다
core.quotepath — git이 비ASCII 경로를 따옴표로 감싸 이스케이프 출력할지 정하는 설정이다. 기본값이 true라서 한글 경로를 grep으로 걸러내면 매칭되지 않는다
paths-ignore — GitHub Actions에서 특정 경로만 바뀐 푸시는 워크플로를 건너뛰게 하는 설정이다. 바뀐 파일이 전부 무시 대상일 때만 건너뛰므로 코드가 섞인 푸시는 정상 실행된다