본문 바로가기

멀티파트 파일 업로드

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

파일 업로드 요청은 JSON 한 덩어리가 아니라 구분선(boundary)으로 나뉜 여러 부분(part)입니다. 부분마다 이름과 헤더, 본문을 가지며, 텍스트 필드와 바이너리 파일이 한 요청에 섞입니다. 컨트롤러가 MultipartFile을 받았다고 해서 파일이 안전하거나 영구히 저장되었다는 뜻은 아닙니다. 요청의 크기와 부분의 이름, 내용, 소유권, 저장의 완료를 단계마다 확인해야 합니다.

MVC는 인증, metadata 검증, mocked AttachmentService의 201 응답을 검사한다. 별도 UploadInspector는 prefix를 포함한 전체 바이트 복사와 실제 형식·크기·SHA-256을 확인한다. 두 검사 및 추가 저장 상태 설계를 구분한다.

CONTRACT / IMPLEMENTATION / REVIEW SCOPE

업로드 MVC와 실제 바이트 검사를 따로 검증한다

인증·metadata 검증·201 응답은 MVC slice에서 확인합니다. 별도 UploadInspector 단위 테스트가 실제 스트림의 형식·크기·SHA-256과 복사를 검증합니다. 저장·스캔·READY 공개는 추가 설계입니다.

업로드 MVC와 실제 바이트 검사를 따로 검증한다MVC는 인증, metadata 검증, mocked AttachmentService의 201 응답을 검사한다. 별도 UploadInspector는 prefix를 포함한 전체 바이트 복사와 실제 형식·크기·SHA-256을 확인한다. 두 검사 및 추가 저장 상태 설계를 구분한다.인증 · metadataservice portMVC에서는 mock201 · Location 91prefix + 나머지type · sizeSHA-256destination실제 bytes 보존
HTTP와 직접 스트림 fixture의 독립 경로원 데스크톱6노드4관계이다. 위MVC는인증metadata와mockservice의201/Location91이고 아래독립inspector는prefix+나머지 type size SHA256과목적지bytes검사이다. 위mocksize12가파일검사를입증하지않으며DB상태/스캔READY공개는추가구현이다.HTTP MVC fixture직접 스트림 fixture인증 · metadataHTTP 입력 경계service portMVC에서는 mock201 · Location 91mock 응답 size=12prefix + 나머지직접 입력 스트림type · sizeSHA-256destination실제 bytes 보존

ACTUAL MVC / BEFORE SERVICE

익명 401 · 잘못된 metadata 400

로그인하지 않은 요청은 part 없이도 401로 끝납니다. 회원 41의 잘못된 postId·caption은 MethodArgumentNotValidException과 필드 오류로 끝납니다. 두 경우 모두 AttachmentService 호출이 없습니다.

ACTUAL MVC / MOCKED SERVICE

201은 inspector 연결을 입증하지 않는다

정상 MVC fixture의 file은 PNG 접두부 4바이트입니다. mock은 size=12인 응답을 돌려줍니다. 테스트는 회원·metadata·파일 이름 전달과 Location=/api/attachments/91, 201 본문을 확인하며 실제 파일 검사를 실행하지 않습니다.

ACTUAL INSPECTOR UNIT

12바이트를 빠짐없이 복사

별도 단위 테스트는 12바이트 PNG를 limit=64로 읽어 prefix를 포함한 destination 전체, 실제 size=12, image/png 및 SHA-256 결과를 확인합니다. 탐지 형식과 선언 Content-Type도 같아야 합니다.

FAILURE / CLEANUP

초기 거부와 뒤늦은 실패를 구분

PNG라고 선언한 PDF와 limit=8인 12바이트 PNG fixture는 prefix 단계에서 거부되어 destination이 비어 있습니다. 뒤쪽 스트림에서 한도를 넘거나 실패하면 일부 바이트가 남을 수 있어 호출자가 임시 목적지를 정리해야 합니다.

ACTUAL CONFIG UNIT

파일 5MB · 요청 전체 6MB

설정 단위 테스트는 MultipartProperties의 max-file-size=5MB, max-request-size=6MB를 확인합니다. raw multipart framing과 실제 컨테이너 제한 적용은 이 MockMvc·inspector 검사와 다른 통합 범위입니다.

STORAGE STATES / ADDITIONAL

