export type AddrItem = { zipNo: string; roadAddr: string; jibunAddr: string; ctpvNm: string; sggNm: string; }; export interface ZipSearchParams { roadNm?: string; bldgNoMno?: number | null; bldgNoSno?: number | null; page?: number; size?: number; } export interface Zip { zoneNo: string; ctpvNm: string; sggNm: string; lotno: string; zipAddr: string; } interface ZipListResponse { content?: Zip[]; totalElements?: number; } export type PostcodeSearchParams = { roadNm: string; bldgNoMno: number; bldgNoSno: number | null; page: number; }; export type ParsePostcodeInputResult = | { ok: true; value: PostcodeSearchParams; keyword: string } | { ok: false; message: string }; export type PostcodeSearchResult = { totalCount: number; items: AddrItem[]; }; type SuggestionSeed = { roadNm: string; bldgNoMno: number; }; /** * 주소 검색 API를 호출해 우편번호/주소 목록을 조회한다. * @param searchQuery 조회 조건 객체 * @param searchQuery.roadNm 도로명 * @param searchQuery.bldgNoMno 건물 본번 * @param searchQuery.bldgNoSno 건물 부번 * @param searchQuery.page 0부터 시작하는 페이지 번호 * @param searchQuery.size 페이지당 조회 건수 * @returns 주소 목록과 전체 건수를 포함한 응답 객체 */ async function findAddress(searchQuery: ZipSearchParams): Promise { if (!searchQuery) return {}; const params = new URLSearchParams(); if (searchQuery.roadNm) params.append("roadNm", searchQuery.roadNm); if (searchQuery.bldgNoMno != null) { params.append("bldgNoMno", searchQuery.bldgNoMno.toString()); } if (searchQuery.bldgNoSno != null) { params.append("bldgNoSno", searchQuery.bldgNoSno.toString()); } if (searchQuery.page != null) params.append("page", searchQuery.page.toString()); if (searchQuery.size != null) params.append("size", searchQuery.size.toString()); const url = `/cms-service/api/v1/zips?${params.toString()}`; const response = await fetch(url); if (!response.ok) throw new Error("Network response was not ok"); return (await response.json()) as ZipListResponse; } /** * 자동완성 후보를 만들기 위해 주소 항목에서 도로명과 건물 본번을 추출한다. * 우선 API 응답 필드의 값을 사용하고, 값이 없으면 표시 주소 문자열을 파싱해 보정한다. * @param item 주소 검색 결과 1건 * @returns 자동완성 생성에 사용할 도로명/건물본번 또는 추출 실패 시 null */ function extractSuggestionSeed(item: Zip): SuggestionSeed | null { const ext = item as Zip & { roadNm?: string; bldgNoMno?: number | string; }; const mnoByField = Number(ext.bldgNoMno); const roadNmByField = (ext.roadNm ?? "").trim(); if (roadNmByField && Number.isInteger(mnoByField) && mnoByField > 0) { return { roadNm: roadNmByField, bldgNoMno: mnoByField }; } const firstSegment = (item.zipAddr ?? "").split(",")[0]?.trim() ?? ""; if (!firstSegment) return null; const parts = firstSegment.split(/\s+/); const bldgToken = parts.at(-1) ?? ""; const matched = bldgToken.match(/^(\d+)(?:-\d+)?$/); if (!matched) return null; const bldgNoMno = Number(matched[1]); const roadNm = parts.slice(0, -1).join(" ").trim(); if (!roadNm || !Number.isInteger(bldgNoMno) || bldgNoMno <= 0) { return null; } return { roadNm, bldgNoMno }; } /** * 검색어에 허용되지 않는 특수문자나 SQL 예약어가 포함되어 있는지 검사한다. * @param search 사용자가 입력한 검색 문자열 * @returns 검색 가능하면 true, 차단 대상이면 false */ function searchFilter(search: string): boolean { if (search.length === 0) return true; const expText = /[%=><]/; if (expText.test(search)) { alert("특수문자를 입력 할수 없습니다."); return false; } const sqlArray = [ "OR", "SELECT", "INSERT", "DELETE", "UPDATE", "CREATE", "DROP", "EXEC", "UNION", "FETCH", "DECLARE", "TRUNCATE", ]; for (const sqlWord of sqlArray) { const regex = new RegExp(sqlWord, "gi"); if (regex.test(search)) { alert(`"${sqlWord}"와(과) 같은 특정문자로 검색할 수 없습니다.`); return false; } } return true; } /** * 단일 입력 주소 문자열을 "도로명 + 건물번호" 구조로 파싱해 API 조회 파라미터로 변환한다. * 예) "테헤란로 152-1" -> { roadNm: "테헤란로", bldgNoMno: 152, bldgNoSno: 1, page } * @param input 사용자가 입력한 주소 검색어 * @param page 1부터 시작하는 요청 페이지 번호 * @returns 파싱 성공 시 API 조회 파라미터와 정규화된 검색어, 실패 시 오류 메시지 */ export function parsePostcodeInput( input: string, page = 1, ): ParsePostcodeInputResult { const normalized = input.replace(/\s+/g, " ").trim(); if (!normalized) { return { ok: false, message: "도로명과 건물번호를 입력해 주세요. 예) 테헤란로 152-1", }; } const parts = normalized.split(" "); if (parts.length < 2) { if (/^\d+(?:-\d+)?$/.test(normalized)) { return { ok: false, message: "도로명을 입력해 주세요" }; } if (/[가-힣]/.test(normalized) && !/\d/.test(normalized)) { return { ok: false, message: "건물번호를 입력해 주세요" }; } return { ok: false, message: "도로명과 건물번호를 공백으로 구분해 주세요. 예) 테헤란로 152-1", }; } const bldgToken = parts.at(-1) ?? ""; const roadNm = parts.slice(0, -1).join(" ").trim(); if (!roadNm || !/[가-힣]/.test(roadNm)) { return { ok: false, message: "도로명을 입력해 주세요", }; } if (!/\d/.test(bldgToken)) { return { ok: false, message: "건물번호를 입력해 주세요" }; } if (!/^\d+(?:-\d+)?$/.test(bldgToken)) { return { ok: false, message: "건물번호는 숫자 또는 숫자-숫자 형식으로 입력해 주세요. 예) 152 또는 152-1", }; } const [mnoStr, snoStr] = bldgToken.split("-"); const bldgNoMno = Number(mnoStr); const bldgNoSno = snoStr ? Number(snoStr) : null; if (!Number.isInteger(bldgNoMno) || bldgNoMno <= 0) { return { ok: false, message: "건물본번은 1 이상의 숫자로 입력해 주세요." }; } if (bldgNoSno !== null && (!Number.isInteger(bldgNoSno) || bldgNoSno <= 0)) { return { ok: false, message: "건물부번은 1 이상의 숫자로 입력해 주세요." }; } return { ok: true, value: { roadNm, bldgNoMno, bldgNoSno, page, }, keyword: `${roadNm} ${bldgNoMno}${bldgNoSno ? `-${bldgNoSno}` : ""}`, }; } /** * 전체 도로명 주소 문자열에서 재검색에 사용할 "도로명 건물번호" 입력값만 추출한다. * 예) "테헤란로 152, OO빌딩" -> "테헤란로 152" * @param roadAddr 화면에 표시된 도로명 주소 문자열 * @returns 재검색 입력란에 넣을 수 있는 문자열, 추출 실패 시 빈 문자열 */ export function extractSearchInputFromRoadAddr(roadAddr: string): string { const firstSegment = roadAddr.split(",")[0]?.trim() ?? ""; if (!firstSegment) return ""; const parts = firstSegment.split(/\s+/); const bldgToken = parts.at(-1) ?? ""; if (!/^\d+(?:-\d+)?$/.test(bldgToken)) return ""; return `${parts.slice(0, -1).join(" ")} ${bldgToken}`.trim(); } /** * 사용자가 입력 중인 도로명을 기준으로 자동완성 후보를 조회한다. * 건물번호가 함께 입력된 경우에는 마지막 숫자 토큰을 제외한 도로명만으로 후보를 찾는다. * @param input 사용자가 입력창에 입력한 원본 문자열 * @returns 최대 5개의 "도로명 건물본번" 자동완성 문자열 목록 */ export async function fetchPostcodeSuggestions(input: string): Promise { const normalized = input.replace(/\s+/g, " ").trim(); const parts = normalized.split(" "); const tail = parts.at(-1) ?? ""; const roadNm = /^\d+(?:-\d+)?$/.test(tail) && parts.length > 1 ? parts.slice(0, -1).join(" ").trim() : normalized; if (!roadNm) return []; if (!searchFilter(roadNm)) return []; const data = await findAddress({ roadNm, page: 0, size: 50, }); const list = Array.isArray(data?.content) ? (data.content as Zip[]) : []; const suggestions: string[] = []; const seen = new Set(); for (const item of list) { const seed = extractSuggestionSeed(item); if (!seed) continue; const label = `${seed.roadNm} ${seed.bldgNoMno}`; if (seen.has(label)) continue; seen.add(label); suggestions.push(label); if (suggestions.length >= 5) break; } return suggestions; } /** * 우편번호 검색 API를 호출해 화면 표시용 우편번호 목록 형태로 변환한다. * @param params 조회 조건 객체 * @param params.roadNm 검색할 도로명 * @param params.bldgNoMno 건물 본번 * @param params.bldgNoSno 건물 부번 * @param params.page 1부터 시작하는 화면 페이지 번호 * @returns 전체 건수와 화면 표시용 주소 목록 */ export async function fetchPostcode( params: PostcodeSearchParams, ): Promise { const keywordForCheck = `${params.roadNm} ${params.bldgNoMno}${ params.bldgNoSno ? `-${params.bldgNoSno}` : "" }`; if (!searchFilter(keywordForCheck)) { return { totalCount: 0, items: [], }; } const query: ZipSearchParams = { roadNm: params.roadNm, bldgNoMno: params.bldgNoMno, bldgNoSno: params.bldgNoSno, page: Math.max(params.page - 1, 0), size: 20, }; const data = await findAddress(query); const list = Array.isArray(data?.content) ? (data.content as Zip[]) : []; const items: AddrItem[] = list.map((item) => ({ zipNo: item.zoneNo ?? "", roadAddr: item.zipAddr ?? "", jibunAddr: item.lotno ?? item.zipAddr ?? "", ctpvNm: item.ctpvNm ?? "", sggNm: item.sggNm ?? "", })); return { totalCount: Number(data?.totalElements ?? 0), items, }; }