영상 업로드 플로우
presigned URL 발급 → S3 직접 PUT → 메타저장 → 격자 점령
MSG-66 머지됨 MSG-64 구현 완료 dev E2E 검증 완료 · 대상: 백엔드 팀원 · 2026-07-15
영상 업로드는 티켓 2개(MSG-64 presigned 발급, MSG-66 메타저장)와 도메인 2개(Owner B의
video, Owner A의 grid)에 걸쳐 있다. 스펙 문서는 티켓 단위로 쪼개져 있어서
"영상을 올리면 실제로 무슨 일이 일어나는가"를 알려면 문서 2개와 코드 5개를 오가야 한다.
이 문서는 그 조각을 하나의 관통 서사로 묶는다.
1. 왜 서버를 안 거치나
영상 파일은 서버를 통과하지 않는다. 클라이언트가 S3로 직접 PUT 하고, 서버는 업로드 권한(서명)만 발급한다. 파일이 서버를 거치면 대역폭·요청 타임아웃·메모리를 모두 잡아먹는데, 최대 30초짜리 영상이 수십 MB인 걸 감안하면 서버가 중계할 이유가 없다.
그래서 서버가 이 플로우에서 다루는 바이트는 0이다. 다루는 건 s3Key 문자열뿐이고,
이 문자열이 발급(MSG-64)과 메타저장(MSG-66)을 잇는 유일한 끈이다.
2. 전체 플로우
POST /api/videos/presigned-url
JWT 필요 · {extension, contentType, contentLength}
→
{uploadUrl, s3Key, expiresInSec: 600}
TTL 10분
POST /api/videos
{s3Key, lat, lng, durationSec, recordedAt}
→
GridEncoder.encode → grids lazy insert
→ videos INSERT → user_grids UPSERT
VideoServiceImpl.saveVideo
→
{videoId, gridId, processingStatus: "UPLOADED", occupied}
occupied = 이번 업로드가 첫 점령인가
s3Key를 발급하는 주체가 없어서 4단계부터는 호출할 방법이
없었다. MSG-64가 1~3단계를 채우면서 비로소 플로우가 끝에서 끝까지 이어졌다.
3단계가 왜 중요한가
uploadUrl은 단순한 링크가 아니라 서명된 권한이다. 서명에는 버킷·키뿐
아니라 Content-Type과 Content-Length가 함께 들어간다. 즉 클라이언트가
발급받을 때 선언한 것과 다른 타입·크기로 올리려 하면 S3가 403 SignatureDoesNotMatch로
거부한다. 자세한 근거는 4장에 있다.
3. 격자와 점령
여기부터가 FillMap의 본체다. 용어의 단일 진실 원천은 .claude/rules/glossary.md이고,
인코딩 규칙의 단일 진실 원천은 GridEncoder / GridConstants다.
격자 인코딩
// GridConstants
public static final double GRID_LAT_STEP = 0.0009;
public static final double GRID_LNG_STEP = 0.00115;
// GridEncoder — 경계는 floor 반열림 구간 [n·step, (n+1)·step)
public static String encode(double lat, double lon) {
long gridY = (long) Math.floor(lat / GRID_LAT_STEP);
long gridX = (long) Math.floor(lon / GRID_LNG_STEP);
return gridY + "_" + gridX;
}
성수(37.5445, 127.0560)를 실제로 넣어보면:
37.5445 / 0.0009 = 41716.1111 → floor → grid_y = 41716
127.0560 / 0.00115 = 110483.4783 → floor → grid_x = 110483
grid_id = "41716_110483"
중심 = (37.544850, 127.056025)
셀 범위 = 위도 [37.5444, 37.5453) 경도 [127.05545, 127.05660)
grid_id는 논리 식별자이자 DB 저장 키다(grids.grid_id VARCHAR(20) PK).
표준 geohash가 아니라 자체 양자화 키이므로, 코드 심볼이나 컬럼명에
geohash라는 이름을 새로 쓰지 않는다.
위경도 등간격 양자화라서 셀의 실제 미터 크기는 위도에 따라 달라진다.
남북은 0.0009 × 111,000 ≈ 99.9m로 위치와 무관하게 거의 일정하지만,
동서는 0.00115 × 111,320 × cos(위도)라 위도를 탄다.
| 위치 | 동서 폭 |
|---|---|
| 서울 (37.5°N) | 101.6 m |
| 부산 (35.1°N) | 104.7 m |
| 적도 (0°) | 128.0 m |
즉 GRID_LNG_STEP은 한국 위도에서 동서 폭이 ≈100m가 되도록 맞춘 값이다.
한국 내에서는 오차 1~5%로 충분한 근사지만, 적도로 갈수록 가로로 긴 직사각형이 된다.
Lazy insert — grids row가 있다는 것의 의미
격자는 지구 전체에 대해 계산 가능한 논리적 개념이라 미리 저장해둘 수 없다(수억 개다). 그래서 실제로 영상이 올라오는 순간에만 grids에 등록한다. 뒤집으면 grids에 row가 있다 = 누군가 거기에 영상을 올렸다는 뜻이다.
-- VideoRepository.upsertGrid — 이미 있으면 no-op, 멱등
INSERT INTO grids (grid_id, grid_y, grid_x, center_geom, bbox_geom)
VALUES (
:gridId, :gridY, :gridX,
ST_SetSRID(ST_MakePoint(:centerLon, :centerLat), 4326)::geography,
ST_SetSRID(ST_GeomFromText(:bboxWkt), 4326)::geography
)
ON CONFLICT (grid_id) DO NOTHING
같은 격자에 두 번째 영상이 올라와도 ON CONFLICT DO NOTHING 덕에 grids row는 1개로 유지된다.
이 멱등성은 MSG-66의 완료 조건 중 하나였고 통합 테스트로 검증돼 있다.
점령 — 개인 도감의 실체
점령은 사용자가 특정 격자에 첫 영상을 올린 상태이고, 그 실체는
user_grids의 row 하나다. 도감에 격자가 색으로 칠해지는 시점이 곧 이 row가 생기는 시점이다.
-- VideoRepository.upsertUserGrid
-- 첫 방문이면 INSERT(video_count=1), 재방문이면 video_count+1
INSERT INTO user_grids (user_id, grid_id, video_count, cover_video_id, first_collected_at, last_uploaded_at)
VALUES (:userId, :gridId, 1, :coverVideoId, now(), now())
ON CONFLICT (user_id, grid_id) DO UPDATE
SET video_count = user_grids.video_count + 1,
last_uploaded_at = now()
구분해두면 헷갈리지 않는 개념 쌍:
| 개념 | 실체 | 횟수 |
|---|---|---|
| 방문 | videos row 1개 | 이벤트 — N회 반복 |
| 점령 | user_grids row 1개 | 상태 — 격자당 1회 |
| 도감 | user_grids 집합 | 결과 |
즉 첫 방문 = 점령이고, 재방문은 video_count만 올린다.
응답의 occupied 필드가 바로 "이번 업로드가 첫 점령이었나"를 알려준다.
owned·conquered는 쓰지 않는다 — 소유권·정복 뉘앙스 때문이다.
권장 네이밍은 isOccupied, occupiedGrids, myGrids.
이 문서는 백엔드용이라 "점령"을 쓰지만, 밖으로 나갈 문구에는 옮기지 말 것.
MVP 범위
MVP에는 개인 도감 하나만 존재한다. 친구·전체 도감, 핫구역, 스폰서 격자는 Phase 2+로 유예됐다. 그리고 미점령 격자는 지도에 표시하지 않는다 — 격자망 오버레이 없이 지도 배경만 보인다(시각적 노이즈 최소화 + 발견의 재미 + 렌더링 성능).
4. MSG-64 설계 결정 이번 작업
크기 제한: 스펙대로는 불가능했다
스펙(D3)은 "presigned 조건에 content-length-range를 걸어 상한 강제"를 요구했다.
이건 PUT presign에서 불가능하다. content-length-range는
POST policy 전용 조건이다. PUT presign은 policy 문서가 아니라 canonical request에
서명하므로 조건식을 표현할 자리가 없고, AWS SDK for Java v2에는 createPresignedPost에
해당하는 API도 없다(JS/Python SDK 전용).
대신 정확 길이 서명이 가능하다는 걸 SDK 소스로 확인했다:
PutObjectRequest.contentLength(...)는Content-Length헤더로 마셜된다.DefaultS3Presigner는 이 헤더를 필터링하지 않는다.- SigV4 서명 제외 목록(
connection, x-amzn-trace-id, user-agent, expect, transfer-encoding, x-forwarded-for)에content-length가 없다 →X-Amz-SignedHeaders에 포함된다.
그래서 2중 방어로 D3의 의도를 달성한다:
- 서버측 상한 —
contentLength > aws.s3.max-upload-bytes면FILE_TOO_LARGE(400). 정책 집행. - 서명측 정확 일치 — 선언한 크기와 다르게 올리면 S3가 403. 선언 위조 방지.
합치면 "100MB 이하이면서 선언한 크기와 정확히 일치하는 업로드만 통과"가 된다.
임의 범위 [0, MAX]가 아니라 매 요청마다 크기를 선언한다는 점만 다른데,
클라이언트는 업로드 전에 file.size를 알기 때문에 실무상 제약이 없다.
// VideoPresignTest — X-Amz-SignedHeaders 값을 파싱해 실제로 검증한다.
// SDK 업그레이드로 서명 헤더 구성이 바뀌면 이 테스트가 회귀를 잡는다.
@Test
void presigned_URL_은_content_length_와_content_type_을_서명한다() {
PresignedUrlResponseDto response = videoService.issuePresignedUrl(
USER_ID, new PresignedUrlRequestDto("mp4", "video/mp4", 8388608L));
assertThat(signedHeadersOf(response.uploadUrl()))
.contains("content-length", "content-type");
}
위 단위 테스트가 보장하는 건 서명 헤더에 content-length가 들어간다는 사실까지다. S3가 실제로 위조된 크기를 거부하는지는 별개 문제라 dev 버킷에 직접 쏴봤고, 설계대로 동작했다.
# 발급된 URL에 서명 헤더가 실제로 박혀 있다
X-Amz-SignedHeaders=content-length;content-type;host
# ① 선언한 크기 그대로 PUT
$ curl -X PUT "$URL" -H 'Content-Type: video/mp4' --upload-file sample.mp4 # 1,048,576 bytes
HTTP 200
$ aws s3api head-object ... # ContentLength=1048576 ContentType=video/mp4
# ② contentLength=1000 으로 발급받은 URL에 1MB 파일을 PUT (크기 위조)
$ curl -X PUT "$FAKE_URL" -H 'Content-Type: video/mp4' --upload-file sample.mp4
HTTP 403
<Code>SignatureDoesNotMatch</Code>
즉 content-length-range를 못 쓰는 대신 택한 정확 길이 서명이 실제로 크기를
강제한다. 서버측 상한(FILE_TOO_LARGE)과 합쳐 D3의 의도가 성립한다.
검증 규칙
// VideoServiceImpl — 확장자와 Content-Type을 '쌍'으로 검증한다.
private static final Map<String, String> ALLOWED_TYPES = Map.of(
"mp4", "video/mp4",
"mov", "video/quicktime");
String extension = request.extension().toLowerCase();
String allowedType = ALLOWED_TYPES.get(extension);
if (allowedType == null || !allowedType.equals(request.contentType())) {
throw new ApiException(VideoErrorCode.UNSUPPORTED_EXTENSION);
}
화이트리스트를 확장자와 타입 쌍으로 검사해서 mp4 +
video/quicktime 같은 엇갈린 조합도 막는다. Content-Type을 서명에 넣기 때문에
저장된 객체의 메타데이터가 확실히 video/mp4가 되고, 이건 MSG-65 인코딩 파이프라인이
기대는 전제가 된다.
보안 경계
s3Key는 videos/original/{userId}/{uuid}.{ext} 형식인데, 여기 들어가는
userId는 JWT(AuthPrincipal.userId())에서만 온다.
요청 DTO에는 userId가 없으므로 남의 경로에 대한 서명을 받아낼 방법이 없다.
인증 자체는 SecurityConfig의 .anyRequest().authenticated()가 이미
커버하므로 이 티켓에서 SecurityConfig는 건드리지 않았다.
TTL 10분이 임의값이 아닌 이유
dev/prod는 EC2 인스턴스 role의 임시 자격증명으로 서명한다. 임시 자격증명으로 만든 presigned URL은 세션 토큰이 만료되면 URL도 함께 무효화된다. IMDS 세션이 약 6시간이므로 10분 TTL은 안전하지만, TTL을 6시간 이상으로 늘리면 URL이 조기에 깨진다. 바꿀 일이 없는 값이라 yml이 아니라 서비스 상수로 뒀다.
에러 코드
스펙은 UNSUPPORTED_EXTENSION에 3400을 배정했는데, MSG-66이 먼저 머지되며
VideoErrorCode를 만들고 INVALID_COORDINATE(3400)으로 3400을 선점한 상태였다.
이미 머지된 API 계약을 깨지 않으려고 재배정했다.
| 상수 | developCode | HTTP | 출처 |
|---|---|---|---|
INVALID_COORDINATE | 3400 | 400 | MSG-66 (변경 없음) |
FILE_TOO_LARGE | 3413 | 400 | MSG-64 (413 니모닉) |
UNSUPPORTED_EXTENSION | 3415 | 400 | MSG-64 (415 니모닉) |
대역 스킴은 AuthErrorCode(2401, 2422)와 같은 3{의미상 HTTP status} 꼴이다.
스펙에 있던 PRESIGN_FAILED(3500)은 넣지 않았다 — presign은 네트워크 호출이
아니라 순수 로컬 서명 연산이라 실패 경로가 사실상 없고, 만에 하나 실패해도
GlobalExceptionHandler가 이미 500으로 처리한다.
자격증명
S3Config는 credentialsProvider를 명시하지 않는다. 기본값인
DefaultCredentialsProvider 체인이 local(환경변수·프로파일)과 dev/prod(EC2 IMDS)를
모두 커버하기 때문에 프로파일별 빈 분기가 필요 없다. 이 체인은 lazy라서
자격증명이 없는 CI에서도 @SpringBootTest 컨텍스트가 정상적으로 뜬다
(테스트 90건 통과로 확인).
5. Owner A/B 경계면
협업 원칙상 grid는 Owner A, video는 Owner B이고, 두 도메인의 접점은
인터페이스로만 두기로 돼 있다. 그런데 현재 코드는 그렇지 않다.
Owner B의 VideoRepository가 Owner A 소유 테이블인 grids와
user_grids에 native SQL로 직접 쓴다(upsertGrid,
existsUserGrid, upsertUserGrid). status.md 기준으로
점령 write 경로는 MSG-66이 흡수했고, Owner A가 제공하는 GridQueryService는
read 계약(격자 색칠 조회, MSG-73)만 담당한다.
origin/feature/MSG-68-deal-with-grid-color(진행 중)가 GridOccupationService와
UserGridRepository로 같은 점령 write 영역을 구현하고 있다.
게다가 이 브랜치는 MSG-66 머지 이전 시점(b89521a)에서 분기된 stale 상태라
V1__init.sql을 통째로 재작성한다. 머지하면 스키마 충돌 + 점령 로직 이중화가
난다. 어느 쪽이 점령 write를 소유할지 팀에서 정해야 한다.
6. 알려진 갭
saveVideo는 existsUserGrid(SELECT)로 점령 여부를 먼저 판정한 뒤
upsertUserGrid를 실행한다. 두 쿼리 사이에 같은 사용자·같은 격자의 업로드가 동시에
들어오면 둘 다 "첫 점령"으로 응답할 수 있다.
MSG-66 스펙 D3는 원자적 UPSERT를 요구했는데 판정이 별도 SELECT로 빠져 있는 상태다.
user_grids의 UNIQUE 제약 덕에 데이터는 깨지지 않는다 — 틀어지는 건
응답의 occupied 플래그뿐이다. UPSERT의 RETURNING이나
xmax = 0 판정으로 1쿼리화하면 해소된다.
recordedAt을 거부하라고 했지만
VideoServiceImpl에는 좌표 검증만 있고, DTO에도 @PastOrPresent가 없다.
한때 saveVideo가 클라이언트의 s3Key를 검증 없이 저장해서
파일을 안 올리고도 격자가 점령됐다. 점령이 인코딩보다 먼저 일어나고, 인코딩이
FAILED가 돼도 user_grids row는 남기 때문이다.
지금은 소유권(prefix)·실존(headObject)·중복(UNIQUE) 3중 검증이 막는다.
발견 경위와 설계 근거는 트러블슈팅 문서 참조.
headObject는 "안 올리고 확정"만 막을 뿐 이건 못 막는다.
Content-Type을 서명에 넣었으므로, 클라이언트는 발급 요청에 보낸 값을 PUT 헤더에 그대로
실어야 한다. blob이 자동 추론한 타입과 다르면 403이 난다 — 특히 .mov는 브라우저가
빈 문자열이나 다른 타입을 추론하는 경우가 있다.
fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType }, // 발급 때 보낸 값 그대로
body: file
})
다음 티켓
스펙상 착수 순서는 66 → 64 → 65 → 72 → 71이다. 64 다음인
MSG-65(FFmpeg 720p 인코딩 워커)는 구현이 끝났다 —
UPLOADED → ENCODING → READY 전이와 썸네일 추출은
인코딩 워커 문서를 참고하면 된다. 그 다음은 MSG-72(삭제·점령 롤백),
MSG-71(교체) 순이다.
근거 파일: VideoServiceImpl · VideoRepository · VideoController ·
GridEncoder / GridConstants · VideoErrorCode ·
S3Config / AwsProperties · VideoPresignTest
규칙 문서: .claude/rules/glossary.md(용어) · .claude/docs/status.md(구현 현황) ·
docs/spec/MSG-64.md / docs/spec/MSG-66.md(스펙)