SpringBoot, React, 업로드 티켓으로 파일 업로드 취약점을 막아보자

· August 04, 2026 · 47 min read

File Upload 시리즈 3편이다. 1편 — Presigned URL로 파일 업로드하기 2편 — Multipart로 업로드 속도 개선하기

앞의 두 편에서 만든 업로드 구조가 보안 리뷰에서 지적을 받았다. 그걸 어떻게 고쳤는지 쓴다. {: .prompt-info }

🧪 테스트 환경

  • SpringBoot 3.x / MyBatis / PostgreSQL
  • React 18 / TypeScript / Vite
  • AWS S3

배경

첨부파일을 어떻게 저장하고 있었나

그룹ID 1개에 파일 N개가 묶이는 구조다.

TB_BBS_M (게시글)                TB_ATCH_FILE_M (첨부파일)
┌─────────────────┐             ┌──────────────────┬──────────────┐
│ BBS_NO          │             │ ATCH_FILE_GR_ID  │ ATCH_FILE_ID │ ← 복합 PK
│ BBS_TITLE       │             ├──────────────────┼──────────────┤
│ ATCH_FILE_GR_ID │────────────▶│ a1b2c3           │ f-001        │
└─────────────────┘    1 : N    │ a1b2c3           │ f-002        │
                                │ a1b2c3           │ f-003        │
                                └──────────────────┴──────────────┘

게시글은 그룹ID 한 개만 들고 있다. 파일 3개를 올리면 같은 그룹ID로 3행이 들어간다. 게시글이 파일을 직접 가리키지 않고 그룹을 통해 가리키는 형태다.

구조 자체는 흔하고 문제도 없다. 문제는 그 그룹ID를 누가 만드느냐였다.

업로드는 이런 순서로 돌았다

핵심은 파일이 먼저고 게시글이 나중이라는 것이다.

사용자가 [저장] 버튼을 누른다
  │
  ├─① 클라가 그룹ID를 만든다
  │     const atchFileGrId = uuidv4()          ← 여기
  │
  ├─② 메타데이터를 서버에 보내고 업로드 URL을 받는다
  │     GET /v1/file/preSignedUploadUrl
  │         ?atchFileGrId=a1b2c3
  │         &originalFileName=보고서.pdf&fileSize=1048576
  │
  │     서버 : TB_ATCH_FILE_M 에 행을 INSERT 하고
  │            presigned PUT URL 을 응답
  │
  ├─③ 클라가 실제 파일을 S3로 올린다
  │     PUT {preSignedUrl}   (body: 파일 바이트)
  │
  └─④ 파일이 다 올라가면 그때 게시글을 저장한다
        POST /v1/bbs/post
            { bbsTitle: "...", bbsCtn: "...", atchFileGrId: "a1b2c3" }

이 순서에는 이유가 있다. presigned URL 방식이라 파일이 서버를 거치지 않고 S3로 바로 간다. 게시글을 저장하는 시점에는 첨부가 이미 확정돼 있어야 하니, 파일을 먼저 올리고 게시글을 나중에 저장하는 게 자연스럽다.

동작은 잘 했다. ①이 문제였다.

클라이언트가 그룹ID를 만든다

그룹ID를 클라이언트가 uuidv4()로 만들고, ②와 ④에서 그 값을 서버에 그대로 보낸다. 서버는 받은 값을 그대로 쓴다.

서버는 "이 그룹이 네 것이냐"를 묻지 않는다. 물을 방법도 없었다. 그룹의 주인이 누구인지 어디에도 기록돼 있지 않기 때문이다.

게다가 그룹ID는 목록 API 응답에 그대로 실려 나온다. 남의 것을 알아내는 데 아무 노력이 필요 없다. 개발자 도구를 열면 보인다.

여기서 보안 리뷰의 지적이 전부 나온다.

# 공격 결과
1 ④의 atchFileGrId를 남의 것으로 바꿔치기 남의 첨부가 내 게시글에 붙는다 → 열람 가능
2 ②에 남의 atchFileGrId를 넣어 업로드 남의 게시글에 파일 주입
3 PUT /v1/files 로 남의 파일 useYn='N' 남의 첨부 삭제. WHEREuseYn 조건이 없어 'N'→'Y' 복구도 됨
4 멀티파트 complete에 임의의 uploadId·fileSavePath 검증이 없다

네 개가 따로 있는 것처럼 보이지만 뿌리는 하나다.

클라이언트가 첨부파일그룹ID를 자유롭게 지정할 수 있다. {: .prompt-danger }

처음엔 각 지점에 소유권 검사를 붙이는 방향으로 갔다. "이 그룹이 내 것인가"를 묻는 검사를 39개 저장 메서드에 심고, 기존 데이터를 채우는 마이그레이션까지 만들었다. 구현을 끝내고 나서 다시 보니 뭔가 이상했다.

검사를 붙일 지점이 계속 늘어난다. 파일 API 4개, 첨부 전용 API 7개, MERGE 3곳… 새 화면이 생길 때마다 또 붙여야 한다. 하나라도 빠뜨리면 그 지점이 그대로 구멍이다.

근본 원인은 검사가 없어서가 아니었다.

파일 API가 엔티티와 무관하게 파일 메타데이터를 쓸 수 있다는 구조 자체가 원인이다. {: .prompt-warning }

세 가지 안

