2026/07/14

오늘의 이야기

Apps in Toss에서 사업자번호 조회 미니앱 만들기


미니앱




billcorea-saupja는 토스 앱 안에서 사업자등록번호의 상태와 진위 여부를 빠르게 확인할 수 있도록 만든 WebView 기반 미니앱입니다. 국세청 공공데이터포털의 사업자등록정보 진위확인 및 상태조회 서비스를 호출하고, 결과를 모바일 화면에 맞게 정리해서 보여주는 것이 핵심입니다.


이 글에서는 앱을 어떤 기준으로 기획했고, 어떤 단계로 개발했으며, 실제 구현에서 주의해야 할 점과 중요한 샘플 코드를 정리합니다.


개발 기획


문제 정의


사업자등록번호는 거래 전 확인, 세금계산서 발행 전 검증, 신규 거래처 등록 같은 업무에서 자주 필요합니다. 하지만 사용자가 별도 웹사이트를 열고 번호를 입력해 확인하는 과정은 모바일 환경에서 번거롭습니다.


이 앱의 목표는 다음과 같습니다.



  • 사업자등록번호 10자리만 입력하면 사업자 상태를 조회한다.

  • 사업자등록번호, 대표자명, 개업일자를 입력하면 진위 여부를 확인한다.

  • 토스 앱 안의 모바일 화면에서 입력, 조회, 결과 확인이 끊기지 않도록 구성한다.

  • 비게임 미니앱이므로 TDS Mobile 컴포넌트를 사용해 토스 UX와 맞춘다.


주요 기능



  1. 사업자등록 상태조회

    • 입력값: 사업자등록번호

    • 호출 API: POST /status

    • 결과: 사업자 상태, 과세 유형, 폐업일, 세금계산서 적용일 등



  2. 사업자등록정보 진위확인

    • 입력값: 사업자등록번호, 대표자 성명, 개업일자, 선택 상호

    • 호출 API: POST /validate

    • 결과: 일치/불일치 여부, 확인 메시지, 상태 정보



  3. 선택적 인앱 광고

    • VITE_AD_BANNER_ID가 있을 때만 토스 배너 광고를 표시한다.




기술 스택


프로젝트는 Apps in Toss WebView 기반으로 구성했습니다.


{
"dependencies": {
"@apps-in-toss/web-framework": "^2.10.4",
"@toss/tds-mobile": "latest",
"@toss/tds-mobile-ait": "latest",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}

개발과 빌드 명령은 다음과 같습니다.


npm run dev
npm run build
npm run deploy

단, 이 프로젝트의 개발 메모에는 "빌드 후 로컬 테스트는 진행하지 않기"라는 요청 사항이 있으므로, 실제 검증 방식은 배포/샌드박스 테스트 정책에 맞춰 조정해야 합니다.


개발 단계


1. Granite 설정


granite.config.ts에서 앱 이름, 브랜드 컬러, 아이콘, 개발 서버 설정을 정의합니다.


import { defineConfig } from "@apps-in-toss/web-framework/config";

export default defineConfig({
appName: "billcorea-saupja",
brand: {
displayName: "사업자번호 조회",
primaryColor: "#5e82fc",
icon: "https://static.toss.im/appsintoss/42541/b8f231ae-24c9-4fff-8666-83227ae2a66e.png",
},
web: {
host: "10.0.2.2",
port: 5173,
commands: {
dev: "vite dev --host 0.0.0.0",
build: "vite build",
},
},
webViewProps: {
type: "partner",
},
permissions: [],
outdir: "dist",
});

현재 앱은 카메라, 위치, 앨범 같은 디바이스 권한을 쓰지 않으므로 permissions를 빈 배열로 둡니다. 권한은 필요할 때만 선언하는 것이 검수와 사용자 신뢰 측면에서 유리합니다.


2. TDS Provider 적용


TDS Mobile 컴포넌트가 앱 전체에서 동작하도록 최상위에 TDSMobileAITProvider를 적용합니다.


import { TDSMobileAITProvider } from "@toss/tds-mobile-ait";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import config from "../granite.config.ts";
import App from "./App.tsx";
import "./index.css";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<TDSMobileAITProvider brandPrimaryColor={config.brand.primaryColor}>
<App />
</TDSMobileAITProvider>
</StrictMode>,
);

