본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 05. 26. 22:46

AI 에이전트 시대의 아키텍처: 특정 모델에 의존하지 않는 설계와 테스트 전략 소개

요약

Slack을 작업 환경으로 활용하는 AI 에이전트 구축 사례를 통해, 특정 모델이나 CLI에 종속되지 않는 추상화 설계의 중요성을 설명합니다. LLM 어댑터 레이어를 도입하여 Claude Code에서 Codex 등으로 유연하게 교체할 수 있는 아키텍처 전략을 제안합니다.

핵심 포인트

  • 특정 AI 벤더에 종속되지 않는 LLM 어댑터 레이어 설계 필요
  • Slack을 컨텍스트 관리 및 작업 로그 저장소로 활용
  • 모델 변경 및 비용 변화에 대응하기 위한 추상화의 중요성
  • 용도별 Skills 주입을 통한 컨텍스트 절약 메커니즘

AI 에이전트의 AI 모델은 가능한 한 교체 가능하도록 만들어 두는 것이 좋다는 이야기에 대해 쓰겠습니다.

서론

저는 Slack에서 호출할 수 있는 AI 에이전트를 운용하고 있습니다. 처음에는 Claude Code를 Slack에서 사용할 수 있다면 편리하겠다 정도의 가벼운 마음이었습니다.

하지만 사용하다 보니 역할이 조금씩 변해갔습니다. Web 앱 개발, Flutter, 스마트 글래스(Even G2)용 앱 개발, 연구 파이프라인 실행 등, 개인 개발의 입구가 Slack으로 쏠리게 되었습니다. 정신을 차려보니 이것은 단순한 채팅 UI가 아니라, 제 작업 환경의 일부가 되어 있었습니다.

그 과정에서 실감한 것이, 특정 AI 벤더나 CLI에 어디까지 의존해도 되는가 하는 점입니다. 이 기사에서는 직접 만든 Slack AI bot에 LLM 어댑터(LLM adapter)라는 추상화 레이어를 만들어, Claude Code와 Codex CLI를 교체 가능하게 만든 이야기를 하겠습니다.

Slack이 작업 환경이 되었다

현재 운용 중인 AI 에이전트는 자택의 Mac mini 위에서 동작하고 있습니다.

Slack에서 bot을 멘션하면, Mac mini 위의 worker.py가 요청을 받아 Claude Code를 실행하고, Slack 스레드에 답변합니다.

↓ 실제 모습은 다음과 같습니다 (두 번째는 AI가 자율적으로 움직이는 예시)

이것만 보면 직접 CLI로 Claude Code를 호출하거나, /loop 또는 Agent Teams와 같은 메커니즘을 사용하면 되지 않느냐고 생각할 수도 있겠지만, Slack을 입구로 삼으면 성격이 조금 달라집니다. 채널이 작업 컨텍스트(Context)가 되고, 스레드가 작업 로그가 되며, 알림이 그대로 완료 보고가 됩니다.

또한, 이 bot은 현재 시점에서 60개 이상의 Skills를 가지고 있습니다. Web 앱 개발, 스마트폰 앱 개발, 스마트 글래스(Even G2) 앱 개발, 연구 등 용도에 따라 Slack 채널을 나누고, 필요한 Skills만 주입하는 메커니즘을 직접 만듦으로써 컨텍스트 절약(Context saving)으로도 이어지고 있습니다 [1].

이 정도로 사용하게 되면, AI 에이전트는 '편리한 도구'에서 '일상적으로 의존하는 작업 기반'에 가까워집니다. 그러면 편리함과는 별개의 문제가 보이기 시작합니다.

편리해질수록 이전하기 어려워진다

Claude Code는 매우 편리합니다. 코딩에 강하고, 도구 실행도 가능하며, 세션 지속도 가능하고, Skills와 같은 메커니즘도 있습니다. 일반적인 사용 측면에서는 상당히 쾌적합니다.

반면, 편리한 기능을 그대로 애플리케이션의 중심에 두면 조금씩 이전하기 어려워집니다.

예를 들어, 다음과 같은 변화는 흔히 일어납니다.

  • 가격 체계나 이용 조건이 변경됨
  • 코딩에 강한 모델의 세력도가 변함
  • CLI나 SDK의 입출력 사양이 변경됨
  • 어떤 모델에서는 사용할 수 있는 기능이 다른 모델에서는 사용할 수 없게 됨

마침 5월 중순에는 Claude Code를 비대화형(Headless)으로 실행하는 claude -p가 구독 이용에서 제외된다는 발표가 있어, 실질적으로 가격 인상이 되었다는 점이 큰 화제가 되었습니다 [2].

이를 계기로 저도 Slack bot의 기반 에이전트를 Codex로 변경하는 작업을 시작했습니다.

하지만 LLM은 진화 속도가 빠릅니다. 어떤 시기에는 Codex가 제 용도에 맞더라도, 다른 시기에는 Gemini CLI가 더 사용하기 쉬울지도 모릅니다. 앞으로는 Qwen 계열이나 DeepSeek 계열과 같은 오픈 웨이트(Open-weight) 모델을 로컬 또는 자체 GPU에서 사용하는 선택지도 늘어날 것이라고 생각합니다.

이때, AI 에이전트의 내부가 Claude Code의 출력 형식이나 동작에 강하게 의존하고 있다면, 단순히 '모델을 바꾸고 싶다'는 이유만으로 Slack 표시, 도구 호출, 세션 관리, 에러 처리까지 모두 수정해야 하며, 미세한 동작을 확인하며 계속 디버깅해야 하는 상황에 놓이게 됩니다.

과제는 '모델'보다 '주변 사양'에 있다

일반적인 프로덕트에서 AI 모델을 교체한다고 하면, 단순히 API 키나 모델 이름을 바꾸는 것처럼 보입니다.

하지만 AI 에이전트의 경우에는 그렇게 단순하지 않습니다. 모델 본체뿐만 아니라 그 주변에 있는 사양도 함께 변하기 때문입니다.

예를 들어, Claude Code와 Codex CLI는 스트리밍 출력 형식이 다릅니다. 도구 호출(Tool calling)의 표현 방식도 다릅니다. thinking 블록의 유무도 다릅니다. 세션 지속 방법도 다릅니다.

bot 측에서 바라볼 때, 원하는 것은 단순합니다.

  • 텍스트가 조금씩 반환됨
  • 도구(Tool)를 실행했음을 알 수 있음
  • 실행이 성공했는지 실패했는지 알 수 있음
  • 다음 대화에 사용할 세션 ID를 알 수 있음

반면, 이것들을 추출하는 방법은 모델이나 CLI에 따라 다릅니다.

즉, 교체하고 싶은 것은 '모델명'뿐만 아니라, '모델을 사용했을 때 발생하는 이벤트의 해석' 그 자체입니다.

이 부분을 bot 본체에 섞어버리면, LLM을 교체할 때마다 대대적인 수정이 수반되어 복잡해집니다. 그래서 이번에는 변경 용이성(Changeability)을 높이기 위해, 모델 고유의 사양을 외부로 밀어내는 아키텍처 구축에 착수했습니다.

AI 모델의 사양을 클린 아키텍처(Clean Architecture)의 '상세 구현(Detail)'으로 다루기

여기서 사용한 사고방식은 클린 아키텍처의 의존성 규칙(Dependency Rule)입니다. 소스 코드의 의존성은 안쪽을 향하며, 유스케이스(Use Case)는 바깥쪽 원에 있는 구체적인 기술이나 데이터 포맷을 알지 못하도록 합니다.

웹 애플리케이션으로 생각하면 이해하기 쉬울 것입니다.

예를 들어, 유스케이스가 MySQL의 SQL 문이나 DynamoDB의 SDK 호출을 직접 알고 있다고 가정해 봅시다. 이 경우, DB를 교체하고 싶을 뿐인데 유스케이스의 내용까지 변경해야 하는 상황이 발생합니다.

따라서 유스케이스 측에는 Repository나 Gateway 인터페이스를 두고, MySQL이나 DynamoDB의 구체적인 구현은 외부에 둡니다. 유스케이스는 추상화된 내용만 알 뿐, 어떤 DB에서 읽는지는 알지 못합니다.

