본문 바로가기
프로젝트 기록

[CINEMATE] Next.js 프로젝트 리팩토링하기 (feat. 영화 추천 서비스) (token, EC2, PM2, Axios Interceptors, SSR, React-Query useInfiniteQuery)

by dygreen 2023. 7. 28.
프로젝트(Github) 👉 https://github.com/MovieApplication/cinemate
사용 기술 👉 React + TypeScript + Next.js + SASS
주소 👉 http://3.34.139.203/

 

CINEMATE라는 프로젝트

회사에서 Next.js와 TypeScript를 좀 써보고

개인 프로젝트에 적용하면 좋을 것 같아서 진행한 프로젝트이다.

 

부족한 실력으로 기능 하나하나를 구현하다 보니 다양한 에러를 만났고,

그 에러를 해결하기 위해 열심히 찾아보고 공부하니 실력이 늘었던 것 같다.

 

아직도 매우 빈틈이 많고 부족한 코드이기 때문에

프로젝트를 끝내고 한 달 동안 (조금) 늘은 실력으로 리팩토링 작업을 시작해보고자 한다.

 

프로젝트 메인 화면

 

목차

  1. 로그인 토큰 정보 저장 방식 변경 (Web Storage)
  2. AWS EC2 인스턴스에 Next.js 프로젝트 배포하기 (feat. PM2)
  3. Axios Interceptors 사용하기
  4. SSR 적용하기 (feat. getServerSideProps)
  5. React-Query 사용하여 무한 스와이퍼 구현하기 (feat. useInfiniteQuery)

 

 

로그인 토큰 정보 저장 방식 변경

이 프로젝트에는 카카오 로그인개인 서비스(CINEMATE) 로그인 기능이 있는데

카카오 로그인 과정의 거의 대부분을 프론트에서 처리하고 있다.

따라서 카카오 로그인에서 필요로 하는 client id 값이나 카카오에서 보내주는 토큰 값들이 모두 프론트에서 다뤄지고 있기 때문에

보안에 취약하고 개인 정보 보호가 제대로 되지 않는 문제가 있었다.

 

이런 문제를 조금이라도 보완하는 것이 필요하다고 느껴서

기존에 localStorage에 저장했던 정보들을 sessionStoragecookie에 저장하는 방식으로 바꾸고자 했다.

 

localStorage vs sessionStorage vs Cookies?

localStorage에 개인 정보나 토큰 값들을 저장하게 되면 XSS(Cross site scripting) 공격에 취약할 수 있다.

공격자가 웹사이트의 JavaScript에 접근하여 정보들을 탈취할 수 있기 때문이다.

또한 브라우저 상에서 데이터를 직접 지우지 않으면 만료되지 않고 유지되기 때문에 정보를 계속 담고 있기엔 위험하다.

 

sessionStorage는 브라우저 탭이나 창을 닫으면 데이터가 지워진다. (새로고침 시에는 유지됨)

데이터를 저장하고 꺼내 쓰는 문법은 localStorage와 거진 동일하지만, 브라우저를 닫으면 데이터가 삭제된다는 점에서 좀 덜 위험하다.

 

Cookies는 JavaScript로 접근이 불가능하게 설정할 수가 있다. 따라서 localStroage만큼 XSS 공격에 취약하지 않다.

또한 쿠키는 자동으로 모든 HTTP 요청에 포함되어 보내진다.

 

결론은...

[ 기존 ]

  • 카카오 토큰 (access token + refresh token) : localStrage에 저장
  • 카카오 유저 정보 : localStorage에 저장
  • 개인 서비스 토큰 (access token + refresh token id) : localStorage에 저장

[ 수정 ]

  • 카카오 토큰 : access token만 sessionStorage에 저장
  • 카카오 유저 정보 : 다양한 정보 중 nickname만 sessionStorage에 저장
  • 개인 서비스 토큰 : access token은 sessionStorage에 저장, refresh token id는 cookie에 저장 (+ 로그아웃 시 sessionStorage와 cookie 삭제)
    * cookie에는 SameSite=Strict 옵션과 expires=36000000(=10시간) 옵션을 주어 이전보다 보안을 강화하였다.

    SameSite는 cookie 전송에 있어 같은 사이트인지 체크하는 옵션이다.
    - Strict : 서로 다른 도메인에서는 아예 전송 불가능 → 도메인이 서로 다르면 쿠키를 사용할 수 없도록 제한 (CSRF 100% 방지)
    - Lax : 허용한 사이트와 같은 사이트에서만 전송 가능 → Strict 설정에 일부 예외(HTTP get method / a href / line href)를 둠
    - None : 모든 사이트에서 cookie 전송 가능

 

또한 기존에는 카카오 client id를 common.ts 파일에 넣어 놓았는데,

.env 파일을 활용해 환경변수를 사용하였다.

Next.js에서 환경변수를 사용할 때는 process.env.NEXT_PUBLIC_변수명 으로 이름을 지어 사용해야 한다.
또한 next.config.js에 env 내용을 추가해야 한다.
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  eslint: {
    ignoreDuringBuilds: true,
  },
  // env 관련 코드 추가
  env: {
    BASE_URL: process.env.BASE_URL,
  },
}