스캔과 READY 공개는 추가 구현

서버 임시 key, 스캔, DB 상태 전이, READY 다운로드, 실패 정리와 중복 업로드의 동시성 처리는 제공된 MVC service interface에 구현되어 있지 않습니다. 연습 fingerprint 값 객체도 DB 유일 제약을 대신하지 않습니다.

MVC는 인증, metadata 검증, mocked AttachmentService의 201 응답을 검사한다. 별도 UploadInspector는 prefix를 포함한 전체 바이트 복사와 실제 형식·크기·SHA-256을 확인한다. 두 검사 및 추가 저장 상태 설계를 구분한다.

그림의 위쪽은 HTTP MVC 예제로, 인증과 메타데이터 검증을 거친 뒤 모의 AttachmentService가 201과 Location 91을 돌려주는 경로입니다. 아래쪽은 그와 별개로 입력 스트림을 직접 넣어, UploadInspector가 탐지에 읽은 앞부분과 나머지를 모두 목적지에 복사하면서 형식과 크기, SHA-256을 확인하는 경로입니다. 위쪽 모의 객체가 돌려준 size=12가 파일 검사를 했다는 뜻은 아니며, DB 상태와 스캔, READY 공개는 추가로 구현할 부분입니다.


멀티파트의 구분선

클라이언트가 Content-Type: multipart/form-data만 직접 쓰고 boundary 파라미터를 빠뜨리면, 서버는 부분이 어디서 나뉘는지 찾을 수 없습니다. 브라우저의 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/attachment/AttachmentController.java
package board.web.attachment;

import java.net.URI;

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

import board.web.auth.CurrentMember;
import board.web.auth.LoginRequired;
import board.web.auth.SessionMemberReader.AuthenticatedMember;

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
@LoginRequired
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(
            @CurrentMember AuthenticatedMember member,
            @Valid @RequestPart("metadata") AttachmentMetadata metadata,
            @RequestPart("file") MultipartFile file
    ) {
        AttachmentResponse saved = attachments.store(
                member.id(), 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) {
    }
}

interface AttachmentService {
    AttachmentController.AttachmentResponse store(
            long memberId,
            AttachmentController.AttachmentMetadata metadata,
            MultipartFile file);
}

소유자 ID는 멀티파트 필드에서 받지 않고, 로그인 인터셉터와 인자 리졸버가 검증한 서버의 주체에서만 받습니다. 클라이언트가 메타데이터 부분의 Content-Type을 application/json으로 보내야 JSON 컨버터가 선택됩니다. 일반 텍스트 필드로 보냈다면 @ModelAttribute 폼 구조가 더 알맞을 수 있으므로, 엔드포인트의 API 스키마에 부분의 이름과 미디어 타입을 명시합니다.


파일 이름과 Content-Type은 믿을 수 없습니다

getOriginalFilename()은 클라이언트가 정한 문자열이어서 ../../app.properties나 예약된 장치 이름, 유니코드의 혼동 문자가 들어올 수 있습니다. 이 값을 저장 키로 쓰지 않고, 서버가 UUID나 콘텐츠 해시를 발급합니다. 원래 이름은 정규화하고 길이를 제한한 뒤 표시용 메타데이터로 따로 보관합니다.

정보어디서 왔나저장과 검증 정책
원본 파일 이름클라이언트표시용으로만, 정규화와 길이 제한
선언된 Content-Type클라이언트 헤더서버가 탐지한 실제 바이트 형식과 비교
SHA-256과 실제 바이트 수서버의 스트림무결성, 할당량, 소유자 범위의 중복 판단
src/main/java/board/attachment/UploadInspector.java
package board.attachment;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Objects;
import java.util.Set;

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

    public UploadInspector(long maxBytes) {
        if (maxBytes < 1) {
            throw new IllegalArgumentException("maxBytes must be positive");
        }
        this.maxBytes = maxBytes;
    }

    public InspectedUpload inspectAndCopy(
            String declaredType,
            InputStream input,
            OutputStream destination
    ) throws IOException {
        Objects.requireNonNull(input, "input");
        Objects.requireNonNull(destination, "destination");

        byte[] prefix = input.readNBytes(16);
        if (prefix.length == 0) {
            throw new InvalidUploadException("file is empty");
        }
        if (prefix.length > maxBytes) {
            throw new InvalidUploadException("file size out of range");
        }
        String detected = detect(prefix);
        if (!ALLOWED.contains(detected)
                || !detected.equals(declaredType)) {
            throw new InvalidUploadException("file type mismatch");
        }

        MessageDigest digest = sha256();
        digest.update(prefix);
        destination.write(prefix);
        long copied = prefix.length;

        byte[] buffer = new byte[8192];
        for (int read; (read = input.read(buffer)) != -1; ) {
            if (read > maxBytes - copied) {
                throw new InvalidUploadException("file size out of range");
            }
            digest.update(buffer, 0, read);
            destination.write(buffer, 0, read);
            copied += read;
        }
        return new InspectedUpload(
                detected,
                copied,
                HexFormat.of().formatHex(digest.digest()));
    }

    private MessageDigest sha256() {
        try {
            return MessageDigest.getInstance("SHA-256");
        } catch (NoSuchAlgorithmException exception) {
            throw new IllegalStateException("SHA-256 unavailable", exception);
        }
    }

    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,
            String sha256
    ) {
    }

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

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

예제는 탐지에 읽은 앞부분을 먼저 임시 목적지에 쓰고, 나머지를 같은 다이제스트와 바이트 제한 아래에서 복사합니다. 검사 때문에 앞부분을 버리지 않으며, 제한을 넘거나 검사에 실패하면 호출한 쪽이 임시 목적지를 폐기합니다.

src/test/java/board/attachment/UploadInspectorTest.java
package board.attachment;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;

import org.junit.jupiter.api.Test;

class UploadInspectorTest {
    private static final byte[] PNG = new byte[]{
            (byte) 0x89, 0x50, 0x4E, 0x47,
            0x0D, 0x0A, 0x1A, 0x0A,
            0x01, 0x02, 0x03, 0x04};

    @Test
    void 탐지에_읽은_prefix까지_목적지와_digest에_포함한다()
            throws Exception {
        var destination = new ByteArrayOutputStream();

        var inspected = new UploadInspector(64).inspectAndCopy(
                "image/png",
                new ByteArrayInputStream(PNG),
                destination);

        assertThat(destination.toByteArray()).containsExactly(PNG);
        assertThat(inspected.mediaType()).isEqualTo("image/png");
        assertThat(inspected.size()).isEqualTo(12);
        assertThat(inspected.sha256()).isEqualTo(
                "4353a1de7e0dcc4e87350e22d5c9ee9f"
                        + "3e70e8ce9c31533ec991bee8870c4814");
    }

    @Test
    void 선언_type과_실제_bytes가_다르면_한_byte도_저장하지_않는다() {
        var destination = new ByteArrayOutputStream();
        byte[] pdf = new byte[]{0x25, 0x50, 0x44, 0x46, 0x2D};

        assertThatThrownBy(() -> new UploadInspector(64).inspectAndCopy(
                "image/png",
                new ByteArrayInputStream(pdf),
                destination))
                .isInstanceOf(UploadInspector.InvalidUploadException.class)
                .hasMessage("file type mismatch");
        assertThat(destination.size()).isZero();
    }

    @Test
    void 선언_size가_아닌_실제_stream_byte를_제한한다() {
        var destination = new ByteArrayOutputStream();

        assertThatThrownBy(() -> new UploadInspector(8).inspectAndCopy(
                "image/png",
                new ByteArrayInputStream(PNG),
                destination))
                .isInstanceOf(UploadInspector.InvalidUploadException.class)
                .hasMessage("file size out of range");
        assertThat(destination.size()).isZero();
    }
}

계층마다 다른 크기 제한

장 전체를 실행하는 프로젝트의 운영 설정은 HTTP 세션 수명 주기에서 정의한 src/main/resources/application.properties 하나에 둡니다. 이 글의 별도 예제는 업로드 제한이 바인딩되는지를 따로 확인하며, 테스트가 그 경로를 명시적으로 불러옵니다. 다만 그 운영 설정에도 같은 두 값이 있고 @SpringBootTest는 SpringApplication으로 컨텍스트를 만들면서 그 파일도 읽으므로, 이 테스트가 통과했다는 것만으로 픽스처 파일 multipart-limits.properties가 적용되었는지까지 알 수는 없습니다. @TestPropertySource로 불러온 값이 application.properties보다 우선하므로, 픽스처가 실제로 읽히는지 보려면 픽스처의 값을 운영 설정과 다르게 바꾸고 테스트의 기대값도 함께 바꿉니다.