AI 에이전트에서도 동일한 일이 일어나고 있다고 생각했습니다.

Claude Code나 Codex CLI는 편리하지만, bot의 유스케이스 관점에서 보면 그것들의 호출 방식, 스트리밍(Streaming) 형식, 도구 호출(Tool calling) 표현, 세션 지속 방법, 에러 표현은 모두 '상세 구현(Detail)'입니다. 다시 말해, Frameworks & Drivers 측에 두어야 할 관심사입니다.

이 설계에서는 AI 모델의 CLI를 직접 다루는 처리를 유스케이스에서 분리하여, LLM 어댑터(LLM Adapter)로 취급합니다. LLM 어댑터의 역할은 Claude Code나 Codex CLI와 같은 외부의 데이터 포맷을 bot 내부의 공통 이벤트로 변환하는 것입니다. 이 글에서는 이 공통 인터페이스를 LLMAdapter로, bot 내부에서 다루는 공통 이벤트를 LLMEvent라고 부르기로 하겠습니다. 용어 측면에서는 Interface Adapter / Gateway에 가까운 역할입니다.

  • bot 본체는 Claude Code나 Codex CLI의 출력 형식을 알지 못함
  • 각 LLM 어댑터가 모델이나 CLI 고유의 출력을 공통 이벤트로 변환함
  • bot 본체는 공통 이벤트만을 보고 Slack에 표시함

그림: Use Case 관점에서 본, DB와 AI 에이전트의 의존 관계를 단순화하여 비교

Claude Code용 LLM 어댑터는 claude -p --output-format stream-json의 출력을 읽어 bot이 다루기 쉬운 이벤트로 변환합니다. Codex CLI용 LLM 어댑터는 codex exec --json의 출력을 읽어 동일한 이벤트 형식으로 변환합니다.

이를 통해 외부의 데이터 포맷이 바뀌더라도 그 영향을 LLM 어댑터 측에 가두기 쉬워집니다. AI 모델이나 CLI를 교체할 때도 유스케이스나 Slack 표시 로직에 미치는 영향을 최소화할 수 있습니다.

모든 것을 똑같이 다루는 것은 아니다

다만, 추상화할 때 '모든 모델을 완전히 동일한 것으로 다룰' 필요는 없습니다.

Claude Code에서는 thinking 블록이나 SSH를 통한 원격 실행을 지원할 수 있습니다. 반면, Codex CLI나 오픈 웨이트(Open weights) LLM에서는 동일한 방법으로 다룰 수 없는 기능도 있습니다. 이미지 입력을 지원하는지, 세션 지속을 지원하는지도 LLM 어댑터마다 다릅니다.

그래서 각 모델이 지원하는 기능을 manifest.toml에 선언합니다.

[capability]
supports_thinking = true
supports_resume = true
...

bot 본체는 이 capability를 보고 기능을 활성화할지 여부를 결정합니다.

예를 들어, 원격 실행 (Remote Execution)이 필요한 Skill이 선택되었을 때, 현재의 LLM 어댑터가 supports_remote_exec = false라면, 해당 기능을 지원하지 않음을 즉시 반환합니다. 만약 supports_resume = true라면 이전의 session_id를 사용하여 대화를 계속합니다.

LLM 어댑터별 차이점을 manifest에 명시해 두면, "이 모델에서는 무엇이 가능하고 무엇이 불가능한가"를 한눈에 볼 수 있습니다. 모든 모델에서 동일한 경험을 완벽하게 재현하는 것이 아니라, 각 모델이 대응하는 능력을 명시하고 그 범위 내에서 교체할 수 있도록 하는 방식입니다.

LLM 어댑터의 계약 (Contract)을 테스트하기

추상화를 하더라도, 실제로 교체했을 때 동작하지 않는다면 의미가 없습니다.

이 부분 역시 DB나 외부 API를 교체하는 것과 동일하게 생각할 수 있습니다.

