feat: 주소 입력을 카카오 우편번호 검색으로 — 주소 오타·누락 원천 차단

- lib/postcode.js: 다음(카카오) 우편번호 서비스 지연 로딩 + embed 방식
  (팝업은 모바일에서 차단될 수 있어 폼 안에 펼치는 embed 채택, 학습 주석 포함)
- 가입·내 정보 페이지: 기본주소는 [주소찾기] 검색으로만 채워지는 읽기 전용,
  상세주소(동·호수)만 직접 입력 → 저장 시 '[우편번호] 도로명주소, 상세' 로 합침
- 검색 결과: 우편번호 + 표준 도로명주소 + 건물명 자동 입력
- 배경: 학생 2명의 가입 주소가 시/군/구 누락 등 불완전했음 —
  '사용자의 실수는 실수할 수 없는 UI로 막는다'

운영 검증: 버튼 클릭 → 카카오 위젯(postcode.map.kakao.com) embed 로드 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 12:01:36 +09:00
parent f557d7a189
commit 57a334545a
3 changed files with 187 additions and 14 deletions

View File

@ -0,0 +1,61 @@
/**
* 파일이 하는 :
* 다음(카카오) 우편번호 서비스를 불러와 주소 검색 UI를 띄운다.
* 회원가입· 정보의 [주소찾기] 버튼이 이걸 쓴다.
*
* 학습 포인트 직접 입력 대신 주소 검색인가?
* 주소는 "형식이 정해진 데이터". 손으로 치게 하면 // 누락, 오타, 지번주소가
* 뒤섞여 들어온다(실제로 그랬다). 검색으로 받으면 항상 표준 도로명주소 + 우편번호가 들어온다.
* 사용자의 실수는 사용자를 탓할 아니라 실수할 없는 UI로 막는 것이다.
*
* 학습 포인트 외부 스크립트 지연 로딩(lazy loading).
* 스크립트는 주소찾기를 누르는 순간에만 필요하다. 시작 미리 받으면
* 모든 페이지가 그만큼 느려진다. 그래서 버튼을 눌렀을 <script> 만들어 붙이고,
* 받았으면 Promise를 재사용한다( 다운로드하지 않는다).
*
* 학습 포인트 팝업이 아닌 embed 방식.
* window.open 팝업은 모바일 브라우저가 차단하기도 한다. embed는 우리가 div 안에
* 검색 화면을 그려 넣는 방식이라 차단당할 없고, 모바일에서도 자연스럽다.
*/
const SCRIPT_SRC = 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js';
let scriptPromise = null;
function loadScript() {
if (!scriptPromise) {
scriptPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = SCRIPT_SRC;
script.onload = () => resolve(window.daum);
script.onerror = () => {
scriptPromise = null; // 실패하면 다음 클릭 때 다시 시도할 수 있게 초기화
reject(new Error('우편번호 서비스 로딩 실패'));
};
document.head.appendChild(script);
});
}
return scriptPromise;
}
/**
* 주소 검색 화면을 container(div) 안에 펼친다.
*
* @param {HTMLElement} container 검색 UI를 그릴 div
* @param {(address: string) => void} onSelect 사용자가 주소를 고르면 완성된 주소 문자열을 받는다
* @returns {Promise<void>} 스크립트 로딩 실패 reject 호출한 쪽에서 안내를 띄운다
*/
export async function embedPostcode(container, onSelect) {
const daum = await loadScript();
container.innerHTML = ''; // 두 번째 열 때 이전 화면이 겹치지 않게 비운다
new daum.Postcode({
oncomplete: (data) => {
// 도로명주소를 기본으로, 건물명이 있으면 괄호로 붙인다. 우편번호는 맨 앞에.
// 예: "[06978] 서울 동작구 상도로17길 14-2 (OO빌딩)"
const building = data.buildingName ? ` (${data.buildingName})` : '';
onSelect(`[${data.zonecode}] ${data.roadAddress}${building}`);
},
width: '100%',
height: '100%',
}).embed(container);
}

View File

