
Next.js, TypeScript, 그리고 선언적 엔진(Declarative Engine)으로 50개 이상의 계산기를 만들며 배운 것들
요약
50개 이상의 다양한 계산기 플랫폼을 구축하며 겪은 시행착오와 이를 해결하기 위해 설계한 선언적 엔진(Declarative Engine) 개발 경험을 공유합니다. 개별 컴포넌트 방식의 한계를 극복하고 입력, 공식, 출력 중심의 데이터 구조로 시스템을 추상화하는 과정을 다룹니다.
핵심 포인트
- 개별 컴포넌트 방식은 확장성 및 유지보수 측면에서 한계가 있음
- 입력, 공식, 출력의 세 가지 핵심 요소로 시스템을 구조화
- 선언적 엔진 설계를 통해 코드 중복을 줄이고 로직을 중앙 집중화
- 단위 변환, 검증 규칙, 동적 양식 등을 데이터 타입으로 정의
6개월 전, 저는 화학, 금융, 건강, 건설 등 다양한 분야를 아우르는 50개 이상의 계산기를 제공하는 플랫폼인 ToolsArena를 구축하기 시작했습니다. 단순히 50개의 간단한 위젯을 만든 것이 아닙니다. 각 계산기에는 단위 변환(unit conversions), 검증 규칙(validation rules), 동적 양식(dynamic forms), 대화형 그래프(interactive graphs), 그리고 전문가가 작성한 수천 단어 분량의 교육용 콘텐츠가 포함되어 있습니다.
저는 실수를 했습니다. 핵심 시스템을 세 번이나 다시 작성했습니다. pHCalculator가 ph-calculator 대신 p-h-calculator로 자동 변환된다는 사실을 발견하기도 했습니다. 통화 출력(currency outputs)과 과학적 표기법(scientific notation)은 절대 섞여서는 안 된다는 것도 배웠습니다.
이것은 제가 배운 것들에 대한 솔직한 이야기입니다.
1. 거의 성공할 뻔했던 순진한 접근 방식
저는 대부분의 개발자가 그러하듯 시작했습니다. 계산기 하나당 하나의 React 컴포넌트를 만드는 방식이었습니다. 여기에는 MolarityCalculator.tsx가 있고, 저기에는 BMICalculator.tsx가 있었습니다. 각 컴포넌트는 자신만의 상태 관리(state management), 자신만의 검증 로직(validation logic), 자신만의 단위 처리 방식을 가지고 있었습니다.
처음 세 개의 계산기를 만들 때는 이 방식이 빨랐습니다. 복사해서 붙여넣고, 이름을 바꾸고, 약간 수정하면 되었습니다. 하지만 계산기 7번째에 이르렀을 때, 저는 허우적거리고 있었습니다. 모든 버그 수정 사항을 여러 파일에 걸쳐 복제해야 했습니다. 질량(mass)에 대한 단위 변환 로직은 네 군데에 복사되었고, 각 복사본은 조금씩 다른 버그를 가지고 있었습니다.
임계점은 showWhen 기능 — 즉, 다른 입력값의 값에 따라 입력을 보여주거나 숨기는 기능 — 을 추가해야 했을 때 찾아왔습니다. 저는 똑같은 조건부 렌더링(conditional rendering) 로직을 열 번째로 작성하고 있는 자신을 발견했습니다.
무언가 변해야만 했습니다.
2. 선언적 엔진 (The Declarative Engine)
통찰은 간단했습니다. 모든 계산기는 단 세 가지 요소로 이루어져 있다는 것입니다 — 입력(inputs), 공식(formula), 그리고 출력(outputs). 입력은 어떤 데이터를 수집할지를 설명합니다. 공식은 결과를 계산합니다. 출력은 그것을 어떻게 표시할지를 설명합니다.
컴포넌트를 작성하는 대신, 저는 타입을 정의했습니다.
type Calculator = {
slug: string;
title: "string;
...
각 계산기는 이 객체를 내보내는(exporting) 데이터 파일이 되었습니다:
const molarityCalculator: Calculator = {
slug: 'molarity-calculator',
title: "'Molarity Calculator',"
...
엔진은 formula 문자열을 읽고, 입력값들이 변수로 사용 가능한 샌드박스 범위(sandboxed scope) 내에서 이를 평가(evaluate)합니다. unitOptions는 엔진에게 사용자 입력을 수식에 전달하기 전에 toBase를 곱하도록 지시합니다. 따라서 사용자가 무엇을 선택하든 모든 수식은 기본 단위(grams, liters)로 작동합니다.
이것이 돌파구였습니다. 새로운 계산기를 추가하는 작업이 React 컴포넌트를 작성하는 것에서 데이터 파일을 편집하는 것으로 바뀌었습니다.
3. 단위 변환은 보기보다 어렵다
단위 변환은 간단해 보입니다. 사용자가 500을 입력하고 mL를 선택하면, 엔진이 0.5 L로 변환합니다. 끝입니다.
함정은 역방향에 있습니다. 수식은 기본 단위(예: 1.5 L)로 결과를 반환합니다. 하지만 사용자는 입력에서 mL를 선택했습니다. 출력 또한 mL로 표시되어야 합니다.
저는 unitType 매칭 시스템을 구축했습니다. 입력값은 unitType: 'volume'을 선언합니다. 출력값도 동일하게 선언합니다. 엔진이 결과를 렌더링할 때, 일치하는 unitType을 가진 입력을 찾아 사용자가 선택한 단위를 읽고, 기본값을 해당 단위의 toBase로 나눕니다.
alternativeUnits: [
{ unit: 'L', toBase: 1, label: 'litre' },
{ unit: 'mL', toBase: 0.001, label: 'millilitre' },
...
이를 통해 "1.500 L"를 기본으로 보여주고, 그 아래에 "기타 단위: 1500 mL | 1,500,000 μL"를 표시할 수 있습니다. 사용자는 머릿속으로 변환할 필요가 전혀 없습니다.
배운 점: 항상 기본 계산(base computation)과 표시 형식(display formatting)을 분리하세요. 수식이 포맷팅된 문자열을 생성하게 하지 마세요. 가공되지 않은 숫자(raw numbers)를 반환하고 나머지는 엔진이 처리하도록 하세요.
4. CompositeInput을 이용한 동적 양식(Dynamic Forms)
GPA 계산기에는 가변적인 수의 과목이 필요합니다. 사용자가 수강하는 과목 수를 선택하면, 양식은 그 수만큼의 행을 생성하며, 각 행에는 과목명, 성적, 학점이 포함됩니다.
이것은 설계하기 가장 어려운 입력 타입이었습니다. 저는 이를 CompositeInput이라고 불렀습니다.
{
type: 'composite',
name: 'courses',
...
formulaKey는 다른 입력값인 numCourses를 참조하므로, 사용자가 과목 수를 변경하면 폼(form)이 동적으로 행을 추가하거나 제거합니다. 엔진은 course1Name, course1Grade, course2Name, course2Grade와 같은 필드 이름을 생성하고 이를 수식 범위(formula scope)에 매핑합니다.
이 패턴은 매우 훌륭하게 확장되었습니다. 동일한 CompositeInput이 신용카드 상환 계산기(여러 장의 카드), 할부 상환 일정(여러 번의 결제), 그리고 칼로리 기록 폼(여러 끼니)을 구동합니다.
추가적인 고민이 필요했던 세부 사항은 플레이스홀더(placeholder) 텍스트였습니다. placeholder: 'e.g. Calculus I'와 같은 필드는 1행에서는 괜찮아 보이지만, 4행에서는 좀 더 문맥에 맞는 내용이 필요합니다. 저는 각 필드에 placeholderMode 속성을 추가했습니다. 'first'는 1행에만 플레이스홀더를 표시하고, 'all'은 행 번호를 추가합니다('e.g. Calculus I 4'). 사용자가 10개 이상의 행에 데이터를 입력할 때는 이러한 작은 UX(사용자 경험) 디테일이 중요합니다.
5. 50개 이상의 계산기 파일 레이지 로딩 (Lazy Loading)
52개의 데이터 파일이 있었기 때문에, 이를 초기 JavaScript 페이로드(payload)에 모두 번들링할 수는 없었습니다. 각 파일은 약 5~10KB에 불과하지만, 52개를 모두 포함하면 모든 페이지 로드 시 불필요한 무게를 더하게 됩니다.
해결책은 자동 생성된 로더(loader)였습니다:
// lib/calculator-loader.ts — 자동 생성됨
export const calculatorLoaders: Record<string, () => Promise<Calculator>> = {
'molarity-calculator': () => import('@/data/calculators/chemistry/molarityCalculator'),
...
페이지 컴포넌트는 현재 슬러그(slug)와 일치하는 계산기만 동적으로 임포트(import)합니다:
const CalculatorEngine = dynamic(
() => import('@/components/CalculatorEngine'),
{ loading: () => <Skeleton />, ssr: false }
...
저는 모든 계산기를 슬러그(slug) 및 파일 이름과 함께 나열하는 단일 진실 공급원(Single Source of Truth)인 calculator-manifest.ts를 만들었습니다. npm run generate 스크립트는 이 매니페스트(manifest)를 읽어 로더(loader) 파일, 카테고리 인덱스, 그리고 관련 계산기 쌍(related-calculator pairs)을 다시 작성합니다. 새로운 계산기를 추가하는 과정은 매우 간단합니다: 데이터 파일을 생성하고, 매니페스트에 한 줄을 추가한 뒤, npm run generate를 실행하면 됩니다.
수동적인 임포트(import) 관리도, 오래된 로더 파일도 필요 없습니다. getCalculator() 함수는 단순한 비동기 조회(async lookup) 방식입니다:
export async function getCalculator(slug: string): Promise<Calculator | null> {
const loader = calculatorLoaders[slug];
if (!loader) return null;
...
동적 임포트(Dynamic import)와 캐시(cache)를 사용합니다. switch 문도, 동기화해야 할 레지스트리(registry) 배열도 필요 없습니다.
6. 콘텐츠, 저자, 그리고 EEAT
단순히 숫자만 있는 계산기는 유용할 뿐입니다. 하지만 전문가가 작성한 4,000단어 분량의 교육적 콘텐츠가 포함된 계산기는 가치가 있습니다.
플랫폼의 모든 계산기는 다음을 포함합니다:
- 전문 저자 (Expert authors) — 화학 석사(MS Chemistry)가 화학 계산기를 작성하고, MBA가 금융 계산기를 작성하며, 약학 박사(Pharm.D)가 건강 관련 계산기를 작성합니다.
- 동료 검토자 (Peer reviewers) — 박사(PhD) 연구원이 모든 화학 공식을 검토하며, 의사가 건강 콘텐츠를 검토합니다.
- 풀이 예제 (Worked examples) — 권위 있는 출처의 검증된 수치를 사용하여 계산기당 4~6개의 풀이 문제를 제공합니다.
- 자주 묻는 질문 (FAQs) — 상세한 답변이 포함된 10~15개의 도메인 특화 질문을 제공합니다.
- 용어 사전 (Glossary) — 이해하기 쉬운 정의가 포함된 8~12개의 용어를 제공합니다.
- 인용 (Citations) — IUPAC, NIST, CDC, WHO 및 동료 검토를 거친 교과서에 대한 번호가 매겨진 참조 문헌을 제공합니다.
다음은 몰 농도(molarity) 계산기의 실제 예시입니다. ExampleBlock 컴포넌트는 KaTeX 수학 수식을 사용하여 풀이 문제를 렌더링합니다:
{
type: 'example',
slot: 'description',
...
이것이 SEO와 신뢰성에 중요한 이유: Google의 EEAT 가이드라인은 실제 전문성을 입증하는 페이지에 보상을 제공합니다. 수식만으로는 충분하지 않습니다. 자격을 갖춘 저자, 작업 내용을 검증한 검토자, 그리고 인용된 출처가 포함된 예제 문제를 보여주는 것은 검색 엔진(및 사용자)이 찾는 권위 신호(authority signals)를 구축합니다.
전체 구현 방식은 molarity calculator page에서 확인할 수 있습니다. 예제 문제 섹션, KaTeX를 사용한 수식, 그리고 저자 정보(byline) 모두 이 선언적 데이터 구조(declarative data structure)로부터 렌더링됩니다.
콘텐츠 자체는 엄격한 품질 기준을 따릅니다. 각 계산기는 16개 이상의 콘텐츠 블록에 걸쳐 최소 4,000개의 고유 단어를 포함합니다. FAQ 섹션에는 1015개의 질문이 포함됩니다. 예제 문제는 IUPAC, NIST 및 동료 검토(peer-reviewed)를 거친 교과서와 같은 인용된 출처의 검증된 수치 데이터를 사용합니다. 용어 사전(glossary)은 812개의 도메인 특화 용어를 정의합니다. 모든 헤딩(heading)은 페이지 내에서 고유하며, 중복된 <h2> 텍스트는 어디에도 존재하지 않습니다.
단순한 계산기에게는 과한 작업일 수 있습니다. 하지만 검색 엔진과 사용자는 깊이 있는 정보에 보상을 줍니다. 단순히 숫자만 계산하는 페이지보다 진정으로 무언가를 가르쳐주는 페이지가 더 나은 성과를 냅니다.
7. 내가 저지른 실수들 (당신은 그러지 않도록)
pH 슬러그(Slug) 버그
계산기 슬러그는 URL 경로입니다. 처음에는 camelCase를 kebab-case로 변환하는 함수를 사용하여 변수 이름으로부터 슬러그를 유도했습니다. 하지만 pHCalculator가 ph-calculator가 아닌 p-h-calculator로 변환되면서 문제가 발생했습니다. 해결책은 슬러그를 유도하는 대신 매니페스트(manifest)에 명시적으로 정의하는 것이었습니다.
통화(Currency) + 과학적 표기법(Scientific Notation)
0.0001 미만 또는 1,000만 초과의 값은 기본적으로 과학적 표기법을 트리거합니다. 화학 분야에서는 훌륭하지만, 돈(통화)에는 최악입니다. $1,234,567.89의 대출 EMI가 $1.23e6로 표시되어서는 안 됩니다. 저는 type: 'currency' 출력에는 과학적 표기법을 사용하지 않도록 제외 규칙을 추가해야 했습니다.
잘못된 위치에서의 유효성 검사(Validation)
처음에는 계산기 엔진 컴포넌트 내부에 입력 유효성 검사 (Validation) 로직을 추가했습니다. 새로운 유효성 검사 규칙이 생길 때마다 컴포넌트를 수정해야 했습니다. 그래서 유효성 검사를 데이터 파일 내의 선언적 규칙 (Declarative rules)으로 이동시켰습니다:
validation: {
dependsOn: {
field: 'volume',
...
엔진이 이를 자동으로 처리합니다. 이제 새로운 유효성 검사 규칙을 추가하는 것은 코드 변경이 아닌 데이터 변경이 됩니다.
SVG 게이지 좌표 (SVG Gauge Coordinates)
rangeBar() 헬퍼 함수는 수평 게이지 미터 (Gauge meter)를 위한 SVG 채우기 경로 (Fill path)를 계산합니다. 이 함수를 사용하는 6개의 계산기 전체에 걸쳐 viewBox, 바 사각형 (Bar rectangle), 축 레이블 (Axis labels)을 조정해야 했습니다. 좌표가 하나라도 틀리면 바가 viewBox를 벗어납니다. 해결책은 헬퍼 함수와 함께 문서화된 고정 좌표 컨벤션 (Coordinate convention)을 사용하는 것이었습니다. 이를 통해 모든 계산기가 정확히 동일한 SVG 템플릿을 사용하게 되었습니다.
8. 성과를 거둔 주요 아키텍처 결정 사항
돌이켜보면, 다음과 같은 결정들이 시간을 가장 많이 절약해 주었습니다:
단일 진실 공급원 (Single source of truth)으로서의 매니페스트 (Manifest). 모든 임포트 (Import), 모든 라우트 (Route), 모든 관련 계산기 쌍 (Related-calculator pairing)은 하나의 파일로부터 파생됩니다. 한 곳에서 계산기를 추가하고 명령어를 한 번 실행하면 모든 것이 업데이트됩니다.
슬롯 기반 콘텐츠 배치 (Slot-based content placement). 콘텐츠 블록은 템플릿에 하드코딩되는 대신 slot (예: 'description', 'howto', 'input:mass')을 선언합니다. 이를 통해 페이지 템플릿을 건드리지 않고도 페이지를 재배치하고, 특정 입력 근처에 문맥적 도움말을 추가하며, 특정 출력 옆에 그래프를 배치할 수 있었습니다.
선언적 showWhen 조건 (Declarative showWhen conditions). 컴포넌트 곳곳에 흩어져 있는 if (x > 5) { renderY() } 대신, 각 입력은 자신이 언제 보여야 하는지를 선언합니다:
showWhen: { field: 'solveFor', operator: 'neq', value: 'mass' }
엔진이 가시성 (Visibility)을 처리합니다. 저는 조건부 렌더링 (Conditional rendering)에 대해 고민할 필요가 전혀 없습니다.
통합된 콘텐츠 블록 시스템 (A unified content block system). 계산기 페이지의 모든 콘텐츠 — 텍스트, 공식, 표, 그래프, 이미지, SVG, 3D 장면, 풀이 과정 예시 — 는 slot과 order를 가진 타입이 지정된 콘텐츠 블록 (typed content block) 입니다. ContentRenderer 컴포넌트는 슬롯(slot)별로 블록을 필터링하고, 순서(order)에 따라 정렬한 뒤, 각 블록을 전용 컴포넌트를 통해 렌더링합니다. 몰 질량 (molar mass) 입력 필드 근처에 참조 표를 추가해야 하나요? slot: 'input:molarMass'와 order: 10을 가진 테이블 블록을 생성하면 됩니다. 그러면 시스템이 자동으로 그 위치에 배치합니다. 요약(summary) 뒤에 그래프가 필요한가요? slot: 'summary'와 order: 20을 가진 그래프 블록을 만들면 됩니다. 이 슬롯 시스템 덕분에 개별 계산기마다 페이지 템플릿을 수정하지 않고도 복잡하고 풍부한 페이지를 구축할 수 있었습니다.
초기화 시에도 유지되는 모드 선택기 (Mode selectors that survive resets). 몰 농도 (molarity) 계산기와 같은 계산기에는 "~ 구하기 (solve for)" 드롭다운이 있습니다. 사용자는 몰 농도, 몰 (moles), 또는 질량 (mass) 중 무엇을 찾을지 선택합니다. 이전에는 이를 전환할 때마다 모든 입력값이 초기화되어 매우 답답했습니다. 저는 계산기의 모드를 정의하는 입력값에 isModeSelector: true를 추가했습니다. 이제 모드가 변경될 때, 해당 모드에 의존하는 입력값들만 초기화됩니다. 몰 질량과 같은 공유 입력값은 그 값을 유지합니다. 사용자는 이제 구하기 모드를 전환할 때 모든 값을 다시 입력할 필요가 없습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기