원인은 하나로 좁혀졌다. 클라이언트가 그룹ID를 지정할 수 있다는 것. 남은 건 그걸 어떻게 막느냐, 그리고 어디까지 손대느냐였다.

안을 셋 만들었다. 셋의 차이는 결국 이 질문 하나에 대한 답이다.

클라이언트가 보내는 그룹ID를, 어떻게 못 믿게 만들 것인가?

1안 — 순서를 뒤집는다 (업로드 티켓)

가장 먼저 나온 안이다. 파일만 먼저 올리고, 파일 메타데이터는 게시글을 저장할 때 같이 넣는다.

기존엔 ②에서 파일 행을 INSERT 했다. 그 시점에 그룹ID가 필요했고, 그래서 클라가 만들어 보내야 했다. INSERT를 뒤로 미루면 그룹ID를 받을 이유 자체가 없어진다.

문제는 하나다. 먼저 올린 파일을 저장 시점까지 어떻게 믿고 들고 가느냐. 서버가 파일 정보에 서명해서 준다. 그게 티켓이다.

사용자가 파일을 고른다
  │
  ├─① 업로드 URL과 티켓을 받는다
  │     GET /v1/file/preSignedUploadUrl
  │         ?originalFileName=보고서.pdf&fileSize=1048576
  │     → { preSignedUrl, ticket }
  │
  │     서버 : DB를 건드리지 않는다. S3 키를 만들어 서명만 해서 준다
  │            ticket = { path, name, size, uid, exp } + 서명
  │
  └─② 클라가 S3에 올린다   PUT {preSignedUrl}

사용자가 [저장] 버튼을 누른다
  │
  └─③ 게시글과 티켓을 같이 보낸다
        POST /v1/bbs/post
            { bbsTitle: "...",
              attachments: { keep: [...], add: ["eyJwYXRo..."] } }

        서버 : ┌─ 한 트랜잭션 ────────────────────┐
               │ 티켓 서명 검증                   │
               │ 그룹ID 생성   ← 여기서 서버가 만든다 │
               │ 파일 행 INSERT                   │
               │ 게시글 INSERT                    │
               └──────────────────────────────────┘

요청 어디에도 그룹ID가 없다. 클라가 보내는 건 "무엇을 남기고(keep) 무엇을 새로 붙일지(add)"뿐이다.

그리고 여기서 덤으로 따라오는 게 하나 있는데, 이게 생각보다 컸다.

파일 메타데이터를 건드리는 지점이 게시글 저장 API 하나뿐이 됐다. 그러면 "이 게시글을 수정할 자격이 있는가"를 이미 통과한 사람만 첨부를 건드릴 수 있다.

게시글 수정 권한이 곧 첨부 수정 권한이 된다. 첨부파일용 인가를 따로 설계할 필요가 없다. 게시글 인가에 얹혀 간다. {: .prompt-tip }

2안과 3안은 파일 API가 여전히 독립적으로 살아 있다. 그래서 "이 파일 API를 호출할 자격이 있는가"를 별도로 고민해야 한다. 1안은 그 고민 자체가 사라진다. 이게 셋 중 1안만 가진 성질이다.

장점 클라가 그룹ID를 만질 경로가 아예 없다. 검사를 추가하는 게 아니라 파라미터가 사라진다
게시글 수정권이 곧 첨부 수정권이 된다. 첨부용 인가를 따로 만들 필요가 없다
올리기만 하고 저장을 안 하면 DB에 아무것도 안 남는다. 고아 행이 0
게시글 저장이 실패하면 파일 행도 같이 롤백된다
단점 수정량이 압도적. 파일 API 4개 + 엔티티 저장 39곳 + 프론트 47파일
요청 본문 계약이 바뀌어 BE·FE를 동시에 배포해야 한다
삭제 판정이 서버로 오면서 새 실패 모드가 생긴다 (뒤에 나올 keep 레이스)

방향은 맞는데 규모가 문제였다. 39곳과 47파일을 한 번에 바꾸는 건 부담이 컸다. 더 적게 고치는 방법을 찾아봤다.

2안 — 그룹ID 대신 출입증을 들고 다닌다 (grant)

순서는 그대로 두고, 클라가 만지는 값만 바꾸는 안이다.

클라가 그룹ID 대신 서버가 서명한 출입증(grant) 을 들고 다닌다. 출입증 안에는 그룹ID와 발급받은 사용자 ID가 들어 있고 서명이 붙어 있다. 클라가 내용을 바꾸면 서명이 깨진다.

사용자가 [저장] 버튼을 누른다
  │
  ├─① 클라가 서버에 출입증을 요청한다
  │     POST /v1/file/grant           ← 파라미터를 받지 않는다
  │     → { grant: "eyJnIjoiYTFi..." }
  │
  │     서버 : 그룹ID를 만들고(UUID) 서명해서 준다
  │
  ├─② 업로드 URL 요청 — 그룹ID 대신 출입증을 보낸다
  │     GET /v1/file/preSignedUploadUrl?grant=eyJnIjoi...
  │     서버 : 출입증을 열어 그룹ID를 꺼내 쓰고, 파일 행 INSERT
  │
  ├─③ 클라가 S3에 올린다
  │
  └─④ 게시글 저장 — 여기도 출입증
        POST /v1/bbs/post
            { bbsTitle: "...", atchFileGrant: "eyJnIjoi..." }