브랜드 기본 색상을 granite.config.tsprimaryColor와 연결하면, 앱 설정과 UI 스타일의 기준을 한 곳에서 관리할 수 있습니다.


3. 입력값 정규화


국세청 API는 사업자등록번호와 날짜에 하이픈 같은 기호가 들어가면 실패할 수 있습니다. 그래서 입력 단계에서 숫자만 남기고 길이를 제한합니다.


function normalizeBusinessNumber(value: string) {
return value.replace(/\D/g, "").slice(0, 10);
}

function normalizeDate(value: string) {
return value.replace(/\D/g, "").slice(0, 8);
}

이 로직 덕분에 사용자는 123-45-67890처럼 입력해도 내부적으로는 1234567890만 API에 전달됩니다.


4. API 호출 공통화


/status/validate는 모두 같은 Base URL, 같은 인증키, 같은 POST JSON 호출 방식을 사용합니다. 따라서 공통 함수로 분리했습니다.


const API_BASE_URL = "https://api.odcloud.kr/api/nts-businessman/v1";
const SERVICE_KEY = import.meta.env.VITE_ENCRIPT_API_KEY;

async function requestPublicData<TResponse>(
path: "/status" | "/validate",
body: unknown,
) {
if (SERVICE_KEY == null || SERVICE_KEY === "") {
throw new Error(".env에 VITE_ENCRIPT_API_KEY를 설정해 주세요.");
}

const url = new URL(`${API_BASE_URL}${path}`);
url.searchParams.set("serviceKey", SERVICE_KEY);
url.searchParams.set("returnType", "JSON");

const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
});

const data = await response.json().catch(() => null);

if (!response.ok) {
throw new Error("조회 중 오류가 발생했어요.");
}

if (data == null) {
throw new Error("응답을 확인할 수 없어요.");
}

return data as TResponse;
}

중요한 점은 serviceKeyreturnType은 쿼리스트링으로 전달하고, b_nobusinesses 같은 요청 데이터는 JSON body로 전달해야 한다는 것입니다.


5. 상태조회 구현


상태조회는 사업자등록번호만 있으면 호출할 수 있습니다.


const result = await requestPublicData<StatusResponse>("/status", {
b_no: [normalizedBusinessNumber],
});

setStatusResult(result);

응답의 대표적인 필드는 다음과 같습니다.


type BusinessStatus = {
b_no: string;
b_stt?: string;
b_stt_cd?: string;
tax_type?: string;
tax_type_cd?: string;
end_dt?: string;
utcc_yn?: string;
tax_type_change_dt?: string;
invoice_apply_dt?: string;
rbf_tax_type?: string;
rbf_tax_type_cd?: string;
};

화면에서는 빈 값이 들어올 수 있으므로 -로 대체해 보여주는 처리를 넣었습니다.


function ResultItem({
label,
value,
}: {
label: string;
value?: string | null;
}) {
return (
<div className="result-item">
<dt>{label}</dt>
<dd>{value != null && value !== "" ? value : "-"}</dd>
</div>
);
}

6. 진위확인 구현


진위확인은 사업자등록번호 외에도 대표자명과 개업일자가 필요합니다.


const result = await requestPublicData<ValidateResponse>("/validate", {
businesses: [
{
b_no: normalizedBusinessNumber,
start_dt: normalizedStartDate,
p_nm: ownerName.trim(),
b_nm: businessName.trim(),
},
],
});

setValidateResult(result);

진위확인 응답의 valid 값은 코드로 오기 때문에 화면에서는 사람이 읽을 수 있는 문구로 변환합니다.


function formatValidity(result: ValidationResult) {
if (result.valid === "01") {
return "일치";
}

if (result.valid === "02") {
return "불일치";
}

return result.valid;
}

7. 제출 가능 조건 제어


불완전한 값으로 API를 호출하지 않도록 조회 버튼의 활성 조건을 명확히 나눴습니다.


const isStatusReady = normalizedBusinessNumber.length === 10;
const isValidateReady =
normalizedBusinessNumber.length === 10 &&
ownerName.trim().length > 0 &&
normalizedStartDate.length === 8;

