Namgung Jong Min

토끼굴을 개척하는 개발자

API 통신은 현대 웹 애플리케이션의 핵심 요소입니다. 하지만 기본적인 fetch API를 직접 사용하면 다양한 문제점들이 발생할 수 있습니다. 이번 포스트에서는 우리 프로젝트에 Custom Fetcher를 도입하게 된 이유와 구현 방식, 효과에 대해 살펴보겠습니다.

▪︎ 기존 fetch 사용 방식의 단점

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const getReview = async ({ pageParam, sortOrder }: ReviewParams): Promise<ReviewResponse> => {
const res = await fetch(`/api/reviews?page=${pageParam}&sortBy=${sortOrder}&limit=12`, {
method: "GET",
credentials: "include",
});

if (!res.ok) {
const error = new Error("Login failed") as ReviewError;
error.status = res.status;
error.message = `Server error: ${res.status}`;
throw error;
}
return res.json();
};

기존에 API 요청 코드들을 작성할 때에는 각 요청마다 method와 credentials와 같은 config 코드들을 작성해야 했고, 반환할 에러 타입들도 각각 작성해주어야 했습니다. 추가로 Next.js의 서버 컴포넌트와 같이 쿠키가 자동으로 실려가지 않는 상황에 대비하기 위한 코드들도 작성해야 했습니다.

따라서 API 관련 코드를 작성하는 개발자들은 각 API가 어디서 쓰일지, 어떤 설정이 필요한지를 매번 생각하고 코드에 적용해야 했습니다. 또한 이러한 API 요청 코드 구조의 변화가 있을 경우, 각 API 요청 코드를 전부 찾아가 일일이 수정해주어야 했습니다. 뿐만아니라 각 개발자마다 다른 구현 방식으로 인해 코드의 일관성이 떨어졌고, 이는 가독성을 저해하는 요소였습니다.

정리해보자면 다음과 같은 문제점들이 있었습니다.

  1. 반복적인 코드
    • 기본 URL 설정
    • 헤더 설정
    • 에러 처리
    • 응답 파싱
  2. 일관성 부족
    • 개발자마다 다른 구현 방식
    • 에러 처리 방식의 차이
    • 타입 처리의 비일관성
  3. 유지보수의 어려움
    • API 로직 변경 시 여러 곳에 전체적인 수정 필요

위와 같은 기본 fetcher의 단점을 극복하기 위해 우리 프로젝트에 Custom Fetcher를 만들었습니다.

▪︎ Custom Fetcher 구현

▫︎ 전체 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import { FetcherError } from "@/@types/api";
import { getAccessTokenFromCookies } from "@/utils/cookies";

type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

interface FetcherConfig extends RequestInit {
method: HTTPMethod;
credentials?: RequestCredentials;
headers?: Record<string, string>;
body?: any;
}

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;

/**
* HTTP 요청을 처리하는 기본 fetcher 함수
* @param endpoint - API 엔드포인트 경로
* @param config - fetch 설정 객체
* @returns Promise<T> - API 응답 데이터
* @throws FetcherError - API 요청 실패 시 발생하는 에러
*/
async function fetcher<T>(endpoint: string, config?: FetcherConfig): Promise<T> {
const defaultConfig: FetcherConfig = {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
};

const accessToken = typeof window === "undefined" ? await getAccessTokenFromCookies() : "";

const mergedConfig = {
...defaultConfig,
...config,
headers: Object.fromEntries(
Object.entries({
...defaultConfig.headers,
...config?.headers,
...(accessToken ? { Cookie: `accessToken=${accessToken}` } : {}),
// eslint-disable-next-line
}).filter(([key, value]) => value !== undefined)
),
} as FetcherConfig;

if (mergedConfig.body instanceof FormData) {
if (mergedConfig.headers) {
delete mergedConfig.headers["Content-Type"];
}
} else if (mergedConfig.body) {
mergedConfig.body = JSON.stringify(mergedConfig.body);
}

const response = await fetch(`${BASE_URL}${endpoint}`, mergedConfig);

if (endpoint === "/auth/token/verification") {
return { status: response.status } as T;
}

if (!response.ok) {
const error = new Error("API request failed") as FetcherError;
error.status = response.status;
error.message = `Server error: ${response.status}`;
throw error;
}

return response.json();
}

// 편의성 메서드
export const http = {
get: <T,>(endpoint: string, config?: Omit<FetcherConfig, "method">) =>
fetcher<T>(endpoint, { ...config, method: "GET" }),

post: <T,>(endpoint: string, data?: any, config?: Omit<FetcherConfig, "method" | "body">) =>
fetcher<T>(endpoint, { ...config, method: "POST", body: data }),

put: <T,>(endpoint: string, data?: any, config?: Omit<FetcherConfig, "method" | "body">) =>
fetcher<T>(endpoint, { ...config, method: "PUT", body: data }),

delete: <T,>(endpoint: string, config?: Omit<FetcherConfig, "method">) =>
fetcher<T>(endpoint, { ...config, method: "DELETE" }),

patch: <T,>(endpoint: string, data?: any, config?: Omit<FetcherConfig, "method" | "body">) =>
fetcher<T>(endpoint, { ...config, method: "PATCH", body: data }),
};

▫︎ 기본 구조 설정

1
2
3
4
5
6
7
8
9
10
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

interface FetcherConfig extends RequestInit {
method: HTTPMethod;
credentials?: RequestCredentials;
headers?: Record<string, string>;
body?: any;
}

const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL;

우선 Custom Fetcher의 기본 구조를 타입스크립트를 통해 설정했습니다. 이를 통해 HTTP 메서드의 타입 안정성을 확보하였고, 설정 객체의 타입 또한 명확하게 정의했습니다.

▫︎ 핵심 Fetcher 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
async function fetcher<T>(endpoint: string, config?: FetcherConfig): Promise<T> {
const defaultConfig: FetcherConfig = {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
};

// SSR을 고려한 토큰 처리
const accessToken = typeof window === "undefined" ? await getAccessTokenFromCookies() : "";

const mergedConfig = {
...defaultConfig,
...config,
headers: Object.fromEntries(
Object.entries({
...defaultConfig.headers,
...config?.headers,
...(accessToken ? { Cookie: `accessToken=${accessToken}` } : {}),
// eslint-disable-next-line
}).filter(([key, value]) => value !== undefined)
),
} as FetcherConfig;

// 특수 케이스 처리
if (mergedConfig.body instanceof FormData) {
if (mergedConfig.headers) {
delete mergedConfig.headers["Content-Type"];
}
} else if (mergedConfig.body) {
mergedConfig.body = JSON.stringify(mergedConfig.body);
}

const response = await fetch(`${BASE_URL}${endpoint}`, mergedConfig);

if (endpoint === "/auth/token/verification") {
return { status: response.status } as T;
}

// 커스텀 에러 객체를 사용하여 일관된 에러 형식 제공
if (!response.ok) {
const error = new Error("API request failed") as FetcherError;
error.status = response.status;
error.message = `Server error: ${response.status}`;
throw error;
}

return response.json();
}

fetcher 함수에서는 타입스크립트 제네릭을 통해 응답 타입을 보장할 수 있도록 하였습니다. 또한 기본적인 설정에 사용자 설정을 병합하도록 하여 보다 유연하고 확장성있게 사용될 수 있도록 하였습니다. 뿐만아니라 Next.js SSR 환경을 고려한 토큰 처리도 해주었습니다.

특수케이스 처리

1
2
3
4
5
6
7
if (mergedConfig.body instanceof FormData) {
if (mergedConfig.headers) {
delete mergedConfig.headers["Content-Type"];
}
} else if (mergedConfig.body) {
mergedConfig.body = JSON.stringify(mergedConfig.body);
}

우리 앱에서 기본적으로 사용되는 api 의 content type인 application/json이 아닌 회원가입, 여행 만들기 등에 활용되는 FormData 타입일 때 content type을 바꿔주도록 구성했습니다. FormData 처리를 위해 Content-Type을 자동으로 제거하였고, JSON 데이터를 자동으로 직렬화하도록 하였습니다.

에러 처리

1
2
3
4
5
6
if (!response.ok) {
const error = new Error("API request failed") as FetcherError;
error.status = response.status;
error.message = `Server error: ${response.status}`;
throw error;
}

에러처리는 Tanstack Query와의 조합을 생각하여 Query를 작성할 때 가독성이 좋을 것이라고 판단한 구조로 커스텀 에러 객체를 반환하도록 하였습니다. 이를 통해 HTTP 상태 코드를 포함한 객체와 메시지로 일관된 에러 형식을 제공할 수 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
return useMutation({
mutationFn: login,
onError: (error: QueryError) => {
switch (error.status) {
case 401:
showToast('잘못된 이메일 또는 비밀번호입니다.', 'error');
break;
case 500:
showToast('네트워크를 확인해주세요.', 'error');
break;
default:
showToast('알 수 없는 에러가 발생했습니다.', 'error');
}
},
onSuccess: () => {
router.push('/');
},
});
...

백엔드에서 반환하는 response의 status를 바로 읽어 에러객체에 저장해주었고 실제 Tanstack Query에 사용할 때에는 에러 객체에서 status를 읽는 방식으로 가독성을 높이고 구조를 통일했습니다.

▫︎ 편의성 메서드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export const http = {
get: <T,>(endpoint: string, config?: Omit<FetcherConfig, "method">) =>
fetcher<T>(endpoint, { ...config, method: "GET" }),

post: <T,>(endpoint: string, data?: any, config?: Omit<FetcherConfig, "method" | "body">) =>
fetcher<T>(endpoint, { ...config, method: "POST", body: data }),

put: <T,>(endpoint: string, data?: any, config?: Omit<FetcherConfig, "method" | "body">) =>
fetcher<T>(endpoint, { ...config, method: "PUT", body: data }),

delete: <T,>(endpoint: string, config?: Omit<FetcherConfig, "method">) =>
fetcher<T>(endpoint, { ...config, method: "DELETE" }),

patch: <T,>(endpoint: string, data?: any, config?: Omit<FetcherConfig, "method" | "body">) =>
fetcher<T>(endpoint, { ...config, method: "PATCH", body: data }),
};

위에 서술한 fetcher함수를 이용하여 편의성 메서드를 작성하였습니다. 이를 통해 메서드별 타입을 최적화하고, 팀원들에게 간결한 사용법을 통한 일관된 인터페이스를 제공할 수 있었습니다.

▪︎ 실제 사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const getMyReview = (limit: number, offset: number) => {
return http.get<ListResponse<Review>>(`/reviews/mine?size=${limit}&page=${offset}`);
};

export const getReviewDetail = (reviewId: number) => {
return http.get<BaseResponse<Review>>(`/reviews/${reviewId}`);
};

export const postReviewLike = (reviewId: number) => {
return http.post<BaseResponse<unknown>>(`/reviews/${reviewId}/likes`);
};

export const deleteReviewLike = (reviewId: number) => {
return http.delete<BaseResponse<unknown>>(`/reviews/${reviewId}/likes`);
};

처음 소개했던 기존 방식에 비해 훨씬 간결하고 가독성있게 API 요청 코드를 관리할 수 있게 되었습니다. API 설정 관련하여 고민하며 작성했던 기존 코드에 비해 중앙 집중화되어 관리되는 Custom Fetcher를 통해 실제 팀원들이 API 요청을 작성할 때에는 “메서드”, “엔드포인트”, “반환 타입”만 신경쓴다면 바로 쉽게 사용할 수 있었습니다.

▪︎ 마치며

Custom Fetcher는 단순한 HTTP 클라이언트를 넘어 애플리케이션의 데이터 통신 계층을 체계화합니다. 타입 안정성, 코드 재사용성, 유지보수성을 모두 고려한 설계로, 개발 생산성을 크게 향상시킬 수 있습니다. 특히 Next.js의 서버 컴포넌트와 미들웨어 환경에서도 원활하게 동작하도록 구현되어, 현대적인 웹 애플리케이션 개발에 매우 적합합니다.

▪︎ 데이터 페칭 방식의 이해: CSR vs SSR

웹 애플리케이션에서 데이터를 가져오는 방식은 크게 클라이언트 사이드 렌더링(CSR)과 서버 사이드 렌더링(SSR)으로 나눌 수 있습니다.

클라이언트 사이드 데이터 페칭의 경우, 브라우저가 JavaScript를 실행하여 데이터를 가져오기 때문에 초기 HTML은 비어있는 상태입니다. 사용자는 데이터가 로드되기 전까지 로딩 상태를 보게 되며, 검색 엔진은 초기 HTML에서 의미 있는 컨텐츠를 찾을 수 없습니다.

반면 서버 사이드 데이터 페칭은 서버에서 데이터를 미리 가져와 완성된 HTML을 생성합니다. 이는 사용자에게 더 빠른 초기 로드 경험을 제공하며, 검색 엔진이 컨텐츠를 즉시 크롤링할 수 있게 합니다.

▪︎ SEO에서 SSR의 장점

검색 엔진 최적화(SEO)에 있어서 SSR은 다음과 같은 핵심적인 이점을 제공합니다.

▫︎ 완성된 HTML 제공

  • 검색 엔진 크롤러가 JavaScript 실행 없이도 모든 컨텐츠 접근 가능
  • 메타 데이터와 구조화된 데이터를 초기 HTML에 포함 가능

▫︎ 빠른 초기 로드

  • Time to First Contentful Paint (FCP) 개선
  • 사용자 경험 향상으로 인한 간접적 SEO 효과

▫︎ 신뢰성 있는 컨텐츠 제공

  • 동적 데이터 로딩으로 인한 컨텐츠 누락 방지
  • 일관된 컨텐츠 구조 제공

▪︎ 프로젝트 적용 (구현 예시)

예시로 우리 프로젝트에서는 리뷰 상세 페이지에 SSR을 적용했습니다. 이 페이지는 정적인 정보를 주로 다루며 SEO가 중요한 페이지이기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/app/review/[id]/page.tsx
const ReviewDetailPage = async ({ params }: { params: Promise<{ id: number }> }) => {
const { id } = await params;
const queryClient = new QueryClient();

// fetchQuery를 사용한 서버 사이드 데이터 페칭
await queryClient.fetchQuery({
queryKey: ["reviews", "detail", id],
queryFn: () => getReviewDetail(id),
});

const data = queryClient.getQueryData<BaseResponse<Review>>(["reviews", "detail", id]);

return (
<div className="pb-[120px]">
<Header title="리뷰 상세보기" />
{data && <ReviewDetailContainer data={data.data} />}
</div>
);
};

▫︎ fetchQuery vs prefetchQuery

구현 과정에서 fetchQuery와 prefetchQuery 중 어떤 것을 사용할지 고민했습니다. 테스트 결과 fetchQuery가 더 나은 성능을 보여주었는데, 이는 다음과 같은 이유 때문입니다.

fetchQuery

  • 데이터를 즉시 가져오고 캐시에 저장
  • Promise가 resolve될 때까지 대기
  • 데이터가 확실히 있는 상태에서 렌더링 시작

prefetchQuery

  • 백그라운드에서 데이터를 가져옴
  • 데이터 로딩 완료를 기다리지 않음
  • 때로는 데이터가 없는 상태에서 렌더링이 시작될 수 있음

prefetchQuery의 경우 가져온 데이터들을 즉시 캐시에 저장하고 반영하는 것이 아니라 prefetch를 통해 백그라운드에 저장해놓은 데이터를 다시 한번 캐시에 등록하는 과정이 추가됩니다. 따라서 fetchQuery에 비해 로딩 속도가 비교적 낮았습니다.

▪︎ 성능 측정 및 개선 효과

SSR 적용 후 다음과 같은 개선 효과를 확인할 수 있었습니다.

▫︎ 검색엔진 크롤링 향상

  • Google Search Console의 색인 생성 증가
  • 크롤링 오류 감소

▫︎ 페이지 성능 개선

기존 CSR 방식과 비교하여 LCP 지표에서 큰 차이가 있었습니다.

image.png

기존 CSR 방식) LCP: 654.93 ms

image.png

수정 SSR 방식) LCP: 182.55 ms

기존 하위 클라이언트 컴포넌트에서 각 데이터를 페칭하여 페이지에 적용했을 경우 LCP 지표가 654.93 ms로 확인되었습니다. 그러나 SSR을 적용한 이후에는 182.55 ms로 약 72% 개선한 수치를 보여주었습니다.

▫︎ LCP 지표 개선의 이유

LCP 지표에 이렇게 큰 차이가 발생하는 이유를 단계별로 확인해보겠습니다.

CSR의 렌더링 단계 (654.93ms)

1
2
3
4
5
6
7
8
1. 빈 HTML 다운로드
2. JavaScript 번들 다운로드
3. React 초기화
4. useQuery 실행
5. API 요청 및 대기
6. 데이터 수신
7. DOM 렌더링
8. 이미지 로드 시작

SSR의 렌더링 단계 (182.55ms)

1
2
3
4
1. 서버에서 데이터 페칭 (다른 단계와 병렬 처리)
2. 서버에서 HTML 생성
3. 완성된 HTML 다운로드 (이미지 URL 포함)
4. 이미지 로드 시작 (HTML 파싱과 동시에)
  • 네트워크 요청 최적화
    • CSR: HTML → JS → API (순차적)
    • SSR: HTML(데이터 포함) → 리소스 (병렬)
  • 렌더링 프로세스 최적화
    • CSR: JS 실행 → 데이터 페칭 → 렌더링
    • SSR: 서버에서 미리 렌더링 완료
  • 리소스 로딩 최적화
    • CSR: 데이터 수신 후 이미지 로드 시작
    • SSR: HTML 파싱과 동시에 이미지 로드

이 측정 결과는 SEO뿐만 아니라 성능 측면에서도 SSR이 매우 효과적인 선택이었음을 입증합니다.

▪︎ 마치며

SSR을 통한 SEO 최적화는 현대 웹 애플리케이션에서 필수적인 요소입니다. 특히 Next.js의 서버 컴포넌트를 활용하면 효과적으로 SSR을 구현할 수 있으며, 이는 검색 엔진 최적화뿐만 아니라 전반적인 사용자 경험 향상에도 큰 도움이 됩니다.

우리 프로젝트에서는 fetchQuery를 활용한 서버 사이드 데이터 페칭으로 완성도 높은 SSR을 구현했으며, 이는 SEO와 성능 모두에서 긍정적인 결과를 가져왔습니다. 앞으로도 지속적인 모니터링과 최적화를 통해 더 나은 사용자 경험을 제공할 계획입니다.

▪︎ CLS란 무엇일까?

CLS(Cumulative Layout Shift)는 Google의 Core Web Vitals 중 하나로, 페이지 로딩 과정에서 발생하는 예기치 않은 레이아웃 이동을 측정하는 지표입니다. 쉽게 말해, 페이지가 얼마나 ‘덜컥거리는지’를 수치화한 것입니다.

1
2
3
4
[CLS 점수 기준]
좋음: 0.1 이하
개선 필요: 0.1 ~ 0.25
나쁨: 0.25 이상

CLS 수치가 점수 기준에서 합격점이라 할지라도 실제 페이지에서 유저가 경험할 때 부자연스러운 부분들이 있을 수 있습니다. WEGO 프로젝트에서 시프팅 현상으로 인한 부자연스로운 페이지 로딩의 예시를 살펴보고, 개선 방법들을 적용하여 실제 CLS 지표의 변화까지 살펴보겠습니다.

▪︎ 레이아웃 시프트가 사용자 경험에 미치는 영향

레이아웃 시프트는 다음과 같은 부정적인 영향을 미칩니다.

▫︎ 사용자 불편

  • 읽고 있던 텍스트의 위치가 갑자기 변경
  • 클릭하려던 버튼이 순간적으로 이동
  • 스크롤 위치의 예상치 못한 변화

▫︎ 신뢰성 저하

  • 웹사이트의 완성도가 떨어져 보임
  • 전문성에 대한 의구심 유발
  • 사용자의 재방문율 감소

▪︎ 프로젝트에서 발견된 CLS 이슈

▫︎ 스크롤바 시프팅

1
2
3
4
5
[문제 상황]
1. 초기 페이지 로드 시 데이터 없음 -> 스크롤바 없음
2. 데이터 로드 완료 -> 스크롤바 생성
3. 페이지 너비가 스크롤바 너비만큼 감소
4. 전체 레이아웃이 왼쪽으로 시프트

image.png

해결 방안 1) min-height

1
2
3
4
/* 항상 스크롤바 공간 확보 */
.page-container {
min-height: 101dvh; /* 스크롤바가 항상 표시되도록 설정 */
}