module.exports = nextConfig

 

AWS EC2 인스턴스에 Next.js 프로젝트 배포하기

직접 Next.js 프로젝트를 AWS에 배포해보기로 했다. (도움이 많이 된 게시글👍)

 

해당 글을 따라서 .pem 파일이 있는 폴더에 접속하여 ssh 접속 명령어까지 입력을 하고

yarn build까지 했는데 최신 커밋 내용이 반영이 안되어 build가 되는 문제가 발생했다.

(git log 명령어로 확인하면 최신 커밋까지 잘 뜨는 ,, ㅎr)

혹여나 기존 폴더에서 접근하여 그런 것인 줄 알고 새로운 폴더를 생성해서 다시 진행해보았다.

 

그러나 이번에는 git clone <repository_url> 에서

fatal: destination path 'cinemate' already exists and is not an empty directory.

이런 에러가 계속 났다. 원인을 몰라서 한참을 삽질한 결과,

rm -rf cinemate
git clone <repository_url>

기존 'cinemate' 폴더를 삭제한 후에 다시 git clone을 하니 해결 되었다.


clone 이후, 다시 yarn install → yarn build → yarn start 잘 되나 싶다가,, 

yarn start에서 또 다른 에러를 맞이했다.,, 하하하

error - Failed to start server
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
    at Server.setupListenHandle [as _listen2] (node:net:1740:16)
    at listenInCluster (node:net:1788:12)
    at doListen (node:net:1937:7)
    at process.processTicksAndRejections (node:internal/process/task_queues:83:21) {
  code: 'EADDRINUSE',
  errno: -98,
  syscall: 'listen',
  address: '0.0.0.0',
  port: 3000
}

해당 에러는 3000번 포트가 현재 다른 프로세스에서 사용중이기 때문에 해당 포트를 사용하는 현재 서비스를 시작할 수 없다는 뜻이다.

sudo lsof -i :3000
kill -9 PID값

3000번을 사용하고 있는 프로세스를 검색한 후, 해당 PID 값을 통해 프로세스를 종료시킨 후 다시 yarn start를 하니 정상적으로 배포되어 동작하였따,,,

 