수정할 때는 상세조회 응답에 출입증을 같이 실어 보낸다. "이 글을 읽을 수 있었다"가 곧 "이 그룹을 쓸 자격이 있다"의 근거가 된다.

여기서 절대 하면 안 되는 게 하나 있다.

"이 그룹ID를 서명해줘" 형태의 엔드포인트를 만드는 순간 전부 원점이다. 공격자가 남의 그룹ID를 제출하고 자기 이름으로 서명받으면 그만이기 때문이다. 그래서 POST /v1/file/grant파라미터를 받지 않는다. 서버가 만든 값만 서명한다. {: .prompt-danger }

장점 아이디어가 단순하다. 값 하나를 서명된 값으로 바꾸는 게 전부다
기존 순서(파일 먼저 → 게시글 나중)를 그대로 둔다. 업로드 흐름을 뒤집지 않아도 된다
1안의 1/5 규모. DDL도 마이그레이션도 없다
단점 결국 서비스 로직도 고쳐야 한다. 저장 지점마다 출입증에서 그룹ID를 꺼내 넣고, 상세조회마다 출입증을 실어 보내야 한다. 공통 영역(파일 API·업로더)에 더해 양쪽을 다 건드린다
출입증 발급 지점을 새로 만들어야 한다. 그 지점이 인가를 안 하면 서명은 장식이다. 발급 규칙을 한 번 잘못 만들면 나머지가 전부 무효가 된다
공격 2가 안 막힌다. 남의 글 상세를 열면 출입증이 나온다. 그걸로 남의 첨부 그룹에 파일을 추가할 수 있다. "읽을 수 있는 사용자"로 범위가 줄 뿐 사라지지 않는다
그룹 바꿔치기가 남는다. 남의 글에서 받은 출입증을 자기 글 수정에 실어 보내면 자기 글의 첨부 그룹이 남의 그룹이 된다. 막으려면 저장 지점마다 "기존 그룹ID와 같은가" 검사를 붙여야 한다
고아 행은 그대로. ②에서 INSERT 하는 구조를 안 바꿨으니 올리고 저장 안 하면 행이 남는다
프론트가 출입증을 관리해야 한다. 신규와 수정에서 출처가 다르다

단점 목록이 길다. 특히 네 번째 — "또 붙여야 한다" 가 걸렸다. 검사를 줄이려고 시작했는데 검사가 늘고 있었다.

하지만 제일 크게 걸린 건 첫 번째다. 2안은 결국 공통 영역과 서비스 로직을 둘 다 고쳐야 한다. 우리 프로젝트에서 서비스 로직은 저장 지점 39곳, 조회 12곳이다. 1안의 1/5이라지만 여전히 50곳이 넘는다.

여기서 욕심이 생겼다.

서비스 로직은 아예 안 건드리고, 공통 영역만으로 끝낼 수 없나?

3안 — 공통 영역만 건드린다 (공통 변환기)

방법이 있었다. JSON을 만들고 읽는 길목에 변환기를 하나 끼우는 것이다.

Spring은 응답 객체를 JSON으로 바꿀 때, 그리고 요청 JSON을 객체로 바꿀 때 Jackson을 쓴다. 모든 요청과 응답이 반드시 지나가는 길목이다. 여기에 규칙을 하나 심으면 서비스 로직은 한 줄도 안 고쳐도 된다.

"atchFileGrId 라는 이름의 필드는 전부 이렇게 처리해라"

  나갈 때   DB의 그룹ID  →  읽기 토큰   → 프론트는 뜻 모르고 왕복만 시킨다
  들어올 때 쓰기 토큰    →  raw 그룹ID  → 기존 서비스·매퍼에 그대로 전달
            읽기 토큰    →  null        → 매퍼의 COALESCE 가 DB 값을 유지
            그 외        →  거부         ← 클라가 지어낸 UUID 가 여기서 걸린다
public class AtchFileGrIdModule extends SimpleModule {

    public static final String PROPERTY_NAME = "atchFileGrId";

    // 이 이름을 가진 String 프로퍼티 전부에 직렬화·역직렬화기를 갈아끼운다
}

서비스도, 매퍼도, VO도, 프론트도 건드리지 않는다. 클래스 하나로 전 엔드포인트가 덮인다. 실제로 만들어 보니 잘 돌았다.

문제는 저 상수 한 줄이다. 필드 "이름"에 걸린다.

코드베이스를 훑어봤다.

reviAtchFileGrId    검토결과 첨부
revAtchFileGrId     검토의견 첨부
inptAtchFileGrId    투입 근거 첨부
iprvAtchFileGrId    개선 근거 첨부

전부 첨부파일 그룹ID를 담는 필드인데 앞에 접두어가 붙어 있었다. 화면이 늘면서 한 화면에 첨부 슬롯이 둘 이상 필요해진 곳들이다. 검토의견 첨부와 검토결과 첨부를 한 테이블에 같이 두려면 컬럼 이름이 달라야 했다.

이 필드들은 변환기를 그냥 지나간다. 클라가 보낸 그룹ID가 그대로 DB까지 간다. 그 엔드포인트만 원래의 취약한 상태로 남는다.

지금 네 개를 찾아 예외 처리한다 해도, 다음에 누가 imgAtchFileGrId 라고 이름 지으면 또 생긴다. 컴파일도 되고 테스트도 통과한다. 조용히 생긴다.

3안이 데려오는 것 — 복사본과 정리 배치

