Skip to content

Instantly share code, notes, and snippets.

@seonghyeonkimm
Last active June 5, 2025 06:19
Show Gist options
  • Save seonghyeonkimm/e562635763e9d6a959e2d5cf46a55586 to your computer and use it in GitHub Desktop.
Save seonghyeonkimm/e562635763e9d6a959e2d5cf46a55586 to your computer and use it in GitHub Desktop.
nextjs 서비스 개발부터 운영까지

nextjs 서비스 개발부터 운영까지

 nextjs로 지금 현재 커머스를 개발하고 있습니다. 그런데 어쩌다보니 많은 부분에 있어서 직접 셋업하는 과정에 참여했고, 그 과정에서 배웠던 내용들을 팀원들에게 공유하고자 다음 글을 기획했습니다. 실제로 나중에는 어떻게 구성들이 추가될지 삭제될지 모르곘지만 아래와 같은 구성으로 몇개의 글을 작성해보려고 합니다. 긴 글을 다 읽기 귀찮으신 분들을 위해서 핵심 내용들만을 강조해두려고 최대한 노력했습니다. 시간이 부족하신 분들은 강조해둔 영역만 읽으셔도 충분할 것이라고 생각합니다.

Part 1 - nextjs 서비스 아키텍처

 처음 글의 주제는 어떤 논의 및 의사결정 과정을 거쳐서 어떤 서비스 아키텍처를 구성하기로 결정했었는지에 대해서 공유하고 서비스 아키텍처를 구성하면서 어떤 요소들에 대한 이해와 공부가 필요했고, 어떤 트러블슈팅 등을 겪었는지에 대해서 적어 보겠습니다. 사실 이 글을 쓰기 시작하면서 얼마나 자세히 써야할까 고민을 했습니다. 결론적으로는 작성하면서 스스로도 어느 정도까지 이해하고 있는지에 대해서도 점검하면 좋겠다는 생각이 들었고, 최대한 자세하게 풀어서 적어 보면서 프론트엔드 개발자들 중에서 아직 SSR Framework에 대한 경험이 거의 없는 개발자들도 이해할 수 있도록 nextjs와 서비스 인프라 관련된 내용들에 있어서 어떤 역할을 하는 것이고 어떻게 사용되는 것인지에 대해서도 최대한 자세히 적어보기로 생각했습니다.

목차

nextjs는 무엇에 쓰는 물건인가?

  React로 개발할 때에 SPA로 접근하는게 대부분이었습니다. 그래서 SSR을 해야 하는 경우에만 nextjs를 찾았던 것으로 기억합니다. SEO에 대한 대응이 필요한 서비스 혹은 페이지를 만들어야 되는데 어떤 framework를 사용해야하지라는 질문을 할 때 보통 떠올리던 것이 nextjs였습니다. 그렇지만, 지금 현재 nextjs는 next export를 통해서 서버 없이 동작할 수 있도록 **SSG(Static Site Generation)**을 지원하기도 하고, next 서버를 띄운다고 하더라도 html을 서빙하더라도 항상 매번 html을 생성해서 response를 해주는 것이 아니고 미리 빌드 시에 pre-rendering을 하여 특정 페이지는 html을 파일을 서빙하기도 하고 getServerSideProps API를 통해서 그때그때 Server에서 만들어지는 형태의 페이지를 섞어서 사용할 수도 있습니다. 그래서 지금 공식 사이트에 들어가게 되면 SSR을 전면에 내세운 것이 아니라 The React Framework For Production이라는 문구로 소개하고 있고 SSR만을 위한 것이 아니라 React를 사용해서 Application을 개발할 때에 언제든지 사용할 수 있는 프레임워크라고 생각하는 것이 맞을 것 같습니다. 또한 nextjs를 개발하고 운영을 Vercel이라는 회사가 하고 있는데 굉장히 업데이트도 빠르게 되고 React Core Team, Google Chrome Team과 협업을 통해서 앞으로 미래의 웹 개발에 있어서 많은 부분들을 주도하고 있다고 느끼고 있습니다.

nextjs-introduction

 특히 제가 엄청 경험이 많지는 않지만 최근까지도 왠만하면 그냥 SPA로 개발하는게 훨씬 이득이 많지 않을까 생각했었습니다. 왜냐하면 SSR을 떠올리던 첫 번째 이유였던 SEO도 Google 검색엔진의 경우에는 SPA를 알아서 로드해서 정보들을 가져갈 수 있고 검색엔진에 노출될 수 있다고 하기도 하고 실제 서버를 띄우는 것보다 SPA로 개발하면 인프라 운영 코스트 등 많은 것들이 월등히 쉽고 편리하기도 하기 때문입니다. 그런데 최근의 흐름을 보면 nextjs를 사용하는 회사들도 많아지고 Remix와 같은 SSR Framework도 나오면서 인기도 많이 얻고 있는 것으로 봐서 조금은 더 이쪽에 관심을 가져야 할 것 같다고 생각하고 있습니다. 지금 개인적으로 생각하기에 nextjs나 remix와 같은 프레임워크의 사용을 고려해야하는 경우를 생각해본다면 다음정도가 있지 않을까 생각합니다.

  • 비록 검색엔진들이 알아서 SPA도 알아서 체크한다고 하지만, SEO를 조금 더 잘 챙기고 싶다.
  • 더 좋은 유저 경험을 주고 싶다. SPA나 SSR이 실제로 유저가 js까지 로드되는 시점을 생각한다면, 속도가 거의 차이가 없다고 알려져 있지만, FCP(First Content Paint)는 SSR이 훨씬 빠르기 때문에 더 좋은 유저 경험을 줄 수 있다고 생각하고 있습니다. 아무리 조금이라도 최대한 빠르게 유저에게 웹의 컨텐츠를 보여주는 것만으로도 리텐션에 꽤 많은 차이를 가져올 수 있다는 연구결과도 있기도 하니까요.

 그리고 SPA가 SSR보다 편리했던 인프라 운영 코스트 등도 Vercel이나 Netlify나 다른 Provider들을 통해서 기술들이 발전하면서 훨씬 덜 신경쓰고 서버를 띄울 수 있고 관리할 수 있게 되었기 때문에 예전보다는 운영 코스트에 대한 걱정을 조금은 덜 해도 되지 않을까라는 생각도 듭니다. 또한 요즘 가끔 보이는 BFF(Backend For Frontend)와 같은 개념을 적용해보는 것도 해보고 싶다는 생각이 많이 드네요.