쉽지 않은 배포 과정이었지만,, 수차례 삽질했으니 다음 번 배포 때는 헤매지는 않을 것 같다,,(?

 

커밋 후 수정 내역 재배포하기

git pull origin main // commit 내역 최신화
git log // 잘 받아졌는지 확인
pm2 stop cinemate // 프로젝트 잠시 중단
yarn build // 빌드
yarn start // start (필수인지 모르겠음)
^C
pm2 restart cinemate // 프로젝트 다시 시작

 

삽질할 때 도움이 됐던 명령어들

// git 연결 확인과 연결
git remote -v
git remote origin main

// git 커밋 최신화
git pull origin main
// 최신화 에러 시 강제 머지 (error: Your local changes to the following files would be overwritten by merge)
git stash

// git 커밋 내역 확인
git log

 

터미널을 종료하더라도 사이트 실행시키기 (feat. pm2)

항상 터미널을 켜놓은 상태로 사이트가 잘 배포되고 실행되는지 확인해서

터미널을 끄면 사이트가 함께 연결이 끊긴다는 사실을 인지하지 못했다,,ㅎ (위에서 3000번에서 실행되고 있던 pm2를 제거하기도 했고;)

이를 해결하기 위해서는 pm2라는 라이브러리를 사용하면 된다.

npm install pm2 -g (*pm2 설치)
pm2 start ./bin/www (*pm2를 이용하여 서버 코드 구동)
pm2 start "npm run start" --name 어플리케이션이름 (*pm2 등록)
pm2 list (*pm2로 돌아가고 있는 프로세스 목록 출력)
pm2 stop [process 이름] (*pm2 구동 멈추기)
pm2 restart [process 이름] (*pm2 재시작)

 

 

Axios Interceptors 사용하기

기존 코드에서 Axios 요청 시 로직들이 겹쳐서 수정이 필요했다.

 

Axios Interceptors를 사용할 경우,

  • 인증 토큰 관리 : 요청 인터셉터에서 access token의 유효함을 확인하고, header에 실어 보내는 작업을 함
  • 중복 코드 감소 : 요청과 응답을 처리하기 전에 공통적으로 적용되어야 하는 로직 중앙화
  • 전역 오류 처리 : 모든 요청 및 응답에 대해 일관된 오류 처리 구현

로 장점이 더 많은 것 같아서 Axios Interceptors를 사용하기로 했다.

 

*자세한 내용은 블로그에 따로 작성해 놓았다.

 

[Axios] Axios Interceptors 사용법

📌 Axios Interceptors ? Axios Interceptors는 axios의 then 또는 catch로 처리 되기 전에 요청과 응답을 가로챌 수 있다. 현재 진행하고 있는 영화 프로젝트의 리팩토링 과정에서 Axios Interceptors를 사용하기로

dygreen.tistory.com

 

 

메인 페이지 SSR 적용하기 (feat. getServerSideProps)

getServerSideProps를 통해 SSR을 적용하려는 이유?

  • SEO 최적화: 서버에서 페이지를 렌더링하기 때문에 검색 엔진 최적화를 할 수 있다
  • 초기 렌더링 성능 개선: SSR은 이미 렌더링된 콘텐츠를 클라이언트에게 제공하므로 초기 페이지 로딩 시간이 단축된다
  • 캐싱 용이: 서버에서 렌더링된 프리렌더링 페이지는 캐싱에 용이하므로 성능과 효율성을 높일 수 있다

적용 코드

const Home = ({popularListInit, inTheaterListInit, releaseListInit, voteListInit, yearListInit}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
  const [popularList, setPopularList] = useState<MovieResult>(popularListInit)
  const [inTheaterList, setInTheaterList] = useState<MovieResult>(inTheaterListInit)
  const [releaseList, setReleaseList] = useState<MovieResult>(releaseListInit)
  const [voteList, setVoteList] = useState<MovieResult>(voteListInit)
  const [yearList, setYearList] = useState<MovieResult>(yearListInit)

  // 실시간 인기 순위 영화 리스트 목록 조회
  const fnGetPopularMovie = async () => {
    await GetApi(apiList.getPopularMovie, {page: popularList.page}).then(res => {
      if (res !== 'FAIL') {
        setPopularList({
          ...popularList,
          results: [...popularList.results, ...res.results]
        })
      }
    })
  }

  // Infinite Swiper (pagination)
  const fnChangePage = ($page: number, $title: string) => {
    if ($title === '실시간 인기 순위 영화') {
      setPopularList({...popularList, page: $page})
    }
  }

  useEffect(() => {
    if (popularList.page !== 1) fnGetPopularMovie()
  },[popularList.page])

  return (
    <>
    </>
  )
}

export const getServerSideProps = async () => {
  try {
    const popularListInit = await GetApi(apiList.getPopularMovie)
    const inTheaterListInit = await GetApi(apiList.getInTheaterMovie)
    const releaseListInit = await GetApi(apiList.getReleaseMovie)
    const voteListInit = await GetApi(apiList.getVoteMovie)
    const yearListInit = await GetApi(apiList.getYearMovie)

    return {
      props: {
        popularListInit: popularListInit === 'FAIL' ? movieResultInit : popularListInit,
        inTheaterListInit: inTheaterListInit === 'FAIL' ? movieResultInit : inTheaterListInit,
        releaseListInit: releaseListInit === 'FAIL' ? movieResultInit : releaseListInit,
        voteListInit: voteListInit === 'FAIL' ? movieResultInit : voteListInit,
        yearListInit: yearListInit === 'FAIL' ? movieResultInit : yearListInit,
      },
    }
  } catch (err) {
    console.warn('server side err : ' + err)
  }
}

export default Home

→ getServerSideProps 내부에서 axios api 콜 결과를 props로 return 하고,

Home 컴포넌트에서 props로 받아서 카테고리별 리스트들에 초기값으로 집어넣는다.

이 후, (swiper를 넘기는 등) 사용자 측의 요청이 있을 시 Home 컴포넌트 내부에서 api를 콜하는 식으로 동작한다.

 

기존에는 useEffect로 Home 컴포넌트에 진입하면 카테고리별 리스트들 api를 호출하는 식으로 동작했는데,

getServerSideProps로 서버에서 pre-rendering된 값들을 가져오니, 클라이언트에서 api를 호출하지 않게 되어 훨씬 속도도 빠르고 성능을 높일 수 있었다.

 

 

React-Query를 사용하여 무한 스와이퍼 구현하기

React-Query를 사용하려는 이유?

  • 중복 요청 감소: React-Query의 캐싱 기능을 통해 중복된 데이터 요청을 방지할 수 있다 → 서버의 부하와 데이터 사용량을 줄일 수 있음
  • 무한 스크롤/스와이퍼 구현 용이: (위 이유와 같이) 중복된 데이터 요청을 방지하여 무한 스크롤/스와이퍼 기능을 자연스럽게 구현할 수 있다

getServerSideProps(SSR) + React-Query(useInfiniteQuery) 사용 시 단점?

  • 복잡한 개발 환경: SSR과 CSR의 로직이 혼합되어 개발 환경이 복잡해질 수 있다
  • 서버 리소스 사용량 증가: SSR로 페이지를 만들 때마다 서버에서 렌더링해야 하므로, 서버의 자원을 많이 사용하게 된다

 

*좀 더 자세한 내용은 다른 게시글에 정리해 두었다.

 

[Next.js] React-Query로 무한 스와이퍼 구현하기 (ft. useInfiniteQuery, getServerSideProps, react swiper)

📌 기존 코드의 문제점 영화 추천 프로젝트 리팩토링 과정에서 메인 페이지에서 카테고리 별 영화 리스트를 불러올 때 SSR을 적용하였다. (적용한 이유는 위 링크의 게시글에 기록해두었다) 기

dygreen.tistory.com

 

728x90

댓글