3안에는 딸린 게 하나 더 있었다. 이 프로젝트는 "이 게시글을 수정할 자격이 있는가"를 묻는 인가가 없었다. 그래서 수정 시 원본 그룹을 건드리지 않고, 유지할 파일만 새 그룹으로 복사하는 방식을 썼다.

// 남의 그룹을 원본으로 지정해도 그 사람 첨부는 그대로다.
// 얻는 건 "이미 읽을 수 있던 파일의 사본"뿐이라 내려받아 다시 올리는 것과 같다.
copyFilesToGroup(sourceGrId, targetGrId, keepFileIds);

영리한 타협이다. 인가 없이도 훼손이 불가능하다.

대가는 파일 메타데이터가 계속 복사된다는 것이다.

수정할 때마다 유지하는 파일 수만큼 행이 새로 생긴다. 첨부 5개짜리 글을 10번 수정하면 55행이 되고 그중 50행이 고아다. 파일 실물은 S3에서 공유하니 용량은 안 늘지만, 메타데이터 테이블만 수정 횟수에 비례해 부푼다.

그러면 안 쓰는 메타데이터를 지우는 정리 배치가 필요해진다. 여기서부터 일이 커졌다.

배치는 "어떤 테이블도 이 그룹ID를 참조하지 않으면 무력화한다"로 만들었다. 참조 여부는 시스템 카탈로그에서 찾는다.

SELECT table_schema, table_name, column_name
  FROM information_schema.columns
 WHERE lower(column_name) = 'atch_file_gr_id'   -- ← 여기

같은 가정이다. 아까 그 네 개 컬럼이 여기서도 안 잡힌다. 즉 그 기능들로 올린 첨부는 배치가 "아무도 안 쓰는 그룹"으로 판정해 전부 무력화한다. 멀쩡히 쓰고 있는 첨부가 주말 배치에 사라진다.

하나의 전제가 공통 변환기와 정리 배치 두 곳에서, 각각 다른 증상으로 터진다. 한쪽은 보안 구멍이고 다른 쪽은 데이터 사고다.

장점 수정량이 셋 중 압도적으로 적다. 클래스 1개
서비스 로직을 한 줄도 안 고친다. 파일 업로드 공통 영역만 건드리면 끝난다
프론트 수정이 거의 없다. 그룹ID를 뜻 모르는 문자열로 왕복만 시키면 된다
단점 필드 이름 규약에 통째로 의존한다. 규약을 벗어난 필드는 조용히 무방비로 남는다
새 필드가 규약을 어겨도 컴파일도 테스트도 통과한다. 발견할 수단이 없다
수정할 때마다 파일 메타데이터가 복사된다. 테이블이 수정 횟수에 비례해 부푼다
그걸 치우려면 정리 배치가 필요한데, 그 배치가 같은 이름 가정 위에 서 있어 쓰고 있는 첨부를 지운다
고아 행은 그대로

3안은 채택 직전까지 갔다

솔직히 3안으로 가려고 했다. 수정량이 압도적으로 적었고, 실제로 만들어서 돌려봤고, 잘 동작했다.

접은 건 딸려 오는 것들을 다 적어놓고 나서다.

서비스 로직을 안 고치려고   →  공통 변환기를 넣었다
인가가 없으니               →  복사본 방식을 썼다
복사본 때문에               →  메타데이터가 계속 불어났다
불어난 걸 치우려고          →  정리 배치를 넣어야 했다
그 배치가                   →  쓰고 있는 첨부를 지웠다

고치기 싫었던 것 하나를 피하려다 네 개가 딸려 왔다. 그리고 마지막 줄이 원래 막으려던 보안 문제보다 무서웠다. 공격자가 첨부를 지우는 건 공격이지만, 배치가 첨부를 지우는 건 우리가 지우는 것이다.

사이드 이펙트가 본체보다 커지면, 그건 더 이상 "적게 고치는 안"이 아니다. {: .prompt-warning }

그래서 1안

셋을 나란히 놓으니 답이 나왔다. 맨 윗줄이 원래 물어야 했던 질문이다.

1안 — 티켓 2안 — 출입증 3안 — 공통 변환기
클라가 그룹ID를 만지나 보내지 않는다 만지면 서명이 깨진다 이름이 맞는 필드만
1. 그룹ID 바꿔치기 파라미터 없음 정보 이득 0 이름이 맞는 필드만
2. 남의 그룹에 주입 파라미터 없음 읽을 수 있는 글로 축소 이름이 맞는 필드만
3. 남의 첨부 삭제 서버가 판정 막힘 막힘
4. complete 위조 티켓 안 막힘 막힘
고아 파일 행 구조적으로 0 남음 늘어남
정리 배치 불필요 선택 필수
수정량 BE 39곳 + FE 47 중간 클래스 1개

2안과 3안은 파일 API가 엔티티와 무관하게 DB에 쓴다는 원래 구조를 그대로 두고, 그 앞에 검문소를 세우는 안이다. 검문소는 우회로가 생기면 무력해진다. 그리고 우회로는 규약을 벗어난 이름 하나로도 생긴다.

"공통부만 고친다"는 말은 "무엇이 공통인지에 대한 가정 위에 선다"는 뜻이었다. 우리 코드베이스에서 그 가정은 이미 네 군데 깨져 있었고, 앞으로도 깨질 예정이었다. {: .prompt-warning }