nextjs 서비스 아키텍처

 처음에 nextjs로 SSR React앱을 구현 해야하는 상황에서 서비스를 어떻게 운영할 것인지에 대한 대안으로 몇 가지를 논의했었습니다. 물론 가장 쉽게 nextjs 앱을 띄우는 방법으로 vercel을 사용할 수 있지만, 회사 내부의 보안정책으로 인해서 무조건 aws 안에서만 구성을 해야만 했습니다. 아래에 적은 방법 이외에도 다른 방법들이 있겠지만 제가 아는 한도 안에서는 결국 마지막 선택사항은 아래 두 가지였습니다.

 우선 서비스를 띄울 수 있는 방법은 많지만 그 중에서도 안정적인 서비스 운영을 위해서 많은 트래픽이 몰려도 수월하게 스케일링할 수 있는 구성이 가장 중요했습니다. 그래서 aws안에서 구성할 수 있는 방법은 위의 두 가지 방법을 생각했고, 그 중에서는 저희는 두 번째인 ECS를 선택했습니다. 사실 둘 중에 어떤 것이 더 우월한가에 대한 없겠지만, 우선 회사 내부에서 서버를 띄울 때 보통 두 번째 방식인 ECS를 사용하는 경우가 많았습니다. 그러므로 제가 직접 구성하면서 여쭤볼 수 있는 분들이 있었다는 사실과 serverless framework는 개인적으로 해본 경험이 있었기 때문에 다른 새로운 챌린지를 해보고 싶다는 욕구도 있었습니다.

 별거 없지만, 위와 같은 이유 때문에 ECS를 사용해서 서버를 띄우기로 결정했습니다. 구체적으로 어떤 설정들을 AWS에서 구성해야 nextjs를 운영할 수 있는 인프라를 구성할 수 있는 지에 대해서 하나씩 실제로 구성을 했던 순서대로 설명을 조금 더 자세히 해보겠습니다. 실제로 인프라를 구성하기 위해서 terraform을 사용해서 구성했었지만 자세한 infra 코드보다는 각각의 구성요소가 어떤 역할을 하는지에 집중해서 작성 해보겠습니다. 자세한 설명을 하기 전에 결국 구성된 모습은 다음과 같습니다. 각 요소들을 설명하고 나서 이 그림을 다시 한 번 보겠습니다.

서비스 구성도

 ECS라는 이름에서도 알 수 있는 것처럼 ECS는 Docker Container를 사용해서 서버를 띄우는 서비스이고 Docker Container를 띄우기 위해서는 Docker Image를 build하고 Docker Image를 Push하고 관리해야 합니다. Docker Image를 push하고 관리하는 서비스가 바로 ECR입니다.

2. ECS(Elastic Container Service)로 서버 띄우기

 ECR에 Docker Image를 올려 두었다면, 그 이후에 신경 써야할 부분은 바로 ECS를 사용해서 ECR에 올라와있는 Docker Image를 사용해서 서버를 띄우는 일입니다. ECS가 서버를 띄우는 방법으로는 두 가지가 있는데 바로 위에서도 언급했던 Fargate가 있고 아니면 많이들 알고 계실 EC2를 사용하는 방식입니다. 저는 스케일링이 더 수월한 Fargate를 이용했습니다.

 ECS를 이해하기 위해서는 작업 정의(Task Definition)라는 용어를 이해해야 합니다. ECS는 서버를 띄울 때에 특정 작업을 기준으로 판단하는 데 그것이 작업 정의라고 부릅니다. 작업 정의에는 서비스를 띄울 때에 어떤 Docker Image를 사용할지 서버의 물리적인 스펙들을 어떻게 할지 등 서비스를 띄우기에 필요한 메타 정보들을 관리하는 것이라고 생각하시면 됩니다.

 ALB는 ECS가 여러개의 container service를 띄웠을 때에 앞단에서 유저의 request를 받아서 request를 분배하는 역할을 합니다. ALB를 설정할 때에 신경써야 하는 몇 가지 구성요소들이 있는데 관련해서 간단히 적어보겠습니다.

  우선 VPC와 Subnets입니다. VPC(Virutal Private Cloud)라는 이름에서 알 수 있는 것처럼 AWS 하나의 기능으로서 유저의 사설 네트워크 클라우드라고 생각하시면 됩니다. 그리고 Subnets은 VPC의 IP 주소 범위들을 관리하는 구성요소라고 생각하시면 됩니다. ALB나 ECS를 설정할 때에 어떤 VPC에 그리고 어떤 Subnets들에 연결해서 사용할 지 등을 설정을 해주어야 합니다. Subnets이 IP 주소 범위들을 관리하는 구성요소들이라고 했는데 IP 주소 범위는 어떻게 표현할 수 있을까요? 보통 IP 주소 범위는 IP CIDR라는 용어로 사용되는 방식으로 표현됩니다. 관련 내용을 간단하게 설명해보겠습니다. 저도 네트워크에 대한 지식이 많지 않아 단어와 같은 것들이 정확하지 않을 수 있습니다. 0.0.0.0/0, 0.0.0.0/16, 0.0.0.0/24, 0.0.0.0/32와 같은 형태들을 보신 적이 있으실텐데, 각각의 의미를 간단히 설명해보면 다음과 같습니다. ip 하나의 섹션마다 모두 8bit로 표현되며, 제일 마지막 숫자는 8의 배수로 앞에서부터 몇칸까지가 HostId(픽스된 IP대역)인지를 표현합니다.

  • 0.0.0.0/0: HostId가 0개 이므로 모든 IP에 해당 한다고 볼 수 있습니다.
  • 172.0.0.0/8: NetId(할당 가능한 IP대역)가 172까지이며 나머지 부분에 해당하는 IP 대역이 HostId라고 볼 수 있습니다.
  • 172.55.0.0/16: NetId가 127.55까지이며 나머지 부분이 HostId라고 볼 수 있습니다.

즉, 마지막 숫자는 8의 배수로 172.55.0.0/16이라고 표현하게되면, 172.55.x.x에 해당되는 모든 IP 대역을 표현하고 있다고 생각하면 됩니다.

 두 번째로, 보안그룹(securityGroup)입니다. 단어에서도 알 수 있는 것처럼 어떤 소스에서 서버에 접근할 수 있는지(inbound rules) 그리고 어떤 소스로 서버에서 요청을 보낼 수 있는지(outbound rules)들을 정하는 보안 규칙같은 것들을 정하는 구성요소라고 생각하면 됩니다.

 마지막으로, 타겟그룹입니다. 타겟그룹은 alb에 들어온 요청들을 어디로 전달할 것인지에 대한 설정입니다. alb는 이름에서도 알 수 있는 것처럼 load balencer 역할을 하는 것이고, 타겟그룹으로 묶여있는 서버들에게 적절하게 request들을 분배하는 역할을 합니다.

  유저의 요청을 바로 alb로 연결할 수도 있지만, 캐싱을 활용하기 위해서 CloudFront를 최앞단에 배치했습니다. cloudfront에는 origin이라는 개념이 있습니다. origin은 cloudfront를 통해서 들어오는 요청을 어디로 보낼 것인가라고 이해하면 됩니다. 예전에 cloudFront를 사용했을 때에는 s3 버킷을 origin만 사용할 수 있는줄 알았는데 요즘은 더 발전된 것인지 cloudfront의 origin으로 사용할 수 있는 종류들이 많아졌음을 배웠습니다. cloudFront의 origin으로 s3 bucket을 사용할 수도 있고, alb도 사용할 수 있고, 그냥 단순히 domain을 연결할 수도 있습니다. 모든 request를 alb를 통해서 ecs까지 도달하게 하지 않고, 단기간에 몰리는 traffic을 잘 관리하기 위해서 nextjs에서 cache-control을 설정하게 하고 cloudFront는 origin의 cache-control header를 보고 cache 정책을 결정하도록 설정했습니다.

 cloudFront 설정을 하면서 하나 겪었던 이슈가 있었는데, nextjs의 response에 기본적으로 아무런 cache-control header가 설정이 안된 상태였는데 배포를 해도 배포된 새로운 버전의 response가 안내려온는 문제가 있었습니다. cloudFront에서 origin의 header를 기준으로 cache를 하도록 해두고 origin의 response에 아무런 cache-control 내용이 없으면 기본적으로 무제한 캐싱을 사용합니다. 그래서 캐시를 안하고 싶은 예를 들어 html 요청들은 cache를 하지 않도록 cache-control을 no-cache로 설정해야 합니다.

 드디어 마지막입니다. 유저가 서비스에 접근하기 위해서는 domain이 필요하고 그 도메인을 관리하는 영역은 route53이라는 서비스이고, 그 도메인에 https protocol을 지원하기 위해서는 ACM에서 인증서를 발급받고 연결해서 사용해야 합니다. 위에서도 구조도를 보았지만 지금까지의 설명을 읽은 후에 아래 그림을 다시 한번 보았을 때 조금 더 이해가 되는 것 같은 느낌을 느끼실 수 있으면 좋겠네요.

