#회고 #백엔드 #인프라 #AWS #DynamoDB #ECS #Cognito 오늘 Paylinker BE의 운영자 API 24개를 모두 정상화시켰다. 처음에는 0/24, 마지막에는 24/24. 다음에 비슷한 상황을 만났을 때 시간을 줄이려고 한 번에 정리해둔다. --- ## 시작: 24개 전부 500 `https://api.paylinker.kr/v3/api-docs`에 24개 운영자 엔드포인트가 정의되어 있었다. 스모크 테스트를 짜서 한 번에 호출했는데 24개 모두 500이었다. 응답 본문이 Spring 디폴트 `{timestamp, status, error, path}` 형식이었다. 즉 `GlobalExceptionHandler`도 잡지 못하는 unhandled exception 상태였다. 다만 `/actuator/health`, `/v3/api-docs`는 200이었다. SecurityConfig에서 두 경로만 permitAll로 빠져 있어서 JWT decoder를 거치지 않았기 때문이다. 결국 앱은 살아 있고 JWT 검증 직전 또는 직후에 매번 깨지고 있었다. CloudWatch 로그를 보니 단서가 나왔다. ``` Caused by: java.net.SocketTimeoutException: Connect timed out while GET cognito-idp.ap-northeast-2.amazonaws.com/.../jwks.json ``` ECS task가 private subnet에 있는데 NAT Gateway도 Cognito용 VPC interface endpoint도 없었다. 그래서 JWT decoder가 JWKS를 가져오지 못해 모든 인증 요청이 timeout으로 500이 되고 있었다. ## 1차 조치: Cognito VPC interface endpoint 추가 해결책은 단순했다. - Service: `com.amazonaws.ap-northeast-2.cognito-idp` - Subnets: ECS가 사용하는 private subnet 두 개 (AZ-2a, AZ-2b) - Security group: 기존 `dev-paylinker-vpce-sg` (ECS task SG에서 443 inbound가 이미 허용되어 있었다) - Private DNS 활성화 필수. 이걸 켜야 기존 도메인이 그대로 endpoint의 private IP로 resolve된다. NAT Gateway를 추가할지 잠깐 고민했지만, dev 환경에서 외부 호출이 사실상 Cognito 하나뿐이라 시간당 비용이 훨씬 저렴한 interface endpoint를 선택했다 (시간당 약 $0.01 vs NAT의 약 $0.06). 추가 직후 응답 포맷이 ApiResponse 표준 (`{success, code, message, data}`)으로 회복됐다. JWT decoder가 정상 작동하기 시작했다는 신호였다. ## 2차 조치: 토큰은 통과하는데 /me만 401 응답 코드가 `AUTH_INVALID_TOKEN`이었다. 코드를 보니 `UserService.getMyProfile()`이 토큰에서 세 가지 클레임을 엄격하게 요구하고 있었다. - `email`: 토큰에 있었음 - `name`: 토큰에 없었음 - `custom:createdAt`: 토큰에 없었음. User Pool 스키마에도 정의되어 있지 않았음 이 사용자 attribute는 가입 흐름이 정비되면 자동으로 채워지는데, 지금은 `AllowAdminCreateUserOnly`라 콘솔에서 생성한 사용자에게는 빠져 있었다. dev 환경이라 우회로 처리했다. 1. User Pool → Sign-up → Custom attributes에서 `createdAt`(String) 추가 2. 사용자 attribute 편집에서 `name`, `custom:createdAt` 채우기 3. 토큰을 새로 발급받기 (Cognito는 기존 토큰에 클레임을 추가하지 않는다) 여기서 짧게 정리한 내용 하나. Cognito 사용자 데이터가 DynamoDB에 저장되는지 의문이 있었는데 그렇지 않았다. Cognito User Pool은 자체 저장소를 가진다. 백엔드는 토큰의 `sub`을 admin_id로 사용해 DynamoDB에 도메인 데이터를 저장하는 구조였다. ## 3차 조치: 캠페인 생성이 500 `/me`와 일부 단순 GET이 200으로 통과하기 시작했다. 그러나 `POST /api/campaigns`는 여전히 500이었다. CloudWatch: ``` ResourceNotFoundException: Requested resource not found ``` 코드를 확인했다. `CampaignRepository.java`에서 두 줄이 잘못 작성되어 있었다. ```java getLimitTable() → tablePrefix + "-campaign_limit" getAuditLogTable() → tablePrefix + "-audit_log" ``` 실제 테이블 이름은 `dev-paylinker-campaign-limit`, `dev-paylinker-audit-log`로 dash를 사용한다. 다른 15곳의 repository는 모두 dash를 사용하는데 이 두 줄만 underscore였다. 단순 typo였다. 이걸 PR #66 으로 수정해서 머지했다. 그런데 ECS 새 deployment가 자꾸 실패하고 옛 task가 그대로 살아남는 현상을 만났다. ALB health check grace period가 180초인데 Spring Boot startup만 100초 정도 걸려서, ALB unhealthy threshold 2회 × 30s 안에 healthy 상태로 잡히지 못하고 ECS가 새 task를 kill했다. ECS service의 health check grace period를 180초에서 600초로 늘려서 해결했다. ## 4차 조치: 캠페인 create 트랜잭션이 ValidationError ``` TransactionCanceledException: [ValidationError, None, None] ``` TransactWriteItems의 첫 번째 PutItem(campaign 테이블)이 ValidationError로 거부됐다. 엔티티를 확인했다. ```java @DynamoDbPartitionKey public String getPk() // @DynamoDbAttribute("PK") 누락 ``` DynamoDB Enhanced SDK는 getter 메서드명을 기반으로 attribute 이름을 결정한다. 어노테이션이 없으면 `pk` (소문자)로 직렬화되는데, 실제 테이블 PK 컬럼은 `PK` (대문자)였다. DynamoDB는 대소문자를 구분하므로 schema mismatch가 발생했다. GSI 쪽은 `@DynamoDbAttribute("GSI1PK")`가 명시되어 있어서 정상 작동했다. 그래서 GSI Query는 통과하고 base table에 대한 PutItem/GetItem만 실패하는 패턴이었다. PR #67 로 17개 엔티티 모두에 `@DynamoDbAttribute("PK")` / `@DynamoDbAttribute("SK")`를 추가했다. 정규식으로 일괄 처리했다. 이걸 적용한 후 15/24까지 통과했다. ## 5차 조치: raw GetItem 호출들의 키 이름 오류 PR #67 적용 후 새 에러가 나타났다. ``` Query condition missed key schema element: PK ``` raw `dynamoDbClient.getItem()`을 직접 호출하는 위치 7곳이 발견됐다. 모두 `Map.of("campaign_id", ...)`처럼 비즈니스 컬럼명을 partition key 자리에 사용하고 있었다. Enhanced Client의 어노테이션을 수정한 것과는 별개로, raw 호출은 직접 수정해야 했다. 더 들어가보니 같은 root cause를 가진 문제가 줄줄이 나왔다. - ownership 검증이 `created_by` 또는 `owner_id` 키로 lookup하는데 실제 컬럼은 `admin_id`였다. 그래서 항상 FORBIDDEN으로 빠지고 있었다. - `CampaignRecipientRepository.findByCampaignId`의 raw query가 `indexName("campaign-index")`를 사용했다. 실제 GSI 이름은 `GSI1`이었다. - `ResendRequestRepository.findById(requestId)`는 sub-id만 받는데 테이블 PK는 composite key였다. URL에서 campaignId가 없어서 lookup이 불가능했다. DynamoDB에 GSI3(`request_id` 단독 hash)을 추가하고 코드를 GSI Query로 변경해서 해결했다. - `DocumentRepository.countByCampaign`의 raw query에서도 `#pk` attribute가 소문자였다. PR #68, #69, #70 을 단계적으로 만들어 처리했다. 한 PR로 묶을지 잠시 고민했지만, ResendRequest GSI 설계 같은 비즈니스 결정 영역이 있어서 분리하는 편이 안전했다. 라운드를 거듭하면서 22/24, 그리고 23/24까지 도달했다. ## 6차 조치: recipients/upload만 마지막에 남음 `POST /api/campaigns/{id}/recipients/upload`만 끝까지 500이었다. `CampaignRecipientService.toRecipientItems`가 생성하는 item map에 PK/SK가 없어서 `batchWriteItem`이 schema mismatch로 실패했다. PK/SK를 추가하려면 `recipient_id`를 어떻게 생성할지를 정해야 했다. UUID로 갈지 employee_no를 그대로 쓸지가 비즈니스 결정이었다. 매칭 로직을 확인했더니 매칭은 `employee_no` 또는 `email` 기반으로 동작하고 `recipient_id`는 매칭에 사용되지 않았다. 그래서 UUID가 안전한 선택이었다. PR #71로 PK/SK + UUID + GSI1/GSI2까지 모두 채우도록 수정했다. 이때 GitHub Actions가 갑자기 workflow run 자체를 거부하기 시작했다 ("Failed to queue workflow run"). aws-cloud-clubs org의 Actions quota가 의심됐지만 owner 권한이 없어서 billing을 확인할 수 없었다. 여기서 로컬에서 직접 빌드해 ECR에 push하는 파이프라인을 구축했다. - `brew install colima docker awscli` - `colima start`로 Docker daemon 실행 - IAM access key 발급 후 `aws configure` - `./gradlew bootJar → docker build (linux/amd64) → ECR push → ecs update-service --force-new-deployment` 한 줄 Mac M-series에서 빌드할 때 `--platform linux/amd64`를 반드시 지정해야 한다. 안 하면 ECS Fargate(x86_64)에서 "exec format error"로 task가 시작되지 않는다. 수동 배포 명령어는 `~/Downloads/paylinker-deploy.txt`에 저장해두었다. 다시 GH Actions가 막혀도 동일한 절차로 우회 가능하다. ## 7차 조치: 권한, 버킷, enum 문제 연속 수동 배포 이후에도 새 에러들이 줄줄이 발생했다. IAM: - `BatchWriteItem` 권한 누락. recipients saveAll에서 AccessDenied. - S3 `PutObject`, `GetObject` 권한 누락. 파일 업로드 실패. 각각 inline policy로 추가해서 해결했다. Enum: - 코드에 `send_status = "PENDING"`을 임시로 지정했는데 `SendJobStatus` enum에는 `QUEUED`, `SUCCESS`, `FAILED`만 정의되어 있었다. - PutItem 자체는 통과했지만 이후 GET 시 Enhanced Client가 deserialize에 실패해서 `IllegalArgumentException`이 발생했다. - PR #72 로 값을 `QUEUED`로 변경하고, 이미 저장된 `PENDING` row들은 scan + delete-item으로 청소했다. S3 버킷: - 코드가 가리키는 `dev-paylinker-uploads`, `dev-paylinker-documents`가 실제로 존재하지 않았다. - `NoSuchBucketException` 한 줄로 명확했다. `aws s3api create-bucket` 두 번으로 생성해서 해결했다. 이 단계까지 끝낸 후 마지막으로 한 번 더 스모크를 돌렸더니 24/24 그린이 나왔다. --- ## 결과 요약 | Round | 통과 | 누적 변경 사항 | | ----- | ----- | -------------------------------------------- | | 1 | 0/24 | Cognito endpoint 추가 전 | | 7 | 5/24 | endpoint 추가 + PR #66 | | 8 | 15/24 | PR #67 엔티티 어노테이션 | | 9 | 22/24 | PR #69 ownership 키 + GSI | | 10 | 23/24 | PR #70 raw query PK | | 14 | 24/24 | PR #71 #72 + IAM + S3 bucket + dirty data 청소 | 총 PR 7건(#66 ~ #72), 인프라 변경 7건, AWS 콘솔 작업 수십 회. dev 환경 첫 정착에 들어간 비용으로 보면 staging, prod는 IaC로 자동화 가능하다. 이번 작업이 사실상 그 설계의 입력 데이터가 됐다. ## 배운 점 기술적인 부분: - 인프라와 코드가 동시에 잘못되어 있을 수 있다. 인프라 한 가지를 풀어도, 코드 한 군데를 풀어도 한 번에 정상화되지 않는다. 신호 하나를 해결하면 다음 층의 신호가 드러나는 식으로 진행된다. - DynamoDB single-table design + Enhanced Client에서 attribute 이름은 반드시 `@DynamoDbAttribute`로 명시한다. getter 메서드명이 우연히 컬럼명과 일치하기를 기대하지 않는다. - 테이블 키 스키마(PK/SK)와 비즈니스 ID(`campaign_id` 같은 것)를 같은 것으로 착각하면 raw 호출이 모두 깨진다. 엔티티의 `pk()`, `sk()` static 메서드를 일관되게 사용한다. - ECS health check grace period는 Spring Boot startup time + ALB threshold 합산보다 충분히 길어야 한다. startup 100s + threshold 60s = 160s 상황에서 grace 180s는 한계에 가깝다. - IAM 정책의 action을 명시적으로 나열하는 경우 자주 누락이 발생한다. `BatchWriteItem`, `TransactWriteItems` 등 그룹화되지 않은 액션은 따로 추가해야 한다. 운영적인 부분: - CloudWatch 로그 한 줄이 추측 시간을 며칠 줄인다. stack trace를 확인하지 않고 코드를 손대지 않는다. - 호출 시각을 명확하게 기록하고 그 시각의 로그를 export한다. - PR scope는 작게 가는 편이 안전하다. 한 번에 묶으면 회귀 위험이 크다. 분할 PR이 결과적으로 더 빠르다. - 스키마나 enum 값이 변경되면 기존 데이터의 dirty row 청소를 함께 진행한다. 코드 수정과 데이터 정리는 항상 한 세트로 본다. - GH Actions 같은 외부 CI가 막혀도 작업을 이어갈 수 있도록 로컬 빌드와 푸시 경로를 미리 만들어둔다. 다음에 비슷한 상황을 만나면 다음 순서로 좁히면 된다. 1. `/actuator/health`로 앱이 살아 있는지 확인 2. JWT decoder가 외부 endpoint에 도달할 수 있는지 확인 3. IAM action 누락 확인 4. 엔티티 어노테이션 점검 5. raw query의 키 이름 점검 회고 끝.