1안은 손대는 곳이 압도적으로 많다. 대신 검문소를 세우는 게 아니라 검문할 대상 자체를 없앤다. 파일 API가 atchFileGrId를 받지 않으면 그 필드 이름이 뭐든 상관이 없다. 새 화면이 imgAtchFileGrId를 만들어도 붙을 자리가 없다.

수정량이 적다는 건 착수 비용이고, 가정이 안 깨진다는 건 유지 비용이다. 후자를 택했다.

목표

원칙을 두 줄로 정했다.

파일 업로드 API는 파일만 저장한다.

파일 메타데이터의 신규·수정·삭제는 엔티티 저장 트랜잭션 안에서만 수행한다.

검사를 붙이는 대신 공격 파라미터를 없애는 쪽이다. 파일 API가 atchFileGrId를 받지도 반환하지도 않으면, 공격 1·2는 검사 추가가 아니라 파라미터 소멸로 사라진다.

문제는 하나 남는다. 파일 업로드와 엔티티 저장이 별개 요청인데, 업로드 결과를 어떻게 신뢰할 수 있게 엔티티 저장까지 들고 가느냐.

그 답이 티켓이다. 예전엔 atchFileGrId가 클라와 서버 사이를 왕복하며 신뢰됐다. 이제 왕복하는 건 서명된 티켓이고, grId는 서버 안에서만 존재한다.

업로드 플로우

앞에서 요약한 1안의 흐름을, 멀티파트까지 포함해 자세히 보자.

① 파일 선택
   FE  →  GET /v1/file/preSignedUploadUrl?name=보고서.pdf&size=1048576
   BE     확장자·크기 검증 → S3 키 생성 → presigned PUT URL 발급
          ※ DB를 건드리지 않는다
      ←  { preSignedUrl, ticket }

② S3 직접 업로드
   FE  →  PUT {preSignedUrl}   (body: 파일 바이트)
   ※ 서버를 거치지 않는다

③ 화면 저장  ← 여기가 유일한 쓰기 지점이다
   FE  →  POST /v1/bbs/post
          {
            bbsTitle: "...", bbsCtn: "...",
            attachments: {
              keep: ["file-id-1"],           // 유지할 기존 첨부
              add:  ["eyJwYXRoIjoi..."]      // 새로 붙일 티켓
            }
          }
   BE     ┌─ 엔티티 저장 트랜잭션 ──────────────────┐
          │ 1. 티켓 검증  HMAC → uid → exp → st     │
          │ 2. grId 결정  신규=서버 생성            │
          │               수정=DB 조회값            │
          │ 3. keep 밖 기존 첨부 논리 삭제          │
          │ 4. 티켓 payload로 파일 행 INSERT        │
          │ 5. 엔티티 INSERT/UPDATE                 │
          └─────────────────────────────────────────┘
      ←  성공

①에서 DB를 건드리지 않는 게 이 설계의 핵심이다. 사용자가 파일을 올렸다가 저장하지 않고 나가면 S3 객체만 남고 DB에는 아무 흔적이 없다. 고아 파일 행이 구조적으로 생길 수 없다. 3안이 정리 배치를 필요로 했던 이유가 통째로 사라진다. S3 쪽은 lifecycle rule이 알아서 치운다.

③이 한 트랜잭션인 것도 중요하다. 저장이 실패하면 3·4·5가 함께 롤백된다. "파일 행만 남고 게시글은 없는" 중간 상태가 존재하지 않는다.

멀티파트도 같은 원리다. 진행 상태가 티켓 안으로 들어간다.

start     →  { partUrls[], ticket }        ticket.st="M", ticket.mpu=uploadId
파트 PUT  →  S3 (병렬)
complete  →  { ticket, parts[] }           ← uploadId·경로를 보내지 않는다
          →  { ticket }                    ticket.st="C" 로 재발급
저장      →  attachments.add: [완료 티켓]   ← st가 "C"가 아니면 거부

st를 두는 이유는 완료되지 않은 업로드가 엔티티에 붙는 것을 막기 위해서다. 멀티파트를 시작만 하고 complete를 하지 않은 티켓(st="M")으로 저장을 시도하면 거부된다. S3에 온전한 객체가 없는 상태이기 때문이다.

Before / After를 나란히 놓으면 이렇다.

Before After
grId를 만드는 주체 클라이언트 (uuidv4()) 서버
grId가 오가는 곳 요청·응답 전부 서버 내부에만
파일 행 INSERT 시점 업로드 API 엔티티 저장 트랜잭션
업로드 취소 시 DB에 행이 남음 아무것도 안 남음
클라가 보내는 것 grId, useYn, 정렬순서 keep 목록과 티켓

구현

1. 업로드 티켓

서버가 파일 메타데이터에 서명해서 클라에 준다. JWT와 같은 발상이다.

ticket = Base64URL(payloadJson) + "." + Base64URL(HMAC-SHA256(secret, payloadB64))

