-
✒️ 0. 들어가기 전 : 간단한 CRUD도 고민할 게 너무 많다.
-
✒️ 1. S3를 이용한 이미지 등록
-
💡 이미지 저장을 어디에 해야 할까?
-
💡 아무래도 데이터베이스에 저장하는 게 맞지
-
💡 다른 방법은 없을까?
-
💡 S3는 무엇인가
-
💡 S3를 왜 사용하는가
-
✒️ 2. 백엔드에서 고려해야 할 이미지 업로드
-
💡 통합 DTO 방식
-
💡 DTO와 MultipartFile 분리
-
💡 게시글과 이미지를 한 번에 처리할 때의 단점
-
💡 이미지 업로드 API 분리
-
💡 어차피 똑같은 거 아니야?
-
💡 다른 기존 서비스들은 어떻게 적용할까?
-
💡 더욱 더 효율적으로
-
💡 프런트엔드에서의 처리
-
✒️ 3. 백엔드 구현 방법
-
💡 고민 지점
-
✒️ 4. 전체 코드
✒️ 0. 들어가기 전 : 간단한 CRUD도 고민할 게 너무 많다.
"개발"을 잘한다는 것은 무엇일까?
우리는 코더가 아닌 개발자이다.
누군가는 무슨 최신 기술을, 누군가는 어떤 것까지 할 수 있다고 말한다.
과연 그 기술을 어디까지 알며, 왜 사용했고, 어떤 로직으로 굴러가지는 설명할 수 있을까?
되묻고 싶다.
왜 여기서는 RequestParam을 썼고, 여기는 왜 PathVariable로 썼고,
에러처리는 어떻게 했고, RestControllerAdvice를 사용했는데 그 원리는 무엇이고,
시큐리티와 JWT는 왜 썼고, 세션과 쿠키와 토큰의 차이는 무엇이며..
내 코드의 성능은 얼마나 좋을까? 더 개선할 수는 없을까?
..................
....
나는 CRUD 하나만 해도 충분히 고민할 게 넘쳐난다고 생각한다..
그래서 가끔 간단한 기술과 간단한 기능 가지고도 내가 깊게 고민한 부분에 대해서 글을 쓸 예정이다.
✒️ 1. S3를 이용한 이미지 등록
💡 이미지 저장을 어디에 해야 할까?
우리는 웹 애플리케이션에서 사용자가 업로드한 사진과, 다양한 이미지와 파일을 관리해야 한다.
사용자들은 사진을 올리고, 이 사진들을 보고, 다운로드하며, 때로는 이미지를 수정하고 삭제하길 원한다.
이제, 이러한 이미지와 파일을 저장하고 관리할 때의 도전 과제를 고려해보자.
서버에서 이미지를 저장할 수 있는 공간을 크게 3가지로 분류해보자.
1: 코드에 직접 저장

무식한 방법일 수 있겠지만, 실제 이미지 자체를 코드에 정적으로 넣을 수 있다.
개인적으로 로컬에서만 작동시킬 목적으로 확장 프로그램 하나를 만든 적이 있었는데,
이때 아이콘과 같은 작은 이미지는 그냥 코드에 직접 적어 넣었다.
이미지를 BASE64 인코딩 시키고 그냥 코드에 넣을 것이다.
https://products.aspose.app/imaging/ko/conversion/image-to-base64

접근 속도가 매우 빠르고, 이미지가 프로그램에 포함되어 있어 별도의 리소스 관리가 필요 없다는 장점이 있지만,
이미지가 바이너리에 포함되어 프로그램 크기가 커지고, 유지보수의 어려움이 있다.
단순히 아이콘 정도 개인 프로그램에 넣는 사유가 아니라면,
실제 배포 서비스의 게시글 이미지 로직에서 코드에 직접 이미지를 넣는 것은 상상하기 어려운 일이다.
2: 로컬 스토리지(파일)에 저장

전통적으로, 이미지와 파일을 저장하기 위해서는 서버의 파일 시스템을 사용할 수 있다.
아주 작고 간단한 프로젝트에서는 적용할 수 있겠지만, 매우 비효율 적일 것이다.
이는 서버의 저장 용량에 제한을 두고, 데이터 백업과 복구를 복잡하게 만들며, 확장성 문제를 초래할 수 있다.
예를 들어, 사용자가 수백, 수천 개의 이미지를 업로드하면, 서버의 저장 용량이 금방 부족해질 수 있다.
💡 아무래도 데이터베이스에 저장하는 게 맞지
3: 데이터베이스에 저장

백엔드 개발자로써 데이터를 저장할 때 1차적으로 데이터베이스에 저장하는 방식을 떠올릴 것이다.
일단 Storage보다는 DB에 저장하는 것이 좋은 이유는 이미지까지 트랜잭션에 포함시킬 수 있다는 장점이 있다.
그렇다면 이미지를 DB에 저장해야 한다면, 이미지가 저장되는 테이블에서 칼럼의 데이터 타입이 무엇일까???
바로 [BLOB] 타입이다.
BLOB 타입은 이진 데이터(Binary Object)를 저장하는 데 사용되는 데이터 타입이다.
주로 이미지, 오디오, 비디오 등을 저장하며 다음과 같이 파일을 직접 데이터에 삽입해서 쓴다.
INSERT INTO `kundol`
(`img`)
VALUES (LOAD_FILE('C:/Users/kundol/Desktop/dev/a.png'))
그런데 이 타입을 사용하여 실제 DB에 이미지를 저장하지 않는 이유는 무엇일까????
왜 실제로는 blob을 많이 쓰지 않을까?
첫번째로는 성능문제이다.
데이터베이스에 큰 이진 파일(예: 이미지)을 저장하면 데이터베이스의 성능이 저하될 수 있다.
사진을 데이터베이스에 저장 시 이를 바이너리로 바꾸고 저장을 하고,
조회 시 바이너리를 다시 사진으로 바꾸는 과정에서 병목이 생길 것이다.
이렇듯 처리하고 관리하는데도 시간이 너무 오래 걸린다.
만약 백업을 한다고 가정할 때 데이터베이스 백업 및 복구 시간도 증가하게 된다.
또한 보안적인 문제도 있다.
데이터베이스에 이미지를 저장하면, 이 이미지에 대한 접근을 제어하고 관리하는 것이 더 복잡해진다.
이는 보안 정책을 적용하고 유지하기 어렵게 만들 수 있다.
💡 다른 방법은 없을까?
4: 스토리지에 저장 후, DB에서 참조

다음과 같은 방식을 상상해보자.
실제 코드에서는 이미지 처리 로직을 담당한다.
예를 들어, 이미지에 관한 CRUD와 더불어 리사이징, 메타데이터 추출 등의 작업을 수행할 수 있다.
그리고 실제 이미지는 Storage (스토리지)에 저장한다.
정적 리소스를 저장해두고, 필요할 때 꺼내주면 된다.
대용량 이미지 파일을 효율적으로 저장하고 관리하는 시스템이면 더욱 좋다.
아까는 로컬의 파일 시스템을 들었지만, 단점이 존재했다. (이후 다시 고민해보자.)
그리고 이미지 관련 메타데이터는 데이터베이스에 저장하는 것이다.
이미지의 파일명, 경로 등과 같은 참조할 수 있는 정보를 DB에 저장하면 된다.
>> 자 그러면 해결이 된 듯하다.
특정 디렉토리에 파일을 저장해두고 이를 호스팅 하도록 하면 접근이 가능하다.
아래는 실제 네이버 홈페이지의 요청/응답 들이다.

