의도(Intent)에 관해서
요새 프로그래밍 메타는 개나소나 의도 붙이는게 문제다

서론
요즘은 프로그래밍하면 화두가 의도(Intent)이다. 항상 유행하는 키워드가 늘 그렇듯 의도가 중요하다면서 아키텍쳐(MVI)에도 의도를 붙이고 프로그래밍에도 의도를 붙이고(Intent-Driven-Programming) 아주 난리가 아니다.
내용은 좋다. 의도를 중시해야한다. 소비자의 의도를 파악해야한다. 엔드 유저의 의도를 파악해서, 프로그래밍에 접목해야한다.
등등 말은 정말 으리으리하다.
문제는 실제로 게시글들을 보다보면 결국 의도라는 것이 다층적이라는 것이 드러난다.
우리는 흔히 “좋은 추상화”라는 말을 쉽게 사용한다.
하지만 실제로 코드를 작성하다 보면, 처음에는 깔끔해 보이던 설계가
시간이 지나면서 점점 무너지는 경험을 반복하게 된다.
이 현상은 단순한 실수나 숙련도의 문제가 아니다.
문제는 더 근본적이다.
의도(intent)가 실제 시스템으로 변환되는 과정 자체가
여러 층의 추상화 레이어를 거치며, 그 사이에서 발생하는
정보 손실과 맥락 의존성 때문이다.