payload = { path, name, size, ext, loc, uid, st, mpu, iat, exp }
필드 내용
path S3 키. 서버가 만든다
name size ext 파일 메타
uid 발급 시점 세션 userId
st "S" 단일완료 / "M" 멀티파트 진행중 / "C" 멀티파트 완료
mpu st="M" 일 때만 S3 uploadId
iat exp 발급·만료 시각
public String issue(String path, String originalName, long size, String loc, String status, String uploadId) {
    UserSessionVO session = SessionScopeUtil.getContextSession();
    String ext = extractExtension(originalName);
    assertUploadable(originalName, ext, size);   // 발급 시점 서버 검증

    Instant now = Instant.now();
    UploadTicketPayload payload = new UploadTicketPayload(
            path, originalName, size, ext, loc,
            session.getUserId(), status, uploadId,
            now.getEpochSecond(),
            now.plus(properties.getTtl()).getEpochSecond());

    String payloadB64 = ENCODER.encodeToString(toJsonBytes(payload));
    return payloadB64 + "." + ENCODER.encodeToString(sign(payloadB64, properties.getSecret()));
}

여기서 두 가지를 실수하기 쉽다.

서명 대상은 payloadB64 문자열 그 자체여야 한다. 검증할 때 파싱 후 재직렬화해서 비교하면 필드 순서나 숫자 표현 차이로 깨진다. 서명 대조 → 통과 후 파싱 순서로 간다.

비교는 MessageDigest.isEqual()을 쓴다. Arrays.equals()String.equals()는 첫 바이트가 다르면 바로 반환해서, 응답 시간으로 서명을 한 바이트씩 맞춰볼 수 있다.

private boolean signatureMatches(String payloadB64, String signatureB64) {
    byte[] provided = DECODER.decode(signatureB64);
    return MessageDigest.isEqual(provided, sign(payloadB64, properties.getSecret()));
}

검증 규칙은 이렇다. 하나라도 어긋나면 거부하되, 실패 사유는 노출하지 않는다. 어느 단계에서 걸렸는지 알려주면 위조 시도에 힌트가 된다.

  1. . 로 2조각 분리
  2. HMAC 대조
  3. JSON 파싱
  4. payload.uid == 세션 userId
  5. exp 미경과
  6. (엔티티 저장 한정) st ∈ {S, C}

uid 고정이 핵심이다. 공격자가 남의 S3 경로를 넣으려면 서명을 위조해야 하고, 자기 티켓의 path만 바꿔도 서명이 깨진다.

2. 파일 API에서 grId 걷어내기

// Before
public List<FilePreSignedUrl> createPreSignedUploadUrl(String atchFileGrId, String[] names, ...) {
    // ... presigned URL 만들고
    fileRepository.upsertFile(atchFileGrId, fileVO, session);   // DB INSERT
}

// After
public List<UploadTicketResponseVO> createPreSignedUploadUrl(String[] names, String[] sizes, String[] types) {
    // presigned URL + 티켓만. DB는 건드리지 않는다
}

멀티파트 complete가 재미있다.

// Before — uploadId, fileSavePath 를 클라가 보낸다. 검증 없음
public String completeMultipartUpload(String uploadId, String fileName, List<PartVO> parts, String fileSavePath)

// After — 티켓 하나만 받는다
public String completeMultipartUpload(String ticket, List<PartVO> parts) {
    UploadTicketPayload payload = uploadTicketService.verifyMultipartInProgress(ticket);
    // payload.mpu() = uploadId, payload.path() = fileSavePath
    ...
    return uploadTicketService.issueCompleted(payload);   // st="C" 로 재발급
}

mpu를 티켓에 담으니 요청 본문에서 uploadIdfileSavePath가 사라졌다. 공격 4가 검증 추가가 아니라 파라미터 제거로 소멸한다. 이게 이 설계에서 제일 마음에 드는 부분이다.

3. 엔티티 저장 쪽

요청 본문은 이렇게 바뀐다.

// Before
{ bbsTitle: "...", bbsCtn: "...", atchFileGrId: "a1b2c3" }

// After
{ bbsTitle: "...", bbsCtn: "...",
  attachments: {
    keep: ["file-id-1", "file-id-2"],   // 유지할 기존 첨부
    add: ["eyJwYXRoIjoi...", "eyJwYX..."]  // 새로 붙일 티켓
  } }

클라는 "무엇을 남기고 무엇을 새로 붙일지"만 말한다. 그룹ID·정렬순서·useYn은 보내지 않는다.

받는 쪽은 이렇다.

@Transactional(propagation = Propagation.MANDATORY)
public String apply(String existingGrId, AttachmentsCommandVO cmd) {
    String atchFileGrId = resolveGroupId(existingGrId, cmd);
    ...
    // 1) keep 에 없는 기존 첨부 논리 삭제
    // 2) keep 순서대로 정렬 재부여
    // 3) add 티켓 검증 후 INSERT
}

MANDATORY를 쓴 이유가 있다. "파일 메타데이터는 엔티티 저장 트랜잭션 안에서만 확정한다"가 이 설계의 전부인데, 컴파일 타임에는 강제할 방법이 없다. REQUIRED로 두면 트랜잭션 밖 호출이 조용히 통과해서 원칙이 무너진다. 런타임에서라도 즉시 실패시킨다.

삭제 SQL은 두 조건이 전부다.

UPDATE TB_ATCH_FILE_M SET USE_YN = 'N', ...
 WHERE ATCH_FILE_GR_ID = #{atchFileGrId}   -- 서버가 결정한 값. 타인 파일에 도달 불가
   AND USE_YN = 'Y'                        -- 'N' → 'Y' 복구 차단
   AND ATCH_FILE_ID NOT IN (keep)

keep에 남의 atchFileId를 넣어도 ATCH_FILE_GR_ID 조건에 갇혀 도달하지 못한다. USE_YN='Y' 조건이 공격 3의 복구 경로를 막는다.