요청들을 살펴보면 .jpg등과 같은 이미지를 요청 한 것이 보이고,
눌러서 상세 정보를 보면 GET 요청으로 정적 리소스를 요청하는 것을 확인 가능하다!!
배포시에 EC2에 파일을 저장하고, DB는 참조하고!
그렇다면 모든 것이 해결 된 것인가?
💡 S3는 무엇인가
AWS S3 등장
여기서 S3의 필요성이 등장한다.
우리는 CI/CD, 즉 배포 환경 또한 고려해야 한다.
새 서버 인스턴스가 생성되고 기존 인스턴스가 제거되는 과정에서 로컬에 저장된 파일들이 손실될 수 있다.
서버와 스토리지의 분리가 필요하다.
또한, 서버 인스턴스가 변경되어도 파일 데이터는 유지되어야 한다.
(BLUE/GREEN 무중단 배포)
EC2에 직접 파일을 저장하는 방식은 이러한 관리 작업이 복잡해질 수 있다.
파일에 대한 접근 제어와 암호화 등의 보안 기능 또한 필요하다.
여러가지 이유에서 우리는 파일 저장만 전문적으로 해주는 클라우딩 서비스가 필요하다는 것을 느꼈다.
먼저 S3는 AWS에서 제공해주는 파일을 업로드 하고 다운로드 하는 등의 스토리지 역할에 특화된 서비스이다.
S3는 "Simple Storage Service"의 약자로,
Amazon Web Services(AWS)에서 제공하는 클라우드 기반의 객체 저장 서비스이다.
S3는 무제한으로 데이터를 저장할 수 있으며, 데이터의 내구성, 가용성, 보안을 보장한다.
그리고 이 S3는 파일을 업로드 해주고, URL을 Return 해준다.

