포스트

소프트웨어 3.0 시대를 맞이하며: 개발 패러다임의 근본적 전환

소프트웨어 3.0 시대를 맞이하며: 개발 패러다임의 근본적 전환

관련글

소프트웨어 3.0 시대를 맞이하며

들어가며: 우리는 지금 어디에 서 있는가

2025년 6월, 테슬라의 전 AI 디렉터이자 현재 가장 영향력 있는 AI 연구자 중 한 명인 Andrej Karpathy는 Y Combinator AI Startup School에서 소프트웨어 역사를 관통하는 중요한 프레임워크를 제시했습니다. 그는 소프트웨어의 진화를 세 개의 명확한 시대로 구분하며, 우리가 지금 세 번째 패러다임으로 진입하고 있다고 선언했습니다. 이것은 단순한 기술 트렌드의 변화가 아닙니다. 개발자가 소프트웨어를 만드는 방식, 생각하는 방식, 그리고 궁극적으로 개발자라는 직업의 본질 자체가 변화하는 역사적 순간입니다.

토스페이먼츠의 Node.js 개발자 김용성은 이 변화를 현장에서 직접 체감하며, Software 3.0 시대를 맞이하는 개발자들이 어떻게 기존 지식을 활용하면서도 새로운 패러다임에 적응할 수 있는지에 대한 실용적인 가이드를 제시합니다. 이 글은 그의 통찰을 바탕으로 Software 3.0의 의미를 깊이 있게 탐구하고, 실무에 적용할 수 있는 구체적인 방법론을 제시합니다.

소프트웨어의 세 시대: How에서 What으로의 여정

Software 1.0: How의 시대 - 명시적 로직의 세계

Software 1.0은 우리가 수십 년간 익숙하게 다뤄온 전통적인 프로그래밍 방식입니다. 개발자는 Python, Java, C++과 같은 프로그래밍 언어로 명시적인 로직을 작성합니다. 이 시대의 핵심 질문은 “어떻게(How)”입니다. 개발자는 컴퓨터에게 문제를 해결하는 정확한 방법을 단계별로 지시해야 합니다.

if-else 문으로 조건을 분기하고, for 루프로 반복을 제어하며, 함수와 클래스로 로직을 추상화합니다. 모든 예외 상황을 미리 예측하고 처리해야 하며, 엣지 케이스마다 명시적인 코드를 작성해야 합니다. 이것은 매우 명확하고 예측 가능한 방식이지만, 동시에 엄청난 노동 집약적 작업이기도 합니다. 개발자는 문제를 이해할 뿐만 아니라, 그것을 해결하는 모든 단계를 컴퓨터가 이해할 수 있는 형태로 번역해야 합니다.

Software 1.0의 강점은 명확성과 제어 가능성에 있습니다. 코드는 결정론적(deterministic)이며, 같은 입력에 대해 항상 같은 출력을 보장합니다. 디버깅이 가능하고, 코드 리뷰를 통해 품질을 관리할 수 있으며, 버전 관리 시스템으로 변경 사항을 추적할 수 있습니다. 하지만 이러한 강점이 동시에 한계이기도 합니다. 복잡도가 증가할수록 코드의 양은 기하급수적으로 늘어나고, 유지보수 비용은 상승합니다.

Software 2.0: Show의 시대 - 데이터가 프로그램이 되다

2010년대 딥러닝의 급격한 발전과 함께 Software 2.0 시대가 열렸습니다. 이 패러다임에서는 개발자가 더 이상 규칙을 직접 작성하지 않습니다. 대신 데이터를 수집하고 모델을 학습시키면, 신경망의 가중치(weights)가 곧 프로그램이 됩니다. 핵심 질문이 “어떻게(How)”에서 “보여주기(Show)”로 바뀐 것입니다.

Andrej Karpathy가 직접 경험한 테슬라 오토파일럿의 사례는 이 변화를 극적으로 보여줍니다. 초기 오토파일럿은 Software 1.0 방식으로 개발되었습니다. 차선을 감지하는 수천 줄의 C++ 코드, 물체를 분류하는 복잡한 if-else 분기문, 상황별 예외 처리 로직 등이 빼곡했습니다. 하지만 점차 이 모든 코드가 신경망으로 대체되었습니다. 수많은 주행 데이터를 학습한 신경망은 명시적인 규칙 없이도 차선을 인식하고, 물체를 분류하며, 적절한 주행 결정을 내릴 수 있게 되었습니다.

Software 2.0의 혁명은 규칙을 명시하기 어려운 영역에서 특히 강력합니다. 이미지 인식, 음성 처리, 자연어 이해 같은 분야에서 인간은 직관적으로 할 수 있지만 명시적인 규칙으로 표현하기 어려운 작업들을 신경망이 데이터로부터 학습할 수 있게 되었습니다. 개발자의 역할은 알고리즘을 작성하는 것에서 데이터를 큐레이션하고, 모델 아키텍처를 설계하며, 학습 과정을 최적화하는 것으로 변화했습니다.

Software 3.0: What의 시대 - 자연어가 프로그램이 되다

그리고 지금, 우리는 Software 3.0 시대로 진입하고 있습니다. 대규모 언어 모델(LLM)의 등장으로 개발의 본질이 다시 한번 근본적으로 변화하고 있습니다. 이제 개발자는 LLM에게 자연어로 “무엇을(What)” 원하는지만 말하면 됩니다. 프롬프트 자체가 프로그램이 되는 시대입니다.

Karpathy는 이를 “Software 3.0 is eating 1.0/2.0”라고 표현했습니다. 새로운 패러다임이 기존의 것들을 집어삼키고 있다는 것입니다. 하지만 이것은 단순한 대체가 아닙니다. Software 1.0의 명확성과 제어 가능성, Software 2.0의 학습 능력이 사라지는 것이 아니라, 더 높은 추상화 계층에서 통합되고 있습니다.

