Next.js 프로젝트의 정적 파일 배포 환경 구성
온프레미스 환경에서 정적 파일(이미지, CSS 등)을 효율적으로 관리하고 배포하는 방법
안녕하세요! 오늘은 Next.js 프로젝트에서 정적 파일(이미지, CSS 등)을 효율적으로 관리하고 배포하는 방법에 대해 공유해보려고 합니다. 특히 개발 환경과 운영 환경을 분리하여 관리하는 방법과 CDN 캐시 관리에 대해 살펴보겠습니다.
들어가기 전에
현재 사람인은 대부분의 서비스를 온프레미스 환경에 배포하고 있습니다. 그렇다보니 소개해드리는 내용은 AWS나 VERCEL에 배포하는 것과는 차이가 있습니다.
이점 참고 부탁드립니다.
현재 상황과 문제점
현재 우리 서비스에서는 개발과 운영 환경을 구분하지 않고 www.saraminimage.co.kr 서버를 공동으로 사용하고 있습니다. 이로 인해 몇 가지 문제점이 발생했습니다:
1. 개발 이미지 관리의 어려움
- FTP를 통해 이미지를 업로드할 때, 수정이 필요한 경우:
- 변경된 이미지를 다시 업로드하면 Akamai에서 퍼지(Purge)가 필요
- 또는 파일명을 변경하여 업로드하면 기존 이미지가 가비지가 됨
- 개발 중인 리소스가 운영 서버에 남아있어 혼란 발생
2. 리소스 관리의 비효율성
- 운영 서버에 불필요한 개발 리소스가 적재됨
- 형상 관리가 어려워 이슈 트래킹이 불가능
- 브랜치별로 독립적인 정적 파일 관리가 불가능
- 개발 환경에서 테스트한 이미지가 운영에 영향을 미칠 수 있음
3. CDN 캐시 관리의 복잡성
- 모든 변경사항에 대해 전체 캐시를 무효화해야 하는 경우 발생
- 변경되지 않은 파일까지 Purge 대상이 되어 불필요한 네트워크 비용 발생
- 개발 환경과 운영 환경의 캐시가 섞여 예상치 못한 동작 발생
서버 구성
이러한 문제를 해결하기 위해 개발 환경과 운영 환경을 분리하고, CI/CD 파이프라인을 통해 정적 파일을 배포하는 새로운 구조를 설계했습니다:
목표
- 환경 분리: 개발과 운영 리소스가 완전히 분리되어 서로 영향을 주지 않음
- 브랜치별 독립 관리: 각 브랜치마다 고유한 경로를 사용하여 병렬 개발 가능
- 자동화된 배포: CI/CD 파이프라인을 통해 자동 배포 및 관리
- 효율적인 캐시 관리: 변경된 파일만 선별적으로 Purge 가능
사용 방법
로컬 개발 환경
로컬 개발 시에는 기존과 동일하게 /public/static 디렉토리를 사용합니다:
1
2
/public/static/images # 이미지 파일
/public/static/css # CSS 파일
로컬 개발 환경에서는 NEXT_PUBLIC_IMAGE_URL이 /static으로 설정되어 있어, Next.js의 기본 정적 파일 서빙 기능을 그대로 사용할 수 있습니다.
환경변수 설정
각 환경별로 다른 이미지 URL을 설정합니다:
1
2
3
4
5
6
7
8
9
10
11
12
# .env.development.local (로컬 개발)
NEXT_PUBLIC_IMAGE_URL=/static
# .env.development (개발)
# __IMAGE_URL__ 는 CICD에서 https://dev.saraminimage.com/static/${SERVICE_NAME}/${CI_COMMIT_REF_SLUG} 로 치환
NEXT_PUBLIC_IMAGE_URL=__IMAGE_URL__
# .env.production (운영)
NEXT_PUBLIC_IMAGE_URL=https://static.saraminimage.co.kr/static/app
# 기존 saraminimage 서버 이미지 (레거시)
NEXT_PUBLIC_SARAMIN_IMAGE_URL=https://www.saraminimage.co.kr
환경변수 치환 로직
CI/CD 파이프라인에서는 환경에 따라 자동으로 환경변수를 치환합니다:
1
2
3
4
5
# .gitlab/ci/extends/build.gitlab-ci.yml
if grep -q "__IMAGE_URL__" ${CI_PROJECT_DIR}/${APP_DIR}/${SERVICE_NAME}/${ENVFILE_NAME}; then
sed -i "s|__IMAGE_URL__|${IMAGE_URL}|g" ${CI_PROJECT_DIR}/${APP_DIR}/${SERVICE_NAME}/${ENVFILE_NAME}
echo "✅ __IMAGE_URL__ 패턴을 찾아 NEXT_PUBLIC_IMAGE_URL 설정 완료"
fi
이를 통해 개발 환경에서는 브랜치별로 다른 URL이 자동으로 설정되며, 운영 환경에서는 고정된 URL을 사용합니다.
Next.js 설정
정적 파일을 효율적으로 관리하기 위해 Next.js 설정을 다음과 같이 구성했습니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// next.config.mjs
const nextConfig = {
images: {
loader: "custom",
loaderFile: "../../common/shared/helper/customImageLoader.ts",
remotePatterns: [
{
protocol: "https",
hostname: "**.saraminimage.co.kr",
port: "",
pathname: "/**",
},
{
protocol: "https",
hostname: "**.saraminbanner.co.kr",
port: "",
pathname: "/**",
},
{
protocol: "https",
hostname: "**.saramin.co.kr",
port: "",
pathname: "/**",
}
],
imageSizes: [96],
deviceSizes: [1920],
},
};
이미지 설정 상세 설명
- loader: “custom”: Next.js의 기본 이미지 로더 대신 커스텀 로더 사용
- loaderFile: 커스텀 이미지 로더 파일 경로 지정
- remotePatterns: 외부 이미지 도메인 허용 목록 (보안을 위해 명시적으로 지정)
- imageSizes: 작은 이미지 크기 목록 (96px)
- deviceSizes: 디바이스별 이미지 크기 목록 (1920px)
이미지 로더 생성
커스텀 이미지 로더는 두 가지 이미지 소스를 지원합니다:
- 기존 saraminimage 서버에 배포된 이미지 (
/sri/경로) - 새로운 static 서버로 배포될 이미지 (
/images/경로)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// common/shared/helper/customImageLoader.ts
interface ParamTypes {
src: string;
width: number;
quality?: number | string;
}
/**
* 이미지 로더
* - 기존 saraminimage 서버에 배포된 이미지
* - static saraminimage 서버로 배포될 이미지
* @param src 이미지 경로
* @param width 이미지 너비
* @param quality 이미지 품질
* @returns 이미지 경로
*/
export default function customImageLoader({
src,
width,
quality = "auto"
}: ParamTypes) {
// 기존 saraminimage 서버에 배포된 이미지
if (src.startsWith("/sri/")) {
return `${process.env.NEXT_PUBLIC_SARAMIN_IMAGE_URL}${src}?w=${width}&q=${quality}`;
}
// static saraminimage 서버로 배포될 이미지
if (src.startsWith("/images/")) {
return `${process.env.NEXT_PUBLIC_IMAGE_URL}${src}?w=${width}&q=${quality}`;
}
return null;
}
이미지 로더 동작 원리
- 경로 기반 분기: 이미지 경로의 prefix로 어느 서버를 사용할지 결정
- 동적 URL 생성: 환경변수를 사용하여 환경별로 다른 URL 생성
- 리사이징 파라미터:
width와quality파라미터를 쿼리스트링으로 추가 - 레거시 호환성: 기존
/sri/경로를 사용하는 이미지도 계속 지원
이미지 사용 예시
컴포넌트에서 이미지를 사용할 때는 다음과 같이 작성합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// apps/business/src/app/(main)/(sub)/_components/ContentCard.tsx
import Image from "next/image";
export default function ContentCard({ image: { name, width, height } }) {
return (
<Image
className="card__image"
alt="title"
src={`/images/${name}`}
width={width}
height={height}
quality={100}
/>
);
}
이미지 사용 시 주의사항
- 경로 규칙 준수: 새로 배포하는 이미지는
/images/경로를 사용 - width/height 지정: Next.js Image 최적화를 위해 명시적으로 크기 지정
- quality 설정: 용도에 따라 적절한 quality 값 설정 (기본값: “auto”)
- alt 텍스트: 접근성을 위해 항상 alt 속성 제공
CI/CD 파이프라인 구성
1. 환경변수 설정
CI/CD 파이프라인에서 환경별 이미지 URL을 설정합니다:
1
2
3
4
5
6
7
# .gitlab-ci.yml
variables:
# 개발 환경: 브랜치별 경로
IMAGE_URL: https://dev.saraminimage.com/static/${SERVICE_NAME}/${CI_COMMIT_REF_SLUG}
# 운영 환경: 고정 경로
IMAGE_URL: https://static.saraminimage.co.kr/static/${SERVICE_NAME}
환경별 변수 설정 로직
개발 환경에서는 브랜치명을 포함한 경로를 사용하고, 운영 환경에서는 고정된 경로를 사용합니다:
- 개발 환경:
/static/{서비스명}/{브랜치명}- 각 브랜치별로 독립적인 공간 - 운영 환경:
/static/{서비스명}- 서비스별로 통합된 공간
2. 정적 파일 배포
rsync를 사용하여 정적 파일을 배포합니다. rsync는 변경된 파일만 동기화하여 효율적인 배포를 가능하게 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# .gitlab/ci/extends/deploy.gitlab-ci.yml
.rsync-static: &rsync-static
# 디렉토리 권한을 755로, 파일 권한을 644로 설정
- find apps/${SERVICE_NAME}/public/static/ -type d -exec chmod 755 {} \;
- find apps/${SERVICE_NAME}/public/static/ -type f -exec chmod 644 {} \;
# 배포 전 디렉터리 생성
- ssh -i ~/.ssh/id_rsa ${SARAMIN_IMAGE_USER}@${IMAGE_HOST} "mkdir -p ${IMAGE_PATH}"
# 정적파일을 rsync로 배포
- >
rsync -rptgoDv
--delete-delay
--size-only
--stats
apps/${SERVICE_NAME}/public/static/
${IMAGE_HOST}::img_data/${IMAGE_PATH}
# 배포 후 확인
- ssh -i ~/.ssh/id_rsa ${SARAMIN_IMAGE_USER}@${IMAGE_HOST} "ls -lR ${IMAGE_PATH}"
rsync 옵션 설명
- -r: 재귀적으로 디렉토리 복사
- -p: 파일 권한 유지
- -t: 수정 시간 유지
- -g: 그룹 정보 유지
- -o: 소유자 정보 유지
- -D: 디바이스 파일과 특수 파일 유지
- -v: 상세 출력
- –delete-delay: 삭제할 파일을 나중에 삭제 (동기화 중 안전성 확보)
- –size-only: 파일 크기만 비교하여 변경 여부 판단 (빠른 동기화)
- –stats: 통계 정보 출력
SSH 키 설정
정적 파일 서버에 접근하기 위해 SSH 키를 설정합니다:
1
2
3
4
5
.sshkey-setting: &sshkey-setting
- mkdir -p ~/.ssh
- echo "${BUILD_SERVER_SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H ${IMAGE_HOST} >> ~/.ssh/known_hosts
3. CDN 캐시 관리
운영 환경에서는 변경된 파일에 대해 Akamai Purge를 실행합니다. 전체 캐시를 무효화하는 대신, 변경된 파일만 선별적으로 Purge하여 효율성을 높입니다.
변경 파일 추출
Git을 사용하여 변경된 파일만 추출합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#!/bin/bash
# .scripts/extract_changed_files.sh
# 필요한 환경변수 체크
if [ -z "$SERVICE_NAME" ]; then
echo "❌ Error: SERVICE_NAME is not set ❌"
exit 1
fi
# MR의 경우 target branch와의 차이, Push일 경우 커밋간 차이를 확인
# 수정된(Modify) 파일만 리스트업
if [ -n "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" ]; then
git diff --name-only --diff-filter=M "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...$CI_COMMIT_SHA" "apps/${SERVICE_NAME}/public/static/" \
| grep -v '^$' \
| sed "s#^apps/${SERVICE_NAME}/public/static/##" \
> rsync_changes.log
else
git diff --name-only --diff-filter=M "$CI_COMMIT_BEFORE_SHA" "$CI_COMMIT_SHA" "apps/${SERVICE_NAME}/public/static/" \
| grep -v '^$' \
| sed "s#^apps/${SERVICE_NAME}/public/static/##" \
> rsync_changes.log
fi
# 변경된 파일 유무를 체크하여 퍼지 진행
if [ -s rsync_changes.log ]; then
echo "✅ 변경된 파일에 대한 Akamai 캐시 삭제를 위해 목록을 생성합니다. ✅"
# 변경된 파일들의 URL 목록 생성
URLS_TO_PURGE=$(while read -r file; do
echo "\"https://static.saraminimage.co.kr/${IMAGE_PATH}/${file}\""
done < rsync_changes.log | tr '\n' ',' | sed 's/,$//')
# API body 형식으로 저장
API_BODY=$(cat <<EOF | tr -d '\n'
{
"objects": [${URLS_TO_PURGE}]
}
EOF
)
export API_BODY
echo "✅ 캐시 대상 목록 ✅"
echo "$API_BODY"
# 캐시 삭제 스크립트 실행
node ./.scripts/purge-cache.js
else
echo "✅ 변경된 파일이 없어 목록을 생성하지 않습니다. ✅"
exit 0
fi
Purge 스크립트
Akamai EdgeGrid API를 사용하여 캐시를 무효화합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// .scripts/purge-cache.js
var EdgeGrid = require("akamai-edgegrid");
// Supply the path to your .edgerc file and name
// of the section with authorization to the client
// you are calling (default section is 'default')
var eg = new EdgeGrid({
path: "/root/.edgerc",
section: "default",
});
eg.auth({
path: "/ccu/v3/invalidate/url/production",
method: "POST",
body: process.env.API_BODY,
}).send((error) => {
if (error) {
console.log("❌ Akamai에 Purge 요청이 실패했습니다 ❌");
return console.log(error);
}
console.log("✅ Akamai에 Purge 요청이 성공했습니다 ✅");
});
캐시 관리 전략
- 선택적 Purge: 변경된 파일만 Purge하여 불필요한 네트워크 비용 절감
- 자동화: CI/CD 파이프라인에서 자동으로 실행되어 수동 작업 불필요
- Git 기반 추적: Git diff를 사용하여 변경사항을 정확히 추적
- 에러 처리: Purge 실패 시 로그를 남겨 문제 파악 가능
4. 배포 프로세스 전체 흐름
실제 사용 사례
케이스 1: 브랜치별 독립 개발
새로운 feature 브랜치에서 이미지를 추가하고 테스트하는 경우:
feature/new-design브랜치에서 작업/public/static/images/new-button.webp추가- CI/CD가 자동으로
dev.saraminimage.com/static/app/feature-new-design/images/new-button.webp에 배포 - 다른 브랜치에 영향을 주지 않고 독립적으로 테스트 가능
케이스 2: 운영 환경 배포
메인 브랜치에 머지하면 운영 환경에 배포:
main브랜치에 머지- CI/CD가
static.saraminimage.co.kr/static/app/경로에 배포 - 변경된 파일만 자동으로 Purge
- 사용자는 즉시 최신 이미지 확인 가능
케이스 3: CSS 업데이트
CSS 파일이 변경된 경우:
- CSS 파일 수정 및 커밋
- 운영 환경 배포 시 커밋 해시가 버전으로 자동 설정
template-style.css?v=abc123def형식으로 링크 생성- 브라우저가 자동으로 새 파일을 다운로드
트러블슈팅
문제 1: 이미지가 로드되지 않음
원인: 환경변수가 제대로 설정되지 않았거나, 이미지 경로가 잘못됨
해결 방법:
- 브라우저 개발자 도구에서 네트워크 탭 확인
- 환경변수 값 확인:
console.log(process.env.NEXT_PUBLIC_IMAGE_URL) - 이미지 경로가
/images/또는/sri/로 시작하는지 확인
문제 2: CDN 캐시가 갱신되지 않음
원인: Purge가 실행되지 않았거나, 잘못된 파일 경로로 Purge 요청
해결 방법:
- CI/CD 로그에서 Purge 실행 여부 확인
rsync_changes.log파일 내용 확인- Akamai 대시보드에서 Purge 요청 상태 확인
문제 3: rsync 배포 실패
원인: SSH 키 권한 문제 또는 네트워크 연결 문제
해결 방법:
- SSH 키 권한 확인:
chmod 600 ~/.ssh/id_rsa - SSH 연결 테스트:
ssh -i ~/.ssh/id_rsa ${SARAMIN_IMAGE_USER}@${IMAGE_HOST} - rsync dry-run으로 테스트:
rsync --dry-run ...
마무리
이번 포스트에서는 Next.js 프로젝트의 정적 파일을 효율적으로 관리하고 배포하는 방법을 살펴보았습니다. 개발 환경과 운영 환경을 분리하고, CI/CD 파이프라인을 통해 자동화된 배포를 구현함으로써 다음과 같은 이점을 얻을 수 있습니다:
- 개발 리소스와 운영 리소스의 명확한 분리: 환경별 독립적인 관리
- 자동화된 배포 프로세스: 수동 작업 최소화 및 오류 감소
- 효율적인 CDN 캐시 관리: 변경된 파일만 선별적으로 Purge
- 브랜치별 독립 개발: 병렬 개발 환경 지원
- 버전 관리 및 추적: Git을 통한 형상 관리
이러한 구조를 통해 개발 생산성을 높이고, 운영 안정성을 확보할 수 있습니다. 감사합니다!

