fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • langgraph 페이지로 이동
    • 사람용 CLI와 AI 에이전트용 CLI는 설계가 다르다
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code 메모리: CLAUDE.md와 .claude/rules를 규칙으로 쓰는 법
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
    • OpenClaw는 context와 memory를 어떻게 관리하나 — 나만의 에이전트를 구성하는 법
    • OpenClaw vs Hermes Agent — 갈아탈까 고민하며 정리한 비교
    • 스킬 문서를 신경망처럼 학습시킨다 — Microsoft SkillOpt 분석
  • ai 페이지로 이동
    • agent 페이지로 이동
    • [초안] AI 제품 백엔드 안정성 — 지연·비용·권한·관측·도구 실패·폴백/재시도/사람 에스컬레이션
    • [초안] LLM 평가 프레임워크: 골든셋, 회귀 테스트, LLM-as-a-judge, 사람 피드백 루프
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] Event Sourcing과 CQRS — 상태가 아니라 변화를 저장한다는 발상
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리
    • [초안] F&B 주문/매장/픽업 상태머신 설계
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 대규모 회원과 메가 프로모션을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • [초안] 운영 데이터 정합성 장애 대응 — 결제 취소 누락과 중복 적재 런북
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS는 어떻게 안전한가 — TLS, 인증서, 그리고 termination
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
    • [초안] Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/devops/API Gateway를 걷어낸 자리 채우기 …
devops

API Gateway를 걷어낸 자리 채우기 — path rewrite, 요청 크기 병목 4개, 그리고 HTTPS

> 관리형 클러스터는 누구의 권한으로 클라우드를 만지는가의 후속 편이다. 그 글에서 클러스터 신원 문제를 해결하고 나서야, 원래 하려던 일 — API Gateway를 걷어내고 공인 LoadBalancer를 그 자리에 앉히는 작업 — 을 이어갈 수 있었다. 이 글은 LB가 뜬 다음부터의 이야기다. API Gateway를 걷어내기로 한 이유는 단순했다. 5MB...

2026.07.10·8 min read·0 views

관리형 클러스터는 누구의 권한으로 클라우드를 만지는가의 후속 편이다. 그 글에서 클러스터 신원 문제를 해결하고 나서야, 원래 하려던 일 — API Gateway를 걷어내고 공인 LoadBalancer를 그 자리에 앉히는 작업 — 을 이어갈 수 있었다. 이 글은 LB가 뜬 다음부터의 이야기다.

API Gateway를 걷어내기로 한 이유는 단순했다. 5MB 요청 크기 제한이 OCR 서비스에는 너무 작았다. 이미지 하나가 10MB를 넘는 경우가 흔한데 Gateway 단에서 먼저 잘려서 413이 났다. Gateway를 빼고 nginx Ingress Controller를 공인 LB 뒤에 직접 두는 방향으로 정했는데, 막상 옮겨보니 Gateway가 조용히 해주던 일이 생각보다 많았다. path rewrite가 그랬고, 요청 크기 제한은 한 겹이 아니라 네 겹이었다.

path rewrite 23개를 nginx 정규식 4개로 접기

API Gateway는 프론트엔드가 부르는 짧은 경로(/v1.0/appkeys/{key}/general)를 백엔드 서비스의 실제 경로(내부 서비스 이름이 붙은 경로)로 바꿔주는 rewrite 규칙을 23개 갖고 있었다. Gateway를 빼면 이 매핑을 어디선가 대신해야 했다.

23개를 하나씩 옮기는 대신 패턴으로 묶었다. 실제 경로들을 나열해보니 뒤에 붙는 백엔드 prefix 기준으로 4개 그룹 + catch-all로 수렴됐다.

yaml
# ingress-nginx use-regex + rewrite-target 조합. $1, $2 는 캡처 그룹.
# target 의 내부 서비스 이름은 예시로 대체했다.
externalRewrites:
  - name: general
    path: /v(1\.[01])/appkeys/([^/]+)/(general.*)$
    target: /api/svc-general/api/v$1/appkeys/$2/$3
  - name: document
    path: /v([12]\.[01])/appkeys/([^/]+)/((?:business|credit-card|id-card).*)$
    target: /api/svc-document/api/v$1/appkeys/$2/document/$3
  - name: public-keys
    path: /v(2\.[01])/appkeys/([^/]+)/(public-keys/.*)$
    target: /api/svc-document/api/v$1/appkeys/$2/$3
  - name: catch-all
    path: /(v.*)$
    target: /api/$1

