본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 22. 07:47

예외를 던지는 대신 Cursor에게 Result<T>를 가르치세요

요약

Cursor와 같은 AI 코딩 도구가 학습 편향으로 인해 예외(Exception)나 null을 사용하는 문제를 해결하는 방법을 다룹니다. 스코프 규칙을 활용해 팀의 Result<T> 패턴에 맞게 AI를 가르치는 가이드를 제공합니다.

핵심 포인트

  • AI의 학습 편향으로 인한 예외/null 사용 문제 지적
  • Result<T> 패턴 사용 시 일관성 및 테스트 용이성 확보
  • 스코프 규칙을 통한 AI 에러 모델 맞춤 설정 방법
  • 비즈니스 로직과 인프라 실패의 명확한 분리 필요성

예외를 던지는 대신 Cursor에게 Result<T>를 가르치세요

만약 당신의 팀이 이미 실패를 Result<T>, ErrorOr<T>, 또는 철도 지향형 (railway-style) 응답으로 모델링하고 있다면, Cursor는 다음 프롬프트에서 여전히 throw와 null을 사용하려 할 것입니다. 이것은 악의가 아니라, 학습 편향 (training bias) 때문입니다. 왜 이런 일이 발생하는지, 그 비용은 무엇인지, 그리고 스코프 규칙 (scoped rules)을 통해 어떻게 AI가 당신이 이미 비용을 지불하여 구축한 에러 모델에 맞추도록 가르칠 수 있는지 알아보겠습니다.

모델이 학습한 기본값

인터넷상의 대부분의 C# 예제들 — 튜토리얼, StackOverflow, 심지어 Microsoft 문서의 샘플들까지 — 비즈니스 실패에는 예외 (exceptions)를 사용하고, "찾을 수 없음"에는 null을 사용합니다. Cursor에게 중복 주문을 거부하는 엔드포인트를 추가하라고 요청하면 다음과 같은 코드를 받게 될 것입니다:

public async Task<OrderDto> CreateAsync(CreateOrderRequest request, CancellationToken ct)
{
    var existing = await _db.Orders.FirstOrDefaultAsync(o => o.Reference == request.Reference, ct);
    if (existing is not null) throw new ConflictException("Order reference already exists");

    var order = Order.Create(request);
    await _db.SaveChangesAsync(ct);
    return order.ToDto();
}

읽기 쉽고 익숙합니다. 하지만 만약 당신의 애플리케이션 (Application) 레이어가 이미 Result<OrderDto>를 반환하고, API가 모든 컨트롤러에서 도메인 예외 (domain exceptions)를 잡지 않고 에러를 ProblemDetails로 매핑하고 있다면, 이는 아키텍처적으로 잘못된 것입니다.

AI가 예외를 던질 때 발생하는 문제점

  • 일관성 없는 HTTP 의미론 (semantics). 어떤 엔드포인트는 매퍼 (mapper)로부터 409를 반환하지만, 다른 엔드포인트는 균일하다고 생각했던 파이프라인을 지나쳐 버블링된 예외 때문에 500 에러를 유출합니다.
  • 테스트 불가능한 핸들러 (handlers). MediatR 핸들러에 대한 단위 테스트 (Unit tests)는 비즈니스 케이스에 대해 Assert.ThrowsAsync가 아니라 result.IsError를 검증해야 합니다.
  • 숨겨진 제어 흐름 (control flow). null 체크와 던져진 예외는 시그니처 (signatures)에서 보이지 않습니다. AI(그리고 다음 개발자)는 본문을 읽지 않고서는 실패 모드 (failure modes)를 볼 수 없습니다.
  • 재시도 독 (Retry poison). 일시적인 인프라 실패는 예외에 속해야 하지만, 비즈니스 규칙 위반은 그렇지 않습니다. 이 둘을 섞으면 운영자가 재시도할 수 없는 결함에 대해 재시도하도록 훈련하게 됩니다.

시니어 팀들이 실패를 가시화하기 위해 명시적인 결과 (explicit results)로 이동한 것입니다. AI가 자동 완성 한 번으로 이를 되돌리는 것은 비용이 많이 듭니다.

MediatR 코드베이스에서 바람직한 모습

동일한 기능, 결과 형태 (result-shaped):


