본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
8장 : 웹 상태와 경계 처리

멀티파트 파일 업로드

게시판 첨부 업로드의 멀티파트/폼-데이터 프레이밍, 크기 제한, 파일명·미디어 타입 신뢰 경계와 임시 저장·커밋 원자성을 실제 요청으로 확인합니다.

파일 업로드 요청은 JSON 한 덩어리가 아니라 경계로 구분된 여러 부분입니다. 각 부분은 이름, 헤더, 본문을 갖고 텍스트 필드와 바이너리 파일이 한 요청에 섞입니다. 컨트롤러가 MultipartFile을 받았다는 사실은 파일이 안전하거나 영구 저장됐다는 뜻이 아닙니다. 요청 크기, 부분 이름, 내용, 소유권, 저장 완료를 단계별로 확인해야 합니다.


멀티파트 경계

클라이언트가 Content-Type: multipart/form-data만 직접 쓰고 경계 파라미터를 빠뜨리면 서버는 부분 경계를 찾을 수 없습니다. 브라우저 FormData나 HTTP 클라이언트 라이브러리가 본문을 만들 때 헤더도 함께 만들게 둡니다.

multipart 요청의 축약된 wire 모양
Content-Type: multipart/form-data; boundary=BoardBoundary7

--BoardBoundary7
Content-Disposition: form-data; name="metadata"
Content-Type: application/json

{"postId":41,"caption":"JDBC 실행 계획"}
--BoardBoundary7
Content-Disposition: form-data; name="file"; filename="plan.png"
Content-Type: image/png

[PNG binary bytes]
--BoardBoundary7--

경계 문자열은 본문 각 구분선과 정확히 일치하고 마지막에는 종료 표시가 있습니다. 줄 끝과 헤더 구문도 파서 계약에 포함됩니다. 애플리케이션 코드가 이 프레이밍을 직접 파싱하지 않고 서블릿 멀티파트 리졸버에 맡기지만, 400이 날 때 원시 헤더와 클라이언트 생성 방식을 확인할 수 있어야 합니다.

JSON·파일 부분 검증

@RequestPart는 JSON 부분을 Jackson으로 DTO에 변환하고 파일 부분을 MultipartFile로 제공합니다. 메타데이터에 @Valid를 적용해도 파일 콘텐츠 검증은 별도 서비스가 수행합니다.

src/main/java/board/web/AttachmentController.java
package board.web;

import java.net.URI;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
public final class AttachmentController {
    private final AttachmentService attachments;

    public AttachmentController(AttachmentService attachments) {
        this.attachments = attachments;
    }

    @PostMapping(
            path = "/api/attachments",
            consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    ResponseEntity<AttachmentResponse> upload(
            @Valid @RequestPart("metadata") AttachmentMetadata metadata,
            @RequestPart("file") MultipartFile file
    ) {
        AttachmentResponse saved = attachments.store(metadata, file);
        return ResponseEntity
                .created(URI.create("/api/attachments/" + saved.id()))
                .body(saved);
    }

    public record AttachmentMetadata(
            @Positive long postId,
            @NotBlank @Size(max = 120) String caption
    ) {
    }

    public record AttachmentResponse(long id, String mediaType, long size) {
    }
}

클라이언트가 메타데이터 부분의 Content-Type을 application/json으로 보내야 JSON 컨버터가 선택됩니다. 일반 텍스트 필드로 보냈다면 @ModelAttribute 폼 구조가 더 적합할 수 있습니다. 엔드포인트 계약에서 부분 이름과 미디어 타입을 API 스키마에 명시합니다.

파일명·Content-Type 신뢰

getOriginalFilename()은 클라이언트가 정한 문자열입니다. ../../app.properties, 예약된 장치 이름, Unicode 혼동 문자가 들어올 수 있습니다. 저장 키로 사용하지 않고 서버가 UUID 또는 콘텐츠 해시를 발급합니다. 원래 이름은 표시용 메타데이터로 정규화·길이 제한 후 별도 보관합니다.

src/main/java/board/attachment/UploadInspector.java
package board.attachment;

import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.Set;

public final class UploadInspector {
    private static final long MAX_BYTES = 5L * 1024 * 1024;
    private static final Set<String> ALLOWED = Set.of(
            "image/png", "image/jpeg", "application/pdf");

