인간관계를 자동화 해보자

카톡 답장하는게 어렵다. 사회성을 기르는 게 빠를지 외주를 맡기는 게 빠를지 고민하다가 후자로 기울었다. 그래서 에이전트 하나를 내 카톡 앞에 앉혀보았다.

2026년에 에이전트를 쓴다는 건 LLM을 API로 호출하는 것과 다르다. Claude Code같은 에이전트 런타임 위에 에이전트를 배치하는 일에 가깝다. 호출 타이밍과 컨텍스트 경계, 인간 개입 지점과 런타임 수명을 설계하는 일이 본체다. 답장 문장을 생성하는 건 그 뒤에 따라오는 자동적인 결과다.

이 글은 auto-kakaotalk 이라는 오픈소스 소프트웨어(스킬)를 만들면서 고민한 결정들을 기록한 것이다. 나의 카톡을 대신 해주는 에이전트를 만들었고, 그 에이전트가 내 카톡 앞에 앉아 말투를 익히고 대신 답장한다.

런타임을 먼저 정한다

에이전트 시스템 설계에서 첫 질문은 "뭘 하게 할 것인가" 가 아니라 "어디서 돌릴 것인가" 다.

선택지가 셋 있었다. Python 데몬 + claude -p subprocess. launchd plist 로 OS 레벨 스케줄러. Claude Code 세션 안에서 돌리기. 직관적으로는 첫 번째나 두 번째가 맞아 보인다. "데몬" 이라는 단어가 익숙하니까.

근데 이 도구가 하려는 일은 "24/7 백그라운드 서비스" 가 아니다. 사용자가 카톡을 안 보고 있는 시간에 대신 답장해주는 일이다. 그러면 데몬을 띄우는 순간 문제가 하나 생긴다. 사용자가 안 보는 시간에 엉뚱한 메시지가 나가는 사고를 막을 구조적 장치가 없다. 로그를 보거나 알림을 달아야 한다. 안전을 운영 부담으로 치환하는 설계다.

Claude Code 세션이 런타임이면 이 문제가 사라진다. 창이 닫혀 있으면 아무 일도 안 일어난다. 창이 열려 있을 때만 에이전트가 돈다. "끄는 방법"이 곧 "창 닫기"라 문서화할 필요도 없다. 안전이 기능이 아니라 런타임 위상에서 나오는 구조다. 이걸 위상적 안전 이라고 부르기로 했다. 기능으로 막는 안전은 운영 부담이 되지만, 위상으로 막는 안전은 시스템이 못 돌리는 것 자체가 보장이다.

이 결정에 따라 이후의 설계도 결정되었다.

CronCreate 로 에이전트를 깨운다

세션이 런타임이면 그 안에서 주기적으로 뭔가를 돌리는 방법이 필요하다. Claude Code는 CronCreate 라는 툴을 제공한다. 세션이 살아있는 동안만 유효한 in-memory 스케줄러다. 표준 cron 표현을 받고, REPL이 idle 일 때만 발화한다. 사용자와 대화하는 중이면 조용히 기다린다. 세션을 닫으면 같이 사라진다.

이게 이 도메인에 정확히 맞다.

CronCreate("*/3 * * * *", "auto-kakaotalk tick — cycle.sh poll → draft → send")

이 한 줄이 전통적 설계로는 launchd plist + 데몬 프로세스 + 헬스체크 + 종료 핸들러 조합이 해야 할 일을 대체한다. 데몬 바이너리도 별도 프로세스도 재시작 스크립트도 없다. 규칙 수정은 마크다운 한 줄 편집으로 끝난다.

써보고 나서야 알았다. 오늘날 에이전트 설계는 코드 호출이 아니라 런타임 활용이다. idle 발화, 세션-수명 = 스케줄-수명. 이 둘을 런타임이 직접 주니까 데몬 아키텍처가 필요 없어졌다.

Skill 을 얇게 쓴다

