시리즈 "노트북 홈서버 k3s 구축기" 11부.
지난 편까지 = 코드를 머지하면 사람 손 없이 3-4분 뒤 파드가 새 이미지로 돈다. 다만 그 파드 안의 앱을 브라우저에서 끝까지 태워본 적이 없다.
11부 = 앱. 어떻게 흐르는지 확인하고(E2E), 남에게 보여줄 시뮬레이터로 다시 만들고, 확인된 동작을 테스트 게이트로 남기고, LAN에 열기 전에 권한·통신을 조인다. 끝에서 이 앱이 돌아가는 판(가상 설정들)을 정리해 부하 실측으로 넘긴다.
0. 이어받기
이 시리즈는 노트북 한 대에서 시작했다. 1-5부가 물리 기반이고, 클러스터 인프라는 6부부터 쌓였다.
| 편 | 세운 것 | 남긴 상태 |
|---|---|---|
| 1-5 | Proxmox + VM 3대(k3s-1·2·3) + 네트워크·용도별 디스크 | 노드 셋이 클러스터를 받을 준비 |
| 6 | k3s 3노드(embedded etcd) + 로컬 PV | 쿠버네티스가 섰다 |
| 7 | GitOps 부트스트랩 — ArgoCD가 App 21개를 git에서 세움 | git에 적힌 대로 클러스터가 맞춰진다 |
| 8 | LGTM 관측 스택(분산 모드) 개통 + 관측 스택 자기 계기판 | 인프라 신호가 흐르고 담긴다. 인프라 대시보드 본편·알림은 뒤 편으로 미뤄둠 |
| 9 | GitLab 자가호스팅 + webhook | 커밋이 3초 만에 클러스터에 반영된다 |
| 10 | CI 게이트 10종 + 레지스트리 + image-updater | 머지 → 3-4분 → 파드 교체. 사람 손은 머지 클릭까지 |
10부가 끝난 지금을 한 줄로 하면: 코드가 클러스터까지 가는 길은 전부 자동인데, 그 길 끝에서 도는 앱은 검증된 적이 없다.
- 파드 넷(queue×2·booking·frontend)은 Running이고, LAN의 LB 주소까지 뚫려 있다
- 그러나 대기열 입장→좌석 선점→예매 확정을 클러스터 위에서 끝까지 태워본 적이 없다 — 이 앱은 로컬 docker-compose에서 curl 왕복까지 확인하고 올라온 상태다
- 테스트는 0건이라 CI의 test 단계가 빈칸이고, 앱 관측은 코드에만 들어 있고, 화면은 남에게 보여줄 데모가 아니다
이 편이 그 간극을 메운다.
1 앱은 어떻게 흐르나 아키텍처 · queue · booking · 프론트 · E2E 검증
2 프론트 — 시뮬레이터로 다시 화면 구성 · 콘솔(투입·오픈·자동예매) · 백엔드 추가 셋
3 테스트 API 목록 · 맞물리는 방식 · 거기서 나오는 불변식
4 파드 사이 접근 범위 좁히기 NetworkPolicy — 정원 통제를 건너뛰는 경로를 닫는다
5 대조 · 6 다음
값의 대장(무엇이 가상값인지, 설계 문서와 실물이 어디서 갈라졌는지)은 분량이 편 하나라 번외로 뺐다 — 12부·13부가 그 대장을 입력으로 쓴다.
stg/prd 관점에서 이 편의 자리: 부하 실측(13부)으로 배포 스펙을 산정하려면 측정 대상이 기능적으로 검증돼 있어야 하고, 측정 중 코드를 고칠 때 테스트 게이트가 있어야 한다. 여기서 만드는 시뮬레이터가 그대로 부하 시나리오의 재현 장치가 된다.
1. 앱은 어떻게 흐르나
왜 이 앱인가 — 사건에서 문제로
시작은 실제 사건이다. 2024년 프로야구가 사상 처음 천만 관중을 돌파했고, 직관 티켓은 전쟁이 됐다. 한국시리즈 예매에선 접속자 폭주로 예매 버튼이 활성화되지도 않은 채 대기 인원이 16만 명까지 불어났다(덴 매거진 보도). 좌석은 순식간에 동났고, 표를 못 구한 수요가 그만큼 남았다.
넘친 수요를 극장이 받았다. CGV가 KBO와 협약을 맺고 야구 생중계를 극장에서 상영하기 시작했다 — 그리고 그 극장 티켓팅마저 시작과 동시에 매진됐다. "경기장에 못 간 수요가 극장 예매로 몰린다"는 것은 가정이 아니라 이미 일어난 일이다.
이 프로젝트는 그 상황을 모델로 한다. 설정은 이렇다 — 2026년 KBO 올스타전 생중계를 CGV가 열고, 그 예매 시스템을 내가 만든다. 오픈 순간 몰릴 폭주를 받아내는 것이 과제다.
시드 데이터가 그 설정 그대로다: 상영물은 "2026 KBO 올스타전 생중계" 하나(18시), 지점 5 × 관 4 = 회차 20, 관당 200석 = 좌석 4,000. 한 상영물에 전원이 몰리는 구조 — 대기열이 필요한 바로 그 모양이다.
대비 없이 열면 서버 안에서 벌어질 일은 일반적인 경로로 재구성할 수 있다(실제 예매 서버들의 내부는 공개된 바 없다 — 추정의 영역이다). 오픈 순간 수만 명이 좌석 조회·선점·결제 API를 동시에 때린다. 이 구간은 잠금(같은 좌석은 한 명만)과 트랜잭션(돈과 좌석의 원자적 기록)이라 동시에 처리할 수 있는 양에 상한이 있다 — DB 커넥션 수, 잠금 경합, 커밋 디스크 속도가 그 상한을 정한다. 상한을 넘는 요청은 줄을 서는 게 아니라 타임아웃·에러로 튕기고, 그 실패가 재시도를 부르며 부하를 더 키운다. 결과: 전원이 실패하는 서버.
처리량 상한은 없앨 수 없다. 그래서 문제를 뒤집는다 — 상한 안의 인원만 안으로 들이고, 나머지는 밖에서 순번을 주며 세운다. 그 장치가 대기열이고, 이 앱은 그 구조를 직접 구현한 것이다.
아키텍처를 쌓아보기 — 요구가 구조를 정한다
완성된 그림을 외우는 대신, 요구를 하나씩 따라가며 쌓는다. 각 조각은 앞 요구의 결과다.
요구 1 — 문지기가 필요하다. 입장 인원을 정원으로 제한하는 서비스, 그게 queue다. 정원은 "예매 구간이 건강하게 처리할 수 있는 동시 인원"이고, 지금은 데모값 2 — 실값은 부하 실측으로 정한다.
요구 2 — 대기열의 상태는 파드 밖에 있어야 한다. "누가 입장했고 누가 몇 번째인가"를 파드 메모리에 두면 두 가지가 무너진다: 파드가 재시작하면 대기열이 증발하고, 파드가 둘이면 대기열도 둘이 된다. 그래서 상태를 Redis 한 곳에 몰고 파드는 무상태로 둔다 — 어느 파드가 받아도 같은 명부를 보고, 파드는 언제든 갈아 끼운다. Redis인 이유: 메모리라 폭주 속도를 받아내고, 명령을 한 줄로 세워 직렬 실행하는 성질이 "동시 요청의 경합"을 푸는 재료가 된다(뒤에 나온다).
요구 3 — 예매는 성격이 정반대다. 대기열은 "빠르고 가볍게, 잃어도 복구되는 상태"이고, 예매는 "느려도 정확하게, 영원히 남는 기록"이다. 한 서비스에 두면 폭주 트래픽이 트랜잭션과 커넥션을 두고 싸운다. 그래서 서비스를 가른다 — booking은 돈이 걸린 확정본만 MySQL에 남기고, 임시 상태(좌석 잠금·입장 인증)는 Redis에 TTL(수명)을 붙여 두어 결제가 끊겨도 저절로 소멸하게 한다.
요구 4 — 갈랐으면 직접 부르지 않는다. queue가 booking을 HTTP로 부르면, booking이 죽는 순간 queue도 응답을 기다리다 같이 밀린다 — 가른 의미가 없다. 그래서 둘 사이에 Kafka를 둔다: 보내는 쪽은 브로커에 메시지를 두고 제 갈 길을 가고, 받는 쪽은 제 속도로 꺼내 읽는다. 죽어 있던 동안의 메시지도 브로커에 남아 재기동 후 이어진다. 토픽 셋이 닫힌 순환을 이룬다 — 입장 승인(admissions), 입장 회수(admissions-revoked), 예매 완료에 따른 자리 반환(bookings-completed).
요구 5 — 브라우저 진입점. 화면은 정적 파일 세 개(HTML·JS·CSS)를 주는 frontend가 맡고, "어떤 경로를 어느 서비스로"는 클러스터의 Ingress(Traefik)가 맡는다.
쌓고 나면 이 모양이 된다.
브라우저 http://192.168.0.240 ← MetalLB가 준 LB IP (LAN)
│
Traefik (Ingress 3경로, 파드 2)
│ /api/admission/* │ 나머지 /api/* │ /
▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌──────────┐
│ queue │ │ booking │ │ frontend │
│ (Go) │ │ (Java) │ │ (정적만) │
└─┬────┬─┘ └─┬─────┬─┘ └──────────┘
│ │ │ │
Redis │ │ Redis (좌석락·admitted)
(대기열 전부)│ │ MySQL (확정 예매)
│ │
└──── Kafka ────────┘
admissions ─────────▶ (queue → booking : 입장 승인)
admissions-revoked ─▶ (queue → booking : 입장 회수)
◀─ bookings-completed (booking → queue : 자리 반환)
.240에 닿는 과정은 요청마다 이렇다: 같은 LAN이라 브라우저가 ".240 누구냐"(ARP)를 물으면 MetalLB가 노드 하나의 MAC으로 답해 패킷을 클러스터까지 실어 오고, Traefik이 요청의 경로 문자열을 보고 세 규칙 중 하나를 고른다. 세 경로가 한꺼번에 열리는 게 아니라 요청 하나가 가지 하나를 탄다 — 화면 한 번 로드에도 /(정적 파일)와 /api/movies(booking 왕복)가 따로 갈라져 들어간다. 이 배선의 원리(ARP·kube-proxy·Ingress의 층)는 9부에서 다뤘다.
queue — 입장 통제
enter ──정원 여유──▶ 200 ADMITTED ──────────────┐
│ │ Kafka admissions
└──정원 참──▶ 202 WAITING (순번) ▼
│ booking이 admitted 키 발급
│ 순번 폴링(1/2/5초)
▼
승격 루프(2초마다) ── 빈자리만큼 앞에서 꺼냄 ──▶ ADMITTED
회수: 예매 완료 / 이탈(leave) / 세션 60초 초과 → 빈자리 → 다음 승격
대기 이탈: 30초 안에 폴링 없으면 대기열에서 제거
Redis에 있는 것은 명부 두 장이다. 입장자 명부(active)와 대기 줄 명부(waiting — 들어온 순서가 곧 순번).
부속으로 대기자별 마지막 폴링 시각, 발행 대기 저널, 승격 누계(속도 계산 재료)가 같이 산다. queue 파드가 하는 일은 이 명부를 읽고 고치는 것뿐이라, 파드는 몇 개든 무상태다.
- 입장 판정은 Redis Lua 한 번이다. "정원 확인 → 입장 또는 대기 등록"이 원자(중간에 아무도 못 끼어드는 한 단위)로 끝난다. 확인과 등록을 따로 하면 그 틈에 다른 요청이 껴 정원을 넘긴다 — 파드가 여럿이라 앱 안의 잠금으로는 못 막고, Redis가 스크립트를 한 줄로 세워 직렬 실행하는 성질을 잠금으로 쓴다(요구 2에서 Redis를 고른 이유가 여기서 회수된다).
- 폴링이 곧 생존 신호다. 서버가 대기자에게 먼저 말을 걸 수단이 없다(연결을 안 붙잡는 폴링 설계 — 연결을 유지하는 방식은 파드 증설·재시작마다 끊긴 연결을 수습해야 한다). 대신 순번 응답을 주면서 마지막 폴링 시각을 찍고, 30초 안에 안 물은 대기자는 지운다 — 브라우저를 닫고 사라진 사람이 유령으로 남지 않게.
- 시계가 두 개다 — 헷갈리기 쉬운 지점. 대기 중엔 "30초 안에 폴링"(생존 신호가 기준), 입장 후엔 "60초 세션"(입장 시각이 기준 — 폴링해도 안 늘어난다). 대기열은 성실히 서 있으면 유지되고, 입장석은 시간이 되면 무조건 비워진다 — 자리를 잡고 뭉개는 사람이 뒤를 막지 않게.
- 발행 유실을 저널로 막는다. 입장·회수 사실은 Kafka로 booking에 가야 하는데, 상태 변경과 발행 사이에서 파드가 죽으면 통보가 유실된다 — 화면상 입장했는데 booking이 몰라 계속 403인 사용자가 생긴다. 그래서 상태 변경과 같은 Lua 안에서 "알릴 의무"를 발행 대기 저널에 기록하고, 발행이 끝나야 지운다. 스윕 루프(5초)가 남은 항목을 재발행한다. 같은 통보가 두 번 갈 수는 있는데 소비 쪽이 멱등(두 번 받아도 결과가 같음)이라 무해하다 — 중복은 흘리고 유실만 막는다.
booking — 게이트 뒤의 예매
정원의 주인은 queue 하나다. booking은 "지금 몇 명까지 받아도 되나"를 스스로 판단하지 않는다 — 판단 근거를 둘로 나누면 언젠가 서로 어긋난다.
booking이 믿는 것은 입장 인증 키(admitted:{movie}:{requestId}) 하나뿐이고, 그 키의 수명은 queue가 보내는 이벤트가 관리한다: 승격되면 발급(admissions 수신), 자리를 잃으면 삭제(revoked 수신), 예매가 확정되면 소진. 키에 TTL 180초를 붙여 두는 것은 최후 방어다 — 회수 이벤트가 유실돼도 인증이 영원히 남지는 않게. 게이트를 안 거친 요청은 회차 목록부터 확정까지 어디서도 403이다.
좌석은 상태가 셋이다.
빈자리 어느 쪽에도 없음
임시점유 Redis SET NX, TTL 45초 ← 결제 안 끝내면 자동으로 풀림
판매완료 MySQL booking_seats 행 ← 영구. 화면의 taken = 판매완료 ∪ 임시점유
"임시"와 "영구"를 저장소부터 갈라놓은 배치다 — 좌석을 고르다 떠난 사람의 흔적은 TTL이 지우고, 돈이 오간 확정만 DB에 남는다.
확정(POST /api/bookings)은 순서가 고정돼 있다: 멱등키 확인 → 게이트 → 좌석 실재·판매 확인 → 락 연장 → PG 승인(mock) → MySQL 트랜잭션 → 커밋 후 후처리(락 해제·완료 발행·인증 소진). 멱등키는 "이 요청은 아까 보낸 그 요청과 같은 것"이라는 표식이다 — 더블클릭·응답 유실 재시도가 두 번 결제로 이어지지 않게, 같은 키가 다시 오면 저장해 둔 결과를 그대로 돌려준다.
멱등키 확인이 게이트보다 앞인 데 이유가 있다 — 결제까지 끝났는데 응답만 유실된 사용자가 재시도했을 때, 그 사이 세션이 만료됐다는 이유로 403에 막히면 돈은 빠졌는데 예매번호를 영영 못 받는다.
이중판매는 세 겹으로 막는다. 락은 필터고 UNIQUE가 보증이다.
| 겹 | 수단 | 성격 |
|---|---|---|
| 1차 필터 | Redis 좌석락(SET NX) | 대부분을 입구에서 차단. TTL 만료·장애 전환에 뚫릴 수 있음 |
| 판매 확인 | MySQL 조회 | 확정 직후엔 락이 없어서, 팔린 좌석은 여기서 걸러짐 |
| 최종 보증 | MySQL UNIQUE(회차, 좌석) | 위가 다 뚫려도 커밋 순간 한쪽만 이김. 진 쪽은 롤백+환불 |
프론트 — 클라이언트가 계약의 반쪽이다
[로그인: requestId 발급(localStorage)]
│
movies ──클릭·enter──▶ waiting ──ADMITTED 발견──▶ entering ──▶ screenings ──▶ seats ──▶ pay ──▶ done
▲ (순번 폴링) (전환 유예)
└──────── EXPIRED(만료·이탈) / 홈·뒤로 ──────────┘
서버는 사용자에게 먼저 말을 걸지 않고(폴링 — push 없음), 사용자별 세션도 파드에 들고 있지 않다(무상태). 그 결과 신원 만들기, 물어보는 박자, 떠날 때 뒷정리가 전부 클라이언트로 넘어온다 — 서버 설계의 반쪽이 프론트에 구현돼 있는 셈이다. 프론트를 다시 만들 때 아래 여섯 줄이 빠지면 백엔드 설계가 반쪽이 된다.
| 몫 | 구현 |
|---|---|
| 신원 | requestId = 자가발급 UUID(localStorage). 모든 요청에 실린다. 서버는 검증만, 발급 안 함 |
| 폴링 | 순번 뒤일수록 길게(1/2/5초) + jitter — 동시 폴링 무리를 분산 |
| 403 흡수 | 입장 직후 좌석·회차 403은 승격↔인증 발급의 시간차 — 0.3초 간격 최대 12회 재시도 |
| 지연 안내 | 503 RETRY_LATER(발행 지연) → 화면 유지 + 안내. 재시도하면 통과 |
| 정리 | 뒤로·홈 이동 시 좌석 해제(release)와 자리 반납(leave)을 짝으로 호출 — 안 하면 TTL까지 유령 점유 |
| 만료 감지 | 입장 후에도 5초 폴링 유지 — 서버가 자리를 거두면(EXPIRED) 감지해 홈으로 |
E2E — 실제로 흘러가는가
개통 확인. 외부 경로는 이미 git에 선언돼 라이브였다 — 확인만 한다: LB IP 192.168.0.240 배정, 3경로 도달(/ 프론트 HTML · /api/movies 시드 JSON · /api/admission/* queue 응답), 지표 비노출(/metrics·/actuator는 프론트 폴백만), 공유기 DHCP 대역(2-199)과 MetalLB 풀(240-250) 안 겹침, Kafka 토픽 5종 READY. 이 시점부터 집 안 어느 기기든 브라우저로 데모에 닿는다.
여정은 두 벌로 탄다. 브라우저로 한 벌 — 화면·폴링·전환이 사람에게 실제로 보이는가. API로 한 벌 — 프론트가 부르는 것과 같은 API를 직접 불러 응답 계약을 분기까지 확인한다.
흐름 한 바퀴 — 실측 타임라인.
4명 enter → 앞의 2명 즉시 입장(200 ADMITTED), 3·4번째는 대기 1·2번(202 WAITING)
1번이 예매 → 좌석 잠금(LOCKED) → 확정(예매번호 발급)
+1초 → 대기 1번이 ADMITTED — 확정이 Kafka를 돌아 자리를 회수하고 승격까지 1초
입장자를 방치 → 60초 세션 만료(EXPIRED) → 같은 3초 관측창 안에서 다음 대기자 승격
예매 완료 소비 로그에는 trace_id가 붙어 나온다 — HTTP에서 Kafka 소비까지 추적이 이어진다는 표시다(실도달 검증은 관측 편에서).
API 열 개 전부, 분기 포함 통과.
| API | 확인된 분기 |
|---|---|
| enter | 즉시 입장 · 대기(순번·총원) · 재진입=자리 유지 · 대기 재진입=꼬리 밀림 · 형식 위반 400 |
| position | WAITING(순번·뒤 인원·ETA) · ADMITTED · EXPIRED · 형식 위반 400 |
| leave | 이탈 → 즉시 EXPIRED, 뒷사람 순번 전진 |
| complete | 자리 반환(removed) → EXPIRED |
| movies | 시드 응답 — 게이트 없음(입장 전 진입점, 설계대로) |
| screenings | 입장자 200 · 미입장 403 |
| seats | 좌석도 200칸(3상태 합성) |
| seats/select | 잠금 · 경합 409 · 다좌석 전부-아니면-전무(실패 시 부분 잠금 없음까지 확인) |
| seats/release | 해제 → 남이 재선점 가능 |
| bookings | 확정 · 같은 멱등키 재시도=같은 예매번호(재결제 없음) · 같은 키+다른 좌석 409 |
검증이 남긴 발견 셋.
- ETA는 아직 믿을 값이 아니다. 60초마다 2명 도는 상태에서 4번째 대기자에게 46분이 표시됐다 — 실제 기대는 약 2분. 승격 속도 평균(EMA)의 기억이 초 단위라, "한 번에 2명 승격 → 60초 침묵" 패턴을 평균하지 못하고 침묵 구간의 0에 눌린다. 승격 직후엔 십몇 초, 30초 뒤엔 수십 분, 곧 "계산 중"으로 널뛴다. 속도 계산을 공유 카운터 + 회전 주기보다 긴 창으로 바꾸는 재설계의 실물 근거다(결정은 시뮬레이터 절에서).
- 폴링을 멈춘 대기자는 30초 만에 사라진다. 대기자를 세워 두고 폴링 없이 예매 절차만 밟자 정확히 설계대로 강퇴됐다. 브라우저는 자동 폴링이라 안 밟는 함정인데, 가짜 대기자를 만드는 생성기는 전원 폴링 루프가 필수라는 제약이 실물로 확인됐다.
- 멀티팟 첫 실증 — 순번은 일치하고 ETA만 갈라진다. 폴링이 queue 두 파드에 번갈아 떨어지는데 순번·상태는 항상 같았다(상태가 Redis 하나라서). ETA만 파드마다 다르게 나온다(-1 ↔ 13초 ↔ 115초) — 속도 평균이 파드 메모리에 살아서다. 로컬에서 검증된 적 없던 다중 replica 동작이 여기서 처음 확인됐다.
여기서 안 한 것. admissions 후커밋 재현(Redis 단절), UNIQUE 최종 방어 실도달(락 만료 창을 인위로 열어야 함), 503 RETRY_LATER(Kafka 발행 실패)는 전부 장애 재현 장치가 필요하다 — 테스트 절과 장애 편으로 넘긴다. 검증이 만든 예매 3건은 DB 비우기 기능이 생기면 첫 청소 대상이다.
2. 프론트 — 시뮬레이터로 다시 만든다
예매 화면만 있던 프론트를 티케팅 대기열 시뮬레이터로 다시 만든다. 왼쪽은 서비스(스토리·아키텍처 다이어그램·전체 현황·예매 카드), 오른쪽은 시뮬레이터 콘솔이다. 방문자가 콘솔에서 가상 관객을 넣고, 대기열이 도는 것을 지켜보다가, 직접 줄을 서서 예매까지 해보는 구성이다. 노출은 LAN까지 — 인터넷 공개는 TLS·인증을 갖추는 14부에서 연다.

콘솔이 하는 일
- 관객 투입 — 가상 관객 = 가짜 requestId로 enter하는 클라이언트다. 3초마다 전원이 position을 폴링한다(6명씩 병렬). 이 폴링이 생존 신호이자 상태 관찰이라, 멈추면 대기자는 30초 만에 서버가 회수한다. 투입은 프리셋 드롭다운으로 최대 200명 — 이 상한은 브라우저 생성기의 한계(동시연결 6개로 생존 창 안에 전원 폴링)지 백엔드 정원이 아니다. 그 이상의 부하는 k6로 잰다(13부).
- 자동 예매 — 켜면 입장한 관객이 좌석을 예매하고 나간다. 예매 완료 이벤트가 Kafka로 queue에 돌아와 자리가 반환되고 다음 대기자가 승격된다 — 줄이 도는 원리가 화면에 그대로 나온다. 끄면 세션 60초를 다 쓰고 나가 정원이 막힌 상황이 된다.
- 예매 오픈 예약 — 오픈 시각을 걸면 예매 버튼이 잠기고 시계가 카운트다운한다. 오픈 전 투입한 관객은 서버에 줄을 세우지 않고 대기실에 모아 뒀다가, 오픈 순간 러시 인원과 함께 한꺼번에 줄을 세운다(미리 줄 세우면 오픈에 들어오는 사람이 이미 진 채로 시작하므로). 게이트는 localStorage 프론트 연출이라 브라우저마다 독립이고, 서버는 이 시각을 모른다.
백엔드에 추가한 것 셋
- stats API —
GET /api/admission/stats. 전체 대기·입장·누적 승격을 Redis 파이프라인 한 왕복으로 집계한다. 화면의 현황 타일이 이걸 3초마다 읽는다. - ETA 재설계 — 검증에서 파드마다 예상 시간이 제각각으로 갈라졌다(파드 메모리 EMA가 원인 — 승격은 60초에 한 번 몰아 도는 주기 신호라 짧은 평활은 빈 구간에서 0으로 수렴한다). 승격 수를 Redis 초 단위 버킷에 기록하고 90초 창 전체를 합산해 평균 내는 방식으로 바꾼다. 값이 Redis에 있어 전 파드가 같은 ETA를 주고, 창이 회전 주기보다 길어 주기 신호에도 버틴다. 실측: 승격 12명 시점 rate 0.133/s, 6번째 대기자 ETA 45초 — 계산과 일치.
- DB 리셋 — 예매는 영구 기록이라 좌석이 다 팔리면 데모가 멈춘다. queue(대기열 키 전부)와 booking(예매·좌석락·입장인증 + 방송일을 실행일+7일로 재설정)에 초기화 API를 만든다. 인증은 X-Admin-Token 헤더 — 토큰을 주입하지 않은 배포에는 라우트 자체가 등록되지 않는다. 화면의 ?ops 도구는 토큰을 입력받아 호출할 뿐, 판정은 서버만 한다.
3. 테스트 — API 계약을 게이트로 남긴다
CI 파이프라인 5단(check·test·build·scan·publish) 중 test 칸이 비어 있다. 지금까지 이 앱의 동작 확인은 AI에게 "코드를 훑고 검증해 달라"고 시켜 한 번 돌린 것이고, 그 결과는 그때 화면에만 남았다. 다음 커밋이 같은 동작을 깨도 아무것도 울리지 않는다.
테스트를 쓰려면 무엇을 지킬지부터 정해야 하는데, 그것은 코드 줄이 아니라 API가 서로에게 한 약속에 있다. 그래서 이 절은 API 목록에서 출발해, 그것들이 어떻게 맞물리는지 따라가고, 거기서 나오는 불변식을 테스트로 옮긴다.
API 목록 — 두 서비스가 여는 창구
queue는 "누가 들어갈 수 있나"를, booking은 "들어온 사람이 무엇을 살 수 있나"를 소유한다. 창구도 그 경계대로 갈린다.
queue (Go) — 입장 통제
| 메서드·경로 | 하는 일 | 상태 변경 | 응답 |
|---|---|---|---|
POST /api/admission/enter |
정원을 원자로 확인해 입장 또는 대기 등록 | O | 200 ADMITTED·ALREADY_ACTIVE / 202 WAITING+순번 / 400 / 503 RETRY_LATER |
GET /api/admission/position |
내 순번·상태 조회. 호출 자체가 생존 신호를 겸한다 | O(생존 시각) | WAITING(순번·뒤 인원·예상) / ADMITTED / EXPIRED |
GET /api/admission/stats |
전체 현황 집계(입장·대기·정원·신규 진입 예상) | X | 숫자 묶음 |
GET /api/admission/events |
최근 이벤트 피드(커서 after 이후만) |
X | 이벤트 배열 + 마지막 id |
POST /api/admission/leave |
자발적 이탈 — 입장·대기·생존 추적에서 모두 제거 | O | LEFT |
POST /api/admission/complete |
사용자가 부르는 자리 반환 — 입장에서만 제거 | O | COMPLETED |
POST /api/admission/reset |
대기열 상태 전부 삭제(운영용, 토큰 필요) | O | RESET |
booking (Java) — 예매
| 메서드·경로 | 하는 일 | 입장 인증 | 응답 |
|---|---|---|---|
GET /api/movies |
상영 목록 | 불필요 | 목록 |
GET /api/screenings |
회차 목록 + 잔여 좌석 | 필요 | 목록 / 403 |
GET /api/seats |
좌석도(빈자리·임시점유·판매완료를 합친 값) | 필요 | 목록 / 403 |
POST /api/seats/select |
좌석 임시 점유(다중 좌석 전부 성공 또는 전부 실패) | 필요 | 200 LOCKED / 409 |
POST /api/seats/release |
점유 해제 | 필요 | 200 |
POST /api/bookings |
결제·예매 확정 | 필요(멱등 재요청은 예외) | 200 예매번호 / 400 / 403 / 409 |
POST /api/admin/reset |
예매·좌석·인증 삭제 + 방송일 재설정(운영용, 토큰 필요) | 토큰 | RESET |
두 서비스를 잇는 것은 HTTP가 아니라 Kafka 세 토픽이다. admissions(queue → booking, 입장 승인) · admissions-revoked(queue → booking, 입장 회수) · bookings-completed(booking → queue, 자리 반환). 서로를 직접 호출하지 않으므로 한쪽이 죽어도 다른 쪽이 함께 멈추지 않는다.
API가 어떻게 맞물리나 — 한 사람의 여정
모든 창구를 관통하는 열쇠는 requestId 하나다. 화면의 [익명으로 시작]을 누르면 브라우저가 UUID를 만들어 이 값으로 쓰고, 앞자리를 딴 라벨(관객-8e9e)을 이름 대신 보여준다. 대기열 등록도 좌석 점유도 예매 확정도 전부 이 값으로 이뤄지고, 값은 브라우저에 저장돼 새로고침해도 유지된다 — 새로고침 뒤에도 대기 순번이 이어지는 이유가 이것이다.
서버가 검증하는 것은 신원이 아니라 상태다. 형식만 확인하고(영숫자·_·-, 64자 이내), 그 값이 지금 대기 줄에 있는지·정원 안에 있는지·좌석을 잡았는지만 본다. 비밀번호도 서명도 없으므로 아무 값이나 만들어 보내도 받아준다 — 가상 관객을 만드는 방법이 bot- 접두의 값을 새로 찍어 입장을 부르는 것뿐인 것도 같은 이유다.
이 설계는 대기열의 동작을 보여주는 데는 충분하고, 공개하는 데는 부족하다. 남의 requestId를 알면 그 사람을 대기열에서 빼거나 좌석 점유를 풀 수 있다. 인터넷에 여는 단계에서 서명된 신원으로 바꾸는 것이 선행 조건이고, 이 편의 노출 범위를 LAN까지로 묶는 근거도 여기 있다.
클라이언트 queue Kafka booking 저장소
│
│ POST /enter
├──────────────────▶ 정원 확인(Lua, 원자)
│ ├ 여유 있음 → active 등록 ─── admissions ──▶ 인증 발급 ──▶ Redis(키+TTL)
│ ◀── 200 ADMITTED │
│ └ 정원 참 → waiting 등록(진입 순서 score)
│ ◀── 202 WAITING + 순번
│
│ GET /position (1·2·5초 간격 반복)
├──────────────────▶ active에 있나 → 없으면 waiting 순번 → 둘 다 없으면 EXPIRED
│ ◀── 상태 + 순번·예상 └ 이 호출이 '마지막으로 본 시각'을 갱신한다
│
│ ┌ 승격 루프(2초): 빈자리만큼 waiting 앞에서 active로 ─ admissions ─▶ 인증 발급
│ 백그라운드 ┤ 세션 타임아웃(10초): 오래된 active 회수 ───── admissions-revoked ─▶ 인증 삭제
│ ├ 대기 타임아웃(10초): 폴링이 끊긴 waiting 회수
│ └ 저널 스윕(5초): 발행이 끝나지 못한 이벤트 재발행
│
│ GET /screenings · /seats (requestId 동봉)
├───────────────────────────────────────────────────▶ 인증 있나? ─ 없으면 403
│ ◀── 회차·좌석
│
│ POST /seats/select
├───────────────────────────────────────────────────▶ 좌석락(Lua) ──▶ Redis(45초 TTL)
│ ◀── 200 LOCKED / 409 이미 선점
│
│ POST /bookings (idempotencyKey 동봉)
├───────────────────────────────────────────────────▶ 확정 8단계 ──▶ MySQL(UNIQUE)
│ ◀── 200 예매번호 │
│ ◀───── bookings-completed ────────────┘
│ 자리 반환(active에서 제거) → 다음 승격이 그 자리를 채운다
읽을 때 걸리는 지점 셋을 풀어 둔다.
입장은 두 곳에 기록된다. queue의 정원 명단(active)과 booking의 입장 인증이다. 사용자가 "들어갔다"고 느끼는 순간과 booking이 그것을 아는 순간 사이에 Kafka 한 구간이 있다. 그래서 입장 직후의 회차 조회가 403으로 튈 수 있고, 화면은 그 틈을 짧은 재시도로 흡수한다. 인증을 booking이 스스로 만들지 않는 이유도 여기 있다 — 정원 통제의 소유자를 한 곳(queue)으로 두기 위해서다.
나가는 길이 셋이다. 예매를 끝내거나(bookings-completed), 스스로 나가거나(leave·complete), 시간이 지나 서버가 회수한다(세션·대기 타임아웃). 어느 길로 나가든 queue의 명단에서 빠지고, 앞의 둘과 셋째는 admissions-revoked로 booking의 인증도 지운다. 이 통보가 빠지면 자리를 잃은 사용자가 계속 예매 API를 통과한다.
폴링이 곧 생존 신호다. 서버가 연결을 붙들지 않으므로 "끊겼다"를 직접 알 수 없다. 마지막 폴링 시각을 기록해 두고 오래된 항목을 회수하는 방식이라, 대기자는 계속 물어봐야 줄을 유지한다.
흐름에서 나오는 불변식
위 그림을 따라가면 "무엇이 깨지면 사용자가 피해를 보는가"가 나온다. 테스트가 지킬 대상은 이 목록이다.
| 불변식 | 깨지면 | 지키는 장치 |
|---|---|---|
| 입장 인원이 정원을 넘지 않는다 | 정원 산정이 무의미해지고 booking이 감당 못 하는 부하를 받는다 | enter·승격 Lua의 원자 확인 |
| 같은 신원의 재진입이 자리를 늘리지 않는다 | 새로고침만으로 정원이 늘어난다 | enter의 멱등 분기 |
| 대기 순번은 진입 순서를 지킨다 | 늦게 온 사람이 먼저 들어간다 | 대기 줄과 생존 추적을 다른 키로 분리 |
| 자리를 잃으면 예매 자격도 사라진다 | 정원 밖 사용자가 좌석을 산다 | 회수 이벤트 + 인증 TTL |
| 같은 좌석이 두 번 팔리지 않는다 | 두 사람이 같은 자리 티켓을 받는다 | 좌석락(1차) + MySQL 유니크 제약(최종) |
| 같은 멱등키는 같은 결과를 준다 | 응답이 유실된 재시도가 이중 결제가 된다 | 멱등키 조회가 확정보다 앞 |
| 좌석 요청이 정규화된다 | 중복 좌석이 결제 금액을 부풀리고, 순서 차이가 교착을 만든다 | 중복 제거·정렬·개수 상한 |
마지막 줄의 "순서 차이"를 덧붙이면 — 두 요청이 [A1,A2]와 [A2,A1]로 동시에 들어오면 서로가 잡은 행을 기다려 데이터베이스 교착이 된다. 정렬은 표시용이 아니라 그 교착을 막는 장치다.
테스트를 어떻게 구축하나
테스트도 코드이고 실행기가 있다. 빌드에 go build·mvn package가 있듯 go test·mvn test가 있고, CI의 test 단계는 그 명령을 부르는 job이다. 실패하면 종료 코드가 0이 아니라서 파이프라인이 멈춘다.
Go(queue) — 표준 라이브러리에 들어 있어 설치할 것이 없다. 대상 파일 옆에 _test.go를 만들고 Test로 시작하는 함수를 쓰면 go test ./...가 전부 실행한다.
Java(booking) — 관례는 같고 위치와 의존성이 다르다. 테스트는 src/test/java에 두고, pom.xml에 test 전용 의존성(spring-boot-starter-test, scope=test라 실행 이미지에는 안 들어간다)을 더한 뒤 @Test 메서드를 쓴다. 이 저장소의 booking에는 src/test 디렉터리 자체가 없어 그 추가가 첫 삽이다. 이미지 빌드가 -DskipTests로 테스트를 건너뛰는 것은 의도된 분리다 — build job은 이미지만 만들고, 테스트는 test job이 따로 돈다.
무엇 위에서 도느냐가 방법을 가른다. 앞의 불변식을 재료별로 나누면 세 층이 된다.
| 층 | 이 앱에서 | 필요한 것 |
|---|---|---|
| 순수 로직 | 좌석 요청 정규화, 식별자 형식 검증 | 없다. 입력을 넣고 출력을 비교한다 |
| Redis 위 | 정원 확인, 승격, 만료 회수, 발행 저널 | 실제 Redis. 원자성이 Lua 스크립트에서 나오므로, 가짜로 흉내 내면 흉내 낸 자기 자신을 검증하게 된다 |
| MySQL 위 | 이중 판매 최종 방어, 멱등키 조회 | 실제 MySQL. 인메모리 대체는 유니크 위반 시점과 잠금 의미가 달라 통과가 증명이 되지 않는다 |
둘째 줄이 이 앱의 특성이다. 정원을 지키는 주체가 Go 코드가 아니라 Redis 스크립트라서, 앱 코드만 단위 테스트하면 정작 지켜야 할 것이 검증 범위 밖에 남는다. 실물을 붙이는 이유는 "더 정확해서"가 아니라 검증 대상이 거기 있어서다.
깨짐이 동시일 때만 나타난다는 것도 방법을 정한다. 정원 초과는 순차 호출로는 절대 재현되지 않는다. 그래서 테스트가 고루틴으로 동시 요청을 만들어 같은 순간에 출발시킨다. 순차로 세 번 부르는 검사는 어떤 구현이든 통과하므로 회귀를 잡지 못한다.
넣은 것
이 프로젝트의 무게중심은 인프라이고 앱은 그 위에서 도는 대상이다. 그래서 앱 테스트는 게이트가 실제로 도는 최소로 둔다.
| job | 대상 | 붙인 실물 |
|---|---|---|
test:queue-go |
동시 요청 50개에도 입장이 정원을 넘지 않는가 · 같은 신원의 재진입이 자리를 늘리지 않는가 | Redis(서비스 컨테이너) |
test:booking |
좌석 요청 정규화 — 중복 제거·순서 고정·개수 상한·빈 요청 거절 | 없음(순수 로직) |
test:queue-go:
stage: test
image: golang:1.26
services:
- name: redis:7-alpine
alias: redis
variables:
REDIS_ADDR: "redis:6379"
script: [ cd queue-go, go test ./... ]
rules: [ changes: [ queue-go/**/*, .gitlab-ci.yml ] ] # 안 바뀐 서비스의 테스트는 돌지 않는다
Redis에 닿지 않으면 실패가 아니라 건너뛰게 했다 — Redis 없이 go test ./...를 돌리는 개발 중 실행이 빨간불이 되지 않게. CI에는 서비스 컨테이너가 붙어 있어 항상 실행된다.
테스트가 실제로 회귀를 잡는지 확인했다. 통과하는 것만으로는 부족하다 — 항상 통과하는 테스트는 없는 것과 같다. 사본에서 정원 확인 조건을 activeCount < maxSessions + 10으로 망가뜨리고 돌리니 이렇게 잡혔다.
--- FAIL: TestEnterKeepsCapacityUnderConcurrency
입장 인원이 정원과 다르다: 정원 3, 입장 13
저장된 입장 인원이 정원을 넘었다: 13(정원 3)
응답만 세지 않고 저장된 명부(ZCARD active)까지 확인하는 이유도 여기 있다. 응답이 맞아도 명부가 넘칠 수 있다.
test 단계는 lint·secret·dependency와 같은 축으로 allow_failure 없이 건다. 조건 없이 막을 때만 게이트다.
넣지 않은 것 — 앱이 커지면 필요한 순서
앱의 비중을 여기까지로 잡았기 때문에 아래는 손대지 않았다. 예매 기능을 더 다루게 되면 이 순서로 늘린다.
- 멱등 재요청 — 같은 키의 재시도가 같은 예매번호를 돌려주는지. 다만 예매번호만 비교하는 테스트는 반쪽이다. 같은 번호를 돌려주면서 결제를 한 번 더 했을 수도 있어서, 결제 승인 호출이 1회인지까지 확인해야 이중 결제를 막는다.
- 이중 판매 최종 방어 — 좌석락이 1차에서 걸러내므로 유니크 제약까지 도달시키려면 락을 일부러 우회시켜야 한다. 그러려면 락 계층을 갈아끼울 수 있어야 하고, 지금 구조가 그것을 허용하는지 확인이 먼저다.
- 확정 경로 여덟 갈래 전수 — 지표 카운터의 결과 라벨 여덟 개(
success·replay·lock_expired·seat_conflict·forbidden·no_screening·bad_request·error)와 하나씩 맞춘다. 폭이 넓어 한 번에 하면 오래 걸린다. - 서비스 간 계약 — 입장 인증은 queue가 만들고 Kafka를 건너 booking이 소비한다. 한 서비스 안에서는 이 계약이 검증되지 않는데, 두 서비스와 브로커를 함께 띄우는 테스트는 CI에서 무겁다. 지금은 배포 후 확인으로 남긴다.
- 시간에 매달린 항목 — 좌석락 45초·세션 60초·인증 180초·대기 이탈 30초. 실시간으로 기다리면 테스트가 분 단위가 되므로, 짧은 값을 주입할 수 있는 구조가 먼저 필요하다.
정리(teardown)는 지금부터 규약으로 둔다. 테스트가 만든 신원과 좌석은 끝에서 회수한다. 남기면 다음 테스트가 정원이 찬 상태에서 시작해 원인 없이 실패하고, 좌석은 영구 기록이라 재고가 줄어든다. 좌석이 4,000석뿐이라 이 규약이 없으면 테스트를 돌릴수록 매진에 가까워진다.
4. 파드 사이 접근 범위 좁히기
앱 쪽 작업이 한 바퀴 끝났다. 다음은 통신 범위 좁히기다. 관측 배선과 k6 부하 실측이 다음 편들에서 이어지는데, 수치를 재기 시작한 뒤에 "누가 어디에 붙을 수 있나"를 바꾸면 다시 재야 한다 — 그래서 재기 전에 먼저 좁힌다.
이 절에서 다루는 단위는 파드다. 파드는 컨테이너 하나 이상을 묶어 IP 하나를 공유하는 배포 단위이고, 쿠버네티스가 띄우고 내리는 최소 단위다. 통신을 허용하거나 막는 대상도 파드다.
클러스터는 노드 3대이고, 객체는 네임스페이스로 갈라 둔다. 네임스페이스는 클러스터 안 객체를 이름으로 나누는 칸이다.
| 네임스페이스 | 들어 있는 것 |
|---|---|
app |
frontend(정적 파일 서빙) · queue(대기열 판정) · booking(예매 처리) |
data |
MySQL · Redis · Kafka |
observability |
지표·로그 수집과 저장 |
막고 여는 단위는 파드와 포트 둘이다. 주소가 두 겹이라 그렇다 — IP 가 어느 파드인지를 가르고, 포트가 그 파드 안의 어느 프로그램인지를 가른다. 파드 하나가 여러 포트를 열어 두면 그 포트마다 다른 상대가 붙는다.
data 의 Redis 파드가 6379(데이터)·26379(마스터 조회)·9121(지표)을 함께 여는 것이 그렇다. 그래서 정책도 포트별로 따로 적는다.
범위는 data 로 들어오는 접속까지다. app 에서 나가는 쪽과 클러스터 밖에서 들어오는 경로는 이번에 안 건드린다.
무엇이 열려 있나
클러스터 안 어느 파드에서든 data 의 MySQL 3306, Redis 6379·26379, Kafka 9092 에 도달한다. 정적 파일만 서빙하는 frontend 도, observability·argocd·kube-system 의 파드도 MySQL 에 붙는다.
통신을 제한하는 규칙이 없어서가 아니다. 쿠버네티스에서 그 규칙을 담는 객체를 NetworkPolicy 라 부르고, 하나는 세 부분으로 되어 있다 — 대상 파드, 방향, 허용 목록. 허용 목록에는 출처와 포트를 적는다. 정책이 고른 파드는 들어오는 접속이 일단 전부 막히고, 허용 목록에 적은 것만 다시 통과한다.
kubectl get netpol -A 를 하면 8건이 나온다.
| 네임스페이스 | 이름 | 만든 주체 | 열린 포트 | 보내는 쪽 |
|---|---|---|---|---|
| argocd | 4건 (server·repo-server·application-controller·redis) | argo-cd 차트 | argocd 안쪽 — 이번 대상 밖 | |
| data | mysql | bitnami/mysql 14.0.3 |
3306 | 안 적힘 |
| data | redis | bitnami/redis 27.0.15 |
6379 · 26379 · 9121 | 안 적힘 |
| data | cgv-network-policy-kafka | Strimzi 오퍼레이터 | 9090 · 9091 · 8443 | 지정돼 있음 |
| data | cgv-network-policy-kafka | Strimzi 오퍼레이터 | 9092 | 안 적힘 |
| data | cgv-entity-operator | Strimzi 오퍼레이터 | 8080 · 8081 | 안 적힘 |
문제는 오른쪽 끝 칸이다. 허용 목록에 포트만 있고 출처가 없으면 그 포트로 오는 것은 어디서 왔든 통과한다. mysql 정책이 막는 것은 3306 아닌 포트뿐이고, 지켜야 할 포트는 3306 이다.
출처가 빠진 이유가 만든 주체마다 다르고, 그래서 고치는 손잡이도 따로다.
차트가 만든 것. Helm 차트는 쿠버네티스 객체를 값에 따라 찍어 내는 템플릿 묶음이고, 그 값은 values.yaml 에 적는다. MySQL 과 Redis 는 Bitnami 가 배포한 차트를 값만 바꿔 감싼 래퍼 차트(cgv-mysql·cgv-redis)로 쓴다.
그 차트의 기본값은 networkPolicy.enabled: true, networkPolicy.allowExternal: true 다. allowExternal: true 면 템플릿이 출처를 빼고 정책을 만든다. 손잡이는 래퍼의 values.yaml 이다.
오퍼레이터가 만든 것. CRD 는 쿠버네티스에 새 객체 종류를 등록하는 정의이고, 그 정의대로 만든 객체 하나를 CR 이라 부른다. 오퍼레이터는 CR 을 지켜보다가 필요한 객체를 대신 만들어 두는 프로그램이고, Strimzi 는 Kafka 용 오퍼레이터다.
Kafka 쪽 정책은 Strimzi 가 Kafka CR 을 읽고 만든다. 9090·9091 은 브로커끼리 쓰는 복제와 제어, 8443 은 오퍼레이터가 쓰는 통로라 Strimzi 가 출처까지 적는다. 클라이언트가 붙는 9092 만 비어 있는데, 그 출처를 적는 자리인 리스너(브로커가 접속을 받는 창구 설정)에 아무것도 안 넣었기 때문이다. 정책을 지워도 오퍼레이터가 다시 만들어 놓으므로 지우는 방식은 안 통한다. 손잡이는 Kafka CR 이다.
열려 있으면 무엇이 깨지나
이 서비스는 좌석 정원만큼만 예매 화면에 들여보내려고 앞에 대기열을 둔다. queue 가 Redis 에서 남은 정원을 확인해 통과한 요청만 Kafka 의 admissions 토픽에 넣고, booking 이 그 토픽을 읽어 예매 자격을 발급한다. 열려 있는 포트 가운데 9092 는 그 통제가 통하지 않게 만든다.
정상 사용자 ─▶ queue ─(Redis 로 정원 확인)─▶ Kafka admissions ─▶ booking
우회 아무 파드 ─────────────────────────▶ Kafka admissions ─▶ booking
│
admitted:{movie}:{requestId} 발급 = 예매 자격
두 줄의 차이는 queue 를 거치느냐 하나다. booking 은 메시지를 보낸 쪽을 확인하지 않아서, admissions 에 메시지를 넣는 것이 곧 예매 자격이 된다. 대기열·순번·정원을 건너뛴다. 리스너는 type: internal, tls: false, 인증 설정 없음이고 internal 은 클러스터 밖 노출만 막는다.
막는 층이 셋이다 — 메시지에 서명을 붙여 앱이 확인하는 것, 브로커가 접속자를 확인하는 것(아이디·비밀번호를 쓰는 SASL, 양쪽 인증서를 쓰는 mTLS), 애초에 도달 자체를 줄이는 것. 이번에 하는 건 세 번째다. 앱과 브로커 설정을 그대로 두고 도달 범위만 줄인다.
NetworkPolicy — 무엇을 적는 물건인가
정책이 하나도 고르지 않은 파드는 들어오는 것도 나가는 것도 전부 허용된다. 네임스페이스는 이름 공간이지 패킷의 경계가 아니다. app 의 파드와 data 의 파드는 정책이 없으면 같은 평면에 있다.
파일 하나를 통째로 놓고 줄마다 무엇을 정하는지 본다. 이 정책은 data 의 MySQL 로 들어오는 접속을 app 의 booking 파드가 3306 으로 보내는 것 하나로 제한한다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: mysql-ingress
namespace: data # ① 정책이 사는 곳. 대상 파드도 여기서만 고른다
spec:
podSelector: # ② 대상 — 이 정책을 어느 파드에 붙이나
matchLabels:
app.kubernetes.io/name: mysql # 이 라벨을 단 파드 = mysql-0
policyTypes:
- Ingress # ③ 방향 — 들어오는 쪽만 다룬다
ingress: # ④ 허용 목록. 여기 맞는 것만 통과, 나머지는 폐기
- from: # ⑤ 출처 — 누가 보내는 것을 허용하나
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: app # 쿠버네티스가 모든 네임스페이스에
podSelector: # 자동으로 붙이는 라벨. 네임스페이스도
matchLabels: # 라벨로만 고를 수 있어 이것을 쓴다
app.kubernetes.io/name: booking
ports: # ⑥ 통로 — 어느 포트로 오는 것을 허용하나
- port: 3306
읽는 축은 ② 와 ④ 다. 정책이 파드를 고르는 순간 그 파드로 들어오는 접속이 전부 막히고, ingress 에 적은 것만 다시 통과한다. 차단할 것을 적는 게 아니라 뚫을 것을 적는다. 문법에 거부 규칙은 없다.
나머지는 이 구조에서 따라 나온다.
- 대상은 라벨로만 고른다. 파드 이름은
booking-5cff7c7fbb-wn4ph처럼 배포마다 바뀌고 IP 도 바뀐다.podSelector: {}는 그 네임스페이스 전부를 뜻한다. - 대상은 정책이 있는 네임스페이스 안에서만 고르고, 출처는 다른 네임스페이스에서도 고른다. 그래서 app 에서 data 로 가는 통신을 제한하는 정책은 받는 쪽인
data에 둔다. from과ports는 AND 로 읽고, 안 적은 축은 제한하지 않는다. 차트와 오퍼레이터가 만든 정책에 출처가 비어 있는 것이 이것이다 —ports만 있으면 어디서 왔든 통과한다.ingress: []는 전부 차단,ingress: [{}]는 전부 허용이다. 규칙 개수가 뜻이 된다.- 라벨로 못 고르는 출처는
ipBlock으로 적는다. 파드는10.42.0.0/16대역을 받고 노드 IP 는 그 밖이라, 노드에서 오는 접속은 파드 셀렉터에 안 잡히고ipBlock으로 적는다.
from 안에서는 하이픈 하나가 AND 와 OR 를 가른다.
- from:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: app}}
podSelector: {matchLabels: {app.kubernetes.io/name: booking}}
# 원소 하나 안에 셀렉터 둘 = AND → app 의 booking 파드만
- from:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: app}}
- podSelector: {matchLabels: {app.kubernetes.io/name: booking}}
# 원소 둘 = OR → app 전체, 또는 정책과 같은 네임스페이스의 booking 파드
하이픈 하나로 파드 하나에서 네임스페이스 전체로 벌어진다.
문법에서 한 가지가 더 따라 나오고, 이것이 작업 순서를 정한다. 여러 정책이 같은 파드를 고르면 허용의 합집합이 적용된다. 우선순위도 거부도 없어서 좁은 정책을 더해도 넓은 정책이 열어 둔 것이 남는다.
정책 A (차트가 생성): podSelector=mysql, ingress[{ports:[3306]}] # from 없음
정책 B (직접 작성): podSelector=mysql, ingress[{from:[booking], ports:[3306]}]
A + B → 3306 은 어디서든 통과 B 만 → booking 에서만 통과
좁히려면 B 를 더하는 것으로는 안 되고 A 를 없애야 한다. 차트가 만드는 정책을 꺼야 하는 이유가 이것이다.
여기까지가 파일에 적는 내용이고, kubectl apply 를 하면 클러스터의 모든 객체를 받아 두는 API 서버에 저장된다. 저장된 글자가 패킷을 막지는 않는다.
그 선언이 패킷을 막기까지 — Calico
막는 쪽은 파드에 네트워크를 붙여 준 프로그램이다. 길을 놓은 쪽이라야 그 길에 문을 달 수 있다.
쿠버네티스는 파드를 어느 노드에 띄울지 정하고 컨테이너를 실행하는 데까지 한다. 파드에 IP 를 주는 일과 다른 노드의 파드까지 패킷을 보내는 일은 하지 않는다.
그 자리는 "프로그램을 하나 꽂아서 시켜라" 로 비워 두었고, 그 프로그램이 지켜야 할 규격이 CNI(Container Network Interface)다. Calico 는 그 자리에 꽂힌 프로그램이고 두 가지를 한다 — 길을 놓는 일과 그 길의 통행을 판정하는 일이다.
길
파드가 뜰 때 CNI 플러그인이 veth 쌍을 만든다. 랜선 하나의 양 끝 같은 것으로, 한쪽 끝은 파드 안에 들어가 eth0 이 되고 반대쪽 끝은 노드에 남아 cali 로 시작하는 이름을 받는다. 파드 하나에 노드 쪽 인터페이스 하나가 대응한다.
주소는 두 종류가 따로 논다. 파드는 10.42.0.0/16, 노드는 LAN 주소(192.168.0.201·202·203)다. 대역이 달라 파드 IP 는 LAN 에서 그대로 오갈 수 없어서, 노드를 넘을 때 원본 패킷을 노드 IP 봉투에 넣어 보내고 받는 쪽에서 벗긴다. 이 방식이 VXLAN 이다.
파드A ─veth─▶ k3s-1 ─[노드 IP 봉투]─▶ k3s-3 ─[벗김]─▶ veth ─▶ 파드B
판정
패킷이 파드로 들어가려면 마지막에 그 파드의 veth 를 지난다. Calico 는 veth 의 노드 쪽 끝마다 전용 규칙 묶음을 붙인다. 들어가는 방향과 나가는 방향이 따로 있어 ingress 와 egress 가 갈리고, 파드마다 인터페이스가 하나씩이라 판정도 파드 단위로 갈린다.
패킷을 통과시키거나 버리는 것은 리눅스 커널이고, 커널이 그 판정에 참조하는 규칙표가 iptables 다. 규칙 묶음을 체인이라 부르며, Calico 가 만드는 체인은 이름이 cali 로 시작한다.
라벨이 IP 로 바뀌는 곳
iptables 는 IP 와 포트로만 판정한다. 라벨은 문자열이라 커널이 모른다. 그 사이를 ipset 이 메운다 — IP 를 묶어 두고 규칙에서 한 덩어리로 참조하는 커널 목록이다.
정책에 적은 것 app 의 booking 라벨을 단 파드
▼ Calico 가 그 라벨을 단 파드의 IP 를 모은다
ipset { 10.42.207.61 }
▼
iptables 규칙 "출발지가 이 묶음에 있으면 통과"
파드가 뜨고 지면 묶음의 멤버만 갱신되고 규칙 자체는 그대로 있다.
선언을 규칙으로 옮기는 것
veth 에 체인을 붙이고 ipset 을 채우는 일은 Felix 가 한다. 파일에 적은 정책이 거기까지 가는 경로다.
kubectl apply ─▶ API 서버 ─watch─▶ 각 노드의 Felix ─▶ 그 노드의 iptables · ipset
Felix 는 노드마다 하나씩 도는 calico-node 안의 데몬이고, 자기 노드에 있는 파드까지만 규칙을 쓴다. 정책이나 파드가 바뀌면 달라진 부분만 다시 쓴다. calico-system 의 나머지 파드는 이 경로를 거드는 것들이다 — calico-typha 는 Felix 들이 API 서버를 각자 지켜보지 않도록 중계하고, install-cni 는 노드에 CNI 플러그인을 설치하고, 나머지는 정리 작업과 Calico 전용 API 를 맡는다.
규칙을 iptables 에 넣을지 eBPF 로 넣을지는 felixconfiguration 이 정한다. 지금은 bpfEnabled: false 라 iptables 다.
flannel 과의 차이
k3s 가 기본으로 설치하는 CNI 플러그인은 flannel 이다. flannel 은 길만 놓는다. 판정은 k3s 에 내장된 별도 컨트롤러가 맡는다. flannel 을 쓰면 NetworkPolicy 가 적용되지 않는 것이 아니라 두 프로그램이 나눠 맡는다.
k3s 기본 flannel(길) + k3s 내장 컨트롤러(판정)
지금 Calico(길 + 판정)
Calico 에는 거부 규칙과 우선순위가 있는 전용 정책 API 도 딸려 온다. 그것으로 쓰면 Calico 에서만 해석되어 CNI 를 바꿀 때 다시 써야 하므로, 이번에 쓰는 것은 표준 NetworkPolicy 뿐이다.
k3s 설치 때 config.yaml 에 넣은 두 줄이 이 구성을 만든다.
flannel-backend: none # 길을 놓는 k3s 기본 플러그인을 끈다
disable-network-policy: true # 판정하는 k3s 내장 컨트롤러를 끈다
뒤의 줄을 안 넣었으면 같은 정책 객체를 두 집행자가 각자 노드에 넣는다. 결과는 같아도 노드에 들어간 규칙이 어느 쪽이 만든 것인지 갈라내기 어려워진다.
무엇을 남길지 정한다
문법과 집행 경로가 정해졌으니 남은 것은 출처에 무엇을 적을지다. data 로 들어오는 접속을 일단 전부 막고 필요한 것만 다시 여는 것이므로, 지금 들어오는 것을 빠짐없이 세야 한다. 목록에서 빠진 것은 적용하는 순간 끊긴다.
들어오는 것이 세 종류다.
| 종류 | 보내는 쪽 | 어디서 아나 |
|---|---|---|
| 앱이 쓰는 것 | app 의 queue · booking |
앱 env 와 차트 값에 적혀 있다 |
data 안에서 자기들끼리 |
Redis 복제·Sentinel 투표, Kafka 브로커 간, Strimzi 오퍼레이터에서 브로커로 | 구조를 알면 나온다 |
| 클러스터가 보내는 것 | kubelet 의 상태 검사, Alloy 의 지표 수집 | 배선한 적이 없어서 조회해야 나온다 |
앞의 둘은 이미 아는 것이고, 세 번째를 조회한다.
kubelet 의 상태 검사
kubelet 은 노드마다 도는 에이전트로 컨테이너를 띄우고 내린다. 그 일 중 하나가 컨테이너가 살아 있는지 주기적으로 확인하는 것이고, 이것을 프로브라 부른다. 실패하면 컨테이너를 재시작한다.
찌르는 방식에 따라 정책에 걸리는지가 갈린다. exec 는 컨테이너 안에서 명령을 실행하므로 네트워크를 안 타고, httpGet 은 노드에서 파드 IP 로 요청을 보내므로 판정 대상이 된다. data 의 파드를 전부 조회한 결과다.
| 대상 | 프로브 | 네트워크 | 지금 걸린 정책 |
|---|---|---|---|
| MySQL · Redis · Kafka 브로커 | exec | 안 탄다 | 각자 있다 |
| Redis 파드의 지표 사이드카 | httpGet 9121 | 탄다 | redis (차트) |
| entity-operator | httpGet 8080 · 8081 | 탄다 | cgv-entity-operator |
| strimzi-cluster-operator | httpGet 8080 | 탄다 | 없다 |
둘째 줄의 사이드카는 파드 하나에 컨테이너를 둘 이상 담을 수 있어서 생긴 것이다. Redis 파드 안에 지표를 내놓는 컨테이너가 따로 돌고, 그 포트가 9121 이다.
kubelet 은 노드 주소에서 접속한다. 출발지가 파드가 아니라 노드라 라벨로 못 고르고 ipBlock 에 노드 IP 를 적어야 한다.
마지막 줄만 지금 어떤 정책에도 걸려 있지 않다. data 전체를 고르는 정책을 넣으면 이 파드가 처음으로 선택되면서 8080 이 막히고, 재시작이 반복되면 Kafka 리소스 조정이 멈춘다. 허용 규칙을 같이 넣는다.
Alloy 의 지표 수집
Alloy 는 observability 에서 도는 지표 수집 에이전트다. 무엇을 긁을지는 ServiceMonitor 객체에 적혀 있고, data 에 있는 것은 redis 하나다. 9121 을 긁는다. Kafka 와 MySQL 은 ServiceMonitor 가 없어 긁는 것이 없다.
Alloy 도 kubelet 처럼 노드마다 하나씩 뜨지만 자기 파드 네트워크를 쓴다. 출발지가 노드 IP 가 아니라 파드 IP 라 라벨로 고른다. 같은 9121 인데 kubelet 은 ipBlock, Alloy 는 셀렉터로 적게 되는 이유다.
손댈 곳
정책 4개를 새로 쓴다 (workloads/manifests/netpol/data-ingress.yaml)
| 정책 | 대상 | 여는 것 |
|---|---|---|
default-deny-ingress |
data 전체 |
없음. 판정 대상으로 만드는 것이 일이다 |
mysql-ingress |
mysql | app 의 booking 에서 3306 |
redis-ingress |
redis | app 의 queue·booking 에서 6379·26379 / Alloy·노드 3대에서 9121 |
strimzi-operator-probe |
클러스터 오퍼레이터 | 노드 3대에서 8080 |
Kafka 브로커와 entity-operator 는 Strimzi 가 만든 정책이 이미 고르고 있고 허용은 합집합이라 그대로 통과한다. 새로 열 것이 없다.
Redis 는 두 포트가 함께 필요하다. 클라이언트가 26379(Sentinel)로 마스터 주소를 받고 그 주소로 6379 에 다시 접속하므로, 26379 만 열면 주소 조회는 되고 다음 명령에서 멈춘다.
차트 정책 2건을 끈다 (cgv-mysql·cgv-redis 의 values.yaml)
networkPolicy.enabled: false. 넓은 정책이 남아 있으면 좁은 정책을 더해도 합집합이라 안 좁아진다.
Kafka CR 에 출처를 적는다 (kafka-cluster.yaml 리스너의 networkPolicyPeers)
9092 의 출처는 app 의 queue 와 booking 이다. 리스너에 적으면 오퍼레이터가 정책을 좁게 다시 만든다.
적용은 두 단계로 나눈다. 정책을 먼저 넣고 그다음에 차트 정책을 끈다. 뒤집으면 그 파드를 고르는 정책이 하나도 없는 구간이 생겨 그동안 전부 열린다.
이번에 안 좁아지는 것
- Kafka 는 무인증 평문 그대로다. 허용된 라벨을 단 파드는 인증 없이
admissions에 발행한다 - 라벨은 누구나 적을 수 있는 문자열이라 이 제한은 출처를 증명하지 않는다
app에서 나가는 방향, 클러스터 밖에서 traefik 을 거쳐 들어오는 경로는 안 건드린다
결과
data 로 들어오는 접속이 이렇게 바뀌었다.
| 전 | 후 | |
|---|---|---|
| MySQL 3306 | 클러스터 안 아무 파드나 | app 의 booking |
| Redis 6379·26379 | 아무 파드나 | app 의 queue·booking |
| Redis 9121 | 아무 파드나 | Alloy · 노드 3대 |
| Kafka 9092 | 아무 파드나 | app 의 queue·booking · 브로커 자신 |
data 의 정책 |
8건, 출처 없음 셋 | 6건, 출처 전부 지정 |
라벨이 없는 파드와 있는 파드에서 각각 붙어 확인했다.
observability, 라벨 없음 mysql 3306 · redis 6379 · kafka 9092 전부 timed out
app, booking 라벨 같은 셋 전부 open
정책은 패킷을 폐기하지 거절 응답을 보내지 않아 타임아웃으로 끝난다. 뒤쪽이 통과한다는 것이 라벨 하나로 갈린다는 뜻이기도 하다 — 두 번째 파드는 busybox 인데 라벨만 달고 통과했다.
파드 재시작은 없었고 컨슈머 오프셋도 계속 올라간다. 프로브와 앱 경로가 안 끊겼다.
걸린 것 둘
브로커 안에서 도는 관리 CLI 가 멈췄다. kafka-topics.sh --describe 가 타임아웃으로 끝났다. 클라이언트는 접속한 브로커에서 리더 목록을 받은 뒤 각 리더에 9092 로 직접 붙는데, 출처를 앱으로만 두니 브로커에서 브로커로 가는 9092 가 막혔다. 서비스 경로는 영향이 없다 — 복제는 9091, 컨트롤러는 9090, 오퍼레이터는 8443 을 쓴다. 9092 출처에 브로커 자신을 더해 풀었다. 브로커는 이미 그 데이터를 보관하는 주체라 열리는 범위가 늘지 않는다.
mysql 차트 정책이 안 지워졌다. 같은 변경인데 redis 는 반영되고 mysql 은 OutOfSync 로 남았다. 두 Application 의 prune 설정이 다르다.
prune: true git 에서 없앤 객체를 클러스터에서도 지운다 redis
prune: false git 에서 없어져도 클러스터에는 남겨 둔다 mysql
mysql 은 StatefulSet 과 PVC 가 실수로 빠질 때 볼륨이 지워지는 것을 막으려고 prune 을 꺼 두었고, 그 설정이 정책 삭제까지 함께 막았다. selfHeal: true 는 둘 다 켜져 있지만 그것은 git 에 있는 객체를 git 값으로 되돌리는 장치라 여기에 관여하지 않는다. 남은 정책은 손으로 지웠다.
5. 대조 — 이은 것과 빌린 것
간극은 메워졌나
10부 끝의 상태는 "코드가 클러스터까지 가는 길은 전부 자동인데, 그 길 끝에서 도는 앱은 검증된 적이 없다"였다. 지금은 대기 입장에서 예매 확정까지가 클러스터 위에서 실제로 돌았고, 그 흐름이 시뮬레이터로 화면에 보이고, 흐름의 약속은 테스트가 되어 CI 의 test 칸을 채웠고, data 로 들어오는 접속은 출처가 지정된 것만 남았다. 검증 안 된 앱은 없어졌다 — 남은 것은 검증 안 된 값들이고, 그 대장은 번외로 넘겼다.
이 편 안에서 닫은 것
E2E 왕복 대기 입장 → 좌석 선점 → 예매 확정이 LAN 에서 끝까지 돌았다
결함 4건 추적 등록 원자화 · 추적 해제 신설 · 입력 검증 · revoked DLT 토픽
ETA 파드마다 제각각이던 예상 시간 → Redis 버킷 90초 창 합산으로 재설계
초기화 API ADMIN_TOKEN 이 인증이자 존재 스위치 — 미주입 배포에는 라우트 자체가 없다
CI test 칸 queue 정원 불변식 · booking 좌석 정규화 + Redis 미도달 시 실패 가드
data 접속 정책 8건(출처 없음 셋) → 6건(출처 전부 지정)
README 정정 토픽 "둘"→5종 · compose 검증 단계 서술 · VIRTUAL_THREADS 잔재
무엇을 빌렸나 — 부채 장부
Kafka 무인증 평문 허용 라벨을 단 파드는 인증 없이 발행한다 — 인증·TLS 는 인터넷 공개 편
발행 저널 지표 없음 저널 크기를 읽는 함수는 있는데 호출부가 0곳 — 12부
queue 결과 카운터 없음 회수가 세션 만료인지 대기 이탈인지 수치로 못 가른다 — 12부
마이그레이션 도구 없음 booking replica 가 1 에 묶여 파드 증설 실험이 막혀 있다 — 13부 블로커
시계 계약이 git 밖 좌석락 45초·승격 배치 100·이탈 판정 30초는 코드 기본값에만 있다
처리율 창 90초 설계 규칙(세션 미만)과 정면 충돌 — 13부 예상 시간 채점으로 판정
커밋 메시지≠내용 2건 스쿼시 머지가 낡은 MR 제목을 커밋 메시지로 남겼다 — dev 역사에 그대로
실무·보안 되짚기
이 편에서 실제로 만진 것에 맞닿는 것만.
- 세그먼테이션과 인증은 다른 층이다. NetworkPolicy 로 출처를 좁혔지만 판정 근거는 라벨이고, 라벨은 누구나 적을 수 있는 문자열이다 — 라벨만 단 busybox 가 통과하는 것을 실측으로 확인했다. 이 제한은 실수와 우회 경로를 막는 층이지 신원 증명이 아니다. 신원은 워크로드 인증(mTLS·SASL)의 몫이고, 트리거는 인터넷 공개다.
- 파괴적 API 는 존재부터 조건부로 한다. 초기화 API 는 토큰 인증에 더해, 토큰이 주입되지 않은 배포에서는 라우트 자체가 등록되지 않는다. "관리용 API 가 실수로 프로덕션에 열려 있었다"는 사고 유형을 설정 실수로 막는 게 아니라 구조로 막는다.
- 조용히 건너뛰는 테스트는 게이트가 아니다. 테스트가 Redis 에 못 닿으면 로컬 실행은 건너뛰고 CI 는 실패한다. 이 구분이 없으면 서비스 컨테이너 배선이 깨진 날부터 게이트가 아무것도 지키지 않으면서 통과로 보인다.
- 스쿼시 머지는 MR 제목을 커밋 메시지로 만든다. 이 편에서 내용과 메시지가 다른 커밋이 dev 에 두 번 남았다 — 브랜치가 바뀌었는데 MR 제목이 낡은 채로 머지 버튼을 눌러서다. 머지 다이얼로그의 커밋 메시지 칸을 확인하는 것이 절차다. 커밋 역사는 나중에 감사하는 쪽이 읽는다.
6. 다음
값의 대장 — 무엇이 가상이고, 설계 문서와 실물이 어디서 갈라졌고, 관측이 무엇을 보고 못 보는지 — 는 번외(설정 대장)에 있다. 12부와 13부가 그것을 입력으로 쓴다.
12부는 관측의 빈 곳을 채운다. 코드에만 있는 계측이 화면까지 실제로 닿는지 확인하고(트레이스·로그), 없는 신호를 배선하고(Kafka·MySQL 지표, 빠진 카운터), 그 위에 앱 대시보드와 클러스터 대시보드를 세운다. 서류에 필요한 것이 여기서 완성된다. 13부는 그 계기판으로 잰다 — 파드 1대의 한계, 파드를 늘렸을 때의 곱, 정원·리소스 확정, 장애 주입, 그리고 stg/prd 스펙 산정.
'HomeLab' 카테고리의 다른 글
| 번외 — 노드 메모리와 쿠버네티스 메모리 (0) | 2026.08.15 |
|---|---|
| 번외 — 클러스터 안 카프카 (1) | 2026.08.11 |
| 노트북 홈서버 k3s 구축기 - 10. CI 와 CD (0) | 2026.08.09 |
| 번외 — CI 파이프라인 기록 (0) | 2026.08.09 |
| 번외 — WSL2와 커밋 한도 (1) | 2026.08.05 |