Software 3.0에서 개발자는 코드를 한 줄 한 줄 작성하는 대신, LLM과 대화하며 원하는 결과를 얻어냅니다. “이 API의 응답 시간을 개선해줘”, “이 함수에 대한 단위 테스트를 작성해줘”, “이 코드의 보안 취약점을 찾아줘”와 같은 자연어 지시가 가능해집니다. 하지만 여기에는 중요한 전제가 있습니다. LLM이 실제로 작업을 수행할 수 있도록 하는 인프라, 즉 “Harness”가 필요하다는 것입니다.

Harness: LLM의 잠재력을 현실의 힘으로 만드는 것

말의 힘을 인간이 쓸 수 있게 만든 마구(馬具)

Harness라는 단어는 원래 ‘마구(馬具)’를 의미합니다. 말은 강력한 동물이지만, 마구 없이는 그 힘을 인간이 활용할 수 없습니다. 말이 아무리 빠르고 강해도, 재갈, 굴레, 안장이 없다면 인간은 그 힘을 제어할 수도, 특정 방향으로 이끌 수도, 짐을 운반하는 데 활용할 수도 없습니다. 마구는 단순한 제약이 아닙니다. 오히려 말의 잠재력을 현실의 유용한 힘으로 변환하는 중요한 인터페이스입니다.

산업혁명 시대의 증기기관도 마찬가지였습니다. 증기기관 자체는 강력한 에너지원이었지만, 이것이 실제 산업 현장에서 가치를 만들어내려면 공장 시스템이라는 Harness가 필요했습니다. 기어, 벨트, 전동 장치를 통해 증기기관의 회전력이 방적기, 직조기, 작업 기계로 전달되었고, 이를 통해 산업혁명이 일어날 수 있었습니다.

LLM이 직면한 근본적인 한계들

LLM도 정확히 같은 상황에 있습니다. ChatGPT에게 “우리 서비스의 버그를 고쳐줘”라고 말한다고 해서 마법처럼 문제가 해결되지는 않습니다. LLM은 텍스트를 생성하는 데 있어서는 놀라운 능력을 보이지만, 그 자체만으로는 현실 세계와 상호작용할 방법이 없기 때문입니다.

LLM의 근본적인 한계를 살펴보면 명확해집니다. 첫째, Context Window의 제한이 있습니다. 아무리 큰 모델이라도 한 번에 처리할 수 있는 정보량에는 한계가 있습니다. 200K 토큰이 많아 보이지만, 대규모 코드베이스를 다루거나 복잡한 작업을 수행하려면 순식간에 부족해집니다.

둘째, 환각(Hallucination) 문제가 있습니다. LLM은 확률적으로 텍스트를 생성하기 때문에, 때로는 그럴듯하지만 사실이 아닌 정보를 매우 자신 있게 제시합니다. 이것은 단순한 버그가 아니라 현재 LLM 기술의 근본적인 특성입니다.

셋째, 도메인 지식의 한계가 있습니다. 일반적인 프로그래밍 지식은 풍부하지만, 특정 회사의 코드베이스, 비즈니스 로직, 내부 규칙 등은 알 수 없습니다.

넷째, 상태 관리가 불가능합니다. LLM은 기본적으로 stateless합니다. 이전 대화를 기억하거나, 작업의 진행 상황을 추적하거나, 여러 단계의 작업을 조율하는 능력이 본질적으로 없습니다.

다섯째, 외부 시스템 접근이 불가능합니다. 파일을 읽거나 쓸 수 없고, API를 호출할 수 없으며, 데이터베이스에 접근할 수 없고, 터미널 명령을 실행할 수 없습니다.

Harness가 제공하는 것들

바로 이 지점에서 Harness가 필요합니다. Harness는 LLM의 이러한 한계들을 보완하고, 실제 업무 환경과 연결해주는 도구와 인프라의 총체입니다.

Memory 관리 시스템은 Context Window의 제한을 극복합니다. 전체 대화 내역을 요약하고, 중요한 정보를 별도로 저장하며, 필요할 때 관련 컨텍스트를 검색해서 제공합니다.

RAG(Retrieval-Augmented Generation)와 Fact Grounding은 환각 문제를 완화합니다. LLM이 답변을 생성하기 전에 신뢰할 수 있는 소스에서 정보를 검색하고, 그 정보를 기반으로 답변하도록 강제합니다.

Knowledge Base는 도메인 특화 지식을 제공합니다. 회사의 코딩 컨벤션, API 문서, 아키텍처 가이드라인 등을 구조화해서 LLM이 참조할 수 있게 만듭니다.

Session과 Orchestration 시스템은 상태 관리를 가능하게 합니다. 여러 단계로 이루어진 복잡한 워크플로우를 추적하고, 각 단계의 결과를 다음 단계로 전달하며, 실패 시 복구 전략을 수행합니다.

Tool과 MCP(Model Context Protocol)는 외부 시스템과의 연결을 제공합니다. LLM이 파일 시스템에 접근하고, API를 호출하며, 데이터베이스를 쿼리하고, 터미널 명령을 실행할 수 있게 합니다.

Claude Code: Harness의 구체적 구현체

Claude Code가 제공하는 실제 능력들

Anthropic의 Claude Code는 Harness 개념을 실제로 구현한 대표적인 사례입니다. 이것은 단순한 코딩 어시스턴트가 아니라, Claude 모델을 실제 개발 환경에서 작동하는 에이전트로 만들어주는 종합적인 인프라입니다.

파일 시스템 접근 기능을 통해 Claude는 프로젝트의 코드를 읽고, 수정하고, 새로운 파일을 생성할 수 있습니다. 단순히 코드 조각을 제안하는 것이 아니라, 실제 파일에 변경사항을 적용합니다.

터미널 실행 기능을 통해 Claude는 npm install, git commit, pytest 같은 명령어를 직접 실행할 수 있습니다. 테스트를 실행하고 결과를 확인하며, 빌드 에러를 보고 스스로 수정을 시도합니다.