    public InspectedUpload inspect(
            String declaredType,
            long size,
            InputStream input
    ) throws IOException {
        if (size <= 0 || size > MAX_BYTES) {
            throw new InvalidUploadException("file size out of range");
        }
        byte[] prefix = input.readNBytes(16);
        String detected = detect(prefix);
        if (!ALLOWED.contains(detected)
                || !detected.equals(declaredType)) {
            throw new InvalidUploadException("file type mismatch");
        }
        return new InspectedUpload(detected, size, prefix);
    }

    private String detect(byte[] bytes) {
        byte[] png = new byte[]{
                (byte) 0x89, 0x50, 0x4E, 0x47,
                0x0D, 0x0A, 0x1A, 0x0A};
        if (bytes.length >= png.length
                && Arrays.equals(
                        Arrays.copyOf(bytes, png.length), png)) {
            return "image/png";
        }
        if (bytes.length >= 4
                && bytes[0] == 0x25
                && bytes[1] == 0x50
                && bytes[2] == 0x44
                && bytes[3] == 0x46) {
            return "application/pdf";
        }
        if (bytes.length >= 3
                && bytes[0] == (byte) 0xFF
                && bytes[1] == (byte) 0xD8
                && bytes[2] == (byte) 0xFF) {
            return "image/jpeg";
        }
        return "application/octet-stream";
    }

    public record InspectedUpload(
            String mediaType,
            long size,
            byte[] prefix
    ) {
        public InspectedUpload {
            prefix = prefix.clone();
        }
    }

    public static final class InvalidUploadException
            extends RuntimeException {
        public InvalidUploadException(String message) {
            super(message);
        }
    }
}

매직 바이트 몇 개만 확인하는 예제는 완전한 악성 코드 검사가 아닙니다. 실제 환경에서는 검증된 콘텐츠 탐지, 이미지 디코딩·재인코딩, 백신 검사, PDF 활성 콘텐츠 정책을 위험에 맞게 추가합니다. 확장자와 Content-Type 헤더만 비교하는 것보다 실제 바이트를 보는 경계가 필요하다는 점을 보여 줍니다.

접두사를 읽은 뒤 나머지 스트림과 다시 합쳐 저장해야 합니다. 검사 때문에 앞부분을 버리면 파일이 손상됩니다. PushbackInputStream, 버퍼링된 임시 파일, 전체 제한된 스트림 중 크기와 처리 모델에 맞는 방법을 선택합니다.

계층별 크기 제한

src/main/resources/application.properties
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB

리버스 프록시가 10MB, Boot가 5MB, 애플리케이션이 4MiB처럼 서로 다른 값을 쓰면 어느 층에서 어떤 응답이 나는지 혼란스럽습니다. 메타데이터와 프레이밍 부가 비용을 포함한 요청 제한은 파일 제한보다 조금 커야 합니다. 단위 MB와 MiB 차이도 문서화합니다.

컨테이너가 먼저 거부한 요청은 컨트롤러 어드바이스까지 도달하지 않을 수 있습니다. 프록시 413, 멀티파트 리졸버 예외, 애플리케이션 422를 클라이언트가 일관되게 처리하도록 게이트웨이 오류 본문과 애플리케이션 ProblemDetail을 조정합니다. 업로드 진행 중 연결 종료와 타임아웃도 메트릭으로 구분합니다.

파일·DB 원자성

파일 시스템 또는 객체 저장소 쓰기와 관계형 DB 트랜잭션은 기본적으로 하나의 원자적 트랜잭션이 아닙니다. 파일을 먼저 쓰고 DB 삽입이 실패하면 고아 파일이 남고, DB를 먼저 커밋한 뒤 업로드가 실패하면 깨진 링크가 남습니다.

임시 키에 업로드하고 스캔과 체크섬을 완료한 뒤 DB 레코드를 PENDING에서 READY로 전환하는 상태 기계를 사용할 수 있습니다. 성공 전 다운로드 엔드포인트는 파일을 공개하지 않습니다. 실패·만료된 임시 객체는 멱등 정리 작업이 제거합니다.

상태객체 저장소DB다운로드
UPLOADING임시 키대기 행거부
SCANNING체크섬 완료스캔 대기거부
READY불변 키미디어 메타데이터허용
REJECTED격리·삭제실패 코드거부

사용자 할당량은 선언된 크기가 아니라 실제 저장 바이트로 계산합니다. 같은 멱등성 키 재시도에서 파일을 중복 저장하지 않게 체크섬과 책임 주체를 확인합니다. 공개 URL에는 원본 파일명을 직접 경로로 연결하지 않습니다.

MockMultipartFile 검증

src/test/java/board/web/AttachmentControllerTest.java
package board.web;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(AttachmentController.class)
class AttachmentControllerTest {
    @Autowired
    MockMvc mvc;

