파일 없이 격자를 점령할 수 있었던 이유

presigned URL 주의사항을 조사하다 전혀 다른 결함을 찾은 기록

MSG-132 수정 완료 · 대상: 백엔드 팀원 · 2026-07-16

1. 무엇이 문제였나

FillMap은 직접 가서 영상을 찍어야 격자를 채우는 게임이다. 그런데 파일을 하나도 올리지 않고 좌표만 찍어서 지도 전체를 칠할 수 있었다.

POST /api/videos
{"s3Key":"아무거나","lat":37.5,"lng":127.0,"durationSec":5,"recordedAt":"..."}

→ 200 OK, 격자 점령됨

saveVideo가 클라이언트가 보낸 s3Key아무 검증 없이 그대로 저장했다. DTO 검증도 @NotBlank뿐이었다.

인코딩이 실패해도 점령은 남는다

"어차피 파일이 없으면 인코딩이 실패하지 않나?"가 첫 반응이었는데, 실패해도 소용없다.

saveVideo:
  ...
  upsertUserGrid(userId, gridId, video.getId());   ← 점령 (먼저)
  triggerEncodingAfterCommit(video.getId());       ← 인코딩 (나중)

점령이 인코딩보다 먼저 일어난다. 인코딩은 나중에 FAILED가 되지만 user_grids row는 그대로 남는다. 그리고 도감 조회(GridRepository)는 user_grids만 보고 영상 상태를 확인하지 않는다. 즉 색칠된 격자는 그대로다.

2. 발견 경위

  1. 발단은 전혀 다른 질문이었다 presigned URL 운영 주의사항을 조사하다 "발급 후 확정 API가 안 오면 S3에 고아 객체가 남는다. 라이프사이클로 정리해야 한다"를 발견하고, 현재 구현이 어떤지 확인 요청.
  2. 고아를 확인하러 코드를 읽다가 더 큰 걸 봤다 saveVideos3Key를 검증 없이 저장하고 있었다. 고아는 비용 문제지만 이건 게임 자체가 성립하지 않는 문제였다.
  3. 티켓을 잘못 묶었다는 걸 알았다 처음엔 "s3Key 검증 + 고아 정리"를 MSG-132 하나로 만들었다. 우선순위가 다른 둘을 합친 게 실수였다 — 하나는 무결성 버그, 하나는 비용·위생. 고아 정리는 MSG-133으로 분리했다.
  4. 고치기 전에 재현부터 테스트를 먼저 써서 버그가 실제로 재현되는지 봤다. 5건 중 4건 실패 — 가짜 키·타인 경로 키·중복 키가 전부 통과해 격자를 점령했다.
  5. prefix 검사만으론 못 막는다는 판단 3장 참조. headObject가 필요했다.
  6. 기존 테스트가 실제 AWS S3를 부르고 있었다 headObject를 넣자 S3Exception: Forbidden 403. 그대로 뒀으면 자격증명 없는 CI가 깨졌다.
  7. 테스트 하나가 현실과 달랐다 "uuid.mp4" 고정 문자열을 써서 두 번 업로드하면 같은 키였다. 실제 presign은 매번 새 UUID를 발급한다 — 테스트가 낡아 있었던 것.
  8. 수정 완료 테스트 121건 통과, E2E로 차단·통과 모두 확인.

3. 왜 prefix만으론 안 되나

처음엔 "s3Key가 videos/original/{내 userId}/로 시작하는지만 보면 되지 않나" 싶었다. 안 된다.

presign이 발급하는 키 형식은 이렇다:

String s3Key = "videos/original/%d/%s.%s".formatted(userId, UUID.randomUUID(), extension);
//              videos/original/42/9f8c....mp4

공격자는 자기 userId를 안다. 그러니 videos/original/42/아무거나.mp4를 지어내면 prefix 검사를 그냥 통과한다. 파일은 여전히 없다.

결론: 실제로 S3에 있는지 물어봐야 한다 prefix 검사(소유권)와 headObject(실존)는 서로 다른 걸 막는다. prefix는 "남의 경로 주장"을, headObject는 "안 올리고 확정"을 막는다. 둘 다 필요하다.

4. 고치며 밟은 함정

① 기존 테스트가 진짜 AWS S3를 호출하고 있었다

headObject를 추가하자 통합 테스트 9건이 이렇게 깨졌다:

software.amazon.awssdk.services.s3.model.S3Exception: Forbidden (Service: S3, Status Code: 403)

테스트가 실제 AWS로 나가고 있었다. 내 로컬엔 자격증명이 있어서 403(권한 문제)이 났지만, CI엔 자격증명이 아예 없으니 그대로 뒀으면 CI가 깨졌다.