(토마스 쿤의 과학혁명의 구조)
문제를 뭉개는 방식
개인적으로 이 의도(Intent)를 보는 관점은
토마스 쿤의 “패러다임”과 유사하다 생각한다.
문제는 이 단어가 너무 많이 쓰였다는 데 있다.
쿤 자신도 『과학혁명의 구조』에서 “패러다임”이라는 용어를
20가지가 넘는 의미로 사용했다는 비판을 받는다.
(Masterman의 고전적 비판)
즉, 이 개념은 이미 하나의 명확한 이론 용어라기보다
맥락에 따라 의미가 바뀌는 메타 개념에 가깝다.
그 결과, 현대의 소프트웨어 논의에서 “패러다임”이라는 단어는
설명력을 가지기보다 오히려 설명을 회피하는 용어로 사용되는 경우가 많다.
이 점은 최근 유행하는 “Intent” 담론에서도 동일하게 나타난다.
의도(intent)라는 개념 역시 명확하게 정의되지 않은 채,
여러 레이어의 의미를 동시에 가리키는 용어로 사용된다.
- 사용자 요구
- 비즈니스 목표
- 도메인 모델
- 코드 레벨의 의도
이 모든 것이 하나의 “intent”라는 단어로 묶이면서,
문제는 단순화되는 것이 아니라 오히려 흐려진다.
따라서 intent를 하나의 단일 개념으로 다루는 대신,
레이어별로 분해해서 볼 필요가 있다.
그런 지점에서 Intent는 하나가 아니라,
서로 다른 레벨에서 정의되는 여러 개의 intent의 집합이다.
저 위의 예시만 봐도 그렇다.
- 사용자 intent (UX 레벨)
- 도메인 intent (비즈니스 규칙)
- 시스템 intent (아키텍처 결정)
- 구현 intent (코드 레벨 선택)
이들은 동일한 단어를 공유하지만,
서로 다른 제약과 최적화 기준을 가진다.
이 지점에서 중요한 전환이 하나 더 필요하다.
문제를 “개발자의 숙련도”나 “설계의 완성도”로 보는 대신,
의도(intent)가 어떻게 시스템으로 변환되는지 자체를 봐야한다.
의도는 곧바로 코드로 번역되지 않는다.
그 사이에는 여러 단계가 존재한다.
Intent
→ 도메인 모델
→ 추상화 (아키텍처, 인터페이스)
→ 코드
→ 컴파일
→ 실행 환경
→ 하드웨어 변환
이 과정은 단순한 변환이 아니라,
각 단계에서 정보를 버리고 특정 방향으로 해석하는 과정이다.
인텐트에서 도메인 모델로, 그리고 추상화로 주요 비즈니스 로직을 추출할때도, 그리고 코드로 쓸때도 모두 정보의 손실이 존재한다.
그리고 이때 발생하는 정보 손실은 단순히 “정밀도가 떨어진다”는 문제가 아니라, 이후의 선택지를 구조적으로 제한한다는 점에서 더 중요하다.
그리고
한 번 선택된 추상화는 이후의 모든 표현을 제약한다.
추상화는 왜 항상 현장에서 망가지는가
추상화가 "설계의 완성도" 문제라면, 숙련된 개발자가 설계하면 해결된다. 그런데 현실은 그렇지 않다. 숙련된 개발자가 설계한 추상화도 시간이 지나면 망가진다. 이유는 추상화 선택 자체가 이후의 선택지를 구조적으로 제한하기 때문이다.
케이스 1: Repository 패턴과 Transaction Boundary
Repository 패턴의 의도는 명확하다. 데이터 접근 로직을 도메인 레이어에서 분리한다. 교과서대로 설계하면 이렇게 된다.
public interface IOrderRepository
{
Order GetById(int id);
void Save(Order order);
}
public interface IInventoryRepository
{
Inventory GetByProductId(int productId);
void Save(Inventory inventory);
}
서비스 레이어에서 두 Repository를 조합한다.
public class OrderService
{
private readonly IOrderRepository orderRepo;
private readonly IInventoryRepository inventoryRepo;
public Result<OrderId, OrderError> PlaceOrder(PlaceOrderCommand command)
{
var inventory = inventoryRepo.GetByProductId(command.ProductId);
if (inventory.Stock < command.Quantity)
return Result.Failure(OrderError.OutOfStock);
var order = Order.Create(command);
inventory.Decrease(command.Quantity);
orderRepo.Save(order); // DB hit 1
inventoryRepo.Save(inventory); // DB hit 2
return Result.Success(order.Id);
}
}
설계는 깔끔하다. 문제는 orderRepo.Save와 inventoryRepo.Save 사이에서 프로세스가 죽으면 주문은 생성됐는데 재고는 그대로인 상태가 된다.
Transaction을 걸려면 어떻게 해야 하는가. 가장 흔한 선택은 IUnitOfWork를 추가하는 것이다.
public interface IUnitOfWork
{
void BeginTransaction();
void Commit();
void Rollback();
}
그런데 이 순간 추상화가 무너지기 시작한다.
IUnitOfWork는 특정 DB 연결 컨텍스트를 공유한다는 전제를 깔고 있다. Repository가 동일한 DbContext 인스턴스를 공유해야만 한다. 즉, "데이터 접근 로직을 분리한다"는 의도가 실제로는 "같은 연결을 공유하는 한에서 분리한다"로 좁혀진다.
MSA로 전환하면 더 심하다. OrderRepository와 InventoryRepository가 서로 다른 서비스에 있으면 공유 DbContext 자체가 불가능하다. 이때 선택지는 Saga 패턴이나 2PC인데, 이건 처음 추상화 설계와 전혀 다른 개념 모델이다. 초기의 "Repository로 분리"라는 추상화 선택이 이후의 분산 트랜잭션 선택지를 구조적으로 차단했다.
Repository 경계를 어디서 나누느냐는 의도 레벨의 선택이다.
그런데 그 선택은 Transaction Boundary라는 런타임 제약과 충돌한다. 이 두 레이어의 intent는 처음부터 다른 최적화 기준을 가졌다. 설계 시점에서는 이 충돌이 보이지 않는다.
케이스 2: 이벤트 기반 설계와 동기 응답 요구
이벤트 기반 아키텍처의 의도는 결합도를 낮추는 것이다.
어차피 C#을 쓰는 사람들은 이벤트 기반으로 코딩하는데 익숙하기때문에 별로 어렵지 않게, 주문 생성 시 재고 차감, 알림 발송, 포인트 적립을 직접 호출하지 않고 이벤트로 분리한다.
public class OrderCreatedEvent
{
public int OrderId { get; init; }
public int ProductId { get; init; }
public int Quantity { get; init; }
public DateTime OccurredAt { get; init; }
}
public class OrderService
{
private readonly IEventBus eventBus;
public OrderId PlaceOrder(PlaceOrderCommand command)
{
var order = Order.Create(command);
// ... persist order
eventBus.Publish(new OrderCreatedEvent
{
OrderId = order.Id,
ProductId = command.ProductId,
Quantity = command.Quantity,
OccurredAt = DateTime.UtcNow
});
return order.Id;
}
}
InventoryHandler, NotificationHandler, PointHandler가 각각 이벤트를 구독한다. 결합도가 낮고, 새 핸들러를 추가할 때 OrderService를 건드리지 않아도 된다. 의도가 명확하게 실현된 것처럼 보인다.
3개월 쯤 지나면 사장이 연락을 해오고, 요구사항이 들어온다.
"주문 완료 시 재고 차감 결과를 실시간으로 화면에 뿌려 주세요. 재고 부족이면 주문 자체를 실패 처리해야 합니다."
이 요구사항은 이벤트 기반 설계의 전제를 부순다. 이벤트는 fire-and-forget이다. Publisher는 subscriber의 처리 결과를 모른다. 재고 차감 결과를 응답에 포함시키려면 동기 호출이 필요하다.
선택지는 세 가지인데, 모두 비용이 있다.
선택 A: 재고 체크를 이벤트 발행 전에 동기 호출로 추가
public Result<OrderId, OrderError> PlaceOrder(PlaceOrderCommand command)
{
var stockResult = inventoryService.CheckAndReserve(command.ProductId, command.Quantity);
if (stockResult.IsFailure)
return Result.Failure(OrderError.OutOfStock);
var order = Order.Create(command);
eventBus.Publish(new OrderCreatedEvent { ... });
return Result.Success(order.Id);
}
이벤트 기반과 직접 호출이 혼재된다. OrderService가 InventoryService를 직접 안다. 처음에 이벤트로 분리했던 결합도가 다시 생긴다.
선택 B: Request/Reply 패턴으로 이벤트 버스를 확장
var reservationResult = await eventBus.Request<ReserveStockCommand, StockReservationResult>(
new ReserveStockCommand { ProductId = command.ProductId, Quantity = command.Quantity }
);
이벤트 버스가 동기 응답을 지원해야 한다. 인프라 복잡도가 올라간다. 이건 이미 이벤트 버스가 아니라 메시지 브로커에 가깝다. RabbitMQ의 RPC 패턴이나 MediatR의 Request/Response를 쓰는 구조인데, 처음 이벤트 기반으로 설계한 의도와 점점 멀어진다.
선택 C: 처음부터 다시 설계
현실에서는 대부분 선택 A로 간다. 그리고 서비스는 이벤트 기반과 직접 호출이 섞인 상태로 굳어진다.
"결합도를 낮춘다"는 시스템 레벨 intent와, "재고 차감 결과를 동기로 받는다"는 비즈니스 레벨 intent는 처음부터 충돌한다.
그런데 이 충돌은 설계 시점에서 보이지 않았다. 비즈니스 요구사항이 6개월 후에 바뀐 것이 아니라, 처음부터 내재해 있던 충돌이 요구사항 추가 시점에 표면으로 드러난 것이다.
두 케이스의 공통 구조는 다음과 같다.
설계 시점의 intent (결합도 분리 / 데이터 접근 분리)
-> 추상화 선택 (Repository / EventBus)
-> 이후 선택지 제약
-> 새 요구사항이 기존 제약과 충돌
-> 패치 또는 전면 재설계
이 과정은 설계가 나빠서 발생하지 않는다. 추상화가 맥락적이기 때문에 발생한다.
설계 시점의 맥락과 요구사항 추가 시점의 맥락이 다르고, 그 간극을 추상화가 흡수하지 못한다.
"좋은 추상화"가 존재한다면, 그것은 미래의 모든 맥락을 예측한 설계가 아니라, 맥락이 바뀔 때 마찰 비용을 감당할 수 있는 설계다.
그리고 그 기준은 설계 시점에서 정의할 수 없고, 모든 걸 해결 할 수 있는 프로그래머는 없다.
여기서 중요한 것은,
이 문제가 특정 방법론의 실패가 아니라는 점이다.
Repository도, EventBus도, DDD도,
모두 특정 시점의 intent를 구조화하려는 시도다.
문제는 그 구조가 고정되는 순간,
미래의 다른 intent가 들어올 자리가 줄어든다는 데 있다.