const canSubmit = menu === "status" ? isStatusReady : isValidateReady;

이렇게 하면 사용자가 필수값을 입력하기 전에는 버튼이 비활성화되고, API 오류도 줄어듭니다.


8. 토스 배너 광고 연동


광고 ID가 설정되어 있을 때만 배너를 붙이도록 만들었습니다.


const AD_BANNER_ID =
(import.meta.env.VITE_AD_BANNER_ID as string | undefined)?.trim() ?? "";

{AD_BANNER_ID !== "" ? (
<div className="lookup-ad-banner">
<TossBannerAd adGroupId={AD_BANNER_ID} />
</div>
) : null}

TossBannerAd 컴포넌트는 SDK가 제공하는 TossAds 객체가 있을 때만 초기화하고, 지원하지 않는 환경에서는 아무것도 렌더링하지 않습니다. 이 방식은 브라우저 개발 환경과 토스 앱 실행 환경의 차이를 흡수하는 데 도움이 됩니다.


참고 사항


공공데이터 API 호출 규칙


국세청 사업자등록정보 API는 다음 규칙을 지켜야 합니다.



  • GET이 아니라 POST로 호출한다.

  • serviceKeyreturnType은 URL querystring에 넣는다.

  • b_no, businesses 같은 조회 데이터는 JSON body에 넣는다.

  • 1회 호출당 최대 100개까지 조회할 수 있다.

  • 사업자등록번호는 숫자 10자리만 전달해야 한다.

  • 개업일자는 YYYYMMDD 형식으로 전달해야 한다.


현재 앱은 단건 조회 중심 UI이므로 100건 제한에는 걸리지 않지만, 추후 대량 조회 기능을 추가한다면 요청을 100건 단위로 나누는 처리가 필요합니다.


환경 변수


현재 구현은 다음 환경 변수를 사용합니다.


VITE_ENCRIPT_API_KEY=공공데이터포털_서비스키
VITE_AD_BANNER_ID=토스_배너_광고_ID

주의할 점은 코드에서 사용하는 이름이 VITE_ENCRIPT_API_KEY라는 것입니다. 문서나 메모에 VITE_ECNCRIPT_API_KEY처럼 다른 철자로 적혀 있으면 앱에서 값을 읽지 못합니다. .env.example, 배포 설정, 실제 코드의 변수명을 반드시 맞춰야 합니다.


TDS 사용


비게임 Apps in Toss 미니앱은 TDS를 사용하는 것이 중요합니다. 이 프로젝트는 @toss/tds-mobileTop, Button을 사용하고, 나머지 폼/결과 UI는 CSS로 모바일 화면에 맞게 정리했습니다.


주의 사항


1. API 키 노출 문제


Vite의 VITE_ 환경 변수는 프론트엔드 번들에 포함됩니다. 즉, 브라우저에서 확인 가능한 값입니다. 공공데이터포털 서비스키를 클라이언트에서 직접 호출하는 구조가 정책상 허용되는지 확인해야 합니다.


운영 보안 기준이 높거나 키 보호가 필요하다면 다음 구조가 더 적절합니다.


토스 미니앱 WebView
-> 자체 백엔드 API
-> 공공데이터포털 API

이렇게 하면 서비스키를 서버에만 보관할 수 있고, 호출량 제한, 로깅, 악용 방지, 장애 대응도 더 쉽게 할 수 있습니다.


2. 개인정보와 민감 정보 저장 금지


대표자명, 사업자등록번호, 개업일자는 업무상 중요한 정보입니다. 현재 앱은 입력값을 로컬 저장소에 저장하지 않는 구조라 단순 조회 도구로 적합합니다. 추후 조회 이력 기능을 추가한다면 저장 목적, 보관 기간, 삭제 방식, 개인정보 처리 기준을 먼저 정해야 합니다.


3. 진위확인의 입력값 정확도


진위확인은 입력값이 사업자등록증 정보와 정확히 맞아야 합니다. 특히 다음 항목은 사용자 안내가 필요합니다.



  • 대표자명은 사업자등록증의 표기와 같아야 한다.

  • 개업일자는 YYYYMMDD 형식이어야 한다.

  • 외국인 사업자, 공동대표, 상호 표기 등은 API 규칙의 영향을 받을 수 있다.