    @MockitoBean
    AttachmentService attachments;

    @Test
    void 잘못된_metadata는_file_service_호출_전에_거부된다()
            throws Exception {
        var metadata = new MockMultipartFile(
                "metadata",
                "metadata.json",
                MediaType.APPLICATION_JSON_VALUE,
                "{\"postId\":0,\"caption\":\"\"}".getBytes());
        var file = new MockMultipartFile(
                "file", "plan.png", "image/png",
                new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47});

        mvc.perform(multipart("/api/attachments")
                        .file(metadata)
                        .file(file))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.fields").isArray());

        verifyNoInteractions(attachments);
    }
}
multipart 검증 결과
metadata Content-Type = application/json
file Content-Type = image/png
metadata violations = [postId, caption]
attachment service calls = 0
HTTP status = 400

실제 콘텐츠 검사 테스트는 UploadInspector에 바이트 스트림을 직접 넣고 선언된 PNG와 실제 PDF 바이트의 불일치를 확인합니다. 큰 파일 테스트에서 수백 MB 배열을 만들지 말고 크기만 보고하는 테스트 대역이나 제한된 스트림으로 제한 분기를 실행합니다.

연습 문제

동일 게시글에 같은 체크섬 파일을 두 번 올리면 기존 첨부 파일을 반환하는 멱등 업로드를 설계하세요. 다른 회원의 같은 파일은 공유할지 분리할지 개인정보 보호 기준을 정하고, DB 커밋 실패와 객체 저장소 타임아웃에서 고아 파일 정리가 가능한 상태를 테스트합니다.

해설 보기

콘텐츠 해시만 전역 키로 쓰면 파일 존재 여부가 다른 회원에게 새어 나갈 수 있습니다. 소유자 범위의 중복 제거를 기본으로 하고 (ownerId, postId, checksum) 고유 제약 조건으로 경쟁을 막습니다.

package board.attachment;

public record AttachmentFingerprint(
        long ownerId,
        long postId,
        String sha256
) {
    public AttachmentFingerprint {
        if (ownerId <= 0 || postId <= 0) {
            throw new IllegalArgumentException("positive ids required");
        }
        if (sha256 == null || !sha256.matches("[0-9a-f]{64}")) {
            throw new IllegalArgumentException("invalid SHA-256");
        }
    }
}

두 동시 요청을 장벽 뒤에서 삽입하게 하고 하나만 성공하는지 확인합니다. 실패 요청은 고유 충돌 후 기존 준비 레코드를 조회하며, 아직 대기 상태이면 202와 재시도 위치를 반환하는 정책을 둘 수 있습니다.

다음 문서에서는 지금까지의 로그인, 세션, 필터, 리졸버, 검증, 업로드를 하나의 게시판 요청 흐름으로 통합하고 운영 신호까지 검증합니다.