MCP(Model Context Protocol)를 통해 Claude는 외부 시스템과 연결됩니다. 데이터베이스를 쿼리하고, REST API를 호출하며, Slack에 메시지를 보내고, Jira에서 이슈를 관리할 수 있습니다.

Sub-agent 시스템을 통해 복잡한 작업을 여러 작은 작업으로 분할합니다. 각 Sub-agent는 독립적인 Context를 가지고 있어, 마치 여러 개발자가 병렬로 작업하듯이 효율적으로 작업을 진행합니다.

Slash Command는 사용자 의도를 명확하게 라우팅합니다. /review를 입력하면 코드 리뷰 워크플로우가 실행되고, /refactor를 입력하면 리팩토링 워크플로우가 시작됩니다.

Skills는 재사용 가능한 기능 단위로, 특정 작업을 수행하는 방법을 캡슐화합니다. “코드 리뷰하기”, “테스트 생성하기”, “문서 작성하기” 같은 일반적인 작업들이 Skill로 정의되어 있어, 여러 프로젝트에서 일관되게 사용할 수 있습니다.

Hooks는 이벤트 기반 자동화를 가능하게 합니다. Git commit 전에 자동으로 린트 검사를 실행하거나, PR이 생성될 때 자동으로 리뷰를 수행하는 등의 작업이 가능합니다.

Harness로서의 Claude Code

이 모든 기능을 종합하면, Claude Code는 본질적으로 Claude 모델을 위한 Harness입니다. 말에게 마구를 채우듯이, 강력하지만 제한적인 LLM에게 현실 세계와 상호작용할 수 있는 능력을 부여하는 것입니다.

증기기관이 공장 시스템이라는 Harness를 통해 산업혁명을 일으켰듯이, LLM은 Claude Code 같은 Harness를 통해 소프트웨어 개발의 혁명을 일으키고 있습니다. 이것은 도구의 변화를 넘어 개발 프로세스 자체의 근본적인 재구성입니다.

익숙함 속의 새로움: Software 1.0의 렌즈로 3.0 바라보기

낯선 용어들의 홍수

MCP, Skills, Sub-agent, Slash Command, Hooks… Claude Code의 문서를 처음 접하는 개발자는 새로운 용어의 홍수에 압도당할 수 있습니다. “이걸 다 새로 배워야 하는 건가?”라는 막연한 불안감이 들 수도 있습니다. 하지만 여기에 중요한 통찰이 있습니다. 이 구조를 자세히 들여다보면, 우리가 수십 년간 사용해 온 레이어드 아키텍처와 놀라울 정도로 유사하다는 것을 발견하게 됩니다.

실제로 전통적인 웹 애플리케이션의 레이어 구조와 Claude Code의 구조를 나란히 놓고 비교해보면, 거의 일대일 대응이 됩니다. 이것은 우연이 아닙니다. 좋은 소프트웨어 아키텍처의 원칙은 기술이 바뀌어도 그대로 유효하기 때문입니다.

Slash Command = Controller / API Route

전통적인 웹 애플리케이션에서 Controller는 사용자 요청의 진입점입니다. Spring의 @RestController, Express의 router.get(), Django의 View가 모두 같은 역할을 합니다. HTTP 요청을 받아서, 적절한 서비스 레이어로 라우팅하고, 결과를 반환합니다.

Claude Code의 Slash Command도 정확히 같은 역할을 합니다. 사용자가 /review PR-1234를 입력하면, 이것은 리뷰 워크플로우로 라우팅됩니다. /refactor src/utils를 입력하면 리팩토링 워크플로우가 실행됩니다. 사용자의 의도를 파악하고, 그에 맞는 처리 로직으로 연결하는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# 전통 웹 애플리케이션
POST /api/reviews
→ ReviewController.createReview()
→ ReviewService.processReview()
→ 여러 Repository와 상호작용
→ 결과 반환

# Claude Code
/review PR-1234
→ review 워크플로우 트리거
→ Sub-agent와 Skill 조합 실행
→ 코드 분석, 테스트 실행, 피드백 생성
→ 결과 제시

두 경우 모두 진입점은 단순합니다. 복잡한 비즈니스 로직을 직접 처리하지 않고, 적절한 하위 계층으로 위임합니다. 이것이 관심사의 분리(Separation of Concerns) 원칙입니다.

Sub-agent = Service Layer

전통 아키텍처에서 Service Layer는 여러 Repository와 Domain 객체를 조율하며 비즈니스 로직을 수행합니다. OrderService는 InventoryRepository, PaymentRepository, ShippingRepository를 조합하여 주문 처리라는 복잡한 워크플로우를 완성합니다.

Claude Code의 Sub-agent도 똑같은 역할을 합니다. Code Review Sub-agent는 여러 Skill(코드 분석, 테스트 확인, 보안 점검)을 조합하여 종합적인 리뷰를 생성합니다. 각 Sub-agent는 독립적인 Context를 가지고 있어서, 마치 별도의 트랜잭션 스코프처럼 독립적으로 동작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 전통적인 Service Layer
class OrderService {
  async processOrder(orderId: string) {
    // 여러 Repository를 조율
    const inventory = await this.inventoryRepo.check(orderId);
    const payment = await this.paymentService.process(orderId);
    const shipping = await this.shippingService.schedule(orderId);
    
    // 트랜잭션 관리
    return this.completeOrder(inventory, payment, shipping);
  }
}

// Claude Code의 Sub-agent (개념적 표현)
Sub-agent: CodeReview {
  // 여러 Skill을 조율
  const analysis = await CodeAnalysisSkill.analyze(files);
  const tests = await TestCoverageSkill.check(files);
  const security = await SecurityCheckSkill.scan(files);
  
  // Context 관리
  return this.generateReview(analysis, tests, security);
}

Service Layer가 트랜잭션 경계를 관리하듯이, Sub-agent는 Context 경계를 관리합니다. 각 Sub-agent는 자신만의 메모리 공간을 가지고 있어서, 다른 Sub-agent와 독립적으로 작업할 수 있습니다. 이것은 병렬 처리와 격리(isolation)를 가능하게 합니다.

Skills = Domain Component (SRP)

전통 아키텍처에서 Domain Component는 단일 책임 원칙(Single Responsibility Principle)을 따릅니다. EmailSender는 이메일 발송만, PdfGenerator는 PDF 생성만, DataValidator는 데이터 검증만 담당합니다. 각 컴포넌트는 하나의 명확한 역할을 가지며, 그 역할을 잘 수행하는 데 집중합니다.

Claude Code의 Skill도 정확히 같은 원칙을 따릅니다. “코드 리뷰하기”, “테스트 생성하기”, “문서 작성하기”처럼 하나의 명확한 기능만 담당합니다. 클래스가 비대해지면 안 되는 것처럼, Skill도 한 가지 일만 잘해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# 전통 아키텍처의 Domain Components
src/domain/
├── EmailSender.ts          # 이메일 발송만
├── PdfGenerator.ts         # PDF 생성만
├── DataValidator.ts        # 검증만
└── CacheManager.ts         # 캐싱만

# Claude Code의 Skills
.claude/skills/
├── code-review/            # 리뷰만
├── test-generation/        # 테스트 생성만
├── documentation/          # 문서 작성만
└── refactoring/           # 리팩토링만

SRP를 따르면 재사용성이 높아집니다. EmailSender는 주문 확인 메일에도, 비밀번호 재설정 메일에도, 뉴스레터에도 사용할 수 있습니다. 마찬가지로 잘 설계된 Skill은 여러 Sub-agent에서, 여러 프로젝트에서 재사용할 수 있습니다.

MCP = Infrastructure Layer / Adapter

전통 아키텍처에서 Infrastructure Layer는 외부 시스템과의 연결을 담당합니다. Repository Pattern은 데이터베이스 접근을 추상화하고, Adapter Pattern은 외부 API를 내부 인터페이스로 변환하며, Gateway는 레거시 시스템과의 통신을 처리합니다. 핵심은 추상화입니다. 내부 비즈니스 로직이 외부 시스템의 구체적인 구현에 의존하지 않도록 만듭니다.

MCP(Model Context Protocol)도 정확히 같은 역할을 합니다. LLM이 데이터베이스, API, 파일 시스템 등 외부 세계와 상호작용할 수 있게 해주면서도, 그 구체적인 구현 방식을 숨깁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 전통적인 Repository Pattern
interface UserRepository {
  findById(id: string): Promise<User>;
  save(user: User): Promise<void>;
}

class PostgresUserRepository implements UserRepository {
  // PostgreSQL 구체적 구현
}

class MongoUserRepository implements UserRepository {
  // MongoDB 구체적 구현
}

// Service는 구체적 구현을 몰라도 됨
class UserService {
  constructor(private userRepo: UserRepository) {}
}

MCP도 같은 구조를 가집니다. LLM은 “데이터베이스에서 사용자 정보를 가져와”라고 요청하면 됩니다. 그것이 PostgreSQL인지, MongoDB인지, REST API인지는 MCP가 처리합니다.

1
2
3
4
5
6
7
8
9
# MCP 구조 (개념적)
MCP: DatabaseAccess {
  abstraction: "사용자 정보 조회"
  
  implementations:
    - PostgreSQL: SELECT * FROM users WHERE id = ?
    - MongoDB: db.users.findOne({_id: ...})
    - REST API: GET /api/users/{id}
}

이것은 의존성 역전 원칙(Dependency Inversion Principle)의 실현입니다. 상위 레벨 모듈(LLM)이 하위 레벨 모듈(구체적인 데이터베이스)에 의존하지 않고, 둘 다 추상화(MCP)에 의존합니다.

CLAUDE.md = package.json / Configuration

모든 프로젝트에는 설정 파일이 있습니다. package.json은 Node.js 프로젝트의 의존성과 스크립트를 정의하고, pom.xml은 Maven 프로젝트의 구성을 담으며, .env는 환경 변수를 관리합니다. 이 파일들의 공통점은 변하지 않는 원칙과 설정을 담고 있다는 것입니다.

Claude Code의 CLAUDE.md도 같은 역할을 합니다. 프로젝트의 기술 스택, 코딩 컨벤션, 빌드 명령어 등 잘 변하지 않는 원칙을 담습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# CLAUDE.md 예시

## 기술 스택
- TypeScript + React 18
- Node.js 20+
- pnpm

## 코딩 컨벤션
- 함수형 컴포넌트만 사용
- 테스트는 vitest로 작성
- 컴포넌트당 하나의 파일

## 빌드 명령어
- `pnpm build` — 프로덕션 빌드
- `pnpm test` — 전체 테스트
- `pnpm lint` — 코드 린팅

중요한 점은 CLAUDE.md를 자주 수정하고 있다면, 그 내용은 거기 있으면 안 되는 것일 가능성이 높습니다. package.json에 오늘의 할 일을 적지 않듯이, CLAUDE.md에도 동적으로 변하는 정보를 넣으면 안 됩니다. 현재 작업 중인 이슈, 오늘의 우선순위 같은 것들은 대화로 전달하거나 Sub-agent의 Context로 넘겨야 합니다.

안티패턴: 과거의 실수는 미래에도 반복된다

레이어드 아키텍처의 안티패턴들

소프트웨어 엔지니어링의 역사는 패턴과 안티패턴의 발견과 퇴치의 역사이기도 합니다. 레이어드 아키텍처에서 우리가 고통스럽게 배운 안티패턴들이 있습니다. 놀랍게도, 이 안티패턴들이 에이전트 설계에도 그대로 나타납니다.

