멀티파트 파일 업로드
게시판 첨부 업로드의 멀티파트/폼-데이터 프레이밍, 크기 제한, 파일명·미디어 타입 신뢰 경계와 임시 저장·커밋 원자성을 실제 요청으로 확인합니다.
파일 업로드 요청은 JSON 한 덩어리가 아니라 경계로 구분된 여러 부분입니다.
각 부분은 이름, 헤더, 본문을 갖고 텍스트 필드와 바이너리 파일이 한 요청에 섞입니다.
컨트롤러가 MultipartFile을 받았다는 사실은 파일이 안전하거나 영구 저장됐다는 뜻이 아닙니다.
요청 크기, 부분 이름, 내용, 소유권, 저장 완료를 단계별로 확인해야 합니다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
업로드 MVC와 실제 바이트 검사를 따로 검증한다
인증·metadata 검증·201 응답은 MVC slice에서 확인합니다. 별도 UploadInspector 단위 테스트가 실제 스트림의 형식·크기·SHA-256과 복사를 검증합니다. 저장·스캔·READY 공개는 추가 설계입니다.
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을 확인한다. 두 검사 및 추가 저장 상태 설계를 구분한다.
멀티파트 경계
클라이언트가 Content-Type: multipart/form-data만 직접 쓰고 경계 파라미터를 빠뜨리면 서버는 부분 경계를 찾을 수 없습니다.
브라우저 FormData나 HTTP 클라이언트 라이브러리가 본문을 만들 때 헤더도 함께 만들게 둡니다.
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를 적용해도 파일 콘텐츠 검증은 별도 서비스가 수행합니다.
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·실제 바이트 수 | 서버 스트림 | 무결성·할당량·소유자 범위 중복 판단 |
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와 바이트 제한 아래 복사합니다. 검사 때문에 앞부분을 버리지 않으며, 제한 초과나 검사 실패 시 호출자가 임시 목적지를 폐기합니다.
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는 업로드 제한 바인딩을 독립적으로 증명하고 테스트가 그 경로를 명시적으로 불러옵니다.
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MBpackage 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 {
}
}리버스 프록시가 10 MB, Boot가 5 MB, 애플리케이션이 4 MiB처럼 서로 다른 값을 쓰면 어느 층에서 어떤 응답이 나는지 혼란스럽습니다.
메타데이터와 프레이밍 부가 비용을 포함한 요청 제한은 파일 제한보다 조금 커야 합니다.
단위 MB와 MiB 차이도 문서화합니다.
컨테이너가 먼저 거부한 요청은 컨트롤러 어드바이스까지 도달하지 않을 수 있습니다.
프록시 413, 멀티파트 리졸버 예외, 애플리케이션 422를 클라이언트가 일관되게 처리하도록 게이트웨이 오류 본문과 애플리케이션 ProblemDetail을 조정합니다.
업로드 진행 중 연결 종료와 타임아웃도 메트릭으로 구분합니다.
파일·DB 원자성
파일 시스템 또는 객체 저장소 쓰기와 관계형 DB 트랜잭션은 기본적으로 하나의 원자적 트랜잭션이 아닙니다.
파일을 먼저 쓰고 DB 삽입이 실패하면 고아 파일이 남고, DB를 먼저 커밋한 뒤 업로드가 실패하면 깨진 링크가 남습니다.
임시 키에 업로드하고 스캔과 체크섬을 완료한 뒤 DB 레코드를 PENDING에서 READY로 전환하는 상태 기계를 사용할 수 있습니다.
성공 전 다운로드 엔드포인트는 파일을 공개하지 않습니다.
실패·만료된 임시 객체는 멱등 정리 작업이 제거합니다.
| 상태 | 객체 저장소 | DB | 다운로드 |
|---|---|---|---|
UPLOADING | 임시 키 | 대기 행 | 거부 |
SCANNING | 체크섬 완료 | 스캔 대기 | 거부 |
READY | 불변 키 | 미디어 메타데이터 | 허용 |
REJECTED | 격리·삭제 | 실패 코드 | 거부 |
사용자 할당량은 선언된 크기가 아니라 실제 저장 바이트로 계산합니다.
같은 멱등성 키 재시도에서 파일을 중복 저장하지 않게 체크섬과 책임 주체를 확인합니다.
공개 URL에는 원본 파일명을 직접 경로로 연결하지 않습니다.
MockMultipartFile 검증
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;
}
}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 fixture의 file 배열은 4바이트인데 응답 size=12는 mock AttachmentService가 돌려준 값입니다. 201·Location·회원 ID 전달 검사와 실제 PNG 검사를 구분하세요. UploadInspector를 호출했다면 같은 4바이트만으로는 8바이트 PNG 시그니처를 확인할 수 없습니다. 별도 inspector fixture는 12바이트를 실제 스트림에 넣어 복사·크기·digest를 검사합니다. 그 12바이트도 완전한 이미지 디코딩 성공을 증명하는 파일은 아닙니다.
실제 콘텐츠 검사 테스트는 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와 재시도 위치를 반환하는 정책을 둘 수 있습니다.
독립 확인
서버 검사 입력은 올바른 8바이트 PNG 시그니처로 시작하는 24바이트 스트림이고 선언 타입은 image/png입니다. 바이트 한도는 20, 목적지는 빈 임시 OutputStream이며 첫 readNBytes(16)은 16바이트를 모두 반환하고 다음 read(buffer)는 남은 8바이트를 한 번에 반환한다고 가정합니다. 실패 시 목적지에는 몇 바이트가 남고 InspectedUpload가 반환되나요? 같은 첫 16바이트 뒤에 4바이트만 있는 별도 20바이트 입력도 비교하세요.
접두사와 나머지 복사의 실패 시점을 읽는 풀이
24바이트 입력은 접두사 16을 탐지·digest·임시 쓰기에 포함한 뒤 copied=16입니다. 남은 허용량은 20−16=4인데 다음 읽기 8이 더 커서 그 덩어리를 쓰기 전에 InvalidUploadException입니다. 임시 목적지에는 이미 쓴 16바이트가 남고 완료 결과는 반환되지 않습니다. 호출자가 그 임시 객체를 폐기해야 하며 성공 파일로 공개하지 않습니다.
별도 20바이트 입력은 나머지 4가 허용량과 같아 전체 20을 복사·digest한 뒤 결과 크기 20을 반환합니다. 정확한 한도는 허용됩니다. 원 limit=8/입력12 테스트는 접두사 단계에서 거부해 0바이트였으므로 모든 실패에서 목적지가 항상 비어 있다는 결론은 잘못입니다. 계산은 명시한 읽기 단위의 원 코드 경로를 추적한 예상이며 실제 저장소·바이러스 검사·다운로드 공개까지 실행한 결과는 아닙니다.
다음 문서에서는 지금까지의 로그인, 세션, 필터, 리졸버, 검증, 업로드를 하나의 게시판 요청 흐름으로 통합하고 운영 신호까지 검증합니다.