1. Next.js 어플리케이션 ECS 컨테이너 로그에 동일로그 반복적인 발생

  • [RangeError] : Incorrect locale information provided

 

2. 로그를 통해 원인 추정

Error [RangeError]: Incorrect locale information provided
at Intl.getCanonicalLocales (<anonymous>)
  • Intl.getCanonicalLocales는 국제화 API (Intl)에서 사용되며, 브라우저 또는 Node.js에서 locale string이 유효한지 검사할 때 호출된다.
  • 이 함수에 잘못된 locale 문자열(예: en_kr 혹은 undefined, 123)이 들어오면 RangeError가 발생.
  • 발생 위치 : file:///application/.next/server/middleware.js:13:82575
  • 미들웨어 동작에서 이슈 추정

 

3. middleware 에서 어디?

  • 미들웨어의 request 로 부터 헤더에서 로케일 정보를 뽑아내는 getLocale 함수를 의심
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
const locale = match(languages, LocalePool, defaultLocale);
  • Negotiator를 통해 헤더에서 언어 목록을 추출하고
  • match 함수로 사전에 정의한 LocalePool에서 가장 적절한 locale을 선택함
  • ⚠️ 그런데 Negotiator().languages()는 Accept-Language 헤더가 malformed일 경우 다음을 리턴할 수 있음:
    • "invalid-locale" 형태의 이상한 문자열
    • 또는 ["*", "zz", "123"] 같이 RFC 5646에 맞지 않는 문자열들
  • 즉, 입력값이 비정상이라면 match() 함수에서 내부적으로 Intl.getCanonicalLocales()를 호출하면서 RangeError를 발생시킬 수 있음.
  • match() 함수는 @formatjs/intl-localematcher 패키지로 부터 가져와서 사용하고 있는데, 해당 페키지에는 에러로그의 일부인 at Intl.getCanonicalLocales (<anonymous>) 함수도 있음
  • match() 함수에서 발생한 에러임을 확인

 

4. 어떻게 수정?

  • match() 함수에서 에러가 발생하면, 패키지 내부적으로 어찌 동작하는지 모르니, try-catch 로 감싸서 defaultLocale 로 세팅한다
  • 사실상 match() 함수의 기대 동작이 찾는 값이 LocalePool 에 없으면 defaultLocale 로 세팅하는 것인데, 타입이 안맞아서 인지.. 원하는 대로 동작을 안하는 것 같다.

 

5. 동작 테스트

  • Postman 으로 헤더에 로케일 정보가 없는 api를 날린다

  • new Negotiator 로 뽑은 languages 값이 match() 함수에서 비정상 동작을 일으켜, catch 로 떨어지고, 이에 따라 locale 이 default 인 kr-ko 로 세팅됨
  • defaultLocale 로 세팅되어 정상 리턴 확인

'programming > error' 카테고리의 다른 글

An invalid form control with name='' is not focusable.  (0) 2022.09.23

Next.js App Router 환경에서 removeChild 에러가 발생하는 현상을 분석하고, 근본 원인 및 실질적인 해결 방법을 안내합니다.


1. 에러 현상 개요

Next.js 기반의 서비스에 외부 라이브챗 서비스를 연동한 후, 페이지 이동시 global-error 페이지로 떨어지는 형상이 발생했다.

또한, Sentry와 같은 APM 도구에서는 다음과 같은 형태로 에러가 포착:

TypeError: Cannot read properties of null (reading 'removeChild')

incomplete-app-router-transaction

 

단순히 외부 라이브챗 스크립트를 적용했을 때만 발생하는 현상이었으므로, 외부스크립트의 동작이 원인일 것으로 예측하였다.

 

2. 현상 파악 과정

과거에도 동일한 에러를 본적이 있고, 그때 스크립트가 우리 서비스의 DOM 요소를 수정하여서 발생한 에러였다.
이 경험을 통해, 외부 스크립트가 DOM 변화를 발생시키는지 확인하기로 함.

 

2-1)실제 DOM 변화 관찰

크롬 개발자도구의 Elements 탭을 통해 다음과 같은 DOM 조작을 확인했다:

  • 외부 라이브챗 스크립트가 기존 <title>을 제거하고 새롭게 생성
  • favicon 용 <link> 태그 일부가 교체됨

이러한 조작이 App Router의 DOM 관리 방식과 충돌을 일으킨 것으로 추정.

 

3. 원인 분석 : 왜 removeChild 오류가 발생하는가?

🔧 Next.js App Router의 <head> 처리 방식

Next.js의 App Router(React 18 기반)는 <title>, <meta>, <link> 등의 태그를 "hoistable elements" 로 취급합니다.

  • 최초 렌더 시 해당 요소를 <head>에 추가
  • 이후 라우팅이 변경될 때 React 내부의 diff 알고리즘이 기존 요소와 비교하여 removeChild 또는 appendChild를 호출

이때 React는 이전 <head> 요소의 참조를 캐싱하고 있기 때문에, 외부 스크립트가 이를 임의로 삭제하거나 교체하면 parent.removeChild(node) 호출 시 다음과 같은 오류가 발생합니다:

 

📌 관련 이슈: Vercel Discussions #52625

 

Cannot read properties of null (reading 'removeChild') with App directory and Intercom · vercel next.js · Discussion #52625

Summary I am having a Next.js (App directory) project which uses Intercom. When I open a page on mobile, open/close the intercom, and then navigate to a new page. I am having Cannot read properties...

github.com

 

 

😱 외부 스크립트의 직접 DOM 조작

  • 라이브챗 위젯은 실행 중에 document.head 내부의 <title> 및 favicon <link> 태그를 직접 수정하거나 교체
  • React는 기존 요소에 대한 참조를 유지하고 있으므로, 라우팅 전환 시 parentNode가 null인 노드를 제거하려고 시도 → 예외 발생

 

4. 해결 전략 및 옵션

