본문 바로가기
개인 프로젝트/CRUD 웹 게시판 제작하기

[CRUD 웹 게시판 만들기] 3. 글, 댓글 Domain 제작, 글 CRUD 기능 개발

by 카펀 2022. 7. 24.

* 본 시리즈는 1인 개발 'CRUD 웹 게시판 만들기'를 진행하며 과정을 기록한 것입니다.

* 해당 프로젝트는 GitHub 저장소를 참고해 주세요.

 

목차

1. 프로젝트 구조 변경

2. Posts 도메인 제작

3. CRUD 메소드 작성

4. 작성한 CRUD 메소드에 대한 테스트 코드 작성

5. Comments 도메인 제작

 

1. 프로젝트 구조 변경

기존의 프로젝트 구조는 이전에 책을 따라 진행했던 프로젝트인 '혼자 구현하는 웹서비스' 의 형태를 매우 닮아 있었습니다.

프로젝트를 처음 시작할 때는 Spring에 대한 이해도가 지금보다 부족했고, 어떤 식으로 구조를 가져가야 할지 판단이 서지 않았습니다.

 

현재는 아래와 같은 형식으로 변경하였습니다.

기본적으로 개발하며 생기는 클래스는 src/main/java/com/tistory/katfun/crud 아래에 위치하게 됩니다.

JPA에 사용되는 domain은 한 군데로 모았고, 각 도메인별로 필요한 Controller, Service, ServiceImpl, Domain 등은 하나의 디렉토리로 모았습니다. 이 때 Dto는 여러 개가 존재하게 되므로, dto 디렉토리를 별도로 만들었습니다.

위와 같은 형식이면, 앞으로 도메인이 꾸준히 추가되어도 관련된 Controller 관련 클래스를 효율적으로 관리할 수 있을 것 같습니다. (Comments에 대한 Controller, Service 등 역시 위처럼 만들어지겠죠?)

 

이 구조가 정답인지, 혹은 더 좋은 구조가 있는지는 잘 모르겠지만, 앞으로 개발을 진행하며 직접 장단점을 체험해 보려 합니다.

2. Posts 도메인 제작

게시판의 기본이 되는 '글 등록'을 위한 도메인을 제작했습니다.

 

package com.tistory.katfun.crud.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.Date;

@Getter
@NoArgsConstructor
@Entity
public class Posts {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long postId;

    @Column(length = 100, nullable = false)
    private String category;

    @Column(length = 500, nullable = false)
    private String title;

    @Column(length = 100, nullable = false)
    private String createId;

    @Column(nullable = false)
    private Date createTime;

    @Column(nullable = false)
    private Date lastEditTime;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    @Column(nullable = false)
    private int viewCount;

    @Builder
    public Posts(Long postId, String category, String title, String createId, Date createTime, Date lastEditTime, String content, int viewCount) {
        this.postId = postId;
        this.category = category;
        this.title = title;
        this.createId = createId;
        this.createTime = createTime;
        this.lastEditTime = lastEditTime;
        this.content = content;
        this.viewCount = viewCount;
    }

    public void update(String title, String content) {
        this.title = title;
        this.lastEditTime = new Date(System.currentTimeMillis());
        this.content = content;
    }

}

Domain은 테이블에 대한 설계라고도 할 수 있는데요.

회사에서는 MyBatis를 쓰다 보니 익숙하지 않은 내용이라 약간의 스터디를 진행한 후에 작성하였습니다.

각 게시글에 필요할 수 있다고 생각하는 내용을 추가하였는데요.

글에는 고유 ID (PK), 카테고리, 제목, 생성자 ID, 생성시간, 최근수정시간, 내용, 조회수를 포함하였습니다. 

 

JPA에 대한 내용입니다.

@GeneratedValue(strategy = GenerationType.IDENTITY) 는 DB 내 각 레코드의 PK를 auto increment 형식으로 DB에 위임한다는 뜻입니다.

따라서 특정한 값을 지정해 주지 않는다면, DB가 알아서 키를 생성해 줍니다. (출처)

 

