음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

국토교통부 도로명주소 API 사용법 — 서버에서 주소 검색 호출하기 (CORS·JSONP 해결)

처음 이 기능을 프로토타이핑할 때는 브라우저에서 바로 국토부 API를 불렀습니다. 화면에 입력창 하나 놓고, 자바스크립트로 주소를 검색해 결과를 띄우는 식이었습니다. 그런데 브라우저에서 fetch로 국토부 주소 검색 API를 그냥 부르면 CORS에 막힙니다. 국토부 서버가 다른 도메인에서 온 요청에 응답 헤더를 열어 주지 않기 때문입니다.

그래서 브라우저 버전에서는 JSONP로 우회했습니다. 국토부는 addrLinkApiJsonp.do라는 별도 엔드포인트를 제공해서, <script> 태그로 불러오는 방식이라면 CORS 제약을 피할 수 있습니다. 동작은 하지만, JSONP는 콜백을 등록하고 응답을 기다리는 구조라 에러 처리와 타임아웃이 지저분해집니다.

보정 로직을 서버로 옮기면서 이 문제가 통째로 사라졌습니다. 서버 대 서버 호출에는 CORS가 없으니까요. 브라우저에서 쓰던 JSONP 우회를 걷어내고, 국토부가 원래 제공하는 정식 JSON 엔드포인트를 직접 부를 수 있게 됐습니다. 이번 편은 그 서버 쪽 호출 코드를 조각별로 뜯어봅니다.

시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다.

  1. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요
  2. 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버) — 지금 보는 글
  3. 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인
  4. 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기
  5. 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli)
  6. 6. 브라우저 프로토타입 → Vercel 서버리스

1. 정식 엔드포인트는 addrLinkApi.do

국토부 도로명주소 검색 API의 정식 엔드포인트는 addrLinkApi.do입니다. 브라우저에서 쓰던 addrLinkApiJsonp.do와 이름이 한 글자 차이지만 성격이 다릅니다. 앞의 것은 순수 JSON을 돌려주고, 뒤의 것은 JSONP 콜백으로 감싸 줍니다. 서버에서는 CORS를 신경 쓸 필요가 없으니 JSON을 주는 쪽을 그냥 부르면 됩니다.

const JUSO_ENDPOINT = "https://business.juso.go.kr/addrlink/addrLinkApi.do";

const params = new URLSearchParams({
  confmKey,                 // 국토부에서 발급받는 승인키
  currentPage: "1",
  countPerPage: "20",
  keyword,                  // 검색할 주소 문자열
  resultType: "json",       // JSON으로 받겠다는 지정
});

핵심은 resultType: "json"입니다. 이 값을 주지 않으면 응답 형식이 달라져서 파싱이 어긋납니다. confmKey는 국토부 사이트에서 발급받는 승인키이고, 코드에 하드코딩하지 않고 환경변수나 설정에서 주입받게 만들어 두는 편이 안전합니다. keyword가 우리가 STT 결과에서 뽑아낸 검색어입니다.

2. "5초에 10건" 제한을 스스로 지키기

여기서 첫 함정이 나옵니다. 이 API에는 속도 제한이 있습니다. 5초에 10건을 넘기면 차단돼서 검색이 실패합니다. 처음에는 이게 넉넉해 보였습니다. 그런데 주소 하나를 보정하는 과정에서 국토부를 여러 번 부르게 됩니다. 건물명 별칭을 시도하고, 동음이의로 갈라진 변형을 시도하고, 시군구를 특정 못 하면 전국으로 넓혀 다시 검색합니다. 주소 한 건에 호출이 대여섯 번씩 나가니, 사용자가 몇 명만 몰려도 5초에 10건은 금방 넘습니다.

그래서 호출을 스스로 묶어서 제한 아래로 유지하는 장치를 뒀습니다. 최근 호출 시각을 배열에 쌓아 두고, 5초 창 안에 든 호출이 이미 꽉 찼으면 잠깐 기다렸다가 보내는 슬라이딩 윈도우입니다. 상한은 10이 아니라 9로 잡았습니다. 딱 10에 맞추면 경계에서 아슬아슬하게 걸릴 수 있어서 한 칸 여유를 뒀습니다.

const RATE_WINDOW_MS = 5000;   // 5초 창
const RATE_MAX = 9;            // 창 안 최대 9건 (10에서 한 칸 여유)
const _callTimes = [];

async function rateLimit() {
  for (;;) {
    const now = Date.now();
    // 창을 벗어난 오래된 호출 기록은 버린다
    while (_callTimes.length && now - _callTimes[0] > RATE_WINDOW_MS) {
      _callTimes.shift();
    }
    if (_callTimes.length < RATE_MAX) {
      _callTimes.push(now);
      return;                 // 여유 있으면 바로 통과
    }
    // 꽉 찼으면 가장 오래된 호출이 창을 벗어날 때까지 대기
    const waitMs = RATE_WINDOW_MS - (now - _callTimes[0]) + 30;
    await new Promise((r) => setTimeout(r, waitMs));
  }
}