God Class → God Skill: 전통 아키텍처에서 God Class는 너무 많은 책임을 가진 클래스입니다. 3000줄짜리 UserManager 클래스가 사용자 인증, 프로필 관리, 알림 발송, 결제 처리까지 모두 담당하는 경우입니다. 이것은 이해하기 어렵고, 테스트하기 어려우며, 유지보수하기 어렵습니다.

에이전트 세계의 God Skill도 똑같습니다. 하나의 Skill이 3000줄에 달하고, 코드 리뷰, 테스트 생성, 리팩토링, 문서 작성을 모두 처리하려고 하면, 그것은 유지보수 악몽이 됩니다.

Spaghetti Code → Spaghetti CLAUDE.md: 스파게티 코드는 구조 없이 뒤섞인 로직을 의미합니다. if 문 속에 for 문이 있고, 그 안에 또 if 문이 있으며, 함수 호출이 여기저기 흩어져 있어서 실행 흐름을 따라가기 어렵습니다.

Spaghetti CLAUDE.md는 구조 없이 모든 지시사항이 뒤섞여 있는 경우입니다. 기술 스택, 코딩 컨벤션, 특정 기능 구현 방법, 임시 작업 지시가 구분 없이 나열되어 있으면, Claude는 무엇이 원칙이고 무엇이 일시적 지시인지 구분할 수 없습니다.

Tight Coupling → MCP 없는 하드코딩: Tight Coupling은 컴포넌트들이 서로 강하게 결합되어 있어서, 하나를 변경하면 다른 많은 것들도 함께 변경해야 하는 상황입니다. 예를 들어, 비즈니스 로직에 “SELECT * FROM users WHERE id = ?” 같은 SQL을 직접 작성하면, 데이터베이스를 PostgreSQL에서 MongoDB로 바꿀 때 모든 비즈니스 로직을 수정해야 합니다.

에이전트에서 MCP 없이 curl 명령을 직접 하드코딩하는 것도 같은 문제입니다. API 엔드포인트가 변경되거나 인증 방식이 바뀌면, 모든 Skill을 찾아다니며 수정해야 합니다.

Leaky Abstraction → Sub-agent가 MCP 내부를 앎: Leaky Abstraction은 추상화가 제대로 작동하지 않아서, 상위 레벨이 하위 레벨의 구현 세부사항을 알아야 하는 상황입니다. Repository를 사용하면서도 SQL 쿼리의 성능 특성을 고려해야 한다면, 추상화가 새고(leak) 있는 것입니다.

Sub-agent가 MCP의 내부 구현을 알고 있다면 같은 문제입니다. “PostgreSQL은 JSONB를 지원하니까 이렇게 쿼리하고, MongoDB였다면 저렇게 해야 하는데…” 같은 로직이 Sub-agent에 있다면, 재사용이 불가능해집니다.

Circular Dependency → Skill 간 순환 호출: Circular Dependency는 A가 B를 참조하고, B가 C를 참조하며, C가 다시 A를 참조하는 상황입니다. 이것은 테스트를 거의 불가능하게 만들고, 무한 루프의 위험을 만듭니다.

Skill 간 순환 호출도 같은 위험이 있습니다. CodeReview Skill이 TestGeneration Skill을 호출하고, TestGeneration Skill이 CodeAnalysis Skill을 호출하며, CodeAnalysis Skill이 다시 CodeReview Skill을 호출한다면, 무한 루프나 예측 불가능한 동작이 발생할 수 있습니다.

코드 스멜도 그대로 적용된다

Martin Fowler가 정리한 코드 스멜(Code Smell)들도 에이전트 설계에 그대로 적용됩니다.

Feature Envy: 어떤 클래스가 다른 클래스의 데이터나 메서드를 과도하게 사용하는 경우입니다. 이것은 책임이 잘못된 곳에 있다는 신호입니다. Skill도 마찬가지입니다. 한 Skill이 다른 Skill의 데이터를 계속 참조한다면, 그 로직은 다른 곳에 있어야 할 가능성이 높습니다.

Duplication: 같은 코드가 여러 곳에 복사되어 있는 경우입니다. DRY(Don’t Repeat Yourself) 원칙의 위반입니다. 비슷한 프롬프트가 여러 Skill에 복사되어 있다면, 그것을 공통 Skill로 추출하거나 references/ 폴더의 공유 문서로 만들어야 합니다.

Long Method: 하나의 메서드가 너무 길어서 이해하기 어려운 경우입니다. Sub-agent가 10개의 Skill을 연속으로 호출한다면, 그것은 Long Method와 같은 문제입니다. 중간 단계를 별도의 Sub-agent로 분리하는 것을 고려해야 합니다.

결정적 차이: 비유가 설명하지 못하는 것

전통 아키텍처는 모든 분기를 미리 정의해야 한다

레이어드 아키텍처 비유가 매우 유용하지만, 한 가지 중요한 차이점을 설명하지 못합니다. 전통적인 서비스 레이어를 생각해보겠습니다. 주문 처리 중 재고가 부족하면 어떻게 될까요? OutOfStockException을 던지거나, 미리 정의된 정책에 따라 백오더 처리를 합니다. 결제가 실패하면? 재시도 로직을 실행하거나, 실패 응답을 반환합니다.

중요한 점은 모든 분기가 미리 정의되어 있어야 한다는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 전통적인 서비스 레이어
class OrderService {
  async processOrder(request: OrderRequest): Promise<Order> {
    // 케이스 1: 재고 부족
    if (inventory.check(request.itemId) < request.quantity) {
      throw new OutOfStockException();  // 정해진 예외
      // 또는
      return backOrderPolicy.apply(request);  // 정해진 정책
    }
    
    // 케이스 2: 결제 실패
    try {
      await payment.process(request);
    } catch (PaymentError e) {
      if (e.isRetryable) {
        return await this.retryPayment(request);  // 정해진 재시도 로직
      } else {
        throw new PaymentFailedException();  // 정해진 예외
      }
    }
    
    // 케이스 3: 배송지 유효성
    if (!shipping.validateAddress(request.address)) {
      throw new InvalidAddressException();  // 정해진 예외
    }
    
    // 모든 케이스가 코드로 명시되어 있음
  }
}

