파일 없이 격자를 점령할 수 있었던 이유
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. 발견 경위
- 발단은 전혀 다른 질문이었다 presigned URL 운영 주의사항을 조사하다 "발급 후 확정 API가 안 오면 S3에 고아 객체가 남는다. 라이프사이클로 정리해야 한다"를 발견하고, 현재 구현이 어떤지 확인 요청.
-
고아를 확인하러 코드를 읽다가 더 큰 걸 봤다
saveVideo가s3Key를 검증 없이 저장하고 있었다. 고아는 비용 문제지만 이건 게임 자체가 성립하지 않는 문제였다. - 티켓을 잘못 묶었다는 걸 알았다 처음엔 "s3Key 검증 + 고아 정리"를 MSG-132 하나로 만들었다. 우선순위가 다른 둘을 합친 게 실수였다 — 하나는 무결성 버그, 하나는 비용·위생. 고아 정리는 MSG-133으로 분리했다.
- 고치기 전에 재현부터 테스트를 먼저 써서 버그가 실제로 재현되는지 봤다. 5건 중 4건 실패 — 가짜 키·타인 경로 키·중복 키가 전부 통과해 격자를 점령했다.
-
prefix 검사만으론 못 막는다는 판단
3장 참조.
headObject가 필요했다. -
기존 테스트가 실제 AWS S3를 부르고 있었다
headObject를 넣자S3Exception: Forbidden 403. 그대로 뒀으면 자격증명 없는 CI가 깨졌다. -
테스트 하나가 현실과 달랐다
"uuid.mp4"고정 문자열을 써서 두 번 업로드하면 같은 키였다. 실제 presign은 매번 새 UUID를 발급한다 — 테스트가 낡아 있었던 것. - 수정 완료 테스트 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 검사를 그냥 통과한다. 파일은 여전히 없다.
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 |
에러 코드
| 상수 | developCode | HTTP |
|---|---|---|
INVALID_S3_KEY | 3401 | 400 |
UPLOAD_NOT_FOUND | 3402 | 400 |
UNIQUE는 새 V2 파일로
-- V2__videos_original_s3_key_unique.sql
ALTER TABLE videos
ADD CONSTRAINT uq_videos_original_s3_key UNIQUE (original_s3_key);
--diff-filter=M이라 수정만 잡는다).
앱 검사 + DB 제약, 왜 둘 다인가
existsByOriginalS3Key는 500 대신 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 |
HOME을 비운 환경에서 돌려도 통과한다 — S3 목이 제대로 막고 있다는 뜻이다.
7. 아직 안 풀린 것
headObject는 "안 올리고 확정"만 막는다.
"올렸는데 확정 안 한" — 즉 진짜 고아는 여전히 S3에 남는다. 두 대책은 서로를
대체하지 않는다.
MSG-133이 그걸 다룬다. 거기엔 설계 제약이 하나 있다 — presign 키와 확정본이
같은 prefix(videos/original/{userId}/)를 써서, 단순 만료 규칙을 걸면
정상 영상까지 지워진다.
근거 파일: 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 가드)