S3를 사용하여 이미지를 올리고 Return 받은 이미지 URL을 DB를 저장한다면, 지금까지의 고민이 다소 해결된 듯하다.
스토리지 : 스토리지 >> EC2 클라우드 서버 내의 스토리지 >> 클라우드 스토리지(Amazon S3)
DB : 이미지의 메타데이터 (경로명 등) >> [varchar] Return 받은 이미지 URL
💡 S3를 왜 사용하는가
그렇다면 왜 꼭 S3인가?
AWS S3는 자동으로 데이터를 분산 저장하여, 사용자가 저장할 데이터의 양에 관계없이 스케일을 조절할 수 있다.
백엔드는 더 이상 저장 용량 문제로 고민할 필요가 없다는 뜻이다.
S3는 99.999999999%의 내구성을 제공하며, 데이터는 여러 데이터 센터에 자동으로 복제된다.
내구성에 이어서 안정성, 비용 효율성, 보안과 접근 제어, 통합성과 유연성 등의 장점도 있다.
완전 Deep 하게 S3에 대해 다루는 포스트가 아니라 일단 배제하겠다.
✒️ 2. 백엔드에서 고려해야 할 이미지 업로드
웹 애플리케이션을 개발하면서, 이미지 업로드와 관련된 문제는 항상 중요한 고려 사항이다.
이미지 업로드는 사용자 경험의 핵심 요소로, 올바르게 처리되지 않으면 애플리케이션의 성능과 사용성이 크게 영향을 받을 수 있다.
사실 다양한 방법이 존재하며, 각 방법은 장단점이 있고, 요구사항에 따라서 구현 방법이 달라질 수 있다는 점도 고려해야한다.
우리는 백엔드 서버 개발자이므로, 일단 백엔드를 통한 이미지 처리 방식에 대해서 이야기 하려고 한다.
💡 통합 DTO 방식
아주 간단히 생각해보자.
처음에는 모든 것을 하나의 DTO로 처리하는 방식이 떠오른다.
아주 간단한 방법이다.
즉, BoardRequestDTO에 모든 필드, 이미지 파일, 게시글 내용 등을 담아 전달하는 것이다.
이 방법은 구조가 간단해 보이고, 요청과 응답이 하나의 DTO로 이루어지기 때문에 직관적이다.
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<BoardResponseDTO.BoardDTO>> createBoard(
@ModelAttribute BoardRequestDTO.CreateBoardDTO createBoardDTO) {
@Getter
public static class CreateBoardDTO {
@NotNull(message = "제목은 필수입니다.")
@Size(max = 30, message = "제목은 30자를 초과할 수 없습니다.")
private String title;
@NotNull(message = "내용은 필수입니다.")
private String content;
@NotNull(message = "카테고리는 필수입니다.")
private Category category;
List<MultipartFile> images;
}
하지만 테스트 시 문제가 발생할 수 있다.
이미지를 포함하는 요청은 multipart/form-data 형식을 사용한다.
Postman이나 Swagger에서 이미지 파일을 올리는 것에 문제가 발생했다.
(사실 이것은 큰 문제가 아니다. 이후에 다시 설명하겠다.)
가장 중요한 이미지 파일과 관련된 정보를 포함한 큰 DTO 객체는 복잡한 요청 처리를 요구한다는 점이다.
특히 @Valid와 같은 검증 어노테이션이 파일 관련 검증에 적합하지 않기 때문에, 별도의 검증 로직이 필요하다.
DTO 구조가 복잡하면, 변경이 필요할 때 해당 DTO를 사용하는 모든 코드와 로직을 수정해야 한다.
이는 유지보수를 어렵게 하고, 시스템의 확장성을 제한할 수 있다.
💡 DTO와 MultipartFile 분리
BoardRequestDTO와 List<MultipartFile>을 분리하여 처리하는 방법을 고려해보았다.
다음과 같은 장점은 있다.
1. 단순한 DTO 형식
게시글 정보와 이미지 파일을 별도의 요청으로 처리하면, DTO는 게시글 정보만 담으면 된다.
이는 DTO의 구조를 단순하게 유지하고, 요청 처리 및 검증을 용이하게 만든다.
2. 파일 업로드 독립성
파일 업로드 로직과 게시글 정보 처리 로직을 분리하면, 각 부분을 독립적으로 처리할 수 있어 코드의 가독성과 유지보수성이 향상된다.
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<BoardResponseDTO.BoardDTO>> createBoard(
@Valid @RequestPart("boardDto") BoardRequestDTO.CreateBoardDTO createBoardDTO,
@RequestPart(value = "images", required = false) List<MultipartFile> images) {
@Getter
public static class CreateBoardDTO {
@NotNull(message = "제목은 필수입니다.")
@Size(max = 30, message = "제목은 30자를 초과할 수 없습니다.")
private String title;
@NotNull(message = "내용은 필수입니다.")
private String content;
@NotNull(message = "카테고리는 필수입니다.")
private Category category;
}
그러나 그나마 이미지를 분리해서 받았으나
통합 DTO와 마찬가지로, 효율성 측면에서 전혀 개선이 안되고 있다.
참고로, 이러한 구현에서 " application/octet-stream " 에러가 발생한다면 다음 글을 참고해보자.
[Spring] Swagger + RequestPart를 통해 파일, Dto 동시 요청 시 발생 에러 핸들링
현재 Spring Boot를 통한 비디오 스트리밍 서버를 개발하는 간단한 프로젝트를 진행 해보고 있는데, 이를 개발하면서 마주한 에러에 대해 소개하고 이를 해결한 방식, 그리고 어째서 해결이 되는지
one-armed-boy.tistory.com
💡 게시글과 이미지를 한 번에 처리할 때의 단점
두 방법 다 어찌됐든 게시글과 이미지를 하나의 API에서 하나의 서비스로 처리한다는 것이다.
실제 프런트와 연결하여 테스트를 해보면, "작성"을 누르고 약 2,3초 가량의 대기시간이 걸린다.

따라서, 이미지 업로드를 보다 효율적으로 처리하고자 한다면,
파일 업로드와 데이터 처리를 별도의 API로 분리하는 방법을 고려하는 것이 좋다.
결국에는 이미지 파일을 처리하는 과정에서 네트워크 대역폭과 서버의 리소스를 추가로 소모하게 된다는 것.
이는 전체적인 시스템의 성능 저하로 이어질 수 있다!
💡 이미지 업로드 API 분리
사실 이미 코드상에서도 문제를 들 수 있다.
게시글 작성 API에서 이미지 파일을 포함시키면,
파일의 크기나 타입, 파일 업로드의 성공 여부 등 다양한 요소를 한 번의 요청에서 처리해야 한다.
이는 코드의 복잡성을 증가시킨다.
우리는 되도록이면 비지니스 로직을 분리 시키는 것을 추구한다.
뭐니뭐니 해도 가장 큰 이유는 성능 저하이다.
게시글 작성과 이미지 업로드를 동시에 처리하면 성능 저하가 발생할 수 있다.
특히 대용량 이미지 파일이 포함될 경우, 요청의 응답 시간이 길어질 수 있다.
실패 처리의 어려움도 있다.
이미지 업로드가 실패와 게시글 작성도 실패를 구분 할 때, 실패 원인을 찾기 어려울 수 있다.
이러한 이유로 이미지 업로드 API 분리하는 것을 추천한다.
💡 어차피 똑같은 거 아니야?
조삼모사라고 생각할 수도 있다.
허나 가장 큰 이유는 사용자 경험 개선이다.
이미지 업로드 작업은 시간이 오래 걸린다.
분리된 API로 이미지를 먼저 업로드하면, 게시글 작성 API의 응답 시간을 줄일 수 있다.
또한, 이미지 업로드 진행 상태를 실시간으로 표시 가능하며,
사용자 입장에서 가장 큰 이점은 이미지 업로드 중 다른 작업 가능하다는 것이다. (게시글 내용 작성 등)
이후 게시글 작성 API는 매우 짧은 시간에 응답되는 것을 확인 할 수 있다.
또한 코드 관점에서도 좋은 점이 있다
첫 번째로 에러 처리면에서 좋다.
이미지 업로드 실패 시, 게시글/댓글 작성에 영향을 주지 않고 독립적으로 처리할 수 있으며,
사용자에게 더 명확한 피드백을 제공할 수 있다.
두 번째로, 재사용성 및 유연성 관점이다.
이미지 업로드 API를 다른 기능(예: 프로필 사진 변경)에서도 재사용할 수 있다.
게시글/댓글 수정 시 이미지만 변경하는 경우에도 효율적으로 처리할 수 있다.
여담으로 이번 포스팅에서는 생략되어 있지만,
대용량 이미지 처리를 하면 CDN과 리사이징을 활용한 이미지 로드 시간 축소 등 추후 이미지 처리를 추가해야 한다.
이러한 기능 추가의 확장성 측면에서도 분리해 놓는 것이 이득이다.
💡 다른 기존 서비스들은 어떻게 적용할까?
코딩하다가 "이게 좋나? 저게 좋나?" 라는 고민이 들면
나는 실무자에게 물어보거나 기존 서비들은 어떻게 적용하는 지 찾아보는 편이다.
당근마켓의 커뮤니티도 이미지 업로드 호출이 따로 이루어지는 것을 볼 수 있다.
💡 더욱 더 효율적으로
어떻게 하면 더 효율적으로 처리할 수 있을까??
이미지 파일을 압축하여 업로드하는 것도 고려할 수 있다.
프런트엔드에서 이미지 파일을 압축하여 이를 보내는 것이다.
이는 전송 데이터의 크기를 줄이고, 네트워크 대역폭을 절약하는 데 도움이 된다.
허나 이 또한 한 번에 Back-End 서버에 전송하는 방식으로는
이미지 업로드의 지연시간 개선에 한계가 있다.
💡 프런트엔드에서의 처리
사용자가 이미지 등록 시 FE에서 S3에 직접 저장하면 안되나?
프론트엔드에서 직접 S3에 이미지를 업로드하고 URL을 백엔드에 전달하는 방법도 고려해볼 수 있다.
사실 가장 효율적이며, 프론트엔드와 백엔드의 책임을 명확히 분리할 수 있다.
프런트엔드에서 직접 처리할 수 있으면 굳이 서버를 안거지는 편이 제일 좋다는 것이다.
이렇게 되면 백엔드 서버의 부하를 줄이고, 이미지 업로드의 처리 속도를 확실히 높일 수 있다.
클라이언트가 직접 S3와 통신하므로, 이미지 업로드가 더 빠르고 원활하게 진행된다.
허나, 이는 보안상의 문제가 있다.
S3 접근 키를 프론트엔드에 노출시킬 수 없다. 이는 심각한 보안 위험을 초래한다.
이 문제를 해결하기 위해 Presigned URL을 사용해야 한다.
Presigned URL은 제한된 시간 동안만 유효한 특별한 URL로, 클라이언트가 직접 S3에 안전하게 업로드할 수 있게 한다.
(현재 프런트엔드에서 S3를 처리하는 방법에 대해 다루는 것이 아니므로 간단히 말하고 패스하겠다.)
- 아래 링크 참고
과연 이 방법은 "무조건" 좋을까?
Presigned URL 생성 및 관리 로직을 구현해야 하며, URL의 만료 시간, 권한 설정 등을 관리해야 한다.
또 업로드된 파일의 유효성, 크기, 타입 등을 서버에서 다시 확인해야 할 수도 있다.
파일 업로드와 관련된 비즈니스 로직(예: 특정 조건에 따른 파일명 변경)을 프론트엔드에서 처리해야 할 수 있어,
백엔드와 프론트엔드 간 로직 일관성 유지가 어려워질 수 있다...
완벽하게 "무엇이 무조건적으로 좋다"라는 것은 없다.
항상 모든 방법을 선택할 때는 장단점이 있다.
모든 것은 TRADE-OFF
✒️ 3. 백엔드 구현 방법
일단 우리는 서버 개발자이므로,
서버를 거친다고 했을 때 이미지 API를 분리하여 구현한다고 하면 어떻게 구현할 수 있을까?
💡 고민 지점
1. 이미지 업로드 시점에 게시글은 없다.
일단, 게시글에서 이미지 파일을 업로드 한다고 치자.
게시물을 작성하며 이미지를 첨부하는 시점에는, 게시물 관련 엔티티가 없다.
왜? 아직 게시물을 안 만들었으니까.
간단히 해결해보자.
1. Board에서의 BoardImg필드에서 orphanRemoval = true 옵션을 제거해준다.
2. 이미지 첨부 시점에 BoardImg 엔티티 생성 및 저장 (Board와 연결하지 않음) 한다.
3. 이때 우리는 BoardImg를 S3에 올리고, 생성된 URL을 프런트에게 리턴해준다.
4. 프런트는 URL을 사용해 다시 화면에 사진을 띄운다.
5. [게시글 작성 클릭] 게시글 제목/내용 등과 url을 함께 보내준다.
6. 서버는 제목/내용 등을 Baord로 만들고, 각각의 이미지들을 이때 Board와 매핑해주고 저장한다.
2. 누락된 이미지 처리는?
문제를 모두 해결했다고 생각했는데, 정상적인 흐름이 아닌 예외 흐름에서 문제가 또 다시 발생한다.
- 이미지 업로드 API를 나중에 호출하면, 업로드하다가 오류나면 게시물은 어떻게 되는걸까?
- 예를 들면, 이미지 업로드만 하고 사용자가 게시글 작성 도중 어플을 끄면?
- 혹은 사용자가 이미지 5개를 추가해놓고, 프런트화면에서 X를 눌러서 5개 중에 2개를 취소하고 3개만 보내면?
< x 버튼 마다 취소 api를 보내야 하나..? >
(이 때 5개 모두 S3는 올라갔고 프런트는 5개의 url을 리턴받았지만,
실제로 프런트가 게시글 작성 시점에 보낼 때는 5개 중 3개의 string값 url만 넘기면 된다.)
결국 게시물 작성이 도중에 취소될 경우 이미지를 찾아서 삭제해야하는 추가적인 처리가 필요하다는 뜻이다.
위와 같이 오류가 나서 꺼지는 경우는 서버가 Catch해서 추가적인 처리를 해준다고 하자.
허나 사용자가 의도해서 x를 누르는 버튼은 따로 API를 만들어야 할까?
사실 두 상황을 구분하지 않아도 된다. 사용자가 업로드를 해놓고 x를 누르는 상황도 "예외 상황"으로 볼 수 있다.
(정상적인 Flow가 아니라고 판단)
두 상황 다 공통적으로 해결할 수 있다.
바로 이러한 고아 이미지들은 "unmappedImages"라는 것.
이러한 경우 스케줄러로 처리 할 수 있다.
(스케줄러+배치 사용하는 방법도 고민해보자.)
(또한, S3에서 트리거를 사용하여 이러한 고아 이미지를 처리할 수도 있다고 하는데, 아직 안써봐서 정확히 모르겠다.)
✒️ 4. 전체 코드
- 컨트롤러
@Operation(summary = "이미지 업로드", description = "게시글에 첨부할 이미지를 업로드합니다.")
@PostMapping(value = "/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<BoardResponseDTO.BoardImgDTO> uploadBoardImages(
@RequestPart("images") List<MultipartFile> images) {
return ApiResponse.onSuccess(boardCommandService.uploadBoardImages(images));
}
@Operation(summary = "게시글 작성", description = "새로운 게시글을 작성합니다.")
@PostMapping
public ResponseEntity<ApiResponse<BoardResponseDTO.BoardDTO>> createBoard(
@Valid @RequestBody BoardRequestDTO.CreateBoardDTO createBoardDTO,
@AuthenticatedMember Member member) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.onSuccess(HttpStatus.CREATED,
boardCommandService.createBoard(createBoardDTO, member)));
}
@Operation(summary = "게시글 수정", description = "지정된 ID의 게시글을 수정합니다.")
@PutMapping("/{boardId}")
public ApiResponse<BoardResponseDTO.BoardDTO> updateBoard(
@PathVariable Long boardId,
@Valid @RequestBody BoardRequestDTO.UpdateBoardDTO updateBoardDTO,
@AuthenticatedMember Member member) {
return ApiResponse.onSuccess(boardCommandService.updateBoard(boardId, updateBoardDTO, member));
}
@Operation(summary = "게시글 삭제", description = "지정된 ID의 게시글을 삭제합니다.")
@DeleteMapping("/{boardId}")
public ApiResponse<Long> deleteBoard(@PathVariable Long boardId,
@AuthenticatedMember Member member) {
return ApiResponse.onSuccess(boardCommandService.deleteBoard(boardId, member));
}
- 서비스
@Override
public BoardResponseDTO.BoardImgDTO uploadBoardImages(List<MultipartFile> images) {
List<String> keyNames = new ArrayList<>();
// 키 이름 생성
for (MultipartFile image : images) {
if (image != null && !image.isEmpty()) {
UUID uuid = UUID.randomUUID();
keyNames.add(s3Manager.generateBoardKeyName(uuid));
}
}
// S3에 파일 일괄 업로드
List<String> imageUrls = s3Manager.uploadFiles(keyNames, images);
// BoardImg 엔티티 생성 및 저장 (Board와 연결하지 않음)
List<BoardImg> boardImgs = imageUrls.stream()
.map(url -> BoardImg.builder().boardImgUrl(url).build())
.toList();
boardImgRepository.saveAll(boardImgs);
// BoardImgDTO 생성 및 반환
return BoardResponseDTO.BoardImgDTO.builder()
.images(imageUrls)
.build();
}
@Override
public BoardResponseDTO.BoardDTO createBoard(BoardRequestDTO.CreateBoardDTO createBoardDTO, Member member) {
Board board = createBoardDTO.toEntity(member);
if (createBoardDTO.getImages() != null && !createBoardDTO.getImages().isEmpty()) {
List<BoardImg> boardImgs = boardImgRepository.findAllByBoardImgUrlIn(createBoardDTO.getImages());
// S3에 등록되지 않은 이미지를 가지고 접근
if (boardImgs.size() != createBoardDTO.getImages().size()) {
throw new BoardException(BoardErrorCode.INVALID_IMAGE_URLS);
}
boardImgs.forEach(img -> img.setBoard(board));
boardImgRepository.saveAll(boardImgs);
}
Board savedBoard = boardRepository.save(board);
return BoardResponseDTO.BoardDTO.from(savedBoard, member.getId());
}
@Override
public BoardResponseDTO.BoardDTO updateBoard(Long boardId,
BoardRequestDTO.UpdateBoardDTO updateBoardDTO,
Member member) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new BoardException(BoardErrorCode.BOARD_NOT_FOUND));
validateBoardOwnership(board, member);
// 게시글 기본 정보 업데이트
board.update(updateBoardDTO.getTitle(), updateBoardDTO.getContent(), updateBoardDTO.getCategory());
// 이미지 처리
if (updateBoardDTO.getImages() != null) {
// 현재 게시글의 이미지 URL 목록
List<String> currentImageUrls = board.getImages().stream()
.map(BoardImg::getBoardImgUrl)
.toList();
// 새로 제공된 이미지 URL 목록
List<String> newImageUrls = updateBoardDTO.getImages();
// 제거할 이미지 찾기
List<BoardImg> imagesToRemove = board.getImages().stream()
.filter(img -> !newImageUrls.contains(img.getBoardImgUrl()))
.toList();
// 추가할 이미지 URL 찾기
List<String> imagesToAdd = newImageUrls.stream()
.filter(url -> !currentImageUrls.contains(url))
.toList();
// 이미지 제거
imagesToRemove.forEach(img -> {
board.getImages().remove(img);
boardImgRepository.delete(img); // DB에서 삭제
s3Manager.deleteFile(img.getBoardImgUrl()); // S3에서도 삭제
});
// 새 이미지 추가
if (!imagesToAdd.isEmpty()) {
List<BoardImg> newBoardImgs = boardImgRepository.findAllByBoardImgUrlIn(imagesToAdd);
// S3에 등록되지 않은 이미지를 가지고 접근
if (newBoardImgs.size() != imagesToAdd.size()) {
throw new BoardException(BoardErrorCode.INVALID_IMAGE_URLS);
}
newBoardImgs.forEach(img -> img.setBoard(board));
boardImgRepository.saveAll(newBoardImgs);
}
}
Board updatedBoard = boardRepository.save(board);
return BoardResponseDTO.BoardDTO.from(updatedBoard, member.getId());
}
@Override
public Long deleteBoard(Long boardId, Member member) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new BoardException(BoardErrorCode.BOARD_NOT_FOUND));
validateBoardOwnership(board, member);
// 연관된 이미지 처리
List<BoardImg> images = board.getImages();
if (!images.isEmpty()) {
// S3에서 이미지 파일 일괄 삭제
List<String> imageUrls = images.stream()
.map(BoardImg::getBoardImgUrl)
.toList();
s3Manager.deleteFiles(imageUrls);
// 데이터베이스에서 BoardImg 엔티티 삭제
boardImgRepository.deleteAll(images);
}
// 게시글 삭제
boardRepository.delete(board);
return boardId;
}
- 고아 이미지 처리 스케쥴러
@Slf4j
@Component
@RequiredArgsConstructor
public class UnmappedImageCleanupScheduler {
private final BoardImgRepository boardImgRepository;
private final S3Manager s3Manager;
@Scheduled(cron = "0 0 3 * * ?") // 매일 새벽 3시에 실행
@Transactional
public void cleanupUnmappedImages() {
List<BoardImg> unmappedImages = boardImgRepository.findUnmappedImages();
for (BoardImg image : unmappedImages) {
s3Manager.deleteFile(image.getBoardImgUrl());
boardImgRepository.delete(image);
}
log.info("[cleanupUnmappedImages 실행] 삭제완료");
}
}
- S3Manager
@Slf4j
@Component
@RequiredArgsConstructor
public class S3Manager {
private final S3Client s3Client;
private final S3Config amazonConfig;
// 단일 파일 업로드
public String uploadFile(String keyName, MultipartFile file){
// 원본 파일 이름 가져오기
String originalFilename = file.getOriginalFilename();
// 확장자 가져오기
String extension;
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
} else {
extension = "";
}
// PutObjectRequest를 생성
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(amazonConfig.getBucket())
.key(keyName + extension)
.contentType(file.getContentType())
.build();
try {
// S3 API 메소드(putObject)로 파일 Stream을 열어서 S3에 파일 업로드
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
// S3에 업로드된 파일의 URL을 가져오기
URL url = s3Client.utilities().getUrl(b -> b.bucket(amazonConfig.getBucket()).key(keyName + extension));
return url.toString();
} catch (IOException e){
log.error("error at AmazonS3Manager uploadFile : {}", e.getMessage(), e);
throw new S3Exception(S3ErrorCode.UPLOAD_FAILED);
}
}
// 다중 파일 업로드
public List<String> uploadFiles(List<String> keyNames, List<MultipartFile> files) {
if (files.size() != keyNames.size()) {
throw new S3Exception(S3ErrorCode.SIZE_MISMATCH);
}
List<String> uploadedFileUrls = new ArrayList<>();
for (int i = 0; i < files.size(); i++) {
String fileUrl = uploadFile(keyNames.get(i), files.get(i));
uploadedFileUrls.add(fileUrl);
}
return uploadedFileUrls;
}
// 단일 파일 삭제
public void deleteFile(String fileUrl) {
try {
// 파일 URL에서 버킷 이름과 키를 추출
URL url = new URL(fileUrl);
String bucket = url.getHost().split("\\.")[0];
String key = url.getPath().substring(1);
// DeleteObjectRequest를 생성하여 파일 삭제
DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
s3Client.deleteObject(deleteObjectRequest);
} catch (IOException e) {
log.error("error at S3Manager deleteFile: {}", e.getMessage(), e);
throw new S3Exception(S3ErrorCode.DELETE_FAILED);
}
}
// 다중 파일 삭제
public void deleteFiles(List<String> fileUrls) {
for (String fileUrl : fileUrls) {
deleteFile(fileUrl);
}
}
public String generateBoardKeyName(UUID uuid) {
return amazonConfig.getBoardPath() + '/' + uuid.toString();
}
}
ㅡ끝ㅡ
✒️ 0. 들어가기 전 : 간단한 CRUD도 고민할 게 너무 많다.
"개발"을 잘한다는 것은 무엇일까?
우리는 코더가 아닌 개발자이다.
누군가는 무슨 최신 기술을, 누군가는 어떤 것까지 할 수 있다고 말한다.
과연 그 기술을 어디까지 알며, 왜 사용했고, 어떤 로직으로 굴러가지는 설명할 수 있을까?
되묻고 싶다.
왜 여기서는 RequestParam을 썼고, 여기는 왜 PathVariable로 썼고,
에러처리는 어떻게 했고, RestControllerAdvice를 사용했는데 그 원리는 무엇이고,
시큐리티와 JWT는 왜 썼고, 세션과 쿠키와 토큰의 차이는 무엇이며..
내 코드의 성능은 얼마나 좋을까? 더 개선할 수는 없을까?
..................
....
나는 CRUD 하나만 해도 충분히 고민할 게 넘쳐난다고 생각한다..
그래서 가끔 간단한 기술과 간단한 기능 가지고도 내가 깊게 고민한 부분에 대해서 글을 쓸 예정이다.
✒️ 1. S3를 이용한 이미지 등록
💡 이미지 저장을 어디에 해야 할까?
우리는 웹 애플리케이션에서 사용자가 업로드한 사진과, 다양한 이미지와 파일을 관리해야 한다.
사용자들은 사진을 올리고, 이 사진들을 보고, 다운로드하며, 때로는 이미지를 수정하고 삭제하길 원한다.
이제, 이러한 이미지와 파일을 저장하고 관리할 때의 도전 과제를 고려해보자.
서버에서 이미지를 저장할 수 있는 공간을 크게 3가지로 분류해보자.
1: 코드에 직접 저장

무식한 방법일 수 있겠지만, 실제 이미지 자체를 코드에 정적으로 넣을 수 있다.
개인적으로 로컬에서만 작동시킬 목적으로 확장 프로그램 하나를 만든 적이 있었는데,
이때 아이콘과 같은 작은 이미지는 그냥 코드에 직접 적어 넣었다.
이미지를 BASE64 인코딩 시키고 그냥 코드에 넣을 것이다.
https://products.aspose.app/imaging/ko/conversion/image-to-base64

접근 속도가 매우 빠르고, 이미지가 프로그램에 포함되어 있어 별도의 리소스 관리가 필요 없다는 장점이 있지만,
이미지가 바이너리에 포함되어 프로그램 크기가 커지고, 유지보수의 어려움이 있다.
단순히 아이콘 정도 개인 프로그램에 넣는 사유가 아니라면,
실제 배포 서비스의 게시글 이미지 로직에서 코드에 직접 이미지를 넣는 것은 상상하기 어려운 일이다.
2: 로컬 스토리지(파일)에 저장

전통적으로, 이미지와 파일을 저장하기 위해서는 서버의 파일 시스템을 사용할 수 있다.
아주 작고 간단한 프로젝트에서는 적용할 수 있겠지만, 매우 비효율 적일 것이다.
이는 서버의 저장 용량에 제한을 두고, 데이터 백업과 복구를 복잡하게 만들며, 확장성 문제를 초래할 수 있다.
예를 들어, 사용자가 수백, 수천 개의 이미지를 업로드하면, 서버의 저장 용량이 금방 부족해질 수 있다.
💡 아무래도 데이터베이스에 저장하는 게 맞지
3: 데이터베이스에 저장

백엔드 개발자로써 데이터를 저장할 때 1차적으로 데이터베이스에 저장하는 방식을 떠올릴 것이다.
일단 Storage보다는 DB에 저장하는 것이 좋은 이유는 이미지까지 트랜잭션에 포함시킬 수 있다는 장점이 있다.
그렇다면 이미지를 DB에 저장해야 한다면, 이미지가 저장되는 테이블에서 칼럼의 데이터 타입이 무엇일까???
바로 [BLOB] 타입이다.
BLOB 타입은 이진 데이터(Binary Object)를 저장하는 데 사용되는 데이터 타입이다.
주로 이미지, 오디오, 비디오 등을 저장하며 다음과 같이 파일을 직접 데이터에 삽입해서 쓴다.
INSERT INTO `kundol`
(`img`)
VALUES (LOAD_FILE('C:/Users/kundol/Desktop/dev/a.png'))
그런데 이 타입을 사용하여 실제 DB에 이미지를 저장하지 않는 이유는 무엇일까????
왜 실제로는 blob을 많이 쓰지 않을까?
첫번째로는 성능문제이다.
데이터베이스에 큰 이진 파일(예: 이미지)을 저장하면 데이터베이스의 성능이 저하될 수 있다.
사진을 데이터베이스에 저장 시 이를 바이너리로 바꾸고 저장을 하고,
조회 시 바이너리를 다시 사진으로 바꾸는 과정에서 병목이 생길 것이다.
이렇듯 처리하고 관리하는데도 시간이 너무 오래 걸린다.
만약 백업을 한다고 가정할 때 데이터베이스 백업 및 복구 시간도 증가하게 된다.
또한 보안적인 문제도 있다.
데이터베이스에 이미지를 저장하면, 이 이미지에 대한 접근을 제어하고 관리하는 것이 더 복잡해진다.
이는 보안 정책을 적용하고 유지하기 어렵게 만들 수 있다.
💡 다른 방법은 없을까?
4: 스토리지에 저장 후, DB에서 참조

다음과 같은 방식을 상상해보자.
실제 코드에서는 이미지 처리 로직을 담당한다.
예를 들어, 이미지에 관한 CRUD와 더불어 리사이징, 메타데이터 추출 등의 작업을 수행할 수 있다.
그리고 실제 이미지는 Storage (스토리지)에 저장한다.
정적 리소스를 저장해두고, 필요할 때 꺼내주면 된다.
대용량 이미지 파일을 효율적으로 저장하고 관리하는 시스템이면 더욱 좋다.
아까는 로컬의 파일 시스템을 들었지만, 단점이 존재했다. (이후 다시 고민해보자.)
그리고 이미지 관련 메타데이터는 데이터베이스에 저장하는 것이다.
이미지의 파일명, 경로 등과 같은 참조할 수 있는 정보를 DB에 저장하면 된다.
>> 자 그러면 해결이 된 듯하다.
특정 디렉토리에 파일을 저장해두고 이를 호스팅 하도록 하면 접근이 가능하다.
아래는 실제 네이버 홈페이지의 요청/응답 들이다.

요청들을 살펴보면 .jpg등과 같은 이미지를 요청 한 것이 보이고,
눌러서 상세 정보를 보면 GET 요청으로 정적 리소스를 요청하는 것을 확인 가능하다!!
배포시에 EC2에 파일을 저장하고, DB는 참조하고!
그렇다면 모든 것이 해결 된 것인가?
💡 S3는 무엇인가
AWS S3 등장
여기서 S3의 필요성이 등장한다.
우리는 CI/CD, 즉 배포 환경 또한 고려해야 한다.
새 서버 인스턴스가 생성되고 기존 인스턴스가 제거되는 과정에서 로컬에 저장된 파일들이 손실될 수 있다.
서버와 스토리지의 분리가 필요하다.
또한, 서버 인스턴스가 변경되어도 파일 데이터는 유지되어야 한다.
(BLUE/GREEN 무중단 배포)
EC2에 직접 파일을 저장하는 방식은 이러한 관리 작업이 복잡해질 수 있다.
파일에 대한 접근 제어와 암호화 등의 보안 기능 또한 필요하다.
여러가지 이유에서 우리는 파일 저장만 전문적으로 해주는 클라우딩 서비스가 필요하다는 것을 느꼈다.
먼저 S3는 AWS에서 제공해주는 파일을 업로드 하고 다운로드 하는 등의 스토리지 역할에 특화된 서비스이다.
S3는 "Simple Storage Service"의 약자로,
Amazon Web Services(AWS)에서 제공하는 클라우드 기반의 객체 저장 서비스이다.
S3는 무제한으로 데이터를 저장할 수 있으며, 데이터의 내구성, 가용성, 보안을 보장한다.
그리고 이 S3는 파일을 업로드 해주고, URL을 Return 해준다.

