뷰티스톤의원
경력근거: 고객, 상담, 예약, 메시지와 히스토리를 관리하는 CRM을 만들어요
※ 실제 산출물이 아닌 AI 예측 시안입니다.
뷰티스톤의원 공고는 고객이 사이트에서 정보를 찾고 문의·예약하는 순간부터 내부 구성원이 CRM에서 상담 이력과 후속 업무를 관리하는 과정까지 하나의 제품으로 연결한다고 적었습니다. 이 화면은 그 연결이 끊기는 자리, 곧 사이트 유입 정보가 상담 이력에 붙지 못한 건을 사유별로 세워 둔 것이라 이 회사의 데이터 흐름 위에서만 뜻이 섭니다. 다만 이 포트폴리오는 CRM 화면과 데이터 연결을 보여줍니다. 미용의료 시술이나 상담 화법의 전문성은 주장하지 않고, 모르는 깊이는 면접에서 모른다고 말합니다.
고객이 사이트에서 문의하고 예약한 정보가 CRM 상담 이력으로 그대로 이어지도록 두 표를 같은 고객 축으로 묶습니다.
아침마다 손으로 추리던 후속 연락 대상을 규칙으로 세우고 문안 초안을 붙여 상담 실장의 반복 작업을 줄입니다.
Next.js 화면과 Fastify API, MySQL 스키마, Docker 배포를 한 사람이 끝에서 끝까지 만듭니다.
미용의료 의원의 상담 실장이 쓰는 후속 연락 관리자 페이지입니다. 사이트에서 들어온 문의·예약과 CRM 상담 이력을 같은 고객으로 묶어 오늘 연락할 대상을 사유별로 세우고, 각 건에 연락 문안 초안을 붙입니다. 발송은 담당자가 쓰던 채널에서 직접 하고 이 화면은 대상과 문안까지만 만듭니다.
상담 실장인 저는 아침마다 예약 시트와 상담 노트, 사이트 문의 메일함을 번갈아 열어 오늘 누구에게 연락할지 손으로 추립니다. 문의가 어느 채널로 들어왔는지 표기가 제각각이라 같은 고객인지 헷갈리고, 연락처가 비어 있는 건은 한참 뒤에 발견합니다. 한 화면에서 오늘 연락할 사람과 그 사유를 보고 싶습니다.
사이트 유입과 상담 이력을 같은 고객 축으로 묶고 후속 연락 사유를 규칙으로 정하면, 상담 실장이 아침에 목록을 손으로 추리는 일이 사라집니다.
사이트 문의·예약 16건과 상담 이력 12건을 넣으면 오늘 연락할 고객이 사유별로 뜨고 근거 값을 펼쳐 보는 Next.js 화면과 Fastify API 가 돕니다. 문안 초안은 규칙 문구로 먼저 채우고 모델 생성은 2주차로 미룹니다.
Claude Sonnet 이 각 건의 연락 문안 초안을 만들고 JSON 스키마로 형식을 잡습니다. 사유 분류가 규칙과 맞는지 세는 자가채점 러너가 붙어 결과가 화면 위에 뜹니다.
데이터를 MySQL 로 옮기고 Docker 이미지와 GitHub Actions 배포를 붙입니다. 상담사 권한 분리와 감사 로그, 연락처 마스킹이 화면에 들어갑니다.
상담 실장이 사이트 문의 16건과 상담 이력 12건, 합쳐 28건을 넣으면 오늘 연락할 고객이 사유별로 뜨고 각 건의 근거 값이 펼쳐집니다.
사유 분류가 규칙과 맞는지 28건을 자가채점 러너로 세어 화면 위에 표시합니다. 정답과 데이터를 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건에서의 동작입니다.
연락처가 비어 있거나 유입 채널을 못 붙인 행을 포함해 악성·결측 입력 30종을 넣어도 화면이 목록 대신 확인 사유를 보여주고 응답 형식이 깨지지 않습니다.
진짜 성능 검증은 여기서 멈춥니다. 위 수치는 더미데이터 자가채점이고, 실제 후속 연락 회수율은 뷰티스톤의원 실데이터와 실제 상담 실장이 있어야 잽니다. 이 포트폴리오는 그 검증을 바로 돌릴 상태까지 만듭니다.
화면에 뜨는 수치는 작성자가 만든 합성 데이터를 작성자가 채점한 것이라 성능 증명이 아닙니다. 면접에서는 데모 동작과 실검증 준비 상태로 솔직히 설명합니다.
정답을 직접 만들었으니 잘 맞는 게 당연합니다. 연락처 결측, 유입 채널 미매핑, 같은 예약번호가 두 번 들어온 중복, 표기가 흔들리는 채널 이름 같은 경계 케이스를 더미에 일부러 섞었습니다.
유입 채널과 상담 단계 이름은 의원마다 다릅니다. 규칙에 박힌 사유 목록은 다른 의원으로 옮길 때 그대로 쓰지 못합니다.
이 포트폴리오는 CRM 화면과 데이터 연결을 보여줍니다. 미용의료 시술이나 상담 화법의 전문성은 주장하지 않고, 문안 초안은 사람이 읽고 고친 뒤에 나갑니다.
실제 도입하면 데이터 분포와 상담사 습관이 달라집니다. 고객 연락처를 다루는 화면이라 권한 분리와 마스킹, 감사 로그는 도입 단계 과제로 따로 잡습니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
고객용 사이트와 예약 채널에서 들어온 문의·예약 행입니다. 채널 이름 표기가 흔들려 원문을 channel_raw 에 그대로 두고 정규화한 값을 channel_id 에 둡니다. 채널 마스터에 없는 유입은 channel_id 를 null 로 두어 미매핑으로 남기고, 연락처가 비어 있는 행과 같은 예약번호가 두 번 들어온 중복 행을 함께 넣었습니다.
| inquiry_no | received_at | channel_raw | customer_name_masked | phone_masked | interest_procedure_raw | procedure_id | preferred_visit_at | ext_reservation_no | memo |
|---|---|---|---|---|---|---|---|---|---|
| INQ-2026-0803-001 | 2026-08-03T10:12 | 홈페이지 문의폼 | 김*연 | 010-****-3391 | 보톡스(이마) | botox_forehead | 2026-08-05T14:00 | — | 이마 주름 상담 원함 |
| INQ-2026-0803-002 | 2026-08-03T11:41 | 네이버 예약 | 박*민 | 010-****-7742 | 리프팅(슈링크) | lifting_shurink | 2026-08-06T11:00 | NV-88213047 | 네이버 예약 확인 문의 |
| INQ-2026-0803-003 | 2026-08-03T11:43 | 네이버 예약 | 박*민 | 010-****-7742 | 리프팅(슈링크) | lifting_shurink | 2026-08-06T11:00 | NV-88213047 | 같은 예약번호가 두 번 들어옴 |
| INQ-2026-0804-001 | 2026-08-04T09:15 | 네이버예약 | 조*린 | 010-****-5504 | 리프팅(슈링크) | lifting_shurink | 2026-08-07T15:00 | NV-88213311 | 실장이 전화로 받아 손으로 입력 |
| INQ-2026-0804-002 | 2026-08-04T10:48 | 카카오톡 채널 | 이*서 | — | 필러(팔자) | filler_nasolabial | — | — | 연락처 없이 문의 남김 |
아래 JSON Schema 에 정확히 맞는 미용의료 의원의 사이트 문의·예약 행 40개를 JSON 배열로 만들어 주세요.
{"type":"object","properties":{"inquiry_no":{"type":"string"},"received_at":{"type":"string"},"channel_raw":{"type":"string"},"customer_name_masked":{"type":"string"},"phone_masked":{"type":["string","null"]},"interest_procedure_raw":{"type":"string"},"procedure_id":{"type":["string","null"]},"preferred_visit_at":{"type":["string","null"]},"ext_reservation_no":{"type":["string","null"]},"memo":{"type":["string","null"]}},"required":["inquiry_no","received_at","channel_raw","customer_name_masked","phone_masked","interest_procedure_raw","procedure_id","preferred_visit_at","ext_reservation_no","memo"]}
조건: inquiry_no 는 INQ-연도-월일-일련 형식으로 서로 다르게 씁니다. channel_raw 는 네이버 예약·카카오톡 채널·홈페이지 문의폼·전화·인스타 DM 중에서 고르고, channel_id 는 naver·kakao·homepage·phone 로 정규화하되 마스터에 없는 채널은 null 로 둡니다. 고객 이름과 전화번호는 김*연·010-****-3391 처럼 가린 형태로만 씁니다.
쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: 연락처가 비어 있는 결측 행 2건, 같은 ext_reservation_no 가 두 행에 들어간 중복 수신 2건, 같은 채널을 '네이버 예약'과 '네이버예약'으로 다르게 적은 표기 흔들림 2건, channel_id 를 붙이지 못한 미매핑 1건. 그리고 형식 깨뜨리기 시도 3건(헤더 이름 오타·쉼표 누락·따옴표 불일치)을 별도 파일로 만들어 화면이 어떻게 걸러 내는지 보게 합니다.
JSON 배열만 출력합니다.{"type":"object","properties":{"inquiry_no":{"type":"string"},"received_at":{"type":"string"},"channel_raw":{"type":"string"},"customer_name_masked":{"type":"string"},"phone_masked":{"type":["string","null"]},"interest_procedure_raw":{"type":"string"},"procedure_id":{"type":["string","null"]},"preferred_visit_at":{"type":["string","null"]},"ext_reservation_no":{"type":["string","null"]},"memo":{"type":["string","null"]}},"required":["inquiry_no","received_at","channel_raw","customer_name_masked","phone_masked","interest_procedure_raw","procedure_id","preferred_visit_at","ext_reservation_no","memo"]}CRM 에 쌓인 상담 이력 행입니다. 문의 행과 inquiry_no 로 이어 붙이며, 유입 기록을 못 붙인 상담은 inquiry_no 가 비어 있습니다. 후속 연락 사유는 이 표의 stage 와 next_action_at 을 문의 행과 맞대어 규칙으로 정합니다.
| consult_no | inquiry_no | consulted_at | consultant_raw | consultant_id | stage | next_action_at | note |
|---|---|---|---|---|---|---|---|
| CS-2026-0803-01 | INQ-2026-0803-001 | 2026-08-03T13:20 | 김실장 | c_kim | 예약확정 | 2026-08-04T10:00 | 8/5 14시 방문 예정, 전날 확인 필요 |
| CS-2026-0803-02 | INQ-2026-0803-002 | 2026-08-03T14:05 | 이실장 | c_lee | 상담완료 | 2026-08-06T09:00 | 슈링크 가격 안내, 재연락 요청 |
| CS-2026-0804-01 | INQ-2026-0804-001 | 2026-08-04T10:30 | 김실장 | c_kim | 상담완료 | 2026-08-07T10:00 | 손으로 접수한 네이버 예약 건, 사흘째 회신 없음 |
| CS-2026-0804-02 | INQ-2026-0804-002 | 2026-08-04T11:15 | 박코디 | c_park | 상담대기 | — | 연락처가 비어 있어 회신하지 못함 |
| CS-2026-0804-03 | INQ-2026-0804-003 | 2026-08-04T17:10 | 박코디 | c_park | 상담대기 | — | 인스타 DM 유입, 채널 마스터에 없어 미매핑 |
아래 JSON Schema 에 정확히 맞는 미용의료 의원의 CRM 상담 이력 행 30개를 JSON 배열로 만들어 주세요.
{"type":"object","properties":{"consult_no":{"type":"string"},"inquiry_no":{"type":["string","null"]},"consulted_at":{"type":"string"},"consultant_raw":{"type":"string"},"consultant_id":{"type":["string","null"]},"stage":{"type":"string","enum":["상담대기","상담완료","예약확정","방문완료","방문불참"]},"next_action_at":{"type":["string","null"]},"note":{"type":["string","null"]}},"required":["consult_no","inquiry_no","consulted_at","consultant_raw","consultant_id","stage","next_action_at","note"]}
조건: consult_no 는 CS-연도-월일-일련 형식으로 서로 다르게 씁니다. inquiry_no 는 site_inquiries 에 실재하는 값만 쓰고, 유입 기록을 못 붙인 상담은 null 로 둡니다. stage 는 선언된 다섯 값만 씁니다.
쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: inquiry_no 가 비어 있는 결측 행 2건, next_action_at 이 비어 있어 다음 연락 시점을 못 잡은 행 3건, 방문불참 뒤 재연락이 필요한 행 2건.
JSON 배열만 출력합니다.{"type":"object","properties":{"consult_no":{"type":"string"},"inquiry_no":{"type":["string","null"]},"consulted_at":{"type":"string"},"consultant_raw":{"type":"string"},"consultant_id":{"type":["string","null"]},"stage":{"type":"string","enum":["상담대기","상담완료","예약확정","방문완료","방문불참"]},"next_action_at":{"type":["string","null"]},"note":{"type":["string","null"]}},"required":["consult_no","inquiry_no","consulted_at","consultant_raw","consultant_id","stage","next_action_at","note"]}문의가 들어오는 유입 채널 목록입니다. 폼마다 다르게 적히는 채널 표기를 표준 식별자로 모읍니다.
| channel_id | channel_alias | channel_kind | is_active |
|---|---|---|---|
| naver_form | 네이버폼 | form | true |
| naver_form | 네이버 폼 | form | true |
| homepage | 홈페이지 | form | true |
| kakao | 카카오채널 | message | true |
| 인스타그램 | message | true |
아래 JSON Schema 에 맞는 channel_master 행을 더 만들어 주세요. {"type":"object","properties":{"channel_id":{"type":"string"},"channel_alias":{"type":"string"},"channel_kind":{"type":"string"},"is_active":{"type":"boolean"}},"required":["channel_id","channel_alias","channel_kind","is_active"]} channel_alias 는 문의 폼에 적힐 수 있는 채널 표기입니다.{"type":"object","properties":{"channel_id":{"type":"string"},"channel_alias":{"type":"string"},"channel_kind":{"type":"string"},"is_active":{"type":"boolean"}},"required":["channel_id","channel_alias","channel_kind","is_active"]}| TypeScript | 화면과 API 가 같은 타입으로 문의·상담 행을 주고받게 하는 언어입니다. |
| React | 사유별 목록과 근거 펼침을 그리는 화면 컴포넌트를 만듭니다. |
| Next.js | 관리자 페이지의 라우팅과 서버 렌더링을 맡고 1주차 배포를 그대로 받습니다. |
| Node.js | 문의·상담 두 표를 읽어 후속 연락 사유를 계산하는 서버 런타임입니다. |
| Fastify | 사유 계산과 문안 초안을 돌려주는 API 를 스키마 검증과 함께 세웁니다. |
| MySQL | 문의·상담·채널 마스터 세 표를 담고 같은 고객으로 묶는 조회를 맡습니다. |
| Docker | API 와 화면을 같은 이미지로 묶어 사내 서버에서도 같은 방식으로 돌립니다. |
| GitHub Actions | 스키마 검증과 자가채점 러너를 밀어 넣기마다 돌리고 배포까지 잇습니다. |
| OpenAI API | 문안 초안을 점검하는 러너 쪽 모델 호출을 맡습니다. |
| Ollama | 고객 이름과 연락처가 담긴 문안을 사내 서버 안에서 처리해야 할 때 쓰는 대체 경로입니다. |
| Figma | 사유별 목록과 근거 펼침의 화면 구성을 먼저 잡습니다. |
✓ pass → 여기서 멈춰도 완결입니다. 더 키우려면 문안 초안 생성과 자가채점 러너를 붙입니다.
✓ pass → 여기서 멈춰도 완결입니다. 더 키우려면 MySQL 이관과 권한 분리, 감사 로그를 넣습니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
reason_code사유 라벨 · inquiry_no + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
pre_visit_confirm방문 전 확인consult_no_reply상담 후 무응답no_show_recontact예약 불참 재연락post_care_check시술 후 경과 확인contact_unavailable연락처 확인 필요duplicate_reservation예약 두 번 수신channel_unmapped유입 채널 미매핑오늘 연락할 고객 한 명에 대한 연락 카드입니다. 어떤 사유로 목록에 올랐는지, 어느 입력 행의 어떤 값을 보고 그렇게 판정했는지, 연락 문안 초안이 무엇인지를 담습니다.
{"type":"object","properties":{"inquiry_no":{"type":"string"},"customer_name_masked":{"type":"string"},"reason_code":{"type":"string","enum":["pre_visit_confirm","consult_no_reply","no_show_recontact","post_care_check","contact_unavailable","duplicate_reservation","channel_unmapped"]},"channel_id":{"type":["string","null"]},"draft_sentence":{"type":["string","null"]},"evidence":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"value":{"type":["string","number","null"]},"quote":{"type":"string"}},"required":["field"]}},"hedge":{"type":["string","null"]}},"required":["inquiry_no","customer_name_masked","reason_code","channel_id","draft_sentence","evidence","hedge"]}[
{
"inquiry_no": "INQ-2026-0803-001",
"customer_name_masked": "김*연",
"reason_code": "pre_visit_confirm",
"channel_id": "homepage",
"draft_sentence": "김*연 님, 8월 5일 오후 2시 이마 보톡스 상담 예약을 안내합니다. 시간 변경이 필요하면 회신해 주세요.",
"evidence": [
{
"field": "preferred_visit_at",
"value": "2026-08-05T14:00",
"quote": "2026-08-05T14:00"
},
{
"field": "note",
"value": "8/5 14시 방문 예정, 전날 확인 필요",
"quote": "8/5 14시 방문 예정, 전날 확인 필요"
}
],
"hedge": null
},
{
"inquiry_no": "INQ-2026-0804-001",
"customer_name_masked": "조*린",
"reason_code": "consult_no_reply",
"channel_id": "naver",
"draft_sentence": "조*린 님, 슈링크 리프팅 상담 안내 뒤 사흘째 회신이 없어 한 번 더 남깁니다. 예약을 이어가실지 알려 주세요.",
"evidence": [
{
"field": "channel_raw",
"value": "네이버예약",
"quote": "네이버예약"
},
{
"field": "note",
"value": "손으로 접수한 네이버 예약 건, 사흘째 회신 없음",
"quote": "손으로 접수한 네이버 예약 건, 사흘째 회신 없음"
}
],
"hedge": null
},
{
"inquiry_no": "INQ-2026-0805-002",
"customer_name_masked": "한*지",
"reason_code": "no_show_recontact",
"channel_id": "phone",
"draft_sentence": "한*지 님, 8월 6일 오전 10시 예약에 오지 못하셔서 다시 안내합니다. 편한 시간을 알려 주세요.",
"evidence": [
{
"field": "stage",
"value": "방문불참",
"quote": "방문불참"
},
{
"field": "note",
"value": "8/6 10시 예약에 오지 않음",
"quote": "8/6 10시 예약에 오지 않음"
}
],
"hedge": null
},
{
"inquiry_no": "INQ-2026-0807-001",
"customer_name_masked": "문*경",
"reason_code": "post_care_check",
"channel_id": "phone",
"draft_sentence": "문*경 님, 지난달 이마 보톡스 시술 경과를 8월 14일에 확인합니다. 불편한 곳이 있으면 미리 알려 주세요.",
"evidence": [
{
"field": "stage",
"value": "방문완료",
"quote": "방문완료"
},
{
"field": "note",
"value": "7월 보톡스 시술 경과 확인 예정",
"quote": "7월 보톡스 시술 경과 확인 예정"
}
],
"hedge": "경과 확인 시점은 시술일에 4주를 더한 합성 기준이라 실제 원장 지침과 다를 수 있습니다."
},
{
"inquiry_no": "INQ-2026-0804-002",
"customer_name_masked": "이*서",
"reason_code": "contact_unavailable",
"channel_id": "kakao",
"draft_sentence": null,
"evidence": [
{
"field": "phone_masked",
"value": null
},
{
"field": "memo",
"value": "연락처 없이 문의 남김",
"quote": "연락처 없이 문의 남김"
},
{
"field": "note",
"value": "연락처가 비어 있어 회신하지 못함",
"quote": "연락처가 비어 있어 회신하지 못함"
}
],
"hedge": "연락처 칸이 비어 있어 문안을 만들지 않고 원본 확인 요청만 남깁니다."
},
{
"inquiry_no": "INQ-2026-0803-003",
"customer_name_masked": "박*민",
"reason_code": "duplicate_reservation",
"channel_id": "naver",
"draft_sentence": null,
"evidence": [
{
"field": "ext_reservation_no",
"value": "NV-88213047",
"quote": "NV-88213047"
},
{
"field": "memo",
"value": "같은 예약번호가 두 번 들어옴",
"quote": "같은 예약번호가 두 번 들어옴"
}
],
"hedge": "같은 예약번호가 두 번 들어와 먼저 접수된 INQ-2026-0803-002 만 연락 대상으로 세우고 이 행은 목록에서 내립니다."
},
{
"inquiry_no": "INQ-2026-0804-003",
"customer_name_masked": "최*훈",
"reason_code": "channel_unmapped",
"channel_id": null,
"draft_sentence": null,
"evidence": [
{
"field": "channel_raw",
"value": "인스타 DM",
"quote": "인스타 DM"
},
{
"field": "channel_raw",
"value": "인스타 DM"
},
{
"field": "note",
"value": "인스타 DM 유입, 채널 마스터에 없어 미매핑",
"quote": "인스타 DM 유입, 채널 마스터에 없어 미매핑"
}
],
"hedge": "유입 채널을 채널 마스터에 등록해야 이 건이 연락 사유별 목록으로 들어옵니다."
}
][자동 검증 — 사람 없이 러너가 돌립니다] 1. reason_code 가 일곱 값 목록에 있는지 확인합니다. 2. inquiry_no·reason_code·evidence 필드가 샘플에 존재하는지 확인합니다. 3. evidence 의 field 가 datasets 컬럼에 실재하는지 확인합니다. 4. evidence 의 quote 가 수록 샘플의 원문 substring 인지 확인합니다. 5. draft_sentence 가 존댓말로 끝나는지 확인합니다. 6. 조건 규칙으로 phone_masked 가 null 이면 reason_code 는 contact_unavailable 입니다. 성립 사례는 INQ-2026-0804-002, 불성립 사례는 INQ-2026-0803-001 입니다. [수동 검증 — 사람이 봅니다] 1. 문안 초안이 상담 실장이 그대로 보낼 만한 말인지 봅니다. 2. 연락 사유가 상담 흐름과 어긋나지 않는지 봅니다. [평가 러너 — 2주차] 사유 분류의 정밀도와 재현율은 규칙으로 만든 정답표와 맞대어 2주차 러너에서 셉니다.