4. 결과 없음과 오류는 다르게 보여주기


사업자등록번호가 없거나 삭제된 정보인 경우, 네트워크 오류와는 다른 사용자 경험이 필요합니다. 현재 구현은 API 오류 메시지와 빈 결과 메시지를 구분합니다.


function EmptyResult() {
return <p className="message">조회 결과가 없어요.</p>;
}

운영 단계에서는 "등록되지 않은 번호", "필수 항목 누락", "일시적인 조회 실패"를 더 세분화하면 문의를 줄일 수 있습니다.


5. 토스 앱 환경과 일반 브라우저 환경 차이


토스 SDK 기능은 일반 브라우저에서 항상 동작하지 않을 수 있습니다. 광고처럼 SDK 의존 기능은 isSupported와 객체 존재 여부를 확인한 뒤 실행하는 패턴이 좋습니다.


if (tossAds == null || tossAds.initialize.isSupported?.() === false) {
return undefined;
}

중요 샘플 예제 코드


전체 조회 흐름 요약


async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();

if (!canSubmit) {
setErrorMessage("필수 항목을 정확히 입력해 주세요.");
return;
}

setLoading(true);
setErrorMessage("");
setStatusResult(null);
setValidateResult(null);

try {
if (menu === "status") {
const result = await requestPublicData<StatusResponse>("/status", {
b_no: [normalizedBusinessNumber],
});
setStatusResult(result);
} else {
const result = await requestPublicData<ValidateResponse>("/validate", {
businesses: [
{
b_no: normalizedBusinessNumber,
start_dt: normalizedStartDate,
p_nm: ownerName.trim(),
b_nm: businessName.trim(),
},
],
});
setValidateResult(result);
}
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "조회 중 오류가 발생했어요.",
);
} finally {
setLoading(false);
}
}

상태 결과 렌더링


function BusinessStatusView({ status }: { status: BusinessStatus }) {
return (
<dl className="result-list">
<ResultItem label="사업자등록번호" value={status.b_no} />
<ResultItem label="사업자 상태" value={status.b_stt} />
<ResultItem label="사업자 상태 코드" value={status.b_stt_cd} />
<ResultItem label="과세 유형" value={status.tax_type} />
<ResultItem label="과세 유형 코드" value={status.tax_type_cd} />
<ResultItem label="폐업일" value={formatDate(status.end_dt)} />
<ResultItem label="단위과세 전환 여부" value={status.utcc_yn} />
<ResultItem
label="과세 유형 전환일"
value={formatDate(status.tax_type_change_dt)}
/>
<ResultItem
label="세금계산서 적용일"
value={formatDate(status.invoice_apply_dt)}
/>
<ResultItem label="직전 과세 유형" value={status.rbf_tax_type} />
</dl>
);
}

날짜 표시 변환


function formatDate(value?: string) {
if (value == null || value.length !== 8) {
return value;
}

return `${value.slice(0, 4)}.${value.slice(4, 6)}.${value.slice(6, 8)}`;
}

마무리


billcorea-saupja는 기능 범위가 크지는 않지만, Apps in Toss 미니앱 개발에서 자주 마주치는 요소를 잘 담고 있습니다. Granite 설정, TDS 적용, 공공 API 연동, 입력값 정규화, 환경 변수 관리, SDK 지원 여부 확인 같은 기본기가 모두 들어갑니다.


운영 수준으로 끌어올릴 때는 API 키 보호를 위한 백엔드 프록시, 오류 메시지 세분화, 접근성 점검, 샌드박스 테스트, 콘솔 검수 항목 확인을 추가하면 됩니다.





오늘의 이야기


#스하리1000명프로젝트,
迷失在韩国?即使您不会说韩语,这个应用程序也可以帮助您轻松出行。
只需说出您的语言即可 - 它会翻译、搜索并以您的语言显示结果。
非常适合旅行者!支持英语、日语、中文、越南语等10多种语言。
现在就试试吧!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/07/13

오늘의 이야기


