본문 바로가기
카테고리 없음

Spring Boot 파일 업로드 (Multipart, 로컬저장, S3)

by ricepuppy9733 2026. 6. 13.

파일 하나 올리는 데 컨트롤러, 서비스, 유틸, 인터페이스까지 네 군데를 건드려야 한다는 걸 처음 알았을 때 솔직히 당황했습니다. 단순히 @RequestBody@RequestPart로 바꾸면 끝나는 줄 알았는데, HTTP 요청 프로토콜 자체가 바뀐다는 사실을 몸으로 겪고 나서야 구조가 눈에 들어왔습니다. 이 글은 그 과정을 순서대로 정리한 기록입니다.

파일 업로드

Multipart와 @RequestPart — 파일 전송이 왜 달라지는가

일반적인 JSON 데이터를 주고받을 때는 Content-Typeapplication/json으로 고정됩니다. 그런데 클라이언트가 파일을 함께 보내는 순간, HTTP 요청의 Content-Typemultipart/form-data로 바뀝니다. 여기서 multipart/form-data란 하나의 HTTP 요청 안에 텍스트 데이터와 바이너리 파일을 구분된 파트(part)로 묶어서 전송하는 인코딩 방식입니다. 각 파트는 독립적인 헤더와 본문을 가지기 때문에, 서버 쪽에서 받는 방식도 달라질 수밖에 없습니다.

그래서 기존에 쓰던 @RequestBody 대신 @RequestPart를 써야 합니다. @RequestPart란 multipart 요청에서 특정 파트를 지정해 꺼내는 어노테이션으로, JSON 데이터 파트와 파일 파트를 각각 별도 매개변수로 받을 수 있게 해줍니다. 제가 직접 써봤는데, Postman에서 body를 form-data로 설정하고 JSON 파트의 Content-Type을 application/json으로 명시하지 않으면 DTO 매핑이 실패하는 경우가 많았습니다. 이 부분은 처음 할 때 꽤 많이 막히는 지점입니다.

컨트롤러 코드를 보면 구조가 명확합니다.

  • @RequestPart BoardDto.Upload dto — JSON 형태의 게시글 데이터(제목, 내용)를 받는 파트
  • @RequestPart List<MultipartFile> files — 실제 파일 목록을 받는 파트
  • throws SQLException, IOException — 파일 I/O 및 DB 작업 시 발생할 수 있는 예외를 선언적으로 처리

서비스 레이어에서는 게시글을 먼저 DB에 저장하고, 반환된 엔티티를 외래키(FK)로 활용해 이미지 정보를 별도 테이블에 저장합니다. 여기서 외래키(FK, Foreign Key)란 한 테이블의 컬럼이 다른 테이블의 기본키를 참조해 두 테이블 간 관계를 맺는 제약 조건입니다. BoardBoardImage@OneToMany@ManyToOne으로 연결된 것이 바로 이 구조입니다.

엔티티 설계 측면에서 보면, Board 하나에 BoardImage가 여러 개 붙는 일대다(One-to-Many) 관계입니다. 이 관계를 JPA 어노테이션으로 표현할 때 @JoinColumn(name = "board_idx")BoardImage 쪽에 선언해 외래키 컬럼을 명시합니다. 제 경험상 이 방향을 반대로 잡으면 불필요한 중간 테이블이 생기거나 업데이트 쿼리가 이중으로 나가는 문제가 생깁니다.

로컬 저장과 S3 업로드 — 인터페이스로 전환 비용을 줄이는 법

파일을 저장하는 방법은 크게 두 가지입니다. 개발 단계에서는 서버 로컬 디렉토리에, 운영 단계에서는 AWS S3 같은 오브젝트 스토리지에 올리는 방식입니다. 여기서 오브젝트 스토리지(Object Storage)란 파일을 계층 구조 없이 고유 키 값으로 저장·조회하는 방식으로, 전통적인 파일 시스템과 달리 HTTP를 통해 어디서든 접근할 수 있습니다. AWS S3가 대표적이고, 확장성 면에서 로컬 저장과 비교가 안 됩니다.

제가 이번에 특히 인상 깊었던 부분은 UploadService 인터페이스를 두고 LocalUploadServiceS3UploadService를 각각 구현체로 나눈 구조입니다. 이 방식의 핵심은 의존성 역전(DIP, Dependency Inversion Principle)에 있습니다. DIP란 상위 모듈이 하위 모듈의 구체적인 구현에 직접 의존하지 않고 추상화(인터페이스)에 의존하도록 설계하는 원칙입니다. 덕분에 BoardService는 로컬이든 S3든 신경 쓰지 않고 UploadService.upload()만 호출하면 되고, 저장 방식을 바꿀 때 서비스 코드를 건드리지 않아도 됩니다.

S3 업로드 구현체를 보면 @Value 어노테이션으로 application.yml에서 버킷 이름을 주입받고, S3Operations.upload()로 파일 스트림을 전달합니다. AWS SDK 의존성은 io.awspring.cloud:spring-cloud-aws-starter-s3:3.0.0을 사용했고, Spring Cloud BOM(Bill of Materials)으로 버전을 관리합니다. 여기서 BOM이란 여러 라이브러리의 버전을 한 곳에서 일괄 관리해 버전 충돌을 방지하는 Maven/Gradle의 의존성 관리 방식입니다.

로컬 저장 방식에서 한 가지 더 짚자면, FileUploadUtil.makeUploadPath()에서 날짜 기반 디렉토리 경로와 UUID를 조합해 저장 경로를 만드는 부분이 있습니다. 파일명 중복 방지 측면에서 UUID를 붙이는 것은 좋은 방식인데, 실제 운영 환경에서 로컬에 저장하면 서버가 여러 대일 때 파일이 분산되는 문제가 생깁니다. 그래서 결국 S3로 가야 하는 이유가 여기 있습니다.

Spring Boot 기반 파일 업로드 구현 시 체크할 핵심 포인트를 정리하면 다음과 같습니다.

  • Postman 테스트 시 body를 form-data로 설정하고 JSON 파트에 Content-Type application/json을 명시할 것
  • 게시글 저장 후 반환된 엔티티를 FK로 활용해 이미지 정보를 저장할 것
  • UploadService 인터페이스로 추상화해 로컬/S3 전환 시 서비스 코드 변경을 최소화할 것
  • application.yml에 AWS 자격증명(access-key, secret-key)과 버킷 이름을 분리 관리할 것

Spring Boot에서 파일 업로드를 다루는 Best Practice에 대해서는 Spring 공식 문서(출처: Spring Framework Docs)에서 @RequestPartMultipartFile 처리 방식을 자세히 확인할 수 있습니다. AWS S3 설정과 관련해서는 awspring 공식 문서(출처: AWS Spring Cloud Docs)를 참고하는 것이 가장 정확합니다.

이번 구현을 거치면서 "파일 저장 로직 하나에 이렇게 많은 레이어가 붙는구나"를 체감했습니다. 단순히 돌아가게 만드는 것과 나중에 S3로 교체하거나 저장 방식을 바꿀 때 코드를 최소한으로 건드릴 수 있게 설계하는 것은 전혀 다른 문제입니다. 처음부터 인터페이스 기반으로 분리해두는 습관이 나중에 얼마나 편한지, 이번에 직접 느꼈습니다. 다음 단계로 넘어간다면 파일 업로드 실패 시 트랜잭션 롤백 처리 방식도 함께 고민해보시길 권합니다.


참고: https://dltldnr2563.tistory.com/entry/%EC%BD%94%EB%94%A9%EA%B3%B5%EB%B6%8020250811-%ED%9A%8C%EC%9B%90-%EC%A2%8B%EC%95%84%EC%9A%94-%ED%94%BC%EB%93%9C-%EA%B8%B0%EB%8A%A5%EA%B5%AC%ED%98%84-%EC%9D%B4%EB%AF%B8%EC%A7%80%EC%97%85%EB%A1%9C%EB%93%9CAWS


소개 및 문의 · 개인정보처리방침 · 면책조항

© 2026 자동식단생성 연관 블로그