예를 들어 UserRepository라는 인터페이스를 만들었다고 해도, MySQL 구현과 DynamoDB 구현에서 반환하는 값의 형태나 에러 처리 방식이 다르다면, 결국 유스케이스 (Use Case) 측이 망가집니다. 추상화를 만드는 것만으로는 불충분하며, "그 구현이 추상의 계약을 충족하는가"를 확인해야 합니다.

AI 모델도 마찬가지입니다.

다만, 여기서 테스트하고자 하는 것은 LLM의 답변 내용 그 자체가 아닙니다. LLM은 비결정적 (Non-deterministic)이기 때문에, 자연어 출력을 완전히 고정하는 것은 어렵습니다. 테스트하고 싶은 것은 각 LLM 어댑터가 LLMAdapter로서 기대되는 입출력 계약을 충족하는가입니다.

이 bot에서는 각 LLM 어댑터에 대해 계약 테스트 (Contract Test)를 준비해 두었습니다. 여기서 말하는 계약 테스트는 각 구현이 LLMAdapter의 계약에 따라 공통의 LLMEvent를 반환할 수 있는지 확인하는 테스트입니다. 예를 들어 다음과 같은 사항을 검증합니다.

  • TextDeltaResultEvent가 반환되는가
  • 치명적인 에러가 예외로 누락되지 않고 ErrorEvent로 반환되는가
  • 스트리밍 (Streaming) 대응 LLM 어댑터의 경우, 완료 전에 진행 이벤트가 반환되는가
  • resume 대응 LLM 어댑터의 경우, 이전 세션을 계속할 수 있는가
  • 도구 호출 (Tool Call)이 ToolUseStart / ToolUseStop으로 대응되어 반환되는가

이것들은 실제로 LLM을 구동하여 테스트합니다.

또한, 여기서도 manifest.toml이 역할을 합니다. manifest.toml은 해당 어댑터가 계약의 어느 부분에 대응하는지를 선언하는 것입니다.

예를 들어 supports_streaming = false인 LLM 어댑터의 경우, 스트리밍 테스트는 건너뜁니다 (skip). supports_images = true라면 이미지 입력 테스트를 실행합니다.

즉, "이 모델은 이 기능에 대응한다"라고 선언했다면, 그 기능이 실제로 계약대로 동작하는지를 테스트를 통해 확인할 수 있습니다.

이를 통해 새로운 LLM 어댑터를 추가할 때의 부담이 크게 줄어듭니다. Qwen 계열이나 DeepSeek 계열 등의 LLM 어댑터를 추가할 때도 manifest.toml을 작성하고, LLMAdapter를 구현하며, 계약 테스트를 통과시키는 흐름으로 진행할 수 있습니다.

새로운 AI 모델을 추가할 때는 "이 테스트를 통과할 수 있도록 LLM 어댑터를 구현해줘"라고 AI에게 요청할 수도 있습니다.

추상화를 통해 보인 경계

이번 구현을 통해 정리할 수 있었던 것은 bot 본체 관점에서의 실행 이벤트 교체입니다.

한편으로는 추상화를 하더라도 남는 차이점이 있습니다. 전달된 Skills를 얼마나 적절하게 읽을 수 있는지, 긴 태스크를 어디까지 자율적으로 진행할 수 있는지, 도구 실행 후에 스스로 상황을 재정비할 수 있는지와 같은 동작은 모델마다 다릅니다.

이러한 동작의 차이는 이벤트 형식을 맞추는 것만으로는 사라지지 않습니다. 계약 테스트를 통해 최소한의 I/O는 확인할 수 있지만, 태스크 수행 능력이나 지시 이행 능력 (Instruction Following)까지 완전히 고정할 수는 없습니다.

모델을 교체할 수 있게 한다는 것이 무엇이든 쉽게 갈아 끼울 수 있다는 이야기로 들릴지도 모릅니다. 하지만 실제로는 "설계로 흡수할 수 있는 부분"과 "모델 선정이나 운영에서 받아들여야 하는 부분"을 나누어 이해할 수 있도록 하는 이야기이기도 합니다.