(바이브 코딩이 최초로 쓰인 카파시의 게시글)
그리고 바로 이 점에서 바이브 코딩이 이 문제를 더 극단적으로 드러낸다.
LLM은 현재 프롬프트에 드러난 intent를 기준으로
그럴듯한 추상화를 빠르게 생성하지만,
그 추상화가 이후의 요구사항 공간을 어떻게 제한하는지는 거의 고려하지 못한다.
이 문제는 단순히 “LLM이 과하게 많이 짠다”는 수준이 아니다.
더 정확히 말하면, LLM은 현재 주어진 intent를 기준으로
구조를 국소적으로 최적화한다.
즉, 지금 보이는 요구사항,
지금 프롬프트에 드러난 제약,
지금 당장 그럴듯해 보이는 분리 기준을 중심으로
추상화를 빠르게 조립한다.
문제는 실제 시스템의 intent가 거기서 끝나지 않는다는 점이다.
현실의 intent는 항상 지연되어 도착한다.
오늘의 요구사항은 명시적으로 주어지지만,
내일의 요구사항은 아직 언어화되지 않은 채 잠복해 있다.
인간 개발자는 이 잠복한 요구사항의 존재를 완전히 예측하지는 못해도,
적어도 그것이 다시 들어올 것이라는 사실 자체는 경험적으로 안다.
그래서 구조를 잡을 때
“지금 맞는가”만 보지 않고,
“나중에 덜 깨지겠는가”를 함께 본다.
반면 LLM은 대체로 현재 프롬프트에 드러난 intent를
언어적 표면으로 받아들인다.
그 결과,
현재 요구사항에는 잘 맞지만
미래 변경 비용은 큰 구조를 아주 높은 확률로 생성한다.
이것이 흔히 말하는 over-engineering의 한 형태다.
중요한 것은 여기서의 over-engineering이
단순히 클래스가 많고 파일이 많다는 뜻이 아니라는 점이다.
진짜 문제는,
현재 intent를 과도하게 구조화함으로써
아직 도착하지 않은 다른 intent가 들어올 경로를
오히려 좁혀 버린다는 데 있다.
즉, LLM은 추상화를 만든다.
하지만 그 추상화가 미래의 선택지를 얼마나 잠가 버리는지는
거의 감각하지 못한다.
이 점에서 LLM의 발산은 우연한 실수가 아니라 어쩌면 당연한 것일지도 모른다.
LLM은 실패 비용을 장기적으로 부담하지 않는다.
코드가 6개월 뒤, 진상 고객의 요구사항과 충돌할지,
어떤 경계가 transaction semantics와 부딪힐지,
어떤 인터페이스가 분산 환경에서 병목이 될지,
그 마찰은 생성 시점의 언어 표면에 충분히 드러나지 않는다.
결국 LLM은 현재 보이는 intent를 기준으로
가장 그럴듯한 구조를 만든다.
그러나 개인적 경험에서 얻은 좋은 구조란
현재 intent를 가장 잘 표현하는 구조가 아니라,
미래의 다른 intent가 들어와도
마찰 비용을 감당할 수 있는 구조다.
그리고 바로 이 차이가,
인간 개발자의 설계 경험과
LLM의 생성 능력 사이에 존재하는 본질적 간극이라는 생각을 한다.
이 때문에 LLM이 만들어내는 코드는
겉으로 보면 오히려 인간 개발자보다 더 “잘 설계된 것처럼” 보일 때가 많다.
인터페이스는 잘 분리되어 있고,
계층은 명확하며,
이름은 그럴듯하고,
패턴도 익숙하다.
문제는 그 구조가 맥락을 해결한 결과가 아니라,
학습 분포에서 자주 등장한 구조를 재조합한 결과라는 점이다.
즉, 구조의 형태는 있어도
구조가 감당해야 할 마찰의 역사성은 비어 있다.
인간이 실패를 통해 배우는 것은 문법이 아니라 마찰이다.
어디서 경계를 나누면 나중에 transaction이 꼬이는지,
어떤 이벤트 분리가 결국 동기 호출로 되돌아오는지,
어떤 추상화가 처음에는 우아해 보여도 요구사항이 두 번만 바뀌면 독이 되는지, 이런 감각은 대부분 성공 사례가 아니라
실패 비용을 치르면서 생긴다. LLM은 바로 이 실패 비용의 기억을 갖지 못한다.
따라서 문제는 LLM이 패턴을 모르기 때문이 아니다.
오히려 패턴을 너무 잘 알기 때문에,
그 패턴이 작동하지 않는 맥락의 마찰을 지워 버린다.
이 점에서 방법론의 교조화와 LLM의 발산은 닮아 있다.
둘 다 살아 있는 맥락을 고정된 구조로 환원하려는 충동을 가진다.

