요약
기존 구글 번역 기반의 느리고 문단이 깨지는 방식을 next-intl 도입으로 렌더링 평균 2.34초 → 0.71초로 70% 개선한 적용하였다.
문제
인턴으로 입사한 직후, 대표님으로부터 받은 첫 업무는 서비스 내 영문 페이지의 성능 개선이었다. 기존에는 별도의 영문 페이지가 존재하지 않았고, 사용자가 전환 버튼을 누르면 국문 페이지에 구글 번역 스크립트를 삽입하여 클라이언트에서 전체 번역을 수행하는 방식이었다.
이 방식은 국문 페이지가 먼저 렌더링된 뒤, 번역 스크립트가 로드되고 번역 요청이 수행된 후에야 화면이 바뀌는 구조라, 사용자 입장에서는 약 2초간 한글 화면을 계속 보게 되어 이질감을 느낄 수 있었다. 또한 번역 도중 줄바꿈이 깨지거나, 마우스 호버 시 구글 번역 UI가 노출되는 등의 문제가 있었다.
성능 개선을 위한 기준 수립을 위해, localhost 환경에서 가장 단순한 페이지(이미지 1개, DB 요청 없음)를 선정하여 테스트를 진행했다. HTTPS 환경에서는 SSL/TLS 핸드셰이크에 의한 초기 지연이 발생하므로, 변경 전후를 모두 로컬에서 테스트했다.
이후 캐시 비우기 및 강력 새로고침을 10회 반복하였고, MutationObserver를 활용해 텍스트가 마지막으로 변경된 시점을 1초 디바운스로 측정하여 평균 렌더링 시간을 구했다.
'use client'
import { useEffect } from 'react'
const GAP = 1000 // 디바운스 기준 시간 (ms)
export default function TranslateObserver() {
useEffect(() => {
const target = document.getElementById('observe-target') // 감지할 최상위 DOM 요소
if (!target) return
let finalTimer: NodeJS.Timeout | null = null
// 텍스트 변화 감지 → 변화 이후 GAP 동안 추가 변화가 없으면 최종 반영 시점으로 판단
const observer = new MutationObserver(() => {
if (finalTimer) clearTimeout(finalTimer)
finalTimer = setTimeout(() => {
const timestamp = performance.now() - GAP // GAP만큼 보정한 최종 반영 시각
console.log(`최종 텍스트 반영 시점: ${timestamp.toFixed(2)}ms`)
observer.disconnect()
}, GAP)
})
// target 이하 모든 자식 노드의 텍스트/구조 변경 감지
observer.observe(target, {
subtree: true,
characterData: true,
childList: true,
})
console.log('번역 감지 시작')
return () => {
if (finalTimer) clearTimeout(finalTimer)
observer.disconnect()
}
}, [])
return null
}
// layout.tsx
// ...
export default async function RootLayout({ children, params }: RootLayoutProps) {
return (
<html lang={locale}>
<body>
<Header />
<main id={'observe-target'}>{children}</main>
<TranslateObserver /> // 타깃(observe-target)보다 아래에.
<Footer />
</body>
</html>
)
}
next.js 자체의 렌더링이 아니라 외부 번역 API로 DOM 값이 변경되고, 번역 시간도 길어서 디바운스 시간을 길게 주었다.
- 1840.00ms
- 1996.70ms
- 2127.30ms
- 2245.60ms
- 2882.30ms
- 2197.20ms
- 2531.90ms
- 2456.70ms
- 2568.90ms
- 2576.30ms
평균 : 2342.29ms (2.34초)
해결
next-intl 국제화 라이브러리를 도입하고 Accept-Language 기반 콘텐츠 협상으로 첫 접속 시 자동으로 해당 언어로 라우팅 되도록 했다.
국제화(i18n)/지역화(l10n)
국제화 (i18n, internationalization) : 소프트웨어를 다양한 언어와 지역에 맞게 쉽게 현지화할 수 있도록 구조화하는 과정이다. i18n인 이유는 i와 n 사이에 18글자가 있기 때문. 쿠버네티스가 k8s라고 적는 이유와 동일하다.
지역화 (l10n, localization) : 국제화된 소프트웨어에 실제 언어, 날짜 형식, 통화, 문화적 요소 등을 적용하여 특정 지역 또는 언어권에 맞게 조정하는 과정이다.
콘텐츠 협상
콘텐츠 협상(Content Negotiation) : HTTP에서 같은 URI에 대해 가장 적합한 자원의 표현을 제공하는 메커니즘이다. 여기서 표현이란 송수신 가능한 자원의 형태로, 서버가 클라이언트에게 리소스를 전달할 때 하나의 자원을 다양한 형식(표현)으로 제공할 수 있다.
예를 들어, 클라이언트가 /items/1 이라는 자원을 요청할 때, Accept : application/json 을 요청 헤더로 함께 보내면 서버는 json 형식으로 응답할 수 있고, application/xml로 요청하면 xml로 응답할 수 있다.
콘텐츠 협상 관련 HTTP 헤더로는 아래와 같다.
- Accept : 선호하는 미디어 타입 (application/json, application/xml, text/html 등)
- Accept-Language : 선호 언어 (ko, en, ko-KR, 등)
- Accept-Charset: 선호하는 문자 인코딩(charset) (utf-8, iso-8859-1, 등)
- Accept-Encoding: 선호하는 콘텐츠 인코딩(압축 방식) (gzip, deflate, br 등)
q(Quality Value) : 콘텐츠 협상을 할 때는 선호도로 우선순위를 반영할 수 있다. 0~1 사이값으로 값이 클수록 우선순위가 높다. 생략 시 1이다. 각 값에 ; 를 붙인 후 q=<값>을 붙인다.
예시 : Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
- ko-KR: 한국어 (대한민국), 가장 선호 (q 생략 시 기본값 1.0)
- ko;q=0.9: 한국어 (국가 상관 없음)
- en-US;q=0.8: 영어 (미국)
- en;q=0.7: 영어 (국가 상관 없음)
<언어> 또는 <언어>-<국가> 로 locale을 지정할 수 있다.
라이브러리 채택
우선 Next.js 특화된 국제화 라이브러리를 물색했다. next-intl, next-i18next 로 일단 좁혀졌다.
라이브러리 선택 기준은 다음과 같았다.
- 러닝 커브가 낮을 것 (Next.js도 아직 익숙하지 않음)
- 참고할 수 있는 자료가 많을 것
- 지역화(l10n)까진 필요 없이 단순한 국제화 기능만 필요
- App Router 기반 프로젝트와 잘 호환될 것
결론적으로 next-intl을 택했다. 찾아보니 next-18next는 공식 깃허브 리드미에서도 앱 라우터는 i18next, react-i18next를 사용하라고 안내했다. 근데 Next.js에 i18next 얹혀서 넥스트에 맞게 또 설정해주는 것을 찾아보기에는 next-intl의 공식문서가 너무 잘 되어 있었다.
앱 라우터인지 페이지 라우터인지, i18n 라우팅하는지 아닌지에 따라 문서가 잘 나누어져 있고, 알려준대로 코드 복붙만 딱딱 해도 바로 작동했다. 또 번들 사이즈도 1.1kB에 주간 다운로드수도 약 55만에 꾸준히 성장하고 있어서 바로 채택했다. next-18next는 16.4kB, 42만 다운로드 수였다.