#스하리1000명프로젝트,
韓国で迷子になりましたか?韓国語が話せなくても、このアプリを使えば簡単に移動できます。
あなたの言語で話すだけで、翻訳、検索が行われ、結果があなたの言語で表示されます。
旅行者に最適!英語、日本語、中国語、ベトナム語などを含む 10 以上の言語をサポートします。
今すぐ試してみましょう!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/07/12

오늘의 이야기


#스하리1000명프로젝트,
한국에서 길을 잃었나요? 한국어를 못하더라도 이 앱을 사용하면 쉽게 돌아다닐 수 있습니다.
귀하의 언어로 말하면 귀하의 언어로 번역, 검색 및 결과가 표시됩니다.
여행자에게 좋습니다! 영어, 일본어, 중국어, 베트남어 등 10개 이상의 언어를 지원합니다.
지금 사용해 보세요!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/07/11

오늘의 이야기


#스하리1000명프로젝트,
Perso in Corea? Even if you don't speak Korean, this app helps you get around easily.
Parla semplicemente la tua lingua: traduce, cerca e mostra i risultati nella tua lingua.
Ottimo per i viaggiatori! Supporta oltre 10 lingue tra cui inglese, giapponese, cinese, vietnamita e altre.
Provalo adesso!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/07/10

오늘의 이야기


#스하리1000명프로젝트,
Perdu en Corée ? Même si vous ne parlez pas coréen, cette application vous aide à vous déplacer facilement.
Parlez simplement votre langue : il traduit, recherche et affiche les résultats dans votre langue.
Idéal pour les voyageurs ! Prend en charge plus de 10 langues, dont l'anglais, le japonais, le chinois, le vietnamien et plus encore.
Essayez-le maintenant !
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




2026/07/09

오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 “혹시나~” 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 “로또 예상번호 by Gemini” 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기


#billcorea #운동동아리관리앱
🏸 श्नीडल, बैडमिंटन क्लबों के लिए एक आवश्यक ऐप!
👉 मैच खेलें - स्कोर रिकॉर्ड करें और विरोधियों को खोजें 🎉
कहीं भी, अकेले, दोस्तों के साथ, या क्लब में बिल्कुल सही! 🤝
अगर आपको बैडमिंटन पसंद है तो इसे जरूर ट्राई करें

ऐप पर जाएं 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기


#스하리1000명프로젝트

오늘 내가 만든앱 하나 알려주고 싶어, 이 앱은 알림수집기 라고 이름을 붙였는 데,
내 폰에 표시 되는 알림을 읽어서 내가 지정한 단어가 들어 있고, 지출기록을 남겨야 하는 알림이
있으면 수집하고, 카카오톡으로 친구에게 전달해 주는 기능을 구현해 줄꺼야. 📲

이번 패치에서는 하루 한번 지정한 시간에 나에게 알림(노티) 하도록 기능을 추가 했어. 🙏
한번 써보고 불편한 거 있으면 말해줘.

앱 바로가기
👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기


#스하리1000명프로젝트,
कभी-कभी विदेशी कामगारों से बात करना कठिन होता है, है न?
मैंने एक सरल ऐप बनाया है जो मदद करता है! आप अपनी भाषा में लिखते हैं, और दूसरे इसे अपनी भाषा में देखते हैं।
यह सेटिंग्स के आधार पर स्वचालित अनुवाद करता है।
आसान चैट के लिए बहुत उपयोगी। जब मौका मिले तो देख लेना!
https://play.google.com/store/apps/details?id=com.billcoreatech.multichat416




오늘의 이야기


#스하리1000명프로젝트,
¿Perdido en Corea? Incluso si no hablas coreano, esta aplicación te ayuda a moverte fácilmente.
Simplemente hable su idioma: traduce, busca y muestra resultados en su idioma.
¡Genial para viajeros! Admite más de 10 idiomas, incluidos inglés, japonés, chino, vietnamita y más.
¡Pruébalo ahora!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




오늘의 이야기

Apps in Toss에서 사업자번호 조회 미니앱 만들기 미니앱 billcorea-saupja 는 토스 앱 안에서 사업자등록번호의 상태와 진위 여부를 빠르게 확인할 수 있도록 만든 WebView 기반 미니...