그런데 실제 개발하다 보면 이런 순간이 있습니다. “이 케이스는… PM한테 물어봐야 하는데”, “스펙에 없는 상황인데 어떻게 하지?”, “이건 도메인 전문가의 판단이 필요한데…” 전통 아키텍처에서는 이런 순간에 코드가 멈출 방법이 없습니다. 예외를 던지거나, 개발자가 임의로 결정하거나, 일단 로그를 남기고 기본값으로 넘어가는 것이 전부입니다.

에이전트는 질문할 수 있다: Human-in-the-Loop의 혁명

에이전트는 근본적으로 다릅니다. Human-in-the-Loop(HITL)가 가능하기 때문입니다. 실행 중간에 불확실한 상황이 발생하면, 에이전트는 사용자에게 질문할 수 있습니다.

1
2
3
4
5
6
7
8
9
Request → Agent → 작업 진행 중...
                      ↓
                 🤔 불확실한 상황 발생
                      ↓
                 "A와 B 중 어떤 걸 원하세요?"
                      ↓
User Answer → "A로 해줘"
                      ↓
                 계속 진행 → 완료

이것은 단순한 기능 추가가 아닙니다. 근본적인 패러다임의 전환입니다. 예외(Exception)가 질문(Question)으로 바뀌는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
전통 방식:
- 모든 케이스를 미리 정의해야 함
- 예외 상황 → 에러 또는 기본값
- 자동화는 100% 아니면 0%
- 실수하면 롤백 필요

HITL 방식:
- 애매하면 물어보면 됨
- 예외 상황 → 사용자 판단 요청
- 부분 자동화 가능
- 실수 전에 확인

예를 들어, 에이전트가 리팩토링을 수행하는 중에 “이 함수는 성능이 중요한가요, 아니면 가독성이 중요한가요?”라고 물어볼 수 있습니다. 데이터베이스 마이그레이션을 수행하면서 “기존 데이터는 유지할까요, 아니면 새로 변환할까요?”라고 확인할 수 있습니다. 코드 리뷰에서 “이 패턴은 팀 컨벤션에 맞지 않는 것 같은데, 예외로 허용할까요?”라고 물어볼 수 있습니다.

언제 질문하고, 언제 알아서 할 것인가

하지만 HITL이 가능하다고 해서 매번 물어보면 안 됩니다. “이 변수명을 camelCase로 할까요, snake_case로 할까요?”같은 사소한 것까지 물어보면, 그것은 그냥 귀찮은 도구입니다.

좋은 에이전트는 언제 질문할지를 아는 에이전트입니다. 질문해야 할 때와 알아서 처리해야 할 때를 구분하는 기준이 필요합니다.

질문해야 할 때:

  • 되돌리기 어려운 작업 (데이터베이스 삭제, 프로덕션 배포, 외부 API 호출)
  • 여러 선택지가 있고 명확한 정답이 없는 경우 (아키텍처 결정, 트레이드오프)
  • 비용이나 리스크가 큰 결정 (대량 데이터 처리, 리소스 집약적 작업)
  • 도메인 특화 지식이 필요한 경우 (비즈니스 규칙, 정책 해석)

알아서 해야 할 때:

  • 안전하게 반복 가능한 작업 (테스트 실행, 린트 검사)
  • 이미 합의된 컨벤션이 있는 경우 (코딩 스타일, 네이밍 규칙)
  • 되돌리기 쉬운 작업 (코드 포맷팅, 주석 추가)
  • 명확한 올바른 답이 있는 경우 (문법 오류 수정, 타입 에러 해결)

실제 구현에서는 UserAskQuestion 같은 도구를 통해 이것을 구현할 수 있습니다. 에이전트는 “나는 95% 확신하는데, 5%의 불확실성이 있다. 이것은 질문할 만한 가치가 있는가?”를 판단할 수 있어야 합니다.

1.0 개발자가 3.0으로 가는 길

버릴 것과 가져갈 것

Software 3.0 시대가 왔다고 해서, 우리가 배운 모든 것이 쓸모없어지는 것은 절대 아닙니다. 오히려 반대입니다. 좋은 소프트웨어 설계의 원칙들은 시대를 초월하여 유효합니다. 다만 그것을 적용하는 맥락과 도구가 바뀌었을 뿐입니다.

버려야 할 것:

  • “모든 로직을 명시적으로 작성해야 한다”는 강박
  • 모든 예외 상황을 미리 정의하려는 시도
  • LLM을 ‘똑똑한 자동완성’ 정도로만 보는 시각
  • “코드를 직접 작성해야 진짜 개발자”라는 생각
  • 도구 없이 순수 실력만으로 평가하려는 태도

가져갈 것:

  • 레이어 분리, 단일 책임 원칙, 추상화의 원칙
  • 높은 응집도(High Cohesion)와 낮은 결합도(Low Coupling)
  • 의존성 관리, 인터페이스 설계, 계약(Contract)의 중요성
  • 테스트 가능성, 디버깅 전략, 점진적 개선
  • 코드 리뷰, 페어 프로그래밍, 지속적 통합의 가치
  • 명확한 문서화, 명명 규칙, 일관성 유지

도구는 바뀌었지만, 좋은 설계의 원칙은 그대로입니다. 실제로 이 원칙들은 에이전트를 설계할 때 더욱 중요해집니다.

기존 지식의 재활용

MCP를 설계할 때 Adapter Pattern과 Repository Pattern을 떠올려 보세요. 추상화 계층을 어떻게 만들고, 구체적인 구현을 어떻게 숨기며, 인터페이스를 어떻게 정의할지는 이미 알고 있는 지식입니다.