catch-all을 넣은 이유가 있다. 매칭이 안 된 /v* 요청을 그냥 nginx 기본 404 HTML로 떨어뜨리면 이 API의 표준 응답 형식(성공/실패를 본문 안 코드로 표현하는 공통 응답 포맷)과 어긋난다. catch-all이 /api/로라도 넘겨서 최소한 애플리케이션이 표준 에러 응답을 만들게 했다. ingress-nginx는 정규식 location을 경로 문자열 길이 내림차순으로 만들기 때문에, 가장 짧은 catch-all이 자연스럽게 맨 마지막 후보가 된다는 점도 이 구조를 믿고 갈 수 있었던 이유다.

요청 크기를 5MB에서 20MB로 올리다가 찾은 병목 4개

크기 제한을 올리는 작업은 "설정 하나 바꾸면 끝"일 줄 알았다. 실제로는 요청이 클라이언트에서 애플리케이션까지 가는 동안 거치는 지점마다 각자 다른 제한이 걸려 있었고, 하나를 풀면 다음 지점에서 막히는 걸 반복하며 찾아냈다.

1번 병목 — nginx proxy-body-size

가장 바깥쪽이라 가장 먼저 걸렸다. nginx.ingress.kubernetes.io/proxy-body-size가 기본값 그대로면 nginx가 애플리케이션보다 먼저 요청을 자르고 413을 내려준다. 여기서 잘리면 표준 응답 형식이 아니라 nginx 기본 에러 HTML이 나가므로, 이 값은 애플리케이션 쪽 제한보다 항상 넉넉하게 잡아야 한다는 걸 배웠다 — 판정은 애플리케이션이 먼저 하게 하고 nginx는 그보다 위쪽 여유값만 갖는 순서다.

2번 병목 — 애플리케이션 multipart 설정과 WebClient 버퍼 (WebClient가 큰 응답을 받으면 왜 죽는가)

Spring의 multipart.max-file-size / max-request-size를 20MB대로 올리는 건 예상한 절차였다. 그런데 이 API는 내부적으로 모델 서버를 리액티브 WebClient로 호출하는데, WebClient의 인메모리 버퍼 기본값(maxInMemorySize)도 별개로 존재했다. multipart 설정만 올리고 이건 그대로 뒀더니 버퍼 초과 에러가 났다. 요청이 애플리케이션 경계를 한 번 더 넘을 때마다 그 경계의 버퍼 설정을 따로 확인해야 한다는 게 이 지점에서 확실해졌다.

3번 병목 — 모델 서버 Envoy의 숨은 버퍼 제한 (제일 오래 걸린 지점)

이 병목이 가장 찾기 어려웠다. nginx와 앱 레이어를 다 풀었는데도 특정 모델(사업자등록증 인식)만 6MB 이상에서 계속 실패했다. 1MB부터 크기를 올려가며 이진 탐색으로 재현했더니 5MB까지는 통과, 6MB부터 실패하는 경계가 뚜렷하게 나왔다.

로그를 추적해보니 애플리케이션도 nginx도 아니고, 모델 서버 앞단의 Envoy가 붙인 grpc-json-transcoder라는 컴포넌트가 원인이었다. 이 컴포넌트가 gRPC 요청을 JSON으로 바꿔주는데, 자체 per_connection_buffer_limit_bytes를 갖고 있었고 기본값이 5MB였다. nginx나 애플리케이션 설정과는 완전히 독립된 값이라, 그 두 곳을 아무리 넉넉하게 올려도 이 지점에서 계속 잘렸다.

yaml
# envoy 설정 — grpc-json-transcoder 앞단의 connection buffer
per_connection_buffer_limit_bytes: 31457280  # 30MB, 기존 5MB/10MB에서 상향

이 지점에서 얻은 교훈은 하나다. 요청 경로에 있는 모든 프록시·게이트웨이 계층은 각자 자기만의 버퍼 제한을 갖고 있을 수 있다는 전제로 접근해야 한다. 앞단 설정을 다 확인했다고 끝난 게 아니라, 실패 지점의 로그에서 어떤 컴포넌트가 응답했는지부터 봐야 한다.