전략 장점 단점
A. 라이브챗이 <head>를 수정하지 않도록 설정<br/>예: disable-favicon-badge, hide_default_launcher 등의 옵션 활용 가장 단순하고 안정적<br/>Next.js의 DOM 관리 규칙을 준수 옵션이 없는 위젯에서는 불가능
B. 위젯을 React 컴포넌트 안에서 mount/unmount 관리<br/>전용 Provider 및 useEffect를 통해 상태 관리 라우팅 간 head diff 예측 가능<br/>제어 및 해제가 용이 초기화/해제 비용, 코드 복잡도
C. iframe 또는 Web Component로 위젯 격리 Next.js의 DOM과 완전히 독립적<br/>head 충돌 없음 도입 난이도 존재, 스타일·크기 조정 필요
D. MutationObserver로 head 조작 감시 후 롤백 위젯에 설정 옵션이 없는 경우 대처 가능 깜빡임 가능성, 유지보수 어려움

 

5. 주의사항 및 고려점

1. <head> 직접 조작은 금물

Next.js 14 이상에서는 Metadata API를 통해 <title>, <meta>, <link> 태그를 설정해야 하며, 직접 DOM을 조작하는 방식은 권장되지 않습니다.

외부 스크립트가 이를 위반하는 경우, 반드시 격리 또는 복원 처리 로직을 도입해야 합니다.

2. 스크립트 중복 로드 방지

cleanup 로직 없이 위젯이 중복 로드되면, 전역 객체 충돌이나 already initialized 오류가 발생할 수 있습니다. 항상 언마운트 처리 후 재로드하도록 구성해야 합니다.

3. 개발 환경(HMR)에서도 반드시 테스트

Next.js 13.4.10 버전에서는 개발 중 CSS HMR 갱신 도중에도 동일한 removeChild 오류가 발생한 사례가 있습니다. 반드시 개발 서버에서도 동일한 방식으로 테스트가 필요합니다.

 

 

🧩 결론 및 요약

문제 원인: 외부 스크립트가 Next.js App Router가 관리하는 <head> 요소를 변경하면서 React의 diff 알고리즘과 충돌

해결 방법:

  1. 라이브챗 위젯 옵션으로 <head> 조작 비활성화
  2. React 컴포넌트에서 위젯 mount/unmount 시점에 head 복원
  3. iframe 또는 Web Component로 위젯 격리
  4. 최후의 수단: MutationObserver로 head 복원 감시

 

💡 실무 팁

  • 라이브챗과 같은 외부 위젯은 필요한 페이지에서만 로드하고, 전역으로 항상 mount하지 마세요.
  • 위젯 초기화 시점과 해제 시점을 명확히 제어하여, head 충돌 및 전역 변수 중복 정의를 방지하세요.
  • iframe 방식이 가능하다면 가장 안전하게 DOM 격리를 보장할 수 있습니다.
⚠️ 주의: 이 포스팅을 포함한 총 3편의 Next.js 배포 최적화 시리즈는 제가 실제 환경에서 시행착오를 겪으며 얻은 경험과 지식을 정리한 글입니다.

이 글들은 완벽한 정답이 아니라, 다양한 시도와 시행착오를 통한 저의 삽질기이자 성장 과정이며, 동시에 나와 비슷한 고민을 하는 사람들과 지식을 나누고자 하는 목적으로 작성되었습니다.

따라서, 가능하면 3편의 글을 모두 읽고, 환경과 상황에 맞춰 신중히 테스트한 후 적용하는 것을 추천합니다.

마지막까지 읽으시면 무엇이 문제였는지, 그리고 어떻게 해결했는지 확인할 수 있습니다! 🚨🚧✨

 

📋 총 3편의 글 리스트 

https://tacit.tistory.com/267

 

Next.js + AWS ECS 컨테이너 배포하기 : CodePipeline 활용한 CI/CD 자동화와 Docker 빌드 최적화 사례 분석 [1

⚠️ 주의: 이 포스팅을 포함한 총 3편의 Next.js 배포 최적화 시리즈는 제가 실제 환경에서 시행착오를 겪으며 얻은 경험과 지식을 정리한 글입니다.이 글들은 완벽한 정답이 아니라, 다양한 시도

tacit.tistory.com

https://tacit.tistory.com/269

 

Next.js + AWS ECS 컨테이너 배포하기 : CodePipeline 활용한 CI/CD 자동화와 Docker 빌드 최적화 사례 분석 [2

⚠️ 주의: 이 포스팅을 포함한 총 3편의 Next.js 배포 최적화 시리즈는 제가 실제 환경에서 시행착오를 겪으며 얻은 경험과 지식을 정리한 글입니다.이 글들은 완벽한 정답이 아니라, 다양한 시도

tacit.tistory.com

https://tacit.tistory.com/271

 

Next.js + AWS ECS 컨테이너 배포하기 : CodePipeline 활용한 CI/CD 자동화와 Docker 빌드 최적화 사례 분석 [3

1. 원인 분석캐시 이미지 생성·업로드·다운로드 과정에서 소요되는 시간 때문에 Docker 레이어 캐싱 전략이 오히려 비효율적이었습니다.npm ci 명령어의 캐싱 불확실성으로 인해 Docker 레이어 캐싱

tacit.tistory.com

 


1. 원인 분석

  • 캐시 이미지 생성·업로드·다운로드 과정에서 소요되는 시간 때문에 Docker 레이어 캐싱 전략이 오히려 비효율적이었습니다.
  • npm ci 명령어의 캐싱 불확실성으로 인해 Docker 레이어 캐싱의 이점을 제대로 활용하기 어려웠습니다.

 

2. 해결방안

이번에는 Next.js의 공식문서에서 권장하는 CI 빌드 캐싱 방식을 사용하기로 했습니다.

buildspec.yml에 캐시할 데이터 폴더를 명시하고, AWS CodeBuild에서 Artifact 캐싱으로 S3 버킷 경로를 설정합니다.

cache:
  paths:
    - 'node_modules/**/*'  # node_modules 캐싱
    - '.next/cache/**/*'   # Next.js 빌드 캐시

 

3. 변경내용

이 전략을 사용하려면 다음과 같은 변경이 필요했습니다.

🔸 npm 명령어 변경 (npm ci → npm i)

  • 캐시된 node_modules를 재사용하기 위해 npm i 사용
  • CI/CD 환경에서는 보통 정확성을 위해 npm ci가 추천되지만, 속도 향상을 위한 전략적 선택
  • 프로덕션에서는 npm ci를 사용하는 것이 권장됨
  • 잦은 배포가 있는 dev 환경에서 사용함

🔸 pre_build.sh 스크립트 수정 (Dockerfile 밖에서 빌드 실행)

  • AWS 빌드 환경에서 직접 node_modules 및 .next/cache 접근 및 S3 업로드 가능하게 변경
  • Dockerfile 내부가 아닌, 외부 환경에서 npm i와 npm run build 실행
  • pre_build.sh
#!/bin/bash

npm install

if [ ! -z "$RUN_ENV" ] && [ "$RUN_ENV" == "dev" ]; then
    echo "DEV BUILD SETTING..."
    envsubst '${COUNTRY}' < .env.development > .env
    echo "" >> .env
else
    echo "PROD BUILD SETTING..."
    envsubst '${COUNTRY}' < .env.production > .env
    echo "" >> .env
fi

npm run build

🔸 Dockerfile 조정

  • Dockerfile은 빌드 결과물만을 복사하여 최종 이미지 생성 역할만 수행
  • Docker Layer 캐싱 제거 (오히려 속도를 저하시킴)
# --- 2) 최종 이미지(Stage: runner) ---
FROM public.ecr.aws/docker/library/node:20.5.1-alpine3.18

# 1. 작업 디렉터리 설정
WORKDIR /application

# 2. 배포 시 필요한 운영 의존성만 설치
COPY package*.json ./
RUN npm ci --omit=dev

# 3. 빌드 산출물(standalone, static, 설정 파일 등) 복사
COPY .next/standalone ./
COPY .next/static ./.next/static
COPY next.config.js ./
COPY run_server.js ./

# 4. 데이터 vercel 에 전송 안함
ENV NEXT_TELEMETRY_DISABLED=1

# 5. 포트 개방 및 실행 명령
EXPOSE 3000
CMD ["node", "run_server.js"]

🔸 .dockerignore 설정

  • 불필요한 파일을 빌드 context에서 제외하여 Docker 빌드 속도 향상
  • 기존에는 Dockerfile 안에서 빌드를 했기 때문에, .next 폴더가 .dockerignore 에 포함되어 있었지만, 다시 외부에서 빌드하고, Dockerfile 에서는 필요한 파일만 복사해 오는 형식으로 변경하므로, .dockerignore 에서 .next 폴더를 제외시켜야 docker context 를 로드하여 복사할 수 있다.
# Next.js 빌드/캐시 폴더
.next/
!/.next/standalone
!/.next/static

# 테스트 산출물
tests/
__tests__/
jest.config.js

node_modules
.git
.gitignore
Dockerfile
docker-compose.yml
README.md

# 2) 환경 변수
.env
.env.*

 

4. 빌드 로그로 개선 확인하기

🔹 S3 캐시 로딩

[Container] Downloading S3 cache...
Expanded cache path node_modules/**/*
Expanded cache path .next/cache/**/*

🔹 npm install 캐싱 적용 확인

[Container] Running command sh buildspec/pre_build.sh
up to date, audited 1724 packages in 12s

🔹 npm run build 캐싱 확인

  • .next/cache를 가져왔으나, 실제 빌드는 재실행됨
  • 캐시 활용 인식은 있으나, 파일 변경으로 전체 빌드가 실행되는 현상
DEV BUILD SETTING...
> next build
○  (Static) prerendered as static content
λ  (Dynamic) server-rendered on demand using Node.js

🔹 Docker 빌드 context 축소로 인한 성능 개선

  • Docker 빌드 context 크기 축소 (2.41GB → 155.82MB)
  • 전송 및 복사 속도 대폭 개선
[internal] load build context
#5 transferring context: 155.82MB 1.3s done

🔹 S3 캐시 업로드 확인

[Container] Uploading S3 cache...

 

5. 실제 소요 시간 비교

최초 vs 현재

단계 최초 현재
S3 캐시 다운로드 24s 15s
pre_build
(npm install / npm run build)
2m 51s 3m 12s
Docker context 로드 34.7s 1.6s
COPY 소스 파일 67.5s 2s
Docker 이미지 내보내기 12.9s 0.6s
post_build.sh 1m 10s 5s
S3 캐시 업로드 1m 30s 1m 27s
전체 평균 소요시간 8m 27s 6m 57s

 

동일 환경 추가 비교 (빠른 빌드 vs 느린 빌드)

  • 일한 빌드 세팅에서 간혹 8분 이상 걸리는 빌드가 존재함
    • 어떤 구간에서 오래걸리는지 오랜빌드 vs 짧은빌드 단계별 소요 시간 비교
단계 빠른 빌드 (5m 10s) 느린 빌드 (8m 10s)
S3 캐시 다운로드 17s 24s
pre_build
(npm install / npm run build)
3m 10s 3m 26s
Docker context 로드 3s 5s
npm ci CACHED 35s
COPY 소스 파일 1.5s 1.5s
Docker 이미지 내보내기 0.6s 6.9s
post_build.sh 4s 40s
S3 캐시 업로드 1m 17s 1m 39s
  • 네트워크 성능과 npm 패키지의 캐싱 여부 등 세부적인 차이로 인해 빌드 시간에 편차가 발생했습니다.
  • 프로세스와 연관지어보았을 때는 npm ci 가 캐시된 것과, 도커 이미지 파일이 캐싱된 점이 차이
  • 패키지의 설치는 없었으나, 이런한 차이가 보임.
⚠️ 주의: 이 포스팅을 포함한 총 3편의 Next.js 배포 최적화 시리즈는 제가 실제 환경에서 시행착오를 겪으며 얻은 경험과 지식을 정리한 글입니다.

이 글들은 완벽한 정답이 아니라, 다양한 시도와 시행착오를 통한 저의 삽질기이자 성장 과정이며, 동시에 나와 비슷한 고민을 하는 사람들과 지식을 나누고자 하는 목적으로 작성되었습니다.

따라서, 가능하면 3편의 글을 모두 읽고, 환경과 상황에 맞춰 신중히 테스트한 후 적용하는 것을 추천합니다.

마지막까지 읽으시면 무엇이 문제였는지, 그리고 어떻게 해결했는지 확인할 수 있습니다! 🚨🚧✨

 

📋 총 3편의 글 리스트 

https://tacit.tistory.com/267

 

Next.js + AWS ECS 컨테이너 배포하기 : CodePipeline 활용한 CI/CD 자동화와 Docker 빌드 최적화 사례 분석 [1

⚠️ 주의: 이 포스팅을 포함한 총 3편의 Next.js 배포 최적화 시리즈는 제가 실제 환경에서 시행착오를 겪으며 얻은 경험과 지식을 정리한 글입니다.이 글들은 완벽한 정답이 아니라, 다양한 시도

tacit.tistory.com

https://tacit.tistory.com/269

 

Next.js + AWS ECS 컨테이너 배포하기 : CodePipeline 활용한 CI/CD 자동화와 Docker 빌드 최적화 사례 분석 [2

⚠️ 주의: 이 포스팅을 포함한 총 3편의 Next.js 배포 최적화 시리즈는 제가 실제 환경에서 시행착오를 겪으며 얻은 경험과 지식을 정리한 글입니다.이 글들은 완벽한 정답이 아니라, 다양한 시도

tacit.tistory.com

https://tacit.tistory.com/271

 

Next.js + AWS ECS 컨테이너 배포하기 : CodePipeline 활용한 CI/CD 자동화와 Docker 빌드 최적화 사례 분석 [3

1. 원인 분석캐시 이미지 생성·업로드·다운로드 과정에서 소요되는 시간 때문에 Docker 레이어 캐싱 전략이 오히려 비효율적이었습니다.npm ci 명령어의 캐싱 불확실성으로 인해 Docker 레이어 캐싱

tacit.tistory.com

 


🚀 Next.js AWS ECS 배포 프로세스 개선하기

1. 현재 빌드 및 배포 프로세스 개요

Next.js 애플리케이션을 AWS ECS에 컨테이너로 배포.

CI/CD 파이프라인은 AWS CodeBuild를 활용하여 아래와 같은 작업을 수행.

  • Docker 이미지 빌드
  • Amazon ECR 업로드
  • ECS에서 컨테이너 실행

📁 빌드 관련 파일 및 폴더 구조

빌드 및 배포를 위한 스크립트와 설정 파일은 buildspec 디렉토리에서 관리.

개선 작업시 아래 파일들에서 변경이 발생.

buildspec/
├── buildspec-build.yml   # AWS CodeBuild 빌드 단계 정의
├── Dockerfile            # Docker 이미지 빌드를 위한 정의
├── pre_build.sh          # 빌드 전 환경 설정 스크립트
└── post_build.sh         # ECR 푸시 스크립트

.dockerignore
next.config.js

 

2. 📌 빌드 및 배포 프로세스 단계

전체 과정은 buildspec-build.yml 파일에서 명시된다.

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 20
    run-as: root
    commands:
      - update-ca-trust
      - node -v

  pre_build:
    commands:
      - echo Logging in to Amazon ECR...
      - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
      - IMAGE_TAG="v-$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | head -c 8)"
      - curl -L https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64 -o /usr/local/bin/jq
      - chmod a+x /usr/local/bin/jq
      - chmod +x locale.sh buildspec/pre_build.sh
      - DOCKERFILE_PATH=buildspec/Dockerfile

  build:
    commands:
      - sh locale.sh
      - sh buildspec/pre_build.sh
      - docker build -f $DOCKERFILE_PATH -t $IMAGE_REPO_NAME:$IMAGE_TAG .

  post_build:
    commands:
      - chmod +x buildspec/post_build.sh
      - sh buildspec/post_build.sh

cache:
  paths:
    - "node_modules/**/*"

artifacts:
  files:
    - imagedefinitions.json

 

1) install 단계

  • Node.js 런타임(Node v20) 및 시스템 인증서 업데이트

2) pre_build 단계

  • Amazon ECR 로그인
  • Git 해시를 기반으로 Docker 이미지 태그 생성
  • jq 및 스크립트 권한 설정

3) build 단계

  • 환경 설정 스크립트 실행
  • Docker 이미지 빌드

4) post_build 단계

  • Docker 이미지를 ECR로 푸시
  • ECS 배포용 imagedefinitions.json 생성

 

3. 개선 내용

1) pre_build.sh 파일의 변경점 및 개선 효과

역할

  • 환경 변수 설정(dev, prod 구분)

Before/After

Before

npm install
npm run build

 

After

# npm install 및 npm run build 제거

📌 무엇이 달라졌는가?

작업 Before (pre_build.sh) After (Dockerfile)
npm install ✅ 포함됨 ✅  Dockerfile에서 처리
npm run build ✅ 포함됨 ✅   Dockerfile에서 처리

📌 변경 이유

  • 📍 책임 분리 명확화
    • 환경 설정(pre_build.sh), 이미지 빌드(Dockerfile)
  • 📍 Docker 캐시 활용으로 빌드 속도 향상

2) Dockerfile의 변경점 및 개선 효과

역할

  • 소스 코드를 컨테이너 이미지로 빌드

Before/After 비교

Before

FROM node:20.5.1-alpine3.18
WORKDIR application
COPY . ./
EXPOSE 3000
CMD ["node", "run_server.js"]

 

After (멀티스테이지 빌드)

FROM node:20.5.1-alpine3.18 AS builder
WORKDIR /application
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20.5.1-alpine3.18
WORKDIR /application
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /application/.next/standalone ./
COPY --from=builder /application/.next/static ./.next/static
COPY --from=builder /application/next.config.js ./
COPY --from=builder /application/run_server.js ./
ENV NEXT_TELEMETRY_DISABLED=1
EXPOSE 3000
CMD ["node", "run_server.js"]

📌 주요 변경사항

  • 멀티스테이지 빌드 적용
  • 개발 및 운영 의존성 분리 설치

📌 변경 이유 및 장점

  • 🎯 이미지 크기 감소로 배포 효율성 향상
  • 🎯 Docker 캐시 최적화로 빌드 속도 단축

3) next.config.js 에 "standalone"

  • Next.js의 standalone 설정 활용
  • 서비스에 필요한 파일만 결과물에 포함
output : standalone

4) .dockerignore 추가 효과

  • 불필요한 파일 제외 → 빌드 속도 향상