서비스 구성도

 위 내용은 aws를 기준으로 인프라를 구성할 때에 대한 설명이며, 다른 provider를 이용할 때에는 다른 단어들이 사용될 수 있습니다. 그래도 비슷한 역할들을 하는 구성요소들은 다들 있을 것이고 어떤 역할을 하는지에 대해서만 간단히 익히고, 단어들에 대해서 조금은 익숙해질 수 있는 기회였다면 좋겠습니다. 저도 사실 네트워크, 인프라에 대한 지식이 거의 없으며 이번 작업을 하면서 단편적으로 익힌 내용들을 적었습니다. 혹시나 부족한 설명이나 잘못된 설명이 있다면 언제든지 피드백 부탁드립니다.

nextjs 서비스 배포하기

  이제 서비스 구성을 다했습니다. 그런데 우리는 계속 개발을 할 것이고 계속 서비스를 배포하고 새로운 기능을 유저에게 전달해야 합니다. 우리는 위와 같이 구성한 nextjs 앱을 어떻게 배포할 수 있을까요? 우선 ecs로 배포한 nextjs를 업데이트하기 위해서는 두 가지를 업데이트 해야 합니다.

  • ecr에 docker image를 최신으로 배포합니다.
  • ecs에서 새로운 docker image를 사용하는 작업정의(task-definition)을 업데이트합니다.

 저는 위 과정을 github action에 workflow를 작성하여 자동으로 배포를 진행하고 있습니다. 원래 모든 infra를 terraform으로 작성했었는데, terraform은 다른 repository에 따로 모아두고 있었고, 작업정의를 업데이트하기 위해서는 terraform을 같은 repository로 가져와야할까 고민했었는데요. infra code를 해당 repository로 가져오지는 않고 ecs에 새로운 작업정의를 만드는 것을 aws cli의 aws ecs update-service를 이용해서 배포를 진행하고 있습니다.

nextjs 서비스 모니터링하기

  앞으로 서비스를 실제로 운영하면서 모니터링하고 트러블슈팅을 해야할 일들이 더 많이 생길 것 같은데요. 우선은 ecs에서 기본적으로 제공해주는 ecs 서비스의 측정치탭에서 기본적인 CPU, Memory값들을 모니터링합니다. 그리고 특별히 서버에서 어떤 에러들이 발생했는지 등을 확인하기위해서 CloudWatch를 생성해서 ecs 서비스의 로그탭에서 로그들을 확인하고 있습니다. 아직 production이 배포되지 않은 상태라서 앞으로 어떤 툴들이 필요할지는 조금 더 시간이 지나야 구체적으로 더 많은 경험이 생기고 공유할 수 있을 것 같습니다.

nextjs 서비스 아키텍처 구성 중 트러블슈팅

회사 내부의 네트워크에 대한 이해

 회사 내부에서 사용하는 aws 계정이 하나가 아니라 여러개였고 서로 연관성을 가지고 있었습니다. 이 이야기를 이 글에 자세히 쓸 순 없지만, 관련된 내용을 파악하느라 여기저기 여쭤보고 네트워크나 보안망에 대한 이해가 조금 더 필요했었습니다.

cloudfront origin, behavior에 대한 이해

  cloudfront를 생성해서 사용하면 가장 기본적으로 익혀야하는 부분이 origin과 behavior에 대한 이해입니다. 우선 origin은 content의 원천이라고 생각하시면 됩니다. 그리고 behavior는 origin에 대한 통신을 할 때에 어떤 규칙을 가지고 통신할 지에 대한 설정들이라고 생각하시면 됩니다.

  예를 들어 origin은 예를 들어서 s3 bucket이 될 수 있고, behavior는 어떤 path에 이 설정을 적용할 것인지 캐시는 어떤 값을 기준으로 할지 혹은 http 요청이 오면 https로 redirect한다와 같은 설정들을 추가할 수 있습니다.

  실제로 cloudFront를 구성하면서 겪었던 트러블 슈팅에 대해서 간략히 공유하자면,

  • cloudFront에서 캐시키를 origin의 header를 바라보게 만들고 origin의 header에 cache 관련 header가 없다면 무한 캐시가 기본 동작입니다. origin에서 cache hader를 추가하지 않고 작업하다가 배포하니까 새롭게 배포된 앱이 서빙이 안되길래 살펴보니 위와 같은 문제를 겪었습니다.
  • cloudFront의 origin으로 특정 domain으로 설정했는데 cloudFront가 해당 origin에 접근해서 바로 response를 주는게 아니라 아예 301 상태 코드를 주면서 그 url로 redirect하는 문제를 겪었습니다. 이는 origin에서는 http만 받을 수 있도록 해두었는데, behavior에서 무조건 http to https redirect을 추가했었는데 두개의 설정이 충돌하면서 발생한 문제였습니다. 위 두개의 설정에 대한 역할에 대해서 적절히 이해하고 있다면 저와 같은 실수를 여러분은 안하실 수 있을 것이라고 생각합니다.
  • 제가 구성하는 nextjs 서비스는 next/image와 같은 next가 제공해주는 image 컴포넌트를 이용하기 위해서는 querystring에 대한 접근이 필요했고, 인증을 위해서 cookie에 대한 접근이 필요했습니다. 그런데 기본적으로 cloudFront는 path를 기준으로만 작동하기 때문에, origin에서 query나 cookie가 필요하다면 query, cookie등도 모두 넘겨 주어야 한다는 설정을 behavior에 설정해주어야 합니다.
  • cloudFront을 통해서 들어오는 요청만을 보안적으로 허용하고 싶다면, cloudfront ip ranges에서 실제로 cloudFront에서 사용하는 ip 대역들을 확인할 수 있습니다.

마무리

  적다보니, 굉장히 글이 길어지고 스스로가 이해하고 있는 개념들이 굉장히 단편적이고 깊지 못하다는 사실까지 깨닫게 되었는데요. 지금 제가 이해하는 수준들이 걸음마라고 생각하고 앞으로 더 열심히 배우고 싶다는 생각이 들었습니다. 특별한 내용은 없지만 저와 같이 nextjs를 ecs로 구성하고 싶은데 어떻게 시작해야할지 모르는 단계에 있으신 누군가에게 조금이라도 도움이 되는 글이었으면 좋겠다고 생각하면서 첫 번째 글을 마무리합니다.

