프론트엔드 시스템 디자인 - 3. Agentic UI와 AI-native 프론트엔드 설계
인프런에서 프론트엔드 시스템 디자인 강의(https://inf.run/8ezQ9)를 듣고 스스로 코드를 짜보면서 알게되는 내용을 정리하고 있다.
Vite + React + TypeScript로 클라이언트를 만들고 Express 서버를 붙이고, LLM에게 목표를 전달하고 계획을 받아 실행하는 정도로 생각했지만 하나씩 붙이다 보니 생각보다 프론트엔드에서 신경 써야 할 게 많았다.
계획을 세우는 동안에는 어떤 상태여야 하는지, 사용자의 승인은 상태인지 이벤트인지, 도구 하나가 실패했을 때 전체 작업을 실패로 볼 것인지, 재시도 이후에는 어떤 데이터를 기준으로 최종 결과를 다시 만들어야 하는지 같은 고려하지 못했던 부분들이 생겨서 고민할거리들이 많았던 것 같다.



채팅 UI만으로는 부족한 이유
AI를 붙인 서비스를 만든다고 하면 가장 먼저 떠오르는 건 채팅 UI다.
사용자가 질문을 입력하고 AI가 답변을 만들어주면 일단 동작하는 서비스처럼 보인다.
그런데 만약 '지난달 매출이 왜 떨어졌는지 분석해서 리포트로 정리해줘.' 같은 요청을 한다면 Thick 하게 된다.
이 요청을 처리하려면 AI가 매출 데이터를 조회하고, 다른 데이터와 비교하고, 필요한 경우 추가 데이터를 가져오고, 그 결과를 바탕으로 리포트를 만들어야 한다.
그렇다면 사용자는 결과만 보고 싶어 하는 게 아니라,
1) AI가 어떤 데이터를 조회했는지, 2) 어떤 순서로 작업했는지, 3) 지금 무엇을 하고 있는지, 4) 중간에 어떤 작업이 실패했는지 정도는 확인할 수 있어야 한다. 그리고, 5) 외부 시스템에 영향을 주는 작업이라면 실행 전에 사용자가 승인할 수 있어야 한다.
Agentic UI에서 프론트엔드가 보여줘야 하는 것은 AI의 답변 하나가 아니라 AI가 답변을 만들어가는 과정을 보여주는 느낌이다
사용자가 목표를 입력
↓
AI가 작업 계획 생성
↓
사용자에게 계획 표시
↓
필요하면 승인
↓
도구 호출
↓
각 도구의 실행 상태와 결과 표시
↓
중간 결과를 바탕으로 최종 답변 생성
↓
실패한 작업이 있다면 부분 결과 + 재시도 제공
어느정도 단계를 나누다보니 머릿속에 컴포넌트도 그려지게 된다. 이후 본격적으로 설계를 위해서 상태 설계를 먼저 해보았다.
상태 설계
처음에는 기존 API 기반 UI처럼 mock 데이터를 붙여서 setTimeout 으로 일단 틀을 잡으려고 했다.
idle → loading → success / error
Agentic UI에서의 loading은 그냥 로딩중 하나로 설명될 수가 없다.
AI가 계획을 만들고 있는 것과 도구를 호출하고 있는 것은 둘 다 기다리는 시간이지만 사용자 입장에서는 전혀 다른 상황이다.
계획을 만드는 중이라면 조금 기다리면 되고, 계획이 완성됐다면 승인하거나 수정할 수 있다. 도구를 실행하고 있다면 어떤 도구가 실행 중인지 확인해야 하고, 사용자의 승인을 기다리고 있다면 사용자가 행동해야 한다.
이러한 플로우를 생각하며 조금 더 나누니까 이런 상태가 됐다.
planning
ready
executing
done
partial
rejected
error
상태를 나누다 보니 또 다른 고민거리가 생겼는데, 그것은 .. 승인도 상태로 봐야 할까? 이다.
처음에는 이렇게 만들었다.
export type PlanStatus =
| "planning"
| "ready"
| "approved"
| "rejected"
| "error";
사용자가 승인 버튼을 누르면 approved가 되고, 화면에는
✓ 승인됨 · 실행 대기 중
같은 메시지를 보여주려고 했다.
계획단계까지 짰을 땐 의미가 있지만 바로 작업을 실행하는 플로우를 붙이고 나니 approved는 거의 의미가 없어졌다.
다시 생각해보니 approved는 별도의 상태로 볼 이유가 없었다. 사용자가 그 상태에서 할 수 있는 행동도 없고, executing과 구분되는 복구 방법도 없기에 아래와 같이 바꿨다.
export type PlanStatus =
| "planning"
| "ready"
| "executing"
| "done"
| "partial"
| "rejected"
| "error";
승인 자체가 사용자가 머무는 상태가 아니라 실행으로 넘어가는 트리거로 역할을 한다.
상태와 이벤트를 구분해야 한다의 좋은 예인 것 같다.
계산할 수 있는 값과 저장해야 하는 값
상태를 정리하면서 하나 더 신경 쓴 건 다른 값으로 계산할 수 있는 것을 굳이 저장하지 않는 것이었다.
제출 가능 여부는 objective(input창에 사용자가 직접 입력한 텍스트)만 보면 알 수 있다.
결과가 확정본인지 초안인지도 별도의 상태를 만들기보다 실행 상태에서 계산했다.
이렇게 하면 렌더링할 때마다 원본에서 다시 계산되기 때문에 값이 어긋날 일이 없다. 반대로 별도 상태로 저장하면 원본이 바뀔 때마다 같이 갱신해줘야 하고, 그 경로를 한 군데라도 빠뜨리면 화면과 실제 값이 달라진다.
반면 최종 답변 텍스트는 LLM이 만들어준 응답이라 다른 상태에서 계산해낼 수 없기에 이건 저장할 수밖에 없다.
문제는 이 둘이 한 화면에 나란히 있을 때 생기게 되는데, 신뢰도 배지는 계산되는 값이고, 답변 텍스트는 저장된 값이다.
- 일부 도구가 실패 → status는 partial, 배지는 초안, 답변 텍스트는 실패 내용을 언급
- 재시도로 전부 성공 → status가 done으로 바뀜
- 배지는 status에서 계산되니까 자동으로 확정본이 됨
- 답변 텍스트는 저장된 값이라 그대로 → 여전히 실패로 표시됨
배지는 확정본이라는데 본문 ui엔 실패를 설명하는 상태가 됐다. 저장한 게 잘못인가 싶었는데, 답변 텍스트는 애초에 계산할 수 없으니 저장이 맞고, 문제는 저장된 값이 의존하는 상태가 바뀌었는데 그 값을 다시 만들 걸 생각을 못한거였다.
그래서 재시도 함수 끝에서 답변을 다시 만들도록 고쳤다.
계산할 수 있는 값은 계산해서 쓰면 갱신을 신경 쓸 필요가 없다. 반대로 저장해야만 하는 값은 무엇이 바뀌면 이 값도 다시 만들어야 하는지를 함께 설계해야 한다.
useState -> useReducer
기존 코드에선 상태 변경이 있을 때마다 setMessages를 직접 호출했다.
상태가 늘어나면서, planning → ready → executing → done/partial로 전환되는 과정에서 어떤 경우에는 steps를 넣고, 어떤 경우에는 toolCalls의 특정 항목만 바꾸고, 또 어떤 경우에는 최종 답변을 업데이트해야 했다. 비슷한 map 코드가 여러 곳에 생기기 시작했고, 코드를 읽어도 왜 상태가 바뀌는지가 잘 보이지 않아서 useReducer로 옮겼다.
이렇게 바꾸고 나니 dispatch({ type: "tool_running", ... })만 봐도 어떤 일이 일어나는지 알 수 있었다.
그리고 리듀서를 한 파일에 분리해서 관리하니 훅이나 컴포넌트 파일이 깔끔해져서 좋았고, 또 순수함수가 되어 테스트하기도 좋은 형태가 되었다.
+ 그리고 이 구조는 뒤에서 나온 stale closure 문제를 해결할 때도 도움이 됐다.
데이터 모델의 변천사
처음에는 AI가 만든 계획을 steps: string[] 이런식으로 그냥 문자열 배열로 받았다.
이런 desc 스타일의 strings 로는 코드는 이 문장이 실제로 무엇을 하는지 알 방법이 없었다.
그래서 데이터 모델을 확장해서, description은 사람이 읽고, tool은 코드가 읽는다.
이렇게 바꾸고 나니 실행 함수에서 도구별로 분기할 수 있었고, Tool Call Panel에서도 도구 이름을 표시할 수 있었고, 나중에 위험도에 따라 승인 여부를 나누는 것도 가능해졌다.
LLM 응답의 다양성^^
계획을 JSON으로 받으려고 프롬프트에 '다른 설명 없이 JSON 배열만 출력해라.' 라고 분명하게 적었다.
그런데 응답에서 가끔 다른 말들을 하는 경우가 있어서 응답을 그대로 JSON.parse하지 않고 별도의 파싱 계층을 만들었다.
일반적인 API는 서버가 정해진 스키마를 지켜서 응답한다고 가정할 수 있지만, LLM은 그렇지 않았다. 프롬프트로 형식을 요청할 수는 있지만 그게 타입 시스템처럼 강제되는 것은 아니다.
배열 부분만 정규식으로 뽑아내기, 필드 타입을 검사, 정해진 값이 아닌 tool은 안전한 기본값으로 떨어뜨리기
비동기 처리, stale closure
이 프로젝트에서 상태는 두 층으로 되어 있다.
도구 하나를 재시도하면 그 항목의 상태만 바뀌지만, 그것 때문에 계획 전체의 상태도 다시 판단해야 한다. 실패가 하나도 남지 않았다면 partial에서 done으로 올라가야 하기 때문이다.
여기서 messages는 최신 상태가 아니었다. 바로 위에서 tool_finished를 dispatch해서 해당 항목을 success로 바꿨는데도, 그 아래에서 읽은 messages에는 그 변경이 반영되어 있지 않았다. 그래서 hasError는 여전히 true로 계산됐고, 전부 성공했는데도 partial로 남았다.
dispatch를 호출했다고 해서 지금 실행 중인 함수 안의 messages 변수가 바로 바뀌는 게 아니기 때문이다. React가 다음 렌더링에서 새로운 상태를 만들어주는 것과, 현재 실행 중인 함수가 가지고 있는 값을 바꾸는 것은 다른 문제였다.
이 경우에는 계산 자체를 reducer 안으로 옮겼다.
reducer는 현재 state를 인자로 받기 때문에 여기서는 오래된 messages를 참조할 일이 없다.
그런데 같은 함수 안에서 stale closure를 한 번 더 만났다.
재시도로 도구 하나가 성공하면 최종 답변도 다시 만들어야 한다. 앞에서 이야기한, 배지는 확정본으로 바뀌었는데 답변 텍스트는 실패 내용을 그대로 들고 있던 문제가 여기서 나온 것이다.
그런데 최종 답변을 만드는 /api/synthesize는 재시도한 항목 하나가 아니라 모든 단계의 결과를 받아서 전체를 종합한다. 그래서 모든 항목의 결과 목록을 만들어 넘겨야 하는데, 여기서 messages를 다시 읽으면 앞과 똑같은 문제가 생긴다.
const message = messages.find((m) => m.id === messageId);
const results = message?.toolCalls?.map(...);
이번에는 reducer로 옮기는게 아니라 가지고 있던 스냅샷에서 해당 인덱스만 새 결과로 교체했다.
재시도 함수가 이미 toolCalls를 인자로 받고 있었기 때문이다. (재시도 버튼을 누른 시점의 목록)
이번 실행에서 바뀐 항목은 방금 재시도한 하나뿐이고, 그 새 결과는 outcome으로 손에 들고 있다. 나머지 항목은 이전에 확정된 값이라 바뀔 일이 없다. 그러니 최신 state를 다시 읽을 이유가 없었다.
정리하면
- 최신 state 전체를 기준으로 다시 계산해야 한다면 reducer 안에서 계산하고
- 이미 필요한 값을 가지고 있다면 그 스냅샷에서 바뀐 부분만 교체한다.
비동기 상태를 다룰 때 "최신 값을 가져와야 한다"는 말만 생각하는 것보다, 정말 최신 state 전체가 필요한 상황인지 먼저 구분하는 게 중요하다!
무한 로딩 탈출
한 번은 LLM 요청이 에러도 내지 않고 그냥 끝나지 않는 상황을 만났다. 화면에는 계속 "AI가 계획을 세우는 중입니다"만 표시됐다. 정상적으로 오래 걸리는 건지 멈춘 건지 사용자가 알 방법이 없었다.
Agentic UI에서는 한 단계가 끝나야 다음 단계가 실행되는 경우가 많기 때문에, 하나가 무한정 기다리면 뒤에 있는 작업도 전부 멈춘다.
타임아웃은 한 겹이 아니고, 위에서 건 타임아웃은 서버가 LLM을 호출하는 구간(2)에만 해당한다.
(1) 에는 아무것도 걸려 있지 않다.
그래서 LLM이 아니라 Express 서버 자체가 멈추거나 응답을 못 돌려주는 상황이라면, 브라우저는 여전히 계속 기다리게 된다. 결국 처음에 겪었던 문제가 다른 구간에서 그대로 재현될 수 있는 셈이다. 브라우저에서 이 구간을 막으려면 직접 걸어줘야 한다.
찾아보니 공식 SDK들은 대부분 기본 타임아웃을 가지고 있긴 했다. 다만 그 값이 분 단위인 경우가 많아서, 긴 생성 작업을 기준으로 하면 합리적이지만 사용자가 화면을 보고 있는 UI 기준으로는 너무 길다. 그래서 기본값이 없어서 설정하는 게 아니라, 제품에 맞는 값으로 줄이려고 설정이 필요하다.
반면 fetch는 실용적인 기본 타임아웃이 없다고 보는 편이다. 그래서 이 구간은 개발자가 명시적으로 쓰지 않으면 그냥 비어 있게 된다.
+ 스트리밍을 쓴다면 기준이 달라진다.
첫 토큰은 1초 만에 오고 전체 응답은 60초가 걸릴 수도 있기 때문에, 요청 전체 시간 하나로 판단하면 정상적으로 동작하는 요청을 끊어버릴 수 있다. 이 경우에는 첫 응답까지의 시간과 토큰 사이의 유휴 시간을 따로 보는 편이라고 한다.
실패했다고 전체 작업을 버리면 X
Agentic UI에서는 여러 도구가 순서대로 실행될 수 있기 때문에 실패도 여러 종류가 있는데,
개발 중에는 도구 호출이 15% 확률로 실패하도록 해두고 부분 실패 상황을 계속 마주치면서 만들 수 있었다.
여러 데이터를 동시에 다루는 작업이라면 하나가 실패했다고 전체 결과를 버릴 필요가 없는 경우가 있다.
ex)
세개의 db 를 보고 데이터를 긁어온다고 할때, 하나가 실패하긴 하지만 Sales와 Inventory를 이용해서 분석을 진행할 수 있다. 그래서 전체 실행 상태도 success / error로 나누지 않고 done / partial로 나눴다.
그리고 실패한 도구 옆에만 재시도 버튼을 표시했다. status === "partial" 로 표시
mock 데이터
처음에는 도구 결과를 정상적으로 처리했습니다 같은 문구로 반환했다. 그런데 최종 답변을 만들 때 LLM에게 전달되는 정보가 Sales DB 조회 → 성공, 보고서 작성 → 성공 정도밖에 없었다. 제품명도 매출액도 없으니 아무리 "실제 데이터를 표로 만들어줘"라고 해도 만들 수 있는 정보가 없었다.