node_modules
.next
.git
.gitignore
Dockerfile
docker-compose.yml
README.md

 

4. 📈 CodePipeline 빌드 로그로 개선 효과 확인

  • 빌드 시간: 기존 7~8분 -> 4~5분으로 단축**
  • npm 패키지 변경 시에는 캐시가 무효화(no-cache)되어 다시 7~8분 소요


🚨 하지만, 모든 게 해결된 줄 알았는데...?

드디어 빌드 시간 7분대에서 4분대로 단축하며 🎉 완벽히 성공했다고 자축하던 순간,
예상치 못한 충격적인 문제를 발견했다! ⚠️

  • "캐싱이 성공했는데, 왜 다시 느려진 거지...?"
  • 동일한 빌드 인스턴스 내에서만 적용되는 제한적 캐싱이라는 함정 🪤
  • 성공했던 빌드가 다른 환경에서는 다시 원점(7분대)으로 돌아가는 현상 발생 😱

 

 

과연, 무엇이 잘못된 걸까? 그리고 어떻게 해결할 수 있을까? 🤔

다음 포스팅에서 CI/CD 환경의 캐싱의 숨겨진 진실을 파헤치고,
지속가능한 캐시 최적화 전략을 완성합니다. 🛠️🔥

 

이어서...

https://tacit.tistory.com/269

 

