이번 일주일 동안 저번 포스팅에서 만든 UI 라이브러리를 가지고 압축 사이트를 만들었습니다.
Runzipper
ZIP, TAR, TAR.GZ 형식을 지원하는 브라우저 기반 파일 압축 및 아카이브 도구입니다. 서버 업로드 없이, 설치 불필요하며, 완벽한 개인정보 보호를 제공합니다. 모든 압축 처리는 사용자의 기기에서
www.runzipper.app
제가 이 프로젝트를 시작한 계기는 간단한 툴을 웹서비스 형태로 제공하여 방문자수를 모니터링하고 싶어서였습니다.
이 서비스를 사용하는 사람이 충분하다면 광고를 추가하여 수익을 창출하는 것도 고려하고 있습니다.
❓ 다양한 툴 중에 왜 압축 사이트를 만들었나요?
일반 컴퓨터 사용자는 보통 압축, 압축해제를 하려면 반디집 같은 압축 프로그램을 설치하여 사용합니다.
압축 프로그램을 브라우저에서 구현하면 설치도 필요 없고, 서버로 정보가 넘어가지도 않으니 프라이버시도 지킬 수 있습니다.
특히, 설치가 필요 없다는 점에서 상용 소프트웨어에 비해 경쟁력을 가질 수 있지 않을까 하여 브라우저에서 실행되는 압축 프로그램을 만들었습니다.
방문자수를 높일 수 있는 방법이 뭐가 있을까 고민했을 때 첫 번째로 떠오르는 것은 전 세계 사람들을 대상으로 서비스하는 것이었습니다.
그래서 runzipper에 국제화를 적용하기로 했습니다.
dictionary 만들기
사실 저는 i18n을 구현하는 게 처음입니다.
그래서 next.js 공식문서의 internationalization 섹션을 참고하였고, 많은 부분을 따라서 만들었습니다.
제가 가장 먼저 한 것은 하드 코딩된 텍스트를 json 형태의 dictionary로 정리하는 것입니다.
만약 영어, 한국어, 일본어, 중국어를 지원한다면
- en.json
- ko.json
- ja.json
- zh.json
등의 dictionary를 만들면 됩니다.
하지만 무장적 json 파일을 작성하면 실수할 가능성이 높겠죠?
그래서 저는 안정성을 위해 json schema 파일을 먼저 작성했습니다.
json schema 작성하기
json schema는 타입스크립트에 비유할 수 있습니다.
타입스크립트는 타입이나 인터페이스를 정의하여 코더에게 형식을 제한할 수 있잖아요?
json schema는 json 버전의 인터페이스라고 이해하시면 됩니다.
json schema를 작성하는 방법은 이 문서에 나와있습니다.
저는 dictionary.schema.json 파일을 만들어서 스키마를 정의했습니다.
// dictionary.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Dictionary Schema",
"description": "Schema for application dictionary files",
"type": "object",
"properties": {
"meta": {
//...
},
"compress": {
//...
},
"header": {
//...
},
"footer": {
//...
},
"required": [
//...
]
}
위와 같이 레이아웃, 메타 데이터, 페이지 단위로 데이터를 정의하였습니다.
필수로 넣어야 하는 필드인 경우 required 배열에 key를 추가합니다.
json dictionary 작성하기
json schema를 정의했으니 이를 사용하여 dictionary를 작성하겠습니다.
딕셔너리를 작성할 때 먼저 $schema 키에 값으로 앞서 작성한 json schema의 url을 넣어줍니다.
// ko.json
{
"$schema": "./dictionary.schema.json",
//...
}
이렇게 하면 에디터에서 자동완성이 지원되고, 필수 값이 누락됐을 경우 경고 표시를 해줍니다.
dictionary 유틸 만들기
dictionary를 정의했으니 이를 사용하기 위한 유틸 함수들을 만들겠습니다.
이 코드 또한 next.js 공식문서에서 가져왔습니다.
// utils/dictionary.ts
// 앞서 정의한 dictionary를 로딩하는 함수
export const dictionaries = {
ko: () =>
import('@/docs/dictionanaries/kr.json').then((module) => module.default),
en: () =>
import('@/docs/dictionanaries/en.json').then((module) => module.default),
ja: () =>
import('@/docs/dictionanaries/ja.json').then((module) => module.default),
zh: () =>
import('@/docs/dictionanaries/zh.json').then((module) => module.default),
de: () =>
import('@/docs/dictionanaries/de.json').then((module) => module.default),
pl: () =>
import('@/docs/dictionanaries/pl.json').then((module) => module.default),
es: () =>
import('@/docs/dictionanaries/es.json').then((module) => module.default),
fr: () =>
import('@/docs/dictionanaries/fr.json').then((module) => module.default),
it: () =>
import('@/docs/dictionanaries/it.json').then((module) => module.default),
} as const;
export type Locale = keyof typeof dictionaries;
// string 타입을 좁히는 함수
export const hasLocale = (locale: string): locale is Locale => {
return Object.keys(dictionaries).includes(locale);
};
// locale에 따른 dictionary를 get하는 함수
export const getDictionary = async (locale: Locale) => dictionaries[locale]();
Route 수정하기
다중 언어를 지원하려면 언어별로 route가 필요합니다.
예를 들어 /compress이라는 경로가 있고
- 영어
- 일본어
- 한국어
를 지원한다면
- /en/compression
- /ja/compression
- /ko/compression
3개의 route가 필요합니다.
이를 위해 폴더 구조를 /compress에서 /[slug]/compress 형태로 변경했습니다.
Proxy.ts에서 언어 감지하기
사용자가 지원하지 않는 언어로 접근하거나 url에 언어(slug)가 포함이 안 돼있을 시를 처리하기 위해 next.js의 proxy를 사용했습니다.
사용자가 /[lang]/compress가 아닌 /compress로 접속했을 때는 요청 헤더의 accept-language를 파싱 하여
해당 언어를 지원하면 /[해당 언어]/compress로 리다이렉트 시키고,
지원하지 않는다면 /en/compress로 리다이렉트 시키도록 만들었습니다.
// proxy.ts
import { dictionaries } from '@/utils/dictionary';
import { type NextRequest, NextResponse } from 'next/server';
// 지원하지 않는 언어로 접속시 기본 언어
const DEFAULT_LANGUAGE = 'en';
// 지원하는 언어 목록
const localList = Object.keys(dictionaries);
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const pathnameHasLocale = localList.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
// 사용자의 요청이 /lang/~ 일 때
if (pathnameHasLocale) return;
// 사용자의 요청이 /lang/~ 형식이 아닐 때
const acceptLanguage = request.headers.get('accept-language') || '';
const primaryLanguage = acceptLanguage
.split(',')[0]
.split(';')[0]
.trim()
.split('-')[0];
const locale =
localList.find((locale) => locale === primaryLanguage) || DEFAULT_LANGUAGE;
request.nextUrl.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(request.nextUrl);
}
언어별 route에 static site generation 적용하기
Dictionary에 저희가 페이지에서 사용할 텍스트가 모두 정의돼 있으니 동적 렌더링을 할 이유가 전혀 없습니다.
SSG를 적용하기 위해 [lang] 폴더의 layout.tsx에 generateStaticparams를 선언하였습니다.
// src/app/[lang]/layout.tsx
export async function generateStaticParams() {
return Object.keys(dictionaries).map((lang) => ({ lang }));
}
export default async function Layout({
children,
params,
}: LayoutProps<'/[lang]'>) {
const locale = (await params).lang;
if (!hasLocale(locale)) notFound();
return (
<html lang={(await params).lang}>
<body className={bodyStyle}>
<DictionaryProvider dictionary={await dictionaries[locale]()}>
<Header lang={locale} />
{children}
<Footer lang={locale} />
</DictionaryProvider>
</body>
</html>
);
}
다음과 같이 지원하는 언어들에 대한 정적페이지를 생성하였습니다.
추가적으로 layout 컴포넌트에서 알아낸 딕셔너리 정보를 자식 컴포넌트에게 전달해야 하는데 저 같은 경우 Provider를 사용하여 전달했습니다.
언어에 따른 메타데이터 제공하기
저는 눈에 보이는 정보뿐만이 아니라 메타데이터도 국제화를 적용하고 싶어서 dictionary를 정의할 때 메타데이터도 각 국가의 언어로 작성했습니다.
generateMetadata 함수를 선언하여 언어에 따른 메타데이터를 생성하였습니다.
// src/app/[lang]/layout.tsx
type MetadataProps = {
params: Promise<{ lang: string }>;
};
export async function generateMetadata({
params,
}: MetadataProps): Promise<Metadata> {
const locale = (await params).lang;
if (!hasLocale(locale)) {
return {};
}
const dictionary = await dictionaries[locale]();
return {
title: 'Runzipper',
description: dictionary.meta.description,
manifest: '/site.webmanifest',
appleWebApp: {
title: 'Runzipper',
},
icons: {
icon: [
{ url: '/favicon.ico' },
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/favicon-96x96.png', sizes: '96x96', type: 'image/png' },
],
apple: { url: '/apple-touch-icon.png', sizes: '180x180' },
shortcut: '/favicon.ico',
},
};
}
SEO를 위해 언어별 sitemap 정의하기
프로젝트 목표는 방문자수 확보이므로 SEO는 필수입니다.
이 사이트가 다양한 언어를 지원한다는 것을 검색엔진에게 알리기 위해서는 hreflang 태그를 html head에 설정해야하는데,
next.js에서는 sitemap 함수를 사용하여 설정할 수 있습니다.
// sitemap.ts
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://runzipper.app/compress',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 1,
alternates: {
languages: {
ko: 'https://runzipper.app/ko/compress',
de: 'https://runzipper.app/de/compress',
pl: 'https://runzipper.app/pl/compress',
fr: 'https://runzipper.app/fr/compress',
it: 'https://runzipper.app/it/compress',
ja: 'https://runzipper.app/ja/compress',
zh: 'https://runzipper.app/zh/compress',
es: 'https://runzipper.app/es/compress',
en: 'https://runzipper.app/en/compress',
},
},
},
];
}
이렇게 할 경우 빌드 결과물로 다음과 같은 sitemap.xml이 생성됩니다.
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://acme.com</loc>
<xhtml:link
rel="alternate"
hreflang="es"
href="https://acme.com/es"/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://acme.com/de"/>
<lastmod>2023-04-06T15:02:24.021Z</lastmod>
</url>
<url>
<loc>https://acme.com/about</loc>
<xhtml:link
rel="alternate"
hreflang="es"
href="https://acme.com/es/about"/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://acme.com/de/about"/>
<lastmod>2023-04-06T15:02:24.021Z</lastmod>
</url>
<url>
<loc>https://acme.com/blog</loc>
<xhtml:link
rel="alternate"
hreflang="es"
href="https://acme.com/es/blog"/>
<xhtml:link
rel="alternate"
hreflang="de"
href="https://acme.com/de/blog"/>
<lastmod>2023-04-06T15:02:24.021Z</lastmod>
</url>
</urlset>
i18n 적용 후기
처음 다국어 지원을 구현했는데 next.js 공식문서에 설명이 잘 되어있어서 어려움 없이 구현할 수 있었습니다.
제가 만든 컴포넌트 라이브러리가 국제화를 고려하지 않고 만들어서, 텍스트가 하드코딩 되있어서 이를 수정하는데 시간이 걸렸습니다.
다음부터 컴포넌트 라이브러리를 만들때는 사용자가 국제화를 할 수 있다는 것도 고려해야할 것 같습니다. 😅
'개발' 카테고리의 다른 글
| [vite] vite로 라이브러리 빌드하기 (0) | 2025.12.02 |
|---|---|
| [압축 알고리즘] LZ77 구현 (0) | 2025.11.13 |
| [압축 알고리즘] LZ77 알고리즘 이란? (0) | 2025.11.13 |
