영상 업로드 플로우

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. 전체 플로우

Client
Server
S3
DB (PostGIS)
1 POST /api/videos/presigned-url JWT 필요 · {extension, contentType, contentLength}
2 확장자·타입 쌍 검증 → 크기 상한 검증 → s3Key 생성 → presign VideoServiceImpl.issuePresignedUrl
{uploadUrl, s3Key, expiresInSec: 600} TTL 10분
3 PUT uploadUrl — 파일 바이트가 Server 레인을 통과만 하고 S3로 직행 서버는 이 바이트를 보지 않는다 · Content-Type 헤더 필수 (서명에 포함됨)
4 POST /api/videos {s3Key, lat, lng, durationSec, recordedAt}
5 한 트랜잭션: 좌표 검증 → GridEncoder.encode → grids lazy insert → videos INSERT → user_grids UPSERT VideoServiceImpl.saveVideo
{videoId, gridId, processingStatus: "UPLOADED", occupied} occupied = 이번 업로드가 첫 점령인가
1~3단계가 MSG-64, 4~6단계가 MSG-66 MSG-66이 먼저 머지됐지만 s3Key를 발급하는 주체가 없어서 4단계부터는 호출할 방법이 없었다. MSG-64가 1~3단계를 채우면서 비로소 플로우가 끝에서 끝까지 이어졌다.

3단계가 왜 중요한가

uploadUrl은 단순한 링크가 아니라 서명된 권한이다. 서명에는 버킷·키뿐 아니라 Content-TypeContent-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라는 이름을 새로 쓰지 않는다.

"100×100m 정사각형"은 근사다 — 오해하면 격자 로직을 잘못 짠다

위경도 등간격 양자화라서 셀의 실제 미터 크기는 위도에 따라 달라진다. 남북은 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의 완료 조건 중 하나였고 통합 테스트로 검증돼 있다.

전역 격자 등록은 백엔드 전용 개념이다 grids row가 생기는 것을 glossary는 "전역 격자 등록"이라 부르는데, 이건 사용자 UI·기획서에 절대 노출하지 않는 내부 개념이다. 코드·설계 논의에서만 쓴다.

점령 — 개인 도감의 실체

점령은 사용자가 특정 격자에 첫 영상을 올린 상태이고, 그 실체는 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 필드가 바로 "이번 업로드가 첫 점령이었나"를 알려준다.

"점령"은 백엔드에서만 쓴다 glossary의 사용 규칙상 UI·기획·발표에서는 "수집" / "채우기"를 쓴다 ("이 격자를 채워보세요", "성수 카페 격자 수집됨"). "점령"·"정복"은 톤이 안 맞아 노출 금지고, 코드에서도 owned·conquered는 쓰지 않는다 — 소유권·정복 뉘앙스 때문이다. 권장 네이밍은 isOccupied, occupiedGrids, myGrids. 이 문서는 백엔드용이라 "점령"을 쓰지만, 밖으로 나갈 문구에는 옮기지 말 것.

MVP 범위

MVP에는 개인 도감 하나만 존재한다. 친구·전체 도감, 핫구역, 스폰서 격자는 Phase 2+로 유예됐다. 그리고 미점령 격자는 지도에 표시하지 않는다 — 격자망 오버레이 없이 지도 배경만 보인다(시각적 노이즈 최소화 + 발견의 재미 + 렌더링 성능).

4. MSG-64 설계 결정 이번 작업

크기 제한: 스펙대로는 불가능했다

스펙(D3)은 "presigned 조건에 content-length-range를 걸어 상한 강제"를 요구했다. 이건 PUT presign에서 불가능하다. content-length-rangePOST policy 전용 조건이다. PUT presign은 policy 문서가 아니라 canonical request에 서명하므로 조건식을 표현할 자리가 없고, AWS SDK for Java v2에는 createPresignedPost에 해당하는 API도 없다(JS/Python SDK 전용).

대신 정확 길이 서명이 가능하다는 걸 SDK 소스로 확인했다:

그래서 2중 방어로 D3의 의도를 달성한다:

합치면 "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");
}
실측으로 확인됨 (2026-07-15, dev 버킷)

위 단위 테스트가 보장하는 건 서명 헤더에 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 인코딩 파이프라인이 기대는 전제가 된다.

보안 경계

s3Keyvideos/original/{userId}/{uuid}.{ext} 형식인데, 여기 들어가는 userIdJWT(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 계약을 깨지 않으려고 재배정했다.

상수developCodeHTTP출처
INVALID_COORDINATE3400400MSG-66 (변경 없음)
FILE_TOO_LARGE3413400MSG-64 (413 니모닉)
UNSUPPORTED_EXTENSION3415400MSG-64 (415 니모닉)

대역 스킴은 AuthErrorCode(2401, 2422)와 같은 3{의미상 HTTP status} 꼴이다. 스펙에 있던 PRESIGN_FAILED(3500)넣지 않았다 — presign은 네트워크 호출이 아니라 순수 로컬 서명 연산이라 실패 경로가 사실상 없고, 만에 하나 실패해도 GlobalExceptionHandler가 이미 500으로 처리한다.

자격증명

S3ConfigcredentialsProvider를 명시하지 않는다. 기본값인 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 소유 테이블인 gridsuser_gridsnative SQL로 직접 쓴다(upsertGrid, existsUserGrid, upsertUserGrid). status.md 기준으로 점령 write 경로는 MSG-66이 흡수했고, Owner A가 제공하는 GridQueryService는 read 계약(격자 색칠 조회, MSG-73)만 담당한다.

MSG-68 브랜치와 정리가 필요하다 origin/feature/MSG-68-deal-with-grid-color(진행 중)가 GridOccupationServiceUserGridRepository같은 점령 write 영역을 구현하고 있다. 게다가 이 브랜치는 MSG-66 머지 이전 시점(b89521a)에서 분기된 stale 상태라 V1__init.sql을 통째로 재작성한다. 머지하면 스키마 충돌 + 점령 로직 이중화가 난다. 어느 쪽이 점령 write를 소유할지 팀에서 정해야 한다.

6. 알려진 갭

① occupied 판정에 2쿼리 race가 있다

saveVideoexistsUserGrid(SELECT)로 점령 여부를 먼저 판정한 뒤 upsertUserGrid를 실행한다. 두 쿼리 사이에 같은 사용자·같은 격자의 업로드가 동시에 들어오면 둘 다 "첫 점령"으로 응답할 수 있다.

MSG-66 스펙 D3는 원자적 UPSERT를 요구했는데 판정이 별도 SELECT로 빠져 있는 상태다. user_grids의 UNIQUE 제약 덕에 데이터는 깨지지 않는다 — 틀어지는 건 응답의 occupied 플래그뿐이다. UPSERT의 RETURNING이나 xmax = 0 판정으로 1쿼리화하면 해소된다.

② recordedAt 미래 시각 거부가 미구현 MSG-66 스펙 D9는 미래 시각 recordedAt을 거부하라고 했지만 VideoServiceImpl에는 좌표 검증만 있고, DTO에도 @PastOrPresent가 없다.
[해소됨] s3Key 검증 없이 격자를 점령할 수 있었다 — MSG-132

한때 saveVideo가 클라이언트의 s3Key를 검증 없이 저장해서 파일을 안 올리고도 격자가 점령됐다. 점령이 인코딩보다 먼저 일어나고, 인코딩이 FAILED가 돼도 user_grids row는 남기 때문이다.

지금은 소유권(prefix)·실존(headObject)·중복(UNIQUE) 3중 검증이 막는다. 발견 경위와 설계 근거는 트러블슈팅 문서 참조.

③ 고아 업로드가 S3에 쌓인다 — MSG-133 presigned URL을 발급받아 S3에 올린 뒤 확정 API를 부르지 않으면 그 객체는 영원히 남는다. 발급을 DB에 기록하지 않아 추적할 방법이 없고, S3 라이프사이클도 없다. MSG-132의 headObject는 "안 올리고 확정"만 막을 뿐 이건 못 막는다.
④ 프론트 계약 — Content-Type 헤더 필수

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(스펙)