사업자등록번호 확인: 체크섬·상태조회·진위확인이 각각 답하는 질문
123-45-67890 은 검증식을 통과합니다. 그리고 국세청에 등록된 적이 없습니다.
두 문장이 동시에 참입니다. 사업자등록번호를 「확인했다」고 말할 때, 그 말이 가리키는 것이 무엇인지부터 갈라야 하는 이유가 여기 있습니다. 가입 폼에서 번호를 걸러 내는 일과 계약서에 적힌 상대가 그 번호의 주인인지 확인하는 일은 같은 작업이 아닙니다.
「확인했다」가 가리키는 세 가지 질문
흔히 사업자등록번호 확인을 한 가지 일로 이해합니다. 실제로는 서로 다른 질문 셋이고, 각각 답하는 수단도 다릅니다.
| 묻는 것 | 수단 | 통과해도 모르는 것 |
|---|---|---|
| 자릿수와 형식이 맞는가 | 검증번호 계산(체크섬) | 그런 사업자가 있는지 |
| 등록된 사업자이고 지금 영업 중인가 | 국세청 상태조회 status |
그 번호가 이 상대의 것인지 |
| 이 번호가 이 사람의 것인가 | 국세청 진위확인 validate |
대표자가 바뀐 뒤의 상태 |
세 번째까지 가야 비로소 「이 상대가 말한 사업자가 맞다」에 가까워집니다. 첫 번째만 하고 확인 완료로 적어 두면, 나머지 두 질문은 답을 받지 않은 채 그대로 열려 있습니다.
검증식은 실재가 아니라 오타를 거르는 장치
사업자등록번호 열 자리의 마지막 한 자리는 앞 아홉 자리로 계산되는 검증번호입니다. 앞 아홉 자리에 1, 3, 7, 1, 3, 7, 1, 3, 5 를 차례로 곱해 더하고, 아홉째 자리에 5를 곱한 값의 십의 자리를 한 번 더 더한 뒤, 10으로 나눈 나머지를 10에서 뺀 한 자리가 마지막 자리와 같아야 합니다(계산 규칙).
const W = [1, 3, 7, 1, 3, 7, 1, 3, 5];
export function isWellFormed(bno: string): boolean {
const d = bno.replace(/\D/g, "");
if (d.length !== 10) return false;
const sum = W.reduce((acc, w, i) => acc + w * Number(d[i]), 0) + Math.floor((Number(d[8]) * 5) / 10);
return (10 - (sum % 10)) % 10 === Number(d[9]);
}이 계산은 서버를 부르지 않고 한 줄로 끝납니다. 그 값어치가 정확히 거기까지라는 점이 중요합니다. 규칙이 공개되어 있으니 검증식을 통과하는 번호는 얼마든지 만들어 낼 수 있고, 그래서 이 함수가 하는 일은 숫자를 잘못 옮겨 적은 경우를 걸러 내는 것입니다. 그 이상을 이 함수에 기대면 안 됩니다. 저는 이 함수를 isValid 가 아니라 isWellFormed 로 부르는 편이 낫다고 봅니다. 이름이 valid 면 호출하는 쪽이 반드시 그 이상을 읽습니다.
상태조회가 답하는 것, 그리고 답하지 않는 한 가지
국세청은 공공데이터포털을 통해 사업자등록정보 진위확인 및 상태조회 서비스를 엽니다. 상태조회는 번호만 받습니다.
const res = await fetch(
`https://api.odcloud.kr/api/nts-businessman/v1/status?serviceKey=${KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ b_no: ["0000000000"] }),
},
);돌아오는 것은 b_stt(사업자 상태)와 b_stt_cd, tax_type(과세유형), 폐업일자 end_dt 같은 값들이고, 응답 전체에는 요청 건수 request_cnt 와 조회된 건수 match_cnt 가 함께 실립니다. 상태 코드는 01 계속사업자, 02 휴업자, 03 폐업자입니다.
요청 건수와 조회 건수를 따로 돌려준다는 사실 자체가 한 가지를 말해 줍니다. 보낸 번호가 전부 답을 받는 것은 아닙니다. 등록된 적 없는 번호는 조회 결과에 담기지 않으므로, 응답 배열의 길이를 보내는 쪽 배열과 같다고 가정한 코드는 조용히 어긋납니다. 번호를 키로 맞춰 읽어야 합니다.
답하지 않는 것은 분명합니다. 「계속사업자」가 나와도 그 번호가 지금 나와 거래하려는 상대의 것인지는 한 글자도 말해 주지 않습니다. 남의 회사 번호를 적어 넣은 상대도 여기서는 초록불을 받습니다.
진위확인이 값 셋을 요구하는 까닭
진위확인은 번호에 대표자성명 p_nm 과 개업일자 start_dt 를 함께 요구합니다. 개업일자는 20000101 처럼 여덟 자리입니다.
const res = await fetch(
`https://api.odcloud.kr/api/nts-businessman/v1/validate?serviceKey=${KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
businesses: [{ b_no: "0000000000", start_dt: "20000101", p_nm: "홍길동" }],
}),
},
);결과는 valid 한 자리로 옵니다. 01 이면 세 값이 맞아떨어진 것이고, 02 면 그렇지 않은 것입니다. 이 API 가 답하는 질문은 실재가 아니라 귀속입니다. 상태조회를 아무리 여러 번 불러도 이 질문에는 닿지 못합니다.
여기서 입력이 까다로워집니다. 대표자성명은 국세청 원장에 적힌 그대로여야 하고, 개업일자도 정확해야 합니다. 상대가 사업자등록증을 그대로 옮겨 적지 않으면 02 가 나옵니다. 그러니 02 는 「가짜」가 아니라 「우리가 보낸 세 값의 조합이 맞지 않는다」입니다.
「불일치」와 「폐업」은 화면에서 갈라야 하는 사건
두 결과는 코드에서 한 덩어리로 묶이기 쉽습니다. 둘 다 통과 아님이고, 둘 다 빨간 글씨 한 줄로 끝나기 때문입니다. 그런데 담당자가 해야 할 다음 행동이 전혀 다릅니다.
| 결과 | 사실 | 다음에 할 일 |
|---|---|---|
valid=02 |
번호·대표자명·개업일자의 조합이 안 맞습니다 | 상대에게 사업자등록증대로 다시 받습니다 |
b_stt_cd=03 |
폐업한 사업자입니다 | 거래 자체를 다시 판단합니다 |
| 조회 결과 없음 | 등록된 적 없는 번호입니다 | 번호를 처음부터 다시 받습니다 |
문구를 하나로 합치면, 자기가 적은 이름 한 글자를 고치면 될 사람이 무엇을 고쳐야 하는지 모른 채 남습니다. 저는 이 세 갈래가 이 기능에서 가장 값이 나가는 부분이라고 봅니다. 요청을 보내고 응답을 읽는 일은 코드 몇 줄이지만, 결과를 사람이 읽을 말로 옮기는 일은 그렇지 않습니다.
30분 주기로 갱신되는 사본을 「지금」으로 읽을 때
포털은 이 자료가 30분 주기로 갱신된다고 밝히고 있습니다. 조회 결과는 국세청 원장을 실시간으로 들여다본 것이 아니라 그 사본을 읽은 것입니다. 평소에는 차이가 없지만, 계약처럼 시점이 남는 자리에서는 결과만큼 「언제 기준의 확인인가」가 중요합니다. 확인 시각을 결과와 함께 남겨 두는 편이 낫습니다.
호출 제한도 같은 문서에 있습니다. 한 번에 100건, 하루 100만 건입니다. 한 건씩 100번 부르는 대신 100건을 한 번에 보내라는 뜻이고, 이 API 가 배열을 받는 이유도 거기 있습니다.
서비스키가 URL 에 실린다는 사실
인증키가 쿼리 문자열로 들어갑니다. 브라우저에서 이 주소를 직접 부르면 키가 그대로 노출되고, 남이 우리 할당량을 씁니다. 우리 서버가 대신 부르고 화면은 우리 서버만 보게 두는 것이 이 구조에서 선택이 아니라 전제입니다.
그리고 증상이 원인을 가리키지 않는 자리가 하나 있습니다. 키 인코딩입니다. 포털은 Encoding 키와 Decoding 키 두 벌을 줍니다. 이미 인코딩된 키를 HTTP 클라이언트가 한 번 더 인코딩하면 인증이 실패하고, 반대로 특수문자가 든 Decoding 키를 인코딩 없이 그대로 붙여도 실패합니다. Spring 의 UriComponentsBuilder 가 키 안의 슬래시를 경로 구분자로 읽어 인코딩하지 않는 사례가 그 한 형태입니다. 증상이 「등록되지 않은 키」로 오기 때문에 발급부터 다시 하기 쉬운데, 대개 키는 멀쩡합니다.
확인 결과에서 남길 것과 남기지 않을 것
진위확인에 쓰는 대표자성명은 개인정보입니다. 확인이 끝난 뒤 그 값을 계속 쥐고 있을 근거가 있는지는 따로 판단할 문제입니다.
저라면 조회에 쓴 값은 남기지 않고 「언제, 어느 번호를, 어떤 결과로 확인했다」까지만 남기겠습니다. 나중에 필요한 것은 대개 「확인을 했는가」이지 「그때 어떤 이름을 넣었는가」가 아니기 때문입니다. 다만 이건 서비스마다 갈립니다. 확인 이력 자체가 규제 대상인 업종이라면 남길 것이 더 많아집니다.
여기까지가 확실한 부분
세 층위가 각각 무엇에 답하는지, 어떤 값을 요구하는지, 제한이 얼마인지는 명세가 정합니다. 그 바깥은 우리가 정합니다.
결과를 얼마나 캐시할지, 실패했을 때 가입이나 계약을 막을지 경고만 하고 통과시킬지, 휴업자를 폐업자와 같이 다룰지는 이 API 가 답하지 않습니다. 저도 확신이 없는 자리가 하나 있습니다. 휴업자는 사업자등록이 살아 있지만 지금 영업하지 않는 상태라, 거래를 막을 근거로 삼기에는 애매하고 무시하기에도 애매합니다. 이건 기술이 아니라 그 서비스가 무엇을 파는가에 따라 갈릴 문제라고 봅니다.
함께 읽기
- DUOLABS CP 기능 탐구 14: 매물, 임대, 민원을 연결하는 부동산·임대 운영부동산 운영에서는 공간의 상태가 계속 바뀝니다. 매물이 계약되고, 임대 기간이 지나며, 입주사의 요청이 생깁니다. 정보를 각각 관리하면 공실·갱신·민원 대응의 우선순위를 놓치기 쉽습니다. DUOLABS CP는 공간을 중심으로 세 가지 업무를 연결합니다.
- DUOLABS CP 기능 탐구 13: 배차, 운송장, 차량 기록을 잇는 물류·운송 운영물류 업무는 출발 전 배차, 이동 중 배송 상태, 운행 후 차량 기록이 이어져야 합니다. 각각을 전화와 종이로 관리하면 중복 배차나 인수 확인 누락, 운행거리 불일치가 생기기 쉽습니다. DUOLABS CP는 이 흐름을 세 개의 모듈로 정리합니다.
- DUOLABS CP 기능 탐구 12: 일보, 공정, 안전, 자재를 한 흐름으로 보는 건설·현장 운영현장은 매일 상황이 바뀝니다. 작업 기록, 계획 대비 진척, 안전 지적, 자재 이동이 따로 남으면 사무실에서는 문제를 늦게 알게 됩니다. DUOLABS CP의 건설·현장 영역은 현장 보고와 후속 조치를 하나의 관리 흐름으로 보는 예시입니다.
- DUOLABS CP 기능 탐구 11: 접수, 수납, 재방문을 잇는 의료·클리닉 운영클리닉의 운영 화면은 진료 판단을 대신하는 시스템이 아니라 환자가 기다리고, 수납하고, 다음 방문을 안내받는 과정이 끊기지 않게 도와야 합니다. DUOLABS CP의 의료·클리닉 영역은 비진료 운영에 초점을 맞춘 세 가지 화면을 보여 줍니다.
- DUOLABS CP 기능 탐구 10: 수강생, 출결, 수납, 성적을 연결하는 교육·학원 운영학원 운영 정보는 학생 명단, 출결표, 수강료 장부, 성적표로 쉽게 나뉩니다. 각각은 관리되어도 보호자에게 필요한 설명이나 미납·결석 대응은 늦어질 수 있습니다. DUOLABS CP의 교육·학원 영역은 학생 한 명의 운영 정보를 네 가지 관점에서 이어 봅니다.