Part 2 - nextjs 서비스 개발 경험 및 트러블슈팅

 충분하지 않았을 수 있지만 Part1 이후로 우리는 이제 nextjs 앱을 aws에 ecs를 활용해서 구성하고 배포할 수 있게 되었습니다. 사실 Part1은 nextjs와 관련된 내용은 거의 없었고 aws 인프라를 어떻게 구성할 수 있는가에 대한 글에 가까웠습니다. 그래서 대제목과 어울리게 이번 글에서는 구체적으로 nextjs로 개발하면서 어떤 이슈들에 부딪혔었고 어떻게 접근해왔었는지에 대한 이슈들에 대해서 이야기해보려고 합니다. 간단하게 목차를 적어 보았습니다.

목차

nextjs 한걸음 더 나아가기

SSG, SSR, SPA에 대해서 조금 더 이해해보기

 nextjs에서 어떤 페이지는 getServerSideProps를 사용해서 SSR을 하고, 어떤 페이지는 getStaticPaths와 getStaticProps를 사용하거나 아무것도 사용하지 않아서 pre-rendering을 해야할까와 같은 고민을 nextjs 서비스를 개발하면서 초기에 했었는데요. 블로그와 같이 생성 시를 제외하고 데이터가 자주 변할 일이 없다면 당연히 SSG가 모든 면에서 뛰어나기 때문에 next export를 사용해서 모두 빌드 시점에 html로 만들어두면 그것이 최선이라고 생각합니다. 하지만 대부분 우리가 만들려고 하는 앱들은 그것보다 조금 더 다이나믹한 앱일 경우가 많습니다. 그러면, SSG를 사용할 수 없는 경우에는 어떤 전략을 취하는게 맞을까요?

 SSR이냐 SPA이냐를 결정해야 할 때 제가 고려한 요소는 SEO와 FCP 두 가지였습니다. 프론트 개발할 때에 사용할 수 있는 접근 3가지가 두 요소에서 어떤 우위를 가지고 있는지를 표현해본다면 다음과 같을 것입니다. 아래에서 SPA라고 표현한 것은 서버에서 데이터를 미리 준비하고 html을 만들지 않고 js가 로드되고 데이터를 가져와서 화면을 구성하는 방법을 표현한 것입니다.

  • [SEO] SSG = SSR > SPA
  • [FCP] SSG >= SSR > SPA

 SSG를 할 수 없는 경우에 위의 요소만을 보았을 때에 우리는 항상 SSR을 하는 것이 맞지 않을까라고 생각할 수 있습니다. 하지만 SSR이 SPA보다 항상 우위를 가질 수 있는가를 판단하기 위해서는 캐시를 할 수 있는지에 대한 조건도 고려를 해야합니다. 위의 비교는 SSR에서 html response를 생성하기 위한 remote server request에 걸리는 시간을 제외하고 비교했기 때문입니다. 캐시를 하기 어려운 페이지는 다음 정도의 페이지들을 생각할 수 있습니다. 쿠키를 캐시키에 포함시키면 가능할 수 있겠지만 유저별로 다르게 표시가 되어야할 페이지는 캐시를 하기가 어렵습니다. 또는 유저의 특정 액션에 따라서 사용되는 데이터가 자주 변경될 수 있는 페이지는 캐시하기가 어렵습니다. 결론적으로 결국 nextjs로 개발할 때에 저는 다음과 같은 조건으로 SSR을 할 페이지들을 결정했습니다.

  • SEO가 필요한 페이지이고, 캐시를 할 수 있다면 SSR -> 상품목록 페이지 (1분 정도 캐시를 해도 크게 문제가 없을 수 있습니다.)
  • SEO가 필요없거나 캐시를 하기 어려운 페이지라면 SPA -> 상품 카트페이지 (유저마다 다르게 보여질 페이지이므로 SEO관련이 없고, 매번 자주 변하는 데이터들이기때문에 캐시를 하기 어렵습니다.)

프로젝트의 구조를 어떻게 구성할 것인가

 React로 서비스를 구성할 때에 어떻게 프로젝트의 구조 및 폴더 구조를 가져가면 좋은지에 대한 Best Practice같은 것들을 React에서 크게 제안하고 있는 내용은 따로 없습니다. 그래서 다들 여러 가지 방법들을 사용하고 있습니다. 그래도 이번에 작업하면서 저는 어떤 접근을 취했는지에 대해서 공유 해보려고 합니다.

  • next config에서 pageExtentionspage.tsx로 설정했습니다.
    • 위 설정을 통해서 pages폴더 안에서 페이지에서 사용하는 컴포넌트들을 pages 폴더 안에 최대한 가깝게 co-location시킬 수 있도록 접근했습니다.
    • 그리고 pages 폴더안에서 components, hooks와 같은 기능적 폴더 분리를 하지 않았습니다. 왜냐하면 하나의 페이지에 관련된 파일들이 많아봤자 보기에 불편할정도로 엄청 많아지지 않을 것이라고 생각했고(-> 아직까지는 그렇습니다. 하지만 나중에 어떤 상황에 의해서 변할 수도 있겠습니다.), 특히나 nextjs의 pages폴더는 pageExtensions를 사용했지만, route를 표현하는 폴더이기때문에 따로 폴더링을 하고 싶지 않았습니다.
  • libs, utils 폴더를 사용했습니다.
    • 서비스를 개발하다보면 공통적으로 사용해야하는 utils성 함수들이 존재할 수 있는데요. libs는 특정 라이브러리와 관련된 전역적인 utils 함수들을 모아두었고, utils 폴더에는 라이브러리와는 관계없지만 전역적으로 사용 함수들을 모아 두었습니다. (ex. format하는 함수)
  • domains라는 폴더를 사용했습니다.
    • domains라는 폴더는 주문, 장바구니와 같이 하나의 페이지에만 국한되는 것이 아니라 여러 페이지에서 공통적으로 사용할 수 있는 로직들을 모아두는 폴더를 생성해서 사용했습니다.

react@next, react-dom@next의 Suspense 이슈

  react@next, react-dom@next를 사용해야만 nextjs에서 suspense for data fetching을 이용할 수 있습니다. 그런데, suspense는 에러가 나면 바로 fallback으로 떨어지고 데이터가 준비가 되면 render를 진행하는 식의 동작방식을 가지고 있는데요. 이게 nextjs의 서버에서 작동할 떄에는 그냥 특정 컴포넌트에서 error가 발생하면, 그냥 fallback으로 떨어져 버리고 html생성을 안하고 그냥 빈 fallback을 return합니다. 그래서 이런 경우가 있었습니다. 특정 컴포넌트가 useEffect가 아닌 useState에서 그냥 window 객체에 접근하고 있었고 서버에서 해당 컴포넌트를 render하려고 했을 때에 에러가 나면서 그냥 html을 서버에서 만들지 않고 null을 내려주어서 SSR이 되어야하는 페이지인데도 불구하고 SSR이 안된 경우가 있었다. 아직은 사용자가 이런 실수를 하지 않도록 주의하는 것 밖에 방법을 찾진 못했는데, 더 좋은 방법이 분명히 있을 것 같다는 생각을 하고 있고 틈틈히 찾아볼 생각입니다.

import * as React from 'react';

