<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>끄적이는 개발 log</title>
    <link>https://pp8817.tistory.com/</link>
    <description>공부한 내용, 개발 관련 지식, 트러블 슈팅 등을 기록합니다.
이전 블로그: https://velog.io/@pp8817/posts</description>
    <language>ko</language>
    <pubDate>Tue, 29 Sep 2026 14:30:05 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>pp8817</managingEditor>
    <item>
      <title>푸시 알림 서버까지 만들고도 로컬 알림을 선택한 이유</title>
      <link>https://pp8817.tistory.com/279</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EC%84%9C%EB%B2%84%EA%B9%8C%EC%A7%80-%EB%A7%8C%EB%93%A4%EA%B3%A0%EB%8F%84-%EB%A1%9C%EC%BB%AC-%EC%95%8C%EB%A6%BC%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;푸시 알림 서버까지 만들고도 로컬 알림을 선택한 이유&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Landit에는 사용자가 학습을 이어가도록 매일 20시에 리마인더를 보내는 기능이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 알림 하나를 위해 EventBridge Scheduler, SQS, Push Device, 발송 이력, Expo Ticket과 Receipt까지 백엔드 구조를 설계하고 구현했다. 처음에는 서버가 최신 학습 상태를 확인한 뒤 Expo Push Service로 알림을 보내는 방식이 자연스럽다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 구조를 완성해 갈수록 질문이 달라졌다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 푸시를 안정적으로 만들 수 있는가가 아니라, 현재 제품이 이 복잡성을 정말 필요로 하는가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 첫 학습 리마인더의 구현 방향을 앱의 로컬 알림으로 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 푸시 구현이 실패해서 로컬 알림으로 돌아간 것은 아니다. 서버 푸시에 필요한 책임을 코드 수준까지 구체화한 뒤, 현재 요구사항과 운영 제약에는 더 단순한 구조가 맞다고 판단했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;매일 20시 알림 하나에서 시작했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 확정된 요구사항은 단순했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 학습을 이어가도록 알림을 보낸다.&lt;/li&gt;
