spring/게시판 api

Spring boot 게시판 API 서버 제작 (18) - 게시글 - Entity 설계

얼킴 2023. 2. 28. 23:29

이번에 시간부터 게시글 기능을 구현해 보도록 하겠습니다. 먼저 게시글 기능을 위한 Entity 설계부터 해보겠습니다.

 

Entity 생성

Post

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends EntityDate {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;
    
    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    @Lob
    private String content;


    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Category category;

    @OneToMany(mappedBy = "post", cascade = CascadeType.PERSIST, orphanRemoval = true)
    private List<Image> images = new ArrayList<>();

    public Post(String title, String content, Member member, Category category, List<Image> images) {
        this.title = title;
        this.content = content;
        this.member = member;
        this.category = category;
        addImages(images);
    }

    private void addImages(List<Image> added) {
        added.stream().forEach(i -> {
            images.add(i);
            i.initPost(this);
        });
    }
}

주요 내용에 대해서만 설명하겠씁니다.

  1.  필드는 다음과 같습니다.
    1. title
    2. content
    3. member
    4. category
    5. images
  2. @Lob : 변수가 큰 객체임을 나타냄
  3. 게시글에는 image가 있는데 여러개의 image를 가질 수 있다.

Image

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Image {

    @Id @GeneratedValue
    private Long id;

    private String uniqueName;

    private String originName;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Post post;

    private final static String supportedExtension[] = {"jpg", "jpeg", "gif", "bmp", "png"};

    public Image(String originName) {
        this.uniqueName = generateUniqueName(extractExtension(originName));
        this.originName = originName;
    }

    public void initPost(Post post){
        if(this.post == null){
            this.post = post;
        }
    }

    private String generateUniqueName(String extension){
        return UUID.randomUUID().toString()+"."+extension;
    }

    private String extractExtension(String originName){
        try {
            String ext = originName.substring(originName.lastIndexOf(".") + 1);
            if(isSupportedFormat(ext)) return ext;
        }catch (StringIndexOutOfBoundsException e){}
        throw new UnsupportedImageFormatException();
    }

    private boolean isSupportedFormat(String ext) {
        return Arrays.stream(supportedExtension).anyMatch(e -> e.equalsIgnoreCase(ext));
    }
}
  1. generateUniqueName : UUID를 사용하여 고유한 파일 이름을 생성
  2. extractExtension: originName에서 확장자를 추출
  3. isSupportedFormat: 지원 가능한 이미지 확장자인지 확인
  4. initPsot : Imgae는 Post와 관계를 가지며, 이 메서드를 통해 관련 Post Entity 초기화 할 수 있다.

Image Test

Image Entity에는 uniqueName관련 기능이 들어가기 때문에 테스트를 진행하겠습니다.

class ImageTest {

    @Test
    public void createImageTest(){
        //given
        String validExtension = "JPEG";

        //when //then
        createImageWithOriginName("image." + validExtension);
    }

    @Test
    public 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);

        System.out.println("post            = " + post);
        System.out.println("image.getPost() = " + image.getPost());
        // then
        assertThat(image.getPost()).isSameAs(post);
    }

    @Test
    void initPostNotChangedTest() {
        // given
        Image image = createImage();
        image.initPost(createPost());
        System.out.println("image.getPost() = " + image.getPost());

        // when
        Post post = createPost();
        image.initPost(post);

        System.out.println("post            = " + post);
        System.out.println("image.getPost() = " + image.getPost());

        // then
        assertThat(image.getPost()).isNotSameAs(post);
    }

}

Factory

Post와 Image 에 대해서 생성 메서드들을 미리 만들어 두겠습니다.

PostFactory

public class PostFactory {
    public static Post createPost() {
        return createPost(createMember(), createCategory());
    }

    public static Post createPost(Member member, Category category) {
        return new Post("title", "content",  member, category, List.of());    }

    public static Post createPostWithImages(Member member, Category category, List<Image> images) {
        return new Post("title", "content",  member, category, images);
    }

    public static Post createPostWithImages(List<Image> images) {
        return new Post("title", "content", createMember(), createCategory(), images);
    }
}

ImageFactory

public class ImageFactory {
    public static Image createImage() {
        return new Image("origin_filename.jpg");
    }

    public static Image createImageWithOriginName(String originName) {
        return new Image(originName);
    }
}

 

Repository 생성

JpaRepository를 상속받는 PostRepository와 ImageRepository를 생성하겠습니다.

PostRepository

public interface PostRepository extends JpaRepository<Post, Long> {
}

ImageRepository

public interface ImageRepository extends JpaRepository<Image, Long> {
}

 

Post Repository 테스트

테스트는 이전 테스트들과 동일하기 때문에 설명은 생략하겠습니다.

@DataJpaTest
class PostRepositoryTest {
    @Autowired PostRepository postRepository;
    @Autowired ImageRepository imageRepository;
    @Autowired MemberRepository memberRepository;
    @Autowired CategoryRepository categoryRepository;
    @PersistenceContext EntityManager em;

    Member member;
    Category category;

    @BeforeEach
    void beforeEach(){
        member = memberRepository.save(createMember());
        category = categoryRepository.save(createCategory());
    }

    @Test
    public 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
    public 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
    public 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();
        for (Image image : images) {
            assertThat(image.getOriginName()).isEqualTo("origin_filename.jpg");
        }
    }

    @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();
    }
}

궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.

github : https://github.com/jaeyeon423/spring_board_api