어댑터 패턴과 프론트엔드 주도 개발
지난 포스트를 통해 Hydration Error에 대해 알아보고, 리액트 쿼리가 어떤 식으로 서버와 클라이언트의 랜더링 불일치를 방지하는지 알아보았다. 나는 이를 통해 어플리케이션이 시도하는 CRUD 중 Read에 대한 전략을 확정할 수 있었다. 몇 달 전의 프로젝트에 처음 도입해보았고, 이후로 조금씩 발전시키고 있다. 아직 완성 단계는 아니지만 어느 정도 틀은 잡혀있다. 이하의 내용은 페이지라우터에서 유효하다.
토큰 관리와 AXIOS
액세스토큰과 리프레시토큰을 어디에 저장하고, 어떻게 가져다 쓰는지는 프론트엔드 개발자라면 누구나 늘 하는 고민일 것이다. 프론트 단독으로 결정할 수는 없고, 결국은 백엔드의 처리 방식에 따라 조금씩 다르겠지만, 일반적으로는 아래의 세 가지 방법 정도로 간추려지는 듯하다.
- 액세스토큰은 로컬 변수에 저장하고, 리프레시토큰은 로컬 스토리지에 저장
- 액세스토큰은 로컬 변수에 저장하고, 리프레시토큰은 백엔드에서 setCookie
- 둘 다 백엔드에서 setCookie
이 중 둘 다 쿠키로 굽는 방법의 경우 토큰의 관리 및 재발급을 모두 백엔드에서 담당하게 될 것이므로 이 글에서는 앞의 두 경우만 고려하여 코드를 작성하려 한다.
우선 액세스토큰을 저장할 로컬 변수의 경우 나는 정적 클래스를 사용해 처리하고 있다. 리액트를 사용한다면 useRef 훅을 사용할 수도 있겠으나, 정적 클래스를 사용하는 편이 훨씬 더 활용의 폭이 높다고 판단했기 때문이다.
export class Token {
static _accessToken: string = "";
static get accessToken() {
return Token._accessToken;
}
static set accessToken(value: string) {
Token._accessToken = value;
}
}
그리고 AXIOS는 아래와 같이 설정해주었다. 원래 401 에러 처리에 대한 미들웨어도 선언해두지만 여기에서는 다루지 않았다. 언젠가 다른 포스트에서 관련 내용을 다뤄보지 않을까 싶다.
import axios, { AxiosRequestConfig } from "axios";
const instance = axios.create({
baseURL: "",
timeout: 5 * 1000,
withCredentials: true,
});
export const fetcher = <T>(config: AxiosRequestConfig) => {
return instance<T>({ ...config });
};
getServerSideProps와 prefetchQuery
Next.js의 getServerSideProps 함수는 서버 사이드 렌더링(SSR)을 위해 사용되는 특수 함수이다. 이 함수는 각 요청 시 마다 서버에서 데이터를 가져와 페이지에 전달할 수 있게 해준다. 이를 통해 동적 데이터를 처리하거나 매 요청마다 다른 데이터를 렌더링할 수 있다.
그리고 리액트 쿼리의 prefetchQuery를 사용하면 컴포넌트가 마운트되기 전에 데이터를 미리 가져오고 이를 캐시에 저장한다. 사용자에게 더 빠른 데이터 로딩 경험을 제공할 수 있다는 장점이 있지만, 너무 많이 사용할 경우 Next.js 서버가 처리해야 할 요청이 많아져서 부하가 증가할 수 있으니 주의가 필요하다.
나는 액세스토큰이 필요하지 않은 공개 데이터 만을 prefetchQuery로 가져오고 있다. 이유야 몇 가지 있겠지만 액세스토큰을 로컬 변수로 관리하면 서버에서 이 값에 접근하기가 몹시 귀찮다는 점과 더불어 서버가 처리할 요청의 가짓수를 정량적으로 억제할 수 있다는 점이 가장 크다.
queryCapsule
앞선 링크에는 초기 버전의 queryCapsule 형태를 확인해볼 수 있는데, 최근에는 이보다 조금 더 발전된 형태로 사용하고 있다. 우선 한 파일에 다 때려박았던 이전과 달리 여러 클래스를 만들어 도메인 별로 상속하여 처리하고 있다(header 설정이 귀찮은 나머지 당장은 모든 요청에 토큰을 태워 보내고 있다. 조만간 수정할 예정이다).
import { Token } from "./token";
import { fetcher } from "./fetcher";
class QueryFn {
queryFn<T>(url: string) {
return () => fetcher<T>({
method: "get",
url,
headers: {
Authorization: `Bearer ${Token.accessToken}`
}
});
}
}
export class ReviewQuery extends QueryFn {
constructor(private reviewId: number) {
super();
}
queryKey = ["review", this.reviewId];
getReview() {
return {
queryKey: [...this.queryKey],
queryFn: this.queryFn<SingleReviewData>(endpoint.review.getReview(this.reviewId)),
};
}
getReviewLikeCount() {
return {
queryKey: [...this.queryKey, "reviewLikeCount"],
queryFn: this.queryFn<ReviewLikeCount>(endpoint.review.getReviewLikeCount(this.reviewId)),
};
}
}
export class ProductQuery extends QueryFn {}
export class ArticleQuery extends QueryFn {}
어댑터 패턴과 useAdaptor
앞서 작성한 코드를 컴포넌트에서 곧바로 사용해보고 싶지만, 여기에는 한 가지 문제가 있다. 앞선 [ Hydration Error ]의 React-Query 부분에서 우리는 리액트 쿼리가 useEffect와 state를 기반으로 동작한다는 사실을 알게되었다. 그렇기 때문에 useQuery가 리턴하는 data가 AxiosResponse | undefined 타입으로 추론되는 것이다.
따라서 컴포넌트에서 사용할 때는 보통 아래와 같이 null 병합 연산자를 사용해 undefined에 대한 예외처리를 해주게 된다.
export default function Home() {
const reviewQuery = new ReviewQuery(reviewId);
const { data } = useQuery(reviewQuery.getReview());
const content: data?.data.content ?? "",
const imageUrlArray: data?.data.images ?? [],
const stars: data?.data.stars ?? 0,
const weather: data?.data.tagValues?.weather ?? "",
return <>{ ... }</>;
}
그런데 여기에는 큰 문제가 하나 있다. undefined를 예외처리하는 비즈니스 로직이 컴포넌트 안에서 처리되고 있고, 그로 인해 서버의 응답 형식이 바뀔 때마다 쿼리를 사용하는 각각의 컴포넌트를 직접 수정해야 한다는 점이다.
이를 해결하기 위해 고민하다가 디자인 패턴 중 '어댑터 패턴'을 알게되었다. 디자인 패턴에서의 어댑터 패턴은 서로 호환되지 않는 인터페이스를 가진 클래스들을 함께 작동할 수 있게 해주는 패턴이다. 나는 이 중에서도 '서로 호환되지 않는 인터페이스'에 집중했다. 당시 우리 프로젝트는 서버의 응답 형식이 굉장히 자주 바뀌었고, 그럴 때마다 프론트 코드를 바꿔주어야 했다.
만약 컴포넌트가 백엔드를 직접 바라보는 것이 아니라, 서로 다른 인터페이스를 호환시켜주는 어댑터 훅을 통해 데이터를 소비하게 된다면 어떻게 될까. 명세가 바뀐 api에 대응하는 어댑터 훅만 수정해주면 모든 컴포넌트가 그 즉시 문제 없이 동작하게 될 것이다.
const useReviewDataAdaptor = () => {
const reviewId = useReviewId();
const reviewQuery = new ReviewQuery(reviewId);
const { data } = useQuery(reviewQuery.getReview());
return {
title: data?.data.title ?? "",
nickName: data?.data.nickName ?? "",
content: data?.data.content ?? "",
imageUrlArray: data?.data.images ?? [],
};
};
const ImageCarouselSection = () => {
const { imageUrlArray } = useReviewDataAdaptor();
return (
<section className="mb-50 select-none">
<ImagesCarousel imageArray={imageUrlArray}></ImagesCarousel>
</section>
);
};
const Title = () => {
const { title, nickName } = useReviewDataAdaptor();
return (
<h2>
<span>{title}</span>
<span>{`by ${nickName}`}</span>
</h2>
);
};
이렇게 어댑터 패턴을 사용하면 백엔드에서 어떻게 응답 형태를 바꾸던 상관 없이 최소한의 코드 수정으로 대응할 수 있다. 만약 nickName 필드 이름이 userNickname으로 바뀌고, title 필드가 객체가 되어서 title : { titleName }로 바뀌었다고 해보자. 어댑터 훅을 아래와 같이 수정하면 Title 컴포넌트를 수정하지 않아도 문제 없이 동작할 것이다.
const useReviewDataAdaptor = () => {
const reviewId = useReviewId();
const reviewQuery = new ReviewQuery(reviewId);
const { data } = useQuery(reviewQuery.getReview());
return {
title: data?.data.title.titleName ?? "",
nickName: data?.data.userNickname ?? "",
content: data?.data.content ?? "",
imageUrlArray: data?.data.images ?? [],
};
};
컴포넌트의 수정을 최소한으로 억제하는 어댑터 패턴을 적용하면 어플리케이션 전체의 코드 안정성을 한 차원 더 높은 곳으로 끌어올릴 수 있다. 또한 어댑터 패턴을 사용하면 백엔드 완성을 기다리지 않고도 프론트 개발을 주도적으로 진행할 수 있다. 완성된 백엔드의 응답 형식이 어떻던 간에 어댑터 훅을 사용하여 인터페이스를 호환시킬 수 있기 때문이다. 내가 이 방식을 프론트엔드 주도 개발(FDD)이라 부르는 것도 그러한 까닭이다.
어댑터 패턴을 사용해 백엔드를 기다리지 않는다는 아이디어 자체는 리액트 뿐 아니라 vue3 등에서도 비슷하게 활용할 여지가 존재한다. 여러분의 프로젝트에 FDD를 도입해보는 것을 적극 추천해보면서 이 포스트를 마무리한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden