카테고리 없음

Next.js와 TypeScript로 레거시 코드 리팩토링: SSR 적용

haseulla 2024. 12. 26. 18:00

1. 프로젝트 배경

레거시 코드를 Next.js와 Typescript에 맞게 리팩토링하면서 겪은 경험을 공유하려 합니다. 사이드 프로젝트 구인 플랫폼을 통해서 팀원들을 모집하여 함께 작업을 진행했습니다. 기획자 분께서 이미 기초 구현이 되어있는 프로덕트가 있어서 리팩토링 위주로 작업하고, 이후 추가 기능을 덧붙이는 형식으로 진행했습니다.

 

처음에는 기본 기능이 이미 구현되어 있으니 작업이 빨리 끝날 것이라고 생각했지만, 제가 구현한 코드가 아니라 코드에 대한 이해와 기능 구현 기능을 넘어서는 리팩토링 작업까지 해야 했고, 예상보다 시간이 많이 소요되었습니다.

특히, 기존 코드가 Next.js와 TypeScript로 작성되어 있었지만 모두 CSR 방식으로 구현되어 있었고, 하나의 useEffect에 여러개의 로직이 돌아가고 있었고, TypeScript 코드에는 any 타입이 여러번 사용되고 있었습니다.

 

이러한 문제들을 해결하기 위해 서버사이드 렌더링(SSR)과 클라이언트사이드 렌더링 파일로 분리하고, 하나의 useEffect에서 돌아가던 로직을 분리하고, TypeScript의 any를 최대한 제거하여 타입 안정성을 강화하며 기존 코드를 개선하기 위해서 노력했습니다.


2. 리팩토링으로 인한 변화

SSR의 적용

로그인 후 진입하는 첫 페이지에서는 이용 가능한 기기 목록, 이용 시간 확인, 약관 동의 여부 등 초기 데이터를 미리 가져와 사용자에게 즉시 화면을 제공하고자 했습니다. 이를 위해 서버사이드 렌더링(SSR)을 적용하였습니다. 기존의 클라이언트 사이드 렌더링(CSR) 방식에서는 데이터가 모두 클라이언트에서 로드될 때까지 화면이 지연되는 문제가 있었으나, SSR을 적용하면서 초기 데이터를 서버에서 미리 렌더링하여 클라이언트로 전달함으로써, 사용자는 더 빠르고 매끄럽게 화면을 경험할 수 있었습니다. 이로 인해 사용자 경험이 크게 향상되었고, 첫 페이지 로딩 시간이 단축되었습니다.

또한, 이전에는 useEffect를 사용해 클라이언트 사이드에서 무분별하게 API를 호출하던 부분도 SSR로 변경되면서 로직이 훨씬 깔끔해졌습니다. 서버에서 데이터를 미리 처리하여 클라이언트로 전달함으로써, API 호출과 관련된 코드가 서버 측으로 집중되었고, 불필요한 useEffect와 중복된 데이터 요청을 줄일 수 있었습니다. 그 결과, 코드가 더 간결해지고 유지보수가 용이해졌습니다.

 

Zustand Persist 도입

기존에 localStorage.getItem()과 Zustand의 상태관리가 동시에 사용되면서 코드가 복잡하고 일관성이 떨어졌습니다. Zustand Persist를 도입하여 상태를 로컬 스토리지에 자동으로 저장 및 복원하여 로컬 데이터와 상태관리로직이 하나로 통합되었고, 코드가 간결해져 유지보수가 용이해졌습니다.

 

TypeScript의 적용

any 대신 명확한 타입을 사용하여 코드를 리팩토링함으로써, 컴파일 타임에 오류를 미리 잡을 수 있어 안정성을 강화하고, API 응답 데이터나 복잡한 객체를 정확하게 정의함으로써 실수를 줄였습니다. 또한, 명확한 타입 덕분에 자동 완성이 원활하게 동작해 개발 속도가 빨라졌고, 함수와 컴포넌트의 입력값 및 반환값을 명확히 추적할 수 있어 유지보수와 확장이 용이해졌습니다.

 


3. 리팩토링 주요 내용

SSR 적용 & API 호출 코드 분리 & localStorage 제거

[기존 코드]

기존에는 app 폴더 내에서 한 파일내에서 하나의 useEffect로 기기 리스트, 이용시간 확인, 약관 동의 여부 데이터를 불러오고 있었습니다. 데이터 통신에 필요한 중요 id값도 localStorage에서 가져오도록 구현되어있었습니다. 또한, api 코드 분리가 안되어있어서 로직을 파악하기에 어려웠습니다. 

useEffect(() => {
    providerId = localStorage.getItem('providerId');
    placeId = localStorage.getItem('placeId');
    reserveId = localStorage.getItem('reserveId');
    email = localStorage.getItem('email');

    token = localStorage.getItem('token');

    const _videoUrl = localStorage.getItem('videoUrl');
    setVideoUrl(_videoUrl != null ? _videoUrl : '');
    const pn = localStorage.getItem('placeName');
    setPlaceName(pn != null ? pn : '');

    if (email == null) {
      router.push(`${providerId}/${placeId}/${reserveId}`);
    }

    // 접속 기록 남기기
    fetch(`/api/v2/users/${email}/place`, {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
        'x-api-key': process.env.NEXT_PUBLIC_API_KEY!,
      },
      body: JSON.stringify({ placeId: placeId }),
    })
      .then((r) => r.json())
      .then((result) => {
        console.log('접속 기록 남기기');
        console.log(result);
      });

    //공간이용동의서 동의했는지 확인
    fetch(
      `/api/v2/providerId/${providerId}/placeId/${placeId}/reserveId/${reserveId}/consent`,
      {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
          'x-api-key': process.env.NEXT_PUBLIC_API_KEY!,
        },
      },
    )
      .then((r) => r.json())
      .then((result) => {
        if ('datetime' in result) {
          console.log('공간이용동의 여부 확인');
        } else {
          router.push(`${providerId}/${placeId}/${reserveId}/consent`);
        }
      });

    //사용 가능 시간인지 확인
    const data = {
      reserveId: reserveId,
      providerId: providerId,
      token: token,
      placeId: placeId,
      api_url: '/api/states/light.mudeudeung',
    };

    fetch('/api/v2/ticket/approve', {
      method: 'POST',
      body: JSON.stringify(data),
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': process.env.NEXT_PUBLIC_API_KEY!,
      },
    })
      .then((r) => r.json())
      .then((result) => {
        console.log(result);
        getDeviceList();
        if (result.status == 'expired') {
          setIsExpired(true);
        } else if (result.status == 'success') {
          setIsExpired(false);
        }
      });
  }, []);

 

 

 

[변경 코드 : SSR  파일]

기존에는 한 파일에서 관리되던 코드를 SSR 부분과 CSR 부분으로 파일을 나누어서 관리했습니다. 그리고 미리 서버에서 받아오면 좋을 데이터는 SSR로 받아오도록 구현했습니다.

export default async function ControlPage({
  searchParams,
}: {
  searchParams: Promise<ControlPageProps>;
}) {
  const { providerId, placeId, reserveId, token } = await searchParams;
  if (!providerId || !placeId || !reserveId || !token) {
    notFound();
  }

  const [checkAvailableTimeData, consentStatusData, deviceData, placeData] =
  await Promise.all([
      checkAvailableTime({ reserveId, providerId, token, placeId }),
      getConsentStatus({ reserveId, providerId, placeId }),
      getDeviceList({ placeId }),
      getPlaceInfo({ placeId }),
    ]);

    if (!consentStatusData.datetime) {
      redirect(`${providerId}/${placeId}/${reserveId}/consent`);
    }

  return (
    <ControlView
      providerId={providerId}
      placeId={placeId}
      reserveId={reserveId}
      token={token}
      deviceData={deviceData}
      consentStatusData={consentStatusData}
      checkAvailableTimeData={checkAvailableTimeData}
      placeData={placeData}
      karaokePaidData={karaokePaidData}
    />
  );
}

 

app 디렉토리의 서버 컴포넌트에서 데이터를 호출해 클라이언트 컴포넌트에 데이터를 전달하는 방식으로 SSR를 구현하였습니다. 

 

기존에는 /control 페이지로 이동해서 localStorage에 저장되어있는 providerId, placeId, reserveId, token 값을 받아와서 그 값들을 기준으로 데이터를 불러왔는데, 변경된 코드에서는 url을  /control?providerId=${providerId}&placeId=${placeId}&reserveId=${reserveId}&token=${result.token} 로 변경하여 useParams를 사용해 providerId, placeId, reserveId, token 값을 추출하여 사용했습니다.

 

처음 렌더링 될 때 필요한 값들만 추출하여 SSR로 불러왔습니다.

여러개의 데이터를 어떤 방식으로 불러왔는지 다음과 같이 정리하였습니다. 

  설명 CSR SSR
checkAvailableTime  사용 가능한 시간 확인 X O
getConsentStatus 약관 동의 여부 X O
getDeviceList 기기 목록  X O
getPlaceInfo 장소 정보 X O
logUserAccessService 사용자 접속 기록 O X
getKaraokePaidStatus 노래방 기기 결제 여부 O X

 

사용자 접속 기록 데이터는 초기 렌더링 시 필요한 데이터가 아니어서 CSR에서 처리하도록 하고, 노래방 기기 결제 여부는 또한 비교적 쉽게 바뀔 수 있는 값이기 때문에 CSR에서 처리하도록 구현했습니다.

 

여러 코드를 한번에 불러오고 있어 Promise.all 을 활용하여 비동기적으로 데이터를 가져올 수 있도록 구현하였습니다.

그리고 약관동의를 하지 않았다면 바로 SSR 파일에서 redirect 함수를 활용하여 다른 페이지로 이동하도록 하였습니다.

 

[변경 코드 : CSR  파일]

API 호출하고 로직 관리하는 코드가 엄청 간단하게 된 것을 확인할 수 있습니다. react query를 활용하여 getKaraokePaidStatus API를 가져왔고, email 값과 placeId 값이 있을 때 사용자 접속 기록(logUserAccessService)을 남길 수 있도록 구현했습니다. 

 

데이터 값들을 SSR로 가져오고, API 코드를 분리하여 로직 부분이 굉장히 깔끔해졌습니다.

'use client';

const ControlView = ({
  providerId,
  placeId,
  reserveId,
  token,
  deviceData,
  checkAvailableTimeData,
  placeData,
}: Props) => {
  const setSelectedDevice = useStore((state) => state.setSelectedDevice);
  const email = useUserStore((state) => state.email);
  const router = useRouter();

  const { data: karaokePaidData, isLoading: isKaraokePaidLoading } = useQuery({
    queryKey: ['karaoke-paid'],
    queryFn: () => getKaraokePaidStatus({ providerId, placeId, reserveId }),
  });

  useEffect(() => {
    if (!email && email !== '') {
      router.push(`${providerId}/${placeId}/${reserveId}`);
    } else if (email && placeId) {
      // 사용자 접속 기록 남기기
      logUserAccessService({ email, placeId });
    }
  }, [email]);

  // 이용시간이 아닐 시
  if (checkAvailableTimeData?.status === 'expired') {
    return (
      <ControlExpiredView
        placeName={placeData.Item.placeName.S}
        link={placeData.Item.inquiryURL.S}
      />
    );
  }

  return (
    <Stack
      flexDirection="column"
      spacing={6}
      style={{
        width: '100%',
        backgroundColor: '#FFFFFF',
        padding: '20px',
      }}
    >
     ...
    </Stack>
  );
};

export default ControlView;

 

 

3. 리팩토링의 주요 성과와 장점

1) 첫 페이지 로딩 시간 개선

기존 CSR 방식에서는 클라이언트가 모든 데이터를 API를 통해 가져오는 동안 화면이 지연되는 문제가 있었습니다. SSR로 전환하면서 초기 데이터를 서버에서 미리 렌더링하여 클라이언트에 전달한 결과, 초기 로드 시간이 크게 단축되었습니다.

  • FCP(First Contentful Paint): 0.3초 -> 0.2초로 33% 개선
  • LCP(Largest Contentful Paint): 2.3초 -> 2.2초로 약 4% 개선

 

2) 코드 구조 간소화 및 유지보수 용이성 강화

기존에는 하나의 useEffect에서 복잡한 로직이 혼재되어 있었고, 여러 번의 API 호출이 분산되어 있었습니다.

  • SSR로 데이터를 미리 가져옴으로써 API 호출을 서버 측으로 집중시키고, 클라이언트에서는 로직이 단순화 되었습니다.
  • API 호출이 분리되면서 코드 가독성과 유지보수성이 크게 향상되었습니다.

 

3) 불필요한 중복 요청 감소

SSR에서 데이터를 한 번에 가져오도록 구현하여 클라이언트 측에서 불필요한 중복 API 호출이 사라졌습니다.

  • Render Blocking Resources 감소: CSR에서는 렌더링 차단 리소스의 절감 가능시간이 100ms였으나, SSR로 전환한 후 60ms로 40% 감소

 

4) 사용자 경험 개선

  • 데이터 로드와 화면 표시가 동시에 이루어져 사용자는 즉시 화면을 확인할 수 있습니다.
  • Lighthouse 분석 결과, Speed Index(SI)가 0.8초에서 0.7초로 개선되어 전반적인 화면 로드 속도가 향상되었습니다.

 

리팩토링 결과, 성능과 사용자 경험에서의 개선 효과를 실감할 수 있었으며, 코드 품질 및 유지보수성에서도 큰 이점을 얻을 수 있었습니다. 이번 경험을 통해 SSR 도입의 유용성을 깊이 이해하게 되었고, 앞으로의 프로젝트에서도 적극적으로 적용할 예정입니다.