Skip to main content
Summary

상태는 하나가 아니라 여러 층에 흩어져 있다

ChatGPT 앱에서 "상태"라고 부르는 것은 한곳에만 있지 않습니다. 사용자가 대화에서 말한 지역, 상품, 예산, 선호도는 모델 컨텍스트에 남습니다. 도구가 실행될 때는 JSON 입력으로 전달됩니다. 위젯은 toolOutput과 widgetState를 읽어 화면을 그립니다. 서버는 장바구니, 주문 초안, 저장된 선호도처럼 오래 유지해야 할 데이터를 따로 보관합니다.

문제는 이 네 층이 서로 다른 속도로 변한다는 점입니다.

사용자가 "서울 강남 근처에서 5만 원 이하로 추천해 줘"라고 말한 뒤, 위젯에서 필터를 "3만 원 이하"로 바꿨다고 생각해 봅시다. 화면은 3만 원 이하 상품을 보여주는데, 모델이 여전히 5만 원 이하로 알고 있다면 다음 답변은 어긋납니다. 반대로 서버 장바구니에는 상품이 2개 담겼는데 위젯의 임시 상태에는 1개만 보이면 사용자는 앱을 믿기 어렵습니다.

따라서 상태 동기화의 목표는 단순합니다.

모델, 서버, 위젯이 같은 현실을 바라보게 만든다.

Apps SDK의 상태 관리 문서는 위젯 안에서 어떤 상태를 어디에 둘지 결정하고, 렌더와 대화 사이에서 상태를 유지하는 방식을 중요하게 다룹니다.1 이 장에서는 그 기준을 실제 설계 관점으로 정리합니다.

대화 컨텍스트와 상태 동기화 설계

대화 컨텍스트의 경계를 먼저 정한다

멀티턴 앱을 만들 때 가장 먼저 정할 것은 "무엇을 같은 작업 흐름으로 볼 것인가"입니다. 사용자가 앱과 나눈 모든 말을 무한히 하나의 상태로 묶으면 오래된 정보가 새 결정을 오염시킬 수 있습니다. 반대로 매 요청을 독립적으로 처리하면 사용자가 이미 말한 정보를 계속 반복해야 합니다.

보통 다음 단위로 나눕니다.

  • conversation: ChatGPT 안에서 이어지는 하나의 대화 흐름

  • session: 서버가 관리하는 사용자 작업 단위

  • widgetSessionId: 같은 위젯 인스턴스를 이어 붙이는 단위

  • user profile: 대화를 넘어 저장되는 사용자 기본 정보

예를 들어 쇼핑 앱이라면 conversation은 "이번 대화에서 노트북을 고르는 과정"입니다. session은 서버가 가진 shopping_session_id일 수 있습니다. widgetSessionId는 현재 렌더링된 장바구니 위젯 인스턴스를 구분합니다. user profile은 "사용자는 가벼운 제품을 선호한다" 같은 장기 선호도입니다.

대화에서 나온 정보를 모두 영구 저장하면 안 됩니다. "이번 여행은 부산으로 갈래"는 현재 conversation의 정보일 수 있지만, "앞으로 항상 부산 여행만 추천해 줘"라는 뜻은 아닙니다. 그래서 서버에는 상태를 저장할 때 반드시 범위를 함께 둡니다.

type AppStateScope = "widget" | "conversation" | "user";

type Preference = {
  key: string;
  value: string;
  scope: AppStateScope;
  source: "user_message" | "widget_event" | "server_rule";
  updatedAt: string;
};

이 구조는 단순하지만 중요합니다. 같은 "지역=서울"이라도 현재 검색 필터인지, 이번 대화의 기본 지역인지, 사용자 프로필의 기본 배송지인지가 다르기 때문입니다.

Responses API나 Agents SDK의 세션 메모리처럼 멀티턴 대화를 이어 주는 기능을 쓸 때도 이 경계는 필요합니다. Agents SDK의 세션은 같은 세션 ID를 기준으로 이전 대화 항목을 자동으로 불러와 다음 실행에 붙여 주지만, 세션 메모리와 서버 애플리케이션 상태는 같은 것이 아닙니다.2 모델이 보는 대화 기록과 서버가 검증하는 업무 상태를 분리해서 생각해야 합니다.

모델 컨텍스트, 도구 입력, 서버 세션을 연결하는 방법

사용자가 밝힌 지역, 상품, 예산, 선호도, 이전 선택은 세 군데에 반영되어야 합니다.

첫째, 모델 컨텍스트입니다. 모델은 사용자의 의도를 이해해야 합니다. "아까 고른 두 번째 상품으로 해 줘"라는 요청을 처리하려면 이전 선택지가 무엇이었는지 알아야 합니다.

