-
Notifications
You must be signed in to change notification settings - Fork 1
Troubleshooting Case Studies
이 문서는 척척학사를 운영하며 실제로 겪은 성능 저하와 장애를 문제, 관측, 원인, 해결, 결과, 재발 방지 순서로 정리합니다. 지금 발생한 장애를 바로 진단해야 한다면 Troubleshooting을 먼저 사용합니다.
사례의 상태는 현재 코드와 런타임을 기준으로 구분합니다.
| 상태 | 의미 |
|---|---|
Current |
현재 코드나 Lambda 운영 구조에도 해결 방식이 남아 있습니다. |
Mixed |
원인 분석과 교훈은 유효하지만 일부 구현이나 인프라가 바뀌었습니다. |
Historical |
EC2, Redis 등 이전 운영 구조의 기록입니다. 현재 조치 절차로 그대로 사용하지 않습니다. |
본문의 처리량, 응답 시간, 비용은 각 글을 작성할 당시의 환경과 시나리오에서 측정한 값입니다. 현재 운영 SLO나 모든 요청의 성능을 보장하는 수치가 아닙니다.
| 사례 | 상태 | 핵심 결과 |
|---|---|---|
| 포털 연동 16초 병목 제거 | Current |
약 16초에서 0.83초로 단축 |
| 인증 필터부터 CPU까지 병목 이동 추적 | Mixed |
7 TPS 안정 구간에서 약 96 req/s 탐색 구간까지 확장 |
| API 쿼리와 캐시 최적화 | Mixed |
주요 API 응답 시간을 30~96% 단축 |
| 트래픽 피크형 구조를 요청 기반으로 전환 | Mixed |
항상 켜진 서버와 긴 요청 점유 제거 |
| Lambda 전환 후 처리량 한계 측정 | Current |
시험 설정에서 80 TPS 안정, 100 TPS부터 실패 관측 |
| Lambda 내부 비동기와 긴 콜백 제거 | Current |
유실 가능한 실행 경계를 SQS·S3·외부 Scheduler로 이동 |
| EC2 주기적 종료와 디스크 고갈 | Historical |
메모리·디스크 고갈이 Health Check 실패로 번지는 경로 확인 |
| 로그 스택 경량화 | Mixed |
ELK에서 Loki·Sentry 역할 분리로 전환 |
| 소셜 로그인 중복 계정 병합 | Current |
소셜 식별자와 학번의 책임을 분리 |
| 포털 과목 분류와 재수강 중복 보정 | Current |
과목 개설 식별자와 재수강 제외 상태 보강 |
| 수동 데이터 적재 후 Sequence 충돌 | Historical |
테이블 ID와 Sequence 불일치 확인 |
상태는 Current입니다. 현재 동기화 코드에도 일괄 조회·생성과 JDBC batch insert가 남아 있습니다.
신규 사용자의 과목 약 50개를 저장하는 포털 연동이 약 16초 걸렸습니다. 외부 포털 응답만 느리다고 추정하면 개선 지점을 놓칠 수 있는 상황이었습니다.
처음 구간을 나눠 측정했을 때 학사 데이터 처리 276ms, 과목 저장 3,577ms, 전체 14,676ms가 관측됐습니다. 저장이 병목이었지만 저장만 줄여서는 전체 10초 이상이 설명되지 않았습니다.
세부 구간을 다시 측정하자 교수 3,593ms, 과목 3,615ms, 개설 과목 조회·생성 3,532ms, 수강 과목 저장 3,613ms가 각각 반복되고 있었습니다.
각 과목을 처리할 때 교수, 과목, 개설 과목을 getOrCreate로 하나씩 조회했습니다. 데이터 수에 비례해 DB round trip이 반복되는 N+1 형태의 병목이었습니다.
- 처리할 교수명과 과목 코드를 먼저 모았습니다.
- 기존 데이터를 한 번에 조회했습니다.
- 없는 데이터만 일괄 생성했습니다.
- 개설 과목도 복합 키를 만들어 한 번에 조회·생성했습니다.
- 마지막으로 남은 수강 과목 insert를 JDBC batch로 바꿨습니다.
일괄 조회·생성 후 전체 시간은 4,431ms로 줄었습니다. JDBC batch까지 적용한 최종 측정은 전체 832ms, 수강 과목 insert 162ms였습니다. 글에서 정리한 개선 폭은 약 16초에서 0.83초, 94.8%입니다.
- 최적화 전에
[PERF] portal.sync로그의 단계별 시간을 비교합니다. - 데이터 개수에 비례해 repository 호출 횟수가 늘지 않는지 테스트합니다.
- 포털 데이터의 식별 키를 바꿀 때 일괄 조회 키와 DB unique 조건을 함께 확인합니다.
- 측정 수치는 같은 데이터 건수와 같은 환경에서 전후를 비교합니다.
상태는 Mixed입니다. 측정 환경은 단일 t3.micro EC2였지만, 현재도 JWT 인증 필터는 사용자 정보를 로컬 캐시에서 조회합니다.
Health endpoint는 평균 약 45 TPS를 처리했지만 인증과 DB 접근이 포함된 실제 API는 10 TPS 부근에서 무너졌습니다. 로그에는 Hikari connection timeout이 나타났기 때문에 DB pool 크기만 원인처럼 보였습니다.
초기 설정에서는 7 TPS가 안정적이었고 10 TPS에서 실패가 급증했습니다. Redis와 Local Cache를 비교한 두 번째 실험에서도 양쪽 모두 CannotCreateTransactionException, EntityManager 생성 실패, Hikari timeout이 남았습니다.
Local Cache가 Redis보다 성공률은 높였지만 전체 장애를 없애지는 못했습니다. 캐시 종류가 아니라 캐시 앞뒤에서 계속 발생하는 DB connection 수요를 찾아야 했습니다.
모든 인증 요청이 JWT 검증 후 사용자 정보를 DB에서 다시 조회했습니다. 비즈니스 API의 결과를 캐시해도 인증 필터가 먼저 connection을 점유하므로 pool 고갈은 계속됐습니다. Hikari timeout은 원인이 아니라 앞 단계 병목의 결과였습니다.
인증 필터의 사용자 조회에 Local Cache를 적용하고 Hikari max pool 3을 유지한 실험에서는 성공률 99.99%, HTTP 오류 0%, 평균 24ms, p95 91ms가 관측됐습니다. 이후 pool을 10으로 늘린 탐색 실험에서는 약 96 req/s까지 올라갔지만 성공률 88.26%, p95 5초로 다시 한계가 나타났습니다.
이때 Hikari timeout은 사라지고 CPU와 JVM heap이 다음 병목으로 이동했습니다. 병목 하나를 제거하면 시스템 용량이 무한히 늘어나는 것이 아니라 다음 제한 요소가 드러난다는 결론을 얻었습니다.
-
JwtAuthenticationFilter는AuthTokenCache.getOrLoad를 통해 사용자 정보를 조회합니다. - 사용자 삭제·병합·변경 시 사용자 캐시를 무효화합니다.
- Lambda의 Local Cache는 execution environment별로 분리되므로 EC2의 단일 프로세스 hit ratio를 그대로 기대하지 않습니다.
- connection timeout이 보이면 pool 크기를 먼저 올리지 않고 인증, transaction, 외부 I/O 순서로 connection 점유 구간을 측정합니다.
부하 테스트 1 원문 · 부하 테스트 2 원문 · 부하 테스트 3 원문
상태는 Mixed입니다. 쿼리 수를 줄이는 방식은 유효하지만 글의 Redis 구현은 현재 Caffeine Local Cache로 대체됐습니다.
학사 요약, 학기 목록, 졸업 진단처럼 조회가 많은 API에서 중복 쿼리와 복잡한 join이 반복됐습니다. 체감이 느리다는 설명만으로는 쿼리, 객체 조립, network 중 어디가 문제인지 판단하기 어려웠습니다.
P6Spy를 local에서만 사용해 API별 쿼리 수와 실행 시간을 측정했습니다. 중복 조회 제거, composite index, fetch join, CTE 분해, 최종 DTO 캐시를 순서대로 적용했습니다.
| API | 전후 관측 |
|---|---|
/api/academic/record |
6개 쿼리에서 4개, 546ms에서 321ms |
/api/academic/summary |
5개 쿼리에서 3개, 556ms에서 207ms, 당시 최종 캐시 17ms |
/api/semester |
198ms에서 62ms, 당시 최종 캐시 7ms |
/api/semester/grades |
178ms에서 36ms |
/api/graduation/progress |
891ms에서 431ms, 당시 최종 캐시 32ms |
| 사용자 Profile | 6개 쿼리에서 3개, 453ms에서 292ms |
- 쿼리 수와 전체 응답 시간은 함께 봅니다. 쿼리 한 개를 줄여도 큰 join이 남으면 개선 폭은 작을 수 있습니다.
- 캐시는 느린 쿼리를 숨기는 첫 수단이 아닙니다. 쿼리 구조를 정리한 뒤 반복 조회 결과에 적용합니다.
- 반환 DTO를 캐시하면 조회 비용은 크게 줄지만 데이터 갱신 경로마다 무효화가 필요합니다.
- 현재 Caffeine 캐시는 Lambda instance 사이에 공유되지 않습니다. 글의 Redis hit ratio와 현재 hit ratio는 직접 비교할 수 없습니다.
상태는 Mixed입니다. 요청 기반 Lambda와 외부 작업 큐는 현재 구조지만, 글의 비용과 일부 인프라 구성은 작성 당시 기준입니다.
척척학사는 특정 기간에 요청이 몰리고 평소에는 유휴 시간이 긴 서비스입니다. 이전 EC2·ALB 구조는 조용한 시간에도 비용이 발생했고, 포털 연동 요청은 외부 호출 약 7초와 DB 처리 약 16초 동안 request와 DB connection을 오래 점유했습니다. VU 50 시나리오에서는 성공률 0%가 관측됐습니다.
- 긴 포털 연동을 API 요청에서 분리했습니다.
- DB를 작업 큐처럼 쓰는 단계를 거쳐 SQS로 burst 흡수 책임을 옮겼습니다.
- API는 Job을 만들고
202 Accepted와 Job ID를 반환하도록 바꿨습니다. - Worker 결과는 callback으로 반영하고 중복 전달은 Job 상태와 attempt로 흡수했습니다.
- 상시 API 서버는 API Gateway와 Lambda 기반으로 전환했습니다.
API request가 외부 포털 작업 전체를 기다리지 않게 됐고 상시 서버 비용을 요청량에 비례하는 구조로 바꿨습니다. 다만 블로그의 월 비용은 당시 24시간 요청량을 월간으로 환산한 추정치입니다. 현재 비용이나 절감률로 사용하려면 AWS 청구와 실제 invocation을 다시 계산해야 합니다.
- 비동기 요청은 HTTP 성공만 확인하지 않고 Job, Outbox, SQS, Worker, Callback 상태를 끝까지 연결합니다.
- retry와 중복 callback을 정상 흐름으로 보고 idempotency 기준을 둡니다.
- 장시간
QUEUED·RUNNING작업은 외부 Scheduler가 정리합니다. - 외부 I/O와 retry sleep을 DB transaction 안에 두지 않습니다.
상태는 Current입니다. 다만 아래 값은 특정 reserved concurrency와 부하 시나리오의 결과입니다.
EC2에서 Lambda로 옮긴 뒤 처리량 한계가 사라졌다고 가정하지 않고, DB pooler와 reserved concurrency를 포함한 전체 경로의 한계를 측정했습니다.
| 요청률 | 관측 결과 |
|---|---|
| 40 TPS | 실패 0건 |
| 60 TPS | 실패 0건 |
| 80 TPS | 실패 0건 |
| 100 TPS | 실패 216건, 실패율 0.70% |
| 120 TPS, reserved concurrency 150 | 실패 4,044건, 실패율 12.25% |
| 120 TPS, reserved concurrency 180 | 실패 740건, 실패율 2.24% |
reserved concurrency를 150에서 180으로 늘리자 실패가 줄었지만 없어지지는 않았습니다. 시험 로그에서는 DB connection 고갈보다 Lambda throttle과 timeout 경계가 더 크게 나타났습니다.
해당 시험 설정에서는 80 TPS가 안정 구간이었고 100 TPS부터 실패가 관측됐습니다. 120 TPS는 concurrency만 올려 해결할 수 없는 경계였습니다. Lambda의 Local Cache는 실행 환경마다 분리되므로 shared cache와 같은 효율을 전제로 용량을 계산하면 안 됩니다.
- 부하 테스트에는 Lambda concurrency, API Gateway 오류, DB pooler connection, cold start를 같이 기록합니다.
- 평균만 보지 않고 p95, timeout, throttle, HTTP 실패율을 함께 봅니다.
- 이 표를 운영 SLO로 복사하지 않고 배포 환경과 데이터량이 바뀌면 다시 측정합니다.
상태는 Current입니다. Lambda 전환 뒤 확인된 후속 장애와 현재 보완 구조를 정리한 사례입니다.
초기 구조는 Job과 Outbox를 저장한 뒤 afterCommit과 내부 executor로 SQS 발행을 이어갔습니다. Lambda는 request가 끝난 뒤 in-memory thread가 계속 실행된다고 보장하지 않습니다. 요청은 성공했지만 SQS 발행까지 도달하지 못한 Job이 QUEUED에 남았습니다.
SQS 발행을 request 안에서 동기로 확인하도록 바꿨습니다. 네트워크 I/O와 retry sleep은 DB transaction 밖에서 실행하고, 발행 결과만 짧은 transaction으로 반영합니다.
Worker가 큰 결과 payload 전체를 callback body로 보내고 backend가 한 요청에서 후처리하던 invocation은 31,741.97ms 후 Task timed out after 31.74 seconds로 끝났습니다. 성공 로그도 남지 않았습니다.
Worker가 결과를 S3에 저장하고 callback에는 result_s3_key만 보내도록 바꿨습니다. Backend는 서명과 S3 key를 검증하고 payload를 읽은 뒤 상태 반영 transaction을 분리합니다. 정상 사례에서 backend callback 내부 시간은 약 5.193초였습니다.
Lambda container는 상시 실행되지 않고 여러 container가 동시에 생길 수 있습니다. stale Job 정리와 refresh token cleanup을 Spring @Scheduled에 맡기면 미실행과 중복 실행을 모두 통제하기 어렵습니다.
주기 작업의 trigger를 EventBridge Scheduler처럼 Lambda 밖의 Scheduler로 옮기고 내부 endpoint는 중복 호출을 견디도록 구성했습니다. Scheduler 리소스의 실제 정의는 이 저장소 밖 IaC에서 확인해야 합니다.
- 요청 이후 반드시 실행돼야 하는 작업은 Lambda 내부 thread에 맡기지 않습니다.
- DB commit과 SQS 발행 사이의 부분 실패를 Job·Outbox 상태와 idempotency로 흡수합니다.
- callback은
jobId, attempt, payload hash, S3 key를 함께 기록합니다. - timeout 수치만 늘리기 전에 어느 구간에서 시간이 사용됐는지 stage metric으로 분리합니다.
현재 처리 흐름 · 관련 ADR · S3 Callback ADR
상태는 Historical입니다. 현재 Lambda 장애에 EC2 instance 교체나 EBS 증설 절차를 적용하지 않습니다.
t3.micro EC2가 약 48시간마다 종료되고 Auto Scaling Group이 새 instance를 만들었습니다. 당시 988MB 메모리 중 842MB를 사용했고 available은 73MB였습니다. JVM Old Gen은 92%까지 올라가 Full GC가 반복됐습니다.
메모리 부족과 swap 사용이 GC·CPU 상승을 만들고 Stop-The-World 지연이 ELB Health Check timeout으로 이어졌습니다. ASG는 이를 비정상 instance로 판단해 종료했습니다. 당시에는 t3.small 2GB로 변경하고 JVM heap과 metaspace 상한을 명시해 해결했습니다.
다른 장애에서는 Insufficient space for shared memory file이 나타났고 root disk가 100%였습니다. 당시에는 EBS volume과 filesystem을 확장하고 journal과 애플리케이션 로그 보존량을 제한했습니다.
블로그에 기록된 임시 파일 삭제 명령은 과거 긴급 조치입니다. 현재 Runbook의 표준 명령으로 복사하지 않으며, 삭제 전에는 점유 파일과 프로세스를 먼저 확인합니다. Lambda의 /tmp는 instance-local 임시 저장소이므로 같은 해결책을 적용할 수 없습니다.
- Health Check 실패는 원인이 아니라 CPU, GC, disk, dependency timeout의 결과일 수 있습니다.
- instance 교체 전에 종료 직전 memory, GC, disk, Health Check 기록을 보존합니다.
- 로그 보존 정책과 disk alarm은 장애 후가 아니라 배포 시 설정합니다.
상태는 Mixed입니다. Loki와 Sentry의 역할 분리 원칙은 남아 있지만 현재 Lambda 로그 전달 경로는 배포된 Extension과 외부 IaC를 확인해야 합니다.
초기 ELK 구성은 작은 서비스의 단일 instance가 감당하기 어려웠습니다. 글의 비교에서는 ELK가 5~6GB 메모리를 요구해 t3.large 수준이 필요했고 월 약 59.9달러로 추정됐습니다.
- 일반 애플리케이션 로그와 검색은 Loki·Grafana가 담당했습니다.
- 예외 집계, stack trace, release 단위 오류 추적은 Sentry가 담당했습니다.
- 애플리케이션이 필요한 구조화 필드만 남기고 도구 역할을 중복시키지 않았습니다.
글의 시험에서는 Loki stack 메모리가 300~500MB, 인프라 비용이 월 약 7.5달러로 추정돼 비용 약 87%, 메모리 80% 이상 감소로 정리됐습니다. 이 값은 과거 EC2 기준입니다.
현재 prod Logback은 file appender를 사용합니다. CloudWatch나 Grafana에서 애플리케이션 로그가 보이지 않으면 LOG_PATH, Lambda Extension, log group, Sentry 설정을 실제 배포 환경에서 확인합니다. 저장소 설정만으로 외부 전송이 보장된다고 판단하지 않습니다.
상태는 Current입니다. 현재도 SocialAccount와 학번 기반 User 병합 로직이 존재합니다.
email만 사용자 식별자로 사용하면 사용자가 email을 바꾸거나 다른 provider로 로그인할 때 새 User가 만들어질 수 있습니다. 이후 같은 학번으로 포털을 연결하면 Student의 unique constraint와 충돌하거나 기존 학사 데이터와 분리됩니다.
소셜 로그인 식별자와 학교 사용자 식별자의 생명주기가 다르지만 하나의 email에 두 책임을 맡겼습니다. email은 provider 정책이나 사용자 설정으로 바뀔 수 있으므로 불변 식별자가 아닙니다.
- 소셜 로그인 계정을
(provider, socialId)unique key를 가진SocialAccount로 분리했습니다. - 한 User가 여러 SocialAccount를 가질 수 있게 했습니다.
- 포털 연동 시 학번으로 기존 User를 찾습니다.
- 기존 User의 SocialAccount와 Student를 현재 User로 옮긴 뒤 중복 User를 제거합니다.
- 병합 후 인증 캐시를 무효화합니다.
현재 entity의 email은 non-null이므로 원문에 있던 nullable 제안과 현재 제약은 다릅니다. 현재 동작을 변경할 때는 코드와 Flyway migration을 함께 수정해야 합니다.
- 로그인 identity는 email이 아니라 provider가 보장하는 subject를 사용합니다.
- 병합 테스트에는 같은 학번, 다른 provider, 변경된 email, 기존 학사 데이터 보존을 포함합니다.
- unique constraint 오류를 만나면 constraint를 제거하지 않고 어떤 identity가 중복됐는지 먼저 확인합니다.
상태는 Current입니다. 현재 동기화 코드와 졸업 진단 쿼리에 분류와 재수강 제외 정보가 반영돼 있습니다.
같은 과목 코드, 연도, 학기, 교수, 분반이더라도 포털의 이수 구분이 다를 수 있습니다. 기존 개설 과목 조회 key에서 이수 구분을 빼면 다른 분류의 CourseOffering을 재사용해 졸업 영역이 잘못 계산됩니다.
재수강 과목은 포털에서 원수강 성적과 재수강 성적이 함께 내려오므로 삭제·제외 상태를 구분하지 않으면 학점이 중복 집계됩니다.
- CourseOffering 식별과 생성에
facultyDivisionName과 학과 정보를 포함했습니다. - 알 수 없는 이수 구분은 원본 값도 보존해 데이터 손실을 줄였습니다.
- StudentCourse에
isRetake와isRetakeDeleted를 저장했습니다. - 졸업 진단에서는
is_retake_deleted = FALSE인 수강 과목만 집계합니다.
- 포털 필드가 식별 key인지 단순 표시 값인지 먼저 정의합니다.
- 같은 과목 코드에 이수 구분만 다른 fixture를 두고 개설 과목이 분리되는지 검증합니다.
- 재수강 상태가 바뀐 재연동에서 insert만 보지 않고 update와 졸업 집계 결과를 함께 확인합니다.
- DB 변경은 수동 DDL이 아니라 새 Flyway migration으로 반영합니다.
상태는 Historical입니다. 원인은 재현 가능하지만 운영 DB 변경은 현재 Flyway와 승인된 복구 절차를 따라야 합니다.
PostgreSQL에서 duplicate key value violates unique constraint가 발생했고 이미 존재하는 student_courses ID와 새 insert가 충돌했습니다.
CSV로 ID를 직접 지정해 데이터를 넣으면서 table의 최대 ID는 증가했지만 자동 증가 Sequence는 따라가지 않았습니다. 이후 애플리케이션 insert가 Sequence의 뒤처진 값을 받아 기존 PK와 충돌했습니다.
- 오류의 constraint와 충돌한 column을 확인합니다.
- table의 최대 ID와 Sequence의 현재 값을 비교합니다.
- backup과 대상 환경을 확인합니다.
- 승인된 DB 작업으로 Sequence를 table 데이터에 맞춥니다.
- 다음 Sequence 값과 애플리케이션 insert를 검증합니다.
운영에서 constraint를 삭제하거나 충돌 행을 임의 삭제하는 방식으로 해결하지 않습니다. 정확한 SQL은 table, Sequence, identity 설정에 따라 달라지므로 블로그 명령을 그대로 실행하지 않습니다.
- CSV 적재 시 PK를 DB가 생성하게 하거나 적재 후 Sequence 정합성을 검증합니다.
- 데이터 보정은 실행 SQL, 대상 환경, 영향 행 수, rollback 방법을 Issue에 남깁니다.
- schema 변경은 Development Guide의 Flyway 절차를 따릅니다.
새 사례는 다음 항목을 남깁니다.
- 사용자 영향과 최초 증상을 적습니다.
- 추측이 아니라 로그, metric, query, 부하 테스트 조건을 적습니다.
- 직접 원인과 연쇄적으로 나타난 증상을 구분합니다.
- 검토한 대안과 선택한 해결책을 적습니다.
- 전후 수치는 같은 조건에서 비교합니다.
- 현재도 유효한 해결인지
Current,Mixed,Historical로 표시합니다. - 재현·검증 명령에 비밀정보나 사용자 원문 데이터를 넣지 않습니다.
- architecture 선택이 바뀌었다면 Architecture Decision Records에 ADR을 추가합니다.