기존에 홍보용으로 진행했던 사이드 프로젝트 코드를 개선한 경험을 공유하고자 한다😊
리뷰 게시판은 취미로 그린 그림들의 후기를 기록하는 간단 게시판이다.
신청서와 리뷰가 1:1 관계를 맺고 있으며, 신청서가 없으면 리뷰를 작성할 수 없다.
https://velog.io/@jinvicky/commission-review-guide
코드를 먼저 설명하고 히스토리를 풀 예정이다. 궁금하면 밑에서부터:)
백엔드에서 몽고디비를 사용해본 적은 처음이다.
처음에는 기본으로 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에 다음과 같이 담기게 된다.
"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
를 적용했다.
npm 라이브러리 설치
npm install react-intersection-observer --save
스크롤의 끝을 감지하기 위해 맨 하단에 컴포넌트 추가 (간단하게 div 정도로 했다)
useInView()
에서 제공하는 inView
값 여부에 따라서 데이터 fetch
🔥 paging과 관계없이 전체 리뷰 totalCount가 있어야 한다.
없이 진행했더니 스크롤 할수록 리뷰가 중복되어 증식하는 괴물이 탄생한다.
처음에는 limitCount와 page를 곱해서 처리하면 될 거라 생각했지만, 사실상 리뷰가 없어서 append가 되지 않은 것이지 계속 fetch를 요청하기 때문에 정답이 아니다.
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이라고 이름붙였다.
나는 서버 액션 방식을 사용하진 않았지만 아래 유튜브가 큰 도움이 되었다. 추천
사실 이번 포스팅 기능 정말 쉽다. 근데 기능 구현하는데 1시간 반 걸렸다고 치면 그 전에 리팩토링한다고 하루를 꼬박 투자해야 했다.
이 게시판을 처음 만들었을 때 안정성 vs. 속도 중에서 속도를 택했다.
현재 작업중인 커미션이 몇 건 있었고, 당시에 언제 또 일이 들어올 지 몰라서 후기를 조금이라도 더 받아두고 싶었기 때문이다.
그래서 3일 안에 프론트,백 다 개발하고 도커로 배포한 뒤에 잠시 코드는 잊고 살았다.
💬 스크롤했을 때 자동으로 데이터를 fetch하는 거 하면 좋겠다~
동료의 말 한 마디에 해볼까? 했다가 과거의 내가 저지른 코드에 말 그대로 경악했다.
급하다고 controller에 떡칠을 해놨었다... (왼쪽이 before, 오른쪽이 after)
이제 어떻게 해결했는지, 개선했는지 일감별로 나열해보겠다.
어디서 들은 건 있어서 dto, vo, entity를 따로 설계해서 변환하는 것이 정석이다! 라는 말을 듣고 막 만들어 놓고 정작 vo는 놀고 있다.
🐾 나는 대규모 시스템이 아니라서 변환 비용과 코드 관리가 더 까다로울 것이기에
dto, vo를 vo로 통일하고 이름도 개선했다.
🐾 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);
}
}
🐾 타입스크립트는 이름을 .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 {};
@JsonFormat
이 적용되었다.