@MockitoBean S3Client로 막았다. 기본 스텁은 "객체가 있다" = 정상 업로드를 마친 상태다.

@MockitoBean
private S3Client s3Client;

@BeforeEach
void setUp() {
    given(s3Client.headObject(any(HeadObjectRequest.class)))
        .willReturn(HeadObjectResponse.builder().build());
    ...
}

② 테스트가 현실과 달랐다

중복 검증을 넣자 VideoServiceIntegrationTest의 "같은 좌표로 두 번 업로드" 테스트가 깨졌다.

// 고치기 전 — 매번 같은 키
"videos/original/" + userId + "/uuid.mp4"

실제 presign은 호출마다 새 UUID를 발급한다. 즉 이 테스트는 애초에 현실에서 일어나지 않는 상황을 검증하고 있었다. 새 검증이 그걸 드러낸 것이다 — 코드가 아니라 테스트가 낡았다.

이런 실패는 좋은 신호다 새 제약이 기존 테스트를 깨뜨렸을 때, 먼저 물어야 할 건 "테스트가 현실을 반영하나?"다. 이 경우 답은 아니오였고, 테스트를 고치는 게 맞았다.

5. 최종 설계

검증 3종을 saveVideo 진입부에 뒀다 — DB를 건드리기 전에 거른다.

검증무엇을 막나방법
소유권 남의 경로 주장 videos/original/{인증된 userId}/ prefix
중복 영상 1개로 무한 점령 existsByOriginalS3Key + DB UNIQUE
실존 안 올리고 확정 S3Client.headObject

에러 코드

상수developCodeHTTP
INVALID_S3_KEY3401400
UPLOAD_NOT_FOUND3402400

UNIQUE는 새 V2 파일로

-- V2__videos_original_s3_key_unique.sql
ALTER TABLE videos
    ADD CONSTRAINT uq_videos_original_s3_key UNIQUE (original_s3_key);
V1을 고치면 안 된다 — CI가 막는다 이미 적용된 V 파일을 수정하면 체크섬이 어긋나 서버가 기동 실패한다. 실제로 그 사고로 dev가 32시간 죽은 적이 있어서 CI 가드를 넣었다(MSG-130). 이번 작업이 그 가드의 첫 실사용이었고, 신규 V2 추가는 정상 통과했다(--diff-filter=M이라 수정만 잡는다).

앱 검사 + DB 제약, 왜 둘 다인가

existsByOriginalS3Key500 대신 4xx를 주려는 것이다. 이게 없으면 DB 제약 위반이 DataIntegrityViolationException → 500으로 나가서 클라이언트 잘못인데 서버 오류처럼 보인다.

반대로 앱 검사만 두면 동시 요청 두 개가 둘 다 검사를 통과할 수 있다. 최종 방어선은 UNIQUE다.

6. 결과

테스트 121건 통과 (116 → 121). 재현 테스트 5건이 수정 전 4건 실패 → 수정 후 전부 통과.

로컬 E2E (실제 S3):

시나리오결과
가짜 s3Key (안 올림)3401 / 400
형식이 다른 키3401 / 400
정상 플로우 (presign → 실제 PUT → 확정)200, occupied=true
같은 키 재확정3401 / 400
CI 안전성도 확인 AWS 자격증명과 HOME을 비운 환경에서 돌려도 통과한다 — S3 목이 제대로 막고 있다는 뜻이다.

7. 아직 안 풀린 것

처음 질문했던 고아 문제는 그대로다

headObject"안 올리고 확정"만 막는다. "올렸는데 확정 안 한" — 즉 진짜 고아는 여전히 S3에 남는다. 두 대책은 서로를 대체하지 않는다.

MSG-133이 그걸 다룬다. 거기엔 설계 제약이 하나 있다 — presign 키와 확정본이 같은 prefix(videos/original/{userId}/)를 써서, 단순 만료 규칙을 걸면 정상 영상까지 지워진다.

이미 저장된 영상은 검증되지 않았다 이 검증은 앞으로의 요청에만 적용된다. 이전에 가짜 키로 만들어진 점령이 있다면 그대로 남아 있다. dev/prod에 실사용자 데이터가 없어 지금은 무해하지만, 운영 전에 한 번 훑어볼 가치는 있다.

근거 파일: VideoServiceImpl(validateUploadedS3Key) · VideoErrorCode(3401·3402) · VideoRepository(existsByOriginalS3Key) · V2__videos_original_s3_key_unique.sql
테스트: VideoS3KeyValidationTest(재현·검증 5건) · VideoServiceIntegrationTest · VideoDeleteIntegrationTest(S3 목 처리)
관련 티켓: MSG-132(이 문서) · MSG-133(고아 정리) · MSG-130(V 파일 CI 가드)