@Column은 각 행의 내용을 정의하는 어노테이션인데요.

  • nullable은 해당 칼럼의 값이 null이 될 수 있는지를 나타냅니다. 따로 지정하지 않는다면 기본값은 true입니다.
  • length는 VARCHAR 형태의 칼럼의 최대 길이를 지정합니다. 기본값은 255로 지정되어 있습니다.
  • columnDefinition = "TEXT": VARCHAR 기본 제한인 255를 풀어줍니다.

위 내용은 전부 null이면 안 될 것이라고 판단하여 nullable = false로 지정해 줬고요.

String 형태로 지정하는 칼럼은 length를 통해 길이를 지정하였고, 글 본문은 columnDefinition = "TEXT"를 통해 글 본문을 저장할 수 있도록 하였습니다.

 

3. CRUD 메소드 작성 및 테스트 코드 작성

이와 관련된 메소드 작성은 앞서 [HTTP] HTTP 메소드와 사용 예 글에서도 다룬 바 있는데요.

 

먼저 Controller는 아래와 같이 작성하였습니다.

package com.tistory.katfun.crud.posts;

import com.tistory.katfun.crud.posts.dto.PostsResponseDto;
import com.tistory.katfun.crud.posts.dto.PostsSaveRequestDto;
import com.tistory.katfun.crud.posts.dto.PostsUpdateRequestDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
public class PostsController {

    private final PostsService postsService;

    @Autowired
    public PostsController(PostsService postsService) {
        this.postsService = postsService;
    }

    // 게시물 목록 조회
    @GetMapping("/posts")
    public void selectPostsList() {
        // 조회할 때 받는 검색조건, 페이징 등의 정보가 있을 것
    }

    // 게시물 내용 조회
    @GetMapping("/posts/{postId}")
    public PostsResponseDto view (@PathVariable Long postId) {
        // 조회할 권한이 있다면 게시글 내용을 조회한다.
        return postsService.viewPost(postId);
    }

    // 게시물 추가
    @PostMapping("/posts/{postId}")
    public Long addPost(@RequestBody PostsSaveRequestDto requestDto, @PathVariable String postId) {
        // 로그인 한 상태라면, 누구나 글을 쓸 수 있다.
        // 회원 ID, 게시판 ID를 키값으로 한다.
        return postsService.savePost(requestDto);
    }

    // 게시물 수정
    @PatchMapping("/posts/{postId}")
    public Long editPost(@PathVariable Long postId, @RequestBody PostsUpdateRequestDto requestDto) {
        // 1. 본인이 해당 게시물의 생성자이거나, 2. 관리자라면
        // 수정할 수 있다.
        // 해당 권한 체크 로직 구현 필요
        return postsService.updatePost(postId, requestDto);
    }

    // 게시물 삭제
    @DeleteMapping("/posts/{postId}")
    public void deletePost(@PathVariable Long postId) {
        // 1. 본인이 해당 게시물의 생성자이거나, 2. 관리자라면
        // 수정할 수 있다.
        // 해당 권한 체크 로직 구현 필요
        postsService.deletePost(postId);
    }
}

위 코드에서 신경 쓴 부분은 아래와 같습니다.

1. 생성자를 통한 의존 관계 주입

2. 여러 HTTP 메서드를 활용한 CRUD 메소드 작성 및 URL 지정

 

생성자를 통한 의존 관계 주입 역시 이전에 Dependency Injection을 수행하는 세 가지 방법 글에서 다룬 바 있습니다.

PostsController는 PostsService에 의존하게 되었고, 이를 생성자를 통해 주입하도록 해 주었습니다.

 

URL은 리소스가 '글' 이므로, posts로 접근하도록 하였고, 각 글을 식별하기 위해 postId로 구분하도록 하였습니다.

메소드는 각각 GET(조회), PUT(생성), PATCH(수정), DELETE(삭제)를 사용하였습니다.

주석을 보시면 권한 관리에 대한 내용이 적혀 있는데요.

추후 Users 도메인을 만들 때, 각 계정의 권한을 지정하고 (NORMAL, ADMIN), 해당 권한을 체크하는 로직을 포함시킬 계획입니다.

 

다음으로 Service (ServiceImpl) 입니다.

package com.tistory.katfun.crud.posts;

import com.tistory.katfun.crud.domain.Posts;
import com.tistory.katfun.crud.posts.dto.PostsResponseDto;
import com.tistory.katfun.crud.posts.dto.PostsSaveRequestDto;
import com.tistory.katfun.crud.posts.dto.PostsUpdateRequestDto;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;
import java.util.List;