// _app.tsx
function MyApp() {
  return (
    <React.Suspense fallback={null}>
      <div>Hello World</div>
      {/* window는 서버에서 실행시 없는 객체이므로 에러가 나면서 서버 렌더링 시에 fallback인 null로 render된다. */}
      <div>{window.location.pathname}</div>
    </React.Suspense>
  );
}

react-query의 suspense 모드를 사용함으로써 useQuery가 서버에서도 실행이 된다.

 react-query의 기본적인 작동방식은 useEffect안에서 queryFn을 실행하는 것이다. 그런데 suspense mode를 키게 되면 suspense의 기본 동작과는 다르게 render될 때에 queryFn이 불리게 된다. 그래서 실행되면 안될 useQuery가 실행되고 에러가 나고 있는 것을 cloudWatch 로그를 보면서 확인했습니다. 처음에는 이유를 정확히 몰랐으나 react-query discusstion에서 contributor들과 이야기를 나누면서 원인을 알게 되었습니다. 지금 이 이슈를 적절하게 처리할 수 있는 방법을 react-query에서 제공하고 있지는 않습니다. 그래서 현재는 server에서는 useQuery가 성공한 것처럼 request는 안보내고 그냥 바로 return해버리는 식으로 우회하고 있다.

// _app.tsx
import * as React from 'react';
import { Hydrate, QueryClientProvider } from 'react-query';

function MyApp({ Component, pageProps }) {
  // suspense 모드 true를 하면
  const [queryClient] = React.useState(() => new QueryClient({ defaultOptions: { queries: { suspense: true }}}));

  return (
    <React.Suspense fallback={null}>
      <QueryClientProvider client={queryClient}>
        <Component {...pageProps}>
      </QueryClientProvider>
    </React.Suspense>
  );
}

// pages/index.tsx
function Page() {
  // 아래의 queryFn이 서버에서도 실행이 됨을 기억해야 합니다.
  const query = useQuery('queryKey', async () => {
    if (typeof window === 'undefined') {
      return { success: true, };
    }

    const result = await Promise.resolve({})
    return { success: true, ...result };
  });
  return (
    <div>
      <div>Hello World: {query.data}</div>
    </div>
  );
}

Persistent Layout 구성하기

 nextjs는 react-router와는 다르게 nested router같은 것들을 구현할 수 없기 때문에 공통된 layout 컴포넌트 등을 사용하는 방법에 대해서 꽤 헷갈릴 수 있습니다. 페이지별로 매번 똑같은 Layout 컴포넌트를 둘러 싸서 사용한다고 하더라고 페이지간 state를 공유하고 싶은 경우를 해결할 수는 없습니다. 이 상황에서 우리는 react의 특성에 대해서 조금 더 이해할 필요가 있습니다. react가 dom을 그리고 업데이트할 때에는 React의 Element의 종류와 순서 등을 고려해서 이전의 React Element를 그대로 유지하고 사용할지 새로 만들지를 결정하게 됩니다. 그렇기 때문에 각 페이지에서 순서를 유지한채로 동일한 컴포넌트를 사용하게 되면 페이지가 변경될 때에도 동일한 state를 가지고 있는 persistent layout을 구현할 수 있습니다. 자세한 내용은 nextjs 문서 링크에서 자세히 확인할 수 있습니다.

// pages/_app.tsx
export default function MyApp({ Component, pageProps }) {
  // Use the layout defined at the page level, if available
  const Layout =
    Component.Layout ??
    (({ children }: { children: React.ReactNode }) => <>{children}</>);

  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

// pages/index.tsx
import Layout from '../components/layout'
import NestedLayout from '../components/nested-layout'

export default function Page() {
  return (
    <div>Hello Word</div>
  )
}

Page.Layout = function Layout({ children }) {
  return (
    <>
      <nav>NavBar</nav>
      <main>{children}</main>
    </>
  );
};

.env, .env.local의 사용

 서비스를 개발하면서 dotenv와 같이 환경변수를 관리하는 라이브러리를 사용해보신 경험이 있으실 수도 있을텐데, nextjs에서는 관련 기능을 조금 더 디테일하게 제공합니다. 두 가지로 나눠서 사용할 수 있는데 관련된 내용을 간단하게 설명해보겠습니다. 다음 설명은 실제 nextjs 공식문서에도 자세히 설명되어 있습니다.

  • .env, .env.development, .env.staging과 같이 환경에 따라서 사용되는 환경변수를 지정할 수 있습니다. local이라는 suffix가 없는 경우는 repository에 포함되고 노출되어도 괜찮은 환경변수만을 포함시켜야합니다.
  • .env.local, .env.development.local과 같이 local이라는 단어가 포함이 되면 repository에 포함되지 않는 환경변수를 사용할 것임을 선언하는 것입니다. 실제 빌드시에 이런 값들은 Github secrets등을 이용해 빌드 시에만 추가해서 사용해야 합니다.

next/router를 사용하면서 주의해야할 것

 nextjs로 개발하면서 next/router를 사용하면서 몇 가지 주의해야할 사항들이 있습니다. 첫 번째로 next는 page를 이동하면서 SPA처럼 바로바로 페이지가 바뀌지 않습니다. 왜냐하면 getServerSideProps를 지정되어있는 경우 해당 함수 실행이 완료되고 페이지를 render하기 시작하기 때문에 그렇고 getServerSideProps가 없더라도 pages별로 asset파일들이 따로 생성이 되기 때문에 asset들을 다운 받는데에도 시간이 걸릴 수 있습니다. (물론, production앱에서는 nextjs가 next/link를 사용해서 잘 prefetch를 해주고 직접 코드로 prefetch를 할 수 있는 방법을 제공하기도 합니다.) 그래서 저는 유저에게 page가 변경되고 있다는 사실을 잘 전달 할 수 있도록 nprogress라는 라이브러리를 활용해서 router의 이벤트가 발생할 때마다 loading bar를 최상단에 보여주도록 작업했습니다. (nprogress)

 두 번째로, next/router의 useRouter에서 return되는 query object가 server-side rendering 페이지가 아닌 경우에는 처음에는 empty object로 내려옵니다. 그래서 실제 pageParams과 querystring에 접근하기 위해서는 useRouter().isReady값이 true가 된 이후에 해당 값을 사용해야 합니다. 하지만, 저는 위 내용이 왜 그렇게 구현되었는지에 대한 이유는 정확히 모르고 이해가 잘 안되었습니다. 또한, React Suspense For data fetching을 사용하고 있었기 때문에 처음 rendering에서부터 해당 값이 필요했는데요. 그래서 직접 router.asPathwindow.location에 존재하는 값들을 매핑해서 직접 pageParams와 queryString을 초기 렌더링 시에도 사용할 수 있도록 작업하긴 했습니다. 뭔가 이유가 있기 때문에 nextjs에서 위와 같이 구현했을 것 같긴 한데, 우선 제가 불편해서 위와 같이 개선해서 사용해보고 있습니다.

function useRouterQuery() {
  const router = useRouter();
  return React.useMemo(
    () =>
      router.isReady
        ? router.query
        : (() => {
            return {
              ...getRouterParams({
                asPath: router.asPath,
                pathname: window.location.pathname,
              }),
              ...toSearchObject(new URLSearchParams(window.location.search)),
            };
          })(),
    [router.asPath, router.isReady, router.query],
  );
}

next/image를 사용하면서 주의해야할 것

 nextjs에서는 next/image를 통해서 이미지를 optimize할 수 있는 기능을 제공합니다. 결론적으로 저는 현재 unoptimized라는 props를 추가해서 optimize하는 기능을 사용하고 있지는 않습니다. image를 optimize하는 작업과 html을 서빙하는 일을 하나의 서버에게 맡기기에는 꽤 무리가 있어 보였고, cache가 잘되지 않는다면 서비스 운영에 큰 영향을 끼칠 수 있다고 생각했기 때문입니다. 왜냐하면 이미지가 로드가 조금 오래걸리는 것보다 html이 생성이 안되는게 훨씬 더 크리티컬한 오류이기 때문입니다. 실제로 캐시 정책이 제대로 작동하지 않는 상황에서 next/image의 optimize를 사용하면서 nextjs 서버의 cpu가 100%를 찍는 것까지 목격했었습니다. 그래서 이미지 최적화에 대한 작업은 서버가 이미지를 업로드할 때에 잘 해줄 것을 기대하고 믿으며 작업하고 있습니다. 실제로 nextjs의 image optimize 기능을 production에서 사용할 때에는 html 서빙하는 서버에서 직접 이 일들을 하기 보다는 다른 provider들을 사용하는게 맞을 것 같은데 다른 분들은 어떻게 접근하고 계신지 궁금하긴 합니다. vercel에서 배포해서 사용했을 때에는 따로 optimize하고 서빙해주는 worker같은 것들이 돌아가는 것처럼 확인됩니다.

Vercel makes it easy to accomplish ideal loading times and prevent layout shifts for images when deploying Next.js applications by providing you with a built-in next/image component that automatically optimizes your images on demand and serves them from our globally distributed Edge Network.

 그 이외에도 그래도 next/image의 흥미로운 점들은 최신 image format들(avif, webp)등도 제공한다는 것이고, imagesSize, deviceSizes등의 설정을 통해서 srcset를 생성해주는 것을 직접 설정해서 사용할 수도 있습니다.

 그리고 minimumCacheTTL을 사용해서 optimized된 이미지들의 cacheTime을 조절할 수도 있지만, nextjs에서 결국 image의 response를 돌려줄 때에 origin의 cache-control을 기준으로 돌려주기 때문에 minimumCacheTTL 설정은 origin의 cache-control보다 우선할 수는 없습니다.

next.config.js에서 headers 옵션 사용하기

 Part1에서도 말했던 것처럼 cloudFront는 cache-control header가 없으면 무제한으로 캐싱을 한다. 그렇기 때문에 nextjs에서 아무런 설정을 안하면 html response에 대해서 무한 캐싱을 해버리기때문에 html response에 대해서 캐시를 아예 하지 않도록 아래와 같은 코드 추가가 필요했다. 아래와 같이 추가하더라도 assets(js, css)에 대한 cache-control은 nextjs에서 알아서 무제한으로 override해주니 그것에 대해서는 걱정 안해도 된다.

export default {
  headers: () => {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'no-cache,no-store,max-age=0,must-revalidate',
          }
        ]
      },
    ]
  },
}

