부트캠프에서 메인프로젝트 서비스 기획을 마무리 지어갈 때 쯤 우리 서비스에서 이미지 업로드 기능을 여러곳에서 사용해야했다. 여러개의 이미지 업로드를 한번에 가능하게 해야했기에 한번도 해 본 경험이 없어 실제 구현에 앞서 샘플프로젝트를 생성해서 구현해보기로 했다.
간단하게 tymeleaf와 springboot를 사용해서 SSR방식으로 샘플 프로젝트를 작성해보았다.
Entity는 총 2개로 구성되어있다. 이미지를 여러개씩 업로드해야하기 때문에 이미지와 게시글이1:N
의 관계로 이미지를 별도의 엔티티로 분리해서 Board에서 List형식으로 참조하는 방식이 더 효율 적이라는 생각이 들었다.
| Image.class
@NoArgsConstructor
@Data
@Entity
public class Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String filename; // 파일명
private String filepath; // 파일경로
@ManyToOne
@JoinColumn(name = "board_id")
// JSON으로 변환 시, 참조하는 객체의 값을 넣지 않음
// -> Dto를 사용해서 순환 참조를 해결할 수 있지만 현재는 간단한 샘플 프로젝트로 이 방식을 사용
@JsonBackReference
private Board board; // 연관관계의 게시글
}
| Board.class
@NoArgsConstructor
@Data
@Entity
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long boardId;
private String title;
private String content;
// cascade = CascadeType.ALL : 부모 엔티티(board)에서 생성, 업데이트, 삭제되면 image도 동일하게 처리
// orphanRemoval = true : 부모 엔티티(board)에서 image를 참조 제거하면 image엔티티에서도 DB에서 삭제
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Image> images = new ArrayList<>(); // 연관된 이미지들
}
Repository는 Jpa에서 제공하는 JpaRepository인터페이스를 상속받아 사용했다.
| BoardRepository.class
@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {}
| BoardService.class
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
// 게시글 작성
public void createPost(Board board, List<MultipartFile> files) throws IOException {
List<String> fileNames = new ArrayList<>();
List<String> filePaths = new ArrayList<>();
// 파일 저장 경로 지정 - 현재 우리의 프로젝트 경로에 images 폴더를 생성하고 그 안에 저장
// System.getProperty("user.dir") : 현재 프로젝트 경로(터미널에서 pwd를 수행했을 때 나오는 경로와 동일)
// 그러나 배포 환경에서 JAR파일 내부에 파일을 저장하게 된다.
// 배포 환경에서 외부 저장소나 별도의 서버가 필요.
String imagePath = System.getProperty("user.dir") + "/src/main/resources/static/images/";
for (MultipartFile file : files) {
// 랜덤 식별자 생성(UUID로 생성) -> UUID를 사용하여 파일명 중복방지를 처리하고 있지만 DB에서 자동으로 생성하는 고유 ID값을 통해 중복 방지도 가능
String uuid = UUID.randomUUID().toString();
// 랜덤 식별자와 파일명 지정(중복 방지)
// Ex) 1234-1234-1234-1234_파일명.jpg
String filename = uuid + "_" + file.getOriginalFilename();
// java.io.File 클래스의 인스턴스 생성
// 파일 경로와 이름을 지정
// imagePath(파일경로) + filename(파일명)을 합친 문자열로 file 인스턴스 생성
File dest = new File(imagePath + filename);
// 파일 저장
// transferTo() 메서드를 호출하면 클라이언트가 업로드한 파일의 내용이 (dest)에 지정된 파일 경로로 저장됨.
file.transferTo(dest);
Image image = new Image();
image.setFilename(filename);
image.setFilepath("/images/" + filename);
image.setBoard(board);
// 파일명을 리스트에 저장
fileNames.add(filename);
// 파일 경로를 리스트에 저장
filePaths.add("/images/" + filename);
// 이미지 저장
board.getImages().add(image);
}
// board 저장
boardRepository.save(board);
}
// 특정 게시글 조회
public Board getPost(Long id) {
// findById() 메서드를 통해 id로 게시글을 찾고, 없으면 예외를 발생시킴
return boardRepository.findById(id).orElseThrow(
() -> new IllegalArgumentException("해당 게시글이 없습니다.")
);
}
// 게시글 전체 조회
public List<Board> boardList() {
return boardRepository.findAll();
}
// 게시글 삭제
public void deletePost(Long id) {
boardRepository.deleteById(id);
}
}
| BoardController
@Controller
@Transactional
@RequiredArgsConstructor
@RequestMapping("/boards")
public class BoardController {
private final static String Board_DEFAULT_URL = "/board";
private final BoardService boardService;
// 게시글 작성
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ResponseBody
// @ModelAttribute 어노테이션은 요청 파라미터를 도메인 모델에 자동으로 바인딩하는 데 사용된다. 따라서 사용하는 데이터베이스 타입 (예: H2, MySQL, PostgreSQL 등)과는 상관 없이 사용할 수 있다.
// 이 부분은 SSR방식에서 사용하기 때문에
// CSR방식으로 개발한다면 유어클래스에서 배웠던 것 처럼 @Requestbody Board board로 사용.
public ResponseEntity createPost(@ModelAttribute Board board, @RequestPart("files") List<MultipartFile> files) throws IOException {
boardService.createPost(board, files);
// 새로 생성된 게시글에 접근할 수 있는 URL을 생성
URI uri = URI.create(Board_DEFAULT_URL + board.getBoardId());
// ResponseEntity.created() 메서드를 사용하여 헤더에 `201 Created`상태코드와 함께 Location 정보를 담아서 응답
return ResponseEntity.created(uri).body(board);
}
// 게시글 조회
@Transactional(readOnly = true)
@GetMapping("/{board-id}")
public String getPost(@PathVariable("board-id") Long id, Model model) {
Board board = boardService.getPost(id);
// model은 SSR방식에서 HTML을 동적으로 생성하고 데이터를 바인딩(로딩해서 완전한 형태의 페이지를 만든다)해서 클라이언트에 전송
// 그렇기 때문에 CSR방식으로 개발하게 되는 페이지의 경우 Model 객체를 사용할 필요가 없어요!<중요>
// 페이지에서 데이터를 가져와서 표시해야 하는 경우 Model 객체가 필요.
// Model 객체에 데이터를 추가하고 해당 데이터를 뷰에서 사용하여 동적으로 HTML을 생성.
// 이는 GET 요청과 같이 사용자에게 무언가를 표시해야 하는 경우에 주로 사용됨.
model.addAttribute("board", board);
return "boardDetails";
}
// 게시글 전체 조회
@GetMapping
@Transactional(readOnly = true)
@ResponseBody
public List<Board> boardList() {
return boardService.boardList();
}
// 게시글 삭제
@DeleteMapping("/{board-id}")
@ResponseBody
public ResponseEntity deletePost(@PathVariable("board-id") Long id) {
boardService.deletePost(id);
return ResponseEntity.noContent().build();
}
}
코드에 대한 설명은 주석으로 처리하여 따로 작성하지는 않았다.
db는 H2 database를 사용했으며 파일 업로드 크기제한과 뷰 리졸버 설정 등을 .yml파일에서 설정했다.
| application.yml
... 생략
servlet:
multipart:
# 각 파일의 최대 크기와 전체 요청 본문의 최대크기 설정
max-file-size: 10MB
max-request-size: 10MB
mvc:
view:
# View Resolver의 Prefix와 Suffix를 설정
prefix: /templates/
suffix: .html
web:
resources:
static-locations: "classpath:/static/, file:src/main/resources/static/" # 정적 리소스의 위치와, 해당 위치에서 파일이 제공되도록 설정
add-mappings: true
thymeleaf:
cache: false
| BoardDetails.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Board Details</title>
</head>
<body>
<h1>Board Details</h1>
<div>
<h2 th:text="${board.title}"></h2>
<p th:text="${board.content}"></p>
<div th:each="image : ${board.images}">
<img th:src="@{${image.filepath}}" th:alt="${image.filename}" th:width="500px" th:height="500px">
</div>
</div>
</body>
</html>