둘째, 도구 입력입니다. MCP 도구는 추측이 아니라 구조화된 값으로 실행되어야 합니다. "서울에서 저렴한 호텔 찾아 줘"라는 말을 모델이 이해했더라도, 서버에는 다음처럼 명확히 전달되어야 합니다.

{
  "location": "서울",
  "category": "hotel",
  "priceLevel": "budget",
  "conversationSessionId": "conv_8f3..."
}

셋째, 서버 세션입니다. 서버는 실제 업무 상태를 관리합니다. 검색 필터, 선택한 상품, 장바구니, 주문 초안, 사용자 확인 여부는 서버에서 검증 가능한 형태로 보관해야 합니다.

중요한 원칙은 이것입니다.

모델이 기억한다고 해서 서버가 알고 있는 것은 아니다. 서버가 저장했다고 해서 모델이 설명할 수 있는 것도 아니다.

그래서 도구는 실행 결과를 structuredContent나 toolOutput에 최신 상태로 반환해야 합니다. 모델은 이 구조화 데이터를 보고 "현재 장바구니에 무엇이 있는지" 설명할 수 있고, 위젯은 같은 데이터를 화면으로 렌더링할 수 있습니다.

widgetSessionId로 위젯 인스턴스를 이어 붙인다

Apps SDK 예제 저장소의 쇼핑 카트 예제는 _meta["widgetSessionId"]를 이용해 같은 위젯의 상태를 도구 호출 사이에서 이어 붙이는 패턴을 보여줍니다. 이 예제에서 위젯은 이전 widgetState와 새 도구 응답을 병합하고, 수량 변경 같은 UI 액션을 다시 공유 상태로 반영합니다.3

여기서 핵심은 toolOutput과 widgetState의 역할을 섞지 않는 것입니다.

  • toolOutput: 모델과 위젯이 함께 알아야 하는 최신 업무 결과

  • widgetState: 같은 위젯이 다시 렌더링될 때 복원해야 하는 UI 상태

예를 들어 장바구니 위젯이라면 toolOutput에는 상품 ID, 이름, 가격, 수량, 합계, 재고 상태가 들어갑니다. 모델도 이 정보를 알아야 "현재 장바구니 합계는 42,000원입니다"라고 말할 수 있습니다.

반면 widgetState에는 "쿠폰 입력창이 열려 있음", "배송 옵션 섹션이 접혀 있음", "사용자가 아직 제출하지 않은 쿠폰 코드 입력값" 같은 정보가 들어갑니다. 이 정보는 사용자 경험에는 필요하지만 모델이 반드시 알아야 할 업무 사실은 아닙니다.

type CartToolOutput = {
  cartId: string;
  items: Array<{
    productId: string;
    name: string;
    quantity: number;
    unitPrice: number;
  }>;
  totalPrice: number;
  version: number;
  updatedAt: string;
};

type CartWidgetState = {
  expandedSections: string[];
  draftCouponCode?: string;
  highlightedProductId?: string;
};

이렇게 나누면 상태 충돌이 줄어듭니다. 모델이 알아야 할 것은 CartToolOutput에 있고, 화면 복원에만 필요한 것은 CartWidgetState에 있습니다.

클라이언트 상태와 서버 상태를 나누는 기준

상태를 어디에 둘지 판단할 때는 다음 질문을 던지면 됩니다.

1. 대화가 재개되어도 남아 있어야 하는가?
그렇다면 서버에 저장합니다. 장바구니, 주문 초안, 저장된 선호도, 예약 진행 상태가 여기에 해당합니다.

2. 다른 기기나 새 탭에서도 같아야 하는가?
그렇다면 서버에 저장합니다. 사용자가 모바일에서 담은 장바구니를 데스크톱에서도 봐야 한다면 클라이언트 widgetState만으로는 부족합니다.

3. 서버 검증이 필요한가?
그렇다면 서버에 저장합니다. 결제 금액, 재고, 쿠폰 적용, 권한, 주문 가능 여부는 반드시 서버가 판단해야 합니다.

4. 화면 편의를 위한 임시 상태인가?
그렇다면 widgetState에 둡니다. 필터 패널 펼침 여부, 정렬 드롭다운 열림 여부, 입력 중인 검색어, 마우스로 임시 선택한 항목이 여기에 해당합니다.

간단히 말하면 이렇습니다.

상태권장 위치
필터 패널 펼침 여부widgetState
입력 중인 검색어widgetState 또는 임시 서버 상태
검색 결과 목록toolOutput
현재 적용된 필터toolOutput + 서버 세션
장바구니 항목서버 저장소 + toolOutput
주문 초안서버 저장소
저장된 사용자 선호도사용자 프로필 저장소
결제 승인 여부서버 저장소