간단하면서 효율적인 방식으로 해결하는 방법으로 min-height에 적정한 값을 주어 항상 스크롤이 존재하는 페이지로 보여주는 방법을 적용하였습니다.

해결 방안 2) Skeleton UI

1
2
3
...
if (isLoading) return <ReviewSkeleton />;
...

데이터 페칭이 진행중일 때, 스크롤바가 생길 정도의 데이터만큼 스켈레톤 UI를 렌더링하여 스크롤바가 존재하도록 하였습니다.

위 두가지 방법을 통해 페이지 초기 로드 시부터 스크롤바 공간을 확보하여 레이아웃의 변화가 없도록 하였고 일관된 사용자 경험을 제공할 수 있었습니다.

개선 이후 측정 결과

image.png

▫︎ 이미지 시프팅

1
2
3
4
5
6
7
<Image
src={travelImage}
alt={`${travelName} - ${travelLocation} 여행 이미지`}
width={300}
height={300}
className="h-full w-full rounded object-cover" // 이미지 로드 전후로 스타일 변경
/>

이미지가 로드되면서 object-fit의 기본값에서 object-cover가 적용되는 과정이 화면에 보여지며 덜컥거리는 현상이 발생하였습니다.

image.png

해결방안

이를 해결하기 위해 Next.js Image 컴포넌트의 onLoadingComplete 속성을 적용하여 이미지가 완전 로드되기 전까지는 투명상태를 유지하고, 로드 완료 시 부드러운 페이드인 효과를 적용하였습니다.

개선 이후 측정 결과

image.png

▪︎ 개선 효과

  1. 사용자 경험 향상
    • 부드러운 페이지 전환
    • 예측 가능한 인터랙션
    • 전문적인 웹사이트 인상
  2. 성능 지표 개선
    • CLS 점수 0.1 이하 달성
    • Core Web Vitals 전반적 향상
    • 모바일 사용성 개선
  3. 비즈니스 효과
    • 사용자 이탈률 감소
    • 페이지 체류 시간 증가
    • 전환율 향상 가능성

▪︎ 마치며

CLS 최적화는 단순한 성능 지표의 개선을 넘어 사용자 경험의 질적 향상을 가져옵니다. 특히 무한 스크롤이나 동적 이미지 로딩이 많은 현대 웹 애플리케이션에서는 필수적인 최적화 요소입니다. 우리 프로젝트에서 적용한 두 가지 해결 방안은 간단하면서도 효과적인 CLS 최적화 전략의 좋은 예시가 될 수 있습니다.

웹 애플리케이션에서 데이터 로딩은 피할 수 없는 과정입니다. 특히 네트워크 상태가 불안정하거나 대량의 데이터를 처리해야 하는 경우, 사용자는 빈 화면이나 로딩 스피너를 보며 기다려야 합니다. 이러한 대기 시간은 사용자 경험을 저하시키는 주요 요인이 됩니다. 이 문제를 해결하기 위해 저희는 스켈레톤 UI를 구현하여 적용했습니다.

▪︎ Skeleton UI 구현 과정

▫︎ 기본 스타일 설정

먼저 Tailwind 설정 파일에서 스켈레톤 UI의 기본 스타일을 정의했습니다. 스켈레톤 UI의 핵심은 로딩 상태를 시각적으로 표현하는 애니메이션입니다. 이를 위해 그라데이션 효과와 움직임을 결합했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// tailwind.config.ts

addUtilities({
".skeleton-style": {
background: "linear-gradient(90deg, #E5E7EB 35%, #F3F4F6 50%, #E5E7EB 65%)",
backgroundSize: "300% auto",
animation: "skeleton-loading 1.5s ease-in-out infinite",
},
"@keyframes skeleton-loading": {
"0%": { backgroundPosition: "100% 0" },
"100%": { backgroundPosition: "0 0" },
},
});

이 설정은 세 가지 주요 요소로 구성됩니다.

  • 그라데이션 배경: 밝은 회색(#F3F4F6)에서 어두운 회색(#E5E7EB)으로 자연스럽게 변화
  • 배경 크기: 실제 요소보다 3배 큰 배경을 설정하여 부드러운 이동 효과 구현
  • 애니메이션: 1.5초 동안 배경이 좌우로 움직이며 무한 반복

▫︎ 재사용 가능한 스켈레톤 컴포넌트

1
2
3
4
5
// Skeleton.tsx

const Skeleton = ({ className, classNameCondition }: Props) => {
return <div className={cn("skeleton-style", className, classNameCondition)} />;
};

이 컴포넌트는 단순하지만 유연합니다. className prop을 통해 각 사용처에 맞는 크기와 스타일을 적용할 수 있으며, classNameCondition을 통해 조건부 스타일링도 가능합니다.

▫︎ 실제 적용 (여행 카드 스켈레톤)

마이페이지에서 MyTravelCardSkeleton 을 적용한 예시입니다. 실제 데이터가 로드되기 전의 UI를 표현합니다.

컴포넌트에 적용할 Skeleton UI 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// MyTravelCardSkeleton.tsx

const MyTravelCardSkeleton = () => {
return (
<div className="w-full max-w-[335px] pb-10 md:max-w-[688px] xl:max-w-[1400px]">
<div className="grid w-full gap-5 xl:grid-cols-2 xl:gap-6">
{Array.from({ length: 6 }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={`skeleton-${index}`}>
<div className="relative flex max-w-[335px] gap-4 md:max-w-[688px] md:gap-9">
<Skeleton className="h-[120px] w-[100px] flex-shrink-0 rounded md:h-[160px] md:w-[223px]" />
<div className="relative flex w-full flex-col justify-between">
<div className="flex flex-col gap-1">
<Skeleton className="h-5 w-12 rounded-[20px]" />
<Skeleton className="h-6 w-full md:h-[26px]" />
</div>
<div className="flex flex-col gap-2.5">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</div>
<Skeleton className="mt-5 h-[1px] w-full max-w-[1400px]" />
</div>
))}
</div>
</div>
);
};

마이페이지 여행 카드에 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const Upcomming = () => {
const itemsPerPage = 6;
const [currentPage, setCurrentPage] = useState(1);
const { data: travels, isLoading } = useUpcommingTravel(itemsPerPage, currentPage - 1);
const totalPages = travels ? Math.ceil(travels.data.total / itemsPerPage) : 0;

const paginate = (pageNumber: number) => setCurrentPage(pageNumber);

if (isLoading) return <MyTravelCardSkeleton />;

return (
<section className="w-full max-w-[335px] pb-10 md:max-w-[688px] xl:max-w-[1400px]" data-testid="upcomming-travels">
{travels && travels.data.total > 0 ? (
<div className="grid w-full gap-5 xl:grid-cols-2 xl:gap-6">
{travels.data?.content.map((travel: TravelCardProps) => (
<div key={travel.travelId}>
<TravelCard
key={travel.travelId}
travelId={travel.travelId}
travelImage={travel.travelImage}
isDomestic={travel.isDomestic}
travelName={travel.travelName}
travelLocation={travel.travelLocation}
maxTravelMateCount={travel.maxTravelMateCount}
currentTravelMateCount={travel.currentTravelMateCount}
startAt={travel.startAt}
endAt={travel.endAt}
bookmarkFlag={travel.bookmarkFlag}
formattedStartDate={checkTomorrow(travel.startAt)}
/>
<HorizontalDivider className="mt-5 xl:mt-6" />
</div>
))}
</div>
) : (
<NoTravel message="아직 참여한 여행이 없어요!" />
)}

<Pagination totalPages={totalPages} currentPage={currentPage} paginate={paginate} />
</section>
);
};

이 컴포넌트는 실제 여행 카드 리스트와 동일한 레이아웃을 가지며, 반응형 디자인을 지원합니다. 6개의 더미 카드를 생성하여 데이터 로딩 중에도 페이지의 구조를 명확히 보여줍니다.

▫︎ 완성된 Skeleton UI

image.png

▪︎ 효과

스켈레톤 UI 구현은 Core Web Vitals를 포함한 주요 웹 성능 메트릭스의 개선에 큰 영향을 미쳤습니다. 성능의 개선은 사용자 경험 향상으로도 이어집니다.

▫︎ FCP(First Contentful Paint) 최적화

스켈레톤 UI는 FCP 개선에도 기여합니다. 페이지의 첫 번째 의미 있는 콘텐츠가 그려지는 시점이 빨라지기 때문입니다.

  • 즉각적인 시각적 피드백: 스켈레톤 UI는 실제 콘텐츠보다 훨씬 가벼워, 페이지 진입 시 즉시 렌더링됩니다.
  • 점진적 로딩: 메인 콘텐츠가 로드되는 동안 의미 있는 UI를 제공하여 체감 LCP를 개선합니다.
  • 레이아웃 안정성: CLS(Cumulative Layout Shift) 또한 개선되어, 콘텐츠 로드 시 레이아웃 변화를 최소화합니다.

Chrome Dev Tools의 성능 탭에서 같은 조건으로 확인해봤을 때 다음과 같은 차이가 있었습니다. 생각보다 큰 차이가 있어 놀랐습니다.

1
2
3
4
5
[스켈레톤 UI 적용 전]
FCP: 559.8ms

[스켈레톤 UI 적용 후]
FCP: 119ms (약 78% 개선)

이러한 성능 개선은 특히 모바일 사용자와 불안정한 네트워크 환경에서 더욱 큰 효과를 발휘합니다. Google의 Core Web Vitals 지표 개선은 검색 엔진 최적화(SEO)에도 긍정적인 영향을 미치며, 이는 결과적으로 웹사이트의 가시성 향상으로 이어집니다.

▪︎ 마치며

스켈레톤 UI 구현을 통해 다음과 같은 효과를 얻을 수 있었습니다:

  1. 인지된 성능 향상
    • 사용자는 빈 화면이나 로딩 스피너 대신 콘텐츠의 구조를 즉시 확인할 수 있습니다.
    • 이는 실제 로딩 시간이 동일하더라도 더 빠르게 느껴지는 효과를 줍니다.
  2. 자연스러운 전환
    • 스켈레톤에서 실제 콘텐츠로의 전환이 부드럽게 이루어집니다.
    • 레이아웃 시프트가 최소화되어 더 안정적인 사용자 경험을 제공합니다.
  3. 프로그레시브 로딩
    • 페이지의 구조가 먼저 로드되어 사용자가 전체적인 레이아웃을 파악할 수 있습니다.
    • 이는 특히 모바일 환경에서 중요한 이점을 제공합니다.
  4. 반응형 디자인 지원
    • 스켈레톤 UI도 실제 컴포넌트와 동일한 반응형 규칙을 따릅니다.
    • 모든 화면 크기에서 일관된 로딩 경험을 제공합니다.