src/test/resources/board/upload/multipart-limits.properties
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB
src/test/java/board/upload/UploadLimitPropertiesTest.java
package board.upload;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.servlet.autoconfigure.MultipartProperties;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.TestPropertySource;
import org.springframework.util.unit.DataSize;

@SpringBootTest(classes = UploadLimitPropertiesTest.TestConfiguration.class)
@TestPropertySource(
        locations = "classpath:board/upload/multipart-limits.properties")
class UploadLimitPropertiesTest {
    @Autowired
    MultipartProperties multipart;

    @Test
    void 별도_fixture가_Boot의_실제_MultipartProperties에_binding된다() {
        assertThat(multipart.getMaxFileSize())
                .isEqualTo(DataSize.ofMegabytes(5));
        assertThat(multipart.getMaxRequestSize())
                .isEqualTo(DataSize.ofMegabytes(6));
    }

    @Configuration(proxyBeanMethods = false)
    @EnableConfigurationProperties(MultipartProperties.class)
    static class TestConfiguration {
    }
}

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

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

더 깊이: 기본 제한값과 단위, 그리고 크기 말고도 있는 제한

설정을 비워 두면 Spring Boot 4.1.2의 MultipartProperties는 파일 하나를 1MB, 요청 전체를 10MB로 제한합니다. 디스크에 쓰기 시작하는 기준인 file-size-threshold의 기본값은 0이고 위치를 정하지 않으면 임시 디렉터리를 쓰므로, 기본 설정에서는 파일 부분이 메모리에 머물지 않고 임시 파일이 됩니다. 단위도 따져 볼 만합니다. Spring의 DataSize는 API 문서에 적힌 대로 2의 거듭제곱 접두사를 써서 1MB를 1,048,576바이트로 읽습니다. 그러니 설정 파일의 5MB는 5,242,880바이트이고, 다른 계층이 MB를 100만 바이트로 계산한다면 같은 숫자를 적어도 Boot 쪽 한도가 조금 더 큽니다.

내장 Tomcat에는 크기 말고도 멀티파트 요청을 거부하는 조건이 있습니다. 부분 수(maxPartCount, 기본 50개)와 부분 헤더 크기, 파라미터 수의 한도는 서블릿 요청 입력의 커넥터 한도 설명 그대로 적용되므로, 첨부 여러 개와 필드를 한 번에 보내는 폼이라면 파일 크기 한도보다 부분 수 한도에 먼저 걸릴 수 있습니다.

server.tomcat.max-swallow-size(기본 2MB)는 다른 종류의 값입니다. 크기 초과로 거부한 요청이라도 클라이언트가 본문을 계속 보내고 있으면, Tomcat은 응답이 클라이언트에 닿도록 남은 본문을 이 한도까지 읽어 버립니다. 남은 본문이 그보다 크면 Tomcat은 끝까지 읽지 않는데, Tomcat 커넥터 문서는 이때 클라이언트가 응답을 보지 못할 가능성이 크다고 설명합니다. 그래서 아주 큰 업로드를 거부하는 테스트에서는 오류 본문 대신 연결 종료가 관찰될 수 있고, 클라이언트가 거부 응답을 확실히 받아야 한다면 앞단 프록시의 한도와 함께 설계해야 합니다.


파일과 DB의 원자성

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

임시 키에 업로드하고 스캔과 체크섬을 마친 뒤, DB 레코드를 PENDING에서 READY로 바꾸는 상태 기계를 쓸 수 있습니다. 성공하기 전에는 다운로드 엔드포인트가 파일을 공개하지 않고, 실패하거나 만료된 임시 객체는 멱등한 정리 작업이 지웁니다.

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

사용자의 할당량은 선언된 크기가 아니라 실제로 저장된 바이트로 계산합니다. 같은 멱등성 키로 재시도할 때 파일이 중복 저장되지 않도록 체크섬과 소유 주체를 확인하며, 공개 URL에는 원본 파일 이름을 경로로 바로 연결하지 않습니다.


MockMultipartFile 검증

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import java.nio.charset.StandardCharsets;

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

