이번 시간부터는 게시글 기능을 구현해보도록 하겠습니다.
게시글은 조회, 생성, 수정, 삭제가 가능하고, 각 게시글은 여러 개의 이미지를 첨부할 수 있습니다.
게시글 목록은 페이지 번호를 이용하여 조회할 수 있으며, 제목 또는 본문 등을 이용하여 동적인 조건 검색을 수행할 수 있습니다.
또한, 각 게시글에는 계층형으로 댓글을 등록할 수 있습니다.
일단 댓글은 나중으로 미루도록 하고, 게시글과 이미지를 처리하기 위한 기능을 먼저 작성해보겠습니다.
엔티티를 설계해봅시다.
entity.post 패키지에 Post 엔티티를 작성해주겠습니다.
package kukekyakya.kukemarket.entity.post;
import ...
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends EntityDate {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
@Lob
private String content;
@Column(nullable = false)
private Long price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Member member; // 1
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Category category; // 2
@OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true)
private List<Image> images; // 3
public Post(String title, String content, Long price, Member member, Category category, List<Image> images) {
this.title = title;
this.content = content;
this.price = price;
this.member = member;
this.category = category;
this.images = new ArrayList<>();
addImages(images); // 4
}
private void addImages(List<Image> added) { // 5
added.stream().forEach(i -> {
images.add(i);
i.initPost(this);
});
}
}
각각의 내용을 살펴보도록 하겠습니다.
앞서 자주 다뤘던 부분은 생략하고, 번호로 표시해둔 부분만 살펴봅시다.
1~2. 게시글의 작성자와 게시글의 카테고리를 @ManyToOne으로 설정해줍니다. 작성자 또는 카테고리가 제거된다면, 게시글 또한 제거되도록 @OnDelete(action = OnDeleteAction.CASCADE)를 지정해주었습니다.
* 데이터를 실제로 삭제하지 않고 삭제 여부만 표시하여 소프트하게 삭제할 수도 있겠지만, 기획의 편의성을 위해 카테고리 또는 작성자의 생명 주기에 의해 게시글도 제거되도록 하겠습니다.
* @OnDelete를 사용하는 이유는, 굳이 새롭게 @OneToMany라는 JPA 연관 관계를 맺을 이유가 없었기 때문입니다.
자세한 내용은,
위 링크를 참고해보시길 바랍니다.
3. 게시글이 처음 저장될 때, 게시글에 등록했던 이미지도 함께 저장되어야합니다. 따라서 cascade를 PERSIST로 설정하였습니다.
또한, 게시글이 삭제되면 모든 이미지 데이터도 삭제되어야하며,
게시글은 삭제하지 않더라도 수정 과정에서 특정한 이미지를 제거하였다면, 해당 이미지 데이터도 제거되어야합니다.
이를 위해 orphanRemoval=true로 설정하였습니다.
위 설정으로 인해 Image는 고아 객체가 되면, 데이터베이스에서 제거됩니다.
즉, 게시글이 제거되거나 게시글 이미지 수정으로 인해 연관 관계가 끊어졌을 경우, JPA에서 이를 감지하여 데이터베이스에서 제거해주는 것입니다.
4. 게시글에 포함되어야 할 Image 리스트를 생성자 파라미터로 전달받으면, addImages라는 private 메소드를 통해 이미지를 저장합니다. 해당 메소드에 대해서는 5번에서 자세히 살펴보겠습니다.
5. 게시글에 새로운 이미지 정보를 등록하는 메소드입니다. 인스턴스 변수 images에 Image를 추가하고, 해당 Image에 this(Post)를 등록해줍니다. cascade 옵션을 PERSIST로 설정해두었기 때문에, Post가 저장되면서 Image도 함께 저장될 것입니다.
이제 이어서 Image 엔티티를 작성해보겠습니다.
일단 우리의 프로젝트에서는, Image는 Post와만 연관 관계를 맺게 될 것이므로, Post와 동일한 패키지에 작성해주도록 하겠습니다. (편의에 따라 다른 곳에 작성하셔도 됩니다.)
package kukekyakya.kukemarket.entity.post;
import kukekyakya.kukemarket.exception.UnsupportedImageFormatException;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import javax.persistence.*;
import java.util.Arrays;
import java.util.UUID;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Image {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String uniqueName;
@Column(nullable = false)
private String originName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Post post; // 1
private final static String supportedExtension[] = {"jpg", "jpeg", "gif", "bmp", "png"}; // 2
public Image(String originName) {
this.uniqueName = generateUniqueName(extractExtension(originName)); // 3
this.originName = originName;
}
public void initPost(Post post) { // 4
if(this.post == null) {
this.post = post;
}
}
private String generateUniqueName(String extension) { // 5
return UUID.randomUUID().toString() + "." + extension;
}
private String extractExtension(String originName) { // 6
try {
String ext = originName.substring(originName.lastIndexOf(".") + 1);
if(isSupportedFormat(ext)) return ext;
} catch (StringIndexOutOfBoundsException e) { }
throw new UnsupportedImageFormatException();
}
private boolean isSupportedFormat(String ext) { // 7
return Arrays.stream(supportedExtension).anyMatch(e -> e.equalsIgnoreCase(ext));
}
}
uniqueName : 이미지의 구분을 위해 고유한 이름을 부여해줄 것입니다.
originName : 원래 이미지의 이름입니다.
이번에도 번호로 표시해둔 부분만 살펴봅시다.
1. Image는 Post와 연관이 맺어져있을 경우에만 저장될 수 있도록, nullable=false로 설정하였습니다.
또한, 게시글이 제거되면 이미지도 연쇄적으로 제거될 수 있도록, @OnDelete를 지정하였습니다.
2. 해당 이미지가 지원하는 이미지 확장자입니다.
이에 대한 사항은 서비스 로직에 포함시켜야할지 고민했지만, 우리는 게시글에 관한 PostService와 실제 파일 업로드 및 제거를 위한 FileService를 분할하여 작성할 예정입니다.
FileService는 단지 파일에 대하여 업로드하고 제거해주는 작업입니다. 그 파일이 이미지든, 텍스트 파일이든 신경쓰지 않습니다. FileService에서 우리가 지원하는 이미지 확장자에 대해 검증해주는 것은, 본연의 임무가 아닐 것입니다.
그렇다면 PostService에서 검사할 수도 있겠지만, 어차피 Image 본인은 자신이 지원할 수 있는 타입이 무엇인지 알아야합니다.
PostService가 없더라도, Image는 자신이 지원하는 이미지만 납득할 수 있어야하는 것입니다.
그렇기에 Image에서 이를 포함하고, 인스턴스 생성 과정에서 검사할 수 있도록 하였습니다.
3. 생성자에서 각 Image의 고유한 이름을 만들어줍니다.
4. Post에서 Image를 추가할 때 호출하던 메소드입니다
Post의 연관 관계에 대한 정보가 없다면 이를 등록해줍니다.
이미지는 작성된 게시글에 소속되어야하므로, 다른 게시글로 연관 관계가 뒤바뀌면 안됩니다.
이를 위해 this.post가 null일 때만 초기화되도록 하였습니다.
post는 nullable=false이고, initPost 메소드는 Image가 처음 Post에 등록될 때 호출되므로, 연관 관계 정보가 없어지거나 뒤바뀌는 상황을 제한할 수 있을 것입니다.
만약 연관 관계 정보가 없어진다면, Post에 설정해두었던 orphanRemoval=true 옵션에 의해 해당 Image도 제거될 것입니다.
5. 고유한 이름을 생성하기 위한 메소드입니다. 여기에서는 단순하게 UUID를 이용하였습니다. 시간 등을 이용하여 필요에 맞게 조절하면 될 것입니다.
6. 이미지 이름에서 확장자를 추출해냅니다.
지원하지 않는 포맷이거나 확장자가 없어서 StringIndexOutOfBoundsException 예외가 발생한다면, UnsupportedImageFormatException 예외가 발생하게 됩니다.
7. 지원하는 확장자인지 확인합니다.
이제 엔티티 작성이 끝났으면, Image 엔티티의 동작을 테스트해주겠습니다.
이전에는 엔티티에서 특정한 동작이 없었기 때문에 Repository에 대한 테스트와 동시에 진행하였는데,
이번에는 Image 엔티티에서 많은 것들을 수행해주므로 JPA에 대한 테스트가 아니라, 객체에 대한 동작을 별도로 테스트 해주겠습니다.
test 디렉토리에서 동일한 패키지 경로 내에 ImageTest를 작성해주겠습니다.
package kukekyakya.kukemarket.entity.post;
import ...
class ImageTest {
@Test
void createImageTest() {
// given
String validExtension = "JPEG";
// when, then
createImageWithOriginName("image." + validExtension);
}
@Test
void createImageExceptionByUnsupportedFormatTest() {
// given
String invalidExtension = "invalid";
// when, then
assertThatThrownBy(() -> createImageWithOriginName("image." + invalidExtension))
.isInstanceOf(UnsupportedImageFormatException.class);
}
@Test
void createImageExceptionByNoneExtensionTest() {
// given
String originName = "image";
// when, then
assertThatThrownBy(() -> createImageWithOriginName(originName))
.isInstanceOf(UnsupportedImageFormatException.class);
}
@Test
void initPostTest() {
// given
Image image = createImage();
// when
Post post = createPost();
image.initPost(post);
// then
assertThat(image.getPost()).isSameAs(post);
}
@Test
void initPostNotChangedTest() {
// given
Image image = createImage();
image.initPost(createPost());
// when
Post post = createPost();
image.initPost(post);
// then
assertThat(image.getPost()).isNotSameAs(post);
}
}
테스트는 이미 익숙해졌으므로, 자세한 설명은 생략하겠습니다.
인스턴스 생성을 위해 사용된 팩토리 클래스 ImageFactory와 PostFactory는 다음과 같습니다.
package kukekyakya.kukemarket.factory.entity;
import kukekyakya.kukemarket.entity.post.Image;
public class ImageFactory {
public static Image createImage() {
return new Image("origin_filename.jpg");
}
public static Image createImageWithOriginName(String originName) {
return new Image(originName);
}
}
package kukekyakya.kukemarket.factory.entity;
import ...
public class PostFactory {
public static Post createPost() {
return createPost(createMember(), createCategory());
}
public static Post createPost(Member member, Category category) {
return new Post("title", "content", 1000L, member, category, List.of());
}
public static Post createPostWithImages(Member member, Category category, List<Image> images) {
return new Post("title", "content", 1000L, member, category, images);
}
public static Post createPostWithImages(List<Image> images) {
return new Post("title", "content", 1000L, createMember(), createCategory(), images);
}
}
자세한 설명은 생략하겠습니다.
이제 repositpry.post 패키지에 PostRepository와 ImageRepository를 만들어줍시다.
package kukekyakya.kukemarket.repository.post;
import kukekyakya.kukemarket.entity.post.Post;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostRepository extends JpaRepository<Post, Long> {
}
package kukekyakya.kukemarket.repository.post;
import kukekyakya.kukemarket.entity.post.Image;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImageRepository extends JpaRepository<Image, Long> {
}
지금 단계에서는 단순히 리포지토리만 정의해주었습니다.
이제 리포지토리를 테스트하면서, 엔티티에 지정해둔 설정들도 함께 테스트해봅시다.
test 디렉토리에서 동일한 패키지 경로 내에 PostRepositoryTest를 작성해줍니다.
Post와 Image는 설정해둔 옵션에 의해 생명 주기가 동일할 것이므로, PostRepositoryTest를 통해서 한 번에 테스트하도록 하겠습니다.
package kukekyakya.kukemarket.repository.post;
import ...
@DataJpaTest
class PostRepositoryTest {
@Autowired PostRepository postRepository;
@Autowired MemberRepository memberRepository;
@Autowired CategoryRepository categoryRepository;
@Autowired ImageRepository imageRepository;
@PersistenceContext EntityManager em;
Member member;
Category category;
@BeforeEach
void beforeEach() {
member = memberRepository.save(createMember());
category = categoryRepository.save(createCategory());
}
@Test
void createAndReadTest() { // 생성 및 조회 검증
// given
Post post = postRepository.save(createPost(member, category));
clear();
// when
Post foundPost = postRepository.findById(post.getId()).orElseThrow(PostNotFoundException::new);
// then
assertThat(foundPost.getId()).isEqualTo(post.getId());
assertThat(foundPost.getTitle()).isEqualTo(post.getTitle());
}
@Test
void deleteTest() { // 삭제 검증
// given
Post post = postRepository.save(createPost(member, category));
clear();
// when
postRepository.deleteById(post.getId());
clear();
// then
assertThatThrownBy(() -> postRepository.findById(post.getId()).orElseThrow(PostNotFoundException::new))
.isInstanceOf(PostNotFoundException.class);
}
@Test
void createCascadeImageTest() { // 이미지도 연쇄적으로 생성되는지 검증
// given
Post post = postRepository.save(createPostWithImages(member, category, List.of(createImage(), createImage())));
clear();
// when
Post foundPost = postRepository.findById(post.getId()).orElseThrow(PostNotFoundException::new);
// then
List<Image> images = foundPost.getImages();
assertThat(images.size()).isEqualTo(2);
}
@Test
void deleteCascadeImageTest() { // 이미지도 연쇄적으로 제거되는지 검증
// given
Post post = postRepository.save(createPostWithImages(member, category, List.of(createImage(), createImage())));
clear();
// when
postRepository.deleteById(post.getId());
clear();
// then
List<Image> images = imageRepository.findAll();
assertThat(images.size()).isZero();
}
@Test
void deleteCascadeByMemberTest() { // Member가 삭제되었을 때 연쇄적으로 Post도 삭제되는지 검증
// given
postRepository.save(createPostWithImages(member, category, List.of(createImage(), createImage())));
clear();
// when
memberRepository.deleteById(member.getId());
clear();
// then
List<Post> result = postRepository.findAll();
Assertions.assertThat(result.size()).isZero();
}
@Test
void deleteCascadeByCategoryTest() { // Category가 삭제되었을 때 연쇄적으로 Post도 삭제되는지 검증
// given
postRepository.save(createPostWithImages(member, category, List.of(createImage(), createImage())));
clear();
// when
categoryRepository.deleteById(category.getId());
clear();
// then
List<Post> result = postRepository.findAll();
assertThat(result.size()).isZero();
}
void clear() {
em.flush();
em.clear();
}
}
이번에도 자세한 설명은 생략하겠습니다.
설명은 생략하더라도, 설정해둔 JPA 옵션이 많으므로 각 테스트를 자세히 살펴보기 바랍니다.
어떠한 테스트인지 각 테스트마다 짤막한 주석을 달아두었습니다.
사용된 예외 PostNotFoundException과 UnsupportedImageFormatException은 exception 패키지에 정의되어있습니다.
package kukekyakya.kukemarket.exception;
public class PostNotFoundException extends RuntimeException {
}
package kukekyakya.kukemarket.exception;
public class UnsupportedImageFormatException extends RuntimeException {
}
자세한 설명은 생략하겠습니다.
이번 시간에는 게시글과 이미지에 대한 엔티티 두 개와 리포지토리를 작성하고, 테스트해보는 시간을 가졌습니다.
다음 시간에는 이렇게 생성된 엔티티와 리포지토리를 이용하여 게시글 기능을 이어서 작성해보겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (22) - 게시글 - 조회 (4) | 2021.12.12 |
---|---|
스프링부트 게시판 API 서버 만들기 (21) - 게시글 - 생성 (4) | 2021.12.12 |
스프링부트 게시판 API 서버 만들기 (19) - Entity Graph로 LAZY 전략에서 N + 1 문제 해결 (0) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (18) - JPA 오해 바로잡기 (delete와 deleteById의 차이) (0) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (17) - 게시판 - 계층형 카테고리 - 3 (4) | 2021.12.08 |