본문으로 건너뛰기

안동민 개발노트

본문 시작

멀티파트 파일 업로드

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

파일 업로드 요청은 JSON 한 덩어리가 아니라 경계로 구분된 여러 부분입니다.

각 부분은 이름, 헤더, 본문을 갖고 텍스트 필드와 바이너리 파일이 한 요청에 섞입니다.

컨트롤러가 MultipartFile을 받았다는 사실은 파일이 안전하거나 영구 저장됐다는 뜻이 아닙니다.

요청 크기, 부분 이름, 내용, 소유권, 저장 완료를 단계별로 확인해야 합니다.

멀티파트 업로드는 경계 해석 뒤 실제 바이트를 검증한다

MULTIPART · TRUST BOUNDARY · STAGED COMMIT

멀티파트 업로드는 경계 해석 뒤 실제 바이트를 검증한다

boundary가 JSON과 파일 부분을 복원한 뒤에도 파일명과 선언 Content-Type은 신뢰하지 않는다. 실제 스트림 크기·형식·digest를 검사하고 임시 객체를 스캔한 뒤에만 READY 다운로드를 연다.

멀티파트 업로드는 경계 해석 뒤 실제 바이트를 검증한다 멀티파트 요청이 boundary와 요청 크기 검사를 지나 JSON metadata와 file part로 나뉘고, 서버가 실제 바이트 형식·크기·SHA-256을 검증해 임시 객체에 온전히 복사한다. 스캔 성공 뒤 READY가 되어 다운로드되며, 불일치나 제한 초과는 REJECTED로 끝난다. REQUEST INTAKE · UNTRUSTED CLIENT DATA MULTIPART BODY metadata + file boundary 포함 FRAMING GATE boundary · request limit resolver가 part 복원 @REQUESTPART JSON DTO + MultipartFile 이름·media type 계약 STREAM INSPECTOR 실제 bytes · type · size SHA-256 + full copy prefix도 저장에서 보존 UPLOADING 임시 object key 아직 다운로드 금지 SCANNING checksum · malware DB 상태도 공개 전 대기 READY 불변 key + metadata 다운로드 허용 REJECTED type mismatch · too large 임시 목적지 폐기 LEGEND 검증된 저장 경로 공개 전 거부·정리
  1. MULTIPART BODY

    boundary가 JSON metadata와 file part를 구분한다

    클라이언트 라이브러리가 본문과 Content-Type 경계를 함께 만들게 합니다.

  2. FRAMING GATE

    요청 전체 제한과 part 이름을 먼저 확인한다

    경계를 복원하지 못한 요청은 컨트롤러에 도달하지 않습니다.

  3. @REQUESTPART

    JSON은 검증 DTO, 파일은 MultipartFile로 분리된다

    metadata의 미디어 타입이 application/json이어야 해당 converter가 선택됩니다.

  4. STREAM INSPECTOR

    실제 바이트 형식·크기·SHA-256을 한 번에 계산한다

    탐지에 읽은 prefix도 임시 목적지에 써서 원본을 온전히 보존합니다.

  5. UPLOADING

    검증된 스트림을 추측 불가능한 임시 key에 둔다

    클라이언트 파일명은 저장 key로 사용하지 않습니다.

  6. SCANNING

    checksum과 콘텐츠 안전성 검사를 완료한다

    검사 중 상태는 다운로드 엔드포인트가 거부합니다.

  7. READY OR REJECTED

    성공만 불변 key로 공개하고 실패는 정리한다

    파일과 DB가 한 트랜잭션이 아니므로 상태 전이와 멱등 정리가 원자성 경계를 대신합니다.

MultipartFile은 안전성 판정이 아니라 파싱 결과다. 공개 가능성은 서버가 계산한 바이트 증거와 READY 상태가 함께 확정한다.


멀티파트 경계

클라이언트가 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/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는 multipart 필드에서 받지 않고 로그인 인터셉터와 인자 리졸버가 검증한 서버 주체에서만 전달합니다.

클라이언트가 메타데이터 부분의 Content-Type을 application/json으로 보내야 JSON 컨버터가 선택됩니다.

일반 텍스트 필드로 보냈다면 @ModelAttribute 폼 구조가 더 적합할 수 있습니다.

엔드포인트 계약에서 부분 이름과 미디어 타입을 API 스키마에 명시합니다.


파일명·Content-Type 신뢰

getOriginalFilename()은 클라이언트가 정한 문자열입니다.

../../app.properties, 예약된 장치 이름, Unicode 혼동 문자가 들어올 수 있습니다.

저장 키로 사용하지 않고 서버가 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 헤더만 비교하는 것보다 실제 바이트를 보는 경계가 필요하다는 점을 보여 줍니다.

예제는 탐지에 읽은 접두사를 먼저 임시 목적지에 쓰고 나머지를 같은 digest와 바이트 제한 아래 복사합니다. 검사 때문에 앞부분을 버리지 않으며, 제한 초과나 검사 실패 시 호출자가 임시 목적지를 폐기합니다.

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();
    }
}

계층별 크기 제한

장 전체 실행 프로젝트의 운영 설정은 ch8-2가 유일하게 소유하는 src/main/resources/application.properties에 둡니다. 이 문서의 별도 fixture는 업로드 제한 바인딩을 독립적으로 증명하고 테스트가 그 경로를 명시적으로 불러옵니다.

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을 조정합니다.

업로드 진행 중 연결 종료와 타임아웃도 메트릭으로 구분합니다.


파일·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

실제 콘텐츠 검사 테스트는 UploadInspector에 바이트 스트림을 직접 넣고 선언된 PNG와 실제 PDF 바이트의 불일치를 확인합니다.

큰 파일 테스트에서 수백 MB 배열을 만들지 말고 크기만 보고하는 테스트 대역이나 제한된 스트림으로 제한 분기를 실행합니다.


연습 문제

동일 게시글에 같은 체크섬 파일을 두 번 올리면 기존 첨부 파일을 반환하는 멱등 업로드를 설계하세요.

다른 회원의 같은 파일은 공유할지 분리할지 개인정보 보호 기준을 정하고, 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");
        }
    }
}

두 동시 요청을 장벽 뒤에서 삽입하게 하고 하나만 성공하는지 확인합니다.

실패 요청은 고유 충돌 후 기존 준비 레코드를 조회하며, 아직 대기 상태이면 202와 재시도 위치를 반환하는 정책을 둘 수 있습니다.

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