고민의 시작
프론트 팀원이 다음과 같은 이슈를 들고 왔습니다.
"LightHouse를 통해 성능 검사를 해봤는데 웹 페이지 리소스가 9.7MB가 나왔어요"
"로컬에서 build하면 2.7MB인데 차이가 꽤 나네요. 로딩 속도도 더 느린것 같구요"
LCP(Largest Contentful Paint) 및 페이지 성능 문제
LCP란?
LCP는 웹 페이지의 주요 콘텐츠(텍스트, 이미지, 비디오 등)가 사용자 화면에 렌더링되는 시간을 측정하는 지표로, 로딩 성능의 핵심 지표 중 하나를 이야기합니다.
우리의 문제점
Lighthouse로 측정한 결과, 우리의 LCP는 10.99초로 평가되었으며 이는 POOR 등급에 해당했고 개선이 필요해보였습니다.
LCP에 영향을 미치는 주요 요소
구글에 의하면 LCP 지표에 영향을 미치는 주요 요소는 다음과 같습니다.
- <img> 요소의 로드 시간
- CSS 배경 이미지 로드
- 텍스트 노드 렌더링 지연
- 비효율적인 정적 파일 서빙 방식
프론트 분들과 논의한 결과, 이들 중 다수가 정적 파일 처리와 밀접하게 관련이 있어 build 최적화가 제대로 이루어지지 않은 것이라고 판단하였습니다.
프론트 팀원이 build 전과 후를 시각화해서 보여줬는데 9.7MB -> 2.7MB로 줄어드는 것을 볼 수 있었습니다.
왜 build가 안되고 있었을까?
COPY package*.json ./
# 의존성 설치
RUN npm install
# 애플리케이션 소스 복사
COPY . .
# 빌드 실행
RUN npm run build
우리의 기존 Front의 Dockerfile을 살펴보면 마지막에 build를 진행하고 있어 build된 상태로 도커 컨테이너가 생성될 줄 알았습니다.
version: "3.8"
services:
front:
build:
context: ./RoadMapKU
dockerfile: Dockerfile
ports:
- "3000:3000" # React 개발 서버의 기본 포트 (필요시 수정)
volumes:
- /app/build
networks:
- nginx-network
command: ["npm", "start"]
networks:
nginx-network:
external: true
하지만 docker-compose.yml을 살펴보면, command: ["npm", "start"]로 실행하도록 설정되어 있습니다.
npm start는 빌드된 결과물을 사용하는 것이 아니라, 개발 서버(예: react-scripts start)를 실행하는 명령입니다.
Spring을 배포할 때처럼 빌드된 .jar 파일을 컨테이너 내부로 복사하고 실행하는 방식으로 React를 처리할 수 있다고 생각했던 것이 문제였습니다.
React는 컴파일러가 development 모드일 때 최적화를 수행하지 않으므로, 이를 배포 환경에서 실행하려면 빌드 파일을 배포하는 과정이 필요합니다.
Nginx를 통한 문제 해결 방법
왜 Nginx인가?
Nginx는 고성능 웹 서버이자 리버스 프록시로 자주 사용됩니다.
특히 웹 서버로서 정적 파일 서빙을 할 수 있습니다. 아래와 같은 이유로 이번 프로젝트에서 가장 적합한 선택이라고 생각했습니다.
정적 파일 서빙의 강점
Nginx는 HTML, CSS, JS, 이미지와 같은 정적 파일을 빠르게 제공하는 데 최적화되어 있습니다.
• 비동기 이벤트 기반 구조로 요청 처리 속도가 빠름.
• 캐싱 및 압축 설정을 통해 추가적인 성능 향상이 가능.
• 낮은 메모리 사용량으로 서버 자원 효율성 극대화.
기존 환경과의 호환성
이미 Nginx를 리버스 프록시 및 SSL/TLS 처리기로 사용 중이었기에, 기존 설정에 정적 파일 서빙 기능만 추가하면 별도의 설정이나 구조 변경 없이 성능 개선이 가능했습니다.
기존 Nginx 설정
현재 Nginx는 다음과 같이 설정되어 있었습니다.
- HTTP 요청을 HTTPS로 리다이렉트
- 프론트엔드(React)와 백엔드(Spring Boot) 서버로 리버스 프록시
Nginx conf 파일
(아래의 설정의 경로나 컨테이너 이름, 도메인 이름은 모두 예시로 바꾸어 실제 KUMAP 운영 설정과는 차이가 있습니다)
# 80번 포트에서 443번 포트로 리다이렉트
server {
listen 80;
server_name example.com; # 서버의 도메인
# HTTP 요청을 HTTPS로 리다이렉트
return 301 https://$host$request_uri;
}
# 443번 포트에서 백엔드 서버로 프록시
server {
listen 443 ssl;
server_name example.com; # 서버의 도메인
# SSL 인증서 설정
ssl_certificate /etc/nginx/ssl/fullchain.pem; # 인증서 경로 (예시)
ssl_certificate_key /etc/nginx/ssl/private.key; # 개인 키 경로 (예시)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 프록시 설정
location / {
proxy_pass http://frontend; # 내부 컨테이너 URL (예시)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://backend; # 내부 컨테이너 URL (예시)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
이제는 더이상 React를 도커 컨테이너로 띄울 필요가 없기 때문에 아래와 같이 변경하였습니다.
# 80번 포트에서 443번 포트로 리다이렉트
server {
listen 80;
server_name example.com; # 서버의 도메인 (익명화)
# HTTP 요청을 HTTPS로 리다이렉트
return 301 https://$host$request_uri;
}
# 443번 포트에서 정적 파일 서빙 및 백엔드 서버로 프록시
server {
listen 443 ssl;
server_name example.com; # 서버의 도메인 (익명화)
# SSL 인증서 설정
ssl_certificate /etc/nginx/ssl/fullchain.pem; # 인증서 파일 경로 (예시)
ssl_certificate_key /etc/nginx/ssl/private.key; # 개인 키 파일 경로 (예시)
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 정적 파일 서빙
location / {
root /app/build; # React 빌드 폴더
try_files $uri $uri/ /index.html; # React 라우팅을 위한 Fallback
expires 1d; # 캐시 만료 시간 설정 (1일)
add_header Cache-Control "public, max-age=86400"; # 캐시 제어 헤더
}
# API 요청 처리 (백엔드 프록시)
location /api/ {
proxy_pass http://backend-service:8080; # 내부 백엔드 서비스 주소 (익명화)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
이제 443포트로 들어온 기본 엔드포인트의 요청을 3000포트로 리버스 프록시를 해주는 것이 아닌 nginx 안의 React 빌드 폴더로 연결을 해줍니다.
Nginx의 docker-compose.yml 설정
Nginx 컨테이너 volume에 SSL 인증서를 설정해주는 것에 추가로 React의 build 파일도 설정해주었습니다.
version: '3.8'
services:
nginx:
image: nginx
container_name: nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./conf/nginx.conf:/etc/nginx/nginx.conf
- ./conf/default.conf:/etc/nginx/conf.d/default.conf
- ./ssl/example_com_crt.pem:/etc/nginx/ssl/fullchain.pem:ro # SSL 인증서 (예시)
- ./ssl/example_com_key.pem:/etc/nginx/ssl/private.key:ro # SSL 개인 키 (예시)
- ./build:/app/build:ro # React 빌드 폴더를 Nginx에 마운트 (경로 예시)
- /etc/localtime:/etc/localtime:ro # 호스트의 타임존 공유
environment:
- TZ=Asia/Seoul
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
networks:
- nginx-network
networks:
nginx-network:
external: true
성능 개선 결과
로딩 속도 개선
LCP 가 2.8s 에서 1.9s로 줄어들었습니다.
Total Blocking Time도 700ms 에서 60ms로 약 91.43% 개선되었습니다. 🚀
메모리 최적화
total used free shared buff/cache available
Mem: 65339388 7145460 455256 870832 57738672 56599404
Swap: 21463036 8704 21454332
개선 전 서버 메모리 사용 현황
total used free shared buff/cache available
Mem: 65339388 6144456 1698760 870764 57496172 57600480
Swap: 21463036 8704 21454332
개선 후 React 컨테이너를 삭제한 후 메모리 사용 현황
최종적으로 약 978 MB의 메모리가 절약되었습니다. 🎉
결론 및 느낀 점
이번 작업을 통해 Nginx를 활용한 정적 파일 서빙의 효율성을 체감할 수 있었습니다.
로딩 속도와 메모리 사용량 모두 개선되었으며, 기존 이미지 로딩 시 버벅이던 사용자 경험이 긍정적으로 변화된 것 같습니다.
KUMAP 살펴보기!
참고 자료
• Largest Contentful Paint (LCP) - Web.dev