Claude Code의 스킬은 ~/.claude/skills/<name>/SKILL.md 라는 문서 하나에서 시작한다. 에이전트가 할 일을 자연어로 쓰면 된다. 순진하게 쓰면 긴 문서 하나에 모든 절차를 담게 되는데, 이건 에이전트 컨텍스트 관점에서 안티패턴이다. 매 cycle에 전체가 컨텍스트에 올라오고, 문서가 길수록 에이전트가 어디에 주목해야 할지 흔들린다.

대신 SKILL.md에는 라우팅 테이블과 최소 루프만 두고, 상세 절차는 references/*.md 로 뺐다. 해당 상황이 실제 발생할 때만 로드된다.

| 하위 작업              | 참조 문서                |
| 초기 셋업 / check 실패 | references/setup.md     |
| 새 상대 등록           | references/register.md  |
| 루프 동작              | references/loop.md      |
| 페르소나 오버라이드    | references/persona.md   |
| 전송 2-phase           | references/send.md      |

매 cycle 첫 단계에서 이 표를 읽고, 무관한 reference는 끝까지 로드하지 않는다. 무엇을 안 읽히는지가 무엇을 읽히는지만큼 결정적이다. 세션이라는 런타임은 컨텍스트 윈도우가 곧 메모리이고, 거기서는 메모리 할당이 곧 설계다.

판단의 단일점

요즘 떠오르는 에이전트 기법 중 하나는 작은 classifier를 연쇄하는 패턴이다. Route → classify → tool-select → generate. LangChain 스타일 체인, 여러 agent framework 의 기본 구조. 어떤 도메인에선 맞다. 이 도메인에선 정반대였다.

시작은 감정 분류기였다. emotion_score.py 가 느낌표, 이모지, 길이를 보고 suspicion 을 뱉는다. 점수가 임계치를 넘으면 "화난 상대" 로 분류하고 답장 AI 를 호출하지 않는다. 이틀 만에 깨졌다.

친구가 *"됐어."* 하고 보냈다. 맥락은 "그래 네 말대로 해" 였다. 느낌표 없음, 이모지 없음, 길이 2자. 점수 낮음. 에이전트가 엉뚱한 답장을 생성했다. 반대로 *"ㅋㅋㅋㅋ 미쳤네 진짜 ㅋㅋ"*. 반복 문자, 이모지, 점수 높음. 차단. 맥락은 그냥 농담이었고, 대화는 거기서 끊겼다.

사람은 카톡을 볼 때 분류기를 먼저 돌리지 않는다. 한 번 읽는다. 그 한 번에 "어떤 기분인가" 와 "뭐라고, 지금 답할까" 가 동시에 정해진다. 쪼개는 순간 맥락이 찢어진다. 스크립트 점수는 표면 신호고, 맥락은 표면에 없다. 맥락은 에이전트에게만 있다.

분류기를 뺐다. 원칙 하나가 남았다.

어떤 스크립트도 단독으로 전송 게이트를 열거나 닫지 않는다.

스크립트가 뽑은 모든 신호는 에이전트에게 참고 정보로만 넘어간다. 전송 여부는 에이전트가 결정한다. LLM 호출은 cycle 당 한 번뿐이다. 에이전트가 가진 통합 판단 능력을 쪼개는 순간 에이전트를 쓰는 이유 자체가 사라진다.

원칙을 한 번 세우고 나면 구현 중 나오는 거의 모든 유혹이 이걸 흔드는 형태로 온다. 가장 강했던 건 register.py 에서였다. 새 상대를 등록하면 과거 500개 대화를 수집하는데, 이왕 읽는 김에 페르소나 문서도 에이전트가 자동 생성하면 깔끔해 보였다. 합리적으로 들렸다. 그렇게 하면 LLM 호출 지점이 둘이 된다. 세션 루프 하나, register 하나. 판단의 단일점이 깨지고, "register 의 판단과 세션의 판단이 일치하는가" 라는 영영 검증할 길 없는 골칫거리가 들어온다.

그래서 register.py 는 LLM 을 호출하지 않는다. 과거 대화를 DB 에 백필하고, 빈 persona.md 를 만들고, 끝. 페르소나는 사용자와 에이전트가 세션 안에서 대화로 함께 쓴다. 스크립트는 수집, 에이전트는 판단. 이 경계가 어디서부터 흐려지는지 감지하는 감각이 이 프로젝트에서 가장 많이 쓰인 도구였다.

승인을 N번에서 1번으로

에이전트와 사람이 함께 돌아가는 시스템에서 가장 어려운 설계 문제는 "언제 사람을 끼워넣을지" 다. 매 행동마다 승인받으면 에이전트 자체가 무의미해진다. 한 번도 안 받으면 재앙이 쌓인다.

초기엔 draft-and-ask 모드였다. Cron tick이 깨어나 새 메시지를 잡으면 에이전트가 드래프트를 만들고 세션 안에서 물어본다.

>  [맹구] "아 망했다" (05:51 KST)
>   ↳ 드래프트: "ㅋㅋ 왜"
>   보낼까? (예 / 아니 / 수정: ...)

안전해 보였다. 하루 써보니 문제가 뚜렷했다. 매번 "ㅇㅇ" 을 치는 게 카톡 답장 자체의 귀찮음이었다. 내가 카톡을 피하던 이유가 그 한 줄 쓰는 부담이었는데, 그걸 "ㅇㅇ" 치는 부담으로 바꿔놓은 꼴이었다. 원점이었다. 사람은 승인 루프가 있으면 승인 루프 자체를 피한다.

그래서 승인을 빼고 자동 전송으로 갔다. 대신 승인을 등록 시점 한 번으로 모았다.

등록할 때 에이전트가 과거 카톡 500개를 읽고 사회과학적 리포트를 쓴다. 관계의 성격과 말투, 답장하지 말아야 할 주제. 대충 이런 식이다.

[맹구 분석 리포트]

▸ 관계의 성격
  - 친밀도: 반말, 농담 섞음, 자조 공유 → 10년 이상 친구로 추정
  - 주도권: 메시지 시작 비율 58:42 (맹구:나). 맹구가 살짝 더 자주 건다
  - 응답 리듬: 평균 응답 간격 나 12분 / 맹구 4분. 내가 늦는 편

▸ 말투 패턴 (나의)
  - 평균 길이 1.4문장. 짧음
  - 말끝: ~임/~ㅇㅇ/~ㄱㄱ 축약형 우세

▸ 최근 변화
  - 최근 2주 응답 지연 평균 38분으로 증가. 뭐 있었나?

▸ 답장하지 말아야 할 패턴 (추정)
  - 계좌/송금 관련
  - "꿈" 에 대한 긴 서사 (과거 사용자가 안 반응한 이력)

이 분석 맞아? 내가 대신 답할 때 어떤 뉘앙스로 가면 좋을까?

사용자는 읽고 교정한다. "최근 짧아진 건 시험기간이라 그런 거임", "형이라고 부르는 거 누락됨", "꿈 얘기 시작하면 길게 공감하지 말고 짧게만". 교정이 반영된 합의가 state/personas/<chat_id>.md 에 박힌다.

이후 루프는 매 메시지마다 묻지 않는다. persona.md를 근거로 판단하고 바로 보낸다. 전송 후 한 줄 로그만 남긴다.

>  [맹구] "아 망했다""ㅋㅋ 왜" 보냄

승인 요청이 아니라 사후 통지다. 승인을 N번에서 1번으로 압축하는 게 이 설계에서 가장 중요한 결정이었다. 자율성과 안전의 트레이드오프는 양자택일이 아니다. 승인의 타이밍과 횟수를 설계하는 문제다. 매 cycle 에 한 번씩 승인받는 도구와 관계마다 한 번 승인받는 도구는 완전히 다른 물건이다.

그리고 이게 내가 본 2026 년 에이전트 UX 의 공통 패턴이다. 권한을 잘게 쪼개 매번 묻는 에이전트는 결국 안 쓰인다. 권한을 몇 개의 굵은 합의 로 모아두고 그 안에서 자율성을 주는 에이전트가 실제로 살아남는다. 굵은 합의는 사용자가 한 번 버티면 긴 자율이 따라오는 구조고, 잔 합의는 사용자가 매번 버티다가 포기하는 구조다. 같은 총 자율성이어도 UX 는 완전히 다르다.

계층은 만들지 않았다

이 프로젝트 설계에서 가장 오래 붙들고 있었던 건 4계층 Reflection Stack 이었다. Reflexion, Voyager, Generative Agents, MemGPT 를 인용했고, drift 방어를 세 층으로 짰다.

L1 Episode     raw 대화 로그
L2 Reflection  일일 요약
L3 Lesson      재사용 가능한 규칙
L4 Persona     실제 스타일 가이드 문서

설계 문서가 멋있게 나왔다. 논문 네 개 인용, 4계층 표, drift 방어 세 층. 근데 구현에 들어가니 L1 외에는 전부 쓸 일이 생긴 적이 없는 레이어였다. L2의 "세션 종료 시 요약" 은 뭘 요약해서 뭐에 쓸 건지 답이 안 나왔다. L3의 "여러 reflection 공통 패턴" 은 reflection 자체가 없으니 입력 데이터가 존재하지 않았다. L4의 "persona 머지 승인" 은 머지할 lesson이 없으면 의미가 없었다.

전부 미래에만 존재하는 기능이었다. 그런 건 기능이 아니라 계획이다. 계획을 기능으로 착각하는 건 에이전트 시스템 설계의 가장 흔한 오류고, 논문 인용은 그 착각을 구조화해주는 가장 정교한 도구다.

다 뺐다. 남은 건 둘이다. DB 에 쌓인 과거 대화, 그리고 persona.md. 에이전트는 매 cycle 에 최근 30개 컨텍스트를 당기고, persona.md 로 오버라이드를 얹는다. DB 에는 사실을, 파일에는 의도를, 합치는 건 판단 시점에만. 레이어 4개에서 2개로 내려왔고 기능은 하나도 안 잃었다. L2 부터 L4 는 기능이 아니라 계획이었으니까.

지운 뒤에 한참을 쳐다봤다. 그림이 너무 깨끗해져서 의심이 들었다. 정말 이게 다인가. 한참을 더 돌려봤는데 추가로 필요해진 게 없었다.

피드백 루프는 DB 가 한다

계층을 지운 직후 떠오른 질문이 있다. "학습은 어떻게 계속되지?" persona.md 는 calibration 때 한 번 박아두고 끝이다. 정적이다. 그럼 관계가 변하거나 말투가 바뀌면 에이전트는 뭘 보고 따라가나.

답은 DB 다. 매 cycle 에 에이전트가 db.py get-context --chat-id <id> --limit 30 으로 최근 30개 메시지를 당긴다. 이 30개는 고정 샘플이 아니라 sliding window 다. 새 메시지가 들어오면 윈도우가 앞으로 전진한다. 오늘 오간 농담이 다음 드래프트의 근거가 되고, 어제 바뀐 어투도 마찬가지다. 페르소나 파일이 아니라 DB 자체가 실시간 피드백 루프다. 답장을 보내는 순간 그 답장도 DB 에 적재되고, 다음 cycle 의 context window 에 포함된다. 에이전트는 자기가 방금 쓴 문장을 다음 판단의 입력으로 받는다.

이 구조의 함의는 두 개다. 첫째, 단기 변화는 별도 학습 파이프라인 없이 자동으로 따라간다. 레이어를 덧붙이지 않아도 된다. Reflection Stack 을 지울 수 있었던 진짜 이유가 여기다. 둘째, persona.md 의 역할이 명확해진다. "관찰되는 것 중 무엇을 덮어쓸지" 를 적는 오버라이드 문서다. DB 에 사실이, 파일에 의도가, 합치는 건 매 판단마다. 이 분리가 계층을 뺀 설계의 다른 한 면이다.

장기 드리프트는 사용자 주도다. 6개월 전 존댓말 쓰던 관계가 반말 섞는 관계로 변했는데 persona.md 가 여전히 존댓말 고정이면, 사용자가 "맹구 다시 분석해줘" 한 마디로 재-calibration 을 트리거한다. 에이전트가 다시 최근 대화를 분석해서 리포트를 내면 사용자가 교정하고, persona.md 가 갱신된다. 자동 드리프트 감지를 넣을 수도 있지만 감지 로직이 틀리면 원치 않는 방향으로 persona 가 움직인다. 단기는 자동, 장기는 수동. 이 경계를 어디에 긋는지가 이 시스템의 자기개선 전략의 전부다.

어댑터는 쉘 verb 로

에이전트가 플랫폼과 대화하는 경계를 어떻게 그을까. 카톡 의존은 한 파일에 격리했다. scripts/adapters/kakao.sh.

kakao.sh check                          → auth 살아있나
kakao.sh resolve                        → 채팅방 목록
kakao.sh history <id> <limit>           → 과거 메시지
kakao.sh poll <id> <since>              → 델타
kakao.sh send <display_name> <text>     → 전송

verb 다섯 개. 처음엔 Python 추상 클래스를 고민했다. 타입 안전성이나 mypy 통과 같은 게 눈에 밟혔다. 근데 실제로 호출하는 쪽은 에이전트고, 에이전트는 bash kakao.sh poll ... 을 호출하고 JSON 을 받는다. 공통 상위 클래스를 둬서 얻는 이득이 에이전트 관점에서 거의 없었다.

쉘 verb 는 계약이 stdout 과 exit code 와 JSON 으로 표현되니 언어 중립이다. Slack 어댑터는 Ruby 로 써도 되고, Discord 는 Go 로 써도 된다. 에이전트가 호출자인 시스템에서는 가장 싼 추상화가 가장 먼 곳까지 간다. 세션이라는 런타임과도 합이 맞는다. 어댑터는 JSON 을 내놓고, 에이전트가 그 위에서 판단하고, 다시 어댑터에 지시를 내린다. 그 이상의 바인딩이 필요 없다.

카톡은 입구가 없었다

어댑터 verb 다섯 개가 깔끔하게 떨어졌지만, 그 아래는 전혀 깔끔하지 않다.

먼저 이 도메인에는 공식 API 가 없다. 정확히 말하면 개인 메시지를 외부에서 읽거나 자유롭게 보낼 수 있는 공식 경로가 없다. Kakao 의 REST API 는 같은 서비스 내 메시지 전달만 허용하고, AlimTalk 같은 채널은 비즈니스 브랜드 전용이다. "내 AI 가 내 카톡을 읽고 답해준다" 는 시나리오는 문서화된 길이 아예 존재하지 않는다.

그래서 두 개의 비공식 경로를 직접 뚫어야 했다. 읽기는 로컬 암호화 DB 를 여는 것, 쓰기는 macOS 접근성 API 로 앱 UI 를 조작하는 것.

읽기부터 보자. 카톡 Mac 앱은 메시지를 SQLCipher 로 암호화해 저장한다. 키는 user_id 와 기기 UUID 로부터 PBKDF2 로 유도된다. 문제는 user_id 가 plist 에 날것으로 저장되지 않는다는 것이다. 어떤 계정은 후보 목록에 섞여 있고, 어떤 계정은 해시만 남아 있다. 해시만 남은 경우엔 최대 10억 범위를 병렬로 뒤져서 user_id 를 역산한다. 맥북 8코어 기준 수십 초. 이 전 과정이 _kakao_auth.py 한 파일 500 라인 안에 들어있다.

쓰기는 또 다른 벽이다. 공식 자동화 경로가 없으니 AppleScript 로 UI 를 직접 조작한다. 근데 한국어 IME 가 켜진 상태에서 글자를 찍으면 V 으로 날아간다. 그래서 텍스트를 직접 타이핑하지 않는다. 클립보드에 복사한 뒤 키코드 9 + Command 조합으로 paste 해서 IME 를 통째로 우회한다. 채팅방 row 는 UI tree 에서 이름으로 찾아 cliclick dc:x,y 로 더블클릭하고, 입력창 좌표는 창 하단 70px 에 고정한다. 전부 실험으로 뽑은 숫자다.

이 스택을 나 혼자 발명한 건 아니다. silver-flight-group 의 kakaocli 와 upstream auth 로직에서 필요한 부분만 떼왔고, IME 우회 AppleScript 를 저장소에 인라인화했다. 원본은 searchschema 같은 추가 기능이 있지만 이 도구엔 chatsmessages 두 개만 필요했으니 나머지는 잘랐다. 어댑터 아래 가장 얇은 충분조건이 무엇인지 정하는 일 자체가 설계였다.

에이전트는 이 위에 올라타서 사회성을 흉내낼 뿐이다.

전송은 한쪽으로 몰았다

UI 자동화 전송의 본질적 문제는 되돌릴 수 없다는 것이다. cliclick 으로 글자를 찍고 Enter 를 누르면 끝. 한 번 Enter 를 누르면 복구 불가. 최악의 시나리오는 중복 전송이다.

"못 보냈다" 와 "두 번 보냈다" 는 가치가 완전히 다르다. 사과 메시지가 두 번 나가면 관계가 더 이상해진다. 놓친 메시지는 사람이 재전송하면 그만이다.

drafted  --(phase 1: DB commit)-->  sending
sending  --(phase 2: adapter)---->  sent    (정상)
sending  --(phase 2 fail)--------->  failed
sending  --(process crash)-------->  (stuck)
(stuck)  --(next session recover)->  failed

프로세스가 phase 1 과 phase 2 사이에서 죽으면 행이 sending으로 남는다. 다음 세션 시작할 때 cycle.sh check 가 무조건 failed 로 마킹한다. AppleScript가 Enter 까지 성공했는지 알 길이 없으니, 진짜로 보냈다면 다음 poll에서 outbound로 되돌아오고, 안 보냈다면 그대로 실패로 남는다.

굳이굳이 이름을 붙이자면 safety-biased reconciliation. 어느 실패가 덜 나쁜가를 명시적으로 정하고, 그 방향으로 편향되게 짜는 것. 에이전트가 사람 대신 메시지를 보내는 시스템에서 이 비대칭을 설계에 박아두는게 꼭 필요하다고 생각했다.

아직 안 풀린 것들

동시에 Mac 을 쓰는 상황이 제일 애매하다. AppleScript 가 입력창에 글자를 찍는 순간 사용자가 타자 치고 있으면 경쟁이 발생한다. 지금은 그냥 둔다. 단체방 발신자 구분도 걸린다. sender 필드는 있지만, 에이전트가 "A가 나한테 한 말" 과 "B가 C에게 한 말" 을 신뢰성 있게 분리하는지는 실전 데이터가 더 쌓여봐야 안다.

조금 더 근본적인 문제도 있다. "답 안 하는 게 나았다" 가 반복되면 에이전트가 침묵에 overfit 할 가능성. baseline 응답률이 시간에 따라 drift하면 이걸 식별하는 것 자체가 어려워진다. 그리고 장기 드리프트 감지 자동화 — 6개월 단위로 관계의 결이 바뀌는 걸 사용자가 알아차리지 못하면 re-calibration도 안 돌아간다. DB 의 단기 피드백 루프는 잡지만, "언제 다시 맞춰야 하나" 를 시스템이 먼저 제안하는 구조는 아직 안 만들었다.

마무리

몇 년 전 유행은 "LLM 을 잘 프롬프트하는 법"이었다. 지금은 "에이전트 시스템을 잘 배치하는 법"이다. 프레임이 완전히 바뀌었다.

답장 문장을 잘 뽑는 건 LLM이 잘 해줄것이다. 이 프로젝트에서 내가 한 일은 그 문장이 실제로 나갈 때까지의 경로를 얇게 만드는 것이었다. 모델 능력은 건드리지 않았다. 런타임, 수명, 컨텍스트 경계, 승인 지점, 어댑터 계약. 이 다섯 축을 어디에 고정할지만 결정했다.


이 글에서 다룬 프로젝트: auto-kakaotalk