@Service
public class PostsServiceImpl implements PostsService {

    private final PostsRepository postsRepository;

    @Autowired
    public PostsServiceImpl(PostsRepository postsRepository) {
        this.postsRepository = postsRepository;
    }

    @Override
    public List<Posts> selectPostsList() {
        return postsRepository.findAll();
    }

    @Transactional
    @Override
    public Long savePost(PostsSaveRequestDto requestDto) {
        return postsRepository.save(requestDto.toEntity()).getPostId();
    }

    @Override
    public PostsResponseDto viewPost (Long postId) {
        Posts entity = postsRepository.findById(postId)
                .orElseThrow(() -> new IllegalArgumentException(
                        "해당 게시물이 존재하지 않습니다. postId = " + postId
                ));

        return new PostsResponseDto(entity);
    }

    @Transactional
    @Override
    public Long updatePost(Long postId, PostsUpdateRequestDto requestDto) {
        Posts posts = postsRepository.findById(postId)
                .orElseThrow(() -> new IllegalArgumentException(
                        "해당 게시물이 존재하지 않습니다. postId = " + postId
                ));

        posts.update(requestDto.getTitle(), requestDto.getContent());

        return postId;
    }

    @Transactional
    @Override
    public Long deletePost(Long postId) {
        Posts posts = postsRepository.findById(postId)
                .orElseThrow(() -> new IllegalArgumentException(
                        "해당 게시물이 존재하지 않습니다. postId = " + postId
                ));

        postsRepository.delete(posts);
        return postId;
    }
}

먼저, Service의 경우 인터페이스를 먼저 만들고, 구현체를 추가했는데요.

현재로써 계획은 없지만 나중에 구현체를 바꿔야 할 경우를 생각해서 위와 같이 구현했습니다.

 

@Transactional 어노테이셔는 DB에 변화가 일어나는 경우에 붙였습니다.

즉, 단순 조회의 경우에는 @Transactional을 붙이지 않았지만, CREATE, UPDATE, DELETE의 경우에는 DB 내의 리소스에 접근을 하므로, 어노테이션을 붙였습니다.

이 어노테이션을 붙인 가장 큰 목적은 이후 테스트 코드를 작성할 때를 생각해서인데요. 관련 내용은 아래에 테스트코드 관련 내용에 적겠습니다.

 

조회, 수정, 삭제의 경우에는 기존에 존재하는 글을 postId를 통해 찾고, 그 다음 작업을 수행하는데요.

글을 찾을 수 없는 경우에는 IllegalArgumentException을 던지고, 에러 메세지를 출력합니다.

이 내용이 반복되는 것 같아서 추후에는 코드를 줄일 수 있는 방법을 고민해 보려고 합니다.

 

Repository는 단순히 JpaRepository를 상속받았구요.

package com.tistory.katfun.crud.posts;

import com.tistory.katfun.crud.domain.Posts;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostsRepository extends JpaRepository<Posts, Long> {

}

 

Dto는 추가, 수정의 경우에만 별도로 작성하였습니다.

 

PostsResponseDto.java

@Getter
public class PostsResponseDto {

    private Long postId;
    private String category;
    private String title;
    private String createId;
    private Date createTime;
    private Date lastEditTime;
    private String content;
    private int viewCount;

    public PostsResponseDto(Posts entity) {
        this.postId = entity.getPostId();
        this.category = entity.getCategory();
        this.title = entity.getTitle();
        this.createId = entity.getCreateId();
        this.createTime = entity.getCreateTime();
        this.lastEditTime = entity.getLastEditTime();
        this.content = entity.getContent();
        this.viewCount = entity.getViewCount();
    }
}

PostsSaveRequestDto.java

@Getter
@NoArgsConstructor
public class PostsSaveRequestDto {

    private String category;
    private String title;
    private String createId;
    private Date createTime;
    private Date lastEditTime;
    private String content;
    private int viewCount;

    @Builder
    public PostsSaveRequestDto(String category, String title, String createId, Date createTime, Date lastEditTime, String content, int viewCount) {
        this.category = category;
        this.title = title;
        this.createId = createId;
        this.createTime = createTime;
        this.lastEditTime = lastEditTime;
        this.content = content;
        this.viewCount = viewCount;
    }