INSERT는 컬럼 값이 전부 티켓 payload 아니면 서버 계산에서 온다.

FileVO file = FileVO.builder()
        .atchFileId(UUID.randomUUID().toString())   // 서버 생성
        .atchFileNm(payload.name())                 // 티켓
        .sortOrd(sortOrd)                           // 서버 계산
        .atchFileSavePathCtn(payload.path())        // 티켓
        .useYn("Y")                                 // 하드코딩
        .build();

요청 본문에서 오는 값이 하나도 없다.

4. 프론트

// Before
handleSaveFiles(): Promise<{ atchFileGrId: string; fileSaveResult: FileSaveResult }>

// After
handleSaveFiles(): Promise<{
  attachments: { keep?: string[]; add: string[] };
  fileSaveResult: FileSaveResult;
}>

uuidv4()가 사라졌다. 클라가 그룹ID를 만들지 않는다.

그런데 여기서 이 전환 최대의 함정을 만났다.

결과

전환 자체는 됐는데, 프론트에서 두 가지가 크게 물렸다.

함정 1 — keep 레이스

삭제 판정을 서버로 옮겼더니 새 위험이 생겼다.

기존 첨부가 있는 수정 화면에서, 첨부 목록을 아직 못 읽은 상태로 저장을 누르면 selectedFiles가 비어 있다. 그대로 keep: []를 보내면 서버는 **"전부 지우라"**로 읽는다.

파일 탭을 열기 전에 저장을 누르거나, 네트워크가 느리면 실제로 발생한다.

const buildKeepList = (): string[] | undefined => {
  if (!initialFileGrId) return []; // 신규 — 지울 기존 첨부가 없다
  if (!isFilesLoaded || isFilesFetching) return undefined; // 목록 미확정
  return selectedFiles.map((f) => f.atchFileId).filter(Boolean);
};

undefined[]의 의미를 서버에서도 구분한다. null이면 삭제 판정 자체를 건너뛴다.

if (cmd.hasKeepDirective()) {   // keep != null 일 때만
    attachmentRepository.disableFilesNotIn(atchFileGrId, keep, session);
}

**"모르겠으면 아무것도 하지 마라"**를 기본값으로 둔 셈이다.

최종 결과

Before After
파일 API의 atchFileGrId 받고 반환 없음
파일 업로드 시 DB INSERT 안 함
엔티티 저장의 atchFileGrId 클라가 보냄 서버가 결정
complete의 uploadId·fileSavePath 클라가 보냄 티켓 안
삭제 판정 클라가 useYn 지정 서버가 판정
응답의 S3 키 노출 마스킹

공격 1~4가 전부 "검사 추가"가 아니라 **"파라미터 소멸"**로 정리됐다.

고찰

1. 왜 JWT를 안 쓰고 직접 만들었나?

라이브러리를 하나 더 들이기엔 payload가 단순하고, JWT의 alg 필드 관련 취약점(alg: none 등)을 신경 쓸 이유가 없었다. HMAC-SHA256 고정에 alg 협상 자체가 없는 편이 낫다고 봤다.

2. Base64URL을 쓴 이유는?

XSS 필터 때문이다. 프로젝트의 HtmlCharacterEscapes< > " ( ) # ' 를 이스케이프하는데, 표준 Base64의 + / ==가 URL 인코딩 과정에서 문제가 될 수 있었다. Base64URL 알파벳(A-Za-z0-9-_)은 전부 안전하다. 패딩 = 없이 쓴다.

한 가지 더. 전역 ObjectMappersetCharacterEscapes()가 걸려 있으면 티켓 payload가 오염된다. 티켓 서비스는 **전용 ObjectMapper**를 따로 갖게 했다.

private final ObjectMapper ticketMapper = JsonMapper.builder().build();

3. 티켓에 만료를 왜 두나?

솔직히 말하면 보안 효과는 거의 없다. exp를 빼도 뚫리는 공격이 사실상 없다.

티켓이 uid에 묶여 있기 때문이다. 티켓이 유출돼도 그 사용자의 세션이 없으면 못 쓴다. 세션을 이미 탈취했다면 굳이 남의 티켓을 쓸 이유가 없다. 새로 발급받으면 그만이다. 그리고 그 사용자 본인이 1년 전 티켓을 다시 쓰는 건 자기가 올린 자기 파일을 자기 글에 붙이는 것이라 공격이 아니다.

상황 TTL이 막나
티켓 변조 아니오 — 서명이 막는다
남의 세션으로 사용 아니오 — uid 가 막는다
secret 유출 후 위조 아니오 — 공격자가 exp 도 원하는 값으로 넣는다
같은 티켓 중복 첨부 아니오 — 중복 검사가 따로 막는다

그럼 왜 두나. S3를 청소하기 위해서다.

1안은 업로드 시점에 DB를 안 건드린다. 그래서 올리고 저장 안 하고 나간 사용자의 S3 객체는 DB에 아무 흔적이 없다. 누가 언제 올렸는지 모른다. 지우려면 lifecycle rule로 "N일 지난 객체 삭제"를 거는 수밖에 없는데, 그럼 N을 뭘로 잡아야 하나?

TTL 없음  →  1년 전 티켓도 지금 유효하다
          →  1년 전 객체도 아직 저장될 수 있다
          →  안전한 N이 존재하지 않는다  →  lifecycle rule 을 못 건다

