[Project] 나만의 블로그 만들기 (3) - 게시글 CRUD 구현

    Intro


    ※ 해당 포스팅은, 프론트엔드에서는 게시글 작성 시에 Toast UI Editor을 사용했고, 백엔드 구현을 위주로 작성되었습니다.

    일단 게시글 CRUD 를 적용함에 있어 다음과 같이 구조를 가져가기로 했다

     

    게시글 C : 글쓰기 버튼을 누를 경우 빈 내용의 데이터를 생성한다

    게시글 R : 메인 화면에는, 최근 9개의 게시글을 보여주고 Blog 페이지에서는 tailwind-nextjs-starter-blog의 양식을 같은 형태로 가져간다.(사실 그대로 가져다 쓰는게 이쁘지만 내가 하나 만들어보고 싶었다.

     

    디자인은 다음과 같이 가져가보려고 한다.

    최근 9개 게시글(Velog 같은 카드의 형태처럼)

    Blog 페이지는 기존과 같이 가져간다. (https://tailwind-nextjs-starter-blog.vercel.app/blog 참고)

     

    게시글 U : 기존에 작성된 게시글 수정 및 Create으로 만든 빈 내용의 게시글을 새로 생성할 때

    즉 다음과 같은 구조이다.

    글쓰기 버튼 클릭 -> 빈 내용의 글 내용 생성 후 DB 저장 -> 실제로 글쓰기를 완료했을 때 내용 및 Complete 컬럼 true로 업데이트

    글쓰기 수정 클릭 -> 글쓰기 내용 수정

    이렇게 굳이 번거로운 구조를 한 이유는 임시저장 기능을 가져가고 싶어서 였다. 실제로 티스토리에 있는 임시저장 기능을 생각했으며 이전에 마무리하지 못한 프로젝트에서 말씀해주셨던 로직을 한 번 완성해보고 싶었다.

     

    게시글 D : Soft Delete방식을 설정. 실제로 Delete 쿼리를 날리지 않고, deleted_at 컬럼에 현재 날짜를 표기하는 것으로 한다. 즉, 게시글 조회 시에는 deleted_at이 null인 데이터만 가져오게 한다. (많은 프로젝트에서 사용하는 방식이라고 하며, 내가 실수로 지운 게시글의 데이터를 다시 살리고 싶을 수도 있을 것 같다고 생각했다)

     

    댓글 CRUD의 경우, 크게 다르지 않게 생각하는 그대로 가져가기로 했다. 하지만, 추후 대댓글 등의 기능을 추가해보면 좋을 것이라고 생각했다.

     

     

    그럼 한번 만들어보자


    프론트 쪽 구조를 간단하게 먼저 작성하고 넘어가겠다.


    [Main.tsx]

    export default function Home() {
      const [contents, setContents] = useState<ContentItem[]>([]) # 가져온 데이터를 세팅하고
    
      useEffect(() => {
        const fetchContents = async () => {
          const response = await fetchData()
          setContents(response.data.contents)
        }
    
        fetchContents()
      }, [])
    
      return (
        <>
          <div className="divide-y divide-gray-200 dark:divide-gray-700">
            <div className="divide-y divide-gray-200 dark:divide-gray-700">
              <div className="space-y-2 pb-8 pt-6 md:space-y-5">
                <h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
                  최근 게시물
                </h1>
              </div>
              <div className="container py-12">
                <div className="-m-4 flex flex-wrap">
                  {contents.map((content, index) => ( # 데이터 배열을 순회하며
                    <RecentPostCard key={index} data={content} /> # RecentPostCard 에서 사용
                  ))}
                </div>
              </div>
            </div>
          </div>
        </>
      )
    }

     

    블로그를 시작하면 처음 볼 수 있는 화면에서는, useState을 이용해서 최근 9개 게시글에 대한 정보를 set하고, 이를 RecentPostCard 라는 컴포넌트로 전달해서 사용하기로 했다. 또한 최대크기에서 한 

     

    [RecentPostCard.tsx]

    const RecentPostCard = ({ data }) => {
      const href: string = '/blog/' + data.content.contentId
      const imgSrc = ''
    
      return (
        <div className="md max-h-full max-w-full p-4 md:w-1/3"> # 크기가 충분할 때에는 한 줄에 3개의 카드만 들어갈 수 있도록 설정
          <div
            className={`${
              imgSrc && 'h-full'
            }  overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}
          >
            {imgSrc && ( # 이미지가 존재할 경우, 이미지도 넣어보자
              <Link href={href} aria-label={`Link to ${data.content.title}`}>
                <Image
                  alt={data.content.title}
                  src={imgSrc}
                  className="object-cover object-center md:h-24 lg:h-32"
                  width={362}
                  height={204}
                />
              </Link>
            )}
            <div className="p-6">
              <h2 className="mb-3 line-clamp-2 min-h-[64px] overflow-ellipsis whitespace-normal break-words text-xl font-bold leading-8 tracking-tight">
                <Link href={href} aria-label={`Link to ${data.content.title}`}>
                  {data.content.title}
                </Link>
              </h2>
              <p className="prose mb-3 line-clamp-4 min-h-[112px] max-w-none overflow-ellipsis whitespace-normal break-words text-gray-500 dark:text-gray-400">
                {data.content.body}
              </p>
              <p className="-mb-4 text-right text-xs leading-6 text-slate-400 dark:hover:text-primary-400">
                {data.content.createdAt.toString().split('T')[0]} | 조회수: {data.content.views}
              </p>
            </div>
          </div>
        </div>
      )
    }

     

    간단하게 작성을 해주었다. css의 경우에는 잘 모르기 때문에, 이것저것 설정해보며 일단 만족할 수 있을 정도로 설정했다.. 또한 작성된 날짜와 조회수도 함께 넣어주었다. 조회수는 한번 클릭할 경우 1씩 증가하게 설정했다.

     

    실제 블로그 페이지로 넘어가서는 페이징 기능이 존재하기 때문에, backend로 보낼 때 몇 페이지에서 보내게 되었는지도 추가하였다.

     

    [ContentController]

    @Tag(name = "게시글 API", description = "게시글 관련 API")
    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/api/v1/contents")
    public class ContentController {
        private final ContentService contentService;
    
    
        @Operation(summary = "최근 게시물 9개 조회", description = "9개까지만 보여주기")
        @GetMapping("/recent")
        @PreAuthorize("permitAll()")
        public ResponseEntity<?> getRecentContent() {
            return ResponseEntity.ok().body(SuccessResponse.from(contentService.getRecentContents()));
        }
    
        @Operation(summary = "게시글 페이지 조회(페이징 기능)", description = "페이지 번호에 맞게 가져오기")
        @GetMapping("")
        @PreAuthorize("permitAll()")
        public ResponseEntity<?> getContentsPage(@RequestParam(name = "page") Long pageNum) {
            pageNum = pageNum == null ? 1 : pageNum;
            return ResponseEntity.ok().body(SuccessResponse.from(contentService.getContentsPage(pageNum)));
        }
    
        @Operation(summary = "게시글 1개 조회", description = "content_id가 일치하는 게시글 1개 조회")
        @GetMapping("/{content_id}")
        @PreAuthorize("permitAll()")
        public ResponseEntity<?> getContent(@Parameter(name = "content_id", description = "게시글 번호")
                                            @PathVariable(name = "content_id") Long contentId) {
            return ResponseEntity.ok().body(SuccessResponse.from(contentService.getContent(contentId)));
        }
        
        // 이외 다른 컨트롤러들...
     }

     

    백엔드 컨트롤러의 경우 위와같은 방식으로 접근을 진행했다.

    @PreAuthorize 기능을 활용하여 권한체크를 진행하였다. 이 방식을 도입하기 이전에는 request로 들어온 토큰의 정보를 service 로직에 담아서 가지고 가야 했다. 즉 다음과 같은 방식으로 진행하였었다.

    @Operation(summary = "최근 게시물 9개 조회", description = "9개까지만 보여주기")
    @GetMapping("/recent")
    public ResponseEntity<?> getRecentContent(@AuthenticationPrincipal AuthUser authUser) {
        return ResponseEntity.ok().body(SuccessResponse.from(contentService.getRecentContents(authUser)));
    }
    
    @Transactional(readOnly = true)
    public RecentContentsRes getRecentContents(AuthUser authUser) {
        User user = userRepository.findById(authUser.getId());
    
        if(user.getRole() != 'ROLE_MEMBER') {
            // 에러 문구 처리
        }
        // 이후 로직 진행
    }

    이렇게 진행하였을 때는 권한 체크를 해야 하는 서비스 기능이 많아질 수록 중복된 코드가 많아질 가능성이 높았다. 또한, 컨트롤러 내부까지 진입을 한 이후에 진행이 되므로 혹시 모를 오류가 더 생길 수도 있을 것이라 생각하였다.

    @PreAuthorize("isAuthenticated() && #userDetails.getRole() == 'ROLE_ADMIN'")
    
    @PreAuthorize("permitAll()")

    권한 체크를 @PreAuthorize 사용으로 변한 이후에는 더 깔끔한 코드를 작성할 수 있게 되었다는 것을 알 수 있었다.

     

    [GET] Method


    현재 프로젝트에서 Content 컨트롤러의 Get Method는 3개를 사용하고 있다.

    1. 게시글 1개 지정 조회
    2. 최근 게시글 조회(9건)
    3. 페이지 조회(5건)

    게시글 1개 지정 조회의 경우 간단했다. PathVariable로 받은 content_id 값을 Spring Data JPA을 이용하여 단순하게 조회하고, DTO를 사용해서 프론트쪽으로 보내주기만 하면 되었다.

     

    하지만, 페이징이 들어가는 부분을 구현하면서 문제가 발생하기 시작했다.

    // [ContentService]
    @Transactional(readOnly = true)
    public AllContentsRes getContentsPage(Long pageNum) {
        Page<Content> contentsPage = contentUtilService.findContentPages(PageRequest.of(pageNum.intValue() - 1,  5, Sort.by(Sort.Direction.DESC, "createdAt")));
        List<Content> contents = contentsPage.getContent();
    
        return AllContentsRes.from(contents, contentsPage.getTotalPages());
    }
    
    // [ContentUtilService]
    public Page<Content> findContentPages(Pageable pageable) {
        return contentRepository.findByCompleteTrue(pageable);
    }
    
    // [ContentRepository]
    @EntityGraph(attributePaths = {"contentTags"})
    Page<Content> findByCompleteTrue(Pageable pageable);

     

    이렇게 적용을 한 이후에, 실행을 했을 때 결과가 나오는 것을 확인할 수 있었는데 스프링의 로그를 보니 이상한 문구를 발견하게 되었다.

    이 에러는, Spring Data JPA에서 fetch join과 페이징 기능을 함께 사용하였을 때 나타날 수 있는 문제라고 한다.

    페이징 기능을 활용하기 위해서는, MySQL 기준으로는 limt 을 사용해야 하는데, Spring Data JPA에서는 MySQL 방언인 limit절을 사용할 수 없다고 한다. 따라서, 모든 데이터를 메모리로 가져와서 처리를 하게 되는데 이는 데이터의 양이 많아질수록 성능 이슈가 발생할 수 있을 것이라고 경고를 하는 것이라고 한다.

    하지만, 뒤에 작성할 태그 기능을 사용할 예정이기 때문에 단순히 내가 원하는 만큼의 게시글을 가져와서 각 게시글마다 그에 해당하는 태그를 조회하게 된다면 N+1 문제가 발생할 것이기 때문에, 다른 방법으로 해결을 해야 했다.

    따라서, 선택하게 된 방법은 QueryDsl을 사용하는 것이었다.

    @Override
    public Page<Content> findContentsPage(Pageable pageable) {
    	// limit에 해당하는 만큼 contentId을 가져옴
        List<Long> contentIds = jpaQueryFactory
                .select(content.id)
                .from(content)
                .where(content.deletedAt.isNull().and(content.complete.isTrue()))
                .orderBy(content.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
    
    	// 가져온 contentId을 바탕으로 fetch join 진행하여 태그 데이터까지 가져옴
        List<Content> contents = jpaQueryFactory
                .selectFrom(content)
                .leftJoin(content.contentTags, contentTag).fetchJoin()
                .where(content.id.in(contentIds))
                .orderBy(content.createdAt.desc())
                .fetch();
                
    	// 페이징 기능을 위한 total 개수 반환
        Long totalCount = jpaQueryFactory
                .select(content.count())
                .from(content)
                .where(content.deletedAt.isNull().and(content.complete.isTrue()))
                .fetchOne();
    
        if (totalCount == null) {
            throw new NullPointerException();
        }
    
        return new PageImpl<>(contents, pageable, totalCount);
    }

    페이징 기능이 필요한 곳에서는 totalCount을 사용하였으며(게시글에 해당하는 부분이 몇 페이지인지 나타내기 위해), 최근 게시물을 조회하는 로직에서는 불필요하다고 생각하여 빼게 되었다.

    이렇게 변경한 이후에는, 문제없이 작동하는 것을 확인할 수 있었다.

    [QueryDsl로 변경 후 로그]

    Hibernate: select c1_0.content_id from content c1_0 where c1_0.deleted_at is null and c1_0.complete=? order by c1_0.created_at desc limit ?,?
    Hibernate: select c1_0.content_id,c1_0.body,c1_0.complete,ct1_0.content_id,ct1_0.content_category_id,ct1_0.created_at,ct1_0.tag_id,ct1_0.updated_at,c1_0.created_at,c1_0.deleted_at,c1_0.title,c1_0.updated_at,c1_0.user_id,c1_0.views from content c1_0 left join content_tag ct1_0 on c1_0.content_id=ct1_0.content_id where c1_0.content_id in (?,?,?,?,?) order by c1_0.created_at desc
    Hibernate: select t1_0.tag_id,t1_0.created_at,t1_0.tag_name,t1_0.updated_at from tag t1_0 where t1_0.tag_id=?
    Hibernate: select u1_0.user_id,u1_0.created_at,u1_0.email,u1_0.nickname,u1_0.oauth_id,u1_0.oauth_provider,u1_0.role,u1_0.updated_at from user u1_0 where u1_0.user_id=?
    Hibernate: select count(c1_0.content_id) from content c1_0 where c1_0.deleted_at is null and c1_0.complete=?

     

    [POST] Method


    Post Method는 단순하게 

    public CreateContentRes createContent(CustomUserDetails userDetails) {
        User user = userUtilService.findById(userDetails.getUserId());
    
        Content newContent = Content.builder()
                .user(user)
                .title("")
                .body("")
                .complete(false)
                .views(0L)
                .build();
    
        contentUtilService.save(newContent);
    
        return CreateContentRes.builder()
                .contentId(newContent.getId())
                .build();
    }

    내용이 비어있는 content 데이터를 하나 만들고 저장하는 방식으로 진행했다.

     

    [PATCH] Method


    POST 요청에서 단순히 빈 객체만 생성한 이유는 완성된 글과 임시저장 한 글을 나누기 위해서이다.

    GET 요청시에 모든 데이터는 complete 필드가 true인 필드만 가져오게 설정하였기 때문에, 이를 이용한다면 각각 따로 관리할 수 있을 것이라 생각했다. 따라서, 우리가 받게될 부분은 만들어진 객체를 update해주기만 하면 되었다.

    @Transactional
    public ModifyContentRes modifyContent(Long contentId, ModifyContentReq req) {
        Content content = contentUtilService.findById(contentId);
    
        content.update(req.getTitle(), req.getBody(), req.getComplete());
    
        return ModifyContentRes.of(content.getId(), content.getUpdatedAt());
    }

    이는 더티 체킹을 이용하여 간단하게 설정하게 하였다.

     

    [DELETE] Method


    Delete의 경우 실제로 DB에서 값을 지우는 대신, deleted_at 필드 값을 현재 시각으로 설정하게 하였다.

    soft delete 방식을 사용하여 만약 데이터를 복구하고 싶다면 이를 복구할 수 있게 하는 것이 좋을 것이라 생각했고, GET 요청 시에는 당연히 deleted_at 필드가 null인 값만 가져오도록 설정했다.

    @Transactional
    public void deleteContent(Long contentId) {
        Content content = contentUtilService.findById(contentId);
    
        content.softDelete();
    }

     

    이렇게 해서 완성된 화면은 다음과 같다.

     

    Output


    페이징 기능이 있는 화면
    최근 게시물

     

    확실히 비교해보았을 때, 내가 직접 만든 UI는 이쁘지 않다고 생각되어서 조금 슬펐다...

     

    추가로 고려하였던 부분


    게시글 작성 시에는 실제로 이렇게 MarkDown 문법을 사용하게 하였다. 예시의 경우 Toast UI Editor 공식 사이트에 나와있는 예시를 가지고 왔다.

    (처음에는 WISIWIG 방법을 사용하였는데, 이미지 크기 조절의 문제로 일단은 MarkDown으로 진행할 예정이다.)

    예상 외로, 원하는 그대로 데이터가 나오게 되었는데 과연 DB에는 어떻게 저장이 되는걸까?

    당연하게도, MarkDown 문법 그대로 나오게 되었다. 하지만, 

    이렇게 글을 선택하는 곳에서 미리보기로 보여주는 부분에서 마크다운 문법으로 사용이 된다면, 이쁘지 않을 것이라 생각해 위 처럼 글자만 가져올 수 있도록 변환을 해주었다.

    CommonMark 라는 라이브러리와 jsoup이라는 라이브러리를 사용했다.

    CommonMark을 통해 마크다운 문법을 html로 변경하고, Jsoup을 통해 html 태그를 제거하는 방식을 택했다.

    @Getter
    @Builder
    @AllArgsConstructor
    public class AllContentsRes {
        private List<GetContentRes> contents;
        private int totalPage;
    
        public static AllContentsRes from(List<Content> contents, int totalPage) {
            Parser parser = Parser.builder().build();
            HtmlRenderer htmlRenderer = HtmlRenderer.builder().build();
            AllContentsRes response = AllContentsRes.builder()
                    .contents(new ArrayList<>())
                    .totalPage(totalPage)
                    .build();
    
            for (Content content : contents) {
                Node document = parser.parse(content.getBody());
                String html = htmlRenderer.render(document);
                Document doc = Jsoup.parse(html); // html 태그 제거
                response.contents.add(GetContentRes.from(content, doc.text()));
            }
    
            return response;
        }
    
    }

     

    Outro


    글로 작성할 때는 되게 간단하게 작업을 한 것 같이 느껴진다. 실제로 작업함에 있어서 가장 큰 걸림돌이 되었던 것은 역시 프론트엔드 쪽이었다.. 지식이 전무하다싶이 한 상태에서 진행을 하였고, 또한 editor을 적용해야 하는데 이런 부분에서도 많이 막혔다...

    (글 포스팅 시, editor에서 제목과 본문 한글자 한글자 쓸때마다 상태를 업데이트 해야하는 방식을 택했는데 너무 어려웠다..)

    이 부분을 구현하는데만 거의 3주가 넘는 시간을 소요했던 것 같다.

    또한, 백엔드에서 json형태로 데이터를 보내는데 프론트에서 이를 받는 것이 생각보다 힘들었다고 생각되었으며 단순히 swagger로 요청을 보내고 응답을 받는 것과 다른 느낌이어서 생각보다 애를 좀 먹었다.

     

    현재는 혼자 프로젝트를 진행했기 때문에, 내가 원하는대로 맞춰서 수정을 할 수 있었지만, 만약 팀원들과 협동을 해야 하는 프로젝트였다면 API 명세를 반드시 꼼꼼하게 가져가야 원활한 진행이 될 수 있다는 점을 느꼈다.

     

    다음은 댓글과 태그에 대해서 포스팅하려고한다.

    댓글