next-intl 예제
// /src/app/[locale]/page.tsx
import { useTranslations } from "next-intl";
import Button from "@/components/Button";
import Vertical from "@/components/Vertical";
export default function Home() {
const t = useTranslations("Home");
return (
<div style={{ textAlign: "center" }}>
<div>{t("hello")}</div>
<Button />
<Vertical />
</div>
);
}
// messages/ko.json
{
"Home": {
"hello": "안녕 세상아",
"vertical": "<br></br>세로"
}
}
// messages/en.json
{
"Home": {
"hello": "Hello World",
"vertical": "<br></br>vertical"
}
}
사용법도 간단했다. t 함수 불러와서 따로 각 언어별 json에 저장된 키를 가져오면 된다.
"use client";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
export default function Button() {
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const handleClick = () => {
const afterLocale = locale === "ko" ? "en" : "ko";
router.push(pathname, { locale: afterLocale });
};
return (
<button
onClick={handleClick}
className="rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
{locale === "ko" ? "영어로 보기" : "View in Korean"}
</button>
);
}
언어 전환 라우팅도 복잡한 설정 없이 그냥 옵션에 locale 넣어주면 알아서 해준다.
import { useTranslations } from "next-intl";
export default function Vertical() {
const t = useTranslations("Home");
return (
<div>
{t.rich("vertical", {
br: () => <br />,
})}
</div>
);
}
요구사항 중, 언어 별로 문단을 분리하거나 합치는 등 별도로 관리할 수 있으면 좋겠다고 하셨었는데, rich 라는 함수를 쓰면 각 언어 json 중 필요한 부분에 원하는 태그를 넣어두면 해당 태그를 변환시킬 수도 있었다. 이를 이용해서 영문 부분에 <br></br>을 넣어두어서 문단 분리를 할 수 있었다.
import { NextRequest } from "next/server";
const locales = ["en", "ko"];
const defaultLocale = "ko";
function parseAcceptLanguage(header: string | null): string[] {
if (!header) return [];
return header
.split(",")
.map((entry) => {
const [lang, qValue] = entry.trim().split(";q=");
return {
lang: lang.trim(),
q: qValue ? parseFloat(qValue) : 1.0,
};
})
.sort((a, b) => b.q - a.q)
.map((item) => item.lang);
}
// 가장 먼저 일치하는 언어 반환
function getLocale(request: NextRequest): string {
const acceptLanguage = request.headers.get("Accept-Language");
const acceptedLanguages = parseAcceptLanguage(acceptLanguage);
for (const lang of acceptedLanguages) {
const baseLang = lang.split("-")[0];
if (locales.includes(baseLang)) {
return baseLang;
}
}
return defaultLocale;
}
export const routing = {
locales,
defaultLocale,
getLocale,
};
또 따로 locale을 지정하지 않을 때는 Accept-Language 요청 헤더를 통해 언어 콘텐츠 협상으로 언어를 정하도록 했다.
적용 후
next-intl 적용 후에는 국제화를 적용하여 번역된 HTML을 내려주므로, 텍스트가 변경되지 않아서 최초 렌더링 시점을 기준으로 잡았다.
'use client'
import { useEffect } from 'react'
export default function TranslateObserver() {
useEffect(() => {
const timestamp = performance.now()
console.log(`최초 렌더링 시점: ${timestamp.toFixed(2)}ms`)
}, [])
return null
}
역시나 마찬가지로 캐시 비우기 및 강력 새로고침으로 10회 진행했다.
- 716.50ms
- 678.10ms
- 654.30ms
- 681.90ms
- 700.50ms
- 746.60ms
- 680.20ms
- 676.00ms
- 787.30ms
- 747.40ms
평균 : 706.88ms (0.707초)
결론
평균 2.34초가 걸리던 화면 렌더링 시간을 next-intl 도입을 통해 평균 0.706초로 69.83% 단축할 수 있었고, 불필요한 스크립트 로딩도 제거되었고, UI 깨짐도 완전히 사라졌다.
'TIL' 카테고리의 다른 글
| TIL #131 : Spring Kafka에서 @KafkaListener 기본 컨테이너 팩토리 설정 오류 해결 (1) | 2025.06.09 |
|---|---|
| TIL #130 : 컨트롤러 테스트에서 커스텀 UserDetails 인증 객체 사용하기 (0) | 2025.04.10 |
| TIL #128 : PNG 이미지를 WebP 확장자로 변환하여 95% 용량 절감 (0) | 2025.04.07 |
| TIL #127 : Axios Interceptor 도입으로 인증 공통화 및 141줄 절감 (0) | 2025.04.07 |
| TIL #126 : SWR 도입으로 7개 페이지에서 93줄 코드 절감 (0) | 2025.04.07 |