@ -1,7 +1,8 @@
// : " " . // : " " .
// ( , , . · .) // ( , , . · .)
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import client from '../api/client'; import client from '../api/client';
import { embedPostcode } from '../lib/postcode';
const COMPANY_DOMAIN = 'awesomedev.dev'; const COMPANY_DOMAIN = 'awesomedev.dev';
@ -31,6 +32,22 @@ export default function MyProfilePage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [saved, setSaved] = useState(''); const [saved, setSaved] = useState('');
// SignupPage ( + embed div + effect )
const [postcodeOpen, setPostcodeOpen] = useState(false);
const postcodeBoxRef = useRef(null);
useEffect(() => {
if (!postcodeOpen || !postcodeBoxRef.current) return;
embedPostcode(postcodeBoxRef.current, (address) => {
setForm((prev) => ({ ...prev, addressBase: address, addressDetail: '' }));
setSaved('');
setPostcodeOpen(false);
requestAnimationFrame(() => document.getElementById('pf-addressDetail')?.focus());
}).catch(() => {
setPostcodeOpen(false);
setError('주소 검색을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.');
});
}, [postcodeOpen]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -44,7 +61,9 @@ export default function MyProfilePage() {
// //
setForm({ setForm({
name: p.name, companyEmailLocal: p.companyEmailLocal, birthDate: p.birthDate, name: p.name, companyEmailLocal: p.companyEmailLocal, birthDate: p.birthDate,
phone: p.phone, address: p.address, school: p.school, major: p.major, phone: p.phone, school: p.school, major: p.major,
// . .
addressBase: p.address, addressDetail: '',
emergencyName: p.emergencyName, emergencyRelation: p.emergencyRelation, emergencyName: p.emergencyName, emergencyRelation: p.emergencyRelation,
emergencyPhone: p.emergencyPhone, emergencyPhone: p.emergencyPhone,
}); });
@ -78,7 +97,7 @@ export default function MyProfilePage() {
if (form.birthDate >= new Date().toISOString().slice(0, 10)) if (form.birthDate >= new Date().toISOString().slice(0, 10))
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.'; return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.'; if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
if (!form.address.trim()) return '주소를 입력해 주세요. (시/군/구부터 상세주소까지)'; if (!form.addressBase.trim()) return '[주소찾기] 버튼으로 주소를 선택해 주세요.';
if (!form.school.trim()) return '학교를 입력해 주세요.'; if (!form.school.trim()) return '학교를 입력해 주세요.';
if (!form.major.trim()) return '전공을 입력해 주세요.'; if (!form.major.trim()) return '전공을 입력해 주세요.';
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.'; if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
@ -99,12 +118,13 @@ export default function MyProfilePage() {
} }
setSaving(true); setSaving(true);
try { try {
const { addressBase, addressDetail, ...fields } = form;
const res = await client.put('/profile/me', { const res = await client.put('/profile/me', {
...form, ...fields,
name: form.name.trim(), name: form.name.trim(),
companyEmailLocal: form.companyEmailLocal.trim(), companyEmailLocal: form.companyEmailLocal.trim(),
phone: form.phone.trim(), phone: form.phone.trim(),
address: form.address.trim(), address: [addressBase.trim(), addressDetail.trim()].filter(Boolean).join(', '),
school: form.school.trim(), school: form.school.trim(),
major: form.major.trim(), major: form.major.trim(),
emergencyName: form.emergencyName.trim(), emergencyName: form.emergencyName.trim(),
@ -112,7 +132,12 @@ export default function MyProfilePage() {
emergencyPhone: form.emergencyPhone.trim(), emergencyPhone: form.emergencyPhone.trim(),
}); });
setSaved('저장했어요! ✅'); setSaved('저장했어요! ✅');
setForm((prev) => ({ ...prev, companyEmailLocal: res.data.companyEmailLocal })); setForm((prev) => ({
...prev,
companyEmailLocal: res.data.companyEmailLocal,
addressBase: res.data.address, //
addressDetail: '',
}));
} catch (err) { } catch (err) {
// ( ) // ( )
setError(err.response?.data?.message || '저장하지 못했어요. 잠시 후 다시 시도해 주세요.'); setError(err.response?.data?.message || '저장하지 못했어요. 잠시 후 다시 시도해 주세요.');
@ -131,7 +156,7 @@ export default function MyProfilePage() {
<h1> 정보</h1> <h1> 정보</h1>
<p> <p>
가입 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 있어요. 가입 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 있어요.
경리 서류에 쓰이니 <strong>주소는 //구부터 상세주소까지</strong> 적어 주세요. 주소는 <strong>[주소찾기] 검색</strong>하고 ·호수만 상세주소에 적어 주세요.
</p> </p>
</div> </div>
@ -166,8 +191,39 @@ export default function MyProfilePage() {
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} /> <Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
<Field label="연락처" k="phone" placeholder="010-1234-5678" value={form.phone} onChange={set} /> <Field label="연락처" k="phone" placeholder="010-1234-5678" value={form.phone} onChange={set} />
<Field label="주소 (시/군/구부터 상세주소까지)" k="address" {/* 주소 — 검색으로만 채워지는 읽기 전용 기본주소 + 직접 입력하는 상세주소 */}
placeholder="예: 서울 동작구 상도로17길 14-2, 501호" value={form.address} onChange={set} /> <div className="field">
<label htmlFor="pf-addressBase">주소</label>
<div style={{ display: 'flex', gap: 6 }}>
<input
id="pf-addressBase"
className="input"
value={form.addressBase}
readOnly
placeholder="[주소찾기]를 눌러 주세요"
onClick={() => setPostcodeOpen(true)}
style={{ flex: 1, cursor: 'pointer' }}
/>
<button type="button" className="btn" onClick={() => setPostcodeOpen((v) => !v)}>
주소찾기
</button>
</div>
{postcodeOpen && (
<div
ref={postcodeBoxRef}
style={{ height: 420, marginTop: 8, border: '1px solid var(--line)',
borderRadius: 10, overflow: 'hidden' }}
/>
)}
<input
id="pf-addressDetail"
className="input"
style={{ marginTop: 6 }}
value={form.addressDetail}
onChange={(e) => set('addressDetail', e.target.value)}
placeholder="상세주소 (동·호수 등) — 주소를 다시 고르면 여기에 입력"
/>
</div>
<Field label="학교" k="school" value={form.school} onChange={set} /> <Field label="학교" k="school" value={form.school} onChange={set} />
<Field label="전공(학과)" k="major" value={form.major} onChange={set} /> <Field label="전공(학과)" k="major" value={form.major} onChange={set} />

View File

@ -1,10 +1,11 @@
// : . + () // : . + ()
// POST /api/auth/signup , . // POST /api/auth/signup , .
// Layout( ) . // Layout( ) .
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, Link, Navigate } from 'react-router-dom'; import { useNavigate, Link, Navigate } from 'react-router-dom';
import { useAuth } from '../AuthContext'; import { useAuth } from '../AuthContext';
import client from '../api/client'; import client from '../api/client';
import { embedPostcode } from '../lib/postcode';
const TRACKS = [ const TRACKS = [
{ value: 'DEV', label: '개발' }, { value: 'DEV', label: '개발' },
@ -18,7 +19,10 @@ const COMPANY_DOMAIN = 'awesomedev.dev'; // 회사 이메일 도메인은 고정
// "" " " . // "" " " .
const EMPTY = { const EMPTY = {
username: '', password: '', passwordConfirm: '', name: '', track: 'DEV', username: '', password: '', passwordConfirm: '', name: '', track: 'DEV',
companyEmailLocal: '', birthDate: '', phone: '', address: '', school: '', major: '', companyEmailLocal: '', birthDate: '', phone: '', school: '', major: '',
// () , .
// // · . lib/postcode.js .
addressBase: '', addressDetail: '',
emergencyName: '', emergencyRelation: '', emergencyPhone: '', emergencyName: '', emergencyRelation: '', emergencyPhone: '',
privacyConsent: false, // · privacyConsent: false, // ·
}; };
@ -61,6 +65,25 @@ export default function SignupPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
// ( ) UI div
const [postcodeOpen, setPostcodeOpen] = useState(false);
const postcodeBoxRef = useRef(null);
// (div ) .
// : embed div
// useEffect (setState effect ).
useEffect(() => {
if (!postcodeOpen || !postcodeBoxRef.current) return;
embedPostcode(postcodeBoxRef.current, (address) => {
setForm((prev) => ({ ...prev, addressBase: address }));
setPostcodeOpen(false);
//
requestAnimationFrame(() => document.getElementById('su-addressDetail')?.focus());
}).catch(() => {
setPostcodeOpen(false);
setError('주소 검색을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.');
});
}, [postcodeOpen]);
if (user) { if (user) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
@ -88,7 +111,7 @@ export default function SignupPage() {
if (form.birthDate >= new Date().toISOString().slice(0, 10)) if (form.birthDate >= new Date().toISOString().slice(0, 10))
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.'; return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.'; if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
if (!form.address.trim()) return '주소를 입력해 주세요.'; if (!form.addressBase) return '[주소찾기] 버튼으로 주소를 선택해 주세요.';
if (!form.school.trim()) return '학교를 입력해 주세요.'; if (!form.school.trim()) return '학교를 입력해 주세요.';
if (!form.major.trim()) return '전공을 입력해 주세요.'; if (!form.major.trim()) return '전공을 입력해 주세요.';
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.'; if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
@ -117,7 +140,8 @@ export default function SignupPage() {
companyEmailLocal: form.companyEmailLocal.trim(), companyEmailLocal: form.companyEmailLocal.trim(),
birthDate: form.birthDate, // "2007-03-15" LocalDate birthDate: form.birthDate, // "2007-03-15" LocalDate
phone: form.phone.trim(), phone: form.phone.trim(),
address: form.address.trim(), // + ( )
address: [form.addressBase, form.addressDetail.trim()].filter(Boolean).join(', '),
school: form.school.trim(), school: form.school.trim(),
major: form.major.trim(), major: form.major.trim(),
emergencyName: form.emergencyName.trim(), emergencyName: form.emergencyName.trim(),
@ -215,7 +239,39 @@ export default function SignupPage() {
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} /> <Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
<Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" value={form.phone} onChange={set} /> <Field label="연락처" k="phone" placeholder="010-1234-5678" autoComplete="tel" value={form.phone} onChange={set} />
<Field label="주소" k="address" placeholder="시/군/구 이하" value={form.address} onChange={set} /> {/* 주소 — 직접 입력 대신 검색으로. 기본주소는 읽기 전용(실수 원천 차단) */}
<div className="field">
<label htmlFor="su-addressBase">주소</label>
<div style={{ display: 'flex', gap: 6 }}>
<input
id="su-addressBase"
className="input"
value={form.addressBase}
readOnly
placeholder="[주소찾기]를 눌러 주세요"
onClick={() => setPostcodeOpen(true)}
style={{ flex: 1, cursor: 'pointer' }}
/>
<button type="button" className="btn" onClick={() => setPostcodeOpen((v) => !v)}>
주소찾기
</button>
</div>
{postcodeOpen && (
<div
ref={postcodeBoxRef}
style={{ height: 420, marginTop: 8, border: '1px solid var(--line)',
borderRadius: 10, overflow: 'hidden' }}
/>
)}
<input
id="su-addressDetail"
className="input"
style={{ marginTop: 6 }}
value={form.addressDetail}
onChange={(e) => set('addressDetail', e.target.value)}
placeholder="상세주소 (동·호수 등, 없으면 비워두세요)"
/>
</div>
<Field label="학교" k="school" placeholder="미림마이스터고등학교" value={form.school} onChange={set} /> <Field label="학교" k="school" placeholder="미림마이스터고등학교" value={form.school} onChange={set} />
<Field label="전공(학과)" k="major" value={form.major} onChange={set} /> <Field label="전공(학과)" k="major" value={form.major} onChange={set} />