next.config.js assetPrefix의 사용

 assetPrefix라는 설정을 사용하면 nextjs 서버에서 직접 asset들을 서빙하지 않고 다른 cdn에서 assets 파일들을 서빙할 수 있습니다. 이 설정을 하는 것이 더 권장되는 이유는 nextjs 서버는 실제로 유저의 인증 등을 위해서 cookie값들 혹은 querystring 값들을 계속해서 들고 다니는데 사실 assets파일들을 서빙할 때에는 그런 값들이 필요없을 수 있는데 불필요한 network resource가 낭비될 수 있기 때문에 더 최적으로 서비스를 운영하기 위해서는 assetPrefix를 사용하는 것이 더 좋습니다. 또 cloudFront를 따로 사용해야 더 적절한 error pages 설정 등을 세팅하기에 좋습니다.

인증을 어떻게 할 수 있을까

 nextjs에서는 인증을 서버에서도 확인할 수 있고 브라우저에서도 확인이 필요합니다. 보통 SPA를 개발할 때면 인증을 받고 token을 받아서 localStroage에 저장해두고 그 값을 기준으로 로그인 유지를 구현하곤 합니다. 하지만 nextjs에서는 서버에서도 인증이 필요하기 떄문에 localStorage만으로는 사용할 수 없습니다. 그래서 저는 nookies라는 라이브러리를 활용해서 쿠키 기반으로 nextjs 서버에서도 인증을 하고 브라우저에서도 인증을 하고 있습니다. 혹시나 모를 상황에 대비해서 fallback으로 클라이언트에서 localStorage를 확인해서 인증을 확인하고 있기는 합니다. (nookies)

nextjs와 같이 사용하고 있는 라이브러리들

react-query를 SSR에서 활용하기

 react-query를 애용해왔지만, SSR에서 사용한 것은 이번이 처음이었는데요. SSR 관련된 API나 docs가 존재한다는 생각을 하지 못하고 무작정 SPA에서 사용하던 방식대로 react-query를 사용하다가 몇가지 시행착오를 겪었습니다.

  • 첫 번째로는 new QueryClient()를 어떤 식으로 생성해서 사용해야하는가에 대한 문제입니다. 전역적으로 new QueryCLient()를 작성해두고 사용해도 SPA를 사용할 때에는 user의 request마다 새로운 queryClient가 만들어지고 사용될 것이 보장되었지만, nextjs에서 전역적으로 선언한 변수는 유저 request별로 공유될 수 있는 가능성이 있기 때문에 매번 _app.tsx파일에서 new QueryClient()를 새로 만들어 줘야 합니다. 위 내용은 react-query ssr 관련 문서에 자세히 작성되어있습니다.
  • 두 번째로는 getServerSideProps에서 이미 fetch해온 정보를 어떻게 컴포넌트에 작성되어 있는 useQuery에서 바로 사용할 수 있게 만들 수 있을까에 대한 고민이었습니다. 이것 또한 react-query의 ssr 문서를 보면 getServerSideProps에서 prefetchQuery를 하고 _app.tsx에서 <Hydrate />를 사용하는 방식이 소개되어 있었는데 관련 내용을 읽지 못했었고 나중에야 발견하고 적용했던 경험을 했습니다.

 따로 경험은 없지만, nextjs를 만든 vercel에서 react-query 역할을 하는 swr이라는 라이브러리를 만들었는데, nextjs와 swr가 훨씬 궁함이 잘맞을 수도 있겠다는 생각이 들긴 했지만, 회사 내부에서 react-query를 더 많이 사용하고 있기에 이번 nextjs 프로젝트에서도 react-query를 사용하게 되었습니다. 다음에는 swr도 사용해볼 기회가 있다면 해보면 또 다른 좋은 경험을 할 수 있지 않을까도 생각해봤습니다.

// _app.tsx
import * as React from 'react';
import { Hydrate, QueryClientProvider } from 'react-query';

function MyApp({ Component, pageProps }) {
  // 매번 user request마다 QueryClient를 새로 생성합니다. (React useState lazy initialization)
  const [queryClient] = React.useState(() => new QueryClient());

  return (
    <React.Suspense fallback={null}>
      <QueryClientProvider client={queryClient}>
        {/* Hydrate 컴포넌트를 활용해서 state를 주입 */}
        <Hydrate state={pageProps?.dehydratedState}>
          <Component {...pageProps}>
        </Hydrate>
      </QueryClientProvider>
    </React.Suspense>
  );
}