이러한 스켈레톤 UI 구현은 단순한 시각적 개선을 넘어 전반적인 사용자 경험 향상에 기여했습니다.

특히 네트워크 상태가 불안정한 모바일 환경에서 더욱 효과적으로 작동하며, 사용자의 이탈률을 줄이는 데 도움이 될 것이라 생각합니다.

▪︎ 로그인 폼 구현 과정과 고민들

▫︎ 상태 관리 방식의 선택

로그인 폼을 구현할 때 가장 먼저 고민했던 것은 입력값을 어떤 방식으로 관리할 것인가였습니다. React에서는 크게 두 가지 접근 방식이 있습니다:

제어 컴포넌트 (Controlled Component)

  • 리액트가 폼 데이터를 완전히 제어
  • useState를 사용하여 상태 관리
  • 실시간으로 입력값 검증 가능

비제어 컴포넌트 (Uncontrolled Component)

  • DOM이 폼 데이터를 처리
  • useRef를 사용하여 DOM에 직접 접근
  • 필요할 때만 값을 참조

저는 다음과 같은 3가지 이유로 useState를 선택하여 구현하였습니다.

1. 상태의 본질

1
2
3
4
5
6
const LoginForm = () => {
const email = useAuthInput({ name: 'email' });
const password = useAuthInput({ name: 'password' });
// ...
};
};
  • 입력값은 시간에 따라 변화하는 애플리케이션의 상태
  • UI에 즉시 반영되어야 하는 데이터
  • 상태 변화에 따른 부수 효과(유효성 검증) 필요

2. 실시간 유효성 검증

1
2
3
4
5
6
7
const validate = ({ name, value, password }: ValidateOptions): boolean => {
switch (name) {
case "email":
return REGEX.email.test(value);
// ...
}
};
  • 사용자 경험 향상을 위한 즉각적인 피드백 필요
  • 상태 변화를 감지하여 자동으로 검증 수행

3. 제어 컴포넌트의 이점

  • 폼 데이터에 대한 완벽한 제어 가능
  • 입력값 포맷팅이나 제한 용이
  • 여러 입력 필드 간의 연동 용이

▫︎ 로직을 분리하여 기능 구현

코드의 재사용성과 유지보수성을 높이기 위해 다음과 같이 구조를 분리했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// hooks/useAuthInput.ts
// 상태 관리와 이벤트 핸들링을 위한 커스텀 훅
const useAuthInput = ({ name, password }: Props) => {
const [value, setValue] = useState("");
const [isValid, setIsValid] = useState<boolean | null>(null);

const debouncedValidate = useMemo(
() =>
debounce((newValue: string) => {
if (!newValue) {
setIsValid(null);
return;
}

const validationResult = validate({ name, value: newValue, password });

setIsValid(validationResult);
}, 250),
[name, password]
);

const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;

setValue(newValue);
debouncedValidate(newValue);
},
[debouncedValidate]
);

useEffect(() => {
return () => {
debouncedValidate.cancel();
};
}, [debouncedValidate]);

return {
value,
isValid,
setValue,
setIsValid,
handleChange,
};
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// utils/validateAuthInput.ts
// 유효성 검증 로직
const validate = ({ name, value, password }: ValidateOptions): boolean => {
switch (name) {
case "email":
return REGEX.email.test(value);
case "password":
return REGEX.password.test(value);
case "passwordConfirm":
return password ? value === password : false;
case "currentPassword":
return REGEX.currentPassword.test(value);
case "newPassword":
return REGEX.newPassword.test(value);
case "name":
return REGEX.name.test(value);
case "nickname":
return REGEX.nickname.test(value);
case "contact":
return REGEX.contact.test(value);
case "birthDate":
return REGEX.birthDate.test(value);
case "verifyNumber":
return REGEX.verifyNumber.test(value);
default:
return false;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// components/auth/form/loginForm.tsx
// UI 컴포넌트
const LoginForm = () => {
const email = useAuthInput({ name: "email" });
const password = useAuthInput({ name: "password" });
const { mutate: login, isPending: isLoggingIn } = useLogin();

const handleLogin = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

login({
email: email.value,
password: password.value,
});
};

return (
<form className="mb-4 w-full" onSubmit={handleLogin} data-testid="login-form">
<AuthText
type="email"
name="email"
size="full"
value={email.value}
isValid={email.isValid}
onChange={email.handleChange}
/>

<AuthPassword
name="password"
value={password.value}
isValid={password.isValid}
onChange={password.handleChange}
/>
<Button
label="로그인"
type="submit"
size="full"
className="mt-[180px] hover:bg-primary-normal"
disabled={!email.isValid || !password.isValid}
showSpinner={isLoggingIn}
/>
</form>
);
};

이러한 구조는 각 부분의 책임이 명확하다는 점, 테스트가 용이하다는 점, 코드의 재사용성이 향상된다는 장점이 있습니다.

▪︎ 상태 공존과 리렌더링 최적화

▫︎ 상태 공존 (State Colocation)

상태 공존이란 하나의 컴포넌트 내에서 여러 개의 독립적인 상태가 함께 존재하는 상황을 말합니다.

1
2
3
4
5
6
7
8
9
10
11
const LoginForm = () => {
const email = useAuthInput({ name: "email" }); // 상태 1
const password = useAuthInput({ name: "password" }); // 상태 2

return (
<form>
<AuthText {...email} /> // 이메일 입력 시 패스워드도 리렌더링
<AuthPassword {...password} /> // 패스워드 입력 시 이메일도 리렌더링
</form>
);
};

상태 공존으로 인해 한 입력 필드의 상태 변화가 다른 필드의 불필요한 리렌더링을 유발한다는 문제점이 있었습니다. 이것은 성능 저하로 이어지고 결국 사용자 경험을 저하시키는 결과를 낳을 수 있었습니다.

이를 위해 메모이제이션 기능을 이용한 최적화 전략을 사용하기로 했습니다.

▫︎ memoization을 통한 최적화

React.memo를 통한 컴포넌트 메모이제이션

1
2
3
4
5
6
7
8
9
10
11
const AuthText = memo(
({
type,
name,
value,
isValid,
}: // ...
Props) => {
// ...
}
);
  • props가 변경되지 않은 컴포넌트의 리렌더링 방지
  • 각 입력 필드가 독립적으로 렌더링

useCallback을 통한 이벤트 핸들러 메모이제이션

1
2
3
4
5
6
7
8
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);
debouncedValidate(newValue);
},
[debouncedValidate]
);
  • 매 렌더링마다 새로운 함수가 생성되는 것을 방지
  • 의존성 배열에 debouncedValidate만 포함하여 불필요한 재생성 방지

useMemo를 통한 검증 함수 메모이제이션

1
2
3
4
5
6
7
8
9
10
11
12
const debouncedValidate = useMemo(
() =>
debounce((newValue: string) => {
if (!newValue) {
setIsValid(null);
return;
}
const validationResult = validate({ name, value: newValue, password });
setIsValid(validationResult);
}, 250),
[name, password]
);
  • 고비용 연산인 debounce 함수의 재생성 방지
  • 의존성이 변경될 때만 새로운 함수 생성

▪︎ 결과

이러한 최적화 전략들을 적용한 결과 다음과 같은 점들이 개선되었습니다.

1. 성능 향상

1
2
3
[리렌더링 패턴]
최적화 전: Input1 변경 → 모든 Input 리렌더링
최적화 후: Input1 변경 → Input1만 리렌더링

2. 메모리 사용 개선

  • 불필요한 함수 생성 감소
  • 효율적인 메모리 관리

3. 사용자 경험 개선

  • 입력 지연 감소
  • 부드러운 UI 업데이트

사용자의 입력을 실시간으로 검증하는 기능을 구현할 때, 가장 먼저 고려해야 할 것은 성능과 메모리 관리입니다. 특히 이메일이나 비밀번호와 같은 입력 필드에서는 사용자가 타이핑하는 동안 지속적으로 유효성 검증이 발생하게 되는데, 이는 불필요한 연산과 메모리 사용을 초래할 수 있습니다. 이러한 문제를 해결하기 위해 debounce 기법을 활용한 최적화 방법을 소개하고자 합니다.

▪︎ Debounce를 활용한 최적화 구현

사용자가 입력 필드에 타이핑을 할 때마다 유효성 검증 함수가 실행되면 다음과 같은 문제가 발생할 수 있습니다:

  • 불필요한 연산 발생: 사용자가 ‘example@gmail.com‘을 입력한다고 가정했을 때, 각 글자가 입력될 때마다 이메일 유효성 검증이 실행됩니다. 즉, ‘e’, ‘ex’, ‘exa’… 와 같이 완성되지 않은 상태에서도 검증이 수행되는 것입니다.
  • 리소스 낭비: 특히 복잡한 유효성 검증 로직이나 API 호출이 포함된 경우, 불필요한 리소스 사용이 발생합니다.
  • 메모리 누수 가능성: 컴포넌트가 언마운트되었을 때 진행 중이던 검증 작업들이 적절히 정리되지 않으면 메모리 누수로 이어질 수 있습니다.

이러한 문제를 해결하기 위해 lodash의 debounce 함수를 활용하여 다음과 같이 구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const debouncedValidate = useMemo(
() =>
debounce((newValue: string) => {
// 빈 값 체크는 즉시 수행
if (!newValue) {
setIsValid(null);
return;
}

// 실제 유효성 검증은 지연 수행
const validationResult = validate({ name, value: newValue, password });
setIsValid(validationResult);
}, 250),
[name, password]
);

// 메모리 누수 방지를 위한 클린업
useEffect(() => {
return () => {
debouncedValidate.cancel();
};
}, [debouncedValidate]);

▫︎ Debounce 함수의 메모이제이션

useMemo를 사용하여 debounce 함수를 메모이제이션한 이유는 다음과 같습니다.

  • 컴포넌트가 리렌더링될 때마다 새로운 debounce 함수가 생성되는 것을 방지합니다.
  • 의존성 배열에 name과 password만 포함하여, 이 값들이 변경될 때만 새로운 함수가 생성되도록 합니다.
  • 불필요한 메모리 사용을 줄이고 성능을 최적화할 수 있습니다.

