지원 답변이 TypeScript 배열에 존재하고, 그 안의 모든 숫자는 plans 파일에서 보간됩니다
요약
본 글은 지원 답변(support answer)이 단순한 문서가 아니라, 제품의 동작을 주장하는 코드 기반의 '문서화된 행동'임을 강조합니다. 특히, 플랜 제한과 같은 숫자는 하드코딩 대신 템플릿 리터럴을 사용하여 데이터 파일에서 보간함으로써 일관성을 유지해야 한다고 설명합니다.
핵심 포인트
- 지원 답변은 제품 동작에 대한 주장(claim)이므로 코드 내에 존재해야 합니다.
- 안정적인 식별자(id)는 레이블이 아닌 고유한 값으로 사용되어야 합니다.
- 숫자는 하드코딩하지 않고, 템플릿 리터럴을 이용해 데이터 파일에서 보간하는 것이 가장 효율적입니다.
Nakodo에서 로그인한 모든 페이지 왼쪽 하단에 있는 도움말 버튼을 누르면 세 가지 선택지, 그리고 몇 개의 칩(chips), 마지막으로 하나의 답변을 보여주는 패널이 열립니다. 대부분의 사람들은 이 답변에서 멈춥니다. 이 답변들은 데이터베이스나 CMS 또는 도움말 데스크에 있는 것이 아닙니다. 그것들은 src/lib/support/manual.ts 파일 내의 배열이며, 222줄에 걸쳐 있고, 그 이유는 파일 자체의 헤더에 나와 있습니다:
FAQ와 같은 이유로 테이블이 아닌 코드 안에 존재합니다: 답변은 제품이 어떻게 작동하는지에 대한 주장(claim)이므로, 그것이 설명하는 동작과 함께 검토되고 배포되어야 합니다.
이것이 전체 논지입니다. 지원 답변은 행동에 대한 문서화입니다. 만약 그것이 제품 변경으로 건드릴 수 없는 곳에 존재한다면, 조용히 구식이 되고, 가장 먼저 이를 알아차리는 사람은 그것을 신뢰했던 누군가일 것입니다.
구조 (The shape)
export type ManualTopic = {
// Slug. 티켓에서 주제로 저장됩니다. 안정적입니다: 운영자가 이 값으로 필터링하고 딥 링크(deep link)가 이름 지을 때 사용합니다.
...
세 개의 섹션이 있으며, 각각 SupportKind(question, problem, billing)에 해당하고, 그 사이에 18개의 주제가 있습니다. 각 섹션은 자체적인 도입부(lede)와 자체적인 양식 플레이스홀더를 가지고 있어, 양식 상단의 질문이 일반적인 프롬프트라기보다는 사용자가 문의한 내용과 일치하게 됩니다.
id는 위젯 외부의 두 곳에서 핵심적인 역할을 합니다: 티켓에 topic으로 작성되고, 운영자의 대기열(queue)에서 이 값으로 필터링되기 때문입니다. 따라서 id는 레이블이 아니라 안정적인 식별자이며, 칩의 이름을 변경한다고 해서 그것을 통해 열린 티켓들이 고아되는 일은 없습니다.
숫자는 타이핑되지 않았습니다 (The numbers are not typed in)
제가 나머지 부분을 버려야 한다면 이 부분이 가장 아쉬운 부분입니다:
const free = PLANS.free.limits;
const pro = PLANS.pro.limits;
const business = PLANS.business.limits;
{
id:
매뉴얼 전반에 걸쳐 13개의 보간(interpolation)이 있습니다. 핵심 속성은 다음과 같습니다: 계획(plan)의 제한을 변경하더라도 지원 답변(support answer)에 오래된 숫자(stale number)가 남아서는 안 되는데, 이는 지원 답변 자체가 숫자를 포함하지 않기 때문입니다. 대신 앱이 강제하는 숫자에 대한 참조를 포함합니다. 가격 페이지, 파이프라인의 제한 확인(limit check), 그리고 채팅 버블에서 누군가가 읽는 문장은 모두 동일한 값을 가집니다.
데이터 파일 내의 템플릿 리터럴(Template literals)은 이것을 구현하는 가장 저렴한 방법입니다. 템플릿 언어도 없고, 문서화할 플레이스홀더 구문(placeholder syntax)도 없으며, 런타임 대체 단계(runtime substitution step)도 필요 없습니다. 게다가 속성 이름에 오타가 나면 빌드가 실패합니다.
## 하나의 열거형(enum)을 위한 세 개의 레이블 맵
export const SUPPORT_KIND_LABELS: Record<SupportKind, string> = {
question: "Ask a question",
problem: "Report a problem",
...
같은 세 개의 키에 대한 세 개의 맵은 중복처럼 보이지만 그렇지 않습니다. 칩(chip)은 사용자에게 무언가를 하도록 요청합니다("Ask a question"). 운영자 대기열의 셀(cell)은 사물에 이름을 붙입니다("Question"). 여러 개를 아우르는 제목(heading)은 복수형입니다("Questions"). 이들을 하나의 맵으로 통합하고 포매터(formatter)를 사용하면, 영어의 복수형 변화와 동사 구문(verb phrases)을 아는 포매터를 갖게 되며, 이는 런타임에 절대 생성되지 않을 세 가지 값에 대한 것입니다. `Record<SupportKind, string>`은 네 번째 종류가 추가되면 컴파일 실패를 일으키는데, 이는 새로운 종류가 세 개의 단어를 필요로 한다는 정확한 세 번의 상기시켜주는 역할을 합니다.
아무도 제목을 쓰지 않은 티켓의 제목 줄(subject line)은 이 두 가지에서 구성됩니다:
export function subjectFor(kind: SupportKind, topic: string | null): string {
const label = topicLabel(topic);
const head = SUPPORT_KIND_LABELS[kind].replace(/^(Ask a|Report a) /, "");
...
"Report a problem"과 "Emails aren't going out"이 결합되어 `Problem: emails aren't going out`이 됩니다. 정규 표현식(regex)은 칩에서 명령형(imperative)을 제거하고, 두 개의 `charAt` 호출은 연결 지점의 대소문자를 수정합니다. 이것은 사소한 함수이며, 영어처럼 읽히는 제목들의 대기열과 `problem / not-sending` 같은 형태의 대기열 사이의 차이를 만듭니다.
## 모델의 유일한 출처가 되는 동일한 배열
위젯은 칩(chip)이 질문에 답하지 못했을 때 모델을 제공합니다. 이는 대부분의 질문이 매뉴얼에 있고 표현 방식이 다르기 때문입니다. 모델이 받는 것은 평탄화된 매뉴얼 전체입니다:
export const MANUAL_TEXT: string = MANUAL.map(
(section) =>
## ${SUPPORT_KIND_LABELS[section.kind]} ${section.topics.map((t) => - ${t.label}: ${t.answer}).join(" ")},
...
따라서 칩의 답변과 모델의 출처는 동일한 문자열이며, 그것이 말하는 모든 내용은 누군가 의도적으로 작성한 라인으로 추적됩니다. 주변 지침은 페르소나라기보다는 거절(refusal) 목록에 가깝습니다: 매뉴얼에서만 답변하고, 가격, 한도, 날짜, 정책 또는 환불액을 절대 꾸며내지 말고, 원인을 추측하지 말고, 무언가 고장 난 것처럼 보이면 사람이 조사하는 데 도움이 될 세부 정보를 말하세요. 출력은 두 개의 필드를 가진 스키마(schema)입니다: 답변과 모델이 스스로 그곳에 도달했는지 여부에 대한 설명이며, 위젯은 이 두 번째 필드가 거짓일 때 사람에게 연결합니다. 이 게이트는 [자체 포스트](https://dev.to/daniel_pertu/our-support-widget-will-not-open-a-ticket-until-the-model-admits-it-could-not-help-237d)였습니다. 여기서 중요한 것은 리드줄(leash)과 칩이 하나의 배열이라는 점입니다.
양쪽 크기는 작성자와 상태와 나란히, 가져오기(imports)가 없는 파일에서 확인되며, 이를 통해 스키마, 서버, 클라이언트 모두 읽을 수 있습니다:
export const SUPPORT_LIMITS = {
messageMin: 4,
messageMax: 4000,
...
`assistantTurns: 2`는 상수(constant) 내의 제품 결정 사항입니다. 후속 답변이 한 번 더 가능하며 그 이상은 아니기 때문인데, 그 이후에는 사람이 처리하는 것이 더 빠르기 때문입니다. `questionMax: 600`은 질문이 모델에 도달하기 전에 서버 측에서 슬라이싱(slicing)되며, 브라우저에서만 확인되는 것이 아닙니다.
## 이것이 해결하지 못하는 것들
매뉴얼이 간략한 형태입니다. 긴 형태는 마케팅 사이트의 FAQ에 있으며, '현장에서 질문받은 내용(Asked in the field)'이라는 제목 아래 9개의 질문이 있습니다. 이들은 서로 다른 두 개의 파일에 있는 두 개의 배열입니다. 둘 다 `PLANS`를 읽기 때문에 숫자가 어긋날 수는 없지만, 그 문장이 일치하는지는 아무도 확인하지 않습니다. 저희 콘텐츠 페이지에는 모든 페이지를 순회하며 제목, 설명, 단어 수, 금지어 등에 대해 검증(assert)하는 테스트 스위트가 있습니다. 홈 페이지의 FAQ와 지원 매뉴얼은 직접 관리되며 읽는 과정을 통해 검토됩니다. 만약 동작 방식이 변경되면 세 군데를 수정해야 하는데, 그중 두 곳만 빌드에 실패하게 됩니다.
솔직한 해결책은 작습니다. 두 파일 모두 짧고, 행동(behavior)과 같은 저장소(repository)에 있으며, 둘 다 플랜을 가져오기 때문에 플랜 변경 시 동일한 diff에서 나타납니다.
## 가서 확인해보세요
위젯은 계정이 필요하지만, 이 답변들의 긴 형태는 모두 공개적이며, 비교가 흥미로운 부분입니다:
- [홈 페이지의 FAQ](https://nakodo.app/#faq)는 동일한 주장의 더 완전한 버전입니다. 'Nakodo가 제가 대신 이메일을 보내주나요?'라는 질문은 세 문장으로 되어 있고, 매뉴얼의 해당 칩(chip)은 두 개입니다.
- [가격 페이지](https://nakodo.app/pricing)는 모든 제한 사항을 나란히 보여주는 표를 포함하여 보간된 숫자가 게시되는 곳입니다. 그 수치와 지원 버블에 있는 수치는 동일한 상수입니다.
- [작동 방식(How it works)](https://nakodo.app/how-it-works)은 매뉴얼의 첫 번째 칩의 긴 형태이며, 어시스턴트가 간략하거나 검색에 대한 질문에 답변할 때 효과적으로 패러프레이징하는 페이지입니다.
만약 지원 답변에 숫자가 포함되어 있다면, 제품이 읽는 파일과 같은 곳에 넣어주세요. 이 게시물의 나머지 모든 내용은 세부 사항입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기