-
Notifications
You must be signed in to change notification settings - Fork 1
Troubleshooting
이 문서는 증상을 재현하고 원인을 좁힌 뒤 해결 결과까지 확인하기 위한 Runbook입니다. 과거 장애의 관측값, 원인 분석, 개선 결과는 Troubleshooting Case Studies에서 확인합니다. 추측으로 설정을 바꾸기 전에 실제 오류 code, 첫 스택 트레이스, jobId, 배포 SHA를 확보합니다.
- GitHub Actions 실패면 실패한 첫 Step을 확인합니다.
- API 전체가 실패하면 Health, Lambda Alias, DB 연결을 확인합니다.
- 특정 요청만
401이면 Access Token과 OIDC 설정을 확인합니다. - 포털 연동만 실패하면
jobId로 Job, Outbox, SQS, Worker, Callback, S3 순서로 추적합니다. - 응답은 성공했지만 데이터가 틀리면 후처리와 학생별 학점·졸업 요건 데이터를 확인합니다.
민감 정보를 진단 출력에 포함하지 않습니다. 특히 scrape_jobs.request_payload_json과 scrape_job_outbox.payload_json에는 포털 자격 증명이 들어갈 수 있으므로 조회 결과를 공유하지 않습니다.
확인합니다.
./gradlew bootRun --args='--spring.profiles.active=local' --stacktrace오류에 나온 변수 이름을 application.yml과 application-local.yml에서 찾습니다.
주요 원인입니다.
-
.env가 저장소 루트에 없습니다. -
LOCAL_DB_*,JWT_*,APP_KEY,APPLE_CLIENT_ID,CRAWLER_BASE_URL중 필수 값이 없습니다. -
.env값에 불필요한 따옴표나 줄 바꿈이 들어갔습니다.
해결하고 검증합니다.
비밀정보 관리 경로에서 값을 다시 받고 .env의 변수 이름을 수정합니다. 값을 터미널이나 채팅에 출력하지 않습니다. 다시 부팅한 뒤 /health를 확인합니다.
증상입니다.
-
Connection refused. -
password authentication failed. -
FATAL: (ENOTFOUND) tenant/user ... not found. - Hikari timeout.
-
UnknownHostException.
확인합니다.
-
LOCAL_DB_URL의 host, port, database 이름을 확인합니다. - DB가 실행 중이고 현재 네트워크에서 접근 가능한지 확인합니다.
- username과 password가 같은 환경의 값인지 확인합니다.
- Supabase pooler를 사용한다면 Dashboard에 표시된 접속 방식과
LOCAL_DB_URL,LOCAL_DB_USERNAME의 조합이 같은지 확인합니다. Direct connection과 Session·Transaction pooler의 host, port, username 형식을 섞으면tenant/user ... not found가 발생할 수 있습니다. - 스택 트레이스의 첫 JDBC 원인을 확인합니다.
해결하고 검증합니다.
접속 정보 또는 DB 상태를 수정합니다. 비밀번호만 반복해서 바꾸기 전에 URL과 username을 한 세트로 다시 발급받습니다. 서버를 다시 시작하고 다음 응답의 DB component가 UP인지 확인합니다.
curl -sS http://localhost:8080/actuator/health확인합니다.
lsof -nP -iTCP:8080 -sTCP:LISTEN해결하고 검증합니다.
기존 프로세스가 필요한지 확인한 뒤 종료하거나 SERVER_PORT로 다른 포트를 사용합니다. 변경한 포트의 /health를 확인합니다.
확인합니다.
curl -i http://localhost:8080/v3/api-docs
curl -i http://localhost:8080/swagger-ui/index.html주요 원인입니다.
- 서버 자체가 부팅되지 않았습니다.
- URL을
/swagger로 잘못 사용했습니다. - 다른 프로필이나 reverse proxy에서 경로가 바뀌었습니다.
재검증합니다.
/v3/api-docs가 200이고 Swagger UI에 변경 endpoint가 보이는지 확인합니다.
확인합니다.
-
AuthorizationHeader가 있는지 확인합니다. - 값이
Bearer로 시작하는지 확인합니다. - Access Token을 사용했는지 확인합니다. Refresh Token을 넣으면 안 됩니다.
해결하고 검증합니다.
로그인 또는 Refresh API로 Access Token을 다시 받고 같은 요청을 재실행합니다. Token 원문은 로그에 남기지 않습니다.
Filter는 만료 Token을 expired, 형식·서명·subject 오류를 invalid로 분류합니다.
확인합니다.
- 서버와 발급 환경의
JWT_SECRET이 같은지 확인합니다. - Token의 만료 시간을 확인합니다.
- Token subject가 사용자 UUID인지 확인합니다.
- 해당 사용자가 DB에 존재하는지 확인합니다.
해결하고 검증합니다.
환경 설정을 맞추고 새 Token을 발급합니다. 만료 Token과 정상 Token으로 각각 401, 200이 나오는지 확인합니다.
원인입니다.
ID Token의 aud가 서버 허용 목록과 다릅니다. Kakao는 APP_KEY, APP_NATIVE_KEY, Apple은 APPLE_CLIENT_ID, APPLE_ALLOWED_CLIENT_IDS를 사용합니다.
확인하고 해결합니다.
Token 원문을 공유하지 말고 Provider Console의 앱 식별자와 배포 환경 변수를 비교합니다. 모바일과 Web에서 다른 Client ID를 사용하면 허용 목록을 함께 관리합니다. 변경 후 새 ID Token으로 로그인합니다.
| Code | 확인 지점 |
|---|---|
T02 |
Token kid와 Provider 공개키가 일치하는지 확인 |
T03 |
서명, claim 파싱, Provider 응답의 첫 원인 확인 |
T04 |
ID Token 만료 시간과 기기 시각 확인 |
T08 |
로그인 요청에서 저장·전달한 nonce가 같은지 확인 |
동일 Token을 반복 사용하지 말고 Provider에서 새 Token을 발급한 뒤 재검증합니다.
분리해서 확인합니다.
curl -i -X OPTIONS http://localhost:8080/api/users/me \
-H 'Origin: http://localhost:3000' \
-H 'Access-Control-Request-Method: GET'preflight가 실패하면 프로필과 SecurityConfig의 허용 Origin을 확인합니다. preflight는 성공하지만 실제 요청이 401이면 CORS가 아니라 인증 문제입니다.
Idempotency-Key, username, password가 비어 있지 않은지 확인합니다. 지원하는 portal_type은 suwon입니다.
같은 사용자가 같은 Idempotency-Key로 다른 portal 계정 또는 다른 요청을 보냈습니다.
해결합니다.
같은 작업 재시도라면 원래 요청을 그대로 사용합니다. 새 작업이라면 새로운 key를 생성합니다. 기존 row를 임의로 삭제해 해결하지 않습니다.
확인합니다.
- 수원대학교 포털에서 같은 계정으로 직접 로그인할 수 있는지 확인합니다.
- 비밀번호 변경, 휴면, 잠금 상태를 확인합니다.
- 반복 재시도로 계정 잠금을 악화시키지 않습니다.
P03은 포털에서 계정 복구를 먼저 진행합니다. 해결 후 새 Idempotency-Key로 다시 요청합니다.
요청과 Outbox는 만들어졌지만 SQS 발행을 완료하지 못한 상태입니다.
로그를 찾습니다.
-
scrape.job.enqueue.sync.fail. -
scrape.outbox.dispatch.fail. -
scrape.outbox.publish.retry. -
scrape.outbox.retry또는scrape.outbox.dead.
확인합니다.
-
SCRAPING_PUBLISHER_ENABLED가true인지 확인합니다. -
SCRAPING_JOB_QUEUE_URL이 비어 있지 않고 대상 환경 Queue인지 확인합니다. - Lambda 실행 Role 또는 로컬 AWS 자격 증명에
sqs:SendMessage권한이 있는지 확인합니다. - Region과 Queue URL이 맞는지 확인합니다.
- AWS SDK timeout과 실제 네트워크 오류를 확인합니다.
DB에서는 민감 payload를 제외하고 조회합니다.
select job_id, status, error_code, retryable, created_at, updated_at, finished_at
from scrape_jobs
where job_id = :job_id;
select outbox_id, job_id, status, attempt_count, next_attempt_at,
last_attempt_at, sent_at, queue_message_id, last_error
from scrape_job_outbox
where job_id = :job_id;PENDING 또는 RETRYABLE_FAILED면 마지막 오류를 해결합니다. DEAD면 원인을 해결한 뒤 새 요청으로 재검증합니다. DB 행의 상태만 수동으로 변경하지 않습니다.
현재 dispatchEligibleOutboxes()를 주기적으로 호출하는 운영 경로는 코드에 없습니다. RETRYABLE_FAILED의 자동 백그라운드 재처리를 기대하지 않습니다.
- Outbox가
SENT인지 확인합니다. -
queue_message_id가 저장됐는지 확인합니다. - SQS의 visible·in-flight message와 DLQ를 확인합니다.
- Worker가 대상 Queue를 polling하는지 확인합니다.
- Worker 로그에서 같은
jobId를 찾습니다.
Outbox가 SENT인데 Worker 로그가 없다면 Queue, Region, Worker 배포 환경이 서로 다른지 확인합니다.
Worker가 포털 응답을 기다리거나 실패 콜백을 보내지 못했을 수 있습니다.
확인합니다.
- Worker 로그의
jobId와workerRequestId. - 포털 timeout, 계정 잠금, 응답 파싱 오류.
- Worker가 실패 시
status=failed콜백을 보내는지. - Backend callback endpoint 접근성과 HMAC 설정.
Backend는 콜백을 받았지만 S3 읽기 또는 DB 반영 중입니다.
로그 순서를 확인합니다.
-
stage=validated. -
stage=receipt_committed. -
stage=payload_ready. -
scrape.job.callback.postprocess.start. -
scrape.job.callback.postprocess.success. -
stage=postprocess_committed.
마지막으로 나온 stage 다음 구간의 오류를 찾습니다. 같은 콜백을 임의로 반복하기 전에 현재 Job의 callback attempt와 duplicate 여부를 확인합니다.
Worker가 제한 시간 안에 결과를 보내지 않았습니다. 기본 stale timeout 설정, Queue 지연, Worker 실행 시간, Callback 전달 실패를 함께 확인합니다. Backend 상태만 다시 열지 말고 Worker 측 작업이 실제로 종료됐는지 먼저 확인합니다.
timeout 전환은 Lambda가 source=eventbridge.scheduler, task=SCRAPE_JOB_RECONCILE_STALE event를 받을 때 실행됩니다. 이 저장소에는 Scheduler 리소스 정의가 없으므로 배포된 EventBridge target, 실행 이력, Lambda invoke 권한을 확인합니다.
로그를 찾습니다.
scrape.job.callback.invalid_signature에서 다음 진단 값만 확인합니다.
-
reason. -
timestampDeltaSeconds. -
actualSignatureEncoding. - Signature length와 hash.
- 예상 Signature hash.
주요 원인입니다.
- Worker와 Backend의
SCRAPING_CALLBACK_HMAC_SECRET이 다릅니다. - JSON을 서명한 뒤 Body를 다시 직렬화했습니다.
-
X-Timestamp단위나 값이 다릅니다. - 허용 시각 차이를 넘었습니다.
- Signature encoding이 다릅니다.
Secret과 Signature 원문을 로그에 남기지 않습니다. 두 환경의 Secret 버전과 Body 생성 순서를 맞춘 뒤 새 요청으로 검증합니다.
canonical string은 X-Timestamp + "." + 원문 Body이며 알고리즘은 HMAC-SHA256입니다. 다음 테스트로 Backend 검증 규칙을 확인합니다.
./gradlew test --tests 'com.chukchuk.haksa.infrastructure.security.HmacSignatureVerifierUnitTests'Body JSON 파싱, status, 필수 필드를 확인합니다. status는 succeeded 또는 failed여야 합니다. 성공이면 result_s3_key, 실패면 오류 정보를 확인합니다.
확인합니다.
-
result_s3_key가 설정된 Bucket과 Prefix 범위인지. - Key가 해당
job_id디렉터리에 속하는지. - 다른 환경의 Bucket이나 Prefix를 사용하지 않았는지.
검증을 우회하거나 임의 Bucket을 허용하지 않습니다. Worker 저장 위치와 Backend의 SCRAPING_RESULT_* 설정을 맞춥니다.
로그를 찾습니다.
scrape.job.s3.fail의 jobId, key, attempt, reason을 확인합니다.
확인합니다.
- Object가 실제로 존재하는지.
- Lambda Role에
s3:GetObject가 있는지. - Bucket Region이
SCRAPING_RESULT_REGION과 같은지. - Object 크기가
SCRAPING_RESULT_MAX_PAYLOAD_BYTES이하인지. - API call timeout이 네트워크 조건에 비해 너무 짧지 않은지.
권한 또는 Object를 수정한 뒤 Worker가 새 결과를 저장하는 전체 흐름으로 재검증합니다.
prod에서는 SCRAPING_RESULT_BUCKET, SCRAPING_RESULT_PREFIX, SCRAPING_RESULT_REGION을 반드시 확인합니다. 저장소의 기본 Bucket과 Prefix는 develop-shadow용입니다.
결과 JSON 파싱, 필드 구조, checksum 중 하나가 맞지 않습니다.
확인합니다.
- Worker가 저장한 Object가 완전한 JSON인지.
-
result_checksum이 원본 Object bytes의 SHA-256과 같은지. - Backend DTO와 Worker schema가 같은 배포 버전인지.
- 필수 학생·학기·과목 데이터가 빠지지 않았는지.
운영 Object 전체를 Issue에 첨부하지 않습니다. 개인정보를 제거한 최소 재현 payload로 테스트를 추가합니다.
S3 결과 검증은 통과했지만 DB 반영에 실패했습니다.
로그를 찾습니다.
-
scrape.job.callback.postprocess.start. -
scrape.job.callback.postprocess.fail. - DB constraint와 transaction 스택 트레이스.
확인합니다.
- 참조하는 사용자, 학과, 과목 row가 있는지.
- unique key 또는 foreign key 위반이 있는지.
- dev·prod 스키마가 현재 코드의 Migration까지 적용됐는지.
- 같은 학생 데이터의 중복·재수강 처리 규칙이 지켜졌는지.
원인을 수정한 테스트와 함께 배포합니다. 실패한 Job의 DB 행만 SUCCEEDED로 바꾸지 않습니다.
포털 연동 작업이 SUCCEEDED인지 확인합니다. 성공 전에 요약이나 졸업 진단을 요청하면 학생 데이터가 없습니다.
학생의 입학년도, 학과, 전공 유형에 맞는 졸업 요건 row를 확인합니다. 포털 학생 정보가 잘못 매핑됐는지와 정책 데이터 누락을 구분합니다.
편입생은 일반 재학생 영역별 요건을 자동으로 확정하지 않고 analysisStatus=MANUAL_REVIEW_REQUIRED로 부분 진단을 반환합니다. transferProgress.manualReviewReasons에서 확인이 필요한 항목을 확인합니다. T13 TRANSFER_STUDENT_UNSUPPORTED는 과거 편입생 차단 정책의 오류 코드이며 현재 졸업 진행도 조회에서는 발생하지 않습니다.
- 저장된 편입연도와
편입연도 - 2의 기준 연도를 확인합니다. 현재 학년을 기준으로 연도를 다시 계산하지 않습니다. - 주전공 또는 학과에 적용되는
department_area_requirements의 해당 연도 전핵·전선 행을 확인합니다. 한 영역의 누락을 다른 영역의 기준으로 대체하지 않습니다. - 같은 학과·연도·영역의 요구학점이 서로 다르게 중복돼 있으면 해당 영역은 미확인입니다. 동일한 값의 중복은 허용합니다.
- 복수전공은 편입 50% 적용 정책이 확정되지 않아 두 영역을 미확인으로 제공합니다.
-
CORE_CURRICULUM_UNAVAILABLE는 전핵 학점 기준 부재,ELECTIVE_REQUIREMENT_UNAVAILABLE는 전선 기준 부재입니다.COURSE_DATA_INCOMPLETE는 개인 수강 학점·코드·상충 기록을 확인해야 하는 경우입니다. - 이 변경은 PR #343에 포함됩니다. 실제 실행 버전의 병합·배포 여부를 함께 확인합니다.
확인 순서입니다.
-
student_courses의 학생별points, 이수 구분, 재수강 상태를 확인합니다. - 공통
course_offerings.points를 개인 이수 학점으로 사용하지 않았는지 확인합니다. - 학생의 입학년도, 주전공, 복수전공, 학과를 확인합니다.
- 적용된 졸업·영역·어학 요건 row를 확인합니다.
- 캐시가 있다면 원본 DB 결과와 분리해 비교합니다.
개인 학사 데이터를 공유할 때는 학번, 이름, Token을 제거합니다.
대상 GitHub Environment에 DEV_FLYWAY_DB_* 또는 PROD_FLYWAY_DB_*가 모두 있는지 확인합니다. dev 값을 prod Workflow에 재사용하지 않습니다.
적용된 Migration SQL이 나중에 수정됐을 때 발생합니다.
해결 원칙입니다.
- 기존 파일을 원래 내용으로 되돌립니다.
- 필요한 스키마 변경은 새 버전 Migration으로 추가합니다.
-
flyway_schema_history를 바로 수정하지 않습니다. - 실제 스키마와 SQL이 일치하는지 확인한 뒤에만 제한적으로 repair를 검토합니다.
Migration SQL 자체는 다음 테스트로 검증합니다.
./gradlew test --tests 'com.chukchuk.haksa.global.db.FlywayMigrationTest'Migration은 성공했는지, Lambda가 같은 DB를 바라보는지, Entity 변경에 대응하는 새 Migration이 있는지 확인합니다. ddl-auto=update로 dev·prod를 임시 수정하지 않습니다.
Workflow는 환경별 concurrency group으로 Migration을 직렬화합니다. 같은 환경의 이전 실행이 진행 중인지 확인합니다. DB lock이 의심되면 실행 중인 DDL과 transaction을 확인하고, 원인을 모른 채 Workflow를 반복 실행하지 않습니다.
Lambda 코드는 아직 배포되지 않았습니다. flyway info와 migrate의 첫 오류를 해결합니다. DB 실패를 건너뛰고 Deploy job만 실행하지 않습니다.
Actions와 같은 명령을 로컬에서 실행합니다.
./gradlew test --stacktrace --no-daemon첫 실패 테스트를 수정하고 전체 테스트를 다시 실행합니다.
확인합니다.
-
./gradlew clean lambdaZip -x test가 성공하는지. -
build/distributions/haksa-lambda.zip이 생성되는지. - Artifact Bucket variable이 대상 환경 값인지.
- AWS 자격 증명에
s3:PutObject권한이 있는지.
Workflow는 최대 30회, 2초 간격으로 State와 LastUpdateStatus를 확인합니다. 실패하면 Lambda configuration의 StateReason, 배포 ZIP 크기, Handler, Runtime, IAM, Layer 설정을 확인합니다.
Actions Summary의 Published Version과 Alias Version을 비교합니다. API Gateway가 같은 Alias를 호출하는지도 확인합니다. $LATEST 또는 다른 환경 Alias를 보고 있으면 Workflow 성공과 실제 트래픽 대상이 다를 수 있습니다.
prod Workflow는 main에서만 실행됩니다. main이 아닌 ref를 선택하면 preflight가 Migration 전에 실패하며, 다른 브랜치에서 실행해도 main을 대신 배포하는 동작은 하지 않습니다.
Alias 검증과 태그 push는 성공했지만 GitHub Release 생성이 실패한 부분 성공입니다. Lambda 배포를 다시 실행하거나 태그를 삭제·이동하지 않습니다.
다음 순서로 확인합니다.
- 실패한 Actions run의
headSha를 확인합니다. -
git rev-parse 'be-v{버전}^{commit}'으로 annotated tag를 역참조한 commit SHA를 확인합니다. - 해당 run의
prod-lambda-deployment-metadataartifact에서commit_sha,published_version,alias_version을 확인합니다. -
aws lambda get-alias로 live Alias가 가리키는 Version을 확인합니다. - run SHA, tag SHA, metadata commit SHA가 같고 metadata의 published·alias version과 live Alias가 일치할 때만 같은 태그에 GitHub Release를 생성합니다.
Release 본문에는 제품 버전, commit SHA, Lambda published version과 alias version을 포함합니다. 다운로드와 대조, gh release create --verify-tag 실행 예시는 Release Management의 릴리즈 기록 게시 실패 복구를 따릅니다. 값이 다르면 후속 배포나 수동 변경 여부를 먼저 조사합니다.
- Alias가 새 Version인지 확인합니다.
- CloudWatch에서 cold start의 첫 예외를 확인합니다.
- Lambda 환경 변수의 DB, JWT, OIDC, SQS, S3 설정을 확인합니다.
-
/actuator/health의 DB 상태를 확인합니다. - 새 Version만 문제면 직전 정상 Version으로 Alias를 롤백합니다.
Lambda에서 SPRING_PROFILES_ACTIVE가 없으면 develop-shadow가 선택됩니다. prod 장애에서는 프로필이 prod로 주입됐는지 가장 먼저 확인합니다. 배포 Workflow는 환경 변수, IAM, Layer, Extension을 변경하지 않습니다.
DB Migration은 Alias 롤백과 별개입니다. 이전 코드가 새 스키마와 호환되는지 먼저 확인합니다.
정상입니다. local 프로필은 Sentry 자동 설정을 제외하고 sentry.enable=false입니다.
DEV_SENTRY_DSN 또는 PROD_SENTRY_DSN, active profile, 최소 event level이 ERROR인지 확인합니다. 예외가 로그에서 삼켜지지 않았는지도 확인합니다.
확인합니다.
-
MANAGEMENT_TRACING_ENABLED. -
MANAGEMENT_OTLP_TRACING_ENDPOINT. 현재 설정만으로 Trace가 전송되지는 않습니다. -
MANAGEMENT_OTLP_METRICS_EXPORT_ENABLED. -
MANAGEMENT_OTLP_METRICS_EXPORT_URL. - Lambda가 OTLP endpoint에 연결 가능한지.
local에서 OTLP metric export는 기본 비활성입니다. Grafana에 데이터가 없다는 사실만으로 요청이 없었다고 판단하지 않습니다. CloudWatch 요청 로그와 Actuator metric을 함께 확인합니다.
현재 애플리케이션은 OtlpAutoConfiguration을 제외해 OTLP Trace exporter를 자동 구성하지 않습니다. Trace·span ID는 생성되지만 Grafana로의 전송은 보장되지 않습니다.
prod Logback에는 Console appender가 없고 기본 file 경로는 /var/log/app입니다. Lambda platform 로그만 보이고 애플리케이션 로그가 없다면 다음을 확인합니다.
-
LOG_PATH가 Lambda에서 쓸 수 있는 경로인지. - Telemetry Extension 또는 file 로그 전달 설정이 있는지.
- Sentry DSN과 event level이 맞는지.
이 저장소에는 실제 Lambda Extension과 CloudWatch log group 정의가 없습니다. 리소스 이름을 추측하지 말고 배포된 Lambda 설정에서 확인합니다.
jobId로 시작해 outboxId, queueMessageId, workerRequestId를 연결합니다. 로그 검색에 포털 username이나 password를 사용하지 않습니다.
주요 로그 키입니다.
scrape.job.accepted
scrape.outbox.sent
scrape.job.callback.stage
scrape.job.callback.postprocess.start
scrape.job.callback.postprocess.success
scrape.job.failed
- 재현 요청의 HTTP status와 오류 code가 정상으로 돌아왔습니다.
- 같은
jobId의 Job과 Outbox 상태가 서로 모순되지 않습니다. - Worker와 Backend 로그가
workerRequestId로 연결됩니다. - DB 변경이면 Flyway와 Hibernate validation이 통과합니다.
- 배포 변경이면 Published Version과 Alias Version이 같습니다.
- Lambda active profile이 대상 환경과 일치합니다.
-
/health와/actuator/health가 정상입니다. - Sentry와 CloudWatch에서 같은 오류가 다시 발생하지 않습니다.
- 원인, 수정, 검증 명령, 남은 위험을 Issue 또는 PR에 기록했습니다.
장애 분석을 재사용할 가치가 있으면 Troubleshooting Case Studies의 작성 규칙에 따라 사례를 추가합니다. architecture 선택이 바뀌었다면 별도 ADR도 작성합니다.