``` C#
public async Task<Result<OrderDto>> Handle(CreateOrderCommand cmd, CancellationToken ct) { var exists = await _orders.ExistsByReferenceAsync(cmd.Reference, ct); if (exists) return Result.Conflict<OrderDto>("Order reference already exists"); var create = Order.Create(cmd); if (create.IsError) return create.Errors; await _orders.AddAsync(create.Value, ct); return create.Value.ToDto(); } The endpoint stays thin: ``` ``` app.MapPost("/orders", async (CreateOrderCommand cmd, ISender sender, CancellationToken ct) => { var result = await sender.Send(cmd, ct); return result.Match(Results.Created, Results.Problem); }); No try/catch for "customer not found". No null return that the caller forgets to check. The signature documents the contract. ## 한 번 가르쳐도 지속되지 않는 이유 `` 채팅에 "우리는 Result 패턴을 사용하며 비즈니스 오류를 위해 throw하지 않는다"라고 붙여 넣는다. 그 파일에서는 준수한다. 세 프롬프트 후에, 유효성 검사기(validator)나 리포지토리 메서드에서 다시 NotFoundException을 던진다. 왜냐하면:
1. ****로컬 컨텍스트가 2019년의 레거시 throw를 가진 파일이기 때문이다.
1. ****프롬프트에 ErrorOr vs Result vs FluentResults 중 무엇인지 언급하지 않았기 때문에, 학습 과정에서 가장 최근에 본 타입 이름을 선택한다.
1. ****애플리케이션 계층 파일에는 강제된 규칙이 없으며, 단지 어제 닫은 채팅의 기억만 존재하기 때문이다.
[](01-the-context-tax.html) 이것은 Context Tax와 동일한 영속성 문제이며, 오류 모델링에 적용된다. 필요한 것은 관련 계층이 열릴 때 재로드하는 관례가 필요하다. 모델에게 강의할 것을 기억할 때가 아니다. ## 규칙 계약 (인코딩해야 할 것) 결과 형태의 코드베이스를 위한 유용한 Cursor 규칙은 특정 NuGet 패키지를 강제할 필요가 없다. 다음을 해야 한다:
- ****`````` 프로젝트가 이미 FluentResults, ErrorOr 또는 내부 Result<T>를 참조하는 경우, 해당 타입을 정확히 일치시킨다.
- **** ````결과(Result)를 사용하는 경우 공개 애플리케이션 API에서 null-as-missing을 금지한다 — null 대신 NotFound 오류를 반환하도록 한다.

- ****진정으로 예외적인 상황(programmer errors, cancelled operations, infrastructure timeouts 등)을 위해서만 예외(exceptions)를 남겨두세요. "이미 사용 중인 이메일"과 같은 상황을 위해 사용하지 마세요. - ****경계(edge)에서의 매핑을 강제하세요 — 핸들러(handlers)는 Result를 반환하고, 엔드포인트(endpoints)가 이를 매핑합니다. 도메인 실패(domain failures)를 위해 Minimal API 람다(lambdas) 내에서 throw를 사용하지 마세요. - ****핸들러보다 검증기(validators)를 앞단에 두세요 — FluentValidation 실패는 핸들러 로직이 실행되기 전에 Result로 변환되어야 합니다 (이는 MediatR 파이프라인과 잘 결합됩니다). Agentic Architect 키트의 `arch-core.mdc`는 Application 및 API 인접 파일에 대해 "기존 Result / ErrorOr / OneOf 패턴과 일치시키기" 조항을 인코딩합니다. 이는 단순한 폴더 배치가 아니라, 제어 흐름(control flow)에 적용되는 경계 수호자(boundary guardian) 역할을 합니다.

## 오늘 바로 사용할 수 있는 프롬프트 (전체 키트 사용 전)
규칙이 완전히 커밋되기 전까지, 핸들러나 엔드포인트를 수정할 때 상단에 다음 내용을 고정하세요:

> 비즈니스 실패는 Result 에러이지, 예외(exceptions)가 아닙니다. 이 프로젝트에서 이미 사용 중인 Result/ErrorOr 타입을 따르세요. API 경계에서만 HTTP로 매핑하세요.

짧고, 지루하며, 반복 가능합니다. 제 경험상 이 방식은 throw 회귀(regressions)를 대략 절반으로 줄여줍니다. 하지만 범위가 지정된 .mdc 파일과 모델이 세션 시작 시 읽는 LEARNING_LOG.md 항목이 없다면 규율은 여전히 무너질 수 있습니다.

## 결정을 한 번 기록하고, 영원히 강제하세요
팀 전체에 Result 패턴을 도입할 때, 영속성 엔진(persistence engine)이 다시 불러올(re-hydrate) 수 있도록 Learning Log에 한 줄을 추가하세요:

```plaintext
ADR-014 — 애플리케이션 에러는 Result임
- 핸들러는 Result<T> / ErrorOr<T>를 반환하며, 비즈니스 규칙에 대해 throw를 사용하지 않음.
- API는 Match / ToProblemDetails를 통해 매핑하며, 컨트롤러(controllers)는 가볍게 유지함.
- 예외(Exceptions): 인프라 관련(timeouts, corruption)으로만 제한함.

다음 주 월요일이 되면, 모델은 지난 6개월 동안 Result를 반환해 온 핸들러에서 throw new InvalidOperationException("duplicate")를 제안하기 전에 이 ADR을 먼저 확인하게 될 것입니다.

다른 실패 모드들과의 병행

**** Result 규칙(Result discipline)이 의존성 주입 (DI) 수명 주기 감사 (Scoped→Singleton 캡처)나 환각 방지책 (seven-word stop phrase)을 대체하는 것은 아닙니다. 이는 세 번째 실패 모드인 '조용한 스타일 퇴보 (silent style regression)'를 해결하기 위한 것입니다. 즉, 컴파일은 되고 전문적으로 보이지만, 팀이 의도적으로 선택한 컨벤션(conventions)을 서서히 침식시키는 코드를 방지합니다. ---

원문은 https://agenticstandardcontact-byte.github.io/agentic-architect/blog/04-cursor-result-not-throw.html에 게시되었습니다. 이 글은 Cursor + .NET을 위한 Agentic Architect 지속성 키트(persistence kit)의 일부입니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0