widgetState는 빠르고 편리하지만 신뢰의 기준으로 삼으면 안 됩니다. 사용자가 새 기기에서 접속하거나 위젯이 다시 생성되면 사라질 수 있습니다. 반대로 모든 작은 UI 상태를 서버에 저장하면 지연이 늘고 구현이 복잡해집니다. 상태의 수명과 검증 필요성에 따라 위치를 정해야 합니다.

위젯 이벤트를 모델이 이해할 수 있게 만든다

위젯에서 수량을 바꾸거나 필터를 적용하는 일을 단순한 UI 변경으로 끝내면 대화와 화면이 분리됩니다. 사용자는 화면에서 "수량 3개"로 바꿨는데, 모델은 여전히 "1개"라고 답할 수 있습니다.

따라서 업무 의미가 있는 위젯 이벤트는 MCP 도구 호출이나 상태 업데이트를 거쳐야 합니다. Apps SDK의 window.openai.callTool()은 위젯에서 도구를 호출할 수 있게 해 주며, 도구 응답이 다시 위젯 렌더링과 모델 컨텍스트에 반영되는 흐름을 만들 수 있습니다.4

예를 들어 수량 변경은 다음 흐름으로 처리합니다.

  1. 사용자가 위젯에서 + 버튼을 누른다.

  2. 위젯이 update_cart_item 도구를 호출한다.

  3. 서버가 재고와 가격을 검증하고 장바구니를 갱신한다.

  4. 도구가 최신 장바구니를 toolOutput으로 반환한다.

  5. 위젯은 최신 장바구니를 렌더링한다.

  6. 모델은 최신 구조화 데이터를 바탕으로 후속 답변을 한다.

async function onQuantityChange(productId: string, quantity: number) {
  await window.openai.callTool("update_cart_item", {
    cartId,
    productId,
    quantity,
    expectedVersion: cart.version
  });
}

여기서 expectedVersion은 충돌 방지에 필요합니다. 사용자가 오래된 화면에서 수량을 바꾸는 동안 서버 상태가 이미 바뀌었을 수 있기 때문입니다.

필터 적용도 마찬가지입니다. "가격 낮은 순", "3성급 이상", "강남 근처" 같은 필터는 모델이 다음 답변에서 알아야 하는 상태입니다. 그러므로 필터 변경 결과는 toolOutput에 포함되어야 합니다.

{
  "filters": {
    "location": "강남",
    "maxPrice": 50000,
    "sort": "price_asc"
  },
  "results": [
    { "id": "p1", "name": "A 호텔", "price": 42000 }
  ],
  "version": 12
}

이렇게 해야 사용자가 "이 중에서 제일 가까운 곳은?"이라고 물었을 때 모델이 현재 필터와 결과 목록을 기준으로 답할 수 있습니다.

오래된 상태와 충돌 상태를 다루는 규칙

멀티턴 앱에서는 오래된 상태가 반드시 생깁니다. 사용자는 몇 분 전에 열린 위젯을 다시 클릭할 수 있고, 같은 대화를 여러 기기에서 열 수 있으며, 서버의 재고나 가격은 중간에 바뀔 수 있습니다.

그래서 상태 객체에는 최소한 다음 필드를 두는 것이 좋습니다.

type VersionedState = {
  id: string;
  version: number;
  updatedAt: string;
  updatedBy: "user" | "tool" | "server";
};

서버는 업데이트 요청을 받을 때 expectedVersion을 비교합니다.

  • 버전이 같으면 업데이트합니다.

  • 버전이 다르면 바로 덮어쓰지 않습니다.

  • 최신 상태를 반환하고 사용자 확인을 요구합니다.

예를 들어 가격이 바뀐 상품을 주문하려 할 때는 자동으로 진행하면 안 됩니다.

"선택하신 상품의 가격이 42,000원에서 45,000원으로 변경되었습니다. 이 가격으로 계속 진행할까요?"

취소와 되돌리기 규칙도 필요합니다. 수량 변경, 필터 적용, 선호도 저장, 선택 취소 중 어떤 동작이 되돌릴 수 있는지 정해야 합니다. 특히 결제, 예약, 외부 시스템 변경처럼 실제 영향을 주는 작업은 "확인 전 초안"과 "확정 후 결과"를 분리합니다.

서버 저장소와 widgetState가 다를 때는 기본적으로 서버를 신뢰해야 합니다. 다만 입력 중인 값처럼 아직 제출되지 않은 초안은 widgetState를 유지할 수 있습니다.

정리하면 다음 우선순위가 안전합니다.

  1. 서버에서 검증한 확정 상태

  2. 최신 도구 응답의 toolOutput

  3. 현재 위젯의 widgetState

  4. 모델의 추론 또는 과거 대화 기억