4번 병목 — k8s 노드 용량과 컨테이너 메모리 limit (서로 다른 두 문제)

네 곳을 다 풀고 단일 20MB 요청은 성공했다. 그런데 6~10MB 이미지 여러 개를 동시에 보내면 여전히 불안했다. 컨테이너가 OOMKilled로 죽는 걸 보고 처음엔 "노드 사양이 낮아서"라고 생각했는데, 실제로는 서로 다른 두 문제가 겹쳐 있었다.

  • 노드 자체의 CPU/메모리 여유가 부족한 문제 — m2.c2m4를 m2.c4m8로 올려서 해결
  • 컨테이너 resources.limits.memory가 500M로 낮게 잡혀 있던 문제 — 노드를 늘려도 이 값이 낮으면 파드 개별로 OOMKilled가 난다. 1536Mi로 올리고 request와 limit을 동일하게 맞춰(Guaranteed QoS) 해결

두 값을 하나만 고치고 "됐다"고 판단했다가 나중에 5개 동시 요청 테스트에서 다시 OOMKilled가 재현된 적이 있다. 노드 용량과 컨테이너 limit은 완전히 다른 레이어의 자원이라, 한쪽만 넉넉해도 다른 쪽이 좁으면 그대로 죽는다는 걸 직접 겪고 나서야 체감했다.

k6로 지속 부하를 걸어 확인하다

단발성 curl 테스트로는 "한 번은 성공했다"까지만 확인된다. 실제로 궁금했던 건 이 설정으로 트래픽이 계속 들어와도 버티는지였다. k6로 VU(가상 사용자)를 단계적으로 올리는 시나리오를 짜서 돌렸다 — 5 VU로 2분 30초, 이어서 10 VU로 3분 30초.

사실 이 안정된 수치를 보기 전에 한 번 잘못 짚었다. 컨테이너 메모리 limit을 올린 직후, 별다른 요청 없이 몇 분 지났을 뿐인데 idle 메모리가 393Mi에서 470Mi로 올라가는 걸 보고 누수를 의심했다. 그런데 이 관측 시점이 노드 flavor 교체 직후 파드가 새 노드로 막 재배치된 시점과 겹쳐 있었다. flavor 교체와 재스케줄이 끝나고 완전히 자리를 잡은 뒤 k6로 다시 지속 부하를 걸어보니, 부하 종료 후에도 메모리가 994Mi/963Mi 수준에서 더 오르지 않고 고정됐다. 재현되지 않은 걸 보면 앞서 본 상승은 재배치 직후의 일시적인 값이었지 누수가 아니었다는 게 최종 판단이다. 겉보기 증상만 보고 "누수다"라고 먼저 결론 내렸다가 나중에 정정한 지점이라, 이 프로젝트에서 스스로 배운 것 중 하나는 관측 시점이 다른 인프라 변경 직후와 겹치면, 그 변경의 여파부터 먼저 의심해야 한다는 것이었다.

이 k6 스크립트는 일회성으로 쓰고 버리지 않고 리포지토리에 남겨뒀다. 다음 환경으로 같은 작업을 확장할 때 그대로 다시 돌려볼 수 있게 하기 위해서다.

도메인 이름을 사내 정책에 맞춰 고른 이야기

공인 LB에 IP가 붙고 나니 다음은 도메인이었다. 처음엔 프로덕션에서 쓰는 패턴과 똑같은 이름을 가져다 쓰려고 했는데, 사내 도메인 명명 정책 문서를 찾아보니 그 패턴은 API Gateway를 거치는 서비스용이었다. 지금 이 서비스는 API Gateway를 뺀 상태라, 정책에서 정한 "API Gateway를 쓸 수 없는 경우"의 별도 패턴 — 스테이지 이름을 도메인에 직접 포함하는 형태 — 을 따르는 게 맞았다.