Next.js + AWS ECS 컨테이너 배포하기 : CodePipeline 활용한 CI/CD 자동화와 Docker 빌드 최적화 사례 분석 [2

⚠️ 주의: 이 포스팅을 포함한 총 3편의 Next.js 배포 최적화 시리즈는 제가 실제 환경에서 시행착오를 겪으며 얻은 경험과 지식을 정리한 글입니다.이 글들은 완벽한 정답이 아니라, 다양한 시도

tacit.tistory.com

 

부제: 브랜치 간 머지베이스 불일치로 인해 발생하는 중복 Merge 현상


🚩 발생한 문제

현재 메인으로 사용하는 브랜치는 두 가지입니다.

  • master: production 환경을 위한 소스
  • dev: dev 환경을 위한 소스

다음과 같은 순서로 작업을 진행하였습니다.

  1. feature/account-add 브랜치를 master에서 생성
  2. 작업 완료 후 commit (feature/int-script)
  3. feature/account-adddev 머지
  4. dev 환경 배포 후 정상 작동 확인
  5. release/0410 브랜치를 master에서 생성
  6. feature/account-addrelease/0410 머지 (master 배포 준비)
  7. release/0410master 머지 후 production 배포 정상 확인
  8. 새 작업을 위해 master에서 feature/address-edit 브랜치 생성
  9. 작업 완료 후 commit (feature/address-edit)
  10. feature/address-editdev 머지를 위한 PR 생성 시 이미 dev에 반영된 feature/account-add 변경내역이 Bitbucket PR 화면에서 함께 나타나는 문제 발생

 

즉, 예상되는 PR 변경점은 feature/address-edit 내용만 보여야 하는데, 이전에 이미 병합된 feature/account-add의 내용까지 PR에 포함된 것처럼 보였습니다.


🧑‍💻 원인 분석 및 설명

이 문제는 Git의 브랜치 간 merge-base 불일치 때문에 발생합니다.

  • feature/account-add 브랜치를 dev와 master 각각에 별도로 머지하면서, 같은 변경점이 서로 다른 커밋(SHA) 으로 생성되었습니다.
  • 이후 master에서 생성한 새 브랜치(feature/address-edit)를 dev에 머지할 때, Git은 두 브랜치 간의 공통 조상(commit) 을 기준으로 diff를 계산합니다.
  • 이때 dev가 이미 포함한 변경 내용임에도 master 브랜치에서의 변경점 SHA가 다르기 때문에, Git은 dev가 이미 가진 변경점을 "새로운 변경점"으로 간주하여 PR에 표시한 것입니다.

이를 시각적으로 표현하면 다음과 같습니다.

A───D1───dev
 \        ↑
  C1      | feature/account-add 내용 (SHA A)
   \
    \         (release/0410 경로)
     C1'──B───master──A1──feature/address-edit
            ↑
            | feature/account-add 내용 (SHA B)

여기서 dev는 SHA A의 변경점을, master는 SHA B의 변경점을 각각 가지고 있습니다. Git의 diff 계산 기준인 merge-base가 C1 이전의 커밋이므로 중복 변경이 발생한 것입니다.


✅ 해결 방법

이 문제를 해결하는 방법은 다음과 같이 크게 세 가지가 있습니다.

방법 1: dev 브랜치를 master로 최신화 (권장/선택)

git checkout dev
git merge --no-ff master  # 또는 rebase
git push origin dev
  • 장점: 한 번의 작업으로 이후 PR이 깔끔해집니다.
  • 단점: dev의 히스토리가 길어질 수 있습니다.
  • 선택한 방법은 배포할때 사용한 release 브랜치를 배포시 master 에도 merge 함과 동시에, dev 에도 merge 하는 방식으로 진행

방법 2: feature 브랜치를 dev 기준으로 rebase

git checkout feature/address-edit
git rebase dev  # 충돌 시 해결
git push -f origin feature/address-edit
  • 장점: PR이 간결해집니다.
  • 단점: force push가 필요합니다.

방법 3: dev에서 새 브랜치를 만들어 cherry-pick

git checkout dev
git checkout -b address-edit-v2
git cherry-pick <커밋SHA>...
git push origin address-edit-v2
  • 장점: force push가 필요 없고 가장 안전한 방법입니다.
  • 단점: 새 브랜치를 추가로 만들어야 합니다.

🚧 예방 방법

다음과 같은 방법으로 유사한 문제를 미리 방지할 수 있습니다.

  • 정기적으로 master 변경점을 dev에 반영합니다.
    • release 후 즉시 master의 변경사항을 dev 브랜치로 머지하거나 fast-forward 합니다.
  • CI/CD 파이프라인에서 merge-base 일관성을 체크합니다.
    • PR 생성 시 merge-base를 체크하는 스크립트를 추가하여 예방합니다.

이러한 현상을 이해하고 관리하면 더욱 명확하고 깔끔한 Git 브랜치 전략을 유지할 수 있을 것입니다.

1. 슬랙 앱 생성 및 설정

 

링크 접속: https://api.slack.com/apps

 

Slack API: Applications | Slack

Your Apps Don't see an app you're looking for? Sign in to another workspace.

api.slack.com

 

2. Create New App

- From scratch 선택

- App name : Front Release Notification App

- 슬랙 워크스페이스 선택

 

 

3. Incoming Webhooks 탭 이동

- On 활성화 처리

 

 

4. 화면 하단의 Add New Webhooks to Workspace 

- 게시할 채널 선택 : 알림 받을 채널을 선택

- 생성된 Webhook Url 복사

 

 

5. Github Repository 설정 - Secrets and Variables - Actions 로 접속

- New repository secert 클릭

- SLACK_WEBHOOK_URL

- 아까 복사한 웹훅 url 기입

 

 

6. github action yml 파일에 액션 추가

- 배포 완료시 슬랙으로 알림을 보내라는 action 을 추가

 

- name: Notify Slack on Success
        if: success()
        id: slack-success
        uses: slackapi/slack-github-action@v1.24.0
        with:
          payload: |
            {
              "channel": "슬랙 채널 ID",
              "attachments": [
                {
                  "color": "#36a64f",
                  "title": "✅ GitHub Action 성공",
                  "title_link": "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}",
                  "fields": [
                    {
                      "title": "Repository",
                      "value": "${{ github.repository }}",
                      "short": true
                    },
                    {
                      "title": "Tag",
                      "value": "${{ github.ref_name }}",
                      "short": true
                    }
                  ]      
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

      - name: Notify Slack on Failure
        if: failure() # 이 step은 job이 실패한 경우에만 실행됩니다.
        id: slack-failure
        uses: slackapi/slack-github-action@v1.24.0
        with:
          payload: |
            {
              "channel": "슬랙 채널 ID",
              "attachments": [
                {
                  "color": "#ff0000",
                  "title": "‼️ GitHub Action 실패",
                  "title_link": "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}",
                  "fields": [
                    {
                      "title": "Repository",
                      "value": "${{ github.repository }}",
                      "short": true
                    },
                    {
                      "title": "Tag",
                      "value": "${{ github.ref_name }}",
                      "short": true
                    }
                  ]      
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

 

 

 

 

참고 : https://github.com/nextauthjs/next-auth/issues/9504#issuecomment-2326123445

 

 

[BEFORE]

layout.tsx 

import Footer from "@/components/footer";
import Header from "@/components/header";
import ReactQueryProvider from "@/components/provider/ReactQueryProvider";
import type { Metadata } from "next";
import { SessionProvider } from "next-auth/react";
import localFont from "next/font/local";
import { ToastContainer } from "react-toastify";
import "./globals.scss";

import "react-toastify/dist/ReactToastify.css";

const pretendard = localFont({
  src: "./fonts/PretendardVariable.woff2",
  display: "swap",
  weight: "45 920",
  variable: "--font-pretendard",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="kr">
      <body className={`${pretendard.variable} font-pretendard antialiased`}>
        <ReactQueryProvider>
          <SessionProvider>
            <div id="modal-root" />
            <ToastContainer />
            <Header />
            {children}
            <Footer />
          </SessionProvider>
        </ReactQueryProvider>
      </body>
    </html>
  );
}

 

 

 

[AFTER]

layout.tsx

import Footer from "@/components/footer";
import Header from "@/components/header";
import ReactQueryProvider from "@/components/provider/ReactQueryProvider";
import type { Metadata } from "next";
import localFont from "next/font/local";
import { ToastContainer } from "react-toastify";
import "./globals.scss";

import { auth } from "@/auth";
import AuthSessionProvider from "@/components/provider/AuthSessionProvider";
import "react-toastify/dist/ReactToastify.css";

const pretendard = localFont({
  src: "./fonts/PretendardVariable.woff2",
  display: "swap",
  weight: "45 920",
  variable: "--font-pretendard",
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const session = await auth();
  const sessionKey = new Date().valueOf();

  return (
    <html lang="kr">
      <body className={`${pretendard.variable} font-pretendard antialiased`}>
        <ReactQueryProvider>
          <AuthSessionProvider session={session} sessionKey={sessionKey}>
            <div id="modal-root" />
            <ToastContainer />
            <Header />
            {children}
            <Footer />
          </AuthSessionProvider>
        </ReactQueryProvider>
      </body>
    </html>
  );
}

 

provider/AuthSessionProvider 프로바이더 컴포넌트를 생성

// AuthSessionProvider.tsx
"use client";

import { SessionProvider } from "next-auth/react";
import { useMemo } from "react";

export default function AuthSessionProvider({ children, ...props }: any) {
  const { session, sessionKey } = props;
  const memoizedSessionKey = useMemo(() => {
    return sessionKey;
  }, [session]);

  return (
    <SessionProvider key={memoizedSessionKey} session={session}>
      {children}
    </SessionProvider>
  );
}

 

 

signIn 성공 후 메인 페이지로 리다이렉트 되었을때, 

클라이언트 컴포넌트에서 useSession() 으로 호출하는 session 데이터나,

서버 컴포넌트에서 auth() / getSession() 등으로 호출하는 session 데이터 모두 바로 authorized 상태와 유저 정보가 업데이트 된다

USECASE : 대만 ECPAY 편의점 픽업 지점 선택을 위한 Request the e-map of convenience store(B2C/C2C)

 

[ECPAY 개발문서]

 

문서에 명시되어 있다시피,

HTTPS Transfer Protocol

HTTP Method:POST
Accept:text/html
Content Type:application/x-www-form-urlencoded

 

form 형태로, POST 로 게다가 <html> 텍스트로 데이터를 요청/응답 받아야한다.

 

 

프론트에서 Request 파라미터를 말아서 form 으로 보내고, 

Request 파라미터 중 하나인 "ServerReplyURL" 키에 어떤 값을 담아 보내야 하는지가 고민이었다.

 

왜냐하면, ServerReplyURL 이 url 은, 편의점을 선택하는 ecpay.map 화면으로 url 이 이동 되었다가, 

유저가 편의점 선택을 완료하여 제출하고 나서

1. 다시 돌아올 mysite 페이지

2. POST 로 쏴주는 응답 데이터를 받을 엔드 포인트 

 

이 두가지 역할을 해야하기 때문이다.

 

 

하지만, Next.js 를 사용하고 있어서, 프론트에서도 POST 요청을 받을 수 있는 엔드포인트를 만들 수 있다.

 

1. app 밑에 api 라우트 파일 생성

/app/api/checkout/cvs-result/route.ts 

import { NextResponse } from "next/server";

// ECPAY 편의점 선택 결과 리턴 엔드 포인트
export async function POST(request: Request) {
  const url = new URL(request.url);

  const body = await request.text();
  return NextResponse.redirect(`https://mysite.com/checkout?${body}`);
}

 

 

2. 편의점 선택 컴포넌트 생성

/components/SelectConvenience.tsx

 

 

SelectConvenience 컴포넌트는, 편의점 종류를 버튼으로 제공하고,

특정 편의점 버튼을 눌러서 선택하면,

ecpay 에 지점 선택을 위한 map 페이지를 요청하는 컴포넌트 이다.

 

편의점 종류 선택지 버튼을 UI 로 제공하고,

form 을 숨겨서 submit 할 수 있도록 하였다.

 

ECPAY 로 보내는 request parameter 에

필수 파라미터 정보를 세팅하고,

ServerReplyUrl = "https://mysite.com/api/checkout/cvs-result" 로 뚫어 놓은 API 주소를 세팅한다

export default function SelectConvenience() {
  const [selectedConvenience, setSelectedConvenience] = useState(pickUpInfo?.selectedConvenience ?? "");

  const onSelectCvs = (cvsType: string) => {
    setSelectedConvenience(cvsType);
    setTimeout(() => {
      const formElement = document.getElementById("cvsForm") as HTMLFormElement;
      if (formElement) formElement?.submit();
    }, 0);
  };

  return (
    <>
      <div className="flex gap-[12px] mobile:flex-col">
        <button onClick={() => onSelectCvs("UNIMARTC2C")}>
          Seven Eleven
        </button>
        <button onClick={() => onSelectCvs("FAMIC2C")}>
          Family Mart
        </button>
      </div>

      <form
        action="https://logistics.ecpay.com.tw/Express/map"
        method="POST"
        encType="application/x-www-form-urlencoded"
        id="cvsForm"
      >
        <input type="hidden" name="MerchantID" value={process.env.NEXT_PUBLIC_MERCHANT_ID ?? ""} />
        <input type="hidden" name="LogisticsType" value="CVS" />
        <input type="hidden" name="LogisticsSubType" value={selectedConvenience} />
        <input type="hidden" name="IsCollection" value="N" />
        <input type="hidden" name="MerchantTradeNo" value='20241023094324' />
        <input
          type="hidden"
          name="ServerReplyURL"
          value={`https://mysite.com/api/checkout/cvs-result`}
        />
      </form>
    </>
  );
}

 

 

3. 요청 form을 전송

편의점 종류 버튼을 누르면, 숨겨져있던 form 이 제출되면서, mysite.com 이었던 화면이 ecpay map 화면으로 url 이 이동된다.

 

 

ecpay 가 제공하는 Map 사이트로 이동되어 유저는 특정 지점을 선택할 수 있다

 

 

4. ecpay 에서 제공하는 site 에서 유저가 편의점을 선택하고 완료를 누르면,

ecpay 는 ServerReplyURL 파라미터 값으로 받은 url 로 formData 형식으로 응답값을 POST 로 쏜다.

이로 인해서, 유저 브라우저 화면은 "https://mysite.com/api/checkout/cvs-result" 로 이동

하지만, 프론트에는 해당 페이지가 없음!

페이지는 없고, api 라우트만 존재!

 

화면상에는 페이지 없음이 뜨고, 

뚫어 놓은 api POST 라우트로 응답 데이터가 들어온다.

 

 

5. 이 데이터를 쿼리 파라미터로 말아서, 다시 원래 화면으로 리다이렉트 시킨다

1번에서 만들어 둔 route 에서 다음과 같이 redirect 를 시킨다

return NextResponse.redirect(`https://mysite.com/checkout?${body}`);

 

ECPAY 가 리턴 시킨 "https://mysite.com/api/checkout/cvs-result" 로는 잠시 이동했지만, 

바로 Next 의 /api/checkout/cvs-result 라우트에서 페이지를 리다이렉트 시키기 때문에,

육안으로 감지 할 수 없는, 페이지 이동이 발생한다

 

6. 유저 화면의 Url 이 변경된다!

 

 

 

Next 에서 api 라우트를 제공하면서, CORS 이슈로 백엔드 서버에서 일일히 API 를 따로 만들어 주어야 했던 것들을 
바로 프론트에서 진행 할 수 있게 된 것들이 많다.
서드파티 API 에서 응답을 POST 로 던져주더라도, 프론트로 바로 받을 수 이뜸!!!!!

목표 : 출발지 주소 ~ 목적지 주소 사이의 거리 구하기

 

이슈 : 클라이언트 사이드에서 fetch 요청을 하면, CORS 에러가 발생

클라이언트 사이드에서 fetch 요청을 하는 경우 참고 코드

 

How to Use Google Maps Distance Matrix API in React.js

React.js is a popular JavaScript library for building user interfaces, and integrating Google Maps services can add powerful location-based…

medium.com

 

 

 

원인 : distancematrix API 는 서버사이드에서 요청을 해야한다

반면에 geocode API 는 클라이언트 사이드에서 요청을 해도 잘만 나온다

 

 

Geocoding API 사용 코드

// Google Maps API를 사용하여 Reverse Geocoding을 통해 장소 이름 가져오기
  const response = await axios.get("https://maps.googleapis.com/maps/api/geocode/json", {
    params: {
      latlng: `${latitude},${longitude}`,
      key: `${process.env.NEXT_PUBLIC_GOOGLE_MAP_API_KEY}`,
    },
  });

 

 => 정상적인 응답을 잘 받아온다 

 

 

Distance Matrix API 사용 코드

const response = await axios.get("https://maps.googleapis.com/maps/api/distancematrix/json", {
      params: {
        origins: origin,
        destinations: destination,
        mode: "transit",
        key: `${process.env.NEXT_PUBLIC_GOOGLE_MAP_API_KEY}`,
      },
    });

 

=> CORS 에러 발생한다

 

 

관련 내용 참고

 

CORS error · Issue #59 · googlemaps/google-maps-services-js

CORS error with a distancematrix request. But geocoder request works fine. What am I missing? Fetch API cannot load https://maps.googleapis.com/maps/api/distancematrix/json?destinations=New%2…%20La...

github.com

 

따봉 많이 받으신 분의 코멘트 요약해보면, API 문서에 어디에도 서버사이드에서만 작동한다는 말이 없는데, distancematrix 는 서버사이드로 요청해야한다고 해서 트러블 슈팅에 시간을 많이 소요했다. 결론은 클라이언트 사이드에서 distance matrix 를 구하려면 Javascript API 를 사용해야한다.

 

 

해결방안 : Next.js 의 api/route 를 사용

브라우저에서 바로 요청하지 않고, Next 서버에서 요청 후 응답을 리턴하는 거로 변경

 

1. /app/api/googlemaps/route.ts 파일생성

import { NextResponse } from "next/server";
import axios from "axios";

export async function GET(request: Request) {
  const url = new URL(request.url);
  const origins = url.searchParams.get("origins");
  const destinations = url.searchParams.get("destinations");

  try {
    const response = await axios.get("https://maps.googleapis.com/maps/api/distancematrix/json", {
      params: {
        origins: `${origins}`,
        destinations: `${destinations}`,
        mode: "transit",
        key: {YOUR_API_KEY},
      },
    });
    
    return NextResponse.json({ distance: response?.data?.rows[0]?.elements[0]?.distance?.text || "" });
  } catch (error) {
    console.error("Failed to fetch distance:", error);
    return NextResponse.json({ distance: "Unknwon distance" });
  }
}

 

2. 컴포넌트 단에서 Next 서버로 fetch 요청

const origins = "서울특별시 강남구 선릉로 551";
const destinations = store.address

const result = await fetch(`/api/googlemaps?origins=${origins}&destinations=${destinations}`);
const data = await result.json();

console.log('distance', data.distance)

 

CORS 해결...!

 

SSR 용 Fetch 인스턴스

 

구현 내용

  1. https://mypage.com/kr/ko" 와 같이, /[country]/[language] 정보를 url params 로 제공한다.
  2. 상품 디테일 페이지를 서버사이드 랜더링으로 제공한다.
  3. 상품 디테일 정보는 국가/언어 에 따라 달라진다.
  4. 국가/언어 정보를 HTTP 요청 헤더에 Country, Language 를 키값으로 전달한다.
  5. 상품 "좋아요"/"위시리스트" 등과 같은 유저 데이터를 제공하여야한다.
  6. 게스트는 쿠키의 "DJSESSION" 값 으로 유저 데이터를 제공한다 -> Cookie 를 헤더에 담아서 전송

 

코드

"use server";

import { cookies } from "next/headers";
import { getServerSession } from "next-auth";

import { CustomError } from "./customError";

interface FetchOptions extends RequestInit {
  headers?: HeadersInit;
}

const serverFetch = async (url: string, country: string, lang: string): Promise<Response | undefined> => {
  const headers: HeadersInit = {
    "Content-Type": "application/json",
    Country: country || "us",
    Language: lang || "en",
    Cookie: cookies().toString(), // DJSESSION SSR
  };

  const fetchOptions: FetchOptions = {
    headers,
    credentials: "include",
  };

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${url}`, fetchOptions);

    if (response.status === 500) {
      throw new CustomError(`Undefined Error ${response.statusText}`, response.status);
    }

    if (response.status >= 400) {
      const errorData = await response.json();
      const error = new CustomError(errorData.message, errorData.status);
      throw error; // catch 로 빠짐
    }

    return response;
  } catch (error) {
    // Promise 자체가 rejected (network error, CORS 등)
    throw error;
  }
};

export { serverFetch };

 

Page 코드

app/[country]/[lang]/item/[id]/page.tsx

import Pdp from "@/components/pages/Pdp";
import { getProductWithSSR } from "@/shared/queries/pdp";

interface Params {
  params: {
    country: string;
    lang: string;
    sku: string;
  };
}

export default async function DetailPage({ params }: Params) {
  const { country, lang, sku } = params;

  // SSR - Country / Lang 전달
  const productDetail = await getProductWithSSR({ sku, country, lang });

  return (
    <section>
      <Pdp productDetail={productDetail} />
    </section>
  );
}

 

Query 코드

export const getProductWithSSR = ({ sku, country, lang }: PropductProps) =>
  serverFetch(`/service/catalog/product/${sku}`, country, lang)
    .then((response) => response?.json())
    .then((data) => data.data);

 

Description

1. page 에서 params 를 통해서 country / language 정보를 얻고, 이를 getProductWithSSR 쿼리 함수에 전달한다

 

 

 

 

+ Recent posts