구현내용
1. 파일업로드 설정
2. DTO 셋팅
3. 파일등록처리
4. 컨트롤러 생성
5. 썸네일 이미지 처리
6. 이미지 가져오기(READ)
7. 이미지 삭제
8. 도메인 이미지 연결(값 타입 객체)
9. @ElementCollection(값타입 객체) Lazy Loading 처리에 대한 이해와 Test
10. @EntityGraph 사용하여 한번에 쿼리로 조회하기
11. 파일 삭제 (Soft delete)
12. 게시글 수정
13. 이미지가 포함된 목록처리
[1. 파일업로드 설정]
spring.application.name=zelkova
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/zelkova
spring.datasource.username=root
spring.datasource.password=1234
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true
#file upload
spring.servlet.multipart.max-request-size=30MB
spring.servlet.multipart.max-file-size=10MB
com.zelkova.upload.path=upload
[2. DTO셋팅]
package com.zelkova.zelkova.dto;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ImageDTO {
private Long ino;
private String name;
private String desc;
private boolean delFlag;
@Builder.Default
private List<MultipartFile> files = new ArrayList<>();
@Builder.Default
private List<String> uploadFileNames = new ArrayList<>();
}
[3. 파일등록처리]
package com.zelkova.zelkova.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PostConstruct;
import lombok.extern.log4j.Log4j2;
@Component
@Log4j2
public class CustomFileUtil {
// 업로드 경로 기본값을 application properties
@Value("${com.zelkova.upload.path}")
private String uploadPath;
// init "@PostConstruct"를 이용해 디렉토리 없으면 생성
// dependency injection 되는 순간 excute
@PostConstruct
public void init() {
File file = new File(uploadPath);
if (!file.exists()) {
file.mkdir();
}
uploadPath = file.getAbsolutePath();
log.info("---------------");
log.info("--------------- uploadPath : " + uploadPath);
}
public List<String> saveFiles(List<MultipartFile> files) {
if (files == null || files.size() == 0) {
return List.of();
}
List<String> uploadFileNames = new ArrayList<>();
// >>> for 시작
for (MultipartFile file : files) {
// 중복값 피하기 위한 UUID
String savedName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();
// 목적지 + 파일명 (파일 자체도 디스크의 주소이기 때문에 path가 될 수 있다.)
Path path = Paths.get(uploadPath, savedName);
try {
// 바이트화 된 multipartFile을 사용할 수 있도록 InputStream에 넣어줌.
Files.copy(file.getInputStream(), path);
uploadFileNames.add(savedName);
} catch (IOException e) {
e.printStackTrace();
}
}
// >>> for 끝
return uploadFileNames;
}
}
이미지처리 프로세스를 재사용을 위해 FileUtil로 빼놓은 것이다.
Controller나 Service 단에서 처리해도 됨.
CustomFileUtil.java에 대한 내용은 간략하게 설명하면 다음과 같다.
1. init: 주입될 때 파일경로 없으면 생성해주기
2. saveFiles:
2-1. 인자로 파일이 없으면 빈 배열 리턴 후 종료
2-2. 파일이 있다면 이미지파일명에 고유값 부여
2-3. 업로드경로 + 파일명 => Path 객체 생성 (파일 디스크 입장에서는 디렉토리든 파일이든 Path로 인식가능)
2-4. Files.copy에 넘겨줌 (이미지 바이트를 InputStream 객체로 변환, 바이트를 담을 경로 + 파일)
[4. 컨트롤러생성]
package com.zelkova.zelkova.controller;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.zelkova.zelkova.dto.ImageDTO;
import com.zelkova.zelkova.util.CustomFileUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/image")
public class ImageController {
private final CustomFileUtil fileUtil;
@PostMapping("/")
public Map<String, String> register(ImageDTO imageDTO) {
List<MultipartFile> list = imageDTO.getFiles();
List<String> uploadFileNames = fileUtil.saveFiles(list);
imageDTO.setUploadFileNames(uploadFileNames);
log.info("uploadFileNames >>> " + uploadFileNames);
return Map.of("RESULT", "SUCCESS");
}
}
[5. 썸네일 작업]
package com.zelkova.zelkova.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PostConstruct;
import lombok.extern.log4j.Log4j2;
import net.coobird.thumbnailator.Thumbnails;
@Component
@Log4j2
public class CustomFileUtil {
// 업로드 경로 기본값을 application properties
@Value("${com.zelkova.upload.path}")
private String uploadPath;
// init "@PostConstruct"를 이용해 디렉토리 없으면 생성
// dependency injection 되는 순간 excute
@PostConstruct
public void init() {
File file = new File(uploadPath);
if (!file.exists()) {
file.mkdir();
}
uploadPath = file.getAbsolutePath();
log.info("---------------");
log.info("--------------- uploadPath : " + uploadPath);
}
public List<String> saveFiles(List<MultipartFile> files) {
if (files == null || files.size() == 0) {
return List.of();
}
List<String> uploadFileNames = new ArrayList<>();
// >>> for 시작
for (MultipartFile file : files) {
// 중복값 피하기 위한 UUID
String savedName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();
// 목적지 + 파일명 (파일 자체도 디스크의 주소이기 때문에 path가 될 수 있다.)
Path path = Paths.get(uploadPath, savedName);
try {
// 바이트화 된 multipartFile을 사용할 수 있도록 InputStream에 넣어줌.
Files.copy(file.getInputStream(), path);
// 썸네일 프로세스 시작
String contentType = file.getContentType();
if (contentType.startsWith("image")) {
Path thumbPath = Paths.get(uploadPath, "s_" + savedName);
Thumbnails.of(path.toFile())
.size(200, 200)
.toFile(thumbPath.toFile());
}
uploadFileNames.add(savedName);
} catch (IOException e) {
e.printStackTrace();
}
}
// >>> for 끝
return uploadFileNames;
}
}
이미지만 썸네일 작업을 한다.
4줄 밖에 없으니 하나씩 살펴보자
5-1. 저장할 디렉토리 + 파일명 생성하기
Path thumbPath = Paths.get(uploadPath, "s_" + savedName)
썸네일이라는 이미지라는 걸 구별하기 위해
prefix에 "s_" 를 붙여준다
5-2. 기존에 저장한 원본 크기 파일을 File화 시키기
Thumbnails.of(path.toFile())
원본이 있어서 가공을 할 수 있으므로
기존에 저장되어 있는 원본 파일을 File 타입으로 가져온다.
아래와 같이 가져오면
File testFile = path.toFile()
해당 객체 안에 value라는 키값으로
byte값이 저장되어있다.
5-3. 사이즈를 정하여 파일화 시키기
Thumbnails.of(path.toFile()
.size(200, 200)
.toFile(thumbPath.toFile();
[6. 이미지 가져오기 - CustomFileUtil.java, ImageController.java (두가지 파일 소스 추가)]
CustomFileUtil.java
package com.zelkova.zelkova.util;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PostConstruct;
import lombok.extern.log4j.Log4j2;
import net.coobird.thumbnailator.Thumbnails;
@Component
@Log4j2
public class CustomFileUtil {
// 업로드 경로 기본값을 application properties
@Value("${com.zelkova.upload.path}")
private String uploadPath;
// init "@PostConstruct"를 이용해 디렉토리 없으면 생성
// dependency injection 되는 순간 excute
@PostConstruct
public void init() {
File file = new File(uploadPath);
if (!file.exists()) {
file.mkdir();
}
uploadPath = file.getAbsolutePath();
log.info("---------------");
log.info("--------------- uploadPath : " + uploadPath);
}
public List<String> saveFiles(List<MultipartFile> files) {
if (files == null || files.size() == 0) {
return List.of();
}
List<String> uploadFileNames = new ArrayList<>();
// >>> for 시작
for (MultipartFile file : files) {
// 중복값 피하기 위한 UUID
String savedName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();
// 목적지 + 파일명 (파일 자체도 디스크의 주소이기 때문에 path가 될 수 있다.)
Path path = Paths.get(uploadPath, savedName);
try {
// 바이트화 된 multipartFile을 사용할 수 있도록 InputStream에 넣어줌.
Files.copy(file.getInputStream(), path);
// 썸네일 프로세스 시작
String contentType = file.getContentType();
if (contentType.startsWith("image")) {
Path thumbPath = Paths.get(uploadPath, "s_" + savedName);
Thumbnails.of(path.toFile())
.size(200, 200)
.toFile(thumbPath.toFile());
}
uploadFileNames.add(savedName);
} catch (IOException e) {
e.printStackTrace();
}
}
// >>> for 끝
return uploadFileNames;
}
public ResponseEntity<Resource> getFile(String filename) {
Resource resource = new FileSystemResource(uploadPath + File.separator + filename);
if (!resource.isReadable()) {
resource = new FileSystemResource(uploadPath + File.separator + "default.png");
}
HttpHeaders headers = new HttpHeaders();
try {
// probeContentType: file이면 file로 타입을 맞춰주고 아니라면 타입을 체크해 넣어줌
headers.add("Content-Type", Files.probeContentType(resource.getFile().toPath()));
} catch (IOException e) {
return ResponseEntity.internalServerError().build();
}
return ResponseEntity.ok().headers(headers).body(resource);
}
}
6-1. getFile 메소드에서는 filename을 넣으면 해당 파일을 리턴해준다. 없으면 기본이미지 리턴
6-2. Files.probeContentType은 Path(디렉토리 + 파일명)을 넣어주면 probe(조사) 하여 ContentType을 알아서 넣어줌
ImageController.java
package com.zelkova.zelkova.controller;
import java.util.List;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.Resource;
import com.zelkova.zelkova.dto.ImageDTO;
import com.zelkova.zelkova.util.CustomFileUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/image")
public class ImageController {
private final CustomFileUtil fileUtil;
@PostMapping("/")
public Map<String, String> register(ImageDTO imageDTO) {
List<MultipartFile> list = imageDTO.getFiles();
List<String> uploadFileNames = fileUtil.saveFiles(list);
imageDTO.setUploadFileNames(uploadFileNames);
log.info("uploadFileNames >>> " + uploadFileNames);
return Map.of("RESULT", "SUCCESS");
}
@GetMapping("/view/{filename}")
public ResponseEntity<Resource> viewFile(@PathVariable(name = "filename") String filename) {
ResponseEntity<Resource> resource = fileUtil.getFile(filename);
return resource;
}
}
[7. 이미지 삭제]
CustomFileUtil.java
... 생략
public void deleteFiles(List<String> fileNames) {
if (fileNames.size() == 0 || fileNames == null) {
return;
}
for (String name : fileNames) {
Path thumbPath = Paths.get(uploadPath, "s_" + name);
Path originPath = Paths.get(uploadPath, name);
try {
Files.deleteIfExists(thumbPath);
Files.deleteIfExists(originPath);
} catch (IOException e) {
e.printStackTrace();
}
}
}
[8. 도메인의 이미지 연결]
JPA에서
도메인 하나당 테이블로 생성이된다.
게시글 이미지 데이터를 담는
BoardImage 테이블은
Board 테이블에 종속적이다.
게시글 하나에
여러 이미지가 묶여있을 수 있다.
그러기 위해서는
'값 타입 객체' 를 사용해야한다.
아래 두 가지의 수행
- 'Board.java' 에서 값 타입 객체로 사용할 이미지리스트를 '@ElementCollection'으로 선언
- 'BoardImage.java' 에서 '나는 값 타입 객체예요' 를 의미하는 '@Embeddable' 선언
Board.java
package com.zelkova.zelkova.domain;
import java.time.LocalDate;
import java.util.List;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.*;
@Entity
@Table(name = "board")
@Getter
@ToString(exclude="imageList")
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String writer;
private boolean isDel;
private LocalDate date;
@ElementCollection
@Builder.Default
private List<BoardImage> imageList = new ArrayList<>();
public void changeTitle(String title) {
this.title = title;
}
public void changeWriter(String writer) {
this.writer = writer;
}
public void changeIsDel(boolean isDel) {
this.isDel = isDel;
}
public void changeDate(LocalDate date) {
this.date = date;
}
public void addImage(BoardImage image) {
image.setOrd(this.imageList.size());
imageList.add(image);
}
public void addImageString(String fileName) {
BoardImage boardImage = BoardImage.builder()
.fileName(fileName)
.build();
addImage(boardImage);
}
public void clearList() {
this.imageList.clear();
}
}
BoardImage.java
package com.zelkova.zelkova.domain;
import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Embeddable
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BoardImage {
int ord;
private String fileName;
public void setOrd(int ord) {
this.ord = ord;
}
}
조회해보면
board에는 board_image_list 테이블에 대한 내용이 없다.
board_image_list 테이블에는
board키를 외래키로 잡혀있다.
[9.@ElementCollection(값타입 객체) Lazy Loading 처리]
게시글 테이블 안에 있는
게시글이미지리스트 '값 타입 객체'가 존재한다.
테이블은 두개이므로
쿼리도 두 번 실행된다.
이미지가 없는 경우는 'Board' 테이블만 접근하면 된다.
이것을 Lazy Loading 방식이라고 한다.
두번 다 실행하여
성공해야 소스가 종료되도록
@Transactional 을 사용한다
아래는 테스트 소스
@Test
@Transactional
public void testRead2() {
// bno 를 이용해 가져옴
long id = 10L;
// 쿼리 한번 실행!
Optional<Board> result = boardRepository.findById(id);
// 가져오는 게 없는 경우 처리
Board board = result.orElseThrow();
// 이미지 리스트 접근
// 쿼리 두번 실행!
List<BoardImage> list = board.getImageList();
log.info("------ imageList " + list);
}
[10. @EntityGraph로 @Transactional 사용하지 말기]
package com.zelkova.zelkova.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.zelkova.zelkova.domain.Board;
public interface BoardRepository extends JpaRepository<Board, Long>{
@EntityGraph(attributePaths = "imageList")
@Query("select b from Board b where b.bno = :bno")
Optional<Board> selectOne(@Param("bno") Long bno);
}
@EntityGraph에서의
'attributePaths'는
'값 타입 객체' 로 잡아 놓은 테이블을
Join 대상으로 잡아준다.
[11. 파일 삭제 (soft delete)]
package com.zelkova.zelkova.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.zelkova.zelkova.domain.Board;
public interface BoardRepository extends JpaRepository<Board, Long>{
@EntityGraph(attributePaths = "imageList")
// attributePath는 '값 타입 객체'로 선언된 필드값을 join 대상 테이블로 잡아준다
@Query("select b from Board b where b.bno = :bno")
Optional<Board> selectOne(@Param("bno") Long bno);
@Modifying
@Query("update Board b set b.isDel = :flag where b.bno = :bno")
void updateToDelete(@Param("flag") boolean flag, @Param("bno") Long bno);
}
@Modifying 어노테이션을 이용해
@Query에 데이터 변환 쿼리가 있다는 걸 알려줍니다.
아래는 테스트 코드입니다.
@Commit
@Transactional
@Test
public void testDel() {
Long bno = 1L;
boardRepository.updateToDelete(true, bno);
}
@Commit 어노테이션은
-> 테스트 임에도 불구하고 DB에 적용시키려면 해당 어노테이션을 붙이면 된다.
[12. 게시글 수정]
@Test
public void testUpdate() {
// 글 하나를 가져와서
Long bno = 10L; //tomhoon9
Optional<Board> result = boardRepository.selectOne(bno);
Board board = result.orElseThrow();
// 이미지 리스트 모두 비우기
board.clearList();
// 새로운 이미지 추가하기
board.addImageString(UUID.randomUUID().toString() + "_" + "NEWIMAGE1.jpg");
board.addImageString(UUID.randomUUID().toString() + "_" + "NEWIMAGE2.jpg");
// title, writer, content를 바꾸기
board.changeContent("Hello!");
board.changeWriter("tomhoony@@@@@");
board.changeTitle("using custom set method instead of setter");
// 저장하기
boardRepository.save(board);
}
수정하는 과정은 아래와 같다.
1. DB에 있는 하나의 튜플을 가져온다.
2. setter 메소드로 속성 수정하기
3. DB에 저장하기
[13. 이미지가 포함된 목록처리]
JPQL을 이용해서 JOIN해서
출력할 수 있다.
BoardRepository.java
package com.zelkova.zelkova.repository;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.zelkova.zelkova.domain.Board;
public interface BoardRepository extends JpaRepository<Board, Long>{
@EntityGraph(attributePaths = "imageList")
// attributePath는 '값 타입 객체'로 선언된 필드값을 join 대상 테이블로 잡아준다
@Query("select b from Board b where b.bno = :bno")
Optional<Board> selectOne(@Param("bno") Long bno);
@Modifying
@Query("update Board b set b.isDel = :flag where b.bno = :bno")
void updateToDelete(@Param("flag") boolean flag, @Param("bno") Long bno);
@Query("select b, bi from Board b left join b.imageList bi where bi.ord = 0 and b.isDel = false")
Page<Object[]> selectList(Pageable pagebale);
}
selectList를 보면
Board 테이블 안에
'imageList' 칼럼이 있다.
해당 칼럼은 테이블 형태로 저장되어 있기 때문에
join을 통해 결과물을 가져온다.
테스트 코드를 보며
좀 더 이해하기
@Test
public void testList2() {
Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());
Page<Object[]> result = boardRepository.selectList(pageable);
result.getContent().forEach(arr -> log.info(Arrays.toString(arr)));
}
조인 쿼리를 거쳐 나온 결과의 타입은
"Page<Object[]>" 이다.
해당 데이터의 형태는 아래와 같다.
Pageable 타입을 인자로 던져
Page<Object[]>로 받는 게 아직 많이 어색하다
'서버' 카테고리의 다른 글
Spring Boot3 - Security 간단 적용 (0) | 2024.09.18 |
---|---|
Spring Boot 3 - 이미지 업로드시 에러 "charset=UTF-8' is not supported" (0) | 2024.09.18 |
Java - 반복해서 문구 생성해야 할 때 java로 프로그램 만들기 (0) | 2024.08.28 |
SpringBoot에서 서버를 켰을 때 compile 에러가 나는 경우 (1) | 2024.08.23 |
Spring boot - Context Bean 생성하기 실습 (1) | 2024.08.22 |