이 문서는 유지합니다. 증상 → 원인 표는 어떤 코드에도 적히지 않는 정보라,
.tf와 워크플로가 생겨도 대체되지 않습니다. 같은 삽질을 두 번 하면 여기에 한 줄 추가하세요.
의존성 때문에 infra/terraform을 한 번에 apply할 수 없습니다. ECR이 비어 있으면 ECS 서비스가 이미지를 못 찾아 무한 재시도하므로 순서가 중요합니다.
cd infra/terraform
terraform init
# 1) 네트워크
terraform apply -target=aws_vpc.main \
-target=aws_subnet.public -target=aws_subnet.private \
-target=aws_internet_gateway.main
# 2) ECR 먼저 (이미지를 넣어야 ECS가 뜸)
terraform apply -target=aws_ecr_repository.api
# 3) 첫 이미지 push
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REGISTRY=$ACCOUNT.dkr.ecr.ap-northeast-2.amazonaws.com
aws ecr get-login-password --region ap-northeast-2 \
| docker login --username AWS --password-stdin $REGISTRY
docker buildx build --platform linux/amd64 \
-t $REGISTRY/hacker-api:latest ./apps/api --push
# 4) RDS (5~10분 소요)
terraform apply -target=aws_db_instance.main
# 5) 전체
terraform plan -out=tfplan
terraform apply tfplan6) ACM 인증서 DNS 검증 (#156). aws_acm_certificate.api는 신청만 하고 끝나지 않는다 —
aws_acm_certificate_validation 리소스가 없어서 terraform apply는 검증을 기다리지 않고 끝나며,
인증서는 PENDING_VALIDATION으로 남는다. 이 상태로는 443 리스너가 TLS 핸드셰이크를 못 마친다.
terraform output -json acm_validation_record나온 name/type/value를 khuhacker.com을 관리하는 DNS(이 AWS 계정의 Route53이 아니다 —
도메인을 산 등록기관 쪽)에 CNAME으로 등록한다. 전파에 몇 분에서 몇십 분 걸린다. 아래로 상태를
확인한다.
aws acm list-certificates --region ap-northeast-2 \
--query "CertificateSummaryList[?DomainName=='api.khuhacker.com'].Status" --output text
# ISSUED가 나오면 완료검증:
curl https://api.khuhacker.com/actuator/health
# {"status":"UP", ...} 나오면 최초 배포 확인 완료vercel.json의 destination은 고정값 https://api.khuhacker.com/api/:path*이라 배포마다 따로
채울 게 없다 (deployment.md) — ALB의 원본 DNS 이름을 여기 넣지 않는다. 그렇게 하면
평문 ALB로 직접 프록시하게 되어 #156 이전으로 되돌아간다.
| 증상 | 원인 / 해결 |
|---|---|
exec format error |
맥에서 arm64 빌드. platforms: linux/amd64 + 태스크 정의 X86_64 일치 (deployment.md) |
| 태스크가 PENDING에서 안 넘어감 | assign_public_ip = false + NAT 없음 → ECR pull 불가 |
ResourceInitializationError |
Execution Role에 ssm:GetParameters 누락 (infra.md ecs.tf) |
| 타겟 unhealthy 무한 반복 | /actuator/health가 Security에 막혀 401 / startPeriod 짧음 |
| DB 연결 타임아웃 | RDS SG에 ECS SG 미등록 |
GHA AccessDenied on RegisterTaskDefinition |
iam:PassRole 누락 (infra.md cicd.tf) |
| GHA OIDC 인증 실패 | permissions: id-token: write 누락 또는 sub 조건 불일치 |
| terraform이 CI 배포를 롤백 | ignore_changes = [task_definition] 누락 |
| presigned 업로드 CORS 에러 | S3 allowed_origins에 localhost/Vercel 도메인 누락 |
| Vercel에서 API 호출 실패 | vercel.json의 /api/* rewrite가 없거나 SPA fallback 아래에 있다 — fallback이 API 요청까지 index.html로 삼킨다. destination은 https://api.khuhacker.com (deployment.md) |
| develop에 머지했는데 운영이 안 바뀜 | Vercel Production Branch는 main이다. develop push는 Preview만 만든다. release/vX.Y.Z → main을 태워야 운영에 나간다 (CONTRIBUTING.md) |
| main 머지 후 Vercel Production 배포가 안 걸림 | GitHub 장애(webhook 유실)일 수 있다. 대시보드 상단 배너 확인 → 해당 커밋의 빌드를 Promote to Production으로 수동 승격 (2026-08-17 실제 사례 — 빌드 결과물은 커밋이 같으면 동일하다) |
| main 머지에 API 배포가 안 돎 | deploy-api.yml은 apps/api/** 변경일 때만 트리거된다. 웹만 바뀐 릴리스면 안 도는 게 정상이다 |
| 태스크가 가끔 재시작 | Fargate Spot 회수. 정상 동작 (결정 3) |
첫 가입자가 계속 PENDING |
최초 관리자 승격을 안 했다. 아래 절차를 밟는다 |
| 관리자가 전부 사라짐 | 아래 절차로 복구한다 (2-2 §2-2-7) |
| 마지막 관리자가 정지됨 | 새 상태 PATCH는 이 조합을 만들지 않는다. 기존 데이터·삭제/탈퇴 중단 여부를 확인하고 아래 "정지된 관리자밖에 없을 때"를 본다 |
승격 API가 계속 403 |
조건 넷 중 하나가 어긋났다. 응답은 사유를 알려주지 않으므로 서버 로그를 본다 (관리자 승격 거절 줄) |
배포 시점에 이미 로그인해 있던 세션은 정지·승인이 반영되지 않는다. 세션을 사용자로 찾으려면 SPRING_SESSION.PRINCIPAL_NAME이 채워져 있어야 하는데, 그 값은 로그인할 때 들어간다 (3-1 §3-1-5).
- 세션 수명이 30분이라 그냥 두면 사라진다. 마이그레이션은 필요 없다.
- 그 사이에 급히 정지해야 하는 계정이 있으면 DB에서 그 사람의 세션을 직접 지운다. 지우면 다음 요청이
401이 되어 정지 안내 대신 로그인 화면으로 가지만, 접근은 즉시 끊긴다.
-- 급할 때만. 평소에는 관리자 화면의 정지를 쓴다.
DELETE FROM spring_session WHERE primary_id IN (
SELECT session_primary_id FROM spring_session_attributes
WHERE attribute_name = 'auth.userId'
);위 질의는 로그인한 세션 전부를 지운다 — 배포 직후 특정 계정만 골라낼 방법이 없기 때문이다(그래서 이 창이 문제다). 전원이 다시 로그인하면 된다.
(2026-08-30, #296.)
배포 뒤에는 PATCH /admin/users/{id}/status가 ADMIN 직접 정지를 거절하고 먼저 USER로 권한을 회수하라고 안내한다. 기존 행은 자동 보정하지 않고 DB CHECK도 추가하지 않는다. 과거 API와 삭제·탈퇴 중단 경로가 SUSPENDED ADMIN을 남길 수 있어, 자동 회수하면 REVOKE_ADMIN 감사 없이 권한이 사라지고 마지막 관리자 복구 의미도 바뀌기 때문이다.
배포 전에 읽기 전용으로 후보를 확인한다. ECS Exec로 API 컨테이너에 들어가 psql을 사용한다.
SELECT id, email, status, role, approved_at
FROM users
WHERE role = 'ADMIN' AND status = 'SUSPENDED'
ORDER BY id;결과가 없으면 끝이다. 결과가 있으면 행마다 admin_actions와 아래 운영 로그를 확인해 원인을 판단한다. 조회 결과를 한꺼번에 UPDATE하지 않는다.
SELECT actor_id, target_id, action, created_at
FROM admin_actions
WHERE target_id = <user-id>
ORDER BY id;| 판단 | 처리 |
|---|---|
| 계속 관리자여야 하고 정지만 풀면 됨 | 회원 관리에서 ACTIVE로 해제한다. 기존 관리자 권한을 유지한다 |
| 관리자 권한을 회수해야 함 | 먼저 role을 USER로 회수한다. 정지를 유지하려면 별도 status 정지를 다시 보내 세션을 확인하고, 제거할 계정이면 제거 절차를 따른다 |
| 삭제·탈퇴가 중단됨 | 아래 중단된 탈퇴와 CloudWatch 로그를 대조해 제거 완결 또는 해제를 고른다 |
| 근거를 찾지 못함 | 자동 변경하지 않는다. 계정 소유자·운영진 확인 뒤 위 셋 중 하나를 고른다 |
ACTIVE ADMIN의 권한 회수 뒤 정지는 두 요청으로 실행한다. 정상 결과는 REVOKE_ADMIN과 SUSPEND 두 감사 행, 세션의 USER 반영 뒤 SUSPENDED 반영이다. 이미 SUSPENDED ADMIN인 기존 행은 role 회수만 새 감사로 남고, 같은 status 재요청은 세션만 다시 확인한다. 직접 관리자 정지 거절에는 status·role·세션·감사 변화가 없어야 한다.
배포 직후 반드시 한 번 해야 한다 (7-DEPLOYMENT MUST). 안 하면 관리자가 0명이라 아무도 가입을 승인할 수 없고, 첫 가입자가 계속 PENDING으로 남는다. 마지막 관리자가 사라졌을 때의 복구 절차이기도 하다.
넷을 모두 만족해야 승격된다 (3-3 결정 11).
- 활성 관리자가 0명
- 요청자 이메일 ==
ADMIN_BOOTSTRAP_EMAIL - 본문 토큰 ==
ADMIN_BOOTSTRAP_TOKEN - 신청서 제출 완료 — 구글 로그인만으로는 안 된다
403이 나오면 사유는 응답이 아니라 로그에 있다:
aws logs filter-log-events --log-group-name /ecs/hacker-api \
--filter-pattern "승격 거절" --region ap-northeast-2 \
--query 'events[].message' --output text"이메일이 설정값과 다르다"가 뜨면 SSM
/hacker/dev/ADMIN_BOOTSTRAP_EMAIL을 의심한다.terraform.tfvars.example의 기본값(admin@khu.ac.kr)이 그대로 apply된 사례가 있다 (2026-08-18, #169). 고치는 곳은terraform.tfvars이고, 콘솔에서 값만 바꾸면 다음 apply가 되돌린다. 바꾼 뒤에는 ECS 강제 재배포까지 해야 반영된다 — SSM 값은 컨테이너가 시작할 때만 읽는다.
① 정상 가입을 마친다. 구글로 로그인하고 신청 폼까지 제출한다. 4번 조건이라 건너뛰면 승격되지 않는다.
② 토큰을 조회한다.
aws ssm get-parameter --name /hacker/dev/ADMIN_BOOTSTRAP_TOKEN \
--with-decryption --region ap-northeast-2 --query Parameter.Value --output text파라미터 이름은 infra.md의
ssm.tf가 원본이다. 환경이 늘면 그 경로도 함께 바뀐다.
③ 브라우저에서 호출한다. 로그인한 그 브라우저의 개발자 도구 콘솔에서 실행한다 — 쿠키 세 개(SESSION·ACCESS_TOKEN·CSRF)가 모두 필요해 curl로는 번거롭다.
await fetch('/api/v1/auth/csrf', { credentials: 'include' }) // 쿠키를 먼저 받는다
const csrf = document.cookie.match(/XSRF-TOKEN=([^;]+)/)[1]
const res = await fetch('/api/v1/auth/bootstrap-admin', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': csrf },
body: JSON.stringify({ token: '<②에서 받은 값>' }),
})
res.status // 204면 성공④ 확인한다. 새로고침하면 관리자 화면이 열린다 — 승격은 기존 세션에 즉시 반영되므로 재로그인이 필요 없다 (3-1 §3-1-5).
한 계정은 15분에 5회, 이 경로 전체는 15분에 20회까지 시도할 수 있습니다. 넘으면 같은 창 동안 막힙니다 (3-2 §3-2-3, #144).
잠긴 것도 다른 거절과 똑같은 403입니다. 응답만으로는 구별할 수 없으므로, 토큰이 맞는데도 계속 403이 나오면 잠겼을 가능성을 의심하세요. 진짜 사유는 API 로그에 남습니다.
관리자 승격이 계정 단위로 잠겼다: userId=... 창=15분
해제 수단은 없습니다. 15분을 기다리세요. 잠긴 동안의 요청은 세지 않으므로 재시도해도 대기 시간이 늘어나지는 않습니다. 해제 스위치는 그 자체가 새 공격 표면이고, RDS가 프라이빗이라 DB에서 직접 지우는 것은 15분보다 오래 걸립니다.
성공하면 그 계정의 시도 기록은 지워집니다. 토큰을 잘못 붙여넣어 몇 번 틀린 뒤 성공했다면, 다음 사고 때 그것 때문에 막히지 않습니다.
사고 대응 중이라면 다른 관리자 후보 계정으로 시도할 수 있습니다 — 잠금은 계정 단위입니다. 다만
ADMIN_BOOTSTRAP_EMAIL과 이메일이 일치해야 하므로, 실제로는 그 이메일을 쓰는 계정만 해당합니다.
파라미터를 지우지 않는다. 태스크 정의가 두 값을 secrets로 항상 참조하므로(infra.md ecs.tf), 지우면 새 태스크가 기동 전에 실패한다 — 배포나 Spot 회수 뒤 대체 태스크가 뜨지 못해 서비스가 통째로 멈춘다.
값만 바꾸고 재배포한다. SSM 값은 컨테이너가 시작할 때 환경변수로 주입되므로, 이미 돌고 있는 태스크에는 반영되지 않는다.
Terraform을 통해 바꾼다. 이 파라미터의 값은 random_password.admin_bootstrap_token이 소유한다(infra.md ssm.tf) — aws ssm put-parameter로 직접 덮으면 다음 terraform apply가 그것을 드리프트로 보고 옛 토큰을 되살린다.
terraform apply -replace=random_password.admin_bootstrap_token
aws ecs update-service --cluster hacker-cluster --service hacker-api --force-new-deploymentTerraform을 쓸 수 없는 상황이라 CLI로 급히 바꿨다면, 그 값을 잊지 말고 Terraform 쪽에도 반영한다 — 하지 않으면 다음 apply에 조용히 되돌아간다.
마지막 활성 관리자가 SUSPENDED가 되어 0명이 된 경우는 위 절차로 복구되지 않는다. #296 뒤 일반 상태 PATCH는 이 조합을 새로 만들지 않지만, 기존 데이터나 관리자 삭제·탈퇴의 선행 정지만 남은 실패에서는 여전히 가능하다. 그 계정은 로그인 단계에서 거절되고, 세션이 남아 있어도 승격 경로가 정지된 계정을 거절한다.
- 신청서까지 마친 다른 계정을 준비한다 (없으면 그 사람이 먼저 가입·신청한다).
ADMIN_BOOTSTRAP_EMAIL을 그 주소로 바꾼다 — 값의 소유자가 Terraform이므로.tf에서 고쳐apply한다.- ECS를 재배포한다. SSM 값은 컨테이너가 시작할 때 읽는다.
- 그 계정으로 위의 승격 절차를 밟는다.
- 복구한 뒤 정지됐던 계정을 배포 전후 점검에 따라 해제하거나, 권한을 회수한 뒤 정지를 유지·제거한다.
토큰을 채팅·이슈·커밋에 남기지 않는다. 안전한 채널로 최초 관리자에게만 전달한다 (infra.md).
403이 나오면 응답만으로는 무엇이 틀렸는지 알 수 없다 — 사유를 알려주면 토큰을 추측할 수 있기 때문이다. CloudWatch 로그에서관리자 승격 거절줄을 찾으면 사유가 적혀 있다.
(2026-08-28, #223. 규칙은 spec 3-2에 있다.)
회원 탈퇴는 계정을 SUSPENDED로 만든 뒤 지운다. 그 사이에 세션 반영 확인이나 삭제가 실패하면 계정이 정지된 채로 남는다. 이때 본인은 로그인도 API 접근도 잃어 다시 시도할 수 없다 — 운영자가 이어받아야 한다.
겉보기로는 관리자가 정지시킨 계정과 구별되지 않는다. 가르는 근거는 로그뿐이다.
# 중단된 탈퇴 찾기 — 대상 id가 함께 찍힌다
aws logs filter-log-events --log-group-name /ecs/hacker-api \
--filter-pattern '"탈퇴 중 정지만 남았다"' \
--start-time $(( ($(date +%s) - 7*86400) * 1000 ))찾았으면 둘 중 하나를 고른다. 본인 의사가 "나가겠다"였으므로 기본은 제거다.
| 상황 | 무엇을 |
|---|---|
| 그대로 탈퇴를 완결한다 (기본) | 회원 관리 화면에서 그 계정을 제거한다. 남을 콘텐츠 건수를 확인 창이 보여준다 |
| 본인이 취소를 원한다고 확인됐다 | 정지를 해제한다. ACTIVE로 돌아오고 본인이 다시 로그인할 수 있다 |
임의로 해제해 두지 않는다. 해제하면 그 사람은 자기가 탈퇴했다고 믿는 채로 계정이 살아 있게 된다. 확인이 안 되면 제거 쪽이 본인 의사에 가깝다.
"탈퇴 중 정지만 남았다"로그가 없다고 곧바로 일반 정지로 단정하지 않는다. #296 이전 관리자 직접 정지, 관리자 제거 중단, 수동 운영 변경도 가능하다.admin_actions와 같은 시각의 CloudWatch 로그를 함께 보고 배포 전후 점검에서 처리한다.
매일 새벽 4시(한국 시간)에 자동으로 돈다 — API 컨테이너 안에서 OrphanObjectCleanupJob이 실행되는 것이지 별도 배치 인프라가 아니다. 이 저장소에 있는 유일한 @Scheduled 사용처다. 컨테이너 시간대와 무관하다 — @Scheduled(zone = "Asia/Seoul")로 못박아 두었다(#342 리뷰). 운영 이미지는 TZ를 설정하지 않아 JVM 기본이 UTC이므로, 이 지정이 없으면 같은 cron이 한국 시간 오후 1시에 돈다.
무엇을 하는가. notes/·photos/의 최종 위치(notes/uploads/·photos/uploads/ 임시 위치는 제외)를 훑어, DB(note_files.stored_path, photos.stored_path + 유도한 썸네일 키)가 참조하지 않고 1시간(app.storage.orphan-cleanup.safety-margin) 넘게 지난 오브젝트를 지운다. 등록 롤백·자료 수정·사진 삭제의 보상 S3 삭제가 실패하면(StagedUploads·PhotoService가 log.error로 남기는 자리) 이 작업이 다음 새벽에 대신 정리한다.
사람이 개입할 일은 평소 없다. 여러 태스크가 배포 중 잠깐 겹쳐도 pg_advisory_xact_lock으로 한 번에 하나만 돈다.
확인하고 싶으면 CloudWatch에서 "고아 오브젝트 정리 완료" 로그를 찾는다 — 지운 건수·안전 여유로 유보한 건수가 함께 찍힌다. "참조를 잃은 오브젝트를 지웠다"가 대량으로 반복되면 보상 삭제가 자주 실패하고 있다는 신호이니 원인(대개 일시적인 S3 오류)을 따로 살펴본다.
안전 여유·주기를 조정하려면 apps/api/src/main/resources/application.yml의 app.storage.orphan-cleanup을 바꾸고 배포한다 — SSM 값이 아니라 일반 설정이라 코드 변경 없이 값만 바꿔도 된다.
# 컨테이너 접속
aws ecs execute-command --cluster hacker-cluster \
--task <task-id> --container api --interactive --command "/bin/sh"
# 로그
aws logs tail /ecs/hacker-api --follow
# 서비스 이벤트 (배포 실패 원인이 여기 찍힘)
aws ecs describe-services --cluster hacker-cluster \
--services hacker-api --query "services[0].events[:10]"dev RDS를 직접 들여다봐야 할 때도 이 컨테이너 접속으로 들어가서 psql을 씁니다 (RDS가 프라이빗 서브넷이라 로컬에서 직접 못 붙습니다).