openapi-typescript로 api의 type generation을 자동화하기

 API에서 데이터를 받아올 때에 서버가 graphql를 사용하고 있지 않다면 type을 직접 작성해줘야하는 일들이 발생하는데요. 이번 프로젝트도 마찬가지였습니다. API는 rest로 구성되어 있었고, typescript를 쓰는 클라이언트에서 type checking을 하기 위해서 직접 typing을 작성해줘야만 했습니다. 그런데 이런 불필요한 작업들을 줄이기 위해서 이번에 도입한 것이 openapi-typescript라는 라이브러리를 사용해서 API서버에서 swagger의 메타정보를 이용해서 바로 type을 generation을 할 수 있도록 했습니다. 위 방식을 도입하기 위해서는 백엔드팀에서도 swagger 정보를 yml혹은 json으로 뽑아줄 수 있는 endpoint를 만들어줘야하는 수고를 해주어야 하는데요. 다른 팀에서도 백엔드팀과 논의시 이것이 프론트팀에게 얼마나 큰 도움이 되는지를 잘 설득하고 할 수 있다면 굉장히 좋을 것 같습니다.

 우선, 위 내용들을 성공하고 openapi-typesciprt를 통해서 type을 만들었다고 해도 조금 불편했던 점은 만들어준 type의 형태가 꽤나 많이 nested 되어있는 object의 형태였고, 조금 더 쉽게 원하는 type들에 접근하기 위해서 좋은 방법이 없을까 생각하다가 typescript utility library인 ts-toolbelt를 활용해서 필요한 type들을 조금 더 쉽게 얻고 활용할 수 있었습니다. 관련해서 몇 가지만 적어보겠습니다.

import { O } from 'ts-toolbelt'
import type { components, paths } from '~/types/generated';

export type OAIMethods = 'get' | 'put' | 'post' | 'delete' | 'patch';

// (O.Selectkeys 사용) 특정 OAIMehotds에 해당하는 path keys들의 타입
export type OAIPathKeysByMethod<TMethod extends OAIMethods> = O.SelectKeys<
  paths,
  Record<TMethod, unknown>
>;
// ex) get endpoint를 가지고 있는 모든 endpoint들
type GetPathKeys = OAIPathKeysByMethod<'get'>;

// (O.Path 사용) 특정 path에 해당하는 pageParams들을 가져오는 type
export type OAIPathParameters<
  TPath extends OAIPathKeys,
  TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'path']>;

// (O.Path 사용) 특정 path에 해당하는 queryString들을 가져오는 type
export type OAIQueryParameters<
  TPath extends OAIPathKeys,
  TMethod extends OAIMethods,
> = O.Path<paths, [TPath, TMethod, 'parameters', 'query']>;

react-query, immer를 사용해서 optimistic update 구현하기

 장바구니에서 장바구니에 담긴 item들의 수량 변경, 체크상태 등을 browser의 상태에서만 관리하는 것이 아니라 서버에 기록을 하고 서버의 데이터에 의존해서 페이지를 만들어야 했습니다. 그런데, 수량을 변경하거나 checkbox 체크를 할 때마다 API call을 하게 되면 빠르게 input에 대한 feedback을 받고 싶어하는 유저의 입장에서는 매번 수량 변경을 하거나 체크박스에 체크를 할 때마다 로딩 화면을 보여주는 것은 좋은 UX가 아니라고 생각했습니다.

 위 상황을 해결하기 위해서 생각했던 방법은 다음 두 가지정도였습니다.

  • optimistic update를 구현하여 매번 API를 보내지만, API의 결과와 상관없이 우선 예상되는 결과를 반영하고 연속적인 요청이 끝났을 때에 서버에 마지막으로 확인하여 UI를 업데이트합니다.
  • 혹은 초기값을 서버에서 받아와서 클라이언트의 state에 저장해두고, 그 이후의 동작들은 클라이언트에서만 변경을 시키다가 특정 시점에 서버에 싱크를 시킵니다.

 초기에는 클라이언트에서 state를 관리하는 방향으로 했었지만 react-query의 특성상 cache가 있을 수 있고 초기 클라이언트 state를 init할 때에 캐시에 들어있던 data를 기준으로 state를 세팅해버리기 떄문에 그걸 업데이트하는 코드 등을 추가해줘야하는 등 처리해야 하는 방식이 복잡해지기도 했고 data의 source를 react-query로 통일하고 싶었습니다. 그래서 react-query에서 제공하는 API중에서 onMutate, onSettled, cancelQueries, invalidateQueries등을 활용하여 optimistic update를 구현하는 것으로 방향으로 잡았습니다. 그리고 data가 꽤 nested되어 있는 Object였기에 해당 Object를 immutable하게 업데이트를 코드를 길게 쓰지 않고 작업하기 위해서 immer 라이브러리르 사용해서 immutable하게 업데이트했습니다. (react-query optimistic update / immer)

react-virtuoso를 사용해서 빠른 무한스크롤링 리스트를 구현하기

 상품목록 페이지에서는 무한스크롤링 리스트를 구현해야하는 이슈가 있었습니다. 무한스크롤링 리스트를 구현할 때에 virtualized list가 아닌 그냥 돔을 계속 추가하는 방향으로 그리게 되면 dom이 굉장히 무거워지고 업데이트가 느려지기 때문에 흔히 virutlized list를 구현합니다. 그래서 이전에도 사용해본 경험이 있던 react-virtualized라는 라이브러리를 사용해서 처음에 구현하려고 했는데, 이 라이브러리의 경우 window scroll에 맞게 구현하는 API가 따로 없었고 다른 라이브러리를 같이 혼용해서 사용해야했습니다. 그런데 동료로부터 더 API가 간단하고 좋다고 추천받은 react-virtuoso를 알게 되었고 해당 라이브러리를 사용해서 조금 더 수월하게 구현했습니다.

 처음에는 API가 꽤나 편하고 쉬워서 만족스럽게 사용하고 있습니다. 그런데 갑자기 react-virtuoso가 SSR에서는 render가 안되는 것을 발견했습니다. 상품 목록이야말로 SEO가 중요한 페이지일 수 있는데 react-virtuoso가 아무리 좋더라도 SSR을 시작한 이유와 더 직결된 이 이점을 포기할 수가 없어서 이리저리 찾아봤는데 처음에는 못찾다가, 나중에야 initialCount와 FixedItemHeight라는 props를 사용해서 SSR에서도 필요한 만큼 잘 render되도록 만들 수 있었습니다. (react-virtuoso)