S3를 사용하여 이미지를 올리고 Return 받은 이미지 URL을 DB를 저장한다면, 지금까지의 고민이 다소 해결된 듯하다.
스토리지 : 스토리지 >> EC2 클라우드 서버 내의 스토리지 >> 클라우드 스토리지(Amazon S3)
DB : 이미지의 메타데이터 (경로명 등) >> [varchar] Return 받은 이미지 URL
💡 S3를 왜 사용하는가
그렇다면 왜 꼭 S3인가?
AWS S3는 자동으로 데이터를 분산 저장하여, 사용자가 저장할 데이터의 양에 관계없이 스케일을 조절할 수 있다.
백엔드는 더 이상 저장 용량 문제로 고민할 필요가 없다는 뜻이다.
S3는 99.999999999%의 내구성을 제공하며, 데이터는 여러 데이터 센터에 자동으로 복제된다.
내구성에 이어서 안정성, 비용 효율성, 보안과 접근 제어, 통합성과 유연성 등의 장점도 있다.
완전 Deep 하게 S3에 대해 다루는 포스트가 아니라 일단 배제하겠다.
✒️ 2. 백엔드에서 고려해야 할 이미지 업로드
웹 애플리케이션을 개발하면서, 이미지 업로드와 관련된 문제는 항상 중요한 고려 사항이다.
이미지 업로드는 사용자 경험의 핵심 요소로, 올바르게 처리되지 않으면 애플리케이션의 성능과 사용성이 크게 영향을 받을 수 있다.
사실 다양한 방법이 존재하며, 각 방법은 장단점이 있고, 요구사항에 따라서 구현 방법이 달라질 수 있다는 점도 고려해야한다.
우리는 백엔드 서버 개발자이므로, 일단 백엔드를 통한 이미지 처리 방식에 대해서 이야기 하려고 한다.
💡 통합 DTO 방식
아주 간단히 생각해보자.
처음에는 모든 것을 하나의 DTO로 처리하는 방식이 떠오른다.
아주 간단한 방법이다.
즉, BoardRequestDTO에 모든 필드, 이미지 파일, 게시글 내용 등을 담아 전달하는 것이다.
이 방법은 구조가 간단해 보이고, 요청과 응답이 하나의 DTO로 이루어지기 때문에 직관적이다.
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<BoardResponseDTO.BoardDTO>> createBoard(
@ModelAttribute BoardRequestDTO.CreateBoardDTO createBoardDTO) {
@Getter
public static class CreateBoardDTO {
@NotNull(message = "제목은 필수입니다.")
@Size(max = 30, message = "제목은 30자를 초과할 수 없습니다.")
private String title;
@NotNull(message = "내용은 필수입니다.")
private String content;
@NotNull(message = "카테고리는 필수입니다.")
private Category category;
List<MultipartFile> images;
}
하지만 테스트 시 문제가 발생할 수 있다.
이미지를 포함하는 요청은 multipart/form-data 형식을 사용한다.
Postman이나 Swagger에서 이미지 파일을 올리는 것에 문제가 발생했다.
(사실 이것은 큰 문제가 아니다. 이후에 다시 설명하겠다.)
가장 중요한 이미지 파일과 관련된 정보를 포함한 큰 DTO 객체는 복잡한 요청 처리를 요구한다는 점이다.
특히 @Valid와 같은 검증 어노테이션이 파일 관련 검증에 적합하지 않기 때문에, 별도의 검증 로직이 필요하다.
DTO 구조가 복잡하면, 변경이 필요할 때 해당 DTO를 사용하는 모든 코드와 로직을 수정해야 한다.
이는 유지보수를 어렵게 하고, 시스템의 확장성을 제한할 수 있다.
💡 DTO와 MultipartFile 분리
BoardRequestDTO와 List<MultipartFile>을 분리하여 처리하는 방법을 고려해보았다.
다음과 같은 장점은 있다.
1. 단순한 DTO 형식
게시글 정보와 이미지 파일을 별도의 요청으로 처리하면, DTO는 게시글 정보만 담으면 된다.
이는 DTO의 구조를 단순하게 유지하고, 요청 처리 및 검증을 용이하게 만든다.
2. 파일 업로드 독립성
파일 업로드 로직과 게시글 정보 처리 로직을 분리하면, 각 부분을 독립적으로 처리할 수 있어 코드의 가독성과 유지보수성이 향상된다.
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<BoardResponseDTO.BoardDTO>> createBoard(
@Valid @RequestPart("boardDto") BoardRequestDTO.CreateBoardDTO createBoardDTO,
@RequestPart(value = "images", required = false) List<MultipartFile> images) {
@Getter
public static class CreateBoardDTO {
@NotNull(message = "제목은 필수입니다.")
@Size(max = 30, message = "제목은 30자를 초과할 수 없습니다.")
private String title;
@NotNull(message = "내용은 필수입니다.")
private String content;
@NotNull(message = "카테고리는 필수입니다.")
private Category category;
}
그러나 그나마 이미지를 분리해서 받았으나
통합 DTO와 마찬가지로, 효율성 측면에서 전혀 개선이 안되고 있다.
참고로, 이러한 구현에서 " application/octet-stream " 에러가 발생한다면 다음 글을 참고해보자.
[Spring] Swagger + RequestPart를 통해 파일, Dto 동시 요청 시 발생 에러 핸들링
현재 Spring Boot를 통한 비디오 스트리밍 서버를 개발하는 간단한 프로젝트를 진행 해보고 있는데, 이를 개발하면서 마주한 에러에 대해 소개하고 이를 해결한 방식, 그리고 어째서 해결이 되는지
one-armed-boy.tistory.com
💡 게시글과 이미지를 한 번에 처리할 때의 단점
두 방법 다 어찌됐든 게시글과 이미지를 하나의 API에서 하나의 서비스로 처리한다는 것이다.
실제 프런트와 연결하여 테스트를 해보면, "작성"을 누르고 약 2,3초 가량의 대기시간이 걸린다.

따라서, 이미지 업로드를 보다 효율적으로 처리하고자 한다면,
파일 업로드와 데이터 처리를 별도의 API로 분리하는 방법을 고려하는 것이 좋다.
결국에는 이미지 파일을 처리하는 과정에서 네트워크 대역폭과 서버의 리소스를 추가로 소모하게 된다는 것.
이는 전체적인 시스템의 성능 저하로 이어질 수 있다!
💡 이미지 업로드 API 분리
사실 이미 코드상에서도 문제를 들 수 있다.
게시글 작성 API에서 이미지 파일을 포함시키면,
파일의 크기나 타입, 파일 업로드의 성공 여부 등 다양한 요소를 한 번의 요청에서 처리해야 한다.
이는 코드의 복잡성을 증가시킨다.
우리는 되도록이면 비지니스 로직을 분리 시키는 것을 추구한다.
뭐니뭐니 해도 가장 큰 이유는 성능 저하이다.
게시글 작성과 이미지 업로드를 동시에 처리하면 성능 저하가 발생할 수 있다.
특히 대용량 이미지 파일이 포함될 경우, 요청의 응답 시간이 길어질 수 있다.
실패 처리의 어려움도 있다.
이미지 업로드가 실패와 게시글 작성도 실패를 구분 할 때, 실패 원인을 찾기 어려울 수 있다.
이러한 이유로 이미지 업로드 API 분리하는 것을 추천한다.
💡 어차피 똑같은 거 아니야?
조삼모사라고 생각할 수도 있다.
허나 가장 큰 이유는 사용자 경험 개선이다.
이미지 업로드 작업은 시간이 오래 걸린다.
분리된 API로 이미지를 먼저 업로드하면, 게시글 작성 API의 응답 시간을 줄일 수 있다.
또한, 이미지 업로드 진행 상태를 실시간으로 표시 가능하며,
사용자 입장에서 가장 큰 이점은 이미지 업로드 중 다른 작업 가능하다는 것이다. (게시글 내용 작성 등)
이후 게시글 작성 API는 매우 짧은 시간에 응답되는 것을 확인 할 수 있다.
또한 코드 관점에서도 좋은 점이 있다
첫 번째로 에러 처리면에서 좋다.
이미지 업로드 실패 시, 게시글/댓글 작성에 영향을 주지 않고 독립적으로 처리할 수 있으며,
사용자에게 더 명확한 피드백을 제공할 수 있다.
두 번째로, 재사용성 및 유연성 관점이다.
이미지 업로드 API를 다른 기능(예: 프로필 사진 변경)에서도 재사용할 수 있다.
게시글/댓글 수정 시 이미지만 변경하는 경우에도 효율적으로 처리할 수 있다.
여담으로 이번 포스팅에서는 생략되어 있지만,
대용량 이미지 처리를 하면 CDN과 리사이징을 활용한 이미지 로드 시간 축소 등 추후 이미지 처리를 추가해야 한다.
이러한 기능 추가의 확장성 측면에서도 분리해 놓는 것이 이득이다.
💡 다른 기존 서비스들은 어떻게 적용할까?
코딩하다가 "이게 좋나? 저게 좋나?" 라는 고민이 들면
나는 실무자에게 물어보거나 기존 서비들은 어떻게 적용하는 지 찾아보는 편이다.
당근마켓의 커뮤니티도 이미지 업로드 호출이 따로 이루어지는 것을 볼 수 있다.
💡 더욱 더 효율적으로
어떻게 하면 더 효율적으로 처리할 수 있을까??
이미지 파일을 압축하여 업로드하는 것도 고려할 수 있다.
프런트엔드에서 이미지 파일을 압축하여 이를 보내는 것이다.
이는 전송 데이터의 크기를 줄이고, 네트워크 대역폭을 절약하는 데 도움이 된다.
허나 이 또한 한 번에 Back-End 서버에 전송하는 방식으로는
이미지 업로드의 지연시간 개선에 한계가 있다.
💡 프런트엔드에서의 처리
사용자가 이미지 등록 시 FE에서 S3에 직접 저장하면 안되나?
프론트엔드에서 직접 S3에 이미지를 업로드하고 URL을 백엔드에 전달하는 방법도 고려해볼 수 있다.
사실 가장 효율적이며, 프론트엔드와 백엔드의 책임을 명확히 분리할 수 있다.
프런트엔드에서 직접 처리할 수 있으면 굳이 서버를 안거지는 편이 제일 좋다는 것이다.
이렇게 되면 백엔드 서버의 부하를 줄이고, 이미지 업로드의 처리 속도를 확실히 높일 수 있다.
클라이언트가 직접 S3와 통신하므로, 이미지 업로드가 더 빠르고 원활하게 진행된다.
허나, 이는 보안상의 문제가 있다.
S3 접근 키를 프론트엔드에 노출시킬 수 없다. 이는 심각한 보안 위험을 초래한다.
이 문제를 해결하기 위해 Presigned URL을 사용해야 한다.
Presigned URL은 제한된 시간 동안만 유효한 특별한 URL로, 클라이언트가 직접 S3에 안전하게 업로드할 수 있게 한다.
(현재 프런트엔드에서 S3를 처리하는 방법에 대해 다루는 것이 아니므로 간단히 말하고 패스하겠다.)
- 아래 링크 참고
과연 이 방법은 "무조건" 좋을까?
Presigned URL 생성 및 관리 로직을 구현해야 하며, URL의 만료 시간, 권한 설정 등을 관리해야 한다.
또 업로드된 파일의 유효성, 크기, 타입 등을 서버에서 다시 확인해야 할 수도 있다.
파일 업로드와 관련된 비즈니스 로직(예: 특정 조건에 따른 파일명 변경)을 프론트엔드에서 처리해야 할 수 있어,
백엔드와 프론트엔드 간 로직 일관성 유지가 어려워질 수 있다...
완벽하게 "무엇이 무조건적으로 좋다"라는 것은 없다.
항상 모든 방법을 선택할 때는 장단점이 있다.
모든 것은 TRADE-OFF
✒️ 3. 백엔드 구현 방법
일단 우리는 서버 개발자이므로,
서버를 거친다고 했을 때 이미지 API를 분리하여 구현한다고 하면 어떻게 구현할 수 있을까?
💡 고민 지점
1. 이미지 업로드 시점에 게시글은 없다.
일단, 게시글에서 이미지 파일을 업로드 한다고 치자.
게시물을 작성하며 이미지를 첨부하는 시점에는, 게시물 관련 엔티티가 없다.
왜? 아직 게시물을 안 만들었으니까.
간단히 해결해보자.
1. Board에서의 BoardImg필드에서 orphanRemoval = true 옵션을 제거해준다.
2. 이미지 첨부 시점에 BoardImg 엔티티 생성 및 저장 (Board와 연결하지 않음) 한다.
3. 이때 우리는 BoardImg를 S3에 올리고, 생성된 URL을 프런트에게 리턴해준다.
4. 프런트는 URL을 사용해 다시 화면에 사진을 띄운다.
5. [게시글 작성 클릭] 게시글 제목/내용 등과 url을 함께 보내준다.
6. 서버는 제목/내용 등을 Baord로 만들고, 각각의 이미지들을 이때 Board와 매핑해주고 저장한다.
2. 누락된 이미지 처리는?
문제를 모두 해결했다고 생각했는데, 정상적인 흐름이 아닌 예외 흐름에서 문제가 또 다시 발생한다.
- 이미지 업로드 API를 나중에 호출하면, 업로드하다가 오류나면 게시물은 어떻게 되는걸까?
- 예를 들면, 이미지 업로드만 하고 사용자가 게시글 작성 도중 어플을 끄면?
- 혹은 사용자가 이미지 5개를 추가해놓고, 프런트화면에서 X를 눌러서 5개 중에 2개를 취소하고 3개만 보내면?
< x 버튼 마다 취소 api를 보내야 하나..? >
(이 때 5개 모두 S3는 올라갔고 프런트는 5개의 url을 리턴받았지만,
실제로 프런트가 게시글 작성 시점에 보낼 때는 5개 중 3개의 string값 url만 넘기면 된다.)
결국 게시물 작성이 도중에 취소될 경우 이미지를 찾아서 삭제해야하는 추가적인 처리가 필요하다는 뜻이다.
위와 같이 오류가 나서 꺼지는 경우는 서버가 Catch해서 추가적인 처리를 해준다고 하자.
허나 사용자가 의도해서 x를 누르는 버튼은 따로 API를 만들어야 할까?
사실 두 상황을 구분하지 않아도 된다. 사용자가 업로드를 해놓고 x를 누르는 상황도 "예외 상황"으로 볼 수 있다.
(정상적인 Flow가 아니라고 판단)
두 상황 다 공통적으로 해결할 수 있다.
바로 이러한 고아 이미지들은 "unmappedImages"라는 것.
이러한 경우 스케줄러로 처리 할 수 있다.
(스케줄러+배치 사용하는 방법도 고민해보자.)
(또한, S3에서 트리거를 사용하여 이러한 고아 이미지를 처리할 수도 있다고 하는데, 아직 안써봐서 정확히 모르겠다.)
✒️ 4. 전체 코드
- 컨트롤러
@Operation(summary = "이미지 업로드", description = "게시글에 첨부할 이미지를 업로드합니다.")
@PostMapping(value = "/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<BoardResponseDTO.BoardImgDTO> uploadBoardImages(
@RequestPart("images") List<MultipartFile> images) {
return ApiResponse.onSuccess(boardCommandService.uploadBoardImages(images));
}
@Operation(summary = "게시글 작성", description = "새로운 게시글을 작성합니다.")
@PostMapping
public ResponseEntity<ApiResponse<BoardResponseDTO.BoardDTO>> createBoard(
@Valid @RequestBody BoardRequestDTO.CreateBoardDTO createBoardDTO,
@AuthenticatedMember Member member) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.onSuccess(HttpStatus.CREATED,
boardCommandService.createBoard(createBoardDTO, member)));
}
@Operation(summary = "게시글 수정", description = "지정된 ID의 게시글을 수정합니다.")
@PutMapping("/{boardId}")
public ApiResponse<BoardResponseDTO.BoardDTO> updateBoard(
@PathVariable Long boardId,
@Valid @RequestBody BoardRequestDTO.UpdateBoardDTO updateBoardDTO,
@AuthenticatedMember Member member) {
return ApiResponse.onSuccess(boardCommandService.updateBoard(boardId, updateBoardDTO, member));
}
@Operation(summary = "게시글 삭제", description = "지정된 ID의 게시글을 삭제합니다.")
@DeleteMapping("/{boardId}")
public ApiResponse<Long> deleteBoard(@PathVariable Long boardId,
@AuthenticatedMember Member member) {
return ApiResponse.onSuccess(boardCommandService.deleteBoard(boardId, member));
}
- 서비스
@Override
public BoardResponseDTO.BoardImgDTO uploadBoardImages(List<MultipartFile> images) {
List<String> keyNames = new ArrayList<>();
// 키 이름 생성
for (MultipartFile image : images) {
if (image != null && !image.isEmpty()) {
UUID uuid = UUID.randomUUID();
keyNames.add(s3Manager.generateBoardKeyName(uuid));
}
}
// S3에 파일 일괄 업로드
List<String> imageUrls = s3Manager.uploadFiles(keyNames, images);
// BoardImg 엔티티 생성 및 저장 (Board와 연결하지 않음)
List<BoardImg> boardImgs = imageUrls.stream()
.map(url -> BoardImg.builder().boardImgUrl(url).build())
.toList();
boardImgRepository.saveAll(boardImgs);
// BoardImgDTO 생성 및 반환
return BoardResponseDTO.BoardImgDTO.builder()
.images(imageUrls)
.build();
}
@Override
public BoardResponseDTO.BoardDTO createBoard(BoardRequestDTO.CreateBoardDTO createBoardDTO, Member member) {
Board board = createBoardDTO.toEntity(member);
if (createBoardDTO.getImages() != null && !createBoardDTO.getImages().isEmpty()) {
List<BoardImg> boardImgs = boardImgRepository.findAllByBoardImgUrlIn(createBoardDTO.getImages());
// S3에 등록되지 않은 이미지를 가지고 접근
if (boardImgs.size() != createBoardDTO.getImages().size()) {
throw new BoardException(BoardErrorCode.INVALID_IMAGE_URLS);
}
boardImgs.forEach(img -> img.setBoard(board));
boardImgRepository.saveAll(boardImgs);
}
Board savedBoard = boardRepository.save(board);
return BoardResponseDTO.BoardDTO.from(savedBoard, member.getId());
}
@Override
public BoardResponseDTO.BoardDTO updateBoard(Long boardId,
BoardRequestDTO.UpdateBoardDTO updateBoardDTO,
Member member) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new BoardException(BoardErrorCode.BOARD_NOT_FOUND));
validateBoardOwnership(board, member);
// 게시글 기본 정보 업데이트
board.update(updateBoardDTO.getTitle(), updateBoardDTO.getContent(), updateBoardDTO.getCategory());
// 이미지 처리
if (updateBoardDTO.getImages() != null) {
// 현재 게시글의 이미지 URL 목록
List<String> currentImageUrls = board.getImages().stream()
.map(BoardImg::getBoardImgUrl)
.toList();
// 새로 제공된 이미지 URL 목록
List<String> newImageUrls = updateBoardDTO.getImages();
// 제거할 이미지 찾기
List<BoardImg> imagesToRemove = board.getImages().stream()
.filter(img -> !newImageUrls.contains(img.getBoardImgUrl()))
.toList();
// 추가할 이미지 URL 찾기
List<String> imagesToAdd = newImageUrls.stream()
.filter(url -> !currentImageUrls.contains(url))
.toList();
// 이미지 제거
imagesToRemove.forEach(img -> {
board.getImages().remove(img);
boardImgRepository.delete(img); // DB에서 삭제
s3Manager.deleteFile(img.getBoardImgUrl()); // S3에서도 삭제
});
// 새 이미지 추가
if (!imagesToAdd.isEmpty()) {
List<BoardImg> newBoardImgs = boardImgRepository.findAllByBoardImgUrlIn(imagesToAdd);
// S3에 등록되지 않은 이미지를 가지고 접근
if (newBoardImgs.size() != imagesToAdd.size()) {
throw new BoardException(BoardErrorCode.INVALID_IMAGE_URLS);
}
newBoardImgs.forEach(img -> img.setBoard(board));
boardImgRepository.saveAll(newBoardImgs);
}
}
Board updatedBoard = boardRepository.save(board);
return BoardResponseDTO.BoardDTO.from(updatedBoard, member.getId());
}
@Override
public Long deleteBoard(Long boardId, Member member) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new BoardException(BoardErrorCode.BOARD_NOT_FOUND));
validateBoardOwnership(board, member);
// 연관된 이미지 처리
List<BoardImg> images = board.getImages();
if (!images.isEmpty()) {
// S3에서 이미지 파일 일괄 삭제
List<String> imageUrls = images.stream()
.map(BoardImg::getBoardImgUrl)
.toList();
s3Manager.deleteFiles(imageUrls);
// 데이터베이스에서 BoardImg 엔티티 삭제
boardImgRepository.deleteAll(images);
}
// 게시글 삭제
boardRepository.delete(board);
return boardId;
}
- 고아 이미지 처리 스케쥴러
@Slf4j
@Component
@RequiredArgsConstructor
public class UnmappedImageCleanupScheduler {
private final BoardImgRepository boardImgRepository;
private final S3Manager s3Manager;
@Scheduled(cron = "0 0 3 * * ?") // 매일 새벽 3시에 실행
@Transactional
public void cleanupUnmappedImages() {
List<BoardImg> unmappedImages = boardImgRepository.findUnmappedImages();
for (BoardImg image : unmappedImages) {
s3Manager.deleteFile(image.getBoardImgUrl());
boardImgRepository.delete(image);
}
log.info("[cleanupUnmappedImages 실행] 삭제완료");
}
}
- S3Manager
@Slf4j
@Component
@RequiredArgsConstructor
public class S3Manager {
private final S3Client s3Client;
private final S3Config amazonConfig;
// 단일 파일 업로드
public String uploadFile(String keyName, MultipartFile file){
// 원본 파일 이름 가져오기
String originalFilename = file.getOriginalFilename();
// 확장자 가져오기
String extension;
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
} else {
extension = "";
}
// PutObjectRequest를 생성
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(amazonConfig.getBucket())
.key(keyName + extension)
.contentType(file.getContentType())
.build();
try {
// S3 API 메소드(putObject)로 파일 Stream을 열어서 S3에 파일 업로드
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
// S3에 업로드된 파일의 URL을 가져오기
URL url = s3Client.utilities().getUrl(b -> b.bucket(amazonConfig.getBucket()).key(keyName + extension));
return url.toString();
} catch (IOException e){
log.error("error at AmazonS3Manager uploadFile : {}", e.getMessage(), e);
throw new S3Exception(S3ErrorCode.UPLOAD_FAILED);
}
}
// 다중 파일 업로드
public List<String> uploadFiles(List<String> keyNames, List<MultipartFile> files) {
if (files.size() != keyNames.size()) {
throw new S3Exception(S3ErrorCode.SIZE_MISMATCH);
}
List<String> uploadedFileUrls = new ArrayList<>();
for (int i = 0; i < files.size(); i++) {
String fileUrl = uploadFile(keyNames.get(i), files.get(i));
uploadedFileUrls.add(fileUrl);
}
return uploadedFileUrls;
}
// 단일 파일 삭제
public void deleteFile(String fileUrl) {
try {
// 파일 URL에서 버킷 이름과 키를 추출
URL url = new URL(fileUrl);
String bucket = url.getHost().split("\\.")[0];
String key = url.getPath().substring(1);
// DeleteObjectRequest를 생성하여 파일 삭제
DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
s3Client.deleteObject(deleteObjectRequest);
} catch (IOException e) {
log.error("error at S3Manager deleteFile: {}", e.getMessage(), e);
throw new S3Exception(S3ErrorCode.DELETE_FAILED);
}
}
// 다중 파일 삭제
public void deleteFiles(List<String> fileUrls) {
for (String fileUrl : fileUrls) {
deleteFile(fileUrl);
}
}
public String generateBoardKeyName(UUID uuid) {
return amazonConfig.getBoardPath() + '/' + uuid.toString();
}
}
ㅡ끝ㅡ