포인트는 창의 기준이 고정된 5초 토막이 아니라 지금 이 순간을 끝으로 하는 최근 5초라는 점입니다. 오래된 기록을 매번 앞에서 덜어내기 때문에, 호출이 몰려도 실제 밀도를 정확히 따라갑니다. 검색 함수는 실제 요청을 보내기 전에 항상 이 rateLimit()을 먼저 await합니다. 그러면 제한을 넘길 상황에서는 자연스럽게 잠깐 멈췄다가 이어집니다.

3. 8초 안에 응답이 없으면 끊는다

두 번째 함정은 응답이 영영 안 오는 경우입니다. 승인키가 잘못됐거나 네트워크가 불안정하면 요청이 매달린 채 안 끝날 수 있습니다. 보정은 여러 번 호출이 이어지는 흐름이라, 한 번이 멈추면 전체가 함께 멈춥니다. 그래서 AbortController로 8초 타임아웃을 걸었습니다.

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 8000);
try {
  const resp = await fetch(`${JUSO_ENDPOINT}?${params}`, {
    method: "GET",
    signal: controller.signal,   // 이 신호로 요청을 취소할 수 있다
  });
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
  return await resp.json();
} catch (e) {
  if (e.name === "AbortError") {
    throw new Error("요청 타임아웃 (8초) - 승인키/네트워크 확인 필요");
  }
  throw e;
} finally {
  clearTimeout(timer);          // 성공하든 실패하든 타이머 정리
}

setTimeout으로 8초 뒤 controller.abort()를 예약해 두면, 그 시점에 fetch가 AbortError를 던지며 끊깁니다. 이걸 잡아서 "타임아웃"이라는 사람이 읽을 수 있는 메시지로 바꿔 던지면, 위쪽 로직이 실패 원인을 구분할 수 있습니다. 그리고 finally에서 clearTimeout으로 타이머를 반드시 정리합니다. 이걸 빼먹으면 요청이 8초보다 빨리 끝나도 예약된 타이머가 남아 불필요하게 도는데, 호출이 많은 흐름에서는 이런 잔여물이 쌓입니다.

4. 200이 왔다고 성공이 아니다

세 번째 함정이 제일 놓치기 쉽습니다. 국토부 API는 승인키가 틀렸거나 검색어가 비어 있어도 HTTP 200으로 응답합니다. 그래서 resp.ok만 보고 성공이라 믿으면 안 됩니다. 진짜 성공 여부는 응답 본문 안의 results.common.errorCode에 들어 있습니다. 이 값이 문자열 "0"이면 정상이고, 아니면 에러입니다.

export function checkJusoError(resp) {
  const code = resp?.results?.common?.errorCode;
  const msg = resp?.results?.common?.errorMessage;
  if (code && code !== "0") {
    return `[${code}] ${msg ?? "알 수 없는 오류"}`;
  }
  return null;   // 정상
}

옵셔널 체이닝(?.)으로 감싼 이유는 응답 구조가 예상과 다를 때 접근하다 터지지 않게 하기 위해서입니다. errorCode가 "0"이 아니면 코드와 메시지를 묶어 돌려주고, 정상이면 null을 반환해 "문제 없음"을 알립니다. 호출한 쪽은 이 반환값이 null인지만 보고 다음 단계로 넘어갈지 판단합니다. HTTP 상태와 API 자체 오류 코드를 둘 다 확인해야 비로소 "실존 후보를 제대로 받아왔다"고 말할 수 있습니다.

5. 이제 정답지가 생겼다

이렇게 서버에서 국토부 도로명주소 API를 안정적으로 부를 수 있게 되면서, 1편에서 말한 "정답지"가 실제로 손에 들어왔습니다. STT가 뱉은 문자열로 검색어를 만들어 이 API에 물어보면, 실존하는 도로명주소 후보 목록이 돌아옵니다. 존재하지 않는 주소는 애초에 후보에 없으니, 여기서부터는 "그럴듯한 문자열"이 아니라 "실제 주소" 위에서 판단하게 됩니다.

다만 후보를 받아오는 것과, 그 여러 후보 중 STT 결과에 가장 가까운 하나를 고르는 것은 다른 문제입니다. "역삼동"과 "역산동"처럼 발음이 비슷한 후보가 섞여 오면, 무엇이 원래 말한 주소에 더 가까운지 점수를 매겨야 합니다. 다음 편에서는 그 순위를 매기는 방법 — 한글을 자음·모음 단위로 쪼개 얼마나 닮았는지 재는 자모 유사도를 다루겠습니다.

댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기