&lt;li&gt;발송 시각은 매일 오후 8시다.&lt;/li&gt;
&lt;li&gt;알림을 누르면 다음 학습 화면으로 이동한다.&lt;/li&gt;
&lt;li&gt;사용자는 알림을 켜거나 끌 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고정된 시간에 같은 문구를 보내는 것만 생각하면 앱이 직접 알림을 예약해도 된다. &lt;a href=&quot;https://docs.expo.dev/versions/latest/sdk/notifications/&quot;&gt;Expo Notifications&lt;/a&gt;는 앱에서 특정 시각의 알림을 예약하고, 예약 식별자를 이용해 기존 알림을 취소하는 기능을 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 알림 내용을 구체화하면서 서버가 필요해 보이는 이유도 늘어났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 푸시를 설계할 때 사용자의 상태에 따라 알림을 세 가지로 나누기로 했기 때문이다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;알림 유형&lt;/th&gt;
&lt;th&gt;선택 조건&lt;/th&gt;
&lt;th&gt;이동 화면&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONTINUE_SCENARIO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;다음으로 진행할 시나리오가 있음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/conversation/{scenarioId}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONTINUE_EXPRESSION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;완료한 시나리오에 아직 학습하지 않은 표현이 있음&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/expressions/{expressionId}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;REVIEW_LEARNING&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;현재 활성 콘텐츠를 모두 완료함&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에 시나리오를 완료했는지, 표현 학습을 완료했는지에 따라 우선순위도 달라졌다. 최근 활동에 이어갈 대상이 없으면 다른 학습 유형으로 대체하고, 새로운 콘텐츠가 추가되면 다음 계산에서는 다시 이어 하기 알림으로 전환돼야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 조건만 놓고 보면 최신 학습 데이터가 있는 서버에서 대상을 계산하는 편이 정확하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 처음에는 서버 푸시를 기준으로 설계를 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 세 유형은 서버 푸시에서 검토한 정책이다. 로컬 알림으로 방향을 바꾸면서 첫 배포 범위는 앱이 직접 아는 학습 이벤트를 기준으로 리마인더를 예약하는 형태로 줄였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Expo API를 호출하는 일보다 그 앞뒤가 더 복잡했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 푸시의 마지막 단계만 보면 단순하다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;Expo Push Token
+ 제목
+ 본문
+ 이동 경로
&amp;rarr; Expo Push API&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제 시스템에는 이 요청 앞뒤로 더 많은 책임이 필요했다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;flowchart LR
    APP[&quot;Landit 앱&quot;] --&amp;gt;|&quot;설치 상태와 Token 동기화&quot;| API[&quot;기존 API 서버&quot;]
    EB[&quot;EventBridge Scheduler&amp;lt;br/&amp;gt;매일 20시&quot;] --&amp;gt; SQS[&quot;Push SQS&quot;]
    SQS --&amp;gt; CONSUMER[&quot;PushNotificationConsumer&quot;]
    CONSUMER --&amp;gt;|&quot;SCHEDULED_NOTIFICATION_BATCH&quot;| TARGET[&quot;사용자별 알림 대상 계산&quot;]
    CONSUMER --&amp;gt;|&quot;PUSH_RECEIPT_CHECK&quot;| RECEIPT[&quot;Receipt 확인&quot;]
    TARGET --&amp;gt; DELIVERY[&quot;발송 이력 선점&quot;]
    DELIVERY --&amp;gt; EXPO[&quot;Expo Push Service&quot;]
    EXPO --&amp;gt; PROVIDER[&quot;FCM / APNs&quot;]
    EXPO --&amp;gt; TICKET[&quot;Ticket 결과 저장&quot;]
    TICKET --&amp;gt;|&quot;15분 지연 메시지&quot;| SQS
    RECEIPT --&amp;gt; EXPO
    SQS --&amp;gt;|&quot;반복 실패&quot;| DLQ[&quot;Push DLQ&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버는 다음 질문에 모두 답해야 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Token은 사용자와 기기 중 어디에 귀속되는가?&lt;/li&gt;
&lt;li&gt;한 사용자가 여러 기기를 사용하면 어디까지 발송해야 하는가?&lt;/li&gt;
&lt;li&gt;같은 기기에서 다른 계정으로 로그인하면 Token의 소유자는 누구인가?&lt;/li&gt;
&lt;li&gt;20시에 어떤 사용자를 조회해야 하는가?&lt;/li&gt;
&lt;li&gt;사용자마다 다른 학습 상태를 어떻게 N+1 조회 없이 계산할 것인가?&lt;/li&gt;
&lt;li&gt;SQS가 같은 메시지를 다시 전달하면 중복 알림을 어떻게 막을 것인가?&lt;/li&gt;
&lt;li&gt;Expo가 요청을 접수했다는 것과 FCM 또는 APNs가 받았다는 것을 어떻게 구분할 것인가?&lt;/li&gt;
&lt;li&gt;일시 오류와 영구 오류를 어떤 기준으로 나눌 것인가?&lt;/li&gt;
&lt;li&gt;삭제된 앱의 Token을 어떻게 발송 대상에서 제외할 것인가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 핵심은 Push API 호출이 아니라 &lt;b&gt;발송 전후의 상태를 누가 소유할 것인가&lt;/b&gt;였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Token은 사용자 ID가 아니라 앱 설치를 기준으로 관리했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 사용자 ID에 Expo Push Token을 저장하면 충분해 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Token은 사용자 계정 자체가 아니라 알림을 받을 앱 설치와 연결된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 사용자가 iPhone과 Android 기기를 함께 사용할 수 있다. 반대로 하나의 기기에서 로그아웃한 뒤 다른 사용자가 로그인할 수도 있다. 이때 사용자 ID만 기준으로 Token을 저장하면 다중 기기와 계정 전환을 안전하게 표현하기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 앱 설치마다 &lt;code&gt;installationId&lt;/code&gt;를 만들고 서버에는 다음 상태를 저장하도록 설계했다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;installationId
+ 현재 사용자
+ 플랫폼
+ Landit 알림 ON/OFF
+ Expo Push Token
+ Token 상태&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 API도 설치 상태를 멱등하게 동기화하는 하나의 API로 제한했다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;PUT /api/v1/me/push-devices/{installationId}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 &lt;code&gt;installationId&lt;/code&gt;로 다시 요청하면 행을 추가하지 않고 현재 상태를 갱신한다. 같은 Expo Push Token이 다른 설치에 연결돼 있으면 이전 연결을 해제하고 현재 설치로 옮긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발송 가능한 설치는 다음 조건을 모두 만족해야 한다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot;&gt;&lt;code&gt;pushEnabled == true
&amp;amp;&amp;amp; expoPushToken != null
&amp;amp;&amp;amp; tokenStatus == ACTIVE&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 다중 기기와 계정 전환을 안전하게 다룰 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 로컬 알림을 선택하면 이 중앙 Token 소유권 관리 자체가 첫 번째 알림 범위에서는 필요하지 않다. 서버 푸시가 제공하는 정교함이 곧 서버가 계속 소유해야 할 상태의 양이기도 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;별도 Worker는 만들지 않았지만 서버의 책임은 여전히 컸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예약 알림을 서버에서 처리한다면 Spring의 &lt;code&gt;@Scheduled&lt;/code&gt;보다 EventBridge Scheduler를 사용하는 편이 안전하다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 서버가 여러 대라면 각 인스턴스의 Spring Scheduler가 같은 작업을 실행할 수 있다. 배포나 재시작 시점도 예약 실행에 영향을 줄 수 있다. EventBridge Scheduler가 매일 20시에 SQS 메시지 한 건을 발행하면 예약 실행 시점과 애플리케이션 배포를 분리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 검토한 전체 구조는 다음과 같았다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;EventBridge Scheduler
&amp;rarr; SCHEDULED_NOTIFICATION_BATCH
&amp;rarr; Push SQS
&amp;rarr; 별도 Push Worker
&amp;rarr; 사용자별 대상 계산
&amp;rarr; Expo Push Service&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 현재 규모에서 별도 ECS Service나 Fargate Task를 추가하는 것은 과하다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Worker를 분리하면 CPU와 메모리를 API 서버와 격리할 수 있고 배포 책임도 명확해진다. 반면 새로운 실행 환경, 배포 파이프라인, 모니터링 대상과 고정 비용이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 중간안으로 기존 API 서버 안에 SQS Consumer를 두었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;EventBridge Scheduler
&amp;rarr; Push SQS
&amp;rarr; 기존 API 서버의 PushNotificationConsumer
&amp;rarr; 대상 계산과 Expo 발송&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consumer 동시성은 2로 작게 시작했다. 별도 서버 비용은 만들지 않으면서 SQS의 재시도와 DLQ는 사용할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 선택은 서버 푸시를 유지한다는 전제에서는 합리적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Worker를 없애도 다음 책임은 사라지지 않았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Queue 소비와 Visibility Timeout 관리.&lt;/li&gt;
&lt;li&gt;API 서버와 배치 작업의 CPU&amp;middot;메모리 공유.&lt;/li&gt;
&lt;li&gt;발송 대상 계산을 위한 DB 조회.&lt;/li&gt;
&lt;li&gt;중복 발송 방지.&lt;/li&gt;
&lt;li&gt;Expo와 FCM/APNs 오류 처리.&lt;/li&gt;
&lt;li&gt;Ticket과 Receipt 상태 저장.&lt;/li&gt;
&lt;li&gt;운영 Queue와 DLQ 모니터링.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도 Worker 비용은 줄였지만 서버 푸시라는 선택의 운영 비용까지 사라진 것은 아니었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;500명씩 계산하고 100건씩 발송했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 활성 사용자를 한 번에 메모리에 올리거나 사용자마다 Repository를 조회하면 사용자 수가 늘수록 처리 시간과 쿼리 수가 함께 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 대상 계산은 &lt;code&gt;userProfileId&lt;/code&gt; Keyset Pagination으로 500명씩 처리하도록 구성했다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;long lastUserProfileId = 0L;

while (true) {
  NotificationTargetPage page = loadPage(lastUserProfileId, 500);
  if (page.userProfileIds().isEmpty()) {
    return;
  }

  List&amp;lt;SendPushNotificationCommand&amp;gt; commands = processPage(page);
  notificationDispatchService.sendAll(commands);
  lastUserProfileId = page.userProfileIds().getLast();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 실제 구조를 설명하기 위해 세부 로직을 줄인 형태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상 계산 단계에서는 각 페이지의 시나리오, 표현, 최근 완료 시각, 발송 가능한 Push Device를 일괄 조회한다. 사용자마다 같은 데이터를 다시 조회하지 않고 메모리에서 알림 유형 하나를 선택한다. 전체 사용자를 하나의 긴 트랜잭션으로 묶지 않고 페이지마다 상태를 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계산 결과는 &lt;code&gt;user_notification_state&lt;/code&gt;에 스냅샷으로 남긴다. 이 테이블은 학습 사실의 원본이 아니다. 시나리오와 표현의 실제 완료 이력에서 그날의 알림 유형과 대상을 다시 계산하므로, 새 콘텐츠가 추가되면 다음 배치에서 이어 하기 알림으로 전환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expo 발송 단위는 별개였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.expo.dev/push-notifications/sending-notifications/&quot;&gt;Expo Push API&lt;/a&gt;는 한 요청에 최대 100개의 메시지 객체를 받을 수 있다. 따라서 500명은 DB 조회와 정책 계산 단위이고, 100건은 Expo HTTP 요청 단위다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;500명
= DB 조회와 대상 계산 단위

100건
= Expo 요청 분할 단위&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한때 실제 발송 대상마다 &lt;code&gt;PUSH_SEND&lt;/code&gt; 메시지를 다시 SQS에 넣는 구조도 검토했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 5만 명이면 기기 수에 따라 5만 개가 넘는 메시지가 새로 생길 수 있다. 개별 실패를 격리하기는 쉬워지지만 현재 규모에서는 메시지 유형, 재처리 상태와 DLQ 관리만 더 복잡해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 500명 단위는 Queue Fan-out 단위가 아니라 서버 내부의 조회 단위로 남겼다. 선택된 기기는 페이지 안에서 모아 Expo에 최대 100건씩 직접 보냈다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;SQS가 같은 메시지를 다시 줘도 알림은 한 번만 보내야 했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html&quot;&gt;SQS Standard Queue&lt;/a&gt;는 최소 한 번 전달 방식을 사용한다. Visibility Timeout은 같은 메시지를 동시에 처리할 가능성을 줄여 주지만, 중복 전달 자체를 완전히 없애지는 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 서버는 SQS가 메시지를 정확히 한 번 전달한다고 가정할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중복 방지는 Expo 호출 전에 &lt;code&gt;push_delivery&lt;/code&gt; 발송 이력을 선점하는 방식으로 구현했다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;날짜
+ 사용자
+ 알림 유형
+ Push Device
&amp;rarr; 중복 방지 키 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념적인 키는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;dust&quot;&gt;&lt;code&gt;push:scheduled:{date}:{userProfileId}:{notificationType}:{pushDeviceId}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 예약 메시지가 다시 전달되면 동일한 키의 발송 이력이 이미 존재한다. 이때 Expo를 다시 호출하지 않는다. 일시 오류로 재시도가 허용된 이력만 기존 행을 다시 선점한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;발송 이력은 단순한 로그가 아니었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 호출 전에 이번 발송을 어느 처리 흐름이 맡았는지 확정하는 멱등성 경계였다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 현재 키가 보장하는 범위는 같은 날짜&amp;middot;사용자&amp;middot;알림 유형&amp;middot;기기에 대한 중복 방지다. 첫 처리와 중복 처리 사이에 사용자가 학습해 선정 유형이 바뀌면 다른 키가 만들어질 수 있다. 사용자&amp;middot;기기별 하루 한 건을 엄격하게 보장하려면 알림 유형을 제외한 일일 키를 사용하거나, 첫 계산 결과를 그날의 확정 대상으로 고정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치 처리 시간이 길어질 가능성도 고려해야 했다. Consumer는 처리 시작과 페이지 전환 시 현재 SQS 메시지의 Visibility Timeout을 300초로 연장한다. Handler가 정상 종료된 경우에만 메시지를 삭제하도록 승인 정책도 &lt;code&gt;ON_SUCCESS&lt;/code&gt;로 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식도 한 페이지의 조회와 발송이 300초 안에 끝난다는 전제가 필요하다. 실제 처리 시간이 이를 넘는다면 페이지 처리 중에도 주기적으로 연장하는 heartbeat를 두거나 작업 단위를 더 작게 나눠야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구현은 같은 발송 이벤트의 중복 Expo 호출을 막는다. 사용자당 하루 한 건이라는 제품 정책까지 보장하려면 중복 키의 범위를 별도로 확정해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Expo Ticket은 기기 전달 완료가 아니었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expo Push Service에 HTTP 요청이 성공했다고 해서 사용자 기기에 알림이 표시된 것은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expo는 요청을 접수하면 Push Ticket을 반환한다. Ticket은 Expo가 Payload를 받았다는 의미다. Expo가 이후 FCM 또는 APNs에 전달을 시도한 결과는 Push Receipt로 확인해야 한다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; REQUESTED
    REQUESTED --&amp;gt; TICKET_ACCEPTED: &quot;Expo가 Ticket 반환&quot;
    REQUESTED --&amp;gt; FAILED: &quot;영구 오류&quot;
    REQUESTED --&amp;gt; REQUESTED: &quot;429, 5xx, timeout 재시도&quot;
    TICKET_ACCEPTED --&amp;gt; DELIVERED: &quot;FCM 또는 APNs가 수신&quot;
    TICKET_ACCEPTED --&amp;gt; FAILED: &quot;Receipt 실패&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;DELIVERED&lt;/code&gt;는 사용자 기기에 알림이 표시됐다는 뜻이 아니다. Expo Receipt로 FCM 또는 APNs가 알림을 받았음을 확인한 상태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DeviceNotRegistered&lt;/code&gt;가 반환되면 두 상태가 함께 바뀐다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;flowchart LR
    ERROR[&quot;Ticket 또는 Receipt&amp;lt;br/&amp;gt;DeviceNotRegistered&quot;] --&amp;gt; DELIVERY_FAILED[&quot;PushDelivery&amp;lt;br/&amp;gt;FAILED&quot;]
    ERROR --&amp;gt; DEVICE_INVALID[&quot;현재 Token 소유 PushDevice&amp;lt;br/&amp;gt;INVALID&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.expo.dev/push-notifications/sending-notifications/&quot;&gt;Expo는 발송 약 15분 뒤 Receipt를 확인할 것을 권장한다&lt;/a&gt;. 그래서 Ticket을 저장한 뒤 &lt;code&gt;PUSH_RECEIPT_CHECK&lt;/code&gt; 메시지를 SQS에 900초 지연 발행하도록 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오류라고 해서 모두 자동 재발송하지도 않았다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;오류&lt;/th&gt;
&lt;th&gt;처리&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 429, 5xx, timeout&lt;/td&gt;
&lt;td&gt;같은 발송 이력으로 재시도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;잘못된 Payload, 영구적인 요청 오류&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FAILED&lt;/code&gt;로 종료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;일반 I/O, interruption, 응답 파싱 실패&lt;/td&gt;
&lt;td&gt;Expo 수신 여부가 불확실해 자동 재발송하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DeviceNotRegistered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;현재 Token 소유 설치를 &lt;code&gt;INVALID&lt;/code&gt; 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 오류가 발생했다고 바로 재발송하면 Expo는 이미 첫 번째 요청을 받았는데 응답만 유실된 상황에서 중복 알림을 만들 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시도는 실패를 복구하는 기능이지만, 외부 시스템이 요청을 받았는지 모르는 상황에서는 새로운 중복 원인이 될 수도 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기술적으로 올바른 구조와 지금 필요한 구조는 달랐다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 구현하면서 서버 푸시에 필요한 책임은 분명해졌다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱 설치와 Token 소유권 관리.&lt;/li&gt;
&lt;li&gt;사용자별 최신 학습 상태 계산.&lt;/li&gt;
&lt;li&gt;예약 실행과 Queue 소비.&lt;/li&gt;
&lt;li&gt;페이지 단위 조회와 트랜잭션.&lt;/li&gt;
&lt;li&gt;발송 멱등성.&lt;/li&gt;
&lt;li&gt;Expo 요청 분할.&lt;/li&gt;
&lt;li&gt;Ticket과 Receipt 추적.&lt;/li&gt;
&lt;li&gt;오류 분류와 Token 무효화.&lt;/li&gt;
&lt;li&gt;Visibility Timeout과 DLQ 운영.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각은 불필요한 기능이 아니었다. 서버가 알림 발송의 원본이라면 필요한 책임이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 현재 제품 요구사항을 다시 보면 다른 결론이 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 알림의 목적은 복잡한 마케팅 캠페인이나 운영 공지가 아니었다. 사용자가 다시 학습하도록 정해진 시각에 리마인더를 보여주는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 범위에서는 다음 기능이 필요하지 않았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운영자가 특정 사용자 집단에 임의 알림을 발송하는 기능.&lt;/li&gt;
&lt;li&gt;국가나 사용자별 시간대를 서버에서 관리하는 기능.&lt;/li&gt;
&lt;li&gt;휴면 기간에 따라 여러 캠페인을 순차 발송하는 기능.&lt;/li&gt;
&lt;li&gt;앱을 열지 않은 상태에서 서버 데이터만으로 알림 정책을 바꾸는 기능.&lt;/li&gt;
&lt;li&gt;모든 기기의 발송 상태를 중앙에서 추적하는 기능.&lt;/li&gt;
&lt;li&gt;Receipt 기반 전달 분석.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 앱은 사용자가 학습한 시점과 다음 알림을 갱신해야 하는 시점을 이미 알고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 조건에서는 서버가 매일 전체 사용자의 상태를 다시 계산하는 것보다, 앱이 학습 이벤트를 기준으로 다음 알림을 예약하는 편이 단순했다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;판단 기준&lt;/th&gt;
&lt;th&gt;서버 푸시&lt;/th&gt;
&lt;th&gt;로컬 알림&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;최신 서버 상태 기반 대상 계산&lt;/td&gt;
&lt;td&gt;유리함&lt;/td&gt;
&lt;td&gt;앱이 알고 있는 상태로 제한됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;운영자 임의 발송&lt;/td&gt;
&lt;td&gt;가능함&lt;/td&gt;
&lt;td&gt;어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;다중 기기 중앙 통제&lt;/td&gt;
&lt;td&gt;가능함&lt;/td&gt;
&lt;td&gt;기기별 독립 동작&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ticket&amp;middot;Receipt 추적&lt;/td&gt;
&lt;td&gt;가능함&lt;/td&gt;
&lt;td&gt;중앙 추적 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue&amp;middot;DB&amp;middot;외부 푸시 서비스 운영&lt;/td&gt;
&lt;td&gt;필요함&lt;/td&gt;
&lt;td&gt;필요하지 않음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;오프라인 예약&lt;/td&gt;
&lt;td&gt;서버와 네트워크 필요&lt;/td&gt;
&lt;td&gt;OS가 예약 실행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;현재 고정 리마인더 범위&lt;/td&gt;
&lt;td&gt;구현 가능하지만 책임이 큼&lt;/td&gt;
&lt;td&gt;더 단순하게 충족 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 푸시가 더 많은 기능을 제공한다는 사실은 분명했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 그 기능들이 지금 필요하지 않았다는 점이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 배포는 로컬 알림으로 방향을 바꿨다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 첫 학습 리마인더는 앱에서 예약하는 로컬 알림으로 정리했다. 서버의 세 가지 알림 정책을 그대로 옮기는 대신, 앱이 직접 확인한 학습 이벤트를 기준으로 다음 리마인더를 갱신하는 범위부터 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념적인 흐름은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;flowchart LR
    COMPLETE[&quot;사용자 학습 완료&quot;] --&amp;gt; STATE[&quot;기기 내부 알림 상태 갱신&quot;]
    STATE --&amp;gt; CANCEL[&quot;이전 예약 알림 취소&quot;]
    CANCEL --&amp;gt; SCHEDULE[&quot;다음 알림 예약&quot;]
    SCHEDULE --&amp;gt; OS[&quot;iOS / Android&quot;]
    OS --&amp;gt;|&quot;예약 시각 도달&quot;| USER[&quot;사용자에게 알림 표시&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱은 사용자가 학습할 때마다 기존 예약을 갱신한다. OS가 예약 실행을 담당하므로 알림 발송을 위해 서버가 실행되고 있을 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expo Notifications는 &lt;code&gt;scheduleNotificationAsync&lt;/code&gt;가 반환한 식별자를 저장하고, &lt;code&gt;cancelScheduledNotificationAsync&lt;/code&gt;로 특정 예약을 취소할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습할 때까지 매일 알림을 보내는 정책이라면 20시 반복 예약 하나를 유지하면 된다. 사용자가 학습을 완료했을 때 기존 예약을 취소하고 다음 문구와 이동 경로로 다시 예약한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현에 필요한 핵심 책임도 다음 정도로 줄어든다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;이전 예약 식별자 조회
&amp;rarr; 이전 예약이 있으면 취소
&amp;rarr; 다음 학습 리마인더 예약
&amp;rarr; 새 예약 식별자 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FE 구현에서는 사용자별 저장 키, OS 알림 권한, Android Notification Channel, 앱 재설치와 로그아웃 시 상태 초기화도 함께 고려해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 알림도 지정한 시각의 표시를 무조건 보장하는 기능은 아니다. &lt;a href=&quot;https://docs.expo.dev/versions/latest/sdk/notifications/&quot;&gt;Expo 문서&lt;/a&gt; 기준으로 Android 12 이상에서 정확한 시각의 알림을 예약하려면 &lt;code&gt;SCHEDULE_EXACT_ALARM&lt;/code&gt; 권한이 필요하고, 실제 표시 여부는 OS 권한과 알림 Handler 설정에도 영향을 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 제품 요구사항도 &amp;ldquo;반드시 20시 정각에 실행되는 작업&amp;rdquo;과 &amp;ldquo;20시 무렵 학습을 상기시키는 리마인더&amp;rdquo;를 구분해야 한다. 첫 번째라면 로컬 알림의 플랫폼 제약을 더 깊게 검토해야 하고, 두 번째라면 현재 구조로 충분할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기기 간 동기화도 자동으로 해결되지 않는다. 사용자가 기기 A에서 학습을 끝내도 기기 B에 저장된 예약은 B가 앱을 열어 상태를 동기화하기 전까지 그대로 남을 수 있다. 서버에 새 콘텐츠가 추가돼도 앱이 이를 확인하기 전에는 예약된 문구와 이동 경로를 바꿀 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 배포가 이 시차를 허용할 수 있다면 로컬 알림의 단순성이 더 크다. 반대로 모든 기기의 알림을 즉시 맞춰야 한다면 서버 푸시가 다시 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 알림으로 방향을 바꾸면서 다음 항목은 첫 배포의 운영 대상에서 빠졌다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;EventBridge Scheduler
Push SQS와 DLQ
SQS Consumer
Push Device 동기화 API
Expo Push Token 소유권
push_delivery
Ticket과 Receipt 확인
외부 푸시 서비스 오류 재시도&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 코드 몇 줄이 줄어든 것이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 중 확인해야 할 Queue, DB 상태, Expo와 FCM/APNs의 장애 경계도 첫 배포에서는 관리할 필요가 없어졌다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로컬 알림이 항상 더 좋은 선택은 아니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 결론을 &amp;ldquo;작은 서비스는 로컬 알림을 쓰면 된다&amp;rdquo;로 일반화할 수는 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 요구사항이 생기면 서버 푸시가 다시 적절해진다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앱을 오래 열지 않은 사용자를 서버 활동 기준으로 선별해야 함.&lt;/li&gt;
&lt;li&gt;마지막 학습 후 1일, 3일, 7일처럼 휴면 기간별 정책이 달라짐.&lt;/li&gt;
&lt;li&gt;새로운 콘텐츠나 운영 공지를 즉시 전달해야 함.&lt;/li&gt;
&lt;li&gt;A/B 테스트에 따라 문구와 발송 시간을 바꿔야 함.&lt;/li&gt;
&lt;li&gt;사용자별 시간대와 국가 정책을 서버에서 통제해야 함.&lt;/li&gt;
&lt;li&gt;여러 기기에 일관된 설정을 적용해야 함.&lt;/li&gt;
&lt;li&gt;발송 이력과 FCM/APNs 전달 결과를 분석해야 함.&lt;/li&gt;
&lt;li&gt;관리자 임의 발송과 긴급 알림이 필요함.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 알림이 기기 안의 행동만으로 예약될 수 있고, 정책이 단순하며, 중앙 발송 이력이 필요하지 않다면 로컬 알림은 유효한 선택이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판단 기준은 서버 푸시와 로컬 알림 중 어느 기술이 더 우수한지가 아니었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 정책의 원본을 서버가 가져야 하는가, 아니면 기기가 이미 필요한 상태와 시점을 알고 있는가?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;서버 푸시 구현은 낭비였을까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과만 보면 첫 배포의 구현 방향은 로컬 알림으로 바뀌었고, 서버 푸시 코드는 매일 20시 예약 발송에 사용하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 서버 설계가 의미 없었던 것은 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현을 통해 서버 푸시로 전환할 때 필요한 경계를 구체적으로 확인할 수 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자 ID와 설치 ID를 분리해야 하는 이유.&lt;/li&gt;
&lt;li&gt;SQS 중복 전달을 DB 멱등성으로 흡수하는 방법.&lt;/li&gt;
&lt;li&gt;DB 계산 단위와 Expo 요청 단위가 다른 이유.&lt;/li&gt;
&lt;li&gt;Ticket 접수와 FCM/APNs 전달 결과의 차이.&lt;/li&gt;
&lt;li&gt;자동 재시도가 오히려 중복 알림을 만들 수 있는 조건.&lt;/li&gt;
&lt;li&gt;별도 Worker가 다시 필요해지는 규모와 운영 신호.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 &amp;ldquo;안정적으로 만들 수 있다&amp;rdquo;와 &amp;ldquo;지금 만들어야 한다&amp;rdquo;가 다른 판단이라는 점을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 푸시는 구현과 자동 테스트까지 진행했지만 당시 Scheduler는 비활성 상태였다. dev 실기기 E2E와 운영 발송 결과를 성과처럼 주장하지 않는 이유도 여기에 있다. 첫 배포에서 검증할 기준은 로컬 알림이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자별 학습 상태를 정확히 계산하려면 서버 푸시가 필요하다고 생각했다. 하지만 정확한 발송을 위해 필요한 상태와 실패 처리를 모두 펼쳐 놓고 보니, 첫 리마인더의 요구사항보다 시스템이 먼저 커지고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중앙 통제, 사용자 세분화, 발송 추적이 필요해지면 서버 푸시는 다시 유효하다. 지금은 앱이 학습 이벤트와 다음 예약 시점을 이미 알고 있었고, 그 정보만으로 제품 요구사항을 충족할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 어려웠던 부분은 Expo Push API 호출이 아니었다. 현재 제품에 필요한 신뢰성의 경계를 정하고, 이미 만든 구조보다 단순한 선택을 받아들이는 일이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 아키텍처의 출발점은 어떤 기술을 쓸지가 아니라, &lt;b&gt;알림 정책의 원본을 누가 가지고 있는지&lt;/b&gt;였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/versions/latest/sdk/notifications/&quot;&gt;Expo Notifications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/push-notifications/sending-notifications/&quot;&gt;Expo Push Service로 알림 보내기&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/push-notifications/overview/&quot;&gt;Expo Push Notifications 개요&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html&quot;&gt;Amazon SQS 최소 한 번 전달&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html&quot;&gt;Amazon SQS Visibility Timeout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule.html&quot;&gt;EventBridge Scheduler 일정 관리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Project/Landit</category>
      <category>EXPO</category>
      <category>Landit</category>
      <category>아키텍처</category>
      <category>알림</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/279</guid>
      <comments>https://pp8817.tistory.com/279#entry279comment</comments>
      <pubDate>Wed, 5 Aug 2026 01:15:05 +0900</pubDate>
    </item>
    <item>
      <title>Sentry 500은 서버 장애가 아니었다. WAF Count 뒤에 있던 취약점 스캐너</title>
      <link>https://pp8817.tistory.com/278</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/Sentry-500%EC%9D%80-%EC%84%9C%EB%B2%84-%EC%9E%A5%EC%95%A0%EA%B0%80-%EC%95%84%EB%8B%88%EC%97%88%EB%8B%A4.-WAF-Count-%EB%92%A4%EC%97%90-%EC%9E%88%EB%8D%98-%EC%B7%A8%EC%95%BD%EC%A0%90-%EC%8A%A4%EC%BA%90%EB%84%88&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;Sentry 500은 서버 장애가 아니었다. WAF Count 뒤에 있던 취약점 스캐너&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2026년 7월 25일 20시 5분, 운영 서버에서 서로 다른 Sentry 이슈 세 개가 37초 안에 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 건은 multipart 요청을 읽다가 본문이 갑자기 끝났다는 오류였다. 나머지 한 건은 form body의 &lt;code&gt;%&lt;/code&gt; 인코딩이 깨져 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 보이는 모양은 애플리케이션 장애였다. 운영 ECS task 한 곳에서 500이 연달아 발생했고, Sentry는 예외 체인이 다른 두 multipart 오류를 별도 이슈로 묶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 같은 시각의 Grafana, ALB access log, WAF 로그를 맞춰 보니 상황이 달랐다. 특정 task에 자동화된 취약점 탐색 요청이 몰렸고, 잘린 연결과 비정상 본문이 같은 시간대에 반복됐다. 로그 패턴상 외부 자동 스캐너가 전송 도중 연결을 끊거나 깨진 본문을 보냈을 가능성이 가장 높았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 세 건의 500에서 시작해 요청 주체를 확인하고, WAF 규칙을 실제 차단으로 바꾸기까지의 과정을 정리한 기록이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수치와 시간은 실제 운영 로그를 집계한 값이다. 공개용 이미지에서는 IP, 계정 ID, task 이름, Trace ID 등 내부 식별자를 제거했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;20시 5분에 무슨 일이 있었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana에서 같은 구간을 열자 평소와 다른 모양이 바로 보였다. BE 로그 발생량이 20시 5분 직후 짧은 구간에 몰렸고, 패널 표시 기준 최고 약 480 ops/s까지 올라갔다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/c4e9d7ab-e5c6-4d0e-b02b-4fe363598dfa/image.png&quot; alt=&quot;2026년 7월 25일 20시 5분 전후 BE 로그 발생량&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 시간대의 관측값을 모으면 아래와 같다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;관측 대상&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sentry 이슈&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;3건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;분석한 대표 이벤트 3건 사이의 시간&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;37초&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이벤트가 발생한 ECS task&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;동일 task 1개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해당 40초의 ALB 404&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;671건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해당 40초의 ALB 460&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,954건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해당 40초의 500&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;2건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WAF IP Reputation 매칭&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,953건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate rule이 차단한 요청&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;306건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Count 상태라 ALB까지 도달한 요청&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,647건&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sentry의 세 이슈만 보면 공통점을 찾기 어렵다. Sentry 이벤트에는 요청 URL과 User-Agent가 남아 있지 않았기 때문에 특정 ALB 요청과 1대1로 연결할 수도 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 시간, task, 예외 원인, 같은 구간의 요청 패턴을 함께 봤다. 네 단서가 모두 같은 방향을 가리켰다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두 종류의 깨진 요청&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째 유형은 multipart 본문이 종료 boundary 전에 끊긴 요청이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana Loki에서 같은 구간을 &lt;code&gt;Stream ended unexpectedly&lt;/code&gt;로 조회하자 Tomcat의 &lt;code&gt;MultipartStream$MalformedStreamException&lt;/code&gt;, &lt;code&gt;FileUploadException&lt;/code&gt;, &lt;code&gt;IOFileUploadException&lt;/code&gt;이 연속으로 확인됐다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/24474962-3e64-4acd-b31a-bb8d5bed0af4/image.png&quot; alt=&quot;Grafana에서 확인한 multipart 본문 중단 예외&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sentry의 두 multipart 이슈는 내부 예외 체인이 조금 달라 별도 그룹이 됐다. 직접 원인은 같았다. 서버가 multipart body를 다 읽기 전에 스트림이 끝났다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/832445b1-77c6-430e-aab9-3a7faad22d13/image.png&quot; alt=&quot;Sentry에서 별도 그룹으로 확인된 첫 번째 multipart 이슈&quot; /&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/241fc165-6343-4642-ba3c-bd070df4f5f8/image.png&quot; alt=&quot;같은 증상이 다른 예외 체인으로 묶인 두 번째 multipart 이슈&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 화면의 제목은 같지만 이벤트 수와 로거 구성이 다르다. Sentry는 예외 체인과 스택 정보에 따라 같은 원인의 오류도 별도 이슈로 묶을 수 있다. 정확한 하위 원인인 &lt;code&gt;Stream ended unexpectedly&lt;/code&gt;는 앞의 Grafana 로그에서 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 유형은 &lt;code&gt;application/x-www-form-urlencoded&lt;/code&gt; 본문의 잘못된 &lt;code&gt;%&lt;/code&gt; escape였다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/ca988466-cae0-4985-a729-1fbe16df9bb8/image.png&quot; alt=&quot;Grafana에서 확인한 잘못된 form URL 인코딩 예외&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;URLDecoder: Illegal hex characters in escape (%) pattern&lt;/code&gt;은 &lt;code&gt;%&lt;/code&gt; 뒤에 정상적인 16진수 두 자리가 오지 않았다는 뜻이다. 정상 클라이언트가 만든 form 요청이라기보다, 임의의 payload를 대량으로 던지는 스캔 요청과 더 잘 맞는 형태였다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/3ca5c235-e065-4d69-9a4e-6a6eb7a41c43/image.png&quot; alt=&quot;Sentry에서 확인한 HTTP form payload 디코딩 오류&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sentry에는 상위 예외인 &lt;code&gt;HttpMessageNotReadableException&lt;/code&gt;과 &lt;code&gt;Could not decode HTTP form payload&lt;/code&gt;가 남았고, 실제 &lt;code&gt;%&lt;/code&gt; escape 파싱 실패는 Grafana의 애플리케이션 로그에서 확인했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ALB 460이 원인을 좁혀 줬다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 40초 동안 ALB 460이 1,954건 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS 문서에서 ALB 460은 로드 밸런서의 idle timeout 전에 클라이언트가 연결을 닫았을 때 기록되는 상태다. 서버가 응답을 끝내기 전에 요청을 보낸 쪽이 연결을 끊은 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 값은 multipart 본문이 중간에 끊겼다는 애플리케이션 로그와 맞아떨어졌다. 같은 task와 같은 시각에 잘린 body 예외가 발생했고, ALB에서는 클라이언트가 연결을 먼저 종료한 요청이 대량으로 관측됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경로도 결정적이었다. 같은 구간에는 존재하지 않는 WordPress, PHP, 관리자, 원격 실행 취약점 경로를 순회하는 요청이 몰려 있었다. Landit 앱이 사용하는 &lt;code&gt;/api/v1/...&lt;/code&gt; 호출 패턴과는 전혀 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때문에 내부 BE나 AI 장애보다 외부 자동 스캐너가 깨진 요청을 보냈다는 설명이 훨씬 잘 맞았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 정확도를 위해 한계도 남겼다. Sentry에 URL과 User-Agent가 없으므로 이벤트 한 건을 ALB의 특정 한 줄과 직접 연결한 것은 아니다. 동일 task, 37초라는 좁은 시간, 대량의 스캔 경로, ALB 460, 동일한 예외 원인을 함께 본 상관분석이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 WAF가 1,647건을 통과시켰나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 요청은 AWS WAF &lt;code&gt;Amazon IP Reputation List&lt;/code&gt;에 1,953건 매칭됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 당시 이 관리형 규칙은 &lt;code&gt;Count&lt;/code&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-action.html&quot;&gt;AWS WAF의 Count action&lt;/a&gt;은 요청을 공격으로 확정한다는 뜻도, 차단한다는 뜻도 아니다. 규칙 조건에 맞은 요청을 집계하고 라벨과 로그를 남기지만, 그 규칙 자체는 요청을 종료하지 않는다. 오탐 가능성을 먼저 관찰할 때 쓰는 모드다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 1,953건 중 1,647건은 IP Reputation 규칙에 걸렸지만 ALB와 BE까지 도달했다. 같은 출발지가 5분당 2,000회를 넘은 뒤의 306건만 별도 rate rule에서 차단됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 숫자는 &amp;ldquo;초과 요청이 총 306건뿐이었다&amp;rdquo;는 뜻이 아니다. AWS WAF rate-based rule은 최근 요청률을 주기적으로 추정해 제한한다. 임계치를 넘기 전 요청과 탐지 시차 동안의 요청은 통과할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5분당 2,000회를 어떻게 정했나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차단 규칙은 정상 클라이언트까지 막지 않는 선에서 정해야 했다. 그래서 먼저 ALB access log로 운영 서버, 정상 사용자 기기, 외부 스캐너의 최고 5분 요청량을 따로 계산했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분석 구간은 2026년 7월 22일 02:58:52부터 7월 25일 14:59:54 KST까지였다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구분&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;최고 5분 요청량&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;2,000회와의 차이&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;운영 BE에서 AI로 보내는 대화 요청&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;42건&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;약 48배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;정상 iOS&amp;middot;Android 사용자 요청&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;21건&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;약 95배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 스캐너 A&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;2,654건&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;임계치 초과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;외부 스캐너 B&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;5,986건&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;임계치 초과&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 기간 전체 ALB 요청 49,837건 가운데 두 스캐너가 만든 요청은 36,410건이었다. 전체의 73.06%다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상 트래픽의 최고치는 42건이었다. 회사, 학교, 통신사 NAT처럼 여러 기기가 하나의 공인 IP를 공유하는 상황도 출발지 IP 집계에 이미 포함돼 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 관측 결과를 근거로 rate rule을 5분당 2,000회 기준 &lt;code&gt;Count&lt;/code&gt;에서 &lt;code&gt;Block&lt;/code&gt;으로 바꿨다. 정상 최고치와 간격이 컸고, 실제로 임계치를 넘은 것은 두 스캐너뿐이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관측 기간이 약 3.5일이라는 한계는 남는다. 그래서 &amp;ldquo;정상 사용자 영향이 절대 없다&amp;rdquo;가 아니라 &amp;ldquo;현재 관측에서 위험이 낮고, 이상 시 바로 Count로 되돌릴 수 있다&amp;rdquo;를 적용 기준으로 잡았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;WAF 로그를 켠 뒤 판단이 달라졌다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 분석에는 WAF 전체 요청 로그가 없었다. CloudWatch Count 지표와 sampled request만으로는 같은 요청이 어떤 하위 규칙에 걸렸는지, 실제 정상 요청이 섞였는지 전수 확인하기 어려웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 prod WAF의 Count와 Block 요청만 별도 S3 bucket에 저장하도록 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그에는 client IP, URI path, method, User-Agent를 남겼다. 대신 &lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;Cookie&lt;/code&gt;, &lt;code&gt;X-Api-Key&lt;/code&gt;, query string은 redaction했다. bucket은 공개 접근을 막고 SSE-S3 암호화와 30일 만료 정책을 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그가 실제로 쌓인 뒤 64개 객체, 3,539건을 분석했다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;규칙&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;실제 로그 결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon IP Reputation Count&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;3,527건, 23개 출발지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common Rule Set Count&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;500건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP rate limit Block&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;311건, 1개 출발지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;한 출발지의 최고 5분 요청량&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;3,253건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해당 출발지가 순회한 경로&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;1,316개&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/2672eb91-c0ce-4b5c-9dff-0f65378e6e8c/image.png&quot; alt=&quot;Athena에서 집계한 WAF 규칙별 Count와 Block&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 요청이 여러 managed rule에 동시에 매칭될 수 있다. 따라서 3,527, 500, 311을 단순히 더하면 공격 요청 수가 되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IP Reputation List에 가장 많이 걸린 출발지는 WordPress, PHP, PHPUnit, &lt;code&gt;.env&lt;/code&gt; 같은 Landit과 무관한 1,316개 경로를 순회했다. 이 표본에서는 &lt;code&gt;api.landit.im&lt;/code&gt;, &lt;code&gt;ai.landit.im&lt;/code&gt;, 실제 Landit BE route에 대한 Count 매칭이 0건이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 결과를 근거로 IP Reputation List의 &lt;code&gt;override_action&lt;/code&gt;을 &lt;code&gt;count&lt;/code&gt;에서 &lt;code&gt;none&lt;/code&gt;으로 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;none&lt;/code&gt;은 규칙을 끈다는 뜻이 아니다. AWS 관리형 Rule Group의 원래 action을 그대로 사용한다는 뜻이다. AWS가 Block으로 정의한 서브룰은 차단하고, 기본 Count 서브룰은 계속 Count한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Common Rule Set은 왜 Count로 남겼나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Common Rule Set 표본도 공격성 패턴이 대부분이었다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;하위 라벨&lt;/th&gt;
&lt;th align=&quot;right&quot;&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;XSS&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;284건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LFI&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;146건&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;그 외&lt;/td&gt;
&lt;td align=&quot;right&quot;&gt;RFI, SSRF, 제한 확장자, 악성 봇, body size 등&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 관리형 그룹 전체를 바로 Block으로 바꾸지는 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Landit에는 대화와 학습 입력처럼 사용자가 코드 조각이나 긴 본문을 보낼 수 있는 API가 있다. XSS body&amp;middot;query 규칙과 body size 규칙은 이런 정상 입력을 오탐할 여지가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 이번 스캐너는 IP Reputation List만 기본 action으로 복원해도 rate limit에 도달하기 전에 먼저 막을 수 있었다. 추가 효과는 얻으면서 오탐 위험은 더 작은 변경이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 적용 상태는 아래와 같다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;규칙&lt;/th&gt;
&lt;th&gt;운영 action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IP rate limit, 5분당 2,000회&lt;/td&gt;
&lt;td&gt;Block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon IP Reputation List&lt;/td&gt;
&lt;td&gt;AWS 관리형 기본 action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common Rule Set&lt;/td&gt;
&lt;td&gt;Count&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Athena 테이블은 만들어졌지만 처음에는 쓸 수 없었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그를 S3에 저장했다고 분석이 끝난 것은 아니었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 만든 ALB Athena 테이블은 &lt;code&gt;count(*)&lt;/code&gt;는 반환했지만 &lt;code&gt;client_ip&lt;/code&gt;, status, URL, User-Agent가 모두 &lt;code&gt;NULL&lt;/code&gt;이었다. Glue table 컬럼 수와 RegexSerDe capture group 수가 맞지 않았기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최신 ALB 로그의 &lt;code&gt;transformed_host&lt;/code&gt;, &lt;code&gt;transformed_uri&lt;/code&gt;, &lt;code&gt;request_transform_status&lt;/code&gt;를 schema와 regex에 추가해 37개 컬럼과 37개 capture group을 맞췄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데도 첫 운영 조회에서는 주요 컬럼이 계속 &lt;code&gt;NULL&lt;/code&gt;이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 Terraform heredoc 끝에 들어간 개행 한 글자였다. Glue의 &lt;code&gt;input.regex&lt;/code&gt;에 &lt;code&gt;\n&lt;/code&gt;이 저장되면서 RegexSerDe의 전체 행 매칭이 실패했다. regex를 개행 없는 HCL 문자열로 바꾼 뒤 1,130행의 type, client IP, User-Agent가 모두 정상 파싱됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAF Athena 테이블에서도 비슷한 검증이 한 번 더 필요했다. 분 단위 partition format은 &lt;code&gt;yyyy/MM/dd/HH/mm&lt;/code&gt;인데 range 시작값이 &lt;code&gt;2026/07/25&lt;/code&gt;로 짧아 파티션 생성이 실패했다. 시작값을 &lt;code&gt;2026/07/25/00/00&lt;/code&gt;으로 맞췄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 조회에서는 전체 3,539행과 action, client IP, URI, terminating rule이 모두 3,539행으로 일치했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://velog.velcdn.com/images/pp8817/post/4ca44147-2109-4dd2-a169-f37582895ab7/image.png&quot; alt=&quot;Athena WAF 로그 주요 컬럼 3,539행 파싱 검증&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 쿼리는 원본 client IP나 URI를 출력하지 않고 파싱된 행 수만 집계했다. 전체 행과 네 주요 컬럼의 행 수가 모두 같으므로 &lt;code&gt;NULL&lt;/code&gt; 파싱 문제와 시간 파티션 문제가 해결됐음을 확인할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;애플리케이션에서도 500을 줄여야 한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WAF 차단과 별개로 BE 처리도 보완할 지점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 malformed multipart 요청은 공통 &lt;code&gt;Exception&lt;/code&gt; 처리에 들어가 500과 Sentry error가 된다. 스캐너가 깨진 body를 보낼 때마다 내부 장애처럼 보이는 노이즈가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;MultipartException&lt;/code&gt;처럼 요청 자체가 잘못된 경우는 400으로 명시 처리하는 편이 맞다. form URL decoding 오류도 실제 예외 타입과 발생 지점을 확인해 잘못된 요청으로 제한적으로 매핑해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주의할 점도 있다. 모든 &lt;code&gt;IllegalArgumentException&lt;/code&gt;을 일괄 400으로 바꾸면 서버 코드 버그까지 클라이언트 오류로 숨길 수 있다. 이번에 확인한 요청 파싱 경로만 좁게 처리해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;적용할 때 지킨 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 WAF 변경은 아래 순서로 진행했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;ALB 로그로 정상 트래픽과 스캐너의 5분 요청량을 분리했다.&lt;/li&gt;
&lt;li&gt;rate rule만 바꾸는 좁은 Terraform plan을 만들었다.&lt;/li&gt;
&lt;li&gt;plan이 기존 Web ACL 한 건의 in-place 변경만 포함하는지 확인했다.&lt;/li&gt;
&lt;li&gt;승인 뒤 apply했다.&lt;/li&gt;
&lt;li&gt;live WAF action과 API&amp;middot;AI health endpoint를 직접 확인했다.&lt;/li&gt;
&lt;li&gt;post-apply plan이 &lt;code&gt;No changes&lt;/code&gt;인지 다시 확인했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IP Reputation 전환도 같은 방식으로 진행했다. prod plan과 apply 모두 &lt;code&gt;0 added, 1 changed, 0 destroyed&lt;/code&gt;였고, 기존 Web ACL 한 건만 변경됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정상 요청이 차단되면 해당 rule을 &lt;code&gt;Count&lt;/code&gt;로 되돌리는 절차도 운영 문서에 남겼다. Block 전환은 롤백 경로까지 준비돼야 완료된 변경으로 볼 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 조사에서 얻은 교훈&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;애플리케이션 TPS만 보면 놓칠 수 있다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;malformed multipart는 controller까지 정상 진입하기 전에 요청 파싱 단계에서 실패할 수 있다. 실제로 Grafana의 애플리케이션 TPS는 큰 변화를 보여 주지 않았지만 로그와 ALB 460은 짧은 시간에 폭증했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP metric, 애플리케이션 로그, ALB access log를 함께 봐야 했던 이유다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Count는 차단이 아니라 관찰이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;IP Reputation에 1,953건이 잡혔다&amp;rdquo;와 &amp;ldquo;1,953건을 차단했다&amp;rdquo;는 완전히 다른 문장이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시에는 1,647건이 Count 상태로 통과했다. WAF 화면에서 Count 숫자만 보고 보호되고 있다고 판단하면 안 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테이블 생성과 데이터 활용은 다른 완료 조건이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Athena table이 존재하고 row count가 나온다고 파서가 정상인 것은 아니다. 주요 컬럼의 non-null count를 함께 확인해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 컬럼과 capture group 수, regex 끝 개행, 파티션 시간 format까지 실제 데이터로 검증하고 나서야 분석 가능한 상태가 됐다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;관리형 규칙도 그룹별로 판단해야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IP Reputation List는 실제 스캐너 비중과 정상 route 0건이라는 근거가 있었다. Common Rule Set은 공격성 라벨이 많아도 정상 대화&amp;middot;학습 입력의 오탐 가능성이 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 규칙을 한꺼번에 Block으로 바꾸지 않은 이유다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 사건의 출발점은 Sentry의 500 세 건이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상관분석 결과, BE나 AI의 내부 장애보다는 외부 자동 스캔이 가장 설득력 있는 원인이었다. 특정 ECS task에 자동화된 탐색 요청과 잘린 multipart, 깨진 form body가 함께 몰렸고, 당시 Count 상태였던 IP Reputation 규칙은 이 요청들을 차단하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조사 뒤 rate rule은 5분당 2,000회 기준 Block으로 바꿨다. WAF 로그를 추가해 IP Reputation List의 실제 매칭을 확인했고, AWS 관리형 기본 action으로 전환했다. Common Rule Set은 오탐 가능성 때문에 Count를 유지했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 오래 남은 교훈은 단순하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보안 규칙의 Count는 보호 완료 상태가 아니다. 실제 요청 로그로 무엇이 걸렸는지 확인하고, 정상 트래픽과의 간격을 계산하고, 롤백 가능한 범위에서 Block으로 옮겨야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-troubleshooting.html&quot;&gt;AWS Application Load Balancer troubleshooting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/waf/latest/developerguide/waf-rule-statement-type-rate-based-caveats.html&quot;&gt;AWS WAF rate-based rule caveats&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-ip-rep.html&quot;&gt;AWS WAF IP reputation rule groups&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/waf/latest/APIReference/API_OverrideAction.html&quot;&gt;AWS WAF OverrideAction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.aws.amazon.com/athena/latest/ug/regex-serde.html&quot;&gt;Amazon Athena Regex SerDe&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Project/Landit</category>
      <category>ATHENA</category>
      <category>AWS</category>
      <category>grafana</category>
      <category>Incident Response</category>
      <category>Landit</category>
      <category>sentry</category>
      <category>WAF</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/278</guid>
      <comments>https://pp8817.tistory.com/278#entry278comment</comments>
      <pubDate>Wed, 5 Aug 2026 01:14:46 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 4에서 사라진 ObjectMapper Bean</title>
      <link>https://pp8817.tistory.com/277</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/Spring-Boot-4%EC%97%90%EC%84%9C-%EC%82%AC%EB%9D%BC%EC%A7%84-ObjectMapper-Bean&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;Spring Boot 4에서 사라진 ObjectMapper Bean&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 파이프라인은 거의 끝까지 정상적으로 진행됐다.&lt;br /&gt;테스트가 통과했고 Docker 이미지도 만들어졌다. ECR 업로드와 ECS 강제 배포도 성공했다. 그런데 마지막 &lt;code&gt;Verify ECS service&lt;/code&gt; 단계에서 배포가 멈췄다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 보이는 에러는 타임아웃이었다.&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;The action 'Verify ECS service' has timed out after 12 minutes.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 타임아웃은 원인이 아니었다. 새로 배포한 애플리케이션이 시작되지 못하고 종료된 결과였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ECS에서는 무슨 일이 벌어지고 있었을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패 당시 PRIMARY deployment의 상태는 다음과 같았다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;status&quot;: &quot;PRIMARY&quot;,
  &quot;rolloutState&quot;: &quot;IN_PROGRESS&quot;,
  &quot;desiredCount&quot;: 1,
  &quot;runningCount&quot;: 0,
  &quot;pendingCount&quot;: 0,
  &quot;failedTasks&quot;: 1
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ECS가 원하는 태스크는 1개였지만 실행 중인 태스크도, 시작을 기다리는 태스크도 없었다. 이미 한 차례 기동에 실패한 상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 ECS 서비스 전체의 &lt;code&gt;runningCount&lt;/code&gt;는 여전히 1로 표시됐다. 이전 버전의 태스크가 계속 실행되고 있었기 때문이다.&lt;br /&gt;이 상태만 보면 서비스가 정상적으로 살아 있는 것처럼 보인다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;서비스 전체 runningCount = 1
신규 PRIMARY deployment runningCount = 0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 성공 여부를 확인할 때 서비스 전체 상태만 봐서는 안 되는 이유다. 새로 생성된 PRIMARY deployment를 따로 확인해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;새 태스크가 시작되지 못한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 &lt;code&gt;RemoteAiConversationClient&lt;/code&gt;가 필요로 하는 &lt;code&gt;ObjectMapper&lt;/code&gt; Bean이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트는 Spring Boot 4를 사용하고 있었다. &lt;a href=&quot;https://spring.io/blog/2025/10/07/introducing-jackson-3-support-in-spring/&quot;&gt;Spring Boot 4의 기본 Jackson 자동 구성&lt;/a&gt;은 Jackson 3 계열을 사용한다.&lt;br /&gt;문제는 기존 AI 클라이언트가 Jackson 2의 타입을 사용하고 있었다는 점이다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;import com.fasterxml.jackson.databind.ObjectMapper;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson 3의 패키지는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;import tools.jackson.databind.ObjectMapper;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스 이름은 둘 다 &lt;code&gt;ObjectMapper&lt;/code&gt;지만 완전히 다른 타입이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot는 Jackson 3의 &lt;code&gt;ObjectMapper&lt;/code&gt;를 자동으로 등록했다.&lt;br /&gt;하지만 &lt;code&gt;RemoteAiConversationClient&lt;/code&gt;가 요구한 것은 Jackson 2의 &lt;code&gt;com.fasterxml.jackson.databind.ObjectMapper&lt;/code&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring 컨테이너 입장에서는 주입할 수 있는 Bean이 없었다. 그 결과 원격 AI 클라이언트를 생성하는 과정에서 &lt;code&gt;NoSuchBeanDefinitionException&lt;/code&gt;이 발생했고, ApplicationContext가 기동하지 못했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 정리하면 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;ECS가 새 컨테이너 실행
&amp;rarr; Spring Boot 애플리케이션 기동
&amp;rarr; RemoteAiConversationClient 생성 시도
&amp;rarr; Jackson 2 ObjectMapper 주입 시도
&amp;rarr; 해당 타입의 Bean 없음
&amp;rarr; ApplicationContext 기동 실패
&amp;rarr; 컨테이너 종료
&amp;rarr; ECS failedTasks 증가&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인프라 문제처럼 보였지만 실제 원인은 애플리케이션의 의존성 주입 실패였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그런데 왜 테스트는 통과했을까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 전에 실행한 테스트는 모두 통과했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 기존 &lt;code&gt;@SpringBootTest&lt;/code&gt;가 원격 AI 모드로 ApplicationContext를 실행하지 않았기 때문이다. 테스트 환경에서는 &lt;code&gt;RemoteAiConversationClient&lt;/code&gt;가 활성화되지 않았고, 따라서 Jackson 2 &lt;code&gt;ObjectMapper&lt;/code&gt; Bean이 없어도 문제가 드러나지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 배포에서는 다음 설정으로 원격 클라이언트가 활성화됐다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;landit.ai.client-mode=remote&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트와 배포 환경이 서로 다른 Bean 생성 경로를 사용한 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 재현하기 위해 ApplicationContext 테스트도 실제 배포 설정과 맞췄다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@ActiveProfiles(&quot;test&quot;)
@SpringBootTest(properties = &quot;landit.ai.client-mode=remote&quot;)
class LanditBeApplicationTests {

    @Autowired
    private RemoteAiConversationClient remoteAiConversationClient;

    @Test
    void remoteAiClientModeLoadsApplicationContext() {
        assertThat(remoteAiConversationClient).isNotNull();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트는 수정 전에는 &lt;code&gt;ObjectMapper&lt;/code&gt; Bean 누락으로 실패했다. 운영 환경에서만 보이던 문제가 테스트에서도 그대로 재현됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &lt;code&gt;contextLoads()&lt;/code&gt;를 실행하는 것만으로는 부족했다. 배포 환경에서 활성화되는 조건부 Bean까지 실제로 생성해야 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Jackson 2 ObjectMapper를 명시적으로 등록했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 AI 클라이언트의 타입을 한꺼번에 Jackson 3로 변경하는 대신, 필요한 Jackson 2 &lt;code&gt;ObjectMapper&lt;/code&gt; Bean만 명시적으로 등록했다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// Jackson 2 기반 외부 API 클라이언트용 ObjectMapper Bean을 제공한다.
package com.landit.landitbe.common.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JacksonConfiguration {

    @Bean
    ObjectMapper objectMapper() {
        return new ObjectMapper();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 범위를 좁힌 이유가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jackson 2와 Jackson 3가 함께 존재하는 상황에서 전체 직렬화 구성을 섣불리 변경하면 다른 API 응답이나 메시지 변환에도 영향을 줄 수 있다. 이번 장애의 직접적인 원인은 &lt;code&gt;RemoteAiConversationClient&lt;/code&gt;가 요구하는 Jackson 2 Bean의 부재였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 해당 타입의 Bean만 제공했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수정 후에는 원격 AI 모드의 ApplicationContext 테스트와 전체 Gradle 테스트가 통과했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 선택은 Jackson 3 전환을 끝낸 해법이 아니라, 기존 Jackson 2 기반 클라이언트를 유지하기 위한 호환 조치다. Spring Boot 공식 마이그레이션 가이드도 Jackson 3 전환을 우선 권장하며, Jackson 2 지원은 마이그레이션을 위한 임시 수단으로 설명한다. 장기적으로는 클라이언트까지 Jackson 3 타입으로 옮겨 두 버전이 공존하는 범위를 줄여야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배포 검증 로직도 실패를 놓치고 있었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 기동 실패와 별개로 배포 검증 스크립트에도 문제가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ECS는 이미 다음 상태를 보여주고 있었다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;runningCount = 0
pendingCount = 0
failedTasks = 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 정도면 새로운 태스크가 실패했다고 바로 판단할 수 있다. 하지만 기존 스크립트는 &lt;code&gt;failedTasks&lt;/code&gt; 값을 정상적으로 처리하지 못했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 로그에는 다음 오류가 반복됐다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;line 57: [: : integer expression expected&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS CLI 조회 결과가 빈 값이나 &lt;code&gt;None&lt;/code&gt;으로 들어오자 Bash의 정수 비교가 실패한 것이다. 스크립트는 실패 태스크를 발견하고 종료하지 못했고, ECS 상태를 계속 조회하다가 제한 시간에 도달했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;failedTasks&lt;/code&gt;를 비교하기 전에 안전하게 정규화했다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot;&gt;&lt;code&gt;case &quot;$failed_tasks&quot; in
  &quot;&quot; | &quot;None&quot; | &quot;null&quot; | *[!0-9]*) failed_tasks=0 ;;
esac&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패 태스크가 확인되면 더 이상 기다리지 않도록 조건도 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;if [ &quot;$failed_tasks&quot; -gt 0 ]; then
  echo &quot;PRIMARY ECS deployment has failed tasks&quot;
  print_service_state
  print_service_events
  exit 1
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 같은 문제가 발생하면 의미 없는 타임아웃 대신 PRIMARY deployment의 상태와 최근 ECS 이벤트를 남기고 즉시 실패한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 스크립트가 애플리케이션 장애를 막아 주지는 않는다. 하지만 장애를 12분짜리 타임아웃으로 숨기지 않고, 처음 실패한 시점에 드러내 준다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 장애에서 배운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 테스트 환경에서 실제 배포 조건을 활성화해야 한다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@SpringBootTest&lt;/code&gt;가 통과했다는 사실만으로 운영 설정의 ApplicationContext까지 정상이라고 볼 수 없다. 프로파일이나 프로퍼티에 따라 Bean 구성이 달라진다면 배포 환경과 같은 조건으로 기동하는 테스트가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 같은 이름의 클래스라도 패키지가 다르면 전혀 다른 타입이라는 점이다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;Jackson 2: com.fasterxml.jackson.databind.ObjectMapper
Jackson 3: tools.jackson.databind.ObjectMapper&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 메이저 버전을 올릴 때는 의존성 버전만 확인해서는 부족하다. 자동 구성으로 등록되는 Bean의 실제 타입과 기존 코드가 주입받는 타입도 함께 확인해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 ECS 서비스 전체 상태와 신규 deployment 상태를 분리해서 봐야 한다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 태스크가 살아 있으면 서비스의 &lt;code&gt;runningCount&lt;/code&gt;는 정상처럼 보인다. 하지만 PRIMARY deployment의 &lt;code&gt;runningCount=0&lt;/code&gt;, &lt;code&gt;pendingCount=0&lt;/code&gt;, &lt;code&gt;failedTasks&amp;gt;0&lt;/code&gt;라면 새 버전은 이미 실패한 상태다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://spring.io/blog/2025/10/07/introducing-jackson-3-support-in-spring/&quot;&gt;Spring의 Jackson 3 지원 소개&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide&quot;&gt;Spring Boot 4.0 마이그레이션 가이드&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend/Spring</category>
      <category>spring boot 4</category>
      <category>트러블슈팅</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/277</guid>
      <comments>https://pp8817.tistory.com/277#entry277comment</comments>
      <pubDate>Wed, 5 Aug 2026 01:00:13 +0900</pubDate>
    </item>
    <item>
      <title>테스트 주도 개발 | 켄트 백</title>
      <link>https://pp8817.tistory.com/276</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C-%EC%BC%84%ED%8A%B8-%EB%B0%B1&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;테스트가 코드를 이끄는 방식&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;켄트 백의 '테스트 주도 개발'은 개발자가 코드를 작성하는 순서를 바꾸자고 말하는 책이다.&lt;br /&gt;내가 지금까지 코드를 작성한 흐름은&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기능을 먼저 구현하고&lt;/li&gt;
&lt;li&gt;어느 정도 완성된 뒤에 테스트한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 TDD는 이 흐름을 역행한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;먼저 테스트를 작성하고&lt;/li&gt;
&lt;li&gt;그 테스트를 통과하는 코드를 만들고&lt;/li&gt;
&lt;li&gt;마지막으로 코드를 정리한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &quot;이게 무슨 말이지?&quot; 생각했다.&lt;br /&gt;당연히 그럴만한게 이전까지의 나에게 테스트 코드란&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;내가 작성한 코드를 검증하는 것&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이었기 때문이다. 당연히 코드가 있어야 테스트 코드를 작성할 수 있다고 생각했다. 코드도 없는데 어떻게 테스트를 작성할 수 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 의문이 바로 TDD의 출발점이다.&lt;br /&gt;테스트를 먼저 작성한다는 것은 '&lt;b&gt;내가 만들 코드가 어떤 동작을 해야 하는지&lt;/b&gt;'를 먼저 정한다는 뜻이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요약: &lt;b&gt;구현보다 기대 동작을 앞세우는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TDD가 해결하려는 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발을 하다 보면 간단해 보이던 코드가 점점 다루기 어려워진다.&lt;br /&gt;기능을 추가하고... 예외를 추가하고... 버그를 고치다 보면 어느 순간부터는 코드를 수정하는 일이 부담스러워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 코드가 부재할 때의 문제는 코드가 정상적으로 동작하는지 확인하는 과정에 있다. 테스트 코드가 없다면 흔히들 사용하는 Swagger, Postman, ... 등으로 직접 API 호출하며 점검해야 한다.&lt;br /&gt;당연하게도 이런 방식은 느리고 불안정하다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람이 직접 개입하는 순간부터는 &lt;b&gt;휴먼 에러(Human Error)&lt;/b&gt;를 무시할 수 없다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 큰 문제는 리팩토링에 있다.&lt;br /&gt;리팩토링을 하는 과정에서 기존 동작에 영향을 줬는지 바로 확인할 수가 없으니 문제가 없는지 하나하나 확인해야 한다.&lt;br /&gt;코드 규모가 크지 않다면 괜찮지만, 수만줄이 넘어가는 코드를 하나씩 점검한다는 것은 상상만 해도 식은땀이 난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDD는 이 문제를 &lt;b&gt;작은 피드백 루프&lt;/b&gt;로 해결한다. 한 번에 큰 기능을 만들지 않고, 아주 작은 기대 동작 하나를 테스트로 적는다.&lt;br /&gt;그 테스트를 통과시키고, 다시 코드를 정리한다. 이 과정을 반복하면서 코드는 기능을 갖추고, 테스트는 안전망으로 쌓인다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;아주 작은 기대 동작?&lt;/b&gt;&lt;br /&gt;코드가 만족해야 하는 가장 작은 단위의 행동을 뜻한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'할인 기능'을 예시로 들면, TDD식으로 작게 쪼개면 아래처럼 된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가격 10,000원에 할인율 10%를 넣으면 9,000원이 나온다.&lt;/li&gt;
&lt;li&gt;가격 20,000원에 할인율 20%를 넣으면 16,000원이 나온다.&lt;/li&gt;
&lt;li&gt;할인율이 0%면 원래 가격이 그대로 나온다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TDD의 핵심 사이클&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDD는 흔히 Red, Green, Refactor라는 세 단계로 설명된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/a877f8757d68a364.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Red 단계&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실패하는 테스트를 작성한다.&lt;/li&gt;
&lt;li&gt;아직 구현이 없거나 부족하기 때문에 테스트는 실패해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Green 단계&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트를 통과하는 가장 단순한 코드를 작성한다.&lt;/li&gt;
&lt;li&gt;이때 목표는 좋은 설계가 아니다. 일단 테스트를 초록색으로 만드는 것이다.&lt;/li&gt;
&lt;li&gt;중복이 있거나 어색한 코드가 있어도 괜찮다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Refactor 단계&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트가 통과하는 상태를 유지한 채 코드를 정리한다.&lt;/li&gt;
&lt;li&gt;이름을 바꾸고, 중복을 제거하고, 책임을 나눈다. 테스트가 있기 때문에 정리 과정에서 기존 동작이 깨졌는지 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Red 단계에서는 기대하는 동작을 테스트로 먼저 작성한다. 아직 실제 코드가 없거나 구현이 부족하기 때문에 이 테스트는 실패한다. 여기서 중요한 점은 테스트가 내가 의도한 이유로 실패하는지 확인하는 것이다.&lt;/li&gt;
&lt;li&gt;Green 단계에서는 Red 단계에서 작성한 테스트를 통과하는 가장 단순한 코드를 작성한다. 이 단계에서는 완벽한 설계나 역할 분리보다 테스트를 통과시키는 것이 우선이다.&lt;/li&gt;
&lt;li&gt;Refactor 단계에서는 테스트가 계속 통과하는 상태를 유지하면서 Green 단계에서 작성한 코드를 정리한다. 중복을 제거하고, 이름을 다듬고, 책임과 구조를 더 명확하게 만든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;간단한 예제로 이해하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 할인 가격을 계산하는 함수를 만든다고 해 보자. 가격이 10,000원이고 할인율이 10퍼센트라면 결과는 9,000원이어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Red 단계에서 테스트를 작성한다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def test_discount_price():
    assert discount_price(10000, 10) == 9000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연히 테스트는 실패한다. &lt;code&gt;discount_price&lt;/code&gt;라는 함수가 아직 없기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Green 단게에서 테스트가 통과하는 최소한의 코드를 작성한다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;def discount_price(price, rate):
    return 9000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 개발자에게는 최악의 코드처럼 보일 것이다.&lt;br /&gt;입력값을 전혀 사용하지 않고 무조건 9,000을 반환한다.&lt;br /&gt;하지만 &lt;b&gt;첫 번째 테스트는 통과&lt;/b&gt;한다. TDD에서는 이런 식의 '가짜 구현'도 허용된다. &lt;b&gt;중요한 것은 처음부터 필요 이상의 일반화를 하지 않는 것이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 두 번째 테스트를 추가한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;def test_discount_price():
    assert discount_price(10000, 10) == 9000
    assert discount_price(20000, 20) == 16000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 하드코딩은 더 이상 통하지 않는다. 테스트가 더 일반적인 구현을 요구한다.&lt;/p&gt;
&lt;pre class=&quot;ruby&quot;&gt;&lt;code&gt;def discount_price(price, rate):
    return price * (100 - rate) // 100&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름이 TDD의 핵심을 잘 보여 준다.&lt;br /&gt;&lt;b&gt;처음부터 완벽한 공식을 떠올려서 구현하는 것이 아니라, 테스트가 요구하는 만큼 코드를 발전시킨다. 필요가 실제로 드러났을 때만 일반화한다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;세 가지 구현 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책에서는 TDD를 진행할 때 사용할 수 있는 몇 가지 전략을 보여 준다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;핵심은 무리하게 똑똑한 코드를 쓰지 않는 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 &lt;b&gt;가짜 구현&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;앞의 예시처럼 일단 정해진 값을 반환해서 테스트를 통과시키는 방식이다. 보기에는 허술하지만, 현재 테스트가 요구하는 최소 동작을 빠르게 확인할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 &lt;b&gt;삼각측량&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;하나의 테스트만으로는 일반화할 근거가 부족할 때 &lt;b&gt;테스트를 하나 더 추가&lt;/b&gt;한다. 서로 다른 두 사례가 생기면 코드가 어떤 방향으로 일반화되어야 하는지 더 분명해진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 &lt;b&gt;명백한 구현&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;너무 뻔한 경우에는 &lt;b&gt;바로 일반적인 구현을 작성&lt;/b&gt;한다. 예를 들어 두 숫자를 더하는 함수라면 굳이 가짜 구현부터 시작할 필요가 없다. 다만 이때도 테스트를 통해 동작을 확인하는 리듬은 유지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/4b0d29d1c1978859.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 전략들의 공통점은 추측을 줄인다는 데 있다.&lt;br /&gt;&lt;b&gt;TDD는 '나중에 필요할 것 같으니까'라는 이유로 코드를 늘리지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트가 설계를 이끄는 방식&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDD에서 테스트는 단순한 검증 도구가 아니다. 테스트는 설계 도구다.&lt;br /&gt;테스트를 먼저 쓰려면 내가 만들 코드를 어떻게 사용할지 먼저 생각해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수 이름은 무엇이 자연스러운지, 입력값은 어떤 형태여야 하는지, 결과는 어떻게 확인할 수 있어야 하는지 고민하게 된다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 코드의 외부 인터페이스를 먼저 드러낸다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 테스트는 읽기 쉬운 사용 예시가 된다. 나중에 코드를 처음 보는 사람도 테스트를 보면 이 코드가 어떤 상황에서 어떤 결과를 내야 하는지 이해할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def test_sum_empty_list_is_zero():
    assert sum_numbers([]) == 0

def test_sum_multiple_numbers():
    assert sum_numbers([1, 2, 3]) == 6&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트들은 단순히 오류를 잡는 장치가 아니다. &lt;code&gt;sum_numbers&lt;/code&gt;라는 함수가 어떤 입력을 받고, 빈 배열을 어떻게 처리하며, 여러 숫자를 어떻게 계산하는지 설명한다. 테스트가 문서처럼 작동하는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TDD의 장점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDD의 가장 큰 장점은 빠른 피드백이다. 코드를 조금 바꾼 뒤 바로 테스트를 실행하면 방금 한 변경이 의도대로 동작하는지 알 수 있다. 비용이 낮아지면 개발자는 더 자주 확인하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 장점은 리팩터링이 쉬워진다는 점이다. 테스트가 없으면 코드를 정리하는 일은 위험하다. 하지만 테스트가 있으면 기존 동작이 유지되는지 확인하면서 구조를 바꿀 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로 가장 강력한 장점이라고 생각한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째 장점은 불필요한 코드를 줄인다는 점이다. TDD는 테스트가 요구하는 코드만 작성하게 만든다. 아직 필요하지 않은 추상화, 설정, 확장 포인트를 미리 만들지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 번째 장점은 설계가 사용성 중심으로 바뀐다는 점이다. 테스트를 먼저 쓰면 코드를 사용하는 입장에서 API를 생각하게 된다. 자연스럽게 호출하기 쉬운 코드, 확인하기 쉬운 코드, 분리하기 쉬운 코드가 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TDD의 한계와 오해&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TDD가 모든 문제의 정답은 아니다.&lt;/b&gt; UI의 시각적 완성도, 복잡한 외부 시스템 연동, 성능 튜닝처럼 테스트만으로 판단하기 어려운 영역도 있다. 이런 영역에서는 다른 검증 방법이 함께 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 TDD를 한다고 해서 좋은 설계가 자동으로 생기는 것도 아니다. 테스트를 너무 구현 세부사항에 묶어 두면 오히려 리팩터링을 방해할 수 있다. 좋은 테스트는 내부 구현보다 외부 동작을 검증해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 흔한 오해는 TDD가 테스트를 많이 쓰는 방법&lt;/b&gt;이라는 것이다. 핵심은 양이 아니라 순서다. 테스트를 먼저 쓰고, 작은 피드백을 반복하며, 동작을 보존한 채 설계를 개선하는 흐름이 중요하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실제 프로젝트에 적용하는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 모든 기능을 TDD로 만들 필요는 없다. 작고 순수한 로직부터 시작하는 것이 좋다. 계산 함수, 문자열 처리, 검증 로직, 정책 판단처럼 입력과 출력이 분명한 코드가 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적용 순서는 단순하다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;만들 기능을 한 문장으로 적는다.&lt;/li&gt;
&lt;li&gt;그 기능이 성공했다는 증거를 테스트로 작성한다.&lt;/li&gt;
&lt;li&gt;테스트가 실패하는지 확인한다.&lt;/li&gt;
&lt;li&gt;테스트를 통과하는 최소 코드를 작성한다.&lt;/li&gt;
&lt;li&gt;중복과 어색한 이름을 정리한다.&lt;/li&gt;
&lt;li&gt;다음 테스트를 추가한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 속도가 느려질 수 있다. 하지만 중요한 것은 속도가 아니라 리듬을 익히는 것이다. 테스트를 작게 쓰고, 구현을 작게 하고, 정리를 작게 하는 감각이 쌓이면 코드 변경에 대한 두려움이 줄어든다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;책을 읽고 남는 태도&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 책에서 가장 크게 남는 태도는 '작은 것부터 하나씩'이다.&lt;br /&gt;큰 설계를 한 번에 완성하려 하지 말고, 작은 테스트 하나로 다음 행동을 정한다. 불확실한 상황에서 가장 확실한 한 걸음을 고르는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나는 '필요해질 때까지 기다리라'는 태도다. 개발자는 미래를 예측해 코드를 복잡하게 만들기 쉽다. TDD는 그 충동을 제어한다. 지금 테스트가 요구하지 않는 코드는 지금 필요하지 않은 코드일 가능성이 높다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 TDD는 개발을 더 안전한 활동으로 만든다. 테스트가 쌓이면 코드는 고정되는 것이 아니라 바꿀 수 있는 대상이 된다. 변경할 수 있는 코드가 좋은 코드라는 점에서, TDD는 단순한 테스트 작성법보다 더 넓은 개발 철학에 가깝다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'테스트 주도 개발'은 오래된 책이지만 여전히 유효하다. 프레임워크와 언어는 바뀌었지만, 개발자가 겪는 불안은 크게 달라지지 않았다. 내가 작성한 코드가 제대로 동작하는지, 고쳐도 괜찮은지, 지금 만든 설계가 과한 것은 아닌지 계속 고민하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TDD는 이 고민을 한 번에 해결하지 않는다. 대신 아주 작은 단위로 쪼갠다. 실패하는 테스트를 쓰고, 통과시키고, 정리한다. 이 짧은 반복이 쌓이면 코드는 점점 기능을 갖추고, 테스트는 변경을 지탱하는 안전망이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이 책은 테스트를 배우려는 사람뿐 아니라, 코드를 더 단순하게 만들고 싶은 개발자에게도 의미가 있다. TDD는 더 많은 코드를 쓰는 기술이 아니다. 필요한 코드만 쓰고, 그 코드가 계속 바뀔 수 있게 만드는 기술이다.&lt;/p&gt;</description>
      <category>Project/Book</category>
      <category>Book</category>
      <category>책</category>
      <category>테스트 주도 개발</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/276</guid>
      <comments>https://pp8817.tistory.com/276#entry276comment</comments>
      <pubDate>Wed, 5 Aug 2026 00:59:55 +0900</pubDate>
    </item>
    <item>
      <title>앱 웹뷰 도입 후 Refresh Token 구조를 다시 설계한 이유</title>
      <link>https://pp8817.tistory.com/275</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/%EC%9B%B9%EC%97%90%EC%84%9C-%EC%95%B1-%EC%9B%B9%EB%B7%B0%EA%B9%8C%EC%A7%80.-Refresh-Token-%EC%A0%80%EC%9E%A5-%EA%B5%AC%EC%A1%B0%EB%A5%BC-%EB%8B%A4%EC%8B%9C-%EB%B3%B4%EA%B2%8C-%EB%90%9C-%EC%9D%B4%EC%9C%A0&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;척척학사는 본래 웹 서비스 중심으로 운영되고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 인증 구조에서는 사용자가 로그인하면 서버가 Access Token과 Refresh Token을 발급했다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;refresh_token
- user_id  PK
- token
- expiry&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에는 하나의 가정이 숨어 있었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 사용자는 동시에 하나의 Refresh Token만 가진다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹 서비스만 운영할 때는 이 가정이 크게 문제 되지 않았다.&lt;br /&gt;대부분의 사용 흐름이 브라우저라는 하나의 진입점에서 시작됐고, 같은 계정으로 여러 클라이언트 환경에 동시에 로그인하는 상황도 많지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 별도 앱 서비스가 붙으면서 상황이 달라졌다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;척척학사는 웹, 앱 서비스가 별도의 기능을 가지고 있었다.&lt;br /&gt;앱: 에브리타임의 시간표 기능을 이용하지 못하는 수원대 학우들을 위한 시간표 서비스&lt;br /&gt;웹: 학점 관리 및 졸업 요건 분석 서비스&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 척척학사 앱은 시간표 기능만 제공하고 있었는데, 이번에 앱 안의 웹뷰로 척척학사 웹 서비스를 보여주게 됐다.&lt;br /&gt;사용자는 이제 &lt;b&gt;브라우저에서도 척척학사에 들어오고, 앱 웹뷰에서도 같은 웹 서비스에 들어올 수 있게 됐다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물리적으로는 같은 사용자의 같은 서비스 접근이다.&lt;br /&gt;하지만 &lt;b&gt;인증 관점&lt;/b&gt;에서는 서로 다른 클라이언트 세션이 생긴다. 브라우저와 앱 웹뷰는 토큰 저장 공간이 다르고, 각각 독립적으로 로그인과 refresh API 호출을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 입장에서는 &lt;b&gt;다중 기기 로그인&lt;/b&gt;과 비슷한 상황이 만들어진 것이다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/674ebfd29daa479d.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 기존 Refresh Token 저장 구조의 약점이 드러났다.&lt;br /&gt;Refresh Token을 &lt;code&gt;userId&lt;/code&gt; 기준으로 하나만 저장하고 있었기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 구조의 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 클라이언트만 생각하면 &lt;code&gt;userId&lt;/code&gt;를 primary key로 쓰는 구조도 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 로그인하면 서버는 Refresh Token을 저장한다.&lt;br /&gt;이후 토큰 재발급 API가 호출되면 요청으로 들어온 Refresh Token과 DB에 저장된 Refresh Token을 비교한다. 두 값이 같으면 새 Access Token을 발급한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 같은 계정으로 두 클라이언트에서 로그인할 때 생긴다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/dbe0e59468dedd73.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 경우 브라우저 사용자는 정상적으로 로그인했고, 정상적으로 refresh API를 호출했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 앱 웹뷰에서 같은 계정으로 로그인하면서 DB row가 덮어씌어 지고 브라우저 사용자의 Refresh Token은 유효하지 않은 토큰이 된다.&lt;br /&gt;그래서 브라우저에서 토큰 재발급 API를 호출하면 &lt;b&gt;토큰 불일치가 발생&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 핵심은 Refresh Token 자체가 아니라 저장 기준이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조는 사용자를 기준으로 Refresh Token을 하나만 보관했다.&lt;br /&gt;하지만 실제로는 같은 사용자에게도 브라우저 세션과 앱 웹뷰 세션이 따로 존재할 수 있다. 사용자 단위로는 하나지만, 로그인 세션 단위로는 여러 개일 수 있는 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1차 완화책: Refresh Token을 매번 갱신하지 않기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠르게 문제를 줄이는 방법은 refresh API가 호출될 때마다 Refresh Token을 새로 발급하지 않는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 refresh API 호출 시점마다 Access Token과 Refresh Token을 함께 재발급했다.&lt;br /&gt;이 방식은 보안상 토큰 탈취 피해를 줄이는 장점이 있지만, &lt;code&gt;userId&lt;/code&gt; 단일 row 구조에서는 충돌을 더 자주 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 1차 완화책으로는 만료까지 충분히 남아 있으면 Access Token만 새로 발급하고, Refresh Token은 그대로 유지하도록 바꿀 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시로 아래의 경우에만 Refresh Token을 갱신한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운영: 만료까지 7일 이하&lt;/li&gt;
&lt;li&gt;개발: 만료까지 2일 이하&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/bd6cf16f44a6e70d.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 충돌 빈도를 줄인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저와 앱 웹뷰가 refresh API를 자주 호출하더라도 DB에 저장된 Refresh Token이 매번 바뀌지는 않는다. 따라서 한쪽의 refresh 요청이 다른 쪽 세션을 계속 깨뜨리는 상황을 어느 정도 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만 근본적인 문제를 해결하지 못한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB row가 여전히 &lt;code&gt;userId&lt;/code&gt; 기준 하나뿐이기 때문이다. 만료가 가까워지는 시점에는 결국 Refresh Token을 새로 발급해야 한다. 그때 한 클라이언트의 갱신이 다른 클라이언트의 토큰을 다시 덮어쓸 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차 완화책은 충돌을 없애는 방법이 아니라, 충돌이 일어나는 횟수를 줄이는 방법일 뿐이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;근본 해결책: Refresh Token을 로그인 세션 단위로 저장하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근본적으로는 저장 기준을 바꿔야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Refresh Token은 사용자에게 속하지만, &lt;b&gt;실제 생명주기는 로그인 세션 단위&lt;/b&gt;로 움직인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한쪽에서 Refresh Token을 rotate해도 다른 쪽 토큰이 깨지면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Refresh Token JWT 안에 &lt;code&gt;sid&lt;/code&gt; claim을 추가했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;sid&lt;/code&gt;: 서버가 로그인 성공 시점에 발급하는 UUID&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public RefreshTokenWithExpiry createRefreshToken(String userId) {
    return createRefreshToken(userId, UUID.randomUUID().toString());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 사용자가 브라우저와 앱 웹뷰에서 로그인하면 서로 다른 &lt;code&gt;sid&lt;/code&gt;가 생긴다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;브라우저 로그인.
userId=U, sid=S1, token=RT_BROWSER.

앱 웹뷰 로그인.
userId=U, sid=S2, token=RT_WEBVIEW.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 스키마도 &lt;code&gt;userId&lt;/code&gt; PK가 아니라 &lt;code&gt;session_id&lt;/code&gt; PK로 바꾼다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;refresh_token
- session_id  PK
- user_id
- token
- expiry&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구조를 그림으로 보면 이렇게 바뀐다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/18bf92b45e40bba2.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 같은 사용자의 Refresh Token도 여러 row로 저장된다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;session_id=S1, user_id=U, token=RT_BROWSER
session_id=S2, user_id=U, token=RT_WEBVIEW&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저의 refresh는 S1 row만 갱신한다.&lt;br /&gt;앱 웹뷰의 refresh는 S2 row만 갱신한다. 서로의 토큰을 덮어쓰지 않는다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;토큰 재발급 API는 어떻게 바뀌나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 재발급 API에서는 요청으로 들어온 Refresh Token을 먼저 파싱한다.&lt;br /&gt;여기서 subject로 &lt;code&gt;userId&lt;/code&gt;를 얻고, claim에서 &lt;code&gt;sid&lt;/code&gt;를 읽는다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Claims claims = jwtProvider.parseToken(refreshToken);
String userId = claims.getSubject();
String sessionId = resolveSessionId(claims, userId);

RefreshToken saved = refreshTokenRepository.findById(sessionId)
        .orElseThrow(...);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음 DB에 저장된 토큰과 요청 토큰이 정확히 같은지 비교한다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;if (!saved.getToken().equals(refreshToken)) {
    throw new TokenException(ErrorCode.REFRESH_TOKEN_MISMATCH);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;sid&lt;/code&gt;는 DB row를 찾기 위한 식별자일 뿐이다. 실제 Refresh Token이 유효한지는 DB에 저장된 토큰 문자열과 요청 토큰 문자열이 일치하는지로 판단해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 Refresh Token을 발급할 때는 기존 &lt;code&gt;sid&lt;/code&gt;를 유지한다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;AuthDto.RefreshTokenWithExpiry newRefresh =
        jwtProvider.createRefreshToken(userId, sessionId);

save(sessionId, userId, newRefresh.token(), newRefresh.expiry());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기가 중요하다. refresh 시점에 &lt;code&gt;sid&lt;/code&gt;를 새로 만들면 같은 로그인 세션의 연속성이 끊긴다. 토큰 문자열은 rotate하되, 세션 식별자는 유지해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 흐름은 이렇게 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/fe6b79b0816bcb26.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 브라우저와 앱 웹뷰가 같은 계정으로 로그인해도 각자의 세션 row만 갱신한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 deviceId를 쓰지 않았나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 보면 &amp;ldquo;기기별로 관리해야 하니 deviceId를 받으면 되지 않나&amp;rdquo;라고 생각할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이번 문제의 본질은 물리 기기를 식별하는 것이 아니었다. 브라우저와 앱 웹뷰처럼 서로 다른 로그인 세션을 분리하는 것이 핵심이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 안정적인 deviceId를 제공하지 않는 상황에서 서버가 기기를 추정하려고 하면 설계가 더 복잡해진다.&lt;br /&gt;IP, User-Agent, OS 정보는 신뢰하기 어렵고 개인정보 관점에서도 불필요한 수집이 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버가 UUID 기반 &lt;code&gt;sid&lt;/code&gt;를 발급하면 API contract를 바꾸지 않고도 세션을 분리할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/d2bcb5ed12e2d284.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 로그인된 기기 목록이나 특정 기기 로그아웃 기능이 필요해지면 그때 deviceName이나 client deviceId를 추가하면 된다. 이번 단계에서는 세션 분리가 목적이었다. 그래서 서버 발급 &lt;code&gt;sid&lt;/code&gt;가 더 단순하고 안전한 선택이었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 Refresh Token 호환&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 발급된 Refresh Token에는 &lt;code&gt;sid&lt;/code&gt;가 없다. 배포 직후 기존 사용자를 모두 로그아웃시키지 않으려면 fallback이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;sid&lt;/code&gt;가 없으면 &lt;code&gt;userId&lt;/code&gt;를 session id처럼 사용한다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;private String resolveSessionId(Claims claims, String userId) {
    String sessionId = claims.get(&quot;sid&quot;, String.class);
    if (sessionId == null || sessionId.isBlank()) {
        return userId;
    }
    return sessionId;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;migration에서도 기존 row의 &lt;code&gt;session_id&lt;/code&gt;를 &lt;code&gt;user_id&lt;/code&gt;로 채운다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;ALTER TABLE public.refresh_token
    ADD COLUMN IF NOT EXISTS session_id VARCHAR(255);

UPDATE public.refresh_token
SET session_id = user_id
WHERE session_id IS NULL;

ALTER TABLE public.refresh_token
    DROP CONSTRAINT IF EXISTS pk_refresh_token;

ALTER TABLE public.refresh_token
    ADD CONSTRAINT pk_refresh_token PRIMARY KEY (session_id);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 기존 Refresh Token도 한 번 더 refresh할 수 있다. 기존 토큰에는 &lt;code&gt;sid&lt;/code&gt;가 없으므로 &lt;code&gt;userId&lt;/code&gt; fallback으로 row를 찾는다. 이후 새로 발급되는 Refresh Token에는 &lt;code&gt;sid&lt;/code&gt;가 들어간다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/aeaf76fabae2c9d6.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호환성의 핵심은 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, DB에는 기존 row를 찾을 수 있도록 &lt;code&gt;session_id=user_id&lt;/code&gt; 값을 채워 둔다. 둘째, 코드에서는 &lt;code&gt;sid&lt;/code&gt;가 없는 토큰을 만났을 때 &lt;code&gt;userId&lt;/code&gt; fallback을 사용한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트로 확인한 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 변경은 인증 흐름과 DB schema를 함께 바꾸는 작업이라 테스트도 같이 보강했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;JwtProvider&lt;/code&gt; 테스트에서는 Refresh Token에 &lt;code&gt;sid&lt;/code&gt;가 들어가는지 확인했다. 같은 사용자에게 Refresh Token을 여러 번 발급해도 &lt;code&gt;sid&lt;/code&gt;가 매번 달라지는지도 확인했다. 반대로 refresh API에서 토큰을 재발급할 때는 기존 &lt;code&gt;sid&lt;/code&gt;를 유지할 수 있어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;RefreshTokenService&lt;/code&gt; 테스트에서는 refresh API가 &lt;code&gt;sid&lt;/code&gt; 기준으로 row를 조회하는지 확인했다. 요청 토큰과 DB 토큰이 다르면 mismatch가 발생해야 하고, &lt;code&gt;sid&lt;/code&gt;가 없는 기존 토큰은 &lt;code&gt;userId&lt;/code&gt; fallback으로 처리되어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flyway migration 테스트에서는 V5 migration 이후 &lt;code&gt;refresh_token&lt;/code&gt; 테이블에 &lt;code&gt;session_id&lt;/code&gt; 컬럼이 생기고, primary key가 &lt;code&gt;session_id&lt;/code&gt;로 바뀌는지 확인했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/e8682dd3796602de.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트들이 있어야 나중에 누군가 Refresh Token 저장 구조를 다시 건드리더라도 다중 세션 문제가 재발했는지 바로 알 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 문제는 단순해 보였다. 같은 계정으로 브라우저와 앱 웹뷰에 로그인했더니 한쪽 Refresh Token이 깨졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인은 Refresh Token을 &lt;code&gt;userId&lt;/code&gt; 기준으로 하나만 저장하던 구조였다.&lt;br /&gt;웹 서비스만 있을 때는 잘 드러나지 않았지만, 앱 웹뷰가 붙으면서 하나의 사용자가 여러 클라이언트 세션을 갖게 됐다. 기존 저장 모델이 그 상황을 버티지 못한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차 완화책은 Refresh Token 갱신 빈도를 줄여 충돌 가능성을 낮춘다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운영 중인 문제를 빠르게 줄이는 데는 도움이 된다. 하지만 &lt;code&gt;userId&lt;/code&gt; 단일 row 구조가 그대로 남기 때문에 충돌 가능성을 완전히 없애지는 못한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근본 해결책은 저장 모델을 바꾸는 것이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Refresh Token을 사용자 단위가 아니라 로그인 세션 단위로 관리한다. &lt;code&gt;sid&lt;/code&gt;를 JWT에 넣고, DB는 &lt;code&gt;session_id&lt;/code&gt;를 primary key로 사용한다. 그러면 브라우저와 앱 웹뷰가 같은 계정으로 로그인해도 서로의 토큰을 덮어쓰지 않는다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Project/척척학사</category>
      <category>Refresh Token</category>
      <category>Spring Security</category>
      <category>척척학사</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/275</guid>
      <comments>https://pp8817.tistory.com/275#entry275comment</comments>
      <pubDate>Wed, 5 Aug 2026 00:59:36 +0900</pubDate>
    </item>
    <item>
      <title>[Saynow] LLM Workflow 개선기 - availableOptions를 버리고 RAG로 간 이유</title>
      <link>https://pp8817.tistory.com/274</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/Saynow-LLM-Workflow-%EA%B0%9C%EC%84%A0%EA%B8%B0-availableOptions%EB%A5%BC-%EB%B2%84%EB%A6%AC%EA%B3%A0-RAG%EB%A1%9C-%EA%B0%84-%EC%9D%B4%EC%9C%A0&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SayNow의 AI 서버를 손보면서 처음에는 프롬프트만 잘 고치면 된다고 생각했다. 메뉴 추천 요청에서 하트가 깎이고, &lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt; 같은 자연스러운 답변이 실패처럼 처리되는 문제였기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 테스트를 계속하다 보니 이건 단순한 프롬프트 문제가 아니었다. LLM을 어느 단계에서 어떻게 쓰는지, 그리고 어떤 정보를 어디에 저장할지에 가까운 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 SayNow에서 next-question, feedback, 도움 요청을 어떤 workflow로 나눴는지 정리한 글이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모든 LLM 작업을 같은 구조로 묶지 않았다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM을 쓰는 구조는 여러 가지가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 구조는 단일 모델 호출이다. 입력을 넣고 응답을 받는다. 분류, 짧은 생성, 간단한 변환에는 이 방식이 빠르고 관리하기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 복잡한 구조는 멀티스텝이다. 한 번의 LLM 호출에 모든 책임을 넣지 않고, 중간에 검증이나 후처리를 둔다. 실패 지점을 나눠 볼 수 있고 테스트하기도 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RAG는 외부 지식을 검색한 뒤 LLM 응답에 넣는 구조다. 이미 있는 문서나 누적된 지식을 바탕으로 답해야 할 때 맞다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Agent Loop는 모델이 계획을 세우고 도구를 부르고, 결과를 보고 다시 판단하는 구조다. 강력하지만 지연 시간과 실패 지점도 늘어난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SayNow에는 하나의 정답 구조를 고르지 않았다. 기능마다 요구가 달랐기 때문이다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;영역&lt;/th&gt;
&lt;th&gt;선택한 구조&lt;/th&gt;
&lt;th&gt;이유&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;꼬리 질문&lt;/td&gt;
&lt;td&gt;단일 모델 중심&lt;/td&gt;
&lt;td&gt;대화 중 바로 응답해야 해서 빠른 처리가 중요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;최종 피드백&lt;/td&gt;
&lt;td&gt;멀티스텝&lt;/td&gt;
&lt;td&gt;품질, 점수, 턴별 피드백을 더 꼼꼼히 봐야 한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도움 요청&lt;/td&gt;
&lt;td&gt;RAG 분기&lt;/td&gt;
&lt;td&gt;메뉴, 추천, 원두 질문처럼 추가 정보가 필요하다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 대화&lt;/td&gt;
&lt;td&gt;Agent Loop 제외&lt;/td&gt;
&lt;td&gt;현재 문제는 반복 계획이나 도구 자동화가 아니다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 개선 후에도 꼬리 질문은 단일 모델 중심이다. 다만 사용자가 도움을 요청한 순간만 RAG 분기로 빠진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 구조의 한계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 next-question 흐름은 이렇게 단순했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/f55136284a8e7641.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 슬롯 답변을 처리하기에는 충분했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 &lt;code&gt;Small iced Americano, please.&lt;/code&gt;라고 말하면 &lt;code&gt;drink&lt;/code&gt;와 &lt;code&gt;size&lt;/code&gt;를 채우면 된다. 사용자가 &lt;code&gt;I want drink.&lt;/code&gt;라고 말하면 구체 음료가 아니므로 &lt;code&gt;INVALID_RESPONSE&lt;/code&gt;로 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 사용자가 질문을 던지면 이야기가 달라진다.&lt;/p&gt;
&lt;pre class=&quot;livecodeserver&quot;&gt;&lt;code&gt;Can you recommend a menu?
What beans do you use?
Do you have decaf?
Can I get it with oat milk?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 발화들은 슬롯을 채우지 않는다. 그렇다고 실패 발화도 아니다. 사용자가 시나리오를 이어가기 위해 필요한 정보를 요청한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 이 문제를 &lt;code&gt;availableOptions&lt;/code&gt;로 풀려고 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 &lt;code&gt;availableOptions&lt;/code&gt;를 버렸나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;availableOptions&lt;/code&gt;는 단순하고 직관적이었다. 백엔드가 아래처럼 메뉴 정보를 넘겨주면 AI가 그 목록을 보고 답한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Available options=drink: iced Americano, latte, tea&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 &lt;code&gt;I need a menu&lt;/code&gt; 같은 요청에 이렇게 답할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;turnClassification&quot;: &quot;ASSISTANCE_REQUEST&quot;,
  &quot;filledSlots&quot;: [],
  &quot;nextQuestion&quot;: &quot;The drink options are iced Americano, latte, and tea. What would you like to order?&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메뉴 요청만 보면 좋은 해결책처럼 보였다. 실제 Prompt 8 live 테스트에서도 &lt;code&gt;I need a menu&lt;/code&gt;, &lt;code&gt;Can I get a menu?&lt;/code&gt;, &lt;code&gt;Menu please.&lt;/code&gt;는 잘 처리됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이 방식은 질문이 조금만 넓어져도 무거워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 원두를 물으면 원두 정보를 넣어야 한다. 디카페인을 물으면 디카페인 여부를 넣어야 한다. 단맛, 우유 변경, 알레르기, 가격, 사이즈 정책까지 대비하려면 시나리오마다 옵션성 데이터를 계속 늘려야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카페만 해도 이런데, 앞으로 공항, 호텔, 식당으로 확장하면 필드는 더 늘어난다. 카테고리별 프롬프트를 따로 만드는 방법도 생각했지만, 그것도 같은 문제를 다른 곳으로 옮길 뿐이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 문제는 &amp;ldquo;메뉴 목록을 어떻게 넘길 것인가&amp;rdquo;가 아니었다. 사용자가 예상하지 못한 도움 요청을 했을 때, 어떻게 대화 흐름을 깨지 않고 받아줄 것인가였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;availableOptions&lt;/code&gt;를 백엔드 계약에서 제거했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;새 구조는 &lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;만 RAG로 보낸다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 구조는 아래와 같다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/ee416bfdecb10817.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 RAG는 전체 대화를 대신 처리하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ANSWER&lt;/code&gt;는 기존처럼 슬롯을 채운다. &lt;code&gt;INVALID_RESPONSE&lt;/code&gt;는 하트 정책으로 보낸다. RAG는 &lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;일 때만 돈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누면 빠른 대화 흐름을 유지하면서도, 메뉴나 원두 같은 도움 요청에는 더 구체적으로 답할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;검색 대상은 인터넷이 아니라 내부 벡터 DB다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 구조에서 RAG는 인터넷 검색이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SayNow의 목표는 실제 카페 정보를 맞히는 것이 아니라, 영어 회화 연습을 자연스럽게 이어가는 것이다. 따라서 검색 대상은 웹이 아니라 SayNow 내부에 쌓인 질문과 답변이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 흐름은 단순하다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/ae2bc2aba0353332.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷한 답변이 있으면 재사용한다. 없으면 &lt;code&gt;gpt-4o-mini&lt;/code&gt;가 역할극에 맞는 일반 답변을 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;What beans do you use?&lt;/code&gt;에 검색 결과가 없으면 아래처럼 답한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;turnClassification&quot;: &quot;ASSISTANCE_REQUEST&quot;,
  &quot;filledSlots&quot;: [],
  &quot;nextQuestion&quot;: &quot;We usually use medium-roasted Arabica beans. What would you like to order?&quot;,
  &quot;translatedQuestion&quot;: &quot;보통 중간 로스팅 아라비카 원두를 사용해요. 무엇을 주문하시겠어요?&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 답변은 실제 매장의 사실을 보장하려는 답변이 아니다. 역할극 안에서 사용자가 다음 발화를 이어갈 수 있게 돕는 답변이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;저장하는 데이터&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벡터 DB에는 최종 답변만 저장하지 않는다. 질문이 어떤 시나리오에서 나왔는지 같이 저장해야 재사용할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;scenarioTitle&quot;: &quot;카페에서 주문하기&quot;,
  &quot;scenarioGoal&quot;: &quot;원하는 음료를 자연스럽게 주문할 수 있다.&quot;,
  &quot;originalQuestion&quot;: &quot;What would you like to order?&quot;,
  &quot;userUtterance&quot;: &quot;What beans do you use?&quot;,
  &quot;assistantAnswer&quot;: &quot;We usually use medium-roasted Arabica beans. What would you like to order?&quot;,
  &quot;turnClassification&quot;: &quot;ASSISTANCE_REQUEST&quot;,
  &quot;answerSource&quot;: &quot;generated&quot;,
  &quot;qualityStatus&quot;: &quot;generated&quot;,
  &quot;usageCount&quot;: 1
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;qualityStatus&lt;/code&gt;는 세 단계로 나눴다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;방금 AI가 만든 답변이다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;candidate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;반복되었거나 품질이 괜찮아 검색 재사용 후보로 볼 수 있다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;approved&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;운영자가 재사용해도 된다고 판단한 답변이다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MVP에서는 처음부터 모든 답변을 검수하지 않는다. 대신 생성된 도움 요청을 저장하고, 같은 시나리오 안에서 같은 질문이 반복되면 &lt;code&gt;candidate&lt;/code&gt;로 자동 승격한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기준은 단순하게 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 &lt;code&gt;scenario_title&lt;/code&gt; 안에서 정규화된 &lt;code&gt;user_utterance&lt;/code&gt;가 2회 이상 &lt;code&gt;generated&lt;/code&gt;로 저장되면 candidate가 된다. 정규화할 때는 대소문자, 연속 공백, 문장부호 차이를 제거한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래 두 문장은 같은 반복 질문으로 본다.&lt;/p&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;Can I see the menu?
can   i see the menu&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기준이 운영 데이터에서 너무 느슨하거나 빡빡한지는 계속 봐야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;API 경계는 유지했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 변경에서 중요하게 본 것은 백엔드와 AI 서버의 계약을 크게 흔들지 않는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드는 기존처럼 질문, 사용자 발화, 시나리오 정보, 슬롯 상태를 넘긴다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;originalQuestion&quot;: &quot;What would you like to order?&quot;,
  &quot;userUtterance&quot;: &quot;What beans do you use?&quot;,
  &quot;scenarioTitle&quot;: &quot;카페에서 주문하기&quot;,
  &quot;scenarioGoal&quot;: &quot;원하는 음료를 자연스럽게 주문할 수 있다.&quot;,
  &quot;slots&quot;: [
    {
      &quot;slotName&quot;: &quot;drink&quot;,
      &quot;filled&quot;: false
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답도 기존 구조를 유지한다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;nextQuestion&quot;: &quot;We usually use medium-roasted Arabica beans. What would you like to order?&quot;,
  &quot;translatedQuestion&quot;: &quot;보통 중간 로스팅 아라비카 원두를 사용해요. 무엇을 주문하시겠어요?&quot;,
  &quot;filledSlots&quot;: [],
  &quot;turnClassification&quot;: &quot;ASSISTANCE_REQUEST&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드는 &lt;code&gt;turnClassification&lt;/code&gt;만 보면 된다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;th&gt;처리&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANSWER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;새로 충족된 슬롯을 반영한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;하트를 깎지 않고 AI 응답을 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INVALID_RESPONSE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;하트 차감 정책을 적용한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RAG 검색, fallback 생성, 저장은 AI 서버 내부 책임이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현과 검증&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벡터 DB는 Supabase의 pgvector를 사용했다. 백엔드가 이미 Supabase DB를 쓰고 있었기 때문에, 별도 Vector DB를 새로 운영하지 않고 같은 DB 안에 &lt;code&gt;ai_rag.assistance_knowledge&lt;/code&gt; 테이블을 두는 방향으로 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 서버는 백엔드와 같은 &lt;code&gt;DB_URL&lt;/code&gt;, &lt;code&gt;DB_USERNAME&lt;/code&gt;, &lt;code&gt;DB_PASSWORD&lt;/code&gt;로 접속할 수 있게 했다. 다만 백엔드 JDBC URL에 들어 있는 &lt;code&gt;prepareThreshold&lt;/code&gt; 같은 JDBC 전용 파라미터는 &lt;code&gt;psycopg&lt;/code&gt; 연결 전에 제거했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;live 검증에서는 다음을 확인했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도움 요청 6건이 &lt;code&gt;ai_rag.assistance_knowledge&lt;/code&gt;에 저장됐다.&lt;/li&gt;
&lt;li&gt;모든 row에 embedding이 채워졌다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generated&lt;/code&gt; row 하나를 &lt;code&gt;candidate&lt;/code&gt;로 올린 뒤 같은 메뉴 질문을 다시 호출하니 &lt;code&gt;answer_source=retrieved&lt;/code&gt; row가 새로 저장됐다.&lt;/li&gt;
&lt;li&gt;같은 임시 도움 요청을 두 번 저장하자 두 row가 모두 &lt;code&gt;quality_status=candidate&lt;/code&gt;로 자동 승격됐다.&lt;/li&gt;
&lt;li&gt;검증용 row는 확인 뒤 삭제했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt 9 테스트에서는 &lt;code&gt;NQ-01&lt;/code&gt;부터 &lt;code&gt;NQ-10&lt;/code&gt;, &lt;code&gt;RAG-01&lt;/code&gt;부터 &lt;code&gt;RAG-03&lt;/code&gt;, &lt;code&gt;FB-01&lt;/code&gt;부터 &lt;code&gt;FB-10&lt;/code&gt;까지 실제 &lt;code&gt;gpt-4o-mini&lt;/code&gt; live 호출로 봤다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Agent Loop를 쓰지 않은 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Agent Loop도 후보가 될 수 있다. 하지만 지금 문제에는 과했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 메뉴를 보거나 원두를 묻고 싶어 한다. AI는 짧게 답하고 다시 원래 질문으로 돌아오면 된다. 여러 도구를 반복 호출하면서 계획을 세울 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Agent Loop를 넣으면 지연 시간이 늘고, 실패 지점도 늘어난다. 지금 단계에서는 &lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt; 전용 RAG 분기가 더 작고 명확하다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 개선의 방향은 &amp;ldquo;모든 상황을 미리 모델링하지 않기&amp;rdquo;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 메뉴 문제처럼 보였지만, 실제로는 사용자가 도움을 요청하는 모든 상황의 문제였다. 그걸 &lt;code&gt;availableOptions&lt;/code&gt; 필드로 계속 확장하면 시나리오가 늘어날수록 관리가 어려워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 AI 서버 내부에서 일반화했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬롯 답변은 그대로 처리한다. 실패 발화는 하트 정책으로 보낸다. 도움 요청만 RAG로 보내고, 비슷한 답변이 없으면 역할극 답변을 생성한다. 생성된 질문과 답변은 다시 벡터 DB에 쌓는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 남은 일도 있다. &lt;code&gt;candidate&lt;/code&gt; 자동 승격 기준을 운영 데이터로 확인해야 하고, 어떤 답변을 &lt;code&gt;approved&lt;/code&gt;로 올릴지 운영 정책도 필요하다. 추천 요청에 한 가지 메뉴를 줄지, 두세 가지 선택지를 줄지도 제품 UX로 정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 방향은 정리됐다. 백엔드 계약은 크게 흔들지 않고, AI 서버 내부 workflow만 확장한다. 지금 SayNow에는 이 정도의 분리가 가장 현실적인 선택이었다.&lt;/p&gt;</description>
      <category>Project/Saynow</category>
      <category>LLM</category>
      <category>Rag</category>
      <category>saynow</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/274</guid>
      <comments>https://pp8817.tistory.com/274#entry274comment</comments>
      <pubDate>Wed, 5 Aug 2026 00:59:18 +0900</pubDate>
    </item>
    <item>
      <title>[Saynow] 프롬프트 개선기 - Prompt 1에서 9까지</title>
      <link>https://pp8817.tistory.com/273</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/SayNow-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8-%EA%B0%9C%EC%84%A0%EA%B8%B0-Prompt-1%EC%97%90%EC%84%9C-9%EA%B9%8C%EC%A7%80&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SayNow는 영어 회화 상황을 연습하는 서비스다. 사용자가 AI의 질문에 답하면, AI 서버는 그 발화가 시나리오에 필요한 정보를 채웠는지 판단한다. 백엔드는 그 결과를 보고 다음 질문을 이어가거나, 세션을 끝내거나, 하트를 깎는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순한 슬롯 채우기 문제처럼 보였다. 카페 주문 시나리오라면 &lt;code&gt;drink&lt;/code&gt;, &lt;code&gt;size&lt;/code&gt; 같은 슬롯이 있고, 사용자가 &lt;code&gt;Small iced Americano, please.&lt;/code&gt;라고 말하면 두 슬롯을 채우면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 사용자가 항상 슬롯 값만 말하지 않는다는 점이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Can you recommend a menu?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Can I see the menu?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;What beans do you use?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 말은 틀린 답이 아니다. 대화 흐름 안에서 충분히 자연스러운 발화다. 그런데 초기 구조에서는 이런 발화가 &lt;code&gt;filledSlots=[]&lt;/code&gt;로 떨어졌고, 백엔드는 이것을 동문서답처럼 볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 사용자는 메뉴를 물어봤을 뿐인데 하트가 깎이거나, 커스텀 음료 제작에서 &lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;이라고 말했는데도 옵션을 더 고르라는 질문을 다시 받았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 문제를 Prompt 1부터 Prompt 9까지 고치면서 정리한 기록이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;출발점은 &lt;code&gt;filledSlots=[]&lt;/code&gt;였다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 next-question 응답은 대략 이런 형태였다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;filledSlots&quot;: [],
  &quot;nextQuestion&quot;: &quot;What drink would you like to order?&quot;,
  &quot;translatedQuestion&quot;: &quot;어떤 음료를 주문하시겠어요?&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조만 보면 &lt;code&gt;filledSlots=[]&lt;/code&gt;가 무엇을 뜻하는지 알기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말로 사용자가 엉뚱한 말을 한 것일 수도 있다. 아직 슬롯을 채우지는 않았지만 메뉴나 추천을 물어본 것일 수도 있다. 또는 &lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;처럼 더 이상 추가할 옵션이 없다는 뜻일 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로 다른 상황이 같은 모양으로 내려오니 백엔드도 구분할 수 없었다. 그래서 처음 고친 지점은 슬롯 판단과 발화 성격 판단을 분리하는 것이었다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;filledSlots&quot;: [],
  &quot;nextQuestion&quot;: &quot;We have coffee, tea, and smoothies. What drink would you like to order?&quot;,
  &quot;translatedQuestion&quot;: &quot;커피, 차, 스무디가 있어요. 어떤 음료를 주문하시겠어요?&quot;,
  &quot;turnClassification&quot;: &quot;ASSISTANCE_REQUEST&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;filledSlots&lt;/code&gt;는 여전히 &amp;ldquo;이번 발화로 새로 채운 슬롯&amp;rdquo;만 나타낸다. 대신 &lt;code&gt;turnClassification&lt;/code&gt;이 생기면서 이 발화가 어떤 종류인지 따로 볼 수 있게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로는 세 가지 상태만 남겼다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;상태&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;백엔드 처리&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANSWER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;사용자가 질문에 답했고 슬롯 판단이 가능하다&lt;/td&gt;
&lt;td&gt;새로 채워진 슬롯을 반영한다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;메뉴, 추천, 원두, 정책처럼 추가 정보를 물었다&lt;/td&gt;
&lt;td&gt;하트를 깎지 않고 AI 응답을 보여준다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INVALID_RESPONSE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;오프토픽, 무의미한 발화, 거절, 불완전한 답변이다&lt;/td&gt;
&lt;td&gt;하트 차감 후보로 본다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름을 그림으로 보면 아래와 같다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/79710d2804b7d8e8.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 더 세분화된 상태도 생각했다. 추천 요청과 정보 요청을 나누거나, 옵션 완료 발화를 별도 상태로 빼는 식이었다. 하지만 실제 정책 경계는 그렇게 복잡하지 않았다. 백엔드가 알아야 하는 것은 &amp;ldquo;슬롯 답변인가&amp;rdquo;, &amp;ldquo;도움을 요청했는가&amp;rdquo;, &amp;ldquo;실패 발화인가&amp;rdquo;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태를 많이 만들수록 모델에게 맞혀야 할 라벨만 늘어난다. 그래서 의사결정에 필요한 최소 상태만 남겼다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프롬프트를 길게 막기보다 방향을 잡았다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간에 배운 프롬프트 원칙도 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 &amp;ldquo;이런 표현은 슬롯으로 채우지 말라&amp;rdquo;, &amp;ldquo;저런 표현은 실패로 봐라&amp;rdquo; 같은 금지 규칙이 계속 늘어났다. 그 방식은 당장은 버그 하나를 막을 수 있지만, 프롬프트가 점점 딱딱해진다. 모델은 대화의 의도를 보지 못하고 금지 목록을 맞히는 쪽으로 움직이기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Prompt 8부터는 프롬프트를 섹션으로 나눴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;역할&lt;/li&gt;
&lt;li&gt;출력 스키마&lt;/li&gt;
&lt;li&gt;판단 순서&lt;/li&gt;
&lt;li&gt;슬롯 정책&lt;/li&gt;
&lt;li&gt;도움 요청 정책&lt;/li&gt;
&lt;li&gt;응답 정책&lt;/li&gt;
&lt;li&gt;few-shot 예시&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 것은 모델에게 답을 억지로 강제하는 것이 아니라, 먼저 무엇을 판단해야 하는지 알려주는 것이었다. 예를 들어 &amp;ldquo;사용자 발화를 슬롯 답변, 도움 요청, 실패 발화 중 하나로 먼저 분류하라&amp;rdquo;는 식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력은 JSON 스키마를 유지했다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;filledSlots&quot;: [
    {
      &quot;slotName&quot;: &quot;drink&quot;
    }
  ],
  &quot;nextQuestion&quot;: &quot;What size would you like?&quot;,
  &quot;translatedQuestion&quot;: &quot;어떤 사이즈로 드릴까요?&quot;,
  &quot;turnClassification&quot;: &quot;ANSWER&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 prompt-only 실험을 해도 백엔드 계약과 맞춰서 볼 수 있었다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트는 next-question과 feedback을 같이 봤다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 next-question만 보면 된다고 생각하기 쉽다. 하지만 실제 사용자 경험은 마지막 피드백까지 이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 사용자가 &lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;이라고 말했을 때 next-question에서는 세션이 잘 끝나도, feedback에서 &amp;ldquo;더 자연스럽게 말해보세요&amp;rdquo; 같은 교정이 붙으면 사용자 입장에서는 또 다른 버그처럼 느껴진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 모든 프롬프트는 같은 입력으로 반복 측정했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;next-question 10개&lt;/li&gt;
&lt;li&gt;feedback 10개&lt;/li&gt;
&lt;li&gt;필요할 때 추가 smoke test&lt;/li&gt;
&lt;li&gt;실제 OpenAI &lt;code&gt;gpt-4o-mini&lt;/code&gt; live 호출 결과만 결과 표에 반영&lt;/li&gt;
&lt;li&gt;mocked unittest는 프롬프트 구조와 회귀 검증 용도로만 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 next-question 입력에는 이런 케이스가 들어갔다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ID&lt;/th&gt;
&lt;th&gt;입력&lt;/th&gt;
&lt;th&gt;관찰 포인트&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NQ-01&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Can you recommend a menu?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;추천 요청에서 하트가 깎이지 않는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NQ-03&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Can I see the menu?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;메뉴 확인 요청을 실패로 보지 않는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NQ-05&lt;/td&gt;
&lt;td&gt;&lt;code&gt;I want drink.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;generic object를 실제 음료로 채우지 않는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NQ-07&lt;/td&gt;
&lt;td&gt;&lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;추가 옵션 없음으로 처리하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NQ-10&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Oat milk and no sugar, please.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;복합 옵션도 하나의 슬롯 답변으로 처리하는가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;feedback도 별도로 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 응답에는 불필요한 피드백이 없어야 한다. 어색한 표현은 한 단계만 자연스럽게 고쳐야 한다. 그리고 오프토픽이나 nonsense는 뜻을 억지로 주문 상황에 끼워 맞추면 안 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기준을 같이 보지 않으면 next-question은 고쳤는데 feedback에서 다시 어색해지는 일이 생긴다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Prompt 8은 절반만 맞았다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt 8에서는 &lt;code&gt;availableOptions&lt;/code&gt;를 도입했다. 백엔드가 메뉴 옵션을 넘겨주면, AI는 그 옵션을 근거로 메뉴를 보여주는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;I need a menu&lt;/code&gt;가 들어오고 &lt;code&gt;drink: iced Americano, latte, tea&lt;/code&gt;가 같이 들어오면 이런 응답이 나왔다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;filledSlots&quot;: [],
  &quot;turnClassification&quot;: &quot;ASSISTANCE_REQUEST&quot;,
  &quot;nextQuestion&quot;: &quot;The drink options are iced Americano, latte, and tea. What would you like to order?&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 결과만 보면 괜찮았다. 사용자는 AI 응답 텍스트만 보고 실제 메뉴 후보를 알 수 있었다. 메뉴 요청이 &lt;code&gt;INVALID_RESPONSE&lt;/code&gt;로 빠지지도 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 곧 한계가 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 묻는 것은 메뉴만이 아니었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;What beans do you use?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Do you have decaf?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Is this drink sweet?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Can I get it with oat milk?&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 질문까지 대비하려면 백엔드 DB에 메뉴, 원두, 디카페인, 재료, 맛, 정책 정보를 계속 넣어야 한다. 카페 하나만 보면 가능해 보이지만, 앞으로 공항, 호텔, 식당으로 시나리오가 늘어나면 관리 방식이 무거워진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;availableOptions&lt;/code&gt;를 키우는 방향은 버렸다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Prompt 9는 도움 요청을 RAG 분기로 보냈다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt 9의 방향은 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드 API 계약에는 새 필드를 늘리지 않는다. 대신 AI 서버 내부에서 &lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;로 분류된 발화만 별도 RAG workflow로 보낸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 결과가 있으면 그 답변을 근거로 사용한다. 검색 결과가 없으면 &lt;code&gt;gpt-4o-mini&lt;/code&gt;가 역할극에 맞는 짧은 답변을 생성한다. 그 뒤에는 반드시 원래 시나리오 질문으로 돌아온다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트에는 이 원칙을 넣었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;The user can only use information that appears in your nextQuestion, so when the user asks for a menu, recommendation, options, rules, ingredients, policy, or details, answer the request briefly before asking the next short scenario question.
If retrieved assistance context is provided, use it as the factual basis for the assistance answer.
If no retrieved assistance context is provided, generate a plausible role-play answer that fits the scenario, then return to the current scenario question.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &amp;ldquo;메뉴 옵션은 이렇습니다&amp;rdquo;처럼 빈 문장을 만들지 않는 것이다. 사용자는 AI 응답 텍스트만 본다. 따라서 도움 요청에 답할 때는 실제로 쓸 수 있는 정보를 응답 안에 넣어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt 9 live 테스트 결과는 이렇게 나왔다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;케이스&lt;/th&gt;
&lt;th&gt;입력&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;메뉴 추천&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Can you recommend a menu?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;, &lt;code&gt;We have coffee, tea, and smoothies...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;추천 요청&lt;/td&gt;
&lt;td&gt;&lt;code&gt;What do you recommend?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;, &lt;code&gt;I recommend a latte or an iced coffee...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메뉴 확인&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Can I see the menu?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;, &lt;code&gt;We have coffee, tea, and smoothies...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메뉴 요청 추가 표현&lt;/td&gt;
&lt;td&gt;&lt;code&gt;I need a menu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;, &lt;code&gt;We have Americano, latte, and tea...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;원두 질문&lt;/td&gt;
&lt;td&gt;&lt;code&gt;What beans do you use?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;, &lt;code&gt;We usually use medium-roasted Arabica beans...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;디카페인 질문&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Do you have decaf?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;, &lt;code&gt;We do have decaf coffee...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt; 문제도 해결됐다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;입력&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;filledSlots=[customOptions]&lt;/code&gt;, &lt;code&gt;turnClassification=ANSWER&lt;/code&gt;, &lt;code&gt;nextQuestion=null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;That's all.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;filledSlots=[customOptions]&lt;/code&gt;, &lt;code&gt;turnClassification=ANSWER&lt;/code&gt;, &lt;code&gt;nextQuestion=null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;STT나 키보드 입력 때문에 apostrophe 모양이 달라져도 같은 정책으로 처리된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;feedback도 같이 보정했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt 9에서는 feedback prompt도 조금 고쳤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전에는 모든 턴이 좋은 응답이어도 summary가 교정처럼 읽히는 경우가 있었다. 사용자는 이미 잘 답했는데 &amp;ldquo;더 자연스럽게 고쳐보세요&amp;rdquo;라는 뉘앙스를 받게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 모든 턴이 &lt;code&gt;feedbackRequired=false&lt;/code&gt;인 세션에서는 summary도 교정처럼 쓰지 않도록 했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;When every turn has feedbackRequired=false, feedbackSummary must not imply that the user needs correction; tell the user to maintain the clear expression instead.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;live 테스트에서 좋은 주문 응답은 이렇게 정리됐다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;케이스&lt;/th&gt;
&lt;th&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;I would like a small iced Americano, please.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;score=90&lt;/code&gt;, &lt;code&gt;feedbackRequired=false&lt;/code&gt;, &lt;code&gt;자연스러운 표현을 계속 유지하세요.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;성공 세션 전체&lt;/td&gt;
&lt;td&gt;&lt;code&gt;score=92&lt;/code&gt;, 모든 턴 &lt;code&gt;feedbackRequired=false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 아직 남은 정책도 있다. &lt;code&gt;Can you recommend a menu?&lt;/code&gt;는 대화 진행상 자연스러운 도움 요청이다. 하지만 feedback에서는 더 간결한 요청 표현인 &lt;code&gt;What do you recommend?&lt;/code&gt;를 제안한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 학습 피드백으로 볼지, 대화 진행을 위한 도움 요청으로 보고 피드백을 줄일지는 제품 정책으로 남겨뒀다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 구조에서 얻은 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prompt 9 기준으로 얻은 변화는 분명하다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;문제&lt;/th&gt;
&lt;th&gt;이전&lt;/th&gt;
&lt;th&gt;이후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;메뉴 추천 요청&lt;/td&gt;
&lt;td&gt;&lt;code&gt;filledSlots=[]&lt;/code&gt;만 보고 하트 차감 가능&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ASSISTANCE_REQUEST&lt;/code&gt;로 분류하고 정보 제공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메뉴 확인 요청&lt;/td&gt;
&lt;td&gt;옵션이 없으면 실질적 답변 부족&lt;/td&gt;
&lt;td&gt;역할극 답변 또는 RAG 답변 제공&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;추가 옵션 없음으로 처리하지 못함&lt;/td&gt;
&lt;td&gt;&lt;code&gt;customOptions&lt;/code&gt; 완료 답변으로 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;피드백 summary&lt;/td&gt;
&lt;td&gt;좋은 응답에도 교정처럼 보일 수 있음&lt;/td&gt;
&lt;td&gt;좋은 응답은 유지 방향으로 안내&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;예상 밖 질문&lt;/td&gt;
&lt;td&gt;미리 넣은 옵션이 없으면 대응 어려움&lt;/td&gt;
&lt;td&gt;RAG 검색 후 없으면 역할극 답변 생성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pgvector 저장도 확인했다. Supabase develop DB의 &lt;code&gt;ai_rag.assistance_knowledge&lt;/code&gt;에 도움 요청 6건이 저장됐고, embedding도 채워졌다. &lt;code&gt;Can I see the menu?&lt;/code&gt; row 하나를 &lt;code&gt;candidate&lt;/code&gt;로 올린 뒤 다시 호출했을 때는 새 row가 &lt;code&gt;answer_source=retrieved&lt;/code&gt;, &lt;code&gt;quality_status=candidate&lt;/code&gt;로 저장됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 임시 도움 요청을 두 번 저장했을 때 두 row가 모두 &lt;code&gt;quality_status=candidate&lt;/code&gt;로 자동 승격되는 것도 확인했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서 가장 크게 배운 점은 &lt;code&gt;filledSlots=[]&lt;/code&gt; 하나로 너무 많은 의미를 표현하면 안 된다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;슬롯을 채우지 않았다는 사실과 사용자가 틀렸다는 판단은 다르다. 메뉴를 물어보는 사용자는 실패한 사용자가 아니다. 커스텀 음료에서 &lt;code&gt;That&amp;rsquo;s all.&lt;/code&gt;이라고 말하는 것도 옵션을 모르는 게 아니라, 더 추가할 게 없다는 뜻이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 프롬프트는 더 많은 금지 규칙을 넣는 방향이 아니라, 모델이 먼저 봐야 할 판단 축을 분리하는 방향으로 바뀌었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 볼 것은 운영 데이터다. 반복 질문을 자동으로 &lt;code&gt;candidate&lt;/code&gt;로 올리는 기준이 너무 느슨하지 않은지 봐야 한다. 그리고 도움 요청을 feedback에서 얼마나 교정할지도 정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트 실험은 한 번에 끝나지 않는다. 그래도 이제는 무엇을 봐야 하는지 조금 선명해졌다. next-question만 보지 않고 feedback까지 같이 보고, mocked test가 아니라 실제 &lt;code&gt;gpt-4o-mini&lt;/code&gt; live 결과로 확인하는 흐름을 계속 유지하려고 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;</description>
      <category>Project/Saynow</category>
      <category>ASM</category>
      <category>Prompt Engineering</category>
      <category>Rag</category>
      <category>saynow</category>
      <category>소프트웨어 마에스트로</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/273</guid>
      <comments>https://pp8817.tistory.com/273#entry273comment</comments>
      <pubDate>Wed, 5 Aug 2026 00:58:59 +0900</pubDate>
    </item>
    <item>
      <title>[특강] AI 프로젝트를 위한 AI 모델 선택 노하우</title>
      <link>https://pp8817.tistory.com/272</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/AI-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EB%A5%BC-%EC%9C%84%ED%95%9C-AI-%EB%AA%A8%EB%8D%B8-%EC%84%A0%ED%83%9D-%EB%85%B8%ED%95%98%EC%9A%B0&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;영어 회화 서비스에 Realtime API를 써도 될까?&lt;/h1&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2026.05.23.&lt;br /&gt;온라인 강의, 박성준 멘토님.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영어 회화 서비스에서 중요한 건 결국 두 가지다.&lt;br /&gt;사용자가 실제로 대화하고 있다는 느낌을 받아야 하고, 피드백도 빠르게 돌아와야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 이 지점에서 병목이 컸다. 음성을 텍스트로 바꾸고, LLM이 답변을 만들고, 다시 음성으로 변환하는 과정을 순서대로 거치면 아무래도 느릴 수밖에 없다. 사용자는 &amp;ldquo;대화하고 있다&amp;rdquo;기보다 &amp;ldquo;응답을 기다리고 있다&amp;rdquo;는 느낌을 받게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이제는 상황이 조금 달라졌다. OpenAI Realtime API 같은 선택지가 생기면서, 초반 PoC 단계에서는 직접 모델을 띄우지 않고도 꽤 자연스러운 음성 대화 경험을 만들 수 있게 됐다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;직접 만들 것인가, API를 쓸 것인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 서비스를 만들 때 보통 세 가지 선택지가 있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/b5a2817aa3c982f5.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫 번째는 모델을 직접 개발하는 방식이다. 성능과 구조를 가장 깊게 통제할 수 있지만, GPU 인프라와 학습 코드, 데이터가 모두 필요하다. 기간도 수주에서 수개월 단위로 잡아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 파인튜닝이다. 직접 모델을 만드는 것보다는 가볍지만, 그래도 학습 데이터와 튜닝 도구가 필요하다. 비용 최적화나 특정 도메인 말투, 출력 포맷을 맞추는 데는 의미가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 API 활용이다. 비용은 사용량에 따라 나가지만, SDK만 붙이면 바로 시작할 수 있다. SW Maestro처럼 기간이 제한된 프로젝트에서는 대부분 이 선택지가 현실적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의에서도 결론은 명확했다. 4개월 안에 PoC와 MVP를 만들어야 한다면, 처음부터 모델 개발에 들어가기보다 API로 빠르게 검증하는 편이 낫다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/67d099919cc3368d.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델 학습이 적합한 경우도 분명 있다. 프로젝트 목표 자체가 새로운 아키텍처나 학습 기법을 연구하는 것이라면 모델을 직접 다루는 게 맞다. 기존 API가 도저히 해결하지 못하는 태스크이거나, 모델 자체가 결과물인 경우도 마찬가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 그 외의 대부분의 서비스형 프로젝트는 API부터 시작하는 것이 안전하다. 프론티어 모델 성능이 이미 충분히 높고, 프롬프트 수정만으로 개선할 수 있는 문제를 굳이 재학습으로 풀 필요는 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;음성 서비스의 병목&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;음성 기반 서비스에는 보통 세 가지 모델이 필요하다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;STT.&lt;/li&gt;
&lt;li&gt;LLM.&lt;/li&gt;
&lt;li&gt;TTS.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 이 세 단계를 모두 파이프라인으로 처리하면 속도가 느려진다는 점이다. STT가 끝나야 LLM이 시작되고, LLM이 끝나야 TTS가 시작된다. 이렇게 직렬로 이어지면 사용자는 매 턴마다 대기 시간을 느낀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영어 회화 서비스에서는 이 지연이 더 크게 체감된다. 회화는 텍스트 QA보다 리듬이 중요하기 때문이다. 답변이 조금 늦어지는 것만으로도 실제 대화감이 깨진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 우리 서비스에서는 초반부터 Realtime API 같은 실시간 음성 인터페이스를 적극적으로 고려할 만하다. 직접 STT, LLM, TTS를 모두 붙이는 구조보다 빠르게 대화 경험을 검증할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;비용도 설계의 일부다&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/81830d518228eeda.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의에서 인상적이었던 부분은 비용을 꽤 현실적으로 봐야 한다는 점이었다. GPU를 직접 빌려 모델을 띄우는 비용과 API 비용은 성격이 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPU는 계속 켜두는 순간 고정비가 된다. A100, H100 같은 GPU를 쓰면 시간당 비용이 계속 쌓인다. 반면 API는 사용량 기반이라 초반 트래픽이 적을 때는 훨씬 관리하기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 API도 싸기만 한 것은 아니다. 사용량이 커지면 금방 비용이 올라간다. 그래서 처음에는 API로 시작하되, 나중에 비용 최적화가 필요해지는 시점에 파인튜닝이나 모델 라우팅을 고민하는 흐름이 자연스럽다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/4d96e8c090db5bb4.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비용을 줄이는 방법도 몇 가지가 있다. 쉬운 작업은 더 저렴한 모델로 보내고, 어려운 추론만 고성능 모델에 맡기는 모델 라우팅을 쓸 수 있다. 같은 시스템 프롬프트를 반복해서 쓴다면 프롬프트 캐싱도 고려할 수 있다. 실시간 처리가 필요 없는 작업은 Batch API를 쓰는 것도 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 비용 관리는 모델 선택 하나로 끝나지 않는다. 어떤 작업을 실시간으로 처리할지, 어떤 작업은 나중에 처리해도 되는지까지 같이 설계해야 한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;좋은 AI 프로젝트 주제의 조건&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/e47a98f1aa729323.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 프로젝트 주제가 결과의 80%를 결정한다는 말도 기억에 남았다. AI 프로젝트는 단순히 &amp;ldquo;AI를 붙였다&amp;rdquo;만으로 좋은 주제가 되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 주제에는 몇 가지 조건이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, AI가 없으면 불가능하거나 비현실적인 가치가 있어야 한다. AI 없이도 쉽게 만들 수 있는 기능이라면 AI 프로젝트로서의 설득력이 약하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 4개월 안에 프로토타입이 나올 수 있어야 한다. 범위가 너무 크면 반드시 쪼개야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 성공과 실패를 측정할 수 있어야 한다. &amp;ldquo;잘 되는 것 같다&amp;rdquo;는 평가 방법이 아니다. 무엇이 잘 된 것이고, 무엇이 실패인지 구체적으로 정의해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;넷째, 데이터에 접근할 수 있어야 한다. 데이터가 없으면 테스트도 어렵고, 태스크를 조정하기도 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다섯째, 실제 사용자가 있어야 한다. 사용자 피드백이 없으면 방향을 잡기 어렵다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/0c697699351654f7.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나쁜 주제와 좋은 주제의 차이도 결국 구체성에서 갈린다. &amp;ldquo;AI 챗봇 만들기&amp;rdquo;는 너무 넓고 ChatGPT와 차별화하기 어렵다. 반면 &amp;ldquo;대학생 수강신청 전략 추천 Agent&amp;rdquo;처럼 대상과 문제를 좁히면 훨씬 좋은 프로젝트가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스도 마찬가지다. &amp;ldquo;영어 회화 AI&amp;rdquo;라고 하면 너무 넓다. 특정 상황, 특정 사용자, 특정 피드백 기준을 잡아야 한다. 예를 들어 면접 영어, 여행 영어, 발표 연습처럼 태스크를 좁히면 평가 기준도 더 분명해진다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;MVP 아키텍처는 어떻게 잡을까&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/5dc02bb980b8f653.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강의에서는 MVP 아키텍처 패턴을 네 가지로 나눴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 단순한 구조는 단일 모델이다. 입력을 넣고 LLM 응답을 받아 출력한다. 텍스트 생성, 요약, 분류처럼 단순한 작업에는 이 방식이 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 멀티스탭 구조다. 하나의 LLM이 모든 걸 처리하게 하지 않고, 중간에 검증이나 후처리를 넣는다. 실패 지점을 파악하기 쉽고, 각 단계별 테스트도 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 번째는 RAG다. 외부 문서나 지식을 벡터 DB에 넣고, 검색 결과를 바탕으로 LLM이 답변하게 만드는 방식이다. Q&amp;amp;A나 검색 보강이 필요한 서비스에 잘 맞는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네 번째는 Agent Loop다. 질문을 받고, 계획을 세우고, 도구를 호출하고, 관찰한 뒤 다시 반복하는 구조다. 외부 도구를 써야 하거나 복잡한 자동화가 필요한 경우에 맞지만 난이도도 가장 높다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 서비스는 단일 모델에서 시작하되, 점점 멀티스탭 구조로 가는 흐름이 맞아 보인다. 실시간 대화는 빠르게 처리하고, 자세한 피드백은 별도 단계에서 검증하거나 저장하는 식으로 나눌 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프롬프트 엔지니어링에서 조심할 점&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/a0c26e7439a18b8c.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트 엔지니어링에서 중요한 원칙도 있었다. 모델에게 답을 억지로 강제하기보다, 모델이 생각해야 할 방향을 잡아주는 것이 낫다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &amp;ldquo;이런 스타일로 말하지 마&amp;rdquo;라고 금지 목록을 길게 주는 방식은 오히려 성능을 떨어뜨릴 수 있다. 대신 역할, 맥락, 목표, 출력 형식을 분명히 주는 편이 더 안정적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템 프롬프트와 Few-shot 예시는 가장 기본적인 방법이다. Zero-shot으로 잘 안 되는 경우에는 입출력 예시 2~3개만 넣어도 결과가 꽤 좋아질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출력 형식이 중요하다면 Structured Output이나 Tool Use를 쓰는 편이 낫다. 단순히 &amp;ldquo;JSON으로 출력해&amp;rdquo;라고 말하는 것보다 스키마를 강제하는 방식이 훨씬 안정적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 흥미로웠던 건 LLM-as-Judge다. AI가 만든 결과를 다시 AI가 평가하게 하는 방식인데, 평가 기준을 잘 정의하면 사람 평가의 상당 부분을 대체할 수 있다. 우리 서비스에서도 영어 피드백의 품질을 자동으로 점검하는 데 활용할 수 있을 것 같다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;우리 서비스에 적용해 보면&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 강의를 들으면서 우리 영어 회화 서비스의 방향도 조금 더 선명해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반에는 API 중심으로 가는 게 맞다. 직접 모델을 서빙하려면 인프라와 운영 부담이 크고, 팀 안에 AI 모델링 경험이 충분하지 않다면 시행착오가 길어질 가능성이 높다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Realtime API는 대화감 검증에 쓰고, STT나 TTS도 처음부터 직접 서빙하기보다 API를 붙여 빠르게 실험하는 쪽이 현실적이다. 정확도가 걱정된다면 먼저 평가 기준과 테스트 케이스를 만들어야 한다. 파인튜닝은 그다음이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 모든 피드백을 실시간으로 처리할 필요는 없다. 대화 중에는 짧고 빠른 반응을 주고, 세부 피드백은 세션이 끝난 뒤 정리해서 제공하는 식으로 나누면 속도와 정확도를 함께 챙길 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/3258933287fbaa8a.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 강의의 결론은 네 가지로 정리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 모델을 직접 만들지 API를 쓸지 먼저 결정해야 한다. 대부분의 4개월 프로젝트는 API부터 시작하는 것이 현실적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 주제를 좁혀야 한다. AI-native하고, 4개월 안에 만들 수 있고, 측정 가능한 문제여야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, 스택은 단순하게 시작해야 한다. SDK 직접 호출부터 시작하고, 필요할 때 멀티스탭, RAG, Agent Loop로 확장하는 편이 낫다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;넷째, 구현은 Playground, PoC, MVP, Production 순서로 가야 한다. Stage 0에서 가능성을 먼저 확인하고, 그다음에 제품화를 고민해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 프로젝트는 기술만의 문제가 아니라 의사결정의 문제라는 말이 가장 크게 남았다. 무엇을 직접 만들고, 무엇을 빌려 쓰고, 어디까지를 MVP로 볼 것인지 정하는 과정이 프로젝트의 성패를 좌우한다.&lt;/p&gt;</description>
      <category>Etc</category>
      <category>AI</category>
      <category>ASM</category>
      <category>소프트웨어 마에스트로</category>
      <category>특강</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/272</guid>
      <comments>https://pp8817.tistory.com/272#entry272comment</comments>
      <pubDate>Wed, 5 Aug 2026 00:58:40 +0900</pubDate>
    </item>
    <item>
      <title>[Saynow] 스피킹 서비스에서 STT와 TTS는 어디에 둬야 할까?</title>
      <link>https://pp8817.tistory.com/271</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/Saynow-%EC%8A%A4%ED%94%BC%ED%82%B9-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90%EC%84%9C-STT%EC%99%80-TTS%EB%8A%94-%EC%96%B4%EB%94%94%EC%97%90-%EB%91%AC%EC%95%BC-%ED%95%A0%EA%B9%8C&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;들어가며&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소프트웨어 마에스트로 17기 연수생으로 참여하며, 팀원들과 서비스를 기획했다.&lt;br /&gt;우리 팀이 찾은 핵심 가치는 &lt;b&gt;&lt;code&gt;영어를 완벽하게 말해야 한다는 부담감, 강박증을 없애주기&lt;/code&gt;&lt;/b&gt;이다.&lt;br /&gt;캐치프라이즈로는 &lt;b&gt;&quot;내 영어를 진짜 외국인이 알아들을 확률은?&quot;&lt;/b&gt;를 잡았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 핵심 가치를 기능으로 구현하기 위해서 스피킹 서비스를 만들기로 결정했다.&lt;br /&gt;자연스럽게 따라오는 질문이 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;사용자의 음성은 어디에서 텍스트로 바꿀까?&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AI가 만든 다음 질문은 어디에서 음성으로 바꿀까?&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순해 보였다.&lt;br /&gt;&lt;code&gt;사용자가 말하면 음성 파일을 서버로 보내고 -&amp;gt; AI 서버가 STT로 텍스트를 만들고 -&amp;gt; 답변을 분석한 뒤 다음 질문을 만들고 -&amp;gt; 그 질문을 TTS로 음성 파일로 변환해 S3 같은 저장소에 올린다.&lt;/code&gt;&lt;br /&gt;프론트는 그 URL을 받아 재생하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름만 보면 자연스럽다. 하지만 실제 대화 경험을 생각하면 문제가 생긴다.&lt;br /&gt;&lt;b&gt;한 턴이 끝날 때마다 STT, 분석, 다음 질문 생성, TTS 생성, 파일 업로드, URL 반환까지 모두 기다려야 하기 때문이다.&lt;/b&gt;&lt;br /&gt;사용자는 말을 끝냈는데 다음 질문이 늦게 나오면 &lt;b&gt;대화가 끊긴 것처럼 느낀다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Saynow의 음성 대화 흐름을 설계하면서 STT와 TTS를 어디에 둘지 고민한 내용을 정리한 글이다. Speak 같은 스피킹 서비스의 구조도 참고했지만, 결론은 지금 단계에서 &lt;b&gt;가장 단순하고 빠르게 검증할 수 있는 MVP 구조&lt;/b&gt;를 선택하는 쪽으로 기울었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 번째 생각: 서버에서 TTS까지 모두 처리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 떠올린 구조는 서버 중심 구조였다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;사용자 음성
-&amp;gt; 백엔드
-&amp;gt; AI 서버 STT
-&amp;gt; 답변 분석
-&amp;gt; 다음 질문 생성
-&amp;gt; TTS 생성
-&amp;gt; S3 업로드
-&amp;gt; 프론트에서 ttsUrl 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오를 시작할 때도 같은 방식이다. 첫 질문의 TTS 파일을 미리 만들어 S3에 저장해 두고, 백엔드는 첫 질문 텍스트와 &lt;code&gt;ttsUrl&lt;/code&gt;을 프론트에 내려준다. 프론트는 그 URL로 음성 파일을 가져와 재생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;턴이 진행될 때는 사용자가 말한 음성 파일을 백엔드로 업로드한다. 백엔드는 세션 정보, 시나리오 정보, 현재 턴 정보를 붙여 AI 서버로 보낸다. AI 서버는 음성을 텍스트로 바꾸고, 사용자의 답변을 분석하고, 다음 질문을 만든다. 이후 그 질문에 대한 TTS 파일을 생성해 S3에 올리고, 최종적으로 &lt;code&gt;nextQuestion&lt;/code&gt;과 &lt;code&gt;ttsUrl&lt;/code&gt;을 백엔드에 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그림으로 보면 이렇다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/0f25e5dc9b67d5b0.png&quot; width=&quot;70%&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점도 있다. &lt;b&gt;음성 품질을 서버에서 통제할 수 있고, 모든 클라이언트에서 같은 목소리를 들려줄 수 있다.&lt;/b&gt; 나중에 캐릭터 음성이나 특정 발음 품질이 중요해질 때도 유리하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 MVP에는 부담이 크다. 특히 &lt;b&gt;한 턴의 응답이 아래 과정에 모두 묶인다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;STT -&amp;gt; 답변 분석 -&amp;gt; 다음 질문 생성 -&amp;gt; TTS 생성 -&amp;gt; S3 업로드 -&amp;gt; ttsUrl 반환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;질문 텍스트는 이미 만들어졌는데 &lt;b&gt;TTS 생성이나 업로드 때문에 프론트 응답이 늦어질 수 있다.&lt;/b&gt; 스피킹 서비스에서 이 지연은 꽤 치명적이다. 사용자는 텍스트가 조금 늦는 것보다 대화 리듬이 끊기는 것을 더 크게 느낄 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두 번째 생각: TTS를 비동기로 분리하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버 TTS를 유지하면서 지연을 줄이는 방법도 있다. &lt;b&gt;TTS 생성을 턴 응답의 필수 조건에서 빼는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. STT와 분석을 먼저 끝낸다.
2. 다음 질문 텍스트를 먼저 프론트에 반환한다.
3. TTS는 뒤에서 생성한다.
4. 준비되면 ttsUrl만 따로 갱신한다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답은 이런 형태가 된다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;nextQuestion&quot;: &quot;What size would you like?&quot;,
  &quot;understoodScore&quot;: 85,
  &quot;ttsStatus&quot;: &quot;PENDING&quot;,
  &quot;ttsUrl&quot;: null
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트는 질문 텍스트를 먼저 보여주고, TTS가 준비되면 polling, callback, websocket 같은 방식으로 &lt;code&gt;ttsUrl&lt;/code&gt;을 받아 음성을 재생한다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/b40e38e4dfc22b7b.png&quot; width=&quot;70%&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 서버 TTS의 장점을 유지하면서도 &lt;b&gt;텍스트 응답은 빨리 줄 수 있다.&lt;/b&gt; 다만 &lt;b&gt;구현 복잡도가 올라간다.&lt;/b&gt; &lt;code&gt;ttsStatus&lt;/code&gt;를 관리해야 하고, TTS URL을 나중에 갱신하는 API나 이벤트 흐름도 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MVP에서 이 정도 복잡도를 감수할 만한지 고민이 됐다. 아직 검증해야 할 핵심은 &lt;b&gt;&amp;ldquo;사용자가 말하면 AI가 잘 이해하고 적절한 다음 질문을 주는가&amp;rdquo;&lt;/b&gt;이지, &lt;b&gt;&amp;ldquo;서버가 자연스러운 음성 파일을 만들어 주는가&amp;rdquo;&lt;/b&gt;가 아니기 때문이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;스픽(Speak)는 어떻게 접근하고 있을까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스피킹 서비스를 기획하며 유사 서비스인 '스픽(Speak)'을 많이 분석했다.&lt;br /&gt;Speak의 공개 자료를 보면, 대략적인 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;모바일 앱
-&amp;gt; WebRTC / LiveKit
-&amp;gt; Voice Agent Server
-&amp;gt; ASR / LLM / TTS provider 또는 Speech-to-Speech provider
-&amp;gt; 모바일 앱으로 음성 스트리밍&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 더 풀면 이렇다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/5c75faeb85f4fdc2.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 눈여겨볼 점은 &lt;b&gt;Speak이 모든 기능을 하나의 방식으로 처리하지 않는다는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;의미 중심의 롤플레이: ASR, LLM, TTS를 조합하는 Cascade 구조&lt;/li&gt;
&lt;li&gt;발음이나 억양처럼 음성 자체의 정보가 중요한 기능:Speech-to-Speech 방식&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;을 선택할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;OpenAI Realtime API&lt;/b&gt; 같은 기술도 이런 흐름과 맞닿아 있다.&lt;br /&gt;기존에는 외부에서 STT, LLM, TTS를 각각 조립해야 했다면, Realtime API는 &lt;b&gt;하나의 실시간 세션에서 음성 입력과 음성 출력을 처리할 수 있게 해 준다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;기존 Cascade:
음성 -&amp;gt; STT -&amp;gt; LLM -&amp;gt; TTS -&amp;gt; 음성

Realtime:
음성 입력 -&amp;gt; 실시간 모델 세션 -&amp;gt; 음성 출력&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 구조를 MVP에 바로 적용하기에는 무겁다.&lt;br /&gt;WebRTC, LiveKit, Voice Agent Server, 실시간 세션 관리까지 들어가면 &lt;b&gt;제품 검증보다 인프라 구현에 더 많은 시간을 쓰게 될 수 있기 때문이다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 MVP에서는 클라이언트 TTS를 선택한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 SayNow의 현재 MVP에서는 &lt;b&gt;TTS를 서버에서 처리하지 않기로 했다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MVP의 핵심 흐름은 이렇게 잡을 수 있다.&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;사용자 음성 파일 업로드
-&amp;gt; AI 서버 STT
-&amp;gt; 답변 분석
-&amp;gt; 다음 질문 텍스트 생성
-&amp;gt; 백엔드 저장/응답
-&amp;gt; 프론트 클라이언트 TTS 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/b2a690fc34432b98.png&quot; width=&quot;70%&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할도 단순해진다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/29d7878367d18d04.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 &lt;b&gt;&lt;code&gt;ttsUrl&lt;/code&gt;이 필수값이 아니다.&lt;/b&gt; 서버는 질문 텍스트를 내려주고, 프론트는 그 텍스트를 클라이언트 TTS로 읽어 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 응답은 이렇게 단순해질 수 있다.&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;sessionId&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,
  &quot;turnId&quot;: 1,
  &quot;transcript&quot;: &quot;I want an iced americano.&quot;,
  &quot;understoodScore&quot;: 85,
  &quot;status&quot;: &quot;IN_PROGRESS&quot;,
  &quot;babsaeText&quot;: &quot;What size would you like?&quot;,
  &quot;babsaeTtsUrl&quot;: null,
  &quot;followUpCount&quot;: 1,
  &quot;maxFollowUpCount&quot;: 5
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;babsaeText&lt;/code&gt;는 필수다.&lt;/b&gt; &lt;code&gt;babsaeTtsUrl&lt;/code&gt;은 제거하거나 nullable로 둔다. MVP에서는 프론트가 &lt;code&gt;babsaeText&lt;/code&gt;를 클라이언트 TTS로 재생하면 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클라이언트 TTS의 단점은 없을까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연히 있다. 클라이언트 TTS는 기기와 브라우저에 따라 품질이 다르다.&lt;br /&gt;iOS, Android, Chrome, Safari에서 목소리와 발음이 달라질 수 있고, 영어 억양도 기대만큼 자연스럽지 않을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 MVP에서는 이 단점이 치명적이지 않다고 봤다. SayNow의 초기 검증 포인트는 &lt;b&gt;고품질 음성 합성&lt;/b&gt;이 아니라, &lt;b&gt;사용자가 말한 내용을 AI가 잘 이해하고 다음 질문으로 대화를 이어 갈 수 있는지&lt;/b&gt;다. 질문 텍스트를 화면에 함께 보여준다면, TTS가 조금 어색해도 사용자는 흐름을 이해할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 서버 TTS를 처음부터 넣으면 응답 지연과 구현 복잡도가 먼저 커진다. 품질 문제를 해결하려다 제품의 핵심 흐름 검증이 늦어질 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;나중에 어떤 방향으로 고도화할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MVP 이후에는 실제 사용 데이터를 보고 판단하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn.jsdelivr.net/gh/pp8817/velog-tistory-assets@main/assets/db99ac7ae260368d.png&quot; width=&quot;70%&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 &amp;ldquo;음성이 너무 어색하다&amp;rdquo;고 느끼면 서버 TTS나 외부 TTS provider를 붙이면 된다. 사용자가 &amp;ldquo;대화가 너무 느리다&amp;rdquo;고 느끼면 WebRTC와 실시간 Voice Agent 구조를 검토할 수 있다. 발음, 억양, 톤까지 평가해야 한다면 Speech-to-Speech나 음성 특성 분석이 필요해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;b&gt;처음부터 최종 구조를 만들려고 하지 않는 것&lt;/b&gt;이 중요하다. Speak 같은 서비스의 현재 구조는 오랜 시간 제품을 운영하며 도달한 결과에 가깝다. 우리는 먼저 가볍게 검증하고, &lt;b&gt;실제 문제가 확인되는 지점부터 고도화하는 편이 낫다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SayNow의 MVP에서는 다음 구조가 가장 현실적이다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;Frontend
- 음성 녹음
- 질문 텍스트 표시
- 클라이언트 TTS 재생

Backend
- 세션 상태 관리
- 턴 저장
- AI 서버 호출
- 응답 정규화

AI Server
- STT
- 답변 분석
- 슬롯/목표 달성 판단
- 다음 질문 텍스트 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 턴의 응답을 &lt;b&gt;TTS 생성과 S3 업로드 완료에 묶지 않는다.&lt;/b&gt; 먼저 텍스트 기반 대화 흐름을 빠르게 만들고, TTS 품질이나 실시간성이 실제 문제로 드러났을 때 서버 TTS나 Realtime 구조로 확장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 필요한 건 완성형 음성 인프라가 아니라, &lt;b&gt;사용자가 말하고 AI가 이어서 질문하는 핵심 루프를 빠르게 검증하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 링크&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.speak.com/blog/building-speaks-voice-agent-platform&quot;&gt;Speak Voice Agent Platform&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.speak.com/blog/live-roleplays&quot;&gt;Speak Live Roleplays&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.speak.com/blog/asr-levelup&quot;&gt;Speak ASR 시스템 개선&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API&quot;&gt;MDN WebRTC&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.livekit.io/intro/cloud/&quot;&gt;LiveKit Cloud docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.openai.com/api/docs/guides/audio&quot;&gt;OpenAI Audio docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://platform.openai.com/docs/guides/realtime&quot;&gt;OpenAI Realtime API&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Project/Saynow</category>
      <category>saynow</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/271</guid>
      <comments>https://pp8817.tistory.com/271#entry271comment</comments>
      <pubDate>Wed, 5 Aug 2026 00:58:22 +0900</pubDate>
    </item>
    <item>
      <title>알고리즘 스터디 1주차</title>
      <link>https://pp8817.tistory.com/270</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 Velog에서 이전한 글입니다. &lt;a href=&quot;https://velog.io/@pp8817/%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98-%EC%8A%A4%ED%84%B0%EB%94%94-1%EC%A3%BC%EC%B0%A8&quot;&gt;Velog 원문 보기&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1번 문제: BFS&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제: &lt;a href=&quot;https://leetcode.com/problems/minesweeper/description/&quot;&gt;https://leetcode.com/problems/minesweeper/description/&lt;/a&gt;&lt;br /&gt;풀이 소요 시간: 30분&lt;br /&gt;알고리즘: BFS&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;from collections import deque
d = [[-1,-1], [-1,0], [0,-1], [1,1], [1,0], [0,1], [1,-1], [-1,1]]

class Solution:
    def updateBoard(self, board: List[List[str]], click: List[int]) -&amp;gt; List[List[str]]:
        R, C = len(board), len(board[0])
        q = deque([(click[0], click[1])])

        if board[click[0]][click[1]] == 'M': 
            board[click[0]][click[1]] = 'X'
            return board

        while q:
            x, y = q.popleft() 

            if board[x][y] != 'E':
                continue

            cnt = 0 
            for dx, dy in d:
                nx, ny = x+dx, y+dy
                if 0&amp;lt;=nx&amp;lt;R and 0&amp;lt;=ny&amp;lt;C:
                    if board[nx][ny] == 'M':
                        cnt += 1

            if cnt &amp;gt; 0: # 주변에 폭탄이 1개 이상 있는 경우
                board[x][y] = str(cnt)
            else: # 주변에 지뢰가 없는 경우 (재귀적으로 주변 확장)
                board[x][y] = 'B'
                for dx, dy in d:
                    nx, ny = x+dx, y+dy
                    if 0&amp;lt;=nx&amp;lt;R and 0&amp;lt;=ny&amp;lt;C:
                        if board[nx][ny] == 'E':
                            q.append((nx, ny))

        return board&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;풀이 사고 흐름&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;우선 크게 2가지 경우로 분류해서 생각
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주변에 폭탄이 1개 이상 있는 경우&lt;/li&gt;
&lt;li&gt;주변에 지뢰가 없는 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;현재 칸 주변 8방향 지뢰 개수를 체크&lt;/li&gt;
&lt;li&gt;지뢰 개수(cnt) &amp;gt; 0 이면 현재 칸을 cnt로 변경하고 끝&lt;/li&gt;
&lt;li&gt;cnt == 0이면 현재 칸을 'B'로 바꾸고, 주변 'E'만 큐에 삽입&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;if board[x][y] != 'E':&lt;/code&gt;&lt;br /&gt;이미 처리된 값은 continue로 스킵&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멘토님 피드백&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;M&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt; 같은 문제에서 의미를 가지는 값은 별도 상수로 관리하는 것을 추천
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상수로 관리해야 재사용 할 때 실수를 안한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x+dx&lt;/code&gt; 같은 부분 띄어쓰기 추가해서 &lt;code&gt;x + dx&lt;/code&gt;로 표기 -&amp;gt; 사소해보이지만 가독성에 큰 차이를 줌&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cnt&lt;/code&gt; 같은 변수명 &lt;code&gt;boom_cnt&lt;/code&gt;로 의미가 명확하게 변경&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x, y = q.popleft()&lt;/code&gt;: 큐에 들어가는 값에 대한 설명 주석을 작성해야 한다.&lt;/li&gt;
&lt;li&gt;시간복잡도 생각해보기 (worst case까지)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2번 문제: DP&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제: &lt;a href=&quot;https://leetcode.com/problems/remove-boxes/description/&quot;&gt;https://leetcode.com/problems/remove-boxes/description/&lt;/a&gt;&lt;br /&gt;풀이 소요 시간: x&lt;br /&gt;알고리즘: DP&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 문제 이해가 어려웠다.&lt;br /&gt;문제는 여러 개의 박스가 주어지며, 각 박스는 서로 다은 양의 정수로 구분한다.&lt;br /&gt;같은 양의 정수로 이루어진 &lt;b&gt;연속된&lt;/b&gt; 박스들을 선택할 수 있고,&lt;br /&gt;선택한 박스의 개수를 k라고 할 때 이 박스들을 제거하면 &lt;code&gt;k * k (k**2)&lt;/code&gt; points를 얻는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 예시로 이해를 해보면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Input: boxes = [1,3,2,2,2,3,4,3,1]&lt;br /&gt;Output: 23&lt;br /&gt;Explanation:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[1, 3, 2, 2, 2, 3, 4, 3, 1] 
----&amp;gt; [1, 3, 3, 4, 3, 1] (3*3=9 points) 
----&amp;gt; [1, 3, 3, 3, 1] (1*1=1 points) 
----&amp;gt; [1, 1] (3*3=9 points) 
----&amp;gt; [] (2*2=4 points)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[1, 3, 2, 2, 2, 3, 4, 3, 1] 
-&amp;gt; (2,2,2) 제거: k=3
[1, 3, 3, 4, 3, 1]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;points = 3**2 = 9&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가운데 2들의 묶음을 제거해서 3들이 가까워짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계&lt;/b&gt;&lt;br /&gt;여기서 가운데에 있는 3의 묶음이 아닌 4를 지워야 한다.&lt;br /&gt;4를 제거하면 3의 묶음이 3개가 되어 k=3이 된다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[1, 3, 3, 4, 3, 1]
-&amp;gt; (4) 제거: k=1
[1, 3, 3, 3, 1]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;points = 1**2 = 1&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[1, 3, 3, 3, 1]
-&amp;gt; (3,3,3) 제거: k=3
[1, 1]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;points = 3**2 = 9&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4단계&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[1, 1]
(1,1) 제거: k=2
[]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;points = 2**2 = 4&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Result = 9+1+9+4 = 23&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;논리적으로는 이해했으나, 코드로 구현하는 것에 실패.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>Algorithm/소마 알고리즘 스터디</category>
      <category>Algorithm</category>
      <category>[소마] 알고리즘 스터디</category>
      <category>소프트웨어 마에스트로</category>
      <category>스터디</category>
      <author>pp8817</author>
      <guid isPermaLink="true">https://pp8817.tistory.com/270</guid>
      <comments>https://pp8817.tistory.com/270#entry270comment</comments>
      <pubDate>Tue, 4 Aug 2026 12:56:48 +0900</pubDate>
    </item>
  </channel>
</rss>