작은 차이지만 여기서 얻은 인사이트는, 회사 규모의 서비스에서는 이름 하나도 "그냥 짓는" 게 아니라 그 이름이 나중에 어떤 라우팅/운영 가정을 전제하는지 정책으로 이미 정해져 있다는 점이었다. 정책을 안 찾아보고 프로덕션 패턴을 그대로 썼다면, 나중에 이 서비스가 API Gateway 없이 운영된다는 사실과 도메인 이름이 암시하는 바가 어긋났을 것이다.

도메인 등록 자체는 사내 결재 시스템으로 신청하는 절차였다. 반복해서 쓸 일이라 신청 흐름을 정리해뒀다.

HTTPS를 놓다가 443 리스너가 잘못 서 있는 걸 발견하다

마지막으로 HTTPS였다. 새로 인증서 발급 체계(cert-manager나 ACME)를 만들 수도 있었지만, 사내에 이미 상용 wildcard 인증서를 파일로 관리하며 쓰고 있는 다른 서비스가 있다는 걸 알게 됐다. 그 팀의 방식을 그대로 참고했다 — 인증서 파일을 저장소에 커밋해두고, Helm 템플릿이 그 파일을 읽어 k8s Secret으로 만들고, Ingress Controller가 그 Secret을 기본 인증서로 물게 하는 구조다.

yaml
# k8s Secret을 저장소에 커밋된 인증서 파일로부터 렌더링
{{- if .Values.tls.certPath }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ .Values.tls.secretName }}
type: kubernetes.io/tls
data:
  tls.crt: {{ .Files.Get (printf "certs/%s/cert.pem" .Values.tls.certPath) | b64enc }}
  tls.key: {{ .Files.Get (printf "certs/%s/key.pem" .Values.tls.certPath) | b64enc }}
{{- end }}

한 가지 결정이 더 필요했다. 인증서를 Ingress 리소스별로 지정하는 방식(spec.tls, SNI 기반)이 일반적이지만, 지금 만든 rewrite Ingress들은 전부 host 없이 경로만으로 매칭하는 catch-all 형태였다. SNI는 요청의 host를 보고 인증서를 고르는 방식이라 host가 없는 Ingress에는 맞지 않았다. 그래서 Ingress Controller 자체의 default-ssl-certificate 옵션으로 컨트롤러 레벨 기본 인증서를 지정하는 쪽으로 바꿨다.

인증서를 연결하고 테스트했는데 TLS handshake가 실패했다. 원인을 추적하다 LB의 443 리스너 자체가 HTTP 프로토콜로 떠 있는 걸 발견했다. HTTPS 트래픽이 이 리스너로 들어오면 LB가 TLS handshake 바이트를 HTTP 텍스트로 해석하려다 실패하는 구조였다 — LB 레이어까지 확인해야 하는 문제라고는 생각을 못 했던 지점이다.

Service 어노테이션으로 443 포트 리스너 프로토콜을 TCP로 바꾸려 했지만, LB를 관리하는 컨트롤러(cloud-controller-manager)는 이미 떠 있는 리스너의 프로토콜을 그 자리에서 바꾸는 기능이 없었다. 새 리스너를 만들려다 같은 포트에서 충돌만 났다. 결국 기존 HTTP 리스너를 API로 직접 지우고, 컨트롤러가 그 자리에 TCP 리스너를 새로 만들도록 유도했다. LB 자체나 VIP, 공인 IP는 그대로 유지되고 리스너 하나만 지웠다 재생성되는 것이라 서비스 단절 시간은 짧았다.

TCP 리스너는 L4(전송 계층)에서 바이트를 그대로 전달만 하고, 실제 TLS 종료는 뒤쪽 nginx가 담당한다. HTTPS는 이 패스스루 구조가 전제라는 걸, 리스너 프로토콜 하나 때문에 handshake가 깨지고 나서야 명확히 이해했다. (TLS termination 위치는 여기서 더 다룬다)

지금 보면

이 작업 전체에서 반복된 패턴이 하나 있다. 문제가 생기면 "지금 보고 있는 레이어"보다 한 단계 위나 아래를 의심하는 습관이 이번에 확실히 붙었다. rewrite는 nginx 문제인 줄 알았고, 요청 크기는 애플리케이션 설정인 줄 알았고, HTTPS는 인증서 파일 문제인 줄 알았다. 실제 원인은 각각 정규식 경로 우선순위, 모델 서버 앞단의 Envoy 버퍼, LB 리스너 프로토콜이었다. 표면 증상과 원인이 다른 레이어에 있다는 전제를 깔고 접근해야, 곁가지 설정을 계속 만지다 시간을 버리는 걸 피할 수 있다는 걸 이번에 다시 확인했다.