useQuery의 refetchOnMount, refethcOnWindwoFocus 활용하기

 useQuery를 사용하면서 react-query staleTime의 기본값인 0을 사용하기 보다 staleTime을 조금 높은 값을 주고 정말로 변화가 필요한 값에 대해서만 invalidate해주는 방식으로 접근했었습니다. 그런데 예를 들어서 다른 페이지의 특정 액션을 통해서 변경이 될 수 있는 query들의 경우는 recthOnMount, refetchOnWindowFocus를 킴으로써 최산값을 최대한 유지하도록 하는 방향으로 접근했습니다. 여러 페이지에서 계속해서 어떤 query들을 invalidate해야하는 지를 항상 잘 기억하기가 어렵기 때문입니다. 장바구니의 전체 상품 목록들에 대한 API, 장바구니 상품 목록의 가격을 계산하는 API등은 다른 페이지에서 장바구니에 상품을 추가할 때마다 새로 invalidate를 해줘야만 했는데 각각 invalidate하기보다는 refetch하도록 하는 것이 작업자의 멘탈모델에서도 훨씬 더 까먹지 않고 실수없이 작성할 수 있을 것이라고 생각했습니다. 그리고 invalidate를 해주는 경우는 한 페이지에서 특정 액션을 통해서 변화가 있을 수 있는 query들은 invalidate하는 방식으로 접근했습니다.

window.history가 있을 때에만 BackButton 노출하기

 모바일 웹뷰 상황에서 push, replace등으로 페이지를 옮기는 것이 아니라 완전히 history의 새로운 맥락을 만드는 경우에 Layout에서 엑스버튼 뿐만 아니라 그 안에서도 history가 생기는 경우에만 BackButton을 보여주고 싶었습니다. 그런데 기본적으로 browser에서 history에서 back을 할 수 있는 상황인지에 대해서 정확하게 알 수 있는 방법은 없습니다. 그렇지만 react-router나 next/router와 같은 라이브러리에서 직접 routing을 관리하면서 window.history.state.idx라는 값에 현재 어떤 순서에 와있는지를 기록합니다. 공식적으로 제공하는 방법은 아니지만 router가 변할 때마다 해당 값의 변화에 따라서 BackButton을 보여주고 말고 등을 결정하는 식으로 위 문제를 해결했습니다.

const ModalAppBar = ({ title }) => {
  const router = useRouter();
  const [showBackButton, setShowBackButton] = React.useState(false);

  React.useEffect(() => {
    const updateShowBackButton = () => {
      setShowBackButton((window.history.state?.idx || 0) > 0);
    };

    router.events.on('routeChangeComplete', updateShowBackButton);
    return () => router.events.off('routeChangeComplete', updateShowBackButton);
  }, [router.events]);

  return (
    <nav>
      <div>
        {showBackButton && (
          <button
            type="button"
            className={styles.left}
            onClick={() => router.back()}
          >
            <MoveBack />
          </button>
        )}
      </div>
      <div>{title}</div>
      <div>
        <button>
          <CloseIcon />
        </button>
      </div>
    </nav>
  );
};

nextjs와 관련이 없을 수도 있는 트러블슈팅들

encodeURI, encodeURIComponent의 차이

 querystring에 url을 기록하고 넘기고 싶은 경우에 우리는 어떤 encode 내장 함수를 써야할까. 정답은 endcodeURIComponent이다. encodeURIComponent, encodeURI 두가지 API가 있다는 것은 알고 있는데 둘의 정확한 차이는 잘 몰랐는데 이번에 알았다. encodeURI는 http://와 같이 프로토콜에 관련된 내용은 encode안하는데 encodeURIComponent는 해당 부분까지 encode해주는 API다. 그러므로 encodeURIComponent를 사용해야한다. 그리고 encode관련해서 꽤 복잡했던 이슈가 있었는데, encode된 스트링을 또 encode할 수있는데 이런 경우 모바일웹뷰가 제대로 해당 string을 decode할 수 없고 정확한 querystring을 못넘기는 경우가 있다. 관련해서는 주의를 해야한다.

window.scroll smooth safari에서는 작동이 안된다

  window.scroll(0, { behavior: 'smooth' })와 같은 코드는 크롬에서는 진짜 유저가 스크롤하는 것처럼 작동하지만 safari에서는 작동하지 않는다. 그래서 이 경우에는 polyfill이 필요하다. 저는 smoothscroll-polyilll을 사용해서 polyfill을 추가했습니다. (smoothscroll-polyfill)

모바일 웹뷰에서 사용할 수 있는 유용한 패턴들

 현재 진행하고 있는 프로젝트에서는 /app/post-form, /app/modal-close와 같은 utils성의 페이지들이 있다. 다른 서비스를 이용할 때에 특정 로직을 심을 수는 없고 url만 지정할 수 있을 때 특정 로직을 실행시키기 위해서 위와 같은 페이지들을 만들어서 사용하고 있다. /app/post-form은 새로운 웹뷰를 열면서 form post action으로 다른 페이지로 이동하고 싶을 때 사용하고 있고, /app/modal-close의 경우 다른 서비스에서 다녀오면서 웹뷰 창을 닫고 싶을 때 해당 로직을 담아둔 페이지를 따로 파서 사용하고 있다.

모바일 웹뷰에서 postMessage없이 postMessage하기

 모바일에서 웹뷰로 작업할 때에는 로드된 웹페이지 위에 또다른 웹이 뜰 수 있고 위에 떠있던 웹에서 아래 웹뷰 특정 데이터를 넘기고 싶을 떄가 있을 수 있다. 이럴 때 보통은 postMessage를 할 수 있는 모바일앱인터페이스를 만들어서 사용하지만 그와 같은 인터페이스가 없다면, localStorage에 필요한 정보를 전달하고 위에 떠있던 웹뷰가 닫히고 아래에 있던 웹뷰가 focus될 때에 localStorage에 정보를 확인하고 특정 로직을 실행시킬지 결정할 수 있다.

 window storage event를 사용할 수도 있지만, window storage event는 실제로 위 쪽에 떠있는 웹뷰가 닫히기도 전에 실행될 수 있을 것 같았다. 실제로 원하는 것은 위쪽 웹뷰가 닫히고 포커스되는 시점에 토스트를 띄워주는 로직을 실행해야되었기 때문에 localStorage + focus event로 해결했다.

Horizontal 스크롤을 발생시키는 컴포넌트에서 디바이스의 크기에 따라서 완전히 보이는 컴포넌트와 걸치는 컴포넌트를 유지하기

  Horizontal 스크롤을 overflow를 auto로 설정하여 작동하도록 만들어 두었어도, 디바이스 크기에 따라 가로 스크롤이 가능하는 것을 인지하지 못하게 정확하게 컴포넌트가 크기가 딱 맞게 render가 되어서 유저가 가로 스크롤링이 가능한지 인지가 안될 수 있고 시도하지 않을 수 있다. 그럴 때에 item들의 width를 vw unit을 사용해서 디바이스 width가 달라지더라도 꼭 가로 스크롤링이 가능하다는 것을 인지할 수 있게 컴포넌트가 겹치게 rendering할 수 있었다.

마무리

 처음에 글을 쓰기 시작할 때에 어느정도 목차를 작성하고 쓰기 시작했지만, 결국 나온 글은 사실과 의견들의 나열 정도를 벗어나지 못했다는 생각이 드네요. 앞으로 쓰게 될 글들은 더 집중된 주제로 일목요연하게 쓰고 싶다는 생각을 했습니다. 또 예시 코드들도 최대한 많이 포함해야겠다는 생각도 드네요. 부족함을 그대로 노출하는 것 같아 부끄럽지만, 조금이라도 저의 시행착오들에서 도움을 얻는 분들이 있길 바랍니다.

@Ah-ae
Copy link

Ah-ae commented Jul 4, 2024

경험들 나눠주셔서 감사합니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment