Intro


기존에 홍보용으로 진행했던 사이드 프로젝트 코드를 개선한 경험을 공유하고자 한다😊

리뷰 게시판은 취미로 그린 그림들의 후기를 기록하는 간단 게시판이다.
신청서와 리뷰가 1:1 관계를 맺고 있으며, 신청서가 없으면 리뷰를 작성할 수 없다.

https://velog.io/@jinvicky/commission-review-guide

Code First


코드를 먼저 설명하고 히스토리를 풀 예정이다. 궁금하면 밑에서부터:)

🧠 구상

  • 프론트에서는 맨 처음에 5개의 리뷰를 가지고 온다.
  • 사용자가 5개의 리뷰를 다 스크롤하면 자동으로 다음 페이지의 5개 리뷰를 가져와서 기존 리뷰 리스트에 추가한다.
  • 더 이상 가져올 데이터가 없다면 스크롤해도 api를 호출하지 않는다.

🌱 Back-End (MongoDB Aggregate)

백엔드에서 몽고디비를 사용해본 적은 처음이다.
처음에는 기본으로 spring에서 제공하는 MongoDBRepository를 사용했다.
하지만 개선하면서 신청서의 username을 review에 보여주어야 했고, RDBMS 용어로조인이 필요했다.

몽고디비에는 조인이라는 개념 대신 3.2버전부터 제공하는 lookup을 사용한다.

결과적으로 db 조회를 위해서 2개의 repository를 사용하고 있다.
정말 간단한 기본은 제공해주는 인터페이스를 사용하고, lookup과 같은 것들은 커스텀해서 사용한다.

📃 ReviewAggregateRepository.java

@Repository
@RequiredArgsConstructor
public class ReviewAggregateRepository {

    private final MongoTemplate mongoTemplate;

    @Value("${spring.data.mongodb.collection.review}")
    private String reviewCollection;

    @Value("${spring.data.mongodb.collection.application}")
    private String applicationCollection;

    public List<ReviewRenewalVO> findReviewListByPage(int page, int size) {
        LookupOperation lookupOperation = LookupOperation.newLookup()
                .from(applicationCollection) // 조인 대상 컬렉션
                .localField("applyId") // 리뷰 컬렉션의 필드
                .foreignField("_id") // 대상 컬렉션의 필드
                .as("applicationDetails"); // 결과를 저장할 필드 이름

        // Aggregation 파이프라인 생성
        Aggregation aggregation = Aggregation.newAggregation(
                lookupOperation,
                Aggregation.sort(Sort.by(Sort.Order.desc("createdAt"))), // createdAt 필드로 내림차순 정렬
                Aggregation.skip((long) page * size), // 페이지에 따라 건너뛸 문서 수
                Aggregation.limit(size) // 페이지 크기
        );

        AggregationResults<ReviewRenewalVO> results = mongoTemplate.aggregate(aggregation, reviewCollection, ReviewRenewalVO.class);
        return results.getMappedResults();
    }
}

reviewCollection은 컬렉션명인데 RDBMS 기준 테이블명이다.
컬렉션명을 직접 하드코딩하면 기능이 추가되고, 행여나 _v3 등으로 이름이 바뀌면 수정사항이 넓어지기 때문에 yml으로 관리하고 @Value로 주입하는 것을 택했다.

📃 RenewalReviewService.java

페이징된 리스트와 페이징 관계없는 리뷰 전체 개수를 담는다.

@Service
@RequiredArgsConstructor
public class ReviewRenewalService {

    private final ReviewRepository repository;

    private final ReviewAggregateRepository aggregateRepository;

    // 리뷰 페이징
    public CustPage selectReviewPage(int page, int size) {
        List<ReviewRenewalVO> list = aggregateRepository.findReviewListByPage(page, size);
        long totalCount = repository.count();
        return CustPage.builder()
                .list(list)
                .pageInfo(PageInfoVO.builder()
                        .page(page)
                        .size(size)
                        .totalCount(totalCount)
                        .build())
                .build();
    }
}

결과적으로 applicationDetails에 다음과 같이 담기게 된다.

🌼 Front-End (Next.js 14)

"use client";

import { useEffect, useMemo, useState } from "react";
import {TailMasking} from "@/utils/masking.util"; 
import { useInView } from "react-intersection-observer";

import { Review } from "@/types/review.type";

const RenewalReviewListWithInfiniteScroll = () => {
  const viewLimitCount = 5; // size, 최대 출력 개수
  const [page, setPage] = useState(0);
  const [totalCount, setTotalCount] = useState(5);
  const [reviewList, setReviewList] = useState<Review[]>([]);
  const [ref, inView] = useInView();

  const updateData = async () => {
    let data = await fetch(
      process.env.NEXT_PUBLIC_DOMAIN_URL +
        "/api/renewal/review/all" +
        "?page=" +
        page +
        "&size=" +
        viewLimitCount
    );
    const reviews: ApiResult<CustPage<Review>> = await data.json();

    if (reviews.data.list.length) {
      setPage(page+1); // 요청 후에 페이지 수를 증가시킨다.
      setTotalCount(reviews.data.pageInfo.totalCount);
      setReviewList((prev: Review[]) => {
        return [...prev, ...reviews.data.list];
      });
    }
  };

  /**
   * 모든 데이터를 가져왔는지 여부 체크
   */
  const fetchedAllData = useMemo(() => {
    // before:: return reviewList.length >= viewLimitCount * (page + 1);
    return reviewList.length >= totalCount;
  }, [reviewList, viewLimitCount, page]); 

  useEffect(() => {
    if (inView) {
      if (fetchedAllData) {
        return;
      }
      updateData();
    }
  }, [inView]);

  const render = () => {
    return reviewList.map((review: Review, index: number) => (
      <li
        key={`review-${index}`}
        className="min-w-20 p-4 mb-4 rounded-lg shadow-sm bg-white"
      >
        <div className="text-lg font-semibold mb-2">{review.content}</div>
        <div className="flex justify-between items-end">
          <span className="text-sm text-gray-600">{TailMasking.shortMasking(review.username, 3)}</span>
          <span className="text-sm text-gray-400">{review.createdAt}</span>
        </div>
      </li>
    ));
  };

  return (
    <>
      <div>
        <ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-6 w-full">
          {render()}
        </ul>
        <div ref={ref} className="bg-orange-600 h-20">
          This is Spinner!!!
        </div>
      </div>
    </>
  );
};

export default RenewalReviewListWithInfiniteScroll;

intersection-observer 라이브러리를 사용해서 리스트 밑의 spinner에 ref를 적용했다.

  1. npm 라이브러리 설치

    npm install react-intersection-observer --save

  2. 스크롤의 끝을 감지하기 위해 맨 하단에 컴포넌트 추가 (간단하게 div 정도로 했다)

  3. useInView()에서 제공하는 inView 값 여부에 따라서 데이터 fetch

🔥 paging과 관계없이 전체 리뷰 totalCount가 있어야 한다.
없이 진행했더니 스크롤 할수록 리뷰가 중복되어 증식하는 괴물이 탄생한다.
처음에는 limitCount와 page를 곱해서 처리하면 될 거라 생각했지만, 사실상 리뷰가 없어서 append가 되지 않은 것이지 계속 fetch를 요청하기 때문에 정답이 아니다.

  1. 기존에 "익명" 텍스트로 처리했던 username을 마스킹을 적용해 보여주기로 했다.
class TailMasking {

    static shortMasking(username: string, displayLength: number): string {
        if (username.length <= 2) {
            return username.slice(0, 1) + "*".repeat(displayLength - 1);
        }
        return username.slice(0, 2) + "*".repeat(displayLength - 2);
    }

    static longMasking(username: string, displayLength: number): string {
        return username.slice(0, 3) + "*".repeat(displayLength - 3);
    }
}


export { TailMasking };

뒷부분을 생략하기에 tailMasking이라고 이름붙였다.
나는 서버 액션 방식을 사용하진 않았지만 아래 유튜브가 큰 도움이 되었다. 추천

🔗 https://www.youtube.com/watch?v=IFYFezylQlI

History


사실 이번 포스팅 기능 정말 쉽다. 근데 기능 구현하는데 1시간 반 걸렸다고 치면 그 전에 리팩토링한다고 하루를 꼬박 투자해야 했다.

💦 어제 짠 코드는 신만이 안다.

이 게시판을 처음 만들었을 때 안정성 vs. 속도 중에서 속도를 택했다.
현재 작업중인 커미션이 몇 건 있었고, 당시에 언제 또 일이 들어올 지 몰라서 후기를 조금이라도 더 받아두고 싶었기 때문이다.
그래서 3일 안에 프론트,백 다 개발하고 도커로 배포한 뒤에 잠시 코드는 잊고 살았다.

