
주인장 개인 학습 기록용 포스팅입니다.
0. 실습 목표
실습 목표 : 게시판을 구현하며 게시글 작성 시 이미지를 첨부할 수 있는 기능을 구현한다.
* 실습 환경 : SSR, View : Thymeleaf, Back : `Spring Boot`
- 세부 기능 1 : 최초 게시글 저장 시 클라이언트에서 이미지를 받아 로컬에 업로드
- 세부 기능 2 : 게시글 클릭 시 해당 이미지를 로컬에서 불러와 게시글에 렌더링
1. 준비 내용
1. 서블렛이 담을 수 있는 이미지 파일 용량 설정
2. 엔티티
3. DTO
4. 리포지토리, 서비스
5. 컨트롤러
6. 타임리프(View)
7. 리소스 핸들러
1) 서블렛이 담을 수 있는 이미지 파일 용량 설정
[application.yaml]
spring: servlet: multipart: max-file-size: 5MB max-request-size: 5MB
yaml 파일에 서블렛이 multipart 클래스로 담을 수 있는 최대 파일 / 요청 사이즈를 설정해준다.
2) 엔티티 작성
[ImageFille.java]
@Entity @SuperBuilder(toBuilder = true) @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @ToString public class ImageFile { Id @GeneratedValue(strategy = GenerationType.IDENTITY) @EqualsAndHashCode.Include private Long id; @CreatedDate private LocalDateTime createDate; @LastModifiedDate private LocalDateTime modifyDate; // 원본 파일 이름과 서버 저장 파일 경로를 분리하는 이유 : 동일 이름 파일 업로드 시 오류 발생 @Column(unique = true, nullable = false) private String filename; @Column(nullable = false) private long filesize; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id", referencedColumnName = "id") private Post post; }
이미지 관련 엔티티의 주요 칼럼은 파일 이름과 파일 사이즈이다.
실습을 위해 각 게시글과 이미지는 OneToOne으로 매핑하였다.
3) DTO
[PostRequestDto.java]
@Getter @Setter @NoArgsConstructor @AllArgsConstructor public class PostRequestDto { @NotNull private boolean isPublished = true; // default @NotBlank(message = "title can not be blank.") @Size(max = 50, message = "title can not exceed 50 length.") private String title; @NotBlank(message = "content can not be blank.") @Size(max = 1000, message = "content can not exceed 1000 length.") private String content; private MultipartFile multipartFile; }
최초 글 작성 시 이미지 첨부가 같이 이뤄져 하나의 폼 데이터로 전송된다.
따라서 기존 게시글 생성 DTO에 multipartFile을 추가해 구현했다.
타임리프 뷰에서 이미지가 어떻게 multipartFile로 변환해서 DTO로 들어오게 되는지는 후술한다. (#1)
4) 리포지토리, 서비스
[ImageFileRepository.java]
public interface ImageFileRepository extends JpaRepository<ImageFile, Long> { }
JpaRepository를 활용해 구성했다.
JPA 기본 제공 편의메소드를 활용해 엔티티를 꺼내므로 별도 메소드는 작성하지 않았다.
[ImageFileService.java]
// 이미지 저장 경로 지정 // 프로젝트 root 디렉토리의 하위 "images" 폴더 아래에 이미지를 저장하겠다는 의미 private final String FILESTORE_PATH = System.getProperty("user.dir") +"/images/";
이미지 파일을 저장할 절대 경로는 아래와 같이 설정했다.
System.getProperty("user.dir") 는 현재 프로젝트의 루트 디렉토리 경로를 불러온다.
이러한 경로 설정은 어떤 프로젝트에서든 상대적인 경로로 절대 경로를 구성할 수 있는 방법이라고 생각한다.
유저가 업로드할 이미지를 모아놓을 폴더 구성을 위해 하위에 images 폴더를 생성해서 관리했다.
images 폴더가 없는 경우 자동적으로 생성할 수 있는 방법은 아래에서 후술한다. (#2)
Q. resoure-static 폴더에 유저 업로드 이미지를 모아놓으면 안될까?
A. 안될 건 없겠지만, static resource의 취지와는 어긋난다고 생각했다. static은 순전히 유저 업로드 이미지가 아닌 관리자의 측면에서 사용할 웹의 기본 정적 리소스만 포함하고 있어야 한다.
Q. 그러면 개발하는 컴퓨터 로컬에 유저 이미지를 저장하는 방법은?
A. 이것 또한 안될 건 없다. 다만, 프로젝트 외부의 로컬 저장소에 이미지를 저장하면 이후 해당 이미지의 경로를 불러와 렌더링 할 때 브라우저 정책에 의해 불러와지지 않는다.
이와 같은 경우 사용할 수 있는 방법은 두가지이다.
1) BASE64 형식의 데이터로 인코딩하여 HTML에서 직접 사용하는 방법
2) 핸들러를 사용하여 직접 특정 로컬 경로에 저장되어 있는 이미지를 URL로 매핑하는 방법 (#3)
본 실습은 2번 방식을 차용하여 우회하였으나, 반드시 외부 경로의 이미지를 불러올 때는 실제 서비스를 운영하는 측면에서는 외부 이미지에 첨부된 악의적인 스크립트 공격 등 보안 대책을 강구해야 한다.
실질적으로 본 실습도 유저 이미지를 프로젝트 디렉토리 내부에 저장하지만, 클라이언트에서는 개발자의 C:/ 와 같은 로컬 디렉토리와 동일하게 인식한다.
Q. 그렇다면 실무와 비슷한 환경을 구성하듯 현명하게 유저 이미지를 저장하는 기능을 구현하면 어떤 저장소를 사용해야 하는가?
A. 일단 프로젝트 내부는 절대 아니다. static은 더더욱 아니다. 프로젝트 내부에 이미지가 쌓이면 프로젝트 용량만 무거워질 뿐이다.
그렇다고 상대 경로, 절대 경로에 무관하게 로컬 디렉토리를 사용하는 법도 좋은 방법은 아니다.
가장 실무와 흡사하게 구현하고자 한다면 W3 기술과 같이 별도의 웹클라우드 기술을 활용하여 별도의 이미지에 특화된 저장소를 구현하는 것이 가장 올바른 방법일 것이라고 생각한다.
Q. 그러면 왜 그렇게 안했어?A. 그렇게 할줄 모르니까!!!
[ImageFileService.java]
// .jpg, .jpeg, .gif, .png 등 이미지 확장자 형식을 리턴하는 메소드 @SneakyThrows private String getFileType(MultipartFile multipartFile) { String contentType = multipartFile.getContentType(); if (contentType != null) { MediaType mediaType = MediaType.parseMediaType(contentType); switch (mediaType.toString()) { case MediaType.IMAGE_JPEG_VALUE -> { return ".jpeg"; } case MediaType.IMAGE_PNG_VALUE -> { return ".png"; } case MediaType.IMAGE_GIF_VALUE -> { return ".gif"; } } } // 상기 확장자에 해당되지 않을 시 "" 리턴 return ""; }
유저가 업로드하는 이미지 확장자를 검증하여 확장자만을 리턴하는 메소드이다.
유저가 업로드할 수 있는 이미지 형식은 .jpg, .jpeg, .png, .gif로 제한했다.
이후 이미지 이름에 난수성 ID를 부여하는데 여기서 분리한 확장자 형식을 ID 뒤에 붙여 확장자를 유지했다.
[ImageFileService.java]
@SneakyThrows private String setUniqueFilename(String fileType) { // ex) randomUUID + .jpg return UUID.randomUUID() + fileType; }
상기 메소드에서 리턴한 확장자를 인자로 받아 이미지의 랜덤한 난수성 이름을 생성한다.
그 후 난수성 이름 뒤에 인자로 받은 확장자명을 붙여 이미지 경로에 저장할 파일 이름을 리턴한다.
각 이미지 이름은 고유해야 하므로 난수값을 부여하였다.
UUID?
UUID는 정규적으로 123e4567-e89b-12d3-a456-426614174000 와 같이
(하이픈으로 구분된 5개의 16진수 문자열) 형식의 36자 문자열로 표시되는 128비트 값이다.
계산 방식이 상이한 여러가지 버전의 UUID가 존재한다. (참고 : MMDN WEB DOCS)

[ImageFileService.java]
@SneakyThrows public String storeAndGetFilename(MultipartFile multipartFile) { if (getFileType(multipartFile).contentEquals("")) { throw new HttpMediaTypeNotSupportedException("Unsupported Image File type"); } // ex) <randomUUID> + .jpg String storeFilename = setUniqueFilename(getFileType(multipartFile)); // ex) C:/mediumimagfiles/<RandomUUID>.jpg 의 경로로 저장 File file = new File(FILESTORE_PATH + storeFilename); // 파일 경로가 없을 시 이미지 파일 생성(#2) if (!file.exists()) { if (!file.mkdirs()){ throw new IOException("Failed to create directory"); } } // 파일 저장 multipartFile.transferTo(new File(FILESTORE_PATH + storeFilename)); return storeFilename; }
이 메소드는 multipart 파일 클래스를 인자로 받아 상기 서술한 이미지 파일의 확장자 검증, 파일 이름 변환,
지정 경로를 변환하며, 파일을 저장한 후 파일 이름을 리턴하는 메소드이다.
(#2) 해당 과정에서 루트 디렉토리에 /images/ 폴더가 없을 시 새로운 /images/ 디렉토리를 생성한다.
[ImageFileService.java]
@Transactional public ResponseData<ImageFile> create(MultipartFile multipartFile, Post post) { String storeFilename = storeAndGetFilename(multipartFile); ImageFile imageFile = ImageFile.builder() .filename(storeFilename) .filesize(multipartFile.getSize()) .post(post) .build(); imageFileRepository.save(imageFile); return ResponseData.of("200", "Image has been successfully uploaded", imageFile); } public ImageFile get(Long id){ return imageFileRepository.findById(id).orElseThrow( () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found") ); }
JPA 레포지토리에 ImageFile 엔티티를 저장하는 메소드이다.
메소드 인자로는 MultipartFile 클래스 인스턴스와 OneToOne으로 매핑할 게시글을 받아 빌더 패턴으로 엔티티를 생성한 후 저장한다.
filesize는 multipartFile.getSize로 바이트 단위로 불러들여 저장된다. (yaml에서 지정한 서블렛 파일 업로드 용량 확인용)
5) 컨트롤러
이미지 첨부는 게시글 생성의 Post 메소드와 함께 실행되므로 별도의 이미지 컨트롤러를 두지 않고, 게시글 컨트롤러에 기능을 구현해놓았다.
[Postcontroller.java]
// Post: /post/write *글 작성 처리 @PreAuthorize("isAuthenticated()") @PostMapping("/post/write") public String write(@ModelAttribute("postRequestDto") @Valid PostRequestDto postRequestDto, BindingResult brs, RedirectAttributes attr, @RequestPart("multipartFile") MultipartFile multipartFile, Principal principal) { if (brs.hasErrors()) { return "domain/post/write_form"; } ResponseData<Post> resp = postService.create( postRequestDto, memberService.findByUsername(principal.getName()) ); attr.addFlashAttribute("msg", resp.getMsg()); // MultiPartFile은 자동적으로 데이터 바인딩이 안되므로 @RequestPart로 받아온 후 직접 처리(#1) if (!multipartFile.isEmpty()) { ResponseData<ImageFile> imageFileResponseData = imageFileService.create(multipartFile, resp.getData()); } return String.format("redirect:/post/%d", resp.getData().getId()); }
일반적인 form 데이터 바인딩에 MultipartFile을 추가한 형태이다.
여기서 주목해야 할 형태는 MultipartFile은 자동적으로 데이터 바인딩이 안되므로 @RequestPart 어노테이션으로MultipartFile 인스턴스를 별도 명시하고, PostRequestDto의 set 메소드로 그 값을 직접 메소드에서 해당 필드로 넣어줬다는 것이다. (#1)
MultipartFile은 String과 같은 일반적인 name, value 형식의 뷰에서의 폼 데이터 자동 바인딩 처리를 지원하지 않는다.
따라서, @RequestPart, @RequestParam 선언 등을 통해 해당 데이터가 웹 요청에 의해 메소드 파라미터에 바인딩이 되어야 함을 뷰에 알린 후, 변환된 Multipartfile을 수동으로 직접 RequestDto에 값을 넣는 형태로 구현했다.
@RequestPart?
multipart / form 데이터 형식의 요청에 특화되어 있는 메소드 어노테이션이다. 웹 요청에 의해 해당 어노테이션이 선언되는 메소드 인수의 경우 HttpMessageConverter를 거쳐 변환된 multipart 파일을 바인딩 받는다.
해당 방식은 @RequestBody와 유사한 방식으로 동작한다.

6) 타임리프(View) form
<form th:action="@{/post/write}" th:object="${postRequestDto}" method="post" enctype="multipart/form-data"> <!-- 이하 생략 --> <input type="file" id="file" class="hidden" th:field="*{multipartFile}" accept=".jpg, .jpeg, .png" onchange="displayFileName()"/> </form>
해당 form은 이미지 파일을 전송하므로 enctype="multipart/form-data" 로 선언했다.
enctype="multipart/form-data" 는 모든 문자를 인코딩하지는 않겠음을 명시하는 인코딩 속성이며, 파일과 이미지 등을 업로드 하는 폼일 시 주로 사용된다.
추가로, 이미지를 업로드 하기 위해 input type="file" 로 지정했으며, accept=".jpg, .jpeg, .png" 로 view에서 일차적으로 파일 형식을 검증했다.
7) 핸들러를 활용한 로컬 디렉토리 URL 매핑
[WebMvcConfig.java]
@Configuration public class WebMvcConfig implements WebMvcConfigurer { private final String FILESTORE_PATH = System.getProperty("user.dir") +"/images/"; // 로컬 이미지를 localhost:8090/images/ 로 핸들링 해 URL 매핑 @Override public void addResourceHandlers(ResourceHandlerRegistry registry){ registry.addResourceHandler("/images/**") .addResourceLocations("file:///" + FILESTORE_PATH); } }
WebMvcConfigurer를 구현하는 WebMvcConfig 클래스를 추가해 로컬 디렉토리의 리소스를 사용하기 위한 addResourceHandlers 메소드를 구현했다. (#3)
addResoureHandler는 도메인의 특정 URL로 이미지를 매핑하는 기능을 수행하며,
addResourceLocations는 상기 핸들러의 매핑 URL과 연결할 특정 로컬 디렉토리를 지정한다.
FILESTORE_PATH는 서비스에서 구현한 프로젝트 내부 루트 디렉토리의 하위 images 디렉토리와 동일하다.
2. 실습 결과

# REFERENCE
UUID - MDN Web Docs 용어 사전: 웹 용어 정의 | MDN
범용 고유 식별자(Universally Unique Identifier, UUID) 는 해당 타입의 다른 모든 리소스 중에서 리소스를 고유하게 식별하는 데 사용되는 레이블입니다.
developer.mozilla.org

'DEV > Spring' 카테고리의 다른 글
[트러블슈팅] @Transactional 미작동 시 해결방법 (4) | 2024.01.04 |
---|---|
[Spring Boot] CORS 에러 해결, CORS 설정 (0) | 2023.12.30 |
@Configuration을 활용한 @Bean 등록 방법 (0) | 2023.11.23 |
@RequestParam의 이해 (0) | 2023.11.21 |
[Spring] @Autowired 활용 의존성 주입, Spring Bean (2 / 2) (0) | 2023.11.16 |