약 17분 읽기
청첩장이 계속 늘어났다
처음에는 지인의 결혼식 청첩장을 하나 만들었을 뿐이었다. 프로젝트를 완성하고 나니 다른 지인에게도 부탁이 들어왔다. 그렇게 두 번째 청첩장을 만들었다. 그런데 그게 끝이 아니었다. 시간이 지나면서 입소문을 타고, 청첩장을 만들어 달라는 요청이 점점 늘어났다. 어느새 내 홈서버에는 4개의 청첩장 프로젝트가 돌고 있었다. 처음에는 기쁘기도 했지만, 곧 문제들이 보이기 시작했다.
각 청첩장은 초기에는 단순히 기존 프로젝트를 복사해 내용을 바꾸는 방식으로 만들었다. 하지만 프로젝트의 수가 늘어나면서 동일한 문제를 여러 번 해결해야 하는 번거로움이 생겼다. 예를 들어 공통된 기능에서 버그가 발생하면, 모든 프로젝트에서 똑같이 수정해야 했다. 또한 환경 설정이 바뀔 때마다 각 프로젝트를 하나씩 수정해야 하는 상황이 발생했다. 이 과정은 시간이 많이 소모되어 점점 부담이 됐다.
게다가 외부 API를 사용하는 부분도 문제가 됐다. 지도 API의 키가 바뀌면 모든 청첩장을 각기 수정해야 했다. 이는 예상치 못한 변경 사항이 생길 때마다 골칫거리가 되었다. 이러한 반복적인 작업에서 오는 비효율성은 더 이상 무시할 수 없었다. 결국 데이터와 코드를 분리해서 빠르게 관리하기로 했다. 이 결정은 큰 변화를 가져왔다.
이제는 중복되는 부분 없이 청첩장을 관리할 수 있게 되어 조금 더 여유가 생겼다. 청첩장 수가 늘어나는 건 더 이상 문제가 아니라 오히려 더 체계적으로 관리할 수 있는 기회가 되었다. 코드와 데이터 분리 덕분에 새로운 청첩장이 추가되더라도 수정할 부분이 많지 않아 편리했다. 계속된 요구에 대응할 수 있게 되어 한결 마음이 가벼워졌다.
복사본 4개를 고치는 게 한계였다
처음 프로젝트를 시작했을 때는 청첩장을 하나씩 복사하여 수정하는 방식이 크게 문제가 되지 않았다. 그러나 시간이 지나면서 청첩장이 4개로 늘어나니 공통된 버그를 수정할 때마다 4곳에서 모두 수정을 해야 했다. 예를 들어 지도 API 키가 바뀌면 네 곳의 파일을 모두 찾아다니며 수정해야 하는 번거로움이 있었다. 이러한 반복적인 작업은 점점 시간이 많이 소요되었고, 실수도 잦아졌다. 게다가 청첩장에 추가되는 기능이 늘어날수록 이러한 문제는 더욱 커졌다.
카카오 공유 스펙이 변경되었을 때도 같은 문제가 발생했다. 각 청첩장마다 모든 내용을 수정하기 위해서는 상당한 작업량이 필요했고, 그 과정에서 누락되거나 잘못된 부분이 발생하기도 했다. 이런 상황은 내가 예상했던 것보다 더 많은 시간을 소모하게 만들었다. 결국 코드를 관리하는 데 있어서 이대로는 안 된다는 생각이 들었고, 프로젝트의 유지보수에 막대한 영향을 미쳤다. 이는 계속해서 쌓이는 기술 부채가 되었다.
더 나아가 새로운 청첩장을 추가할 때마다 초기 설정을 반복하느라 불필요한 시간이 소모되었다. 데이터와 코드를 분리하기 전까지는 단순히 프로젝트를 복제하여 수정하는 방식으로 문제를 해결하려 했기 때문에, 복잡성이 증가할수록 오히려 시간이 더 걸렸다. 이는 장기적으로 프로젝트 확장에 방해가 되었다. 결국 기존의 접근 방식으로는 한계가 명확했고, 근본적인 변화가 필요했다.
데이터와 코드를 분리
프로젝트를 복제하여 청첩장을 만들던 초기 접근 방식은 유지보수의 악몽이었다. 각 복제본에 동일한 버그가 발생했을 때, 이를 모두 개별적으로 수정해야 하는 번거로움이 있었다. 게다가 외부 API의 키나 공유 스펙 변경 시에도 각각의 코드를 수정해야 했기 때문에, 이 방식은 고통과 시간 낭비를 초래했다. 문제를 해결하기 위해 데이터와 코드를 분리하는 방향으로 갔다.
데이터와 코드의 분리를 통해 중복된 작업을 줄이고 유지보수의 속도를 높이고자 했다. 각 청첩장은 개별적인 데이터 파일에 정보를 저장하고, 공통 코드는 모듈화하여 공유하는 구조로 설계했다. 이로 인해 여러 청첩장에서 동일한 기능을 사용할 수 있었고, 변경 사항이 발생했을 때 한 곳에서만 수정하면 되었다. 이러한 접근 방식은 코드의 가독성을 높이고 유지보수를 훨씬 수월하게 해주었다.
// data/weddings/couple-a.ts
import type { WeddingConfig } from '@/types/wedding';
export const config: WeddingConfig = {
slug: 'couple-a',
groom: { name: '홍길동', father: '홍판서', mother: '춘섬',
phone: '010-0000-0000', account: '0000-00-000000 (○○은행)' },
bride: { name: '성춘향', father: '성참판', mother: '월매',
phone: '010-0000-0000', account: '0000-00-000000 (○○은행)' },
ceremony: {
at: '2026-11-15T13:00:00+09:00',
venue: '○○컨벤션 3층 그랜드홀',
address: '서울특별시 ○○구 ○○로 123',
coords: { lat: 37.5000, lng: 127.0000 },
parking: '건물 지하 1~3층, 2시간 무료',
},
gallery: { count: 24, cover: 3 }, // public/couple-a/1..24.jpg
theme: { primary: '#8B7355', font: 'serif' },
guestbook: true,
rsvp: true,
};
// app/[slug]/page.tsx — 코드는 한 벌
export async function generateStaticParams() {
return Object.keys(WEDDINGS).map((slug) => ({ slug }));
}
데이터와 코드 분리 — 복사본 4개를 고치는 걸 멈추려고
이 새로운 구조로 인해 공통 기능의 변경이 훨씬 간단해졌다. 예를 들어, 지도 API 키가 바뀌는 경우, 한 곳에서만 키를 변경하면 모든 청첩장에 반영되었다. 또한, 새로운 기능을 추가하거나 기존 기능을 수정할 때도 모듈화된 코드를 활용할 수 있어 개발 속도가 빨라졌다. 이로 인해 유지보수에 드는 시간과 노력이 크게 줄어들었고, 더 나은 품질의 서비스를 제공할 수 있게 되었다.
카카오톡 미리보기가 안 뜨던 이유
처음에 청첩장을 웹으로 만들었을 때, 카카오톡으로 공유했을 때 미리보기가 제대로 뜨지 않는 문제가 있었다. 카카오톡은 페이지의 OG(Open Graph) 태그를 참조해 제목, 설명, 이미지 등을 미리보기로 보여준다. 그런데 클라이언트 사이드에서만 렌더링을 처리하다 보니, 카카오톡의 크롤러가 페이지를 방문했을 때 OG 태그가 없는 빈 페이지를 보게 되는 상황이었다. 이는 SEO가 중요하지 않은 청첩장 프로젝트에서도 미리보기를 위해선 반드시 해결해야 할 문제였다.
그래서 서버사이드에서 OG 태그를 처리하도록 코드를 수정하게 되었다. Next.js의 App Router를 활용하면 서버에서 동적으로 OG 태그를 생성할 수 있어서 이를 활용하기로 했다. 서버사이드에서 OG 태그를 생성하면 크롤러가 페이지에 접근할 때마다 올바른 정보를 전달받을 수 있다. 이 방식으로 카카오톡 미리보기가 정상적으로 작동하도록 했다.
// app/[slug]/layout.tsx
import type { Metadata } from 'next';
import { WEDDINGS } from '@/data/weddings';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const w = WEDDINGS[slug];
if (!w) return { title: '청첩장' };
const date = new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'long', timeStyle: 'short', timeZone: 'Asia/Seoul',
}).format(new Date(w.ceremony.at));
const title = `${w.groom.name} ♥ ${w.bride.name} 결혼합니다`;
const description = `${date} · ${w.ceremony.venue}`;
return {
title, description,
openGraph: {
title, description,
type: 'website',
locale: 'ko_KR',
// 카카오톡은 절대 URL만 읽는다. 상대 경로로 두면 미리보기가 빈다
url: `https://example.com/${slug}`,
images: [{
url: `https://example.com/${slug}/og.jpg`,
width: 1200, height: 630,
}],
},
};
}
// 클라이언트에서만 렌더하면 크롤러가 빈 페이지를 본다.
// 여기서 서버가 확정된 값을 내보내야 한다.
카카오 공유 미리보기 때문에 OG 태그는 서버에서 뱉어야 한다
이렇게 서버사이드에서 OG 태그를 생성하도록 변경한 후, 카카오톡 미리보기가 문제없이 표시되었다. 서버에서 직접 데이터를 제공하니 클라이언트 사이드의 렌더링 속도와는 무관하게 항상 정확한 정보를 전달할 수 있었다. 처음에는 클라이언트 사이드 렌더링만을 고려했지만, SEO와 미리보기를 고려해 서버사이드로 변경한 것이 효과적이었다. 이로써 청첩장을 공유할 때, 사용자들이 원하는 미리보기를 제공할 수 있게 되었다.
원본 5MB 사진 30장
청첩장 프로젝트를 진행하면서 가장 큰 문제 중 하나는 이미지 파일의 용량이었다. 신부 측에서 제공한 사진 30장은 각각 5MB의 용량을 가지고 있었다. 이 때문에 초기 로딩 시간이 지나치게 길어지는 문제가 발생했다. 모바일 환경, 특히 3G 네트워크를 사용하는 사용자를 고려했을 때, 사진 로딩 속도를 줄이는 것이 절실했다. 이에 따라 처음 화면에 보이는 사진만 우선 로딩하고, 나머지 사진들은 사용자가 스크롤할 때 로딩되도록 조정했다.
이를 구현하기 위해 next/image의 자동 변환 기능을 활용했다. 이 기능은 이미지 파일을 적절한 크기와 포맷으로 자동 변환해 주기 때문에, 사용자는 잘 맞는 이미지 파일을 빠르게 다운로드 받을 수 있었다. 이렇게 해서 로딩 시간 문제를 해결할 수 있었다.
'use client';
import Image from 'next/image';
import { useState } from 'react';
export function Gallery({ slug, count, cover }: GalleryProps) {
const [open, setOpen] = useState<number | null>(null);
const items = Array.from({ length: count }, (_, i) => i + 1);
return (
<>
<div className="grid grid-cols-3 gap-1">
{items.map((n, idx) => (
<button key={n} onClick={() => setOpen(n)}
className="relative aspect-square overflow-hidden">
<Image
src={`/${slug}/${n}.jpg`}
alt={`갤러리 ${n}`}
fill
sizes="(max-width: 768px) 33vw, 200px"
quality={70}
// 첫 화면에 보이는 것만 즉시, 나머지는 스크롤할 때
loading={idx < cover * 3 ? 'eager' : 'lazy'}
priority={idx < 3}
className="object-cover"
/>
</button>
))}
</div>
{open !== null && (
<Lightbox slug={slug} index={open} count={count}
onClose={() => setOpen(null)} />
)}
</>
);
}
원본 5MB 사진 30장 — 처음 보이는 것만 먼저 받는다
기존에는 모든 이미지를 한 번에 로딩하는 방식이었지만, 이 코드를 적용하면서 초기 화면에 보이는 것만 로딩되도록 했다. lazy 로딩을 통해 스크롤할 때만 이미지를 불러오게 하면서, 초기 화면의 로딩 속도가 크게 개선되었다. 특히, 모바일 3G 환경에서 첫 화면이 뜨는 시간을 기준으로 적절하게 조정했기 때문에 사용자 경험이 크게 향상되었다. 덕분에 사용자는 필요한 순간에만 이미지를 로딩하여 데이터 사용량을 줄일 수 있었다.
방명록과 스팸
방명록 기능은 처음에 간단하게 MariaDB를 통해 구현했다. 이미 홈서버에 MariaDB가 설치되어 있었기 때문에 새로운 데이터베이스와 사용자만 추가하면 되었다. 사용자끼리 결혼식에 대한 축하 메시지를 남기고 소통할 수 있는 공간이 필요하다는 생각에서 출발했다. 기본적인 기능은 순조롭게 구현되었지만, 예상치 못한 문제가 발생했다.
바로 스팸 메시지가 문제였다. 초기에는 방명록을 자유롭게 사용할 수 있도록 개방했지만, 곧 스팸 메시지들이 방명록을 가득 채웠다. 자동화된 봇들이 제대로 차단되지 않았고, 이를 수동으로 삭제하는 데 시간이 많이 걸렸다. 초대장을 사용하는 지인들에게 불편을 끼치지 않기 위해 빠르게 해결책을 찾을 필요가 있었다.
그래서 간단한 rate limit을 도입했다. 일정 시간 내에 너무 많은 메시지를 보내려는 시도를 제한하는 방식이었다. 이 방법은 비교적 쉽게 적용할 수 있었고, 효과도 있었다. 스팸 메시지가 현저히 줄어들었고, 사용자는 다시 쾌적하게 방명록을 이용할 수 있게 되었다. 이 과정에서 방명록 기능을 개선할 여지가 많다는 것을 깨달았다.
결국 방명록 기능은 예상치 못한 문제와 그 해결 과정에서 많은 교훈을 남겼다. 스팸 문제를 해결하면서도 사용자 경험을 해치지 않는 적절한 방식을 찾는 것이 중요했다. 이 문제를 해결한 후, 다시는 방명록 관리에 시간을 빼앗기지 않게 되었다. 앞으로도 유사한 문제가 발생하지 않도록 지속적으로 모니터링할 계획이다.
컨테이너 하나에 청첩장 하나
각 청첩장을 독립적인 컨테이너로 관리하기로 결정했다. 이렇게 하면 각 청첩장이 서로 영향을 받지 않고 독립적으로 운영될 수 있다. 처음엔 모든 청첩장을 하나의 컨테이너에서 관리하려고 시도했지만, 이는 여러 가지 문제를 야기했다. 예를 들어, 특정 청첩장의 업데이트가 다른 청첩장에 영향을 미치는 경우가 있었다. 이러한 문제를 해결하기 위해 각 청첩장을 별도의 컨테이너에 배포하는 방식을 선택했다.
컨테이너를 개별적으로 운영하면서 얻은 가장 큰 장점은 유연성이다. 청첩장별로 맞춤 설정이 필요할 때, 컨테이너 이미지를 수정해 각각 배포할 수 있다. 이미지 태그만 바꿔서 새로운 컨테이너를 띄우기 때문에 유지보수가 간편해졌다. 또한, 필요한 경우 특정 청첩장에만 새로운 기능을 테스트할 수 있어 개발 과정이 한결 수월해졌다.
또한, nginx를 활용해 서브도메인별로 라우팅을 설정했다. 이렇게 하면 각 청첩장이 독립적인 URL을 가질 수 있어 사용자 접근성과 관리가 용이해진다. 서브도메인으로 나눈 덕분에 각 청첩장의 로딩 속도나 성능도 정확히 분석할 수 있다. 이 방식은 사용자 경험을 개선하는 데 기여했다. 단, 서버 자원을 손 안 가게 사용하기 위해 컨테이너 수를 필요 이상으로 늘리지 않도록 주의가 필요하다.
하지만, 이 방식에도 한계가 있다. 가령, 컨테이너가 많아질수록 서버의 리소스가 더 많이 요구된다. 이것은 서버 운영 비용 증가로 이어질 수 있다. 그래서 현재는 각 컨테이너가 동일한 베이스 이미지를 사용하도록 최적화했다. 이렇게 하면 업데이트나 버그 수정을 중앙에서 관리할 수 있어 전반적인 유지보수 속도가 향상됐다.
함께 볼 글
이 글과 이어지는 내용은 아래에 정리해뒀다.