Skill을 만들 때 단일 책임 원칙을 떠올려 보세요. 한 Skill이 너무 많은 일을 하려고 하지 않는가? 다른 Skill과 책임이 겹치지 않는가? 명확한 입력과 출력이 정의되어 있는가?

Sub-agent를 구성할 때 Service Layer 설계를 떠올려 보세요. 트랜잭션 경계를 어떻게 정의하고, 여러 Repository를 어떻게 조율하며, 에러 처리를 어떻게 할지는 이미 수없이 해본 작업입니다.

CLAUDE.md를 작성할 때 설정 파일의 원칙을 떠올려 보세요. 무엇이 정적 설정이고 무엇이 동적 상태인가? 환경별로 달라지는 것과 모든 환경에서 동일한 것을 어떻게 구분할 것인가?

실전 적용: Setup & Config 패턴

이론에서 실천으로

지금까지 개념을 설명했다면, 이제 실제로 어떻게 적용하는지 살펴보겠습니다. HITL과 자동화를 자연스럽게 조합하는 실용적인 패턴이 있습니다. Slash Command를 활용한 Setup & Config 패턴입니다.

익숙한 CLI 패턴을 생각해보면:

1
2
3
4
5
6
7
# 전통적인 CLI 도구
npm init          # 최초 프로젝트 구조 생성
npm config set    # 이후 설정 변경

# 에이전트 명령어
/setup            # 레포 분석 → 구조 생성
/config           # 기존 설정 조정

Setup 과정의 HITL

HITL이 가장 빛나는 순간은 setup 과정입니다. 새 프로젝트에 Claude Code를 적용할 때를 생각해보겠습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/setup

→ 레포지토리 구조를 분석 중입니다...
→ 감지된 환경: TypeScript + React, 패키지 매니저는 pnpm
→ 
→ 테스트 프레임워크가 vitest와 jest 둘 다 발견되었습니다.
  기본으로 사용할 프레임워크를 선택해주세요:
  
  1. vitest (더 최신, Vite와 통합)
  2. jest (더 성숙, 더 많은 플러그인)
  
  어떤 것을 선택하시겠습니까? [1/2]

> 1

→ vitest를 기본 테스트 프레임워크로 설정합니다.
→ CLAUDE.md 생성 중...
→ 기본 Skills 설치 중...
→ 완료! 이제 /review, /test, /refactor 등을 사용할 수 있습니다.

에이전트가 환경을 자동으로 감지하되, 애매한 부분은 질문합니다. 개발자는 모든 설정을 수동으로 작성할 필요 없이, 불확실한 순간에만 판단을 제공하면 됩니다.

실제 사례: claude-hud 플러그인

오픈소스 프로젝트인 claude-hud 플러그인이 이 패턴을 잘 보여줍니다:

1
2
3
4
5
# 1. 플러그인 설치
/plugin install claude-hud

# 2. 레포에 맞게 설정 — 여기가 setup!
/claude-hud:setup

/claude-hud:setup이 하는 일:

  1. 현재 터미널 환경 감지 (터미널 타입, Claude Code 버전 등)
  2. Statusline 설정 자동 구성 (터미널 호환성 확인)
  3. 필요한 Hook 자동 등록 (git commit, file save 등)
  4. 애매한 부분만 질문 (“git hooks를 자동으로 설치할까요?” 등)

핵심은 사용자의 수동 설정을 최소화하면서, 정말 필요한 순간에만 질문하는 것입니다. 이것은 좋은 UX 설계의 원칙이기도 합니다: Progressive Disclosure - 한 번에 모든 것을 보여주지 말고, 필요할 때 필요한 만큼만 드러내라.

한계점: 비유가 숨기는 것들

토큰은 메모리다

레이어드 아키텍처 비유가 매우 유용하지만, 중요한 차이점을 숨기고 있습니다. 그 중 가장 중요한 것이 토큰의 개념입니다.

전통적인 서버 애플리케이션에서 우리는 RAM을 걱정했습니다. 메모리 누수(Memory Leak)를 찾고, 캐시 크기를 조정하며, 가비지 컬렉션을 튜닝했습니다. 에이전트 세계에서는 토큰이 메모리입니다.

1
2
3
Context Window = 작업 메모리 (RAM)
토큰 사용량 = 메모리 점유율
토큰 한계 초과 = Out of Memory (OOM)

CLAUDE.md, Skills, 대화 히스토리, MCP 응답… 이 모든 것이 Context Window에 쌓입니다. 200K 토큰이 많아 보여도, 대규모 코드베이스를 다루다 보면 순식간에 차오릅니다.

1
2
3
4
5
6
7
요소                          대략적인 토큰         비고
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CLAUDE.md (잘 정리된 경우)     500~2,000         프로젝트당 한 번
Skill 하나                    300~1,500         로드될 때마다
대화 히스토리                  누적              세션 내내 유지
MCP 응답 (DB 쿼리 등)         가변              큰 응답 주의
코드 파일 하나                 1,000~10,000      파일 크기에 따라

OOM을 예방하듯이, 토큰 폭발도 미리 감지해야 합니다. CLAUDE.md에 “모든 테스트 파일을 분석하라”고 쓰기 전에, 테스트가 50개일 때 어떻게 될지 상상해보세요. 정확한 토큰 수를 계산할 필요는 없지만, 파일 수와 라인 수만 대략 파악하면 충분합니다.

토큰 최적화 전략

지침을 작성한 뒤, Claude에게 “이 워크플로우를 실행하면 어떤 파일들을 읽게 될 것 같아?”라고 물어보세요. 예상보다 많은 파일이 나온다면, 지침을 좁히거나 단계를 나눠야 한다는 신호입니다.

토큰을 아끼는 또 다른 방법은 결정적 로직을 scripts로 분리하는 것입니다.