▫︎ 지연 시간의 설정

250밀리초의 지연 시간을 설정한 이유는 다음과 같습니다.

  • 사용자의 타이핑 속도를 고려하여 적절한 대기 시간을 설정했습니다.
  • 너무 짧으면 debounce의 효과가 미미하고, 너무 길면 사용자가 답답함을 느낄 수 있습니다.
  • 실제 사용자 테스트를 통해 최적의 시간을 도출했습니다.

▫︎ 메모리 누수 방지

클린업 함수를 구현한 이유와 그 중요성.

  • 컴포넌트가 언마운트될 때 진행 중인 모든 debounce 작업을 취소합니다.
  • 이는 메모리 누수를 방지하고 예기치 않은 상태 업데이트를 막아줍니다.
  • React의 Strict Mode에서도 안정적으로 동작하도록 보장합니다.

▪︎ 최적화의 효과

최적화 이전과 이후를 비교하기 위해 chrome dev tools의 performance 탭과 memory 탭을 활용하였습니다.

image.png

▫︎ 테스트 시나리오

다음과 같은 상황에서 메모리 사용량을 측정했습니다.

  1. 이메일 입력 필드에 지정된 문자열을 250ms 이상의 속도로 빠르게 입력
  2. 5초 동안 대기
  3. 컴포넌트 언마운트

▫︎ 측정 결과

Debounce 적용 시

1
2
3
4
5
6
7
8
9
10
11
12
// Debounce 없이 직접 검증
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setValue(newValue);

const validationResult = validate({
name,
value: newValue,
password,
});
setIsValid(validationResult);
};
  • 초기 메모리: 24MB
  • 입력 중 최대 메모리: 32MB
  • 언마운트 후 메모리: 28MB (메모리 누수 발생)
  • 검증 함수 호출 횟수: 16회 (“test@example.com“ 입력 시)

Debounce 미 적용 시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const debouncedValidate = useMemo(
() =>
debounce((newValue: string) => {
if (!newValue) {
setIsValid(null);
return;
}
const validationResult = validate({ name, value: newValue, password });
setIsValid(validationResult);
}, 250),
[name, password]
);

useEffect(() => {
return () => {
debouncedValidate.cancel();
};
}, [debouncedValidate]);
  • 초기 메모리: 24MB
  • 입력 중 최대 메모리: 27MB
  • 언마운트 후 메모리: 24MB (초기 상태로 복귀)
  • 검증 함수 호출 횟수: 1회 (“test@example.com“ 입력 시)

메모리 패턴

1
2
3
4
5
6
7
8
9
10
11
// 메모리 사용량 그래프 (시간에 따른 변화)
/*
Debounce 미적용:
32MB ┌─────┐
28MB │ └───────
24MB └─────────────

Debounce 적용:
27MB ┌─┐
24MB └─└───────────
*/

주요 차이점:

  • 최대 메모리 사용량: Debounce를 적용했을 때 약 16% 낮은 최대 메모리 사용량을 보였습니다.
  • 메모리 해제: Debounce 적용 시 컴포넌트 언마운트 후 메모리가 완전히 해제되었습니다.
  • 메모리 변동폭: Debounce 적용 시 메모리 사용량의 변동폭이 더 작았습니다.

CPU 사용량

1
2
3
4
5
6
7
8
9
10
// CPU 사용량 비교
/*
Debounce 미적용:
- 입력 중 CPU 사용: 평균 15%
- 검증 함수 실행 시 스파이크: 최대 25%

Debounce 적용:
- 입력 중 CPU 사용: 평균 5%
- 검증 함수 실행 시 스파이크: 최대 8%
*/

가비지 컬렉션 (GC) 패턴

  • Debounce 미적용: 잦은 GC 발생 (초당 약 2-3회)
  • Debounce 적용: GC 발생 빈도 감소 (초당 약 0.5회)

▪︎ 최적화 의의

  1. 사용자 경험 개선
    • 입력 지연 감소
    • 브라우저 반응성 향상
    • 배터리 사용량 감소 (모바일 환경)
  2. 서버 리소스 절약
    • API 호출이 포함된 검증의 경우, 서버 부하 감소
    • 네트워크 트래픽 감소
  3. 장기적 안정성
    • 메모리 누수 방지로 인한 안정적인 장시간 사용
    • 예측 가능한 리소스 사용 패턴

이러한 측정 결과를 통해 Debounce 적용이 단순한 최적화를 넘어서, 애플리케이션의 전반적인 성능과 안정성 향상에 큰 영향을 미친다는 것을 확인할 수 있었습니다.

Next.js 프로젝트에서 모달을 효율적으로 관리하고 재사용 가능한 시스템을 구축하기 위해 Zustand를 사용하여 전역 상태 관리커스텀 훅을 통한 편리한 모달 호출 인터페이스를 작성하였습니다.

▪︎ 주요 기능

  • 전역 모달 상태 관리 ( Zustand )
  • 텍스트 하이라이팅 기능
  • 아이콘 커스터마이징
  • 확인/취소 버튼 커스터마이징
  • 콜백함수 지원

▪︎ 구현 상세

▫︎ Modal Store

zustand를 사용하여 모달의 상태를 전역적으로 관리하였습니다. 텍스트 하이라이팅, 아이콘, 버튼 텍스트, 콜백 함수 등 다양한 커스터마이징 옵션 제공이 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// useModalStore.ts

interface TextHighlight {
range: {
start: number;
end: number;
};
color: string;
}

interface ModalState {
isOpen: boolean;
icon?: ComponentType;
title: string;
titleHighlight?: TextHighlight;
message: string;
messageHighlight?: TextHighlight;
confirmText: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
}

interface ModalStore {
modal: ModalState;
openModal: (modal: Partial<ModalState>) => void;
closeModal: () => void;
}

const initialState: ModalState = {
isOpen: false,
title: "",
message: "",
confirmText: "확인",
};

const useModalStore = create<ModalStore>((set) => ({
modal: initialState,
openModal: (modal) =>
set((state) => ({
modal: { ...state.modal, ...modal, isOpen: true },
})),
closeModal: () => set({ modal: initialState }),
}));

export { useModalStore, type TextHighlight };

▫︎ Modal Component

실제 화면에 그려질 컴포넌트입니다. 텍스트 하이라이팅 기능에 대해 어떻게 구현할지가 최대 고민거리였습니다.

image.png

위 이미지처럼 Title과 message 들 중 텍스트에 색상으로 하이라이트가 들어가 있는 경우가 있었습니다. 어떤 식으로 인자를 받아야 해당 부분을 구현할 수 있을까 고민하다 하이라이팅 할 텍스트의 시작점과 끝점에 대한 정보와 color 정보를 받아 구현하도록 하였습니다.

1
2
3
4
5
6
7
8
9
10
11
const HighlightedText = ({ text, highlight }: { text: string; highlight?: TextHighlight }) => {
if (!highlight) return <span className="whitespace-pre-line">{text}</span>;

return (
<span className="whitespace-pre-line">
{text.slice(0, highlight.range.start)}
<span className={highlight.color}>{text.slice(highlight.range.start, highlight.range.end)}</span>
{text.slice(highlight.range.end)}
</span>
);
};

모달 컴포넌트의 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// Modal.tsx

const Modal = () => {
const { modal, closeModal } = useModalStore();
const {
isOpen,
icon: Icon,
title,
titleHighlight,
message,
messageHighlight,
confirmText = "확인",
cancelText = "취소",
onConfirm,
onCancel,
} = modal;

const handleConfirm = () => {
if (onConfirm) onConfirm();
closeModal();
};

const handleCancel = () => {
if (onCancel) onCancel();
closeModal();
};

if (!isOpen) return null;

return (
<div className="fixed inset-0 z-50 flex animate-fade-in items-center justify-center bg-black/40 pb-[80px]">
<div className="min-w-[298px] animate-slide-up rounded-lg bg-white p-8 md:min-w-[380px]">
{Icon && (
<div className="mb-4 flex justify-center">
<div className="bg-primary-light flex h-16 w-16 items-center justify-center rounded-full">
<Icon />
</div>
</div>
)}

<div className="text-center">
<h2 className="title-5-b">
<HighlightedText text={title} highlight={titleHighlight} />
</h2>
<p className="text-body-2-r mt-2 whitespace-pre-line text-label-neutral">
<HighlightedText text={message} highlight={messageHighlight} />
</p>
</div>

<div className="mt-6 flex w-full justify-center">
{onCancel ? (
<div className="flex w-[250px] gap-2">
<Button
label={cancelText}
size="full"
handler={handleCancel}
fill="white"
className="h-[38px] w-[120px]"
/>
<Button label={confirmText} size="full" handler={handleConfirm} className="h-[38px] w-[120px]" />
</div>
) : (
<Button
label={confirmText}
size="addon"
handler={handleConfirm}
className="h-[38px] w-[120px] md:w-[210px]"
/>
)}
</div>
</div>
</div>
);
};

export default Modal;

▫︎ Modal Custom Hook

간편한 모달 호출을 위해서 인터페이스를 제공하기 위해 커스텀 훅을 작성해주었습니다. 이제는 어디서든 이 커스텀 훅을 필요한 정보와 함께 호출하면 모달 사용이 가능해집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
interface ModalOptions {
icon?: ComponentType<SVGProps<SVGElement>>;
titleHighlight?: TextHighlight;
messageHighlight?: TextHighlight;
confirmText?: string;
cancelText?: string;
onConfirm?: () => void;
onCancel?: () => void;
}

const useModal = () => {
const { openModal, closeModal } = useModalStore();

const showModal = (title: string, message: string, options?: ModalOptions) => {
openModal({
title,
message,
...options,
});
};

return {
showModal,
closeModal,
};
};

export default useModal;

▪︎ 사용 예시

프로젝트 내 동일한 형식의 모달이 필요한 곳에서는 모달 커스텀 훅을 호출하여 편리하게 사용이 가능했습니다. 예시 코드는 TanstackQuery의 useMutation 훅으로 작성된 회원가입 api 가 성공했을 시, 모달이 보여지게 동작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// useSignup.ts
const useSignup = () => {
const router = useRouter();
const { showModal } = useModal();

return useMutation({
mutationFn: signup,
onError: (error: QueryError) => {
switch (error.status) {
case 500:
showModal("네트워크를 확인해주세요.", "회원가입에 실패했습니다.", {
icon: ModalErrorIcon,
confirmText: "돌아가기",
messageHighlight: {
range: { start: 6, end: 8 },
color: "text-status-error",
},
onConfirm: () => {
router.push("/login");
},
});
break;
case 400:
// 400 에러 핸들링
break;
default:
// default 에러 핸들링
}
},
onSuccess: () => {
showModal("축하해요! WE'GO의\n회원가입이 완료 되었습니다.", "이제 함께 떠나는 여행을 시작해요.", {
icon: ModalSuccessIcon,
confirmText: "확인",
titleHighlight: {
range: { start: 19, end: 21 },
color: "text-primary-normal",
},
onConfirm: () => {
router.push("/");
},
});
},
});
};

▪︎ 구현 방식의 장점

1. 재사용성

프로젝트 전체에서 일관된 모달 UI/UX 제공

2. 타입 안정성

TypeScript를 활용한 완벽한 타입 지원

3. 커스터마이징

다양한 옵션으로 상황에 맞는 모달 구성 가능

4. 사용 편의성

간단한 인터페이스로 복잡한 모달 구현 가능

▪︎ 마치며

ZustandCustom Hook을 활용하여 재사용 가능하고 타입 안전한 모달 시스템을 구축했습니다. 이를 통해 프로젝트의 일관성을 유지하면서도 다양한 상황에 대응할 수 있는 유연한 모달 구현이 가능했습니다.

프로젝트를 진행하면서 버튼 컴포넌트와 같은 공통 컴포넌트를 설계할 때, 확장성과 재사용성을 최우선으로 고려했습니다. 이를 위해 cva, clsx, tailwind-merge라는 세 가지 라이브러리를 조합하여 사용했는데요, 이번 글에서는 각 라이브러리의 특징과 이들을 조합했을 때의 시너지에 대해 공유하고자 합니다.

▪︎ 각 라이브러리의 특징과 역할

▫︎ CVA (Class Variance Authority)

Class Variance Authority(CVA)는 컴포넌트의 다양한 스타일 변형(variants)을 타입 안전하게 관리할 수 있게 해주는 라이브러리입니다. 특히 Tailwind CSS와 함께 사용할 때 그 진가를 발휘하는데, 이는 미리 정의된 스타일 조합을 타입 시스템의 보호 아래 안전하게 사용할 수 있게 해줍니다.

우리 프로젝트의 버튼 컴포넌트에는 다음과 같이 variants를 정의해 사용하였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const ButtonVariants = cva(
// 기본 스타일
"body-1-m rounded disabled:border disabled:border-line-normal disabled:bg-background-alternative disabled:text-label-alternative flex items-center justify-center",
{
variants: {
fill: {
default: "bg-label-normal text-white",
white: "bg-white border border-line-strong text-line-strong",
blue: "bg-primary-normal text-white hover:text-primary-normal hover:bg-blue-100",
},
size: {
default: "w-[335px] h-[52px]",
full: "w-full h-[52px]",
// ... 기타 사이즈 variants
},
// ... 기타 variant 그룹들
},
defaultVariants: {
fill: "default",
size: "default",
font: "default",
},
}
);

이러한 CVA의 사용은 다음과 같은 구체적인 이점을 제공합니다.

  1. 타입 안전성: TypeScript와 완벽하게 통합되어, 정의되지 않은 variant 조합을 사용하려 할 때 컴파일 단계에서 오류를 발견할 수 있습니다. 예를 들어, fill=”invalid”와 같은 잘못된 값을 사용하려 하면 즉시 TypeScript 에러가 발생합니다.
  2. 중앙 집중화된 스타일 관리: 모든 스타일 variant를 한 파일에서 관리함으로써, 디자인 시스템의 일관성을 유지하기가 훨씬 쉬워집니다. 스타일 수정이 필요할 때도 한 곳만 수정하면 되므로 유지보수가 용이합니다.
  3. 자동 완성 지원: IDE에서 사용 가능한 variant 옵션들을 자동으로 제안받을 수 있어, 개발 생산성이 크게 향상됩니다.

▫︎ CLSX

CLSX는 조건부 클래스명을 처리하기 위한 유틸리티 라이브러리입니다. 특히 React 컴포넌트에서 동적인 클래스명을 다룰 때 매우 유용합니다. 이 라이브러리는 다양한 형태의 입력을 처리할 수 있으며, 최종적으로 유효한 클래스명 문자열을 생성해줍니다.

1
2
3
4
5
6
7
import { clsx, ClassValue } from "clsx";

// clsx 사용 예시
const className = clsx("base-class", isActive && "active", { "is-disabled": isDisabled }, [
"additional-class-1",
"additional-class-2",
]);

CLSX는 다음과 같은 강력한 기능들을 제공합니다.

  1. 다양한 입력 형식 지원: 문자열, 객체, 배열 등 어떤 형태로든 클래스명을 전달할 수 있습니다. 이는 다양한 상황에서 유연하게 클래스를 조합할 수 있게 해줍니다.
  2. 자동 정리: falsy 값(undefined, null, false 등)을 자동으로 필터링하여 깨끗한 클래스명 문자열을 생성합니다.
  3. 직관적인 문법: 복잡한 조건부 클래스 로직도 읽기 쉽고 이해하기 쉬운 형태로 작성할 수 있습니다.

▫︎ Tailwind Merge

Tailwind Merge는 Tailwind CSS를 사용할 때 발생할 수 있는 클래스 충돌 문제를 우아하게 해결해주는 라이브러리입니다. 특히 재사용 가능한 컴포넌트를 만들 때 매우 유용한데, 이는 기본 스타일과 커스텀 스타일이 충돌할 때 가장 마지막에 선언된 스타일을 우선적으로 적용하는 방식으로 동작합니다.

Tailwind Merge를 통해 해결할 수 있는 실제 문제 상황을 예시로 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
// Tailwind Merge 없이 사용할 경우
<button className="px-4 py-2 text-sm text-blue-500 px-6">
// px-4와 px-6이 충돌, 브라우저 CSS 규칙에 따라 먼저 선언된 px-4가 적용됨
</button>

// Tailwind Merge 사용
<button className={twMerge("px-4 py-2 text-sm text-blue-500", "px-6")}>
// 의도한 대로 px-6이 적용됨
</button>

Tailwind Merge는 다음과 같은 핵심적인 문제들을 해결합니다.

  1. 충돌 해결: 같은 속성을 가진 클래스들이 충돌할 때, 가장 마지막에 선언된 클래스를 우선적으로 적용합니다. 이는 CSS의 일반적인 캐스케이딩 규칙과도 일치하는 직관적인 동작입니다.
  2. 최적화: 중복되거나 불필요한 클래스들을 자동으로 제거하여 최종적으로 깔끔한 클래스 문자열을 생성합니다.
  3. 모든 Tailwind 규칙 지원: Tailwind CSS의 모든 유틸리티 클래스와 변형자(modifiers)를 완벽하게 이해하고 처리할 수 있습니다.

▪︎ 세 라이브러리를 조합한 유틸함수 활용

이 세 라이브러리를 조합하여 사용할 때 발생하는 시너지는 매우 강력합니다. 각각의 라이브러리가 가진 장점들이 서로를 보완하면서, 더욱 강력하고 유지보수하기 쉬운 컴포넌트 시스템을 구축할 수 있게 됩니다.

우리 프로젝트에 적용하기 위해 세 라이브러리를 조합한 유틸함수인 cn을 작성하였습니다.

1
2
3
4
5
6
7
8
// cn.tsx

import { twMerge } from "tailwind-merge";
import { clsx, ClassValue } from "clsx";

const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/ Button.tsx (유틸 함수 활용한 공통 컴포넌트)

const Button = forwardRef<HTMLButtonElement, Props>(
({
fill,
size,
font,
hover,
hoverBorder,
className,
classNameCondition,
...props
}, ref) => {
return (
<button
className={cn(
ButtonVariants({ fill, size, font, hover, hoverBorder }),
className,
classNameCondition,
)}
{...props}
ref={ref}
>
{/* 버튼 내용 */}
</button>
);
}
);

1. 개발 경험 향상

  • 타입 안전성: CVA를 통해 정의된 variants는 TypeScript의 타입 검사를 받게 되어, 잘못된 prop 사용을 방지할 수 있습니다.
  • 자동 완성: IDE에서 사용 가능한 모든 variant 옵션들을 자동으로 제안받을 수 있어 개발 속도가 향상됩니다.
  • 디버깅 용이성: 스타일 관련 문제가 발생했을 때, 문제의 원인을 쉽게 추적할 수 있습니다.

2. 유지보수성 개선

  • 중앙 집중화된 스타일 관리: 모든 기본 스타일 변형이 한 곳에서 관리되어 일관성 유지가 쉽습니다.
  • 변경 용이성: 디자인 시스템의 변경이 필요할 때, variants 정의만 수정하면 되므로 변경 사항을 쉽게 적용할 수 있습니다.
  • 코드 재사용: 잘 정의된 variant 시스템으로 인해 동일한 스타일을 여러 곳에서 일관되게 사용할 수 있습니다.

3. 확장성 확보

  • 커스텀 스타일링: className prop을 통해 기본 스타일을 확장하거나 재정의할 수 있습니다.
  • 조건부 스타일링: classNameCondition을 통해 특정 조건에 따른 스타일 적용이 가능합니다.
  • 새로운 변형 추가: 새로운 디자인 요구사항이 생겼을 때 variants에 쉽게 추가할 수 있습니다.

▪︎ 컴포넌트 사용 예시

▫︎ 기본적인 사용 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
// 기본 버튼
<Button
fill="default"
size="full"
label="로그인"
/>

// 모달 내 버튼
<Button
fill="white"
size="modal"
label="취소"
/>

이러한 기본 사용에서는 미리 정의된 variants를 통해 일관된 디자인을 유지하면서도, 각 상황에 맞는 적절한 스타일을 적용할 수 있습니다.

▫︎ 활용 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 동적 스타일링이 필요한 경우
<Button
fill="blue"
size="modal"
className={cn(
"mt-4",
isSpecial && "border-2 border-primary-normal",
isPriority ? "shadow-lg" : "shadow-sm"
)}
label="확인"
/>

