SNS 사이트지만 업데이트 시간만 조절 잘하면 ssr방식이 훨씬 좋다는 의견이 있어서 리펙토링하는 겸 지저분하게 하나의 컴포넌트에서 길게 구현한 것도 공용컴포넌트화 시킬 수 있는건 시키고, 세분화시켜보려 한다.
일단
getServerSideProps(SSR)
- 요청 시마다 페이지를 서버에서 렌더링
- 매 요청마다 서버에서 데이터를 가져와 페이지를 렌더링하기 때문에 항상 최신 데이터를 제공
- 장점: 사용자에게 항상 최신 데이터 제공
- 단점: 요청이 있을 때마다 데이터 가져오고 렌더링 해서 응답 시간이 길어지고 서버에 부하 발생할 수 있음.
export async function getServerSideProps(context) {
// 서버에서 데이터를 동적으로 가져옴
const response = await fetch('https://api.example.com/data')
const data = await response.json()
return {
props: {
data, // 페이지 컴포넌트로 전달
},
}
}
실시간 데이터가 필요한 페이지 등에서 사용 가능
getStaticProps(SSG)
- 빌드 시에 한 번만 데이터를 가져오고 HTML 파일을 생성
- 빌드 타임에 데이터를 가져와서 HTML을 생성 후, 같은 HTML파일을 이후 클라이언트에서 제공. 페이지 요청 시 서버에서 다시 렌더링하지 않음
- 장점:빠른 로딩 속도, 서버 부하가 적음. 페이지가 미리 생성되어서 CDN을 통해 빠르게 제공할 수 있음
- 단점: 빌드 시의 데이터만 사용되므로 실시간 데이터가 필요하거나, 동적으로 변화하는 콘텐츠엔 부적합
export async function getStaticProps() {
// 빌드 시에 데이터를 가져옴
const response = await fetch('https://api.example.com/data')
const data = await response.json()
return {
props: {
data, // 페이지 컴포넌트로 전달
},
revalidate: 10, // 이 페이지를 10초마다 갱신할 수 있도록 설정
}
}
자주 내용이 변경되지 않는 블로그 페이지, 마케팅 렌딩 페이지 등에서 사용.
단 SSG에선 revalidate 옵션으로 주기적으로 갱신 할 수 있음 이 옵션은 ISR(Incremental Static Regeneration)
SEO에 영향을 끼치지 않는 페이지이고 사용자 인증 정보를 클라이언트에서 처리하게 하였으니, 페이지를 서버에서 렌더링 할 필요 없음. CSR
SEO중요하지 않고, 클라이언트에서 사용자 입력 처리하고, 서버에 제출하는 방식으로 구현 CSR
SNS의 핵심 기능인 피드를 보여주는 페이지. 실시간 데이터를 반영해야함.
사용자가 새로고침을 하지 않아도 피드 내용이 변경되면 실시간으로 반영되어야 함.
그래서 ISR(주기적으로 업데이트) / SSR(실시간 업데이트) 중에 SSR로 택하기로 하였다
그래서 getServerSideProps로 ssr렌더링 페이지로 만들어주려하였으나 이건 Page.router에서만 사용하는 수법이고, 그냥 최초 데이터만 ssr로 서버에서 잘 불러온 후, 이후의 스크롤데이터는 클라이언트 컴포넌트에서 무한스크롤로 불러오도록 요청하였다.
그 과정에서 usePosts 훅에서 게시글 패치해주는 함수를 구현했었는데, 그 파일은 클라이언트 컴포넌트였기에 ssr
렌더링으로 서버에서 게시글 호출하기엔 불가능한 함수였다.
그래서 최초 10개 게시글만 ssr렌더링을 이용해서 서버에서 불러와서 SEO 향상, 초기 로딩 속도 개선을 할건데, 이후의 게시글은 csr 즉, 클라이언트 컴포넌트에서도 해당 함수가 필요해서 app/src/lib/api/fetchPosts.ts에 넣어줬다.
import { FetchPostsResult } from "@/types/post"
export const fetchPosts = async (
pageParam = 1,
userId?: string,
): Promise<FetchPostsResult> => {
const API_URL =
typeof window !== "undefined" // 클라이언트 환경
? "" // 클라이언트에서는 상대 경로를 사용
: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000" // 서버 환경에서는 절대 경로 사용
const userQuery = userId ? `&userId=${userId}` : ""
try {
const response = await fetch(
`${API_URL}/api/posts?page=${pageParam}${userQuery}`,
{
method: "GET",
},
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Unknown error fetching posts")
}
const data = await response.json()
// 데이터 길이가 10개일 경우 다음 페이지가 있다고 간주
const nextPage = data.length === 10 ? pageParam + 1 : undefined
return {
data: data || [],
nextPage,
}
} catch (error) {
console.error("Error fetching posts:", error)
throw error // 에러를 재던져서 상위에서 처리
}
}
그 과정에서 많은 에러를 겪은게, 서버 환경에서 fetchPosts가 호출이 되지 않는 것이다.
절대경로
Next.js의 서버사이드 코드가 브라우저가 아닌 서버에서 실행되어서 절대경로가 필요!!
즉, 서버 환경에선 브라우저의 window객체나 브라우저의 URL을 참조할 수 없고, node.js환경에서 실행되므로, 기준이 없어서 제대로 된 요청을 만들 수 없음
그래서 절대 경로 NEXT_PUBLIC_API_URL
를 넣어주니 서버에서도 드디어 응답하기 시작함
// src/app/home/page.tsx
import { homeMetadata } from "@/lib/metadata/metadata"
import HomePage from "./_components/HomePage"
import Header from "./_components/header/Header"
import { fetchPosts } from "@/lib/api/fetchPosts"
export const metadata = homeMetadata
const Page = async () => {
const initialPosts = await fetchPosts(1)
return (
<>
<Header />
<HomePage initialPosts={initialPosts.data} />
</>
)
}
export default Page
이후 가장 부모컴포넌트인 page.tsx에서 ssr렌더링 시켜준 후, HomePage.tsx에 props로 내려줬다.
그 이유는, 10개 이후로 클라이언트에서 요청해서 이후로는 무한 스크롤로 나머지 게시물들을 보여줄 것이기 때문에,
"use client"
import usePosts from "@/hooks/usePosts"
import React, { useCallback, useEffect, useRef, useState } from "react"
import PostCard from "../_components/post/PostCard"
import PostSkeleton from "./skeleton/PostSkeleton"
import { Post } from "@/types/post"
interface HomePageProps {
initialPosts: Post[] // Post 배열로 타입 정의
}
const HomePage: React.FC<HomePageProps> = ({ initialPosts }) => {
const {
posts,
loadMorePosts,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
error,
} = usePosts(undefined, initialPosts)
이후, homepage.tsx에서 usePosts쪽으로 최초의 게시물 데이터들을 보내준 후,
그 값을 반영하여
const {
data: postsData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading, // 초기 로딩 상태 추가
isError, // 에러 상태 추가
error, // 에러 정보 추가
} = useInfiniteQuery({
queryKey: ["posts", userId],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam, userId),
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 1,
initialData: initialPosts
? {
pages: [
{
data: initialPosts,
nextPage: 2,
},
],
pageParams: [1],
}
: undefined,
})
최초의 데이터에 대해 initialData로 넣어줬고, 이후에 2페이지부터 보여주도록 조치해주었다.
그러나, 처음부터 잘 되었던건아니고,
page.tsx / homepage.tsx에선 initialPosts 데이터가 잘 찍히는데 usePosts에만 잘 보내주는데 undefined가 떠서 오래 고통을 겪었지만, 코드를 건든것도아닌데 2일만에 갑자기 되서 원인을 찾진 못했다. ㅠㅠ 너무 아쉬운 상황
그래도 추가 에러가 있었는데,
실질적으로 usePosts에도 초기 데이터를 담아줘서 다음 데이터를 가져와야하는데 10개 보여주고 무한스크롤이 되지 않는 것이다.
그래서 처음엔 initialPosts값이 usePosts에 안담겨서 발생한 문제라 생각하였지만, 그건 아니였고,
HomePage.tsx를 리펙토링하여 컴포넌트 분리하는 과정에서 생겨난 문제였는데,
실제 게시글 ui를 PostCard쪽에서 구현해서 HomePage.tsx에 넣어주는식으로 리펙토링을 진행했었다.
기존에는
React.FC타입으로 PostCard를 정의했는데,
기본적으로 ref를 전달하지 않아서 ref를 사용할 수 없었음.
const PostCard: React.FC<{ post: Post; ref?: React.Ref<HTMLDivElement> }> = ({
post,
ref,
}) => {
그래서 forwardRef로 감싸서 해결했는데,
const PostCard = React.forwardRef<HTMLDivElement, { post: Post }>(
({ post }, ref) => {
React.forwardRef는 ref를 컴포넌트의 props로 전달할 수 있도록 해줌. 그리고 컴포넌트의 두번째 인자로 ref를 받아서, 컴포넌트의 DOM 요소에 전달할 수 있게 해줌
즉, SSR로 최초 게시글들을 렌더링한 후, 클라이언트에서 추가 게시글을 동적으로 로딩하려고 했으나, ref가 PostCard.tsx로 잘 전달되지 않아, intersection observer를 통해 스크롤 끝에 다다른 후 데이터를 로드하는 로직 자체가 동작하지 않았음.
forwardRef를 사용하기 전엔, PostCard 컴포넌트가 ssr로 초기 렌더링 되었을 땐, ref가 DOM 요소에 연결안됨.
ref는 클라이언트 측에서만 동작하기 때문에, ref가 제대로 전달이 안되어 IntersectionObserver가 PostCard 컴포넌트의 마지막 게시글을 감지하지 못함
React.forwardRef는 ref를 컴포넌트의 두 번째 인자로 전달하고, DOM 요소에 연결시켜줌.
ref는 클라이언트 측에서만 유효해서, ref가 article DOM 요소에 연결됨.
그래서 추가 데이터를 요청할 수 있었음.
ref란?
React에서 특정 DOM 요소나 클래스 컴포넌트 인스턴스를 참조하는 기능
이를 통해 DOM을 직접 제어하거나 컴포넌트의 상태를 읽거나 수정할 수 있음
기본적으로 React컴포넌트는 상태나 동작을 관리해도 때때로 DOM요소에 직접 접근해야할 경우에 ref를 사용함
그래서 실제 ref 즉 참조하는 article 태그를 부모에게 인식시켜줘서 새로운 게시글을 보여주게 조치할 수 있었음.
최종 정리
ref는 DOM 접근용, forwardRef는 ref를 자식 컴포넌트에 전달할 때 사용하며, 클라이언트 측에서 동적 동작을 위해 필수
그리고 무한스크롤 구현 시, 마지막 게시글이 화면에 나타나면 새로운 게시글을 로드할때 Intersection Observer API가 사용되며, 마지막 게시글의 DOM 요소에 접근해야함
그 과정에서 forwardRef가 부모컴포넌트가 자식 컴포넌트 DOM의 직접 접근하도록 해줌
결국 이런 수치를 만들어낸 나 자신 칭찬해~
ISR 방식
비교적 덜 자주 변경되는 정보들이 있고 그 중에서 즉각적으로 상태 변경을 해야하는 부분은 CSR로 처리할 수도 있어서 이렇게 결정하였다.
근데 당장 급한건 아니여서 나중에 해볼 예정
하나의 ts나 tsx에서만 쓰이는 type은 내부에, 여러군데에서 쓰이는 타입은 ex-src/app/type/ 여기서, 여기에 해당안되지만 쓰이는 타입은 src/app/types 여기서 등등 너무 여러군데에서 타입을 선언하다보니 결국은 유지 보수, 관리, 재사용성 다 체크하기가 너무 어려웠다. 중복 선언까지 막기위해서
src/
types/
post.d.ts # Post, Comment 등 공통 타입
user.d.ts # User 관련 타입
이런식으로 모든 관련 타입을 몰아 넣고, 여기에서 전역에 사용할 수 있게 관리하니 나름 고민해서 결정내린 만큼 더 찾기도 쉽고 유지보수도 재사용성도 챙긴것같다 굿굿
그리고 파일명은 post.d.ts이런식으로 넣어서 확실히 type파일이구나도 알아볼 수 있게 하였다.
기존에 로컬에서는 git husky + lint-staged로 알아서 프로덕션 환경에서의 문제가 있는지 없는지 테스트를 해줘서 상관 없었지만, 하도 채용조건에 CI/CD를 강조하는 회사가 있어서 겉으로 보여주기 위해 2중 작업 즉, 로컬 개발 환경에서도 문제 테스트를 해주고, 협업 공간인 git hub에서도 배포에 문제가 있나 없나 테스트를 해주는게 git actions이라 볼 수 있다.
CI
Continuous Integration(지속적 통합)
테스트, 빌드 Dockerizing, 저장소에 전달하는 것까지 프로덕션 환경으로 서비스를 배포할 수 있도록 준비하는 프로세스
CD - Continous Delivery(지속적 전달)
저장소로 전달된 프로덕션 서비스를 실제 사용자에게 배포하는 프로세스
즉, 코드 품질 유지, 배포 주기 짧게 만들어 효율적인 프로세스 지원.
사실 근데 로컬에서 이미 테스트 진행하고, 깃헙에서 자동으로 버셀에 코드 업데이트해서 배포는 하고 있어서 굳이 git actions이 필요없긴했는데, 그래도 회사에서 쓰는 것들이니까 2중 3중 테스트해준다는 의미로 사용..!
.github/workflows
루트 디렉토리에 YAML파일 정의해서 사용함
다양한 이벤트(푸쉬, 풀 리퀘스트)에 반응하여 자동 실행
그래서 그 안에 ci/.yml
파일 생성하여 CI 파이프라인 설정했음
name: CI Workflow
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build-and-test:
runs-on: ubuntu-latest # 리눅스 환경에서 실행
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '20' # Node.js 버전 설정
- name: Install dependencies
run: pnpm install # pnpm을 사용하여 의존성 설치
- name: Lint code
run: pnpm run lint # 린트 실행
- name: Run tests
run: pnpm test # 테스트 실행
actions/checkout@v3
레포지토리 코드 체크아웃해서 워크플로우에서 사용할 수 있게 함
actions/setup-node@v3
node 환경 설정. 앞으로 20이후래서 20으로 업그레이드까지 진행함
패키지 매니저는 pnpm
사용
그래서 적용 후 커밋 푸쉬를 하면
github레퍼지토리 action쪽에 코드 품질 검증 / 문제 조기 발경 / 배포 프로세스 자동화를 해준다.
그래서 앞으로는 커밋 푸쉬하면 코드 관련 테스트를 로컬 + 깃헙 + 버셀에서 3중 테스트를 거친다!!
아주 기본적인 세팅만 진행해보았지만 추후 더 필요하면 추가 옵션들을 살펴볼 예정
깃 액션을 추가해보면서 jest에 대해서 알게 되었는데,
단위 테스트, 통합 테스트를 실행하는데 사용된다 한다.
자동화된 테스트를 쉽게 실행할 수 있도록 해주며, 테스트 실행, 결과 보고, 코드 커버리지 등 모두 처리
스냅샷 테스트
UI 출력 결과를 저장하여 변경 사항이 발생했을 때 감지하는 테스트 방식
- 테스트 실행 시 컴포넌트의 HTML 렌더링 결과, 객체 구조 스냅샷 파일로 저장
- 동일 테스트 실행 시 새로운 결과를 기존 스냅샷과 비교
- 변경 사항 있으면 테스트 실패, 스냅샷 업데이트 필요
장점
UI 변경 사항 자동 감지
예상치 못한 변경으로 인한 버그 방지
단점
자주 변경되는 UI에선 스냅샷 업데이트가 잦아 관리 부담 증가
커버리지 측정
코드 커버리지는 테스트가 전체 코드 중 얼마나 많은 부분을 실행했는지 측정하는 지표
라인,함수,브랜치,문장 단위로 측정
- Jest 실행 시 테스트 싫행 흐름을 추적하여 각 코드라인 실행되었는지 확인
- 커버리지 리포트를 생성하여 테스트 미흡 부분을 한눈에 봄
장점
테스트 범위 시각적 파악 가능
테스트 부족 부분 찾아 보완
단점
커버리지가 높다고 코드가 항상 안전한 것은 아님(테스트 품질도 중요)
병렬 테스트
여러 테스트를 병렬로 실행하여 테스트 실행 속도 빠르게 하는 방식
CPU의 멀티코어를 활용, 각 테스트 파일 별도의 프로세스로 실행
- 기본적으로 병렬 처리 지원, 실행 환경에 따라 최적의 프로세스 개수 자동으로 결정
장점
테스트 실행 속도 대폭 개선
CI/CD환경에서도 빌드 시간 단축
단점
비동기 테스트, 의존성이 있는 테스트에는 병렬 처리 시 문제가 발생할 수 있음
Mocking
외부 의존성 제거, 가짜 데이터 제공하여 독립적인 테스트 환경 만드는 기술
외부 API호출, 데이터 베이스, 타이머 등 실제 동작을 대체하는 Mock 객체나 함수로 테스트 실행
장점
외부 환경에 의존하지 않아 테스트 신뢰성 증가
네트워크, 데이터 베이스 등의 비용 절약
단점
Mocking이 과도하면 실제 동작과의 차이가 커질 수 있음
일단
pnpm add --save-dev jest @types/jest ts-jest
로 설치를 진행 하고,
package.json 에
"scripts": {
"test": "jest"
}
이 부분을 추가해준다
이후 typescript에서 Jest 사용을 위해선 ts-jest를 사용하여 ts파일 실행할 수 있는 설정이 필요하다 (zero-config여도)
프로젝트 루트에 jest-config.ts
추가
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
};
export default config;
이후 ts-jest를 Jest와 통합하여야만
Jest가 ts설정파일을 사용할 수 있다고 한다.(기본적으로 Jest는 .js 확장자만 지원함)
근데?? 마지막으로 tsconfig.json에서
"module": "commonjs", // Jest와 호환되는 CommonJS 모듈 시스템 사용
"moduleResolution": "node", // node 방식 모듈 해석
이렇게 바꿔주라는데 나는
"module": "esnext",
"moduleResolution": "bundler",
이렇게 사용하고 있었다.
esnext
-ES6모듈시스템(import/export)사용, 브라우자, 최신js런타임에서 지원(node.js,v8엔진)
bundler
-번들러(webpack)에서 빌드될때 모듈 처리하는 방식(next.js와 같은 환경에서 사용)
근데 Jest는 호환을 위해 commonjs,node를 적용해야하는데, Jest는 import/export 문법을 CommonJS 방식으로 변환해야 하므로 테스트 환경에서는 commonjs가 필요
그래서
require(), module.exports 같은걸Commonj로 해석할 수 있음.
근데 next를 쓰는 그리고 모듈 처리하는 방식을 Jest때문에 전체를 바꿔줄 순 없어서 방법을 찾아보니,
Next.js용 / Jest용 설정을 각각 할 수 있다고 한다.
// jest.tsconfig.json
{
"compilerOptions": {
"module": "commonjs", // Jest는 CommonJS 사용
"target": "esnext", // 최신 JavaScript 기능 사용
"jsx": "react-jsx", // React 17+ JSX 변환
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["**/*.ts", "**/*.tsx"]
}
이렇게 Jest용 config를 따로 설정해줘서 두 환경 모두 해결해준 모습!!
결국 jest.config.ts / jest.tsconfig.json까지 적용해줬으므로 Jest 테스트를 진행해볼 수 있게 되었다.
근데 진행해보니 ts-node도 필요하고, jest.config.ts에 type 세팅도 해줘야 테스트가 진행되서 추가 설치 및 코드 추가를 진행했다.
pnpm add ts-node typescript --D
{
"compilerOptions": {
"module": "commonjs",
"target": "esnext",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["jest"] // jest 타입 추가
},
"include": ["**/*.ts", "**/*.tsx"]
}
이후 src/test/example.test.ts에서
// src/tests/example.test.ts
import { describe, expect, it } from "@jest/globals"
describe("Basic Test", () => {
it("should add two numbers correctly", () => {
const sum = 1 + 2
expect(sum).toBe(3)
})
})
테스트 코드를 넣고 pnpm run test 로 테스트를 진행해보니
이렇게 노드에 잘 찍히는걸 볼 수 있다.
한편, 테스트 코드에서 describe, expect 같은걸 import하기 위해
pnpm add @jest/globals -D
로 설치를 진행해줬었다.
이렇게 테스트도 해보긴했지만.. 그래서 실제로 쓸일이 있나?싶었는데,
단순히 코드를 검증하는 도구를 넘어, 개발 생산성과 품질 관리를 돕는 역할
유닛테스트 / Mocking으로 의존성 분리 / 스냅샷 테스트 / 통합테스트 / 테스트 자동화
에 장점이 있다고 하는데, 사실 직접 해보는게 아니면 그냥 탁상공론일뿐 조금씩 적용할 사례를 만들어 보자
pnpm run test:watch
테스트실행을 지속적으로 모니터링 하는 모드
코드 수정하면 자동으로 관련 테스트만 실행함
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch"
},
이렇게 추가하면 실행할 수 있음
사실 개발 환경에서 잘 구현되는걸 굳이 테스트 코드까지 작성해야하는지에 대한 의문점이 든다.
하지만 테스트 코드의 가치는 코드 변경 / 유지보수 단계에서 명확히 드러난다고 하는데..!
장점
1. 코드 변경의 안정성 보장
일단 가장 최근 ssr렌더링을 위해 파일 위치를 옮긴 fetchPosts.ts부터 테스트 코드로 만들어보았는데,
import { FetchPostsResult } from "@/types/post"
export const fetchPosts = async (
pageParam = 1,
userId?: string,
): Promise<FetchPostsResult> => {
const API_URL =
typeof window !== "undefined" // 클라이언트 환경
? "" // 클라이언트에서는 상대 경로를 사용
: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000" // 서버 환경에서는 절대 경로 사용
const userQuery = userId ? `&userId=${userId}` : ""
try {
const response = await fetch(
`${API_URL}/api/posts?page=${pageParam}${userQuery}`,
{
method: "GET",
},
)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Unknown error fetching posts")
}
const data = await response.json()
// 데이터 길이가 10개일 경우 다음 페이지가 있다고 간주
const nextPage = data.length === 10 ? pageParam + 1 : undefined
return {
data: data || [],
nextPage,
}
} catch (error) {
console.error("Error fetching posts:", error)
throw error // 에러를 재던져서 상위에서 처리
}
}
이 코드를
// tests/lib/api/fetchPosts.test.ts
import { fetchPosts } from "@/lib/api/fetchPosts"
import {
afterEach,
beforeEach,
describe,
expect,
it,
jest,
} from "@jest/globals"
describe("fetchPosts", () => {
const API_URL = "http://localhost:3000"
beforeEach(() => {
global.fetch = jest.fn() as jest.MockedFunction<typeof fetch>
})
afterEach(() => {
jest.clearAllMocks()
})
it("fetches posts from the correct API endpoint", async () => {
const mockResponse = [
{ id: 1, title: "Post 1" },
{ id: 2, title: "Post 2" },
]
// Mocking fetch with a proper type
;(global.fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
} as Response)
const result = await fetchPosts(1)
expect(global.fetch).toHaveBeenCalledWith(`${API_URL}/api/posts?page=1`, {
method: "GET",
})
expect(result).toEqual({ data: mockResponse, nextPage: undefined })
})
it("throws an error when response is not ok", async () => {
;(global.fetch as jest.MockedFunction<typeof fetch>).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: "Some error" }),
} as Response)
await expect(fetchPosts(1)).rejects.toThrow("Some error")
})
})
이렇게 했더니 import에서 @ < alias매핑이 안되서 에러가 뜨고있었다. 참.. 세팅할것도 많다 아휴 ㅋㅋ
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1", // @/을 src/로 매핑
},
moduleDirectories: ["node_modules", "<rootDir>/src"], // moduleDirectories에 src 추가
rootDir: ".", // rootDir을 프로젝트 루트로 설정
};
export default config;
그래서 마지막으로 rootDir을 명시적으로 세팅을 해줘서 Jest가 프로젝트 내에서 경로를 올바르게 해석할 수 있게 세팅해주었다.
그리고 여기 함수는 실제로 외부 api요청을 진행하는 코드이다보니, mocking을 통해 의존성을 대체하여, 가짜 데이터와 동작을 제공해 테스트를 간단하고 빠르게 실행할 수 있게 함.
이전에 잘 구현되는 코드들을 이젠 테스트할 필요는 없고 앞으로 추가될 데이터만 Jest로 테스트해볼 예정
아직은 많이 어색한 프레임워크인듯..ㅎㅎ;;
아 참 pnpm run test:watch 는 실시간 테스트 기능~!
깃액션이 배포에서도 로컬에서도 문제가 없지만 뜨는 이유는 git actions에 pnpm을 설치해주는 코드를 추가해주지 않아서였다.
그래서 pnpm 설치하는 코드를 ci.yml에 추가해주었고,
name: CI Workflow
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
# 저장소 클론
- name: Checkout repository
uses: actions/checkout@v3
# Node.js 18.19.0 설정
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "20" # 최신 Node.js 버전으로 업데이트
# pnpm 설치
- name: Install pnpm
run: npm install -g pnpm
# pnpm 체크
- name: Check pnpm version
run: pnpm -v
# 의존성 설치
- name: Install dependencies
run: pnpm install
# 린트 실행
- name: Lint code
run: pnpm run lint
# (선택 사항) 테스트 실행
- name: Run tests
run: pnpm test
# (선택 사항) 빌드 확인
- name: Build project
run: pnpm run build
와 깃액션이 배포환경에서 문제있는지 없는지 알아서 테스트해주는게 너무 메리트있긴한듯?!
어.. 근데 에러가 또 뜨네..?
그래서 살펴보니 useProfile.ts에서 선언한 useState의 타입을 설정을 안해줘서 전부 boolean을 추가해주었다
const [showPostModal, setShowPostModal] = useState<boolean>(false)
const [selectedPost, setSelectedPost] = useState<Post | null>(null)
const [showFollowers, setShowFollowers] = useState<boolean>(false)
const [showFollowing, setShowFollowing] = useState<boolean>(false)
const [showUnfollowConfirm, setShowUnfollowConfirm] = useState<boolean>(false)
그렇게 해결했더니 또 깃 액션에서 에러가 뜬다 빌드에서 계속 에러를 잡아보자
Run pnpm run build
> share-life@0.1.0 build /home/runner/work/ShareLife-SNS-/ShareLife-SNS-
> next build
▲ Next.js 14.2.5
Creating an optimized production build ...
✓ Compiled successfully
Linting and checking validity of types ...
Collecting page data ...
Error: supabaseUrl is required.
at new Ea (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/chunks/273.js:1:116188)
at Aa (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/chunks/273.js:1:119659)
at 38693 (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/[...nextauth]/route.js:1:279901)
at a (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/webpack-runtime.js:1:136)
at 27673 (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/[...nextauth]/route.js:1:1579)
at a (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/webpack-runtime.js:1:136)
at /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/[...nextauth]/route.js:1:293810
at a.X (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/webpack-runtime.js:1:1222)
at /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/[...nextauth]/route.js:1:293775
at Object.<anonymous> (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/[...nextauth]/route.js:1:293846)
Error: supabaseUrl is required.
at new Ea (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/chunks/273.js:1:116188)
at Aa (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/chunks/273.js:1:119659)
at 38693 (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/logout/route.js:1:1733)
at a (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/webpack-runtime.js:1:136)
at 32970 (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/logout/route.js:1:891)
at a (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/webpack-runtime.js:1:136)
at /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/logout/route.js:1:1843
at a.X (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/webpack-runtime.js:1:1222)
at /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/logout/route.js:1:1804
at Object.<anonymous> (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/api/auth/logout/route.js:1:1879)
> Build error occurred
Error: Failed to collect page data for /api/auth/[...nextauth]
at /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/node_modules/.pnpm/next@14.2.5_@babel+core@7.26.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/next/dist/build/utils.js:1268:15
at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
type: 'Error'
}
ELIFECYCLE Command failed with exit code 1.
Error: Process completed with exit code 1.
하다보니 감이 잡힌게 아예 actions에도 프로젝트 세팅해주는것처럼 필요한 요소들을 다 넣어줘야되는구나 느꼈다.
결국 .env.local에 있는 내용도 build에 들어가지 않아서 실패가 떳던거라 방법을 찾아보니
GitHub Actions에서 .env파일없이 해당 환경 변수를 사용하려면 GitHub Secrets에 저장해야된다고 한다.
GitHub Repository > Settings > Secrets and variables > Actions
에서 변수들 추가해놓기!
자~ 역시나 또 뜨네 에러
Run pnpm run build
> share-life@0.1.0 build /home/runner/work/ShareLife-SNS-/ShareLife-SNS-
> next build
▲ Next.js 14.2.5
Creating an optimized production build ...
✓ Compiled successfully
Linting and checking validity of types ...
Collecting page data ...
Generating static pages (0/21) ...
Generating static pages (5/21)
Generating static pages (10/21)
Generating static pages (15/21)
⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase
TypeError: fetch failed
at node:internal/deps/undici/undici:13185:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656) {
digest: '1178238990',
[cause]: AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1118:18)
at afterConnectMultiple (node:net:1685:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
Error: [errors]: [ [Error], [Error] ]
}
}
TypeError: fetch failed
at node:internal/deps/undici/undici:13185:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656) {
digest: '1178238990',
[cause]: AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1118:18)
at afterConnectMultiple (node:net:1685:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
Error: [errors]: [ [Error], [Error] ]
}
}
TypeError: fetch failed
at node:internal/deps/undici/undici:13185:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656) {
digest: '1178238990',
[cause]: AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1118:18)
at afterConnectMultiple (node:net:1685:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
Error: [errors]: [ [Error], [Error] ]
}
}
Error occurred prerendering page "/home". Read more: https://nextjs.org/docs/messages/prerender-error
TypeError: fetch failed
at node:internal/deps/undici/undici:13185:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656)
✓ Generating static pages (21/21)
> Export encountered errors on following paths:
/home/page: /home
ELIFECYCLE Command failed with exit code 1.
Error: Process completed with exit code 1.
일단
⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase
메타 데이터 관련된 내용 중 도메인설정을 각 페이지 마다 해주었지만 전체 프로젝트 기준으로는 넣어주지 않아서 발생한 문제여서, next.config.mjs에서 해당내용을 추가해주었다
그리고 일단 차근차근 에러를 해결해 나가보자
확인해보니 최신 next에선 experimental안에 metadatabase를 넣는게 아닌 아예 바깥옵션에 넣어야했다.
그러나, 실제 문제의 원인은
이게 아니였던 이유가,
모든 페이지마다 메타데이터 세팅을 해주었었는데,
저 에러가 떴던 것이다.
원인을 찾아보니 openGragh 즉, 동적 데이터가 포함되어 있는 옵션을 사용하기 위해선 generate함수를 사용했어야했는데, url을 동적으로 사용하기 위한 프로필페이지만 해당 함수를 사용해서 발생한 문제였다.
그래서 opengraph를 사용하는 home에도 generate함수를 추가해줘서 조치를 취할 수도 있었으나, 굳이 필요없는 내용인것같아서 주석처리하니 해결되었다!!
// 홈 페이지 메타데이터
export const homeMetadata: Metadata = {
...defaultMetadata,
title: "Share Life - 새로운 소셜 네트워크",
description:
"다양한 사람들과 공유하고 소통하는 SNS, Share Life에 오신 것을 환영합니다.",
keywords: ["SNS", "소셜 미디어", "공유 플랫폼", "좋아요"],
alternates: {
canonical: "https://www.sharelife.shop/home",
},
// openGraph: {
// title: "Share Life - 새로운 소셜 네트워크",
// description:
// "다양한 사람들과 공유하고 소통하는 SNS, Share Life에 오신 것을 환영합니다.",
// url: "https://www.sharelife.shop/home",
// images: [
// {
// url: "/mainpageimage.webp",
// width: 800,
// height: 600,
// },
// ],
// type: "website",
// },
}
두번째 문제는
TypeError: fetch failed
at node:internal/deps/undici/undici:13392:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656) {
digest: '2300758890',
[cause]: AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1122:18)
at afterConnectMultiple (node:net:1689:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
Error: [errors]: [ [Error], [Error] ]
}
}
TypeError: fetch failed
at node:internal/deps/undici/undici:13392:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656) {
digest: '2300758890',
[cause]: AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1122:18)
at afterConnectMultiple (node:net:1689:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
Error: [errors]: [ [Error], [Error] ]
}
}
TypeError: fetch failed
at node:internal/deps/undici/undici:13392:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656) {
digest: '2300758890',
[cause]: AggregateError [ECONNREFUSED]:
at internalConnectMultiple (node:net:1122:18)
at afterConnectMultiple (node:net:1689:7)
at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
code: 'ECONNREFUSED',
Error: [errors]: [ [Error], [Error] ]
}
}
Error occurred prerendering page "/home". Read more: https://nextjs.org/docs/messages/prerender-error
TypeError: fetch failed
at node:internal/deps/undici/undici:13392:13
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at async /home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15776
at async v (/home/runner/work/ShareLife-SNS-/ShareLife-SNS-/.next/server/app/home/page.js:1:15656)
✓ Generating static pages (21/21)
> Export encountered errors on following paths:
/home/page: /home
ELIFECYCLE Command failed with exit code 1.
Error: Process completed with exit code 1.
패치에러가 뜨고 있다. 원인을 찾아보니 깃액션이 어찌되었든 배포환경인데 배포환경의 URL인 www.sharelife.shop인 env.local을 실행 코드인 ci.yml과 git actions에 환경 변수를 넣어줬어야 했는데, 그 부분에서 누락이 되어서 문제가 발생되었다. 그래서 그 부분들을 조치했더니
드디어.. 드디어..!
하 처음으로 CI/CD해결..! 후..
이제 면접 다녀오고 회사에서 기초라고 판단될 내용만 하고 있던것같아서 프론트지만 백 개발자와 협업을 다음스텝, 그 후엔 node로 백 다루기를 그 다음스텝으로 가져가서 꼭 취업 해버린다 니들이 얼마나 잘하길래 날 계속 짜르는지 두고봐 내가 꼭 높은 곳에 서줄테니깐 이러면 너무 독기 넘쳐보이니깐~ 어쨋든 첫번째 혼자만의 프로젝트는 여기까쥐~~ 쥐쥐~~
고생했다 나자신!!