import board.web.attachment.AttachmentController.AttachmentMetadata;
import board.web.attachment.AttachmentController.AttachmentResponse;
import board.web.RequestTraceFilter;
import board.web.auth.AuthWebMvcConfiguration;
import board.web.auth.SessionMemberReader;
import board.web.auth.SessionMemberReader.AuthenticatedMember;

@WebMvcTest(AttachmentController.class)
@Import({AuthWebMvcConfiguration.class, RequestTraceFilter.class})
class AttachmentControllerTest {
    @Autowired
    MockMvc mvc;

    @MockitoBean
    AttachmentService attachments;

    @Test
    void 익명_upload는_401_problem으로_끝나고_service를_호출하지_않는다()
            throws Exception {
        mvc.perform(multipart("/api/attachments")
                        .header(RequestTraceFilter.REQUEST_ID_HEADER,
                                "upload-auth-01"))
                .andExpect(status().isUnauthorized())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(header().string(
                        RequestTraceFilter.REQUEST_ID_HEADER,
                        "upload-auth-01"))
                .andExpect(jsonPath("$.status").value(401))
                .andExpect(jsonPath("$.code")
                        .value("authentication-required"));

        verifyNoInteractions(attachments);
    }

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

        var result = mvc.perform(multipart("/api/attachments")
                        .file(metadata)
                        .file(file)
                        .session(authenticated(41L)))
                .andExpect(status().isBadRequest())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(MethodArgumentNotValidException.class);
        var validation = (MethodArgumentNotValidException)
                result.getResolvedException();
        assertThat(validation.getBindingResult().getFieldErrors())
                .extracting(error -> error.getField())
                .containsExactlyInAnyOrder("postId", "caption");
        verifyNoInteractions(attachments);
    }

    @Test
    void JSON_part와_file이_유효하면_201과_저장된_메타데이터를_반환한다()
            throws Exception {
        when(attachments.store(anyLong(), any(), any())).thenReturn(
                new AttachmentResponse(91L, "image/png", 12L));
        var metadata = new MockMultipartFile(
                "metadata",
                "metadata.json",
                MediaType.APPLICATION_JSON_VALUE,
                "{\"postId\":41,\"caption\":\"실행 계획\"}"
                        .getBytes(StandardCharsets.UTF_8));
        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)
                        .session(authenticated(41L)))
                .andExpect(status().isCreated())
                .andExpect(header().string(
                        "Location", "/api/attachments/91"))
                .andExpect(jsonPath("$.id").value(91))
                .andExpect(jsonPath("$.mediaType").value("image/png"))
                .andExpect(jsonPath("$.size").value(12));

        var memberCaptor = ArgumentCaptor.forClass(Long.class);
        var metadataCaptor = ArgumentCaptor.forClass(
                AttachmentMetadata.class);
        var fileCaptor = ArgumentCaptor.forClass(MultipartFile.class);
        verify(attachments).store(
                memberCaptor.capture(),
                metadataCaptor.capture(), fileCaptor.capture());
        assertThat(memberCaptor.getValue()).isEqualTo(41L);
        assertThat(metadataCaptor.getValue().postId()).isEqualTo(41);
        assertThat(metadataCaptor.getValue().caption()).isEqualTo("실행 계획");
        assertThat(fileCaptor.getValue().getOriginalFilename())
                .isEqualTo("plan.png");
    }

    private MockHttpSession authenticated(long memberId) {
        var session = new MockHttpSession();
        session.setAttribute(
                SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
                new AuthenticatedMember(memberId));
        return session;
    }
}
multipart 검증 결과
metadata Content-Type = application/json
file Content-Type = image/png
anonymous upload = 401 authentication-required, service calls 0
metadata violations = [postId, caption]
attachment service calls = 0
HTTP status = 400
valid metadata + file -> 201 Location=/api/attachments/91
captured authenticated memberId = 41
bound max file size = 5MB
bound max request size = 6MB
inspected bytes preserved = true

정상 MVC 예제의 file 배열은 4바이트인데 응답의 size=12는 모의 AttachmentService가 돌려준 값입니다. 201과 Location, 회원 ID의 전달을 확인하는 검사와 실제 PNG 검사를 구분해야 합니다. UploadInspector를 호출했다면 같은 4바이트로는 8바이트의 PNG 시그니처를 확인할 수 없습니다. 별도의 검사기 예제는 12바이트를 실제 스트림에 넣어 복사와 크기, 다이제스트를 확인하지만, 그 12바이트도 이미지를 완전히 디코딩할 수 있는 파일은 아닙니다.

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