// 조건부 스타일링이 필요한 경우
<Button
fill="white"
size="default"
classNameCondition={{
'opacity-50 cursor-not-allowed': isDisabled,
'shadow-md': isActive,
'ring-2 ring-primary-normal': isFocused
}}
label="제출하기"
disabled={isDisabled}
/>

활용 패턴에서는 다음과 같은 이점을 얻을 수 있습니다.

  • className을 통해 상황에 따른 추가적인 스타일을 적용할 수 있습니다.
  • classNameCondition을 사용하여 여러 조건에 따른 스타일 변화를 한 번에 관리할 수 있습니다.
  • 기본 variants와 커스텀 스타일을 자연스럽게 조합할 수 있습니다.

▪︎ 유지보수와 확장

이러한 설계는 장기적인 관점에서 큰 이점을 제공합니다.

1. 새로운 디자인 요구사항 대응

  • 새로운 variant를 추가하기 쉽습니다.
  • 기존 스타일을 수정하더라도 타입 시스템이 변경이 필요한 곳을 알려줍니다.

2. 일관성 유지

  • 모든 버튼이 동일한 스타일 시스템을 따르게 됩니다.
  • 디자인 토큰의 변경이 용이합니다.

3. 팀 협업

  • 명확한 사용 방법과 타입 지원으로 다른 개발자들도 쉽게 사용할 수 있습니다.
  • 문서화가 용이합니다.

▪︎ 마치며

이번 글에서는 CVA, CLSX, Tailwind Merge를 조합하여 어떻게 확장 가능하고 유지보수하기 좋은 버튼 컴포넌트를 구현했는지 살펴보았습니다. 이러한 설계 방식을 통해 얻은 주요 이점들을 정리해보면 다음과 같습니다:

▫︎ 실제 프로젝트에서의 효과

  1. 개발 생산성 향상
    • 타입 시스템의 지원으로 실수를 사전에 방지할 수 있었습니다.
    • 자동 완성 기능으로 개발 속도가 크게 향상되었습니다.
    • 반복적인 스타일링 코드 작성이 줄어들었습니다.
  2. 디자인 시스템 일관성 확보
    • 중앙에서 관리되는 스타일 variants로 인해 일관된 UI를 유지할 수 있었습니다.
    • 디자인 변경 사항을 쉽게 적용할 수 있었습니다.
    • 새로운 디자인 요구사항에도 유연하게 대응할 수 있었습니다.
  3. 코드 품질 향상
    • 타입 안전성으로 인해 런타임 에러가 감소했습니다.
    • 스타일 관련 버그를 쉽게 추적하고 해결할 수 있었습니다.
    • 코드베이스가 커져도 유지보수가 어렵지 않았습니다.

이러한 접근 방식은 단순히 스타일링 문제를 해결하는 것을 넘어서, 확장 가능하고 유지보수하기 좋은 컴포넌트 시스템을 구축하는 데 큰 도움이 되었습니다. 특히 팀 단위의 개발에서 일관성을 유지하면서도 유연한 확장이 가능한 구조를 만들 수 있었다는 점에서 큰 의미가 있었습니다.

앞으로도 이러한 경험을 바탕으로, 더 나은 컴포넌트 시스템을 구축해 나가도록 하겠습니다.

웹 애플리케이션에서 인증된 사용자와 비인증 사용자에 대한 페이지 접근 제어는 매우 중요합니다. 특히 사용자 경험(UX)을 해치지 않으면서 안전하게 구현하는 것이 핵심인데요. 이번 글에서는 Next.js의 미들웨어를 활용해 어떻게 더 나은 인증 플로우를 구현했는지 공유하고자 합니다.

▪︎ 기존 인증 구현의 문제점

유저의 인증 여부를 판단하기 위해 token 검증만을 위한 api를 백엔드 측에 요청했습니다. 백엔드 측에서는 요청의 쿠키에서 accessToken을 읽어 그 검증 여부에 따라 결과를 반환해줬습니다. 우리 프론트에서는 인가 필요 페이지에서 그 결과가 성공이면 페이지를 보여주고, 실패면 로그인 페이지로 리다이렉트 시키는 로직이 필요했습니다.

처음에는 컴포넌트 내부에서 api 요청을 하고 그 결과에 따라 리다이렉트 여부를 결정했습니다. 그러나 이 경우 api 요청에 대한 결과를 받는 동안 잠깐동안 페이지의 레이아웃이 보여지는 플리킹 현상이 발생했습니다. 추가로 각 컴포넌트마다 토큰 인증에 따른 리다이렉트 로직을 작성해주어야 했습니다.

▫︎ 깜빡임 현상 (Flash of Unauthorized Content)

1
2
3
4
5
6
7
8
9
10
11
// 컴포넌트 내부에서 인증 체크 시 발생하는 문제
const ProtectedPage = () => {
useEffect(() => {
// 인증 체크 후 리다이렉트
if (!isAuthenticated) {
router.push("/login");
}
}, []);

return <div>보호된 콘텐츠</div>; // 잠깐 보였다가 사라짐
};

▫︎ 중복 코드 발생

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 여러 서버 컴포넌트에서 반복되는 인증 체크 로직
export default async function Page1() {
const isAuthenticated = await checkAuth();
if (!isAuthenticated) {
redirect("/login");
}
// ...
}

export default async function Page2() {
const isAuthenticated = await checkAuth();
if (!isAuthenticated) {
redirect("/login");
}
// ...
}

▪︎ 미들웨어를 활용한 해결 방안

▫︎ 중앙 집중화된 인증 로직

미들웨어의 가장 큰 장점은 모든 페이지 요청에 대해 선제적으로 실행된다는 점입니다. 이를 활용하여 인증 로직을 중앙에서 관리할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// middleware.ts
export const middleware = (request: NextRequest) => {
// 정적 파일 요청은 미들웨어 처리하지 않음
if (
request.nextUrl.pathname.startsWith("/_next") ||
request.nextUrl.pathname.includes("/api/") ||
request.nextUrl.pathname.match(/\.(ico|png|svg|jpg|jpeg|gif)$/)
) {
return NextResponse.next();
}

const response = NextResponse.next();

// 로그인 여부에 따라 리다이렉트
return authRedirect(request, response);
};

여기서 주목할 점은 다음과 같습니다.

  • 성능 최적화: 정적 파일에 대한 요청은 미들웨어를 거치지 않도록 처리했습니다. 이는 불필요한 인증 검사를 줄여 성능을 향상시킵니다.
  • 확장성: authRedirect 함수를 별도로 분리하여, 추후 인증 로직이 변경되거나 추가되어도 유연하게 대응할 수 있습니다.
  • 유지보수성: 인증 관련 로직이 한 곳에 집중되어 있어, 변경이 필요할 때 이 파일만 수정하면 됩니다.

▫︎ 환경별 토큰 검증 전략

개발 환경과 프로덕션 환경에서의 토큰 검증 방식을 분리하여 관리했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const authRedirect = async (request: NextRequest, response: NextResponse) => {
const verifyResponse =
process.env.NODE_ENV === "development" ? await verifyTokenMock(request) : await verifyToken(request);

// 토큰 유효성 검증이 필요한 페이지 처리
if ((Object.values(NEED_LOGIN_PATH) as string[]).includes(request.nextUrl.pathname)) {
if (verifyResponse.status === 200) {
return response;
}

const loginUrl = new URL("/login", request.nextUrl.origin);
return NextResponse.redirect(loginUrl);
}

// 로그인된 사용자가 접근하면 안 되는 페이지 처리
if ((Object.values(NEED_LOGOUT_PATH) as string[]).includes(request.nextUrl.pathname)) {
if (verifyResponse.status !== 200) {
return response;
}

const mainUrl = new URL("/", request.nextUrl.origin);
return NextResponse.redirect(mainUrl);
}

return response;
};
  • 환경 분기: process.env.NODE_ENV를 통해 개발/프로덕션 환경을 구분하여 적절한 검증 로직을 실행합니다.
  • 페이지 접근 제어:
    • NEED_LOGIN_PATH: 로그인이 필요한 페이지들 (예: 마이페이지, 리뷰 작성 등)
    • NEED_LOGOUT_PATH: 비로그인 상태에서만 접근 가능한 페이지들 (예: 로그인, 회원가입)
  • 명확한 리다이렉션: 인증 상태에 따라 적절한 페이지로 리다이렉트합니다.

▫︎ 구현 시 고려 사항

1. 성능 최적화

1
2
3
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
  • 선택적 실행: 모든 요청이 아닌, 필요한 경로에 대해서만 미들웨어가 실행되도록 설정
  • 정적 리소스 제외: 이미지, 스타일시트 등 정적 리소스는 미들웨어 처리에서 제외

2. 서버 컴포넌트 지원

1
2
3
4
const setPathnameHeader = (request: NextRequest, response: NextResponse) => {
response.headers.set("x-pathname", request.nextUrl.pathname);
return response;
};
  • 경로 정보 전달: 서버 컴포넌트에서 현재 경로 정보가 필요한 경우를 위해 헤더에 포함
  • 확장성: 추후 서버 컴포넌트에서 필요한 추가 정보도 헤더를 통해 전달 가능

▪︎ 미들웨어의 Edge Runtime을 고려한 모킹 시스템

Next.js 미들웨어는 Edge Runtime에서 실행되기 때문에, api 모킹을 위해 개발 환경에서 사용하고 있던 MSW와 같은 모킹 도구를 사용할 수 없었습니다. 이를 해결하기 위한 전용 모킹 시스템을 구현했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// middleware-mock.ts

export const mockResponses = {
"/auth/token/verify": {
success: {
status: 200,
body: { ok: true, message: "Token is valid" },
},
error: {
status: 401,
body: { ok: false, message: "Unauthorized" },
},
},
};

export const mockFetch = async (url: string, options?: RequestInit) => {
const endpoint = new URL(url).pathname;
const mock = mockResponses[endpoint as keyof typeof mockResponses];

if (!mock) {
return fetch(url); // 모킹되지 않은 엔드포인트는 실제 요청 수행
}

const cookies = options?.headers && "Cookie" in options.headers ? options.headers.Cookie : "";
const hasAccessToken = cookies.includes("accessToken=");

// 쿠키의 토큰 존재 여부에 따라 적절한 응답 반환
return new Response(JSON.stringify(hasAccessToken ? mock.success.body : mock.error.body), {
status: hasAccessToken ? mock.success.status : mock.error.status,
headers: { "Content-Type": "application/json" },
});
};