    public Posts toEntity() {
        return Posts.builder()
                .category(category)
                .title(title)
                .createId(createId)
                .createTime(createTime)
                .lastEditTime(lastEditTime)
                .content(content)
                .viewCount(viewCount)
                .build();
    }

}

PostsUpdateRequestDto.java

@Getter
@NoArgsConstructor
public class PostsUpdateRequestDto {

    private String title;
    private String content;
    private Date lastEditDate;
    private int viewCount;

    @Builder
    public PostsUpdateRequestDto(String title, String content, int viewCount) {
        this.title = title;
        this.content = content;
        this.lastEditDate = new Date(System.currentTimeMillis());
        this.viewCount = viewCount;
    }
}

위 클래스는 각자 PostsServiceImpl에서 호출하게 됩니다.

 

보시면 날짜를 지정할 때 @Builder 아래에서 수정 시간을 받아 오도록 되어 있습니다.

이를 추후에 JPA Auditing을 이용하여 자동화하도록 수정할 계획입니다.

 

4. 작성한 CRUD 메소드에 대한 테스트 코드 작성

작성한 Controller와 Repository에 대해 테스트 코드를 작성하였습니다.

 

PostsControllerTest.java

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class PostsControllerTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private PostsRepository postsRepository;

    @BeforeEach
    public void setup() {
        restTemplate.getRestTemplate().setRequestFactory(new HttpComponentsClientHttpRequestFactory());
    }

    @AfterEach
    public void rollback() {
        postsRepository.deleteAll();
    }

//    @Transactional
    @Test
    @DisplayName("게시물이_등록된다")
    public void postsCreate() throws Exception {

        // given
        LocalDate now = LocalDate.now();

        String category = "질문";
        String title = "안녕하세요 JPA에 대해 질문이 있습니다";
        String createId = "KAKAO00001";
        Date createTime = new Date(System.currentTimeMillis());
        Date lastEditTime = new Date(System.currentTimeMillis());
        String content = "안녕하세요, 질문이 있어서 게시물을 작성합니다. \nJPQL은 이렇게 쓰는게 맞나요?";
        int viewCount = 0;

        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .category(category)
                .title(title)
                .createId(createId)
                .createTime(createTime)
                .lastEditTime(lastEditTime)
                .content(content)
                .viewCount(viewCount)
                .build();

        String postId = requestDto.getCreateId();

        String url = "http://localhost:" + port + "/posts/" + postId;

        // when
        ResponseEntity<Long> responseEntity = restTemplate.postForEntity(url, requestDto, Long.class);

        // then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> createdPosts = postsRepository.findAll();
        assertThat(createdPosts.get(0).getCategory()).isEqualTo(category);
        assertThat(createdPosts.get(0).getTitle()).isEqualTo(title);
        assertThat(createdPosts.get(0).getCreateId()).isEqualTo(createId);
//        assertThat(createdPosts.get(0).getCreateTime()).isEqualTo(createTime);
//        assertThat(createdPosts.get(0).getLastEditTime()).isEqualTo(lastEditTime);
        assertThat(createdPosts.get(0).getContent()).isEqualTo(content);
        assertThat(createdPosts.get(0).getViewCount()).isEqualTo(viewCount);
    }