(Refactoring To Patterns)

패턴을 배우는 길에서 패턴에 심취하게 되는 것은 아마도 피할 수 없을 것입니다.
사실, 우리 대부분은 실수를 통해 배우니까요. 저 또한 여러 번 패턴에 심취했던 경험이 있습니다.
패턴의 진정한 즐거움은 그것들을 현명하게 사용하는 데서 비롯됩니다.
리팩토링은 중복 제거, 코드 단순화, 그리고 코드의 의도를 명확히 전달하는 데 집중하도록 도와줌으로써 우리가 그렇게 할 수 있도록 돕습니다.
패턴이 리팩토링을 통해 시스템 속으로 자연스럽게 녹아들 때, 패턴을 과도하게 사용하는 과잉 설계의 위험은 줄어듭니다.
리팩토링 실력이 향상될수록, 패턴의 진정한 즐거움을 발견할 가능성은 더욱 커질 것입니다
맥락의 고정
인간은 복잡한 것을 본질적으로 싫어한다. 그래서 '패턴'을 발견하고, 복잡계를 단순화해서 설명하려고 한다.
문제는 그러한 단순화 된 부분은 어디까지나 맥락과 상황에 의존적임에도 불구하고, 이것이 정답인냥 심취하는 경우가 있다. 패턴 프로그래밍 책등지에서도 저자가 패턴 프로그래밍에 심취했던 경험을 나열한 것을 보듯 말이다.
그런면에서 의도(Intent)의 사용 양태는 복잡한 것을 단순 한것으로 뭉개는데 쓰는 깨끗한 상위개념처럼 쓰인다.
TDD나 패턴 프로그래밍과 같이 그 방법론이 나왔던 시기와 맥락을 파악치 않고,
본질적 함의보다는 방법론의 교조화가 되서 반드시 지켜져야하는 일종의 종교적 교리처럼 사용되어진다.
개인적으로 TDD가 최악의 방법론 중 하나가 될 수 있다 생각한다.
추상화가 잘못되었다면 테스트는 무슨 의미가 있을까?
아키텍처를 점진적으로 파괴하면서까지 통과 테스트만 쫓아가는 사례를 많이 봤다.
예를 들어 SOLID 원칙은 항상 준수할 필요는 없지만, 일부 조직에서는 거의 종교적 교리처럼 여겨진다.
UI 컴포넌트 디자인에서 LSP를 지나치게 엄격하게 적용하면 UI의 다양성과 유연성을 저해할 수 있다.
결국 우리가 의도라고 부르는 것은 상황에 따라 유연성을 유지하고 그 상황 안에서 최적의 해결책을 찾는 능력일지도 모른다.
결국 의도(Intent)는 단일한 개념이 아니다.
사용자 레벨, 도메인 레벨, 시스템 레벨, 구현 레벨에서 각각 다른 제약과 최적화 기준을 가지는 여러 개의 intent가 공존하고, 그것들은 서로 충돌한다.
좋은 설계란 이 충돌을 없애는 것이 아니다.
충돌이 표면으로 드러나는 시점을 늦추거나, 드러났을 때 마찰 비용을 감당할 수 있는 구조를 만드는 것이다.
그 감각은 패턴을 아는 것에서 오지 않는다. 패턴이 작동하지 않는 맥락을 겪어본 것에서 온다.
의도를 말하는 것은 쉽다. 의도가 레이어를 거칠 때마다 무엇을 잃는지를 아는 것이 어렵다.
그리고 그것을 아는 것이 결국 나의 목표인 것 같다.