이 경계를 보는 것만으로도 새로운 모델을 시도할 때, 어디를 구현으로 맞추고 어디를 평가나 운영에서 살펴봐야 할지 판단하기 쉬워질 것이라고 생각합니다.

마치며

AI 에이전트가 커질수록 특정 AI 모델이나 벤더에 대한 의존도는 무거워집니다.

Claude Code는 편리하고, Codex CLI도 편리합니다. 앞으로는 로컬 LLM (Large Language Model)도 더욱 현실적이 될 것이라고 생각합니다. 그렇기 때문에, 애플리케이션 전체를 어느 하나에 의존시키기보다는, 모델을 교체 가능한 외부 의존성 (External Dependency)으로 취급할 수 있다면 안심할 수 있습니다.

저의 bot에서는 LLMAdapter, LLMEvent, Capability manifest, 그리고 계약 테스트 (Contract Testing)라는 형태로 그를 위한 토대를 만들었습니다.

이번에 만든 메커니즘으로 모든 모델을 동일하게 다룰 수 있는 것은 아닙니다. 그럼에도 불구하고, 모델 고유의 차이점을 외곽으로 밀어내고, 대응하는 기능을 명시하며, 계약 테스트로 확인할 수 있게 됨으로써 상당히 다루기 쉬워졌습니다.

AI 에이전트 시대의 아키텍처에서는 AI 모델을 핵심 (Core)이 아닌 외부의 상세 구현 (Detail)으로 취급한다. 이 사고방식은 앞으로 더욱 중요해질 것이라고 생각합니다.

모델 교체가 가능한 설계로 해두면, 새로운 모델이 나올 때마다 "또 전부 다시 만들어야 하나..."가 아니라, "그럼 LLM 어댑터(LLM Adapter)를 하나 추가할까"라고 말할 수 있습니다. 정신 건강에도 좋습니다.

보충: 구현 측면에서는 어떻게 하고 있는가

구현상의 대응을 간단히 적어둡니다.

LLM 어댑터는 AI 모델이나 CLI를 호출하기 위한 공통 인터페이스입니다. 기사상에서는 LLMAdapter라고 부릅니다. bot 본체는 LLMAdapter.invoke()를 호출하고, 공통의 LLMEvent를 받습니다.

의존 방향으로 보면 다음과 같습니다. bot 본체는 구체적인 Claude Code나 Codex CLI에 의존하지 않고, LLMAdapter라는 추상화만을 호출합니다. 구체적인 모델이나 CLI로의 연결은 그 외곽에 있는 어댑터(Adapter) 구현이 담당합니다.

각 LLM 어댑터는 모델 고유의 출력을 다음과 같은 이벤트 (LLMEvent)로 변환합니다.

이벤트내용
TextDelta텍스트의 차분 (Delta)
ToolUseStart도구 호출 (Tool Call) 시작
ToolUseInputDelta도구 인자 (Argument)의 차분
ToolUseStop도구 호출 완료
ThinkingStart / ThinkingDelta / ThinkingStopthinking 블록
ResultEvent실행 완료
ErrorEvent치명적 에러

Claude Code 고유의 claude -p --output-format stream-json 파싱은 Claude Code용 LLM 어댑터에 가둡니다. Codex CLI 고유의 codex exec --json 파싱은 Codex CLI용 LLM 어댑터에 가둡니다.

실제로는 전후에 hooks를 삽입하고 싶다거나, 사용자 확인이 필요한 조작을 어떻게 다룰 것인가... 와 같은 유스케이스(Use Case)에 따라 그 외에도 다양한 이벤트가 생각될 수 있습니다. 어떤 경우든 bot 본체 입장에서 본 CLI의 출력이나 상태는 TextDeltaToolUseStart 등의 공통 이벤트로서 추가하고 다룹니다.

Skills가 늘어날수록 컨텍스트 비대화뿐만 아니라 다중 레이블 분류 문제도 발생하며, 노이즈로 인한 라우팅 (Routing) 정밀도 저하도 일어날 수 있다고 생각합니다. 참고: https://note.com/engineers_hub/n/n2c0f588c1924 ↩︎

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0