정리

  • 멀티파트 요청은 boundary로 나뉜 여러 부분이므로, 헤더는 클라이언트 라이브러리가 본문과 함께 만들게 둡니다.
  • @RequestPart로 JSON 메타데이터와 파일을 따로 받고, 소유자는 요청 필드가 아니라 서버가 검증한 주체에서 가져옵니다.
  • 원본 파일 이름과 선언된 Content-Type은 클라이언트가 정한 값이므로, 저장 키는 서버가 발급하고 실제 바이트로 형식을 확인합니다.
  • 프록시와 Boot, 애플리케이션의 크기 제한을 맞추고, 요청 전체의 제한은 파일 제한보다 조금 크게 둡니다.
  • 파일 저장과 DB 트랜잭션은 원자적이지 않으므로 임시 키와 상태 기계로 READY가 된 뒤에만 공개하고, 실패하면 정리합니다.

연습 문제

1. 같은 파일을 두 번 올리는 멱등 업로드

같은 게시글에 체크섬이 같은 파일을 두 번 올리면 기존 첨부 파일을 돌려주는 멱등 업로드를 설계하려고 합니다. 다른 회원이 올린 같은 파일은 공유할지 나눌지 개인정보 보호 기준을 정하고, DB 커밋 실패와 객체 저장소 타임아웃에서 고아 파일을 정리할 수 있는 상태인지 테스트해야 합니다. 어떤 키와 제약을 쓰나요?

해설 보기

콘텐츠 해시만 전역 키로 쓰면, 어떤 파일이 이미 있는지가 다른 회원에게 새어 나갈 수 있습니다. 소유자 범위 안에서만 중복을 제거하는 것을 기본으로 하고, (ownerId, postId, checksum) 유일성 제약으로 경쟁을 막습니다.

src/main/java/board/attachment/AttachmentFingerprint.java
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");
        }
    }
}

동시에 들어온 두 요청이 장벽(barrier) 뒤에서 함께 삽입하게 하고, 하나만 성공하는지 확인합니다. 실패한 요청은 유일성 충돌 뒤 기존의 준비된 레코드를 조회하며, 아직 대기 상태라면 202와 재시도할 위치를 돌려주는 정책을 둘 수 있습니다.

2. 앞부분과 나머지 복사의 실패 시점

서버 검사 입력은 올바른 8바이트 PNG 시그니처로 시작하는 24바이트 스트림이고, 선언된 타입은 image/png입니다. 바이트 한도는 20, 목적지는 비어 있는 임시 OutputStream이며, 첫 readNBytes(16)은 16바이트를 모두 돌려주고 다음 read(buffer)는 남은 8바이트를 한 번에 돌려준다고 가정합니다. 실패하면 목적지에는 몇 바이트가 남고 InspectedUpload가 반환되나요? 같은 첫 16바이트 뒤에 4바이트만 있는 별도의 20바이트 입력과도 비교하세요.

해설 보기

24바이트 입력은 앞부분 16바이트를 탐지와 다이제스트, 임시 쓰기에 넣은 뒤 copied=16이 됩니다. 남은 허용량은 20 − 16 = 4인데 다음에 읽은 8바이트가 더 크므로, 그 덩어리를 쓰기 전에 InvalidUploadException이 납니다. 임시 목적지에는 이미 쓴 16바이트가 남고 완료 결과는 반환되지 않으므로, 호출한 쪽이 그 임시 객체를 폐기해야 하며 성공한 파일로 공개하지 않습니다.

별도의 20바이트 입력은 나머지 4바이트가 허용량과 같으므로, 전체 20바이트를 복사하고 다이제스트에 넣은 뒤 크기 20의 결과를 돌려줍니다. 한도와 정확히 같은 크기는 허용됩니다. 기존 테스트의 limit=8, 입력 12바이트 경우는 앞부분 단계에서 거부되어 목적지가 0바이트였지만, 그렇다고 모든 실패에서 목적지가 비어 있다고 결론 내리면 안 됩니다. 이 계산은 정한 읽기 단위로 코드의 경로를 따라간 예상이며, 실제 저장소나 바이러스 검사, 다운로드 공개까지 실행한 결과는 아닙니다.

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