Sales / Marketing / Inventory를 실제 데이터처럼 보이는 mock DB로 만들고, 도구가 그 데이터를 조회해서 실제 수치를 반환하도록 바꿨다.
결과에서의 신뢰 수준 구분
Agentic UI에서 결과를 빨리 보여주는 것 뿐만 아니라 유저가 분석 결과가 완성본이 아닐경우 이를 인지할 수 있도록 해주는게 중요하다.
그래서 구분한 결과의 상태들은 다음과 같다.
Draft - 작성 중인 초안
Partial - 일부 데이터만 반영된 결과
Verified - 필요한 데이터와 검증을 통과한 결과
Final - 확정본으로 보여줄 수 있는 결과
내 프로젝트에서는 단순화해서 모든 도구가 성공하면 확정본, 하나라도 실패하면 초안으로 표시했다.
한글 IME 버그 대응
또 하나 기억에 남았던 건 한글 입력 문제였는데, Enter를 누르면 "요약해줘"가 전송되고, 조합 중이던 "줘"가 입력창에 남아 한 번 더 전송되는 문제가 있었다. AI 에게 물어보니 원인은 한글 IME 조합이었다.
isComposing이 true라면 아직 한글 조합 중이기 때문에 그 Enter를 실제 메시지 전송으로 처리하면 안 된다.
Rollback & Audit Trail
AI가 외부 시스템을 변경할 수 있다면 모든 작업을 <완료>로만 기록할 수는 없다.
파일 수정처럼 되돌릴 수 있는 작업이 있는 반면, 이메일 발송처럼 원래 상태로 되돌릴 수 없고 정정 메일 같은 보상 작업만 가능한 것도 있다. 결제나 영구 삭제처럼 아예 되돌릴 수 없는 것도 있다.
그런데 되돌리기는 사용자가 되돌리기 버튼을 눌렀을 때 자동으로 생기는 기능이 아니라 이전 값을 실행하는 시점에 저장해두지 않으면 나중에는 복구할 방법 자체가 없다. 그래서 작업을 실행할 때부터 이런 정보를 남겨야 한다.
previous_value: To Do
new_value: Done
inverse_action: Restore To Do
처럼 이전 값을 함께 저장해두어야 원래 상태로 돌아갈 수 있다.
난 이 정보가 Audit Trail에 필요한 것과 유사해 보였었다. 누가 요청했고, 어떤 계획이었고, 어떤 도구를 호출했고, 어디서 승인했는지. 필드도 겹치고 만들어지는 시점도 같으니 한 번만 기록하면 되지 않을까? 싶어서 AI 에게 물어봤다.
그런데 찾아보니 두 기록은 목적부터 달랐다. 되돌리기는 사용자 편의 기능이지만, 감사 기록은 나중에 제3자가 검증할 수 있어야 하는 증거에 가깝다. 규제 대응이나 보안 사고 조사, "나는 승인한 적 없다" 같은 분쟁에서 쓰이는 기록이다.
가장 구분되는 점은, 민감 정보 부분이다. 고객 이메일 주소를 바꾸는 작업을 되돌리려면 이전 주소가 그대로 남아 있어야 하는데, 감사 기록에서는 그런 값을 남기지 말아야 한다. 만약 개인정보 삭제 요청이 들어오면 더 분명해지게 되는데, 되돌리기용 데이터는 지워야 하고 감사 기록은 남겨야 하는 상황이 생기는데, 한 곳에 저장해두면 둘 중 하나는 어기게 된다.
그래서 같은 이벤트에서 출발하더라도 저장은 나누는 편이 안전해 보였다.
공통 필드는 이벤트 ID로 연결해두면 된다. 물론 개인용 프로젝트라면 한곳에서 시작해도 되겠지만, 처음부터 같은 것으로 생각하고 설계하면 나중에 분리하기 어려워진다.
+ 한 가지 더, 이 데이터는 사용자가 개발자 도구로 얼마든지 조작할 수 있기 때문에 클라이언트가 만들어 보내면 안 된다. 프론트엔드는 "승인 버튼을 눌렀다" 같은 액션만 보내고, 실제 기록은 서버가 세션과 권한 정보를 기준으로 직접 남겨야 한다. (프론트엔드의 역할은 그 기록을 조회해서 타임라인으로 보여주는 쪽)
Agentic UI에서 측정해야할 지표들
Agentic UI에서 제품 경험을 설명/측정 해야할 지표들은 다음과 같다.
- Agent Run Completion Rate
- Approval Rate
- Tool Failure Rate
- Rollback Rate
- User Intervention Rate
- Time to First Useful Progress
- Time to Completion
- Unsafe Action Blocked Rate
그중에서 이번 프로젝트를 만들면서 재미있었던 건 Time to First Useful Progress였다.
사용자가 요청을 보낸 뒤 최종 결과가 나오기까지 20초가 걸리더라도, 그 사이에
계획을 세우고 있습니다 → Sales 데이터를 조회하고 있습니다 → Inventory 데이터를 조회하고 있습니다
처럼 현재 작업이 계속 보인다면 사용자는 AI가 일하고 있다는 걸 알 수 있다. 반대로 최종 결과가 10초 만에 나오더라도 그동안 아무런 변화가 없다면 "멈춘 건가?"라고 느낄 수 있다.
그래서 진행 상황을 보여주는 것이 사용자가 AI의 작업을 이해할 수 있게 만드는 인터페이스라고 생각하고 경험을 설계해야한다는 것을 염두해두려 했다.
RADIO로 다시 분석해보기
위의 내용들과 겹치긴 하지만 복습용으로 다시 분석해본다.
Requirements
- AI가 지금 무엇을 하고 있는지 알 수 있어야 한다.
- 외부에 영향을 주는 작업은 사용자가 승인할 수 있어야 한다.
- 일부 단계가 실패해도 성공한 결과는 유지되어야 한다.
- 결과를 얼마나 믿어도 되는지 알 수 있어야 한다.
Architecture
LLM을 브라우저에서 직접 호출하지 않고 Express 서버를 둔 것도 이 단계에서 결정한 구조였다.
/api/plan, /api/synthesize, /api/execute-step을 나누면서 각각의 책임도 분리했다. 계획과 결과 생성에는 LLM이 필요하지만, DB 조회 같은 도구 실행에는 필요하지 않았다. 이렇게 나눠놓으니 나중에 Anthropic에서 Gemini로 바꿀 때도 클라이언트는 건드릴 필요가 없었다.
Data Model
이번 프로젝트에서 가장 많이 바뀐 부분이기도 했다.
였던 것이 아래처럼 변화했다
데이터 모델은 결국 UI를 그리기 위한 형태만 결정하는 게 아니라 시스템이 그 데이터를 가지고 무엇을 할 수 있는지를 결정한다.
실행해야 하고, 상태를 표시해야 하고, 도구 이름을 보여줘야 하고, 나중에는 위험도에 따라 승인 여부도 판단해야 한다.
Interface
이번 프로젝트에서는 기존 프론트엔드 인터페이스에 몇가지가 더 붙었다.
컴포넌트 Props, API, 이벤트, Telemetry 외에 프롬프트도 인터페이스라고 볼 수 있었다.
문제는 타입 시스템이 프롬프트를 검증해주지 않는다는 것이다. 그래서 LLM 응답을 파싱하고 검증하는 계층이 필요했고, 프롬프트를 수정할 때마다 실제 렌더링 결과를 확인해야 했다. 표가 실행 로그로 나왔던 버그도 결국 이 인터페이스가 잘못 설계된 문제였다.
Optimization & Observability
이전 글에서 성능 최적화를 이야기할 때는 JS 실행 시간이나 DOM, 이미지 로딩 같은 것들을 고려했지만, Agentic UI에서의 병목은 LLM 응답을 기다리는 시간으로부터 대부분 오는 것 같다.
그래서 이번 프로젝트에서는 타임아웃을 두고, 모델 호출을 필요 이상으로 늘리지 않고, 단계별 진행 상태를 보여주는 데 더 신경 썼다.
반면 Observability는 아직 부족했다. 실제 제품이라면 도구 호출 성공률, 실패율, 실행 시간, 승인 비율, 재시도 횟수 같은 데이터를 처음부터 남겨야 한다. (이번 프로젝트에서는 이 부분까지 구현하지 못했다.)
글을 마무리하며
이 레포작업 시작 전엔 Agentic UI면 내 생각보단 복잡하겠지 정도로 생각했다. 사용자가 목표를 입력하고, AI가 계획을 만들고, 그 계획대로 도구를 실행한 뒤 결과를 보여주면 된다고 생각했다. 만들다보니 어려웠던 건 LLM을 호출하는 것 자체보단 그 사이에 있는 프론트엔드의 상태와 데이터 모델, 비동기 처리, 실패 복구, 사용자 승인, 결과의 신뢰 수준 같은 것들이 계속 트러블슈팅 or 고민해야할 지점으로 추가됐었다.
무엇보다도 처음부터 완벽한 구조를 설계해서 해결한 문제는 거의 없었는데, 상태가 복잡할수록 더 그런 것 같다.
이젠 AI가 코드를 만들어주는 환경에서는 컴포넌트 하나를 만드는 것 자체는 일도 아닌 것이 되어버렸다. AI가 만들어낸 계획과 실행 과정을 어떤 데이터로 표현할지 / 어디까지 자동으로 실행할지 / 실패했을 때 무엇을 남길지 / 사용자가 지금 화면을 보고 어떤 판단을 할 수 있어야 하는지는 여전히 사람이 설계해야 한다. AI가 답을 만들어주는 화면을 만드는 것보다, AI가 일을 하는 과정을 사용자가 이해하고 통제할 수 있도록 경험을 만드는 일이 훨씬 어려웠다. 오랜만에 설계하고 트러블슈팅하며 거의 일주일 넘게 고민했고, 이 글을 쓰는데도 3일이 걸렸다. 하나의 시스템을 설계하는 일은 회사에서만 했는데 혼자 PR 도 올리고 계획도 하니 일하는 기분이 들어 좀 새록새록 하기도 .ㅎ