TTL 24h   →  24시간 지나면 그 티켓으로는 저장이 불가능하다
          →  "아직 저장될 수 있는 객체" 는 24시간 안에만 존재한다
          →  N 을 24시간보다 크게 잡으면 안전하다   ✓

TTL은 공격을 막는 장치가 아니라, 쓰레기를 언제 버려도 되는지 증명하는 근거다. 이게 없으면 S3가 무한히 자란다.

이유가 하나 더 있다. secret을 바꿀 수 있게 된다. TTL이 있으면 구 secret을 TTL 기간만 병행 검증하다가 버리면 무중단 교체가 된다. TTL이 없으면 모든 과거 secret을 영원히 들고 있거나, 교체하는 순간 사용자가 들고 있던 티켓을 전부 죽이거나 둘 중 하나다.

없애고 싶다면 대체재가 필요하다. S3 청소 쪽은 저장 시점에 객체 존재를 확인하는 걸로 대신할 수 있다. 멀티파트 complete에서 이미 크기 검사를 하고 있으니 같은 걸 한 번 더 하면 된다. 대신 저장할 때마다 파일 수만큼 S3 호출이 는다. secret 로테이션 쪽은 대체재가 없다.

4. TTL은 얼마로 잡아야 하나?

24시간으로 했다. 짧게 잡고 싶었지만 성과검증·과제등록처럼 폼 작성이 긴 화면이 있고, 500MB 멀티파트를 올린 뒤 저장까지 시간이 걸린다. 만료되면 재첨부해야 하는데 500MB를 다시 올리라고 할 순 없다.

5. 확장자 검증은 허용 목록이 낫나 차단 목록이 낫나?

처음엔 허용 목록으로 만들었다가 차단 목록으로 뒤집었다. 이유는 두 가지다.

화면마다 필요한 확장자가 다르다. 어떤 화면은 제한이 없어야 한다. 서버가 허용 목록을 들고 있으면 화면이 늘 때마다 서버 설정을 고쳐야 한다.

그리고 허용 목록이 실제로 막고 있던 게 거의 없었다.

위협 실제 방어 장치 확장자 허용 목록 기여
메타데이터 변조 티켓 서명 없음
남의 그룹에 주입 티켓 + 서버 판정 없음
Stored XSS 다운로드 응답의 Content-Disposition: attachment 없음
사내 도메인이 악성 실행파일 배포처가 됨 확장자 검사 유일

마지막 행만 고유 역할이고, 그건 실행 가능한 형식만 막으면 된다. 화면별 허용 범위는 공통코드로 프론트가 정하고, 서버는 "무엇을 올리든 이것만은 안 된다"는 최소선만 지킨다.

6. 마이그레이션은 어떻게 했나?

안 했다. 이게 이 방식의 큰 장점이었다.

소유권 검사 방식은 기존 데이터에 "이 그룹이 누구 것인지"를 채워야 해서 마이그레이션이 필수였다. 티켓 방식은 기존 파일 행을 그대로 두고 앞으로 들어오는 요청의 형태만 바꾸므로 DDL도 마이그레이션도 없다. 롤백도 이전 태그로 되돌리면 끝이다.

7. 가장 위험했던 건?

keep 레이스다. 함정 1에서 쓴 그것.

삭제 권한을 서버로 옮기는 건 보안상 옳은 방향인데, 그 순간 **"클라가 침묵하면 서버가 전부 지운다"**는 새 실패 모드가 생긴다. 방향이 옳다고 해서 중간 상태까지 안전한 건 아니었다.

undefined(모름)와 [](전부 지워)를 타입 수준에서 구분하고, 프론트가 확신할 수 없을 때는 undefined를 보내게 했다. 서버는 null이면 삭제 판정 자체를 하지 않는다.

8. 2안과 3안을 만든 건 낭비였나?

아니다. 이게 없었으면 1안을 고를 근거도 없었다.

3안을 실제로 만들어 보지 않았으면 reviAtchFileGrId 같은 이름을 못 찾았을 것이다. 그리고 그걸 못 찾았으면 정리 배치에 있던 같은 구멍도 못 찾았다. 서로 다른 두 코드가 같은 가정을 공유하고 있다는 건, 그 가정을 실제로 코드로 옮겨 본 뒤에야 보였다.

설계 문서만 보고 "1안이 근본적이니까 1안" 이라고 골랐다면 그건 취향이다. 가장 싼 안을 진지하게 만들어 보고 그게 왜 안 되는지 확인한 다음 고르는 게 판단이다.

3안에서 만든 공통 변환기도 버리지 않았다. 필드 이름 하나로 전 VO에 거는 접근 자체는 유효해서, 이름 매칭을 접미어 기준으로 바꾸고(atchFileGrId로 끝나는 필드 전부) 부팅 시 클래스패스를 스캔해 걸리지 않은 필드가 있으면 아예 기동을 실패시키는 형태로 살렸다. 안전망이 하나 늘었다.


1편에서 presigned URL로 서버 부하를 덜었고, 2편에서 멀티파트로 속도를 올렸다. 3편은 그 구조에 서명을 하나 얹은 이야기다. 기능은 그대로인데 공격 표면이 줄었다.

@Pang
안녕하세요! 왜 이렇게 까지 하지를 담당하는 3년차 프론트엔드 개발자입니다!