1
2
3
4
5
6
7
8
# 안티패턴: LLM이 컨벤션을 매번 해석
"브랜치명은 feature/JIRA-{티켓번호}-{설명} 형식으로 만들어줘.
설명은 kebab-case로 작성하고, 20자를 넘으면 안 되고,
한글이면 영어로 변환하고, 특수문자는 제거해야 해."

# 권장: scripts가 컨벤션을 캡슐화
./scripts/create-branch.sh JIRA-1234 "로그인 기능"
→ feature/JIRA-1234-login-feature

LLM 입장에서는 스크립트를 실행하고 결과를 활용하면 끝입니다. 컨벤션을 이해할 필요도, 매번 토큰을 소비할 필요도 없습니다. 판단이 필요 없는 결정론적 작업은 도구로 만들어 제공하는 것이 효율적입니다.

Skill 분리의 딜레마: 클래스 폭발 vs 거대 클래스

전통 아키텍처에서 단일 책임 원칙을 맹목적으로 따르다 보면 클래스 폭발(Class Explosion)이 발생합니다. 수백 개의 작은 클래스가 난립하고, 이들 간의 관계를 파악하는 것만으로도 인지 부하가 생깁니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 클래스 폭발의 예
class UserNameValidator { ... }
class UserEmailValidator { ... }
class UserAgeValidator { ... }
class UserPhoneValidator { ... }
class UserAddressValidator { ... }
// ... 20개 더

// 사용하는 쪽
new UserNameValidator().validate(user.getName());
new UserEmailValidator().validate(user.getEmail());
// 매번 어떤 Validator를 써야 하는지 기억해야 함

Skill도 마찬가지입니다. Claude는 시작 시 모든 Skill의 메타데이터(name/description)를 시스템 프롬프트에 로드합니다. Skill이 20개면 20개의 Description이 항상 Context를 점유합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# 안티패턴: Skill 폭발
.claude/skills/
├── review-naming/
│   └── SKILL.md
├── review-types/
│   └── SKILL.md
├── review-complexity/
│   └── SKILL.md
├── review-security/
│   └── SKILL.md
├── review-performance/
│   └── SKILL.md
└── ... (15개 더)

해결책은 Facade Pattern과 Progressive Disclosure를 결합하는 것입니다.

1
2
3
4
5
6
7
8
9
10
# 권장: Progressive Disclosure 구조
.claude/skills/
└── code-review/
    ├── SKILL.md              # "코드 리뷰해줘" → 여기만 항상 로드
    ├── references/           # 필요할 때만 로드
    │   ├── naming-rules.md   # "네이밍 컨벤션은?" → 그때 로드
    │   ├── security-checklist.md
    │   └── performance-guide.md
    └── scripts/
        └── lint-check.sh

SKILL.md는 Facade 역할을 합니다. 진입점만 제공하고, 세부 지식은 references/에 위임합니다. Claude가 필요하다고 판단할 때만 해당 파일을 Context에 로드합니다.

디미터의 법칙 (Law of Demeter)

이것은 디미터의 법칙의 적용입니다. “친구의 친구에게 말하지 마라.” 객체는 직접 관련된 객체만 알아야 합니다.

1
2
3
4
5
// 디미터의 법칙 위반
user.getWallet().getMoney().getCurrency().getSymbol();

// 디미터의 법칙 준수
user.getCurrencySymbol();  // 내부 구조를 숨김

Skill 설계에 적용하면: SKILL.md는 진입점만 제공하고, 세부 지식은 references/에 위임하라.

균형점을 찾는 기준:

1
2
3
4
5
6
상황                         전통 아키텍처              Skill 설계
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
독립적인 워크플로우           별도 Service 클래스         별도 Skill
같은 도메인의 세부 규칙       Private 메서드              references/ 파일
재사용 가능한 유틸리티        공통 모듈                   scripts/ 또는 MCP
자주 변하는 설정              외부 설정 파일              대화 또는 Context

마치며: Start Building by Refactoring Your Mindset

Software 3.0 시대의 개발은 코드를 작성하는 것에서 코드를 조립하고 지시하는 쪽으로 점차 무게중심이 옮겨가고 있습니다. 하지만 그 조립의 원칙 자체는 우리가 이미 익숙하게 다뤄온 개념들과 크게 다르지 않습니다.

Claude Code의 MCP, Skills, Sub-agent, Slash Command가 낯설게 느껴진다면, 이를 익숙한 레이어드 아키텍처의 관점에서 바라보는 것이 도움이 됩니다. 새로운 기술이라 하더라도 기존의 엔지니어링 원칙에 비춰 해석해 보면, 그 안에서 자연스럽게 설계 패턴이 드러납니다.

MCP를 설계할 때 Adapter Pattern을 떠올려 보세요. Skill을 만들 때 단일 책임 원칙을 떠올려 보세요. Sub-agent를 구성할 때 Service Layer를 떠올려 보세요. CLAUDE.md를 작성할 때 설정 파일의 원칙을 떠올려 보세요. 여러분이 가진 아키텍처 지식이 곧 최고의 에이전트를 만드는 기반입니다.

또 하나 기억해둘 점은 애플리케이션이 이제 질문할 수 있는 존재가 되었다는 점입니다. 모든 것을 처음부터 완벽히 정의하려 하기보다는, 애매한 부분은 묻게 두는 접근도 고려해볼 수 있습니다. Exception이 Question으로 바뀌는 세상입니다.

Software 1.0의 엄격한 제어 가능성, Software 2.0의 학습 능력, 그리고 Software 3.0의 자연어 인터페이스가 결합되면서, 우리는 이전보다 훨씬 높은 생산성과 창의성을 발휘할 수 있게 되었습니다. 하지만 그 기반에는 여전히 좋은 소프트웨어 설계의 불변하는 원칙들이 자리하고 있습니다.

Start building by refactoring your mindset. 도구가 바뀌었지만, 좋은 엔지니어의 사고방식은 여전히 유효합니다.


참고 자료


작성일자: 2026-01-27

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.