//    @Transactional
    @Test
    @DisplayName("게시물이_수정된다")
    public void postEdit() throws Exception {

        // given
        LocalDate now = LocalDate.now();

        String category = "질문";
        String title = "안녕하세요 JPA에 대해 질문이 있습니다";
        String createId = "KAKAO00001";
        Date createTime = new Date(System.currentTimeMillis());
        Date lastEditTime = new Date(System.currentTimeMillis());
        String content = "안녕하세요, 질문이 있어서 게시물을 작성합니다. \nJPQL은 이렇게 쓰는게 맞나요?";
        int viewCount = 0;

        Posts savedPosts = postsRepository.save(Posts.builder()
                .category(category)
                .title(title)
                .createId(createId)
                .createTime(createTime)
                .lastEditTime(lastEditTime)
                .content(content)
                .viewCount(viewCount)
                .build()
        );

        Long postId = savedPosts.getPostId();
        String title2 = "(완료) 안녕하세요 JPA에 대해 질문이 있습니다";
        String content2 = "괜찮습니다, 질문은 해결했습니다. 감사합니다.";

        PostsUpdateRequestDto requestDto = PostsUpdateRequestDto.builder()
                .title(title2)
                .content(content2)
                .build();

        String url = "http://localhost:" + port + "/posts/" + postId;

        HttpEntity<PostsUpdateRequestDto> requestEntity = new HttpEntity<>(requestDto);

        // when
        ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.PATCH, requestEntity, Long.class);

        // then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(responseEntity.getBody()).isGreaterThan(0L);

        List<Posts> editedPosts = postsRepository.findAll();
        assertThat(editedPosts.get(0).getTitle()).isEqualTo(title2);
        assertThat(editedPosts.get(0).getContent()).isEqualTo(content2);
    }

    //    @Transactional
    @Test
    @DisplayName("게시물이_삭제된다")
    public void postDelete() throws Exception {

        // given
        LocalDate now = LocalDate.now();

        String category = "질문";
        String title = "안녕하세요 JPA에 대해 질문이 있습니다";
        String createId = "KAKAO00001";
        Date createTime = new Date(System.currentTimeMillis());
        Date lastEditTime = new Date(System.currentTimeMillis());
        String content = "안녕하세요, 질문이 있어서 게시물을 작성합니다. \nJPQL은 이렇게 쓰는게 맞나요?";
        int viewCount = 0;

        Posts savedPosts = postsRepository.save(Posts.builder()
                .category(category)
                .title(title)
                .createId(createId)
                .createTime(createTime)
                .lastEditTime(lastEditTime)
                .content(content)
                .viewCount(viewCount)
                .build()
        );

        Long postId = savedPosts.getPostId();
        String url = "http://localhost:" + port + "/posts/" + postId;

        // when
        ResponseEntity<Long> responseEntity = restTemplate.exchange(url, HttpMethod.DELETE, HttpEntity.EMPTY, Long.class);

        // then
        assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);

        List<Posts> editedPosts = postsRepository.findAll();
        assertThat(editedPosts).isEmpty();
    }
}

원래는 @Transactional을 이용하여, 테스트가 수행된 후에 DB에 만들어진 변경 사항이 commit 되지 않고, rollback 되도록 하려고 했습니다.

하지만 postDelete("게시물이 삭제된다")에서 문제가 생겼습니다.

먼저 글을 등록하고, 그 이후에 수정을 하는데요.

글을 등록하고 나서 바로 롤백이 되어 버려서, 수정을 하려고 글을 조회하는데 해당 글을 찾지 못하는 현상이 발생했습니다.

 

그래서 Controller를 테스트 할 때는 @Transactional을 이용하기 어려울 것 같아서, @Transactional은 주석 처리 해 두고 @AfterEach에 DB 내의 모든 내용을 삭제하도록 하였습니다.

이 테스트 코드는 지금은 문제가 없지만, 실제 운영 DB에 붙어 있다면 실행하면 안 됩니다.

 

5. Comments 도메인 제작

Comments 도메인 역시 앞의 Posts 도메인과 유사합니다.

 

Comments.java

package com.tistory.katfun.crud.domain;

import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;

import javax.persistence.*;
import java.util.Date;

@Getter
@NoArgsConstructor
@Entity
public class Comments {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long commentId;

    @Column(nullable = false)
    private String postId;

    @Column(length = 100, nullable = false)
    private String createId;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    @Column(nullable = false)
    private Date lastEditTime;

    @Type(type="yes_no")
    @Column(length = 1)
    private Boolean secretYn;

    @Type(type="yes_no")
    @Column(length = 1)
    private Boolean editedYn;

}

대부분의 내용은 Posts에서와 비슷한데요.

@Type(type="yes_no") 는 JPA에서 자체적으로 지원하는 어노테이션으로, Java 코드 상에서 Boolean 값을 DB에 Y/N으로 지정하여 넣어 준다고 합니다 (출처).

 

마무리

현재까지는 게시판으로써 기본적인 뒷단 기능을 개발 중입니다.

어느 정도 완성이 되고 나면, 간단한 Vue.js 스터디를 진행한 후 앞단 화면을 만들고, AWS 환경에 배포한 후에, 추가 기능을 개발해 나가려 합니다.

댓글