▫︎ 모킹 시스템의 주요 특징:

  • 엔드포인트 기반 모킹: 각 엔드포인트별로 성공/실패 응답을 미리 정의합니다.
  • 토큰 기반 응답: 쿠키의 토큰 존재 여부에 따라 다른 응답을 반환합니다.
  • 유연한 확장: 새로운 엔드포인트 추가가 용이한 구조입니다.
  • 폴백 메커니즘: 모킹되지 않은 엔드포인트는 실제 API를 호출합니다.
  • 이러한 모킹 시스템의 장점:
    • 개발 효율성: 백엔드 API 완성 전에도 프론트엔드 개발 진행 가능
    • 안정성: 예측 가능한 응답으로 일관된 개발 환경 제공
    • 디버깅 용이성: 인증 관련 문제 발생 시 빠른 원인 파악 가능

▪︎ 마치며

Next.js의 미들웨어를 활용한 인증 플로우 구현은 단순히 기능적인 요구사항을 충족시키는 것을 넘어, 사용자 경험과 코드 품질 모두를 개선하는 결과를 가져왔습니다. 특히 페이지 전환 시의 깜빡임 현상 제거와 인증 로직의 중앙 집중화는 프로젝트의 품질을 한 단계 높이는 중요한 요소가 되었습니다.

앞으로도 사용자 경험을 해치지 않으면서도 안전한 인증 시스템을 구축하기 위한 고민을 계속해 나갈 예정입니다.

프로젝트를 진행하면서 가장 중요하게 생각했던 것 중 하나는 코드의 안정성이었습니다. 특히 여행 관련 서비스를 개발하면서, 사용자 경험에 직접적인 영향을 미치는 UI 컴포넌트들의 안정성은 매우 중요했습니다.

▪︎ 테스트 코드의 필요성

▫︎ 복잡한 UI 상태 관리

여행 서비스의 특성상 다양한 상태를 가진 컴포넌트들이 많았습니다. 예를 들어 리뷰 카드의 경우 아래와 같은 복잡한 상태들을 수동으로 테스트하는 것은 비효율적이며, 실수하기 쉬웠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
describe('리뷰 카드 컴포넌트', () => {
const props = {
reviewId: 1,
profileImage: '/user.jpg',
reviewImage: '/test.png',
title: '테스트 리뷰',
content: '이것은 테스트 리뷰 내용입니다.',
starRating: 4.5,
travelLocation: '서울',
createdAt: '2023-10-10',
likesFlag: false,
};

it('리뷰 카드가 올바르게 렌더링되어야 합니다 (리뷰 페이지)', () => {
renderWithQueryClient(<ReviewCard {...props} nickname="테스터" />);

expect(screen.getByText('테스트 리뷰')).toBeInTheDocument();
expect(
screen.getByText('이것은 테스트 리뷰 내용입니다.'),
).toBeInTheDocument();
expect(screen.getByText('테스터')).toBeInTheDocument();
expect(screen.getByText('4.5')).toBeInTheDocument();
});

it('리뷰 카드가 올바르게 렌더링되어야 합니다 (마이페이지)', () => {
renderWithQueryClient(<ReviewCard {...props} />);

expect(screen.getByText('테스트 리뷰')).toBeInTheDocument();
expect(
screen.getByText('이것은 테스트 리뷰 내용입니다.'),
).toBeInTheDocument();
expect(screen.getByText('4.5')).toBeInTheDocument();
expect(screen.getByText('서울')).toBeInTheDocument();
});

it('이미지가 올바르게 표시되어야 합니다', () => {
renderWithQueryClient(<ReviewCard {...props} />);

const image = screen.getByAltText('테스트 리뷰');
expect(image).toBeInTheDocument();
});

▫︎ 비동기 데이터 처리의 안정성

API 호출과 같은 비동기 작업이 많은 서비스 특성상, 데이터 로딩, 에러 상태 등 다양한 상황에 대한 테스트가 필요했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
it("로딩 중일 때 스켈레톤 UI를 표시한다", () => {
(useReview as jest.Mock).mockReturnValue({
isLoading: true,
});

const { container } = renderWithQueryClient(<ReviewContents />);
// 스켈레톤 UI의 특정 클래스명을 기준으로 요소를 찾습니다.
const skeletonElements = container.querySelectorAll(".skeleton-style");
expect(skeletonElements.length).toBeGreaterThan(0);
});

it("리뷰 데이터가 있을 때 리뷰 카드가 렌더링된다", () => {
(useReview as jest.Mock).mockReturnValue({
data: {
pages: [
{
data: {
content: [
{
id: 1,
nickname: "사용자1",
profileImage: "https://example.com/profile1.jpg",
reviewImage: "https://example.com/review1.jpg",
title: "리뷰 제목 1",
content: "리뷰 내용 1",
starRating: 5,
travelLocation: "서울",
createdAt: "2023-10-01",
isLiked: true,
},
],
},
},
],
},
isLoading: false,
isError: false,
hasNextPage: false,
fetchNextPage: jest.fn(),
});

const { getByText } = renderWithQueryClient(<ReviewContents />);
expect(getByText("리뷰 제목 1")).toBeInTheDocument();
expect(getByText("리뷰 내용 1")).toBeInTheDocument();
});

it("리뷰 데이터가 없을 때 아무것도 렌더링하지 않는다", () => {
(useReview as jest.Mock).mockReturnValue({
data: {
pages: [
{
data: {
content: [],
},
},
],
},
isLoading: false,
isError: false,
hasNextPage: false,
fetchNextPage: jest.fn(),
});

const { queryByText } = renderWithQueryClient(<ReviewContents />);
expect(queryByText(/리뷰 제목 1/i)).not.toBeInTheDocument();
});

▪︎ 테스트 코드 작성 전략

▫︎ 테스트 시나리오 구성

테스트 코드는 크게 세 가지 관점에서 작성했습니다.

1. 기본 렌더링 테스트

1
2
3
4
5
6
7
8
9
10
11
describe("ReviewComment", () => {
it("리뷰 작성칸을 렌더링합니다", () => {
render(<ReviewComment />);

expect(screen.getByText("여행에 대한 후기를 남겨주세요!")).toBeInTheDocument();
expect(screen.getByLabelText("최대 20자 입력 가능 textarea")).toBeInTheDocument();
expect(screen.getByPlaceholderText("여행 제목을 입력해 주세요.")).toBeInTheDocument();
expect(screen.getByLabelText("최대 100자 입력 가능 textarea")).toBeInTheDocument();
expect(screen.getByPlaceholderText("여행에 대한 다양한 후기를 공유해 주세요!")).toBeInTheDocument();
});
});

2. 사용자 인터렉션 테스트

1
2
3
4
5
6
7
it("페이지 버튼 클릭 시 paginate 함수가 호출된다", () => {
const paginateMock = jest.fn();
const { getByText } = render(<Pagination totalPages={4} currentPage={1} paginate={paginateMock} />);

fireEvent.click(getByText("2"));
expect(paginateMock).toHaveBeenCalledWith(2);
});

3. 엣지 케이스 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
it("리뷰 데이터가 없을 때 빈 상태 메시지를 렌더링한다", () => {
(useMyReview as jest.Mock).mockReturnValue({
data: {
data: {
content: [],
total: 0,
},
},
});

renderWithProvider(<Written />);

expect(screen.getByText("아직 작성한 리뷰가 없어요!")).toBeInTheDocument();
});

▫︎ 비동기 테스트 처리

1. 로딩 상태 처리

1
2
3
4
5
6
7
8
9
10
it("로딩 중일 때 스켈레톤 UI를 표시한다", () => {
(useReview as jest.Mock).mockReturnValue({
isLoading: true,
});

const { container } = renderWithQueryClient(<ReviewContents />);
// 스켈레톤 UI의 특정 클래스명을 기준으로 요소를 찾습니다.
const skeletonElements = container.querySelectorAll(".skeleton-style");
expect(skeletonElements.length).toBeGreaterThan(0);
});

2. 데이터 페칭 결과 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
it("여행 데이터가 있을 때 여행 카드가 렌더링된다", () => {
(usePastTravel as jest.Mock).mockReturnValue({
data: {
data: {
content: [
{
travelId: 1,
travelName: "과거 여행 1",
maxTravelMateCount: 5,
currentTravelMateCount: 2,
isDomestic: true,
location: "서울",
image: "https://example.com/image.jpg",
startAt: "2023-10-01",
endAt: "2023-10-10",
},
],
total: 1,
},
},
});

const { getByText } = renderWithQueryClient(<PastTravel />);
expect(getByText(/과거 여행 1/i)).toBeInTheDocument();
});

3. 에러 상태 처리

추가로 각 컴포넌트에서 발생할 수 있는 에러 상황을 고려하여 테스트케이스를 작성했습니다.

▫︎ 모킹 전략 활용

외부 의존성이 있는 컴포넌트의 경우, 적절한 모킹을 통해 테스트의 안정성을 확보했습니다.

1
2
3
4
5
6
7
8
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
// 필요한 다른 router 메서드들...
}),
}));

▪︎ 테스트 코드 작성 시 중점 사항

▫︎ 테스트 가독성

각 테스트 케이스의 의도가 명확히 드러나도록 작성했습니다.

1
2
3
4
5
6
7
it('기본적으로 "myTravel" 탭이 선택되어야 한다', () => {
renderWithQueryClient(
<MainTab selectedTab="myTravel" setSelectedTab={setSelectedTabMock} setSelectedSubTab={setSelectedSubTabMock} />
);

expect(screen.getByText(/나의 여행/i)).toBeInTheDocument();
});

▫︎ 테스트 유지보수성

반복되는 테스트 로직은 유틸리티 함수로 분리하여 재사용성을 높였습니다.

1
2
3
const renderWithProvider = (ui: React.ReactNode) => {
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};

▪︎ 마치며

테스트 코드 작성은 단순히 버그를 잡기 위한 도구가 아닌, 더 나은 설계를 위한 도구이자 문서의 역할도 수행했습니다. 특히 비동기 처리가 많은 현대 웹 애플리케이션에서 다양한 상황에 대한 테스트 케이스를 작성함으로써, 더 안정적인 서비스를 제공할 수 있었습니다.

앞으로도 테스트 커버리지를 높이고, 더 효율적인 테스트 전략을 발전시켜 나갈 예정입니다.

0%