모델의 기억은 사용자 경험을 자연스럽게 만들지만, 업무 상태의 최종 근거가 되어서는 안 됩니다.

로그 없이는 상태 불일치를 고칠 수 없다

상태 동기화 문제는 겉으로 보면 단순한 버그처럼 보입니다.

  • 화면에는 상품이 있는데 모델은 없다고 말한다.

  • 모델은 필터가 적용됐다고 말하는데 결과는 그대로다.

  • 서버에는 장바구니가 비어 있는데 위젯에는 항목이 남아 있다.

이런 문제를 고치려면 어느 단계에서 상태가 누락되었는지 알아야 합니다. 따라서 LLM 호출, 도구 호출, 위젯 이벤트, 서버 저장소 변경을 같은 추적 ID로 묶어 기록해야 합니다. 에이전트 관찰성 문서에서도 LLM 호출, 도구 호출, 의사 결정 흐름을 추적하면 예기치 않은 동작의 원인을 찾는 데 도움이 된다고 설명합니다.5

운영 로그에는 최소한 다음 항목을 남깁니다.

{
  "traceId": "trace_123",
  "conversationSessionId": "conv_abc",
  "widgetSessionId": "w_789",
  "toolName": "update_cart_item",
  "toolInput": { "productId": "p1", "quantity": 3 },
  "stateBeforeVersion": 11,
  "stateAfterVersion": 12,
  "result": "success",
  "timestamp": "2026-05-19T10:15:30Z"
}

개발 중에는 도구 입력과 출력을 자세히 기록하는 것이 좋습니다. 하지만 프로덕션에서는 개인정보, 결제 정보, 인증 토큰, 민감한 사용자 입력을 그대로 남기면 안 됩니다. 필요한 값은 마스킹하거나 해시 처리합니다.

로그를 볼 때는 다음 순서로 확인합니다.

  1. 모델이 올바른 도구를 선택했는가?

  2. 도구 입력에 최신 필터와 세션 ID가 들어갔는가?

  3. 서버가 올바른 이전 상태를 읽었는가?

  4. 서버가 새 상태를 저장했는가?

  5. 도구 응답에 최신 상태가 포함되었는가?

  6. 위젯이 toolOutput과 widgetState를 올바르게 병합했는가?

이 순서가 있으면 "ChatGPT가 이상하게 답했다"는 막연한 문제를 구체적인 상태 누락 문제로 바꿀 수 있습니다.

상태 동기화 설계 체크리스트

마지막으로 앱을 구현하기 전에 다음 질문에 답해 보세요.

  • 이 앱의 conversation/session 경계는 무엇인가?

  • 사용자의 지역, 예산, 선호도는 현재 대화용인가, 장기 저장용인가?

  • 모델이 알아야 하는 상태와 화면만 알아도 되는 상태를 구분했는가?

  • 업무 상태는 toolOutput에 포함되어 모델과 위젯이 함께 볼 수 있는가?

  • widgetSessionId를 사용해 같은 위젯 인스턴스의 상태를 이어 붙이는가?

  • 장바구니, 주문 초안, 저장 선호도처럼 검증이 필요한 상태는 서버에 저장하는가?

  • 위젯 이벤트가 MCP 도구 호출 또는 서버 상태 업데이트로 이어지는가?

  • 상태 객체에 version과 updatedAt이 있는가?

  • 오래된 위젯에서 온 요청을 감지하고 사용자 확인을 요구하는가?

  • 서버 상태와 widgetState가 다를 때 어느 쪽을 신뢰할지 정했는가?

  • LLM 호출, 도구 호출, 위젯 이벤트, 서버 저장소 변경을 하나의 traceId로 추적하는가?

상태 동기화는 화려한 기능이 아닙니다. 그러나 이 부분이 무너지면 앱은 화면과 대화가 따로 노는 경험을 만듭니다. 사용자는 한 번만 불일치를 경험해도 "이 앱이 지금 내 선택을 제대로 알고 있나?"라고 의심합니다.

좋은 ChatGPT 앱은 자연어와 UI가 서로를 보완합니다. 사용자가 대화에서 말한 것은 도구 입력과 서버 상태로 이어지고, 위젯에서 바꾼 것은 다시 모델이 이해할 수 있는 구조화 데이터로 돌아와야 합니다. 그 연결이 안정적일수록 앱은 단순한 위젯이 아니라 대화 속에서 함께 일하는 제품이 됩니다.

참고

1Managing State - Apps SDK

2OpenAI Agents SDK Sessions

3OpenAI Apps SDK Examples

4Apps SDK Reference

5Azure AI Agent Framework Tracing

책 목록

ChatGPT 앱을 만드는 법: OpenAI Apps SDK 실전 가이드

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기