My Role & Technical Decisions
연구실 선배로부터 데모 단계 프로젝트를 인수해 운영 중인 서비스의 고도화를 백엔드 단독으로 담당했다(프론트엔드는 별도 담당자). Gradle 멀티모듈(common + Gateway·Auth·Course·Submit), Kafka 기반 채점 디스패치·결과 회신, SSE, JWT, KEDA 오토스케일링과 Kubernetes 배포를 설계·구현했다. 이후 GitHub Actions의 직접 배포를 Argo CD app-of-apps·ApplicationSet 기반 GitOps로 전환하고, Prometheus 메트릭과 Loki 로그를 Grafana 대시보드·Slack 알림으로 연결했다.
Troubleshooting & Evidence
원인: 단일 빌드·배포 단위에서는 작은 변경에도 전체가 재빌드·재배포되어 배포 시간이 늘고 무관한 서비스까지 재기동됨
해결: 모듈별 Dockerfile + CI/CD(GitHub Actions) path 필터로 변경된 모듈만 선택적으로 빌드·이미지화·배포 (공통 common이 바뀐 경우에만 의존 모듈로 전파)
배운 점: 모듈 경계를 CI/CD와 연계하면 변경 영향 범위를 빌드·배포 단위까지 좁힐 수 있다
원인: 제출 디스패치는 Kafka인데 결과 회신만 HTTP POST(3회 retry + API 키)로 남아 통신 방식이 혼재했다. 서버가 내려가 있으면 retry 3회가 모두 실패한 뒤 결과가 영구 유실되고, 워커가 서버 주소와 API 키를 알아야 해 결합·장애 전파도 함께 생겼다
해결: 결과 회신을 grading-result 토픽 produce/consume로 전환. 워커 5종의 retry 로직·API 키·콜백 엔드포인트와 서버의 ApiKeyFilter를 제거해 워커가 서버의 존재를 몰라도 동작하게 했다. 브로커가 메시지를 보관하므로 서버가 늦게 떠도 재처리된다
배운 점: 한 파이프라인에 통신 방식이 섞이면 약한 쪽이 전체의 신뢰도를 결정한다 — 디스패치만 비동기화하고 회신을 동기로 두면 '채점은 됐는데 점수가 없는' 상태가 남는다 (결과 유실 0건 보장, 코드 +58/−222줄)
원인: 문제 은행이 강좌에 종속돼 공유의 최소 단위가 강좌였다 — 문제 하나를 주려면 강좌를 통째로 열어야 했다
해결: 공유 단위를 문제로 낮춰 교수 간 개별 공유·해제 API를 두고(공유 대상·가능 사용자 조회 포함), 전체 공개 목록을 분리해 기본 제공 문제를 쌓을 수 있게 했다
배운 점: 재사용성은 기능을 더해서가 아니라 공유의 최소 단위를 낮춰서 생긴다
원인: CPU·메모리 기반 오토스케일링은 이미 발생한 자원 소모만 반영해, 아직 처리되지 않은 채점 수요(대기열)를 제때 감지하지 못함
해결: KEDA로 채점 워커 오토스케일링의 제어지표를 CPU·메모리에서 Kafka Consumer Lag(미처리 메시지 수)로 바꿨다. 언어별 ScaledObject를 consumerGroup·topic 단위로 분리하고(min1/max10, 15초 폴링, cooldown 60s), 파드당 허용 대기 메시지 수(lagThreshold)를 언어별로 차등했다 — JVM 콜드스타트를 제거해 케이스당 60ms 수준이 된 C·Java는 3, 처리가 더 걸리는 Python·C++·C# 는 5. 빠른 언어는 파드 한 대가 대기열을 빨리 소진하므로 더 일찍 확장시키고, 느린 언어는 소진이 더뎌 과확장을 피하도록 완화한 판단이다
배운 점: 오토스케일링은 자원 사용률보다 '미처리 작업량(Consumer Lag)'이 부하를 더 정확히 반영한다. 같은 큐라도 파드 한 대가 대기열을 소진하는 속도가 워크로드(언어)마다 달라, 임계값을 하나로 덮으면 과·소 확장이 함께 생긴다
원인: 논블로킹 WebFlux 위에서 블로킹 JPA를 호출해 이벤트 루프 스레드가 묶이는 블로킹 병목이 발생
해결: 제출 경로를 Spring MVC로 전환해 블로킹 작업을 전용 스레드풀에서 처리하도록 정리
배운 점: WebFlux는 호출 경로 전체가 논블로킹일 때만 이득 — 블로킹 JPA와 섞으면 오히려 병목이다 (JMeter 동시 500 기준 제출 p95 2,320ms→271ms, 자체 목표 응답시간 300ms 위반율 77.5%→4.5%. 단 이 수치는 MVC 전환과 HikariCP 풀·CPU 사이징이 함께 적용된 결과다)
원인: 노드 자원은 남는데도 auth 파드가 limits.cpu=2·replicas=1에 고정 — bcrypt가 CPU 바운드라 동시 요청이 늘면 Tomcat 스레드가 큐잉됨. 자원 부족이 아니라 파드에 걸린 상한이 병목이었고, 수평 확장의 진짜 천장은 MySQL max_connections였음
해결: 스레드풀 증설 대신 코어를 늘리는 수직 확장(auth CPU 2→4)에 KEDA 수평 확장(min2/max4)·gateway 이중화를 더하고, HikariCP 풀을 줄여 (replica 합산)×풀 < max_connections로 커넥션 예산을 맞춤. KEDA CPU Utilization이 limits가 아닌 requests 기준이라 조기 스케일되던 함정은 AverageValue(절대 CPU)로 정정
배운 점: '느리다'를 자원 부족으로 단정하면 안 된다 — 노드는 남는데 파드 limits/replicas 상한이 진짜 병목이었다. CPU 바운드 병목엔 스레드풀이 아니라 코어가 답이고, 수평 확장은 앱보다 공유 자원(DB 커넥션)에서 먼저 막힌다
GitHub Actions가 빌드와 배포를 모두 담당해 클러스터 상태와 Git 선언이 어긋날 수 있음
원인: CI가 kubectl로 직접 배포하면 파이프라인 실행 결과가 실제 상태의 기준이 되고, 수동 변경·실패한 재실행·구버전 워크플로가 배포 상태를 되돌릴 위험이 있었다
해결: GitHub Actions는 변경 모듈의 이미지를 빌드하고 매니페스트의 이미지 SHA만 갱신하도록 축소했다. 배포는 Argo CD root Application(app-of-apps)과 서비스별 ApplicationSet이 담당하며 automated prune·selfHeal로 Git 선언 상태를 지속적으로 복구한다. Secret은 Sealed Secrets로 Git 관리한다
배운 점: CI는 산출물을 만들고 GitOps 컨트롤러는 원하는 상태를 조정하도록 책임을 나누면 배포 이력과 실제 클러스터 상태의 기준을 Git으로 단일화할 수 있다
서버와 채점 워커의 메트릭·로그가 흩어져 장애 원인과 처리 흐름을 함께 보기 어려움
원인: 서비스별 로그를 개별 파드에서 확인해야 했고, Argo CD 동기화 상태·서비스 메트릭·채점 결과를 한 화면에서 연결해 볼 관측 계층이 없었다
해결: kube-prometheus-stack의 Prometheus·ServiceMonitor로 서비스와 Argo CD 메트릭을 수집하고, Loki에 구조화 로그를 보관했다. Grafana에 서비스·레벨·trace_id·submission_id 필터와 언어별 채점 처리량·테스트케이스 결과 패널을 구성하고 로그 알림을 Slack에 연결했다
배운 점: 관측은 도구 설치보다 동일한 요청을 메트릭·로그·배포 상태 사이에서 추적할 수 있는 공통 식별자와 대시보드 구조가 핵심이다
원인: 학생×퀘스트×문제 3중 루프에서 제출을 건별 조회 — 학생 30·퀘스트 5·문제 10 기준 요청당 최대 1,500회 SELECT (Hibernate SQL 로그로 확인)
해결: IN + JOIN FETCH 기반 단일 쿼리로 개선
배운 점: 쿼리 수는 코드 줄 수가 아니라 루프 깊이에 비례한다 — 3중 루프 안의 건별 조회 한 줄이 요청당 1,500 SELECT가 됐다. 목록 조회는 중첩이 생기는 순간 코드를 보기 전에 쿼리 수를 먼저 세야 한다 (1,500→1, Submit 목록 21→1)