💬 스크롤했을 때 자동으로 데이터를 fetch하는 거 하면 좋겠다~

동료의 말 한 마디에 해볼까? 했다가 과거의 내가 저지른 코드에 말 그대로 경악했다.

급하다고 controller에 떡칠을 해놨었다... (왼쪽이 before, 오른쪽이 after)
이제 어떻게 해결했는지, 개선했는지 일감별로 나열해보겠다.

🎯 무분별한 dto, vo, entity

어디서 들은 건 있어서 dto, vo, entity를 따로 설계해서 변환하는 것이 정석이다! 라는 말을 듣고 막 만들어 놓고 정작 vo는 놀고 있다.

🐾 나는 대규모 시스템이 아니라서 변환 비용과 코드 관리가 더 까다로울 것이기에
dto, vo를 vo로 통일하고 이름도 개선했다.

🎯 매핑 작업을 하는 mapper 별도 분리

🐾 entity와 vo 사이의 변환 작업을 서비스에서 코드를 나열하지 않고 하도록 했다.

public interface CommonMapper<E, V> {
    V entityToVO(E entity);
}

@Component
public class ReviewMapper implements CommonMapper<ReviewEntity, ReviewRenewalVO> {

    @Override
    public ReviewRenewalVO entityToVO(ReviewEntity entity) {
        return ReviewRenewalVO.builder()
                .applyId(entity.getApplyId().toString())
                .content(entity.getContent())
                .createdAt(entity.getCreatedAt())
                .updatedAt(entity.getUpdatedAt())
                .build();
    }
}

🎯 공통 클래스, 예외처리 추가

🐾 페이징, 공통 응답 객체 및 예외 처리(GlobalControllerAdvice) 등을 추가했다.
특히 신청서 post 요청의 경우 신청서 id가 유효하지 않을 때 백에서 응답값이 불명확했던 이슈를 해결했다.
사실 더 에러 케이스가 복잡하고 예외 종류 따라서 클래스로 또 분류하는 식이 대규모 프로젝트인데, 학습을 위해서 원 클래스로 시작했다.

📃 ErrorCode.java

public enum ErrorCode {

    // api error code
    BadRequest("400", "Http Request 문법에 맞지 않습니다."),

    // biz error code
    MISS_MATCH("300", "일치하지 않음"),

    // server error code
    UNKNOWN("774", "심각한 예외발생");

    @Getter
    String errorCode;

    @Getter
    String message;

    @Getter
    HttpStatus status;

    private ErrorCode(String errorCode, String message){
        this.errorCode = errorCode;
        this.message = message;
    }


}

예외를 던지면 아래서 받아서 처리하도록

📃 GlobalControllerAdvice.java


@RestControllerAdvice
public class GlobalControllerAdvice {

    public GlobalControllerAdvice() {

    }

    @ExceptionHandler({CommonException.class})
    public ResponseEntity<ErrorResponse> handleException(CommonException e, WebRequest req) {
        ErrorResponse response = new ErrorResponse(e.getErrorCode(), req);
        return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

🎯 declare 전역 선언

🐾 타입스크립트는 이름을 .d.ts로 지으면 자동으로 인식된다.
공통 api 타입은 declare로 해서 페이지마다 불필요한 import를 없앴다.
모듈로 인식하는 것을 막기 위해 export {}를 필수 추가.

📌 만약에 declare global 타입들이 인식이 안된다면 tsconfig.json에 가서 include 속성 리스트를 건드려야 한다.

declare global {

    interface ApiResult<T> {
        status: number;
        message: string;
        data: T;
    }

    interface PageInfo {
        page: number;
        size: number;
        totalCount: number;
    }

    interface CustPage<T> {
        list: T[];
        pageInfo: PageInfo;
    }

}
export {};

기타

  • Aggregate 패턴을 쓰면서 시간을 보여줄 때 타입을 String -> LocalDateTime으로 변경해야 @JsonFormat이 적용되었다.
  • MongoDB lookup을 이해하는데 조금 오래 걸렸다.
profile
Front-End와 Back-End 경험, 지식을 공유합니다.

0개의 댓글

Powered by GraphCDN, the GraphQL CDN