또 하나는 판단을 되돌리는 데 인색하지 않아야 한다는 것이다. k6 테스트에서 메모리 누수라고 먼저 판단했다가, 노드 교체 타이밍과 겹친 걸 확인하고 그 판단을 접었다. 틀린 결론을 먼저 내렸다는 사실보다, 그걸 붙잡고 있지 않는 게 더 중요했다.

on this page
  • 01path rewrite 23개를 nginx 정규식 4개로 접기
  • 02요청 크기를 5MB에서 20MB로 올리다가 찾은 병목 4개
  • 1번 병목 — nginx proxy-body-size
  • 2번 병목 — 애플리케이션 multipart 설정과 WebClient 버퍼 ([WebClient가 큰 응답을 받으면 왜 죽는가](../../java/spring/webclient-max-in-memory-size.md))
  • 3번 병목 — 모델 서버 Envoy의 숨은 버퍼 제한 (제일 오래 걸린 지점)
  • 4번 병목 — k8s 노드 용량과 컨테이너 메모리 limit (서로 다른 두 문제)
  • 03k6로 지속 부하를 걸어 확인하다
  • 04도메인 이름을 사내 정책에 맞춰 고른 이야기
  • 05HTTPS를 놓다가 443 리스너가 잘못 서 있는 걸 발견하다
  • 06지금 보면

이런 글도

  • 관리형 클러스터는 누구의 권한으로 클라우드를 만지는가 — trust 단절과 service user 전환
    > 선언한 LoadBalancer가 안 만들어질 때의 후속 편이다. 그 글에서 "클러스터 내부 인증 경로가 죽었다"까지 격리해 놓고 플랫폼 문의로 넘겼는데, 답이 왔고 원인은 예상보다 훨씬 사람 냄새 나는 것이었다. 클러스터를 만든 사람의 권한이 사라져 있었다. 관리형 쿠버네티스에서 LB 발급, 볼륨 생성, 클러스터 업그레이드가 몇 달에 걸쳐 하나씩 죽어...
    🚀 devops
    devops
    2026.07.03
  • 선언한 LoadBalancer가 안 만들어질 때 — cloud-controller-manager 장애 격리 진단기
    > 외부 트래픽은 어떻게 Pod까지 닿는가를 먼저 읽으면 좋다. 그 글이 "LoadBalancer 타입 Service를 선언하면 클라우드가 LB를 만들어준다"까지 다뤘다면, 이 글은 선언했는데 안 만들어질 때 어디서부터 파고드는지를 다룬다. 공인 진입점 전환 작업에서 외부 전용 ingress-nginx controller를 배포했다. controller P...
    🚀 devops
    devops
    2026.07.03
  • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    F&B 디지털 채널 백엔드는 일반 커머스보다 운영 변수가 더 많다. 매장 POS, 배달 외부 채널, PG, 멤버십, 쿠폰, 재고, 알림이 한 트랜잭션 안에서 같이 움직이고, 점심·저녁 피크타임이 하루에 두 번 분명하게 찍힌다. 이 시간대에 한 쪽이 어긋나면 주문은 받았는데 매장에 안 떨어졌거나, 결제는 됐는데 쿠폰이 안 빠졌거나, 재고가 0인데 주문이 들어...
    🚀 devops
    devops
    2026.06.16
  • [초안] 운영 데이터 정합성 장애 대응 — 결제 취소 누락과 중복 적재 런북
    결제 취소 누락과 데이터 중복 적재처럼 "돈과 데이터가 한 건 더 많거나 적은" 장애를, 원인 파악 → 조치 → 검증 → 재발 방지의 같은 4단계 루프로 다루는 운영 감각을 정리한다. 이 두 장애는 표면 증상이 정반대로 보이지만(하나는 일어나야 할 일이 안 일어났고, 하나는 일어나지 말아야 할 일이 두 번 일어났다) 뿌리는 같다. 분산 시스템에서 부수효과를...
    🚀 devops
    devops
    2026.06.16

댓글 (0)