본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 26. 21:46

트랜스포트(Transport)를 실패하게 만드는 다섯 가지 방법

요약

API 에러 핸들링 시 발생하는 지식 퇴화 문제를 해결하기 위해, 에러 케이스를 컴파일러 수준에서 강제하는 'op' 설계 철학을 다룹니다. 기존의 이진 분할(성공/실패) 방식이 가진 한계를 지적하며, 도메인 연산의 모든 결과를 명시적으로 선언하는 방법론을 제시합니다.

핵심 포인트

  • 에러 핸들링 지식이 코드와 컴파일러에 연결되지 않으면 퇴화함
  • 기존 Try/catch나 Result 타입의 이진 모델은 복잡한 도메인 모델링에 한계가 있음
  • 연산이 생성할 수 있는 모든 결과를 선언부에서 명시적으로 강제해야 함
  • 차별된 공용체(Discriminated Union)를 통한 타입 안전한 에러 처리의 중요성

(Ruuk은 F#과 구문을 공유합니다. 필요하다면 여기에서 F#에 대한 짧은 소개를 확인할 수 있습니다.)

테크 리드(Tech lead)로서 저는 API 에러 핸들링(error handling)에 대한 최소 표준을 설정하곤 했습니다. 모든 호출은 400, 401, 403, 500 에러를 다루어야 한다는 식이었죠. 하지만 티켓 리뷰를 하다 보면 여전히 노트에는 세 가지 실패 시나리오가, 코드에는 한 가지가 남아 있는 것을 발견하곤 했습니다. 개발자들은 해당 케이스들을 파악하고 있었지만, 언어 차원에서 그 지식을 영구적으로 담아둘 곳을 제공하지 않았던 것입니다.

어떤 에러를 처리해야 하는지에 대한 지식은 항상 존재했습니다. 그것은 티켓에, 팀 표준에, 코드 주석에 존재했습니다. 하지만 그 장소들 중 어느 곳도 컴파일러(compiler)와 연결되어 있지 않았기에, 그 지식은 점차 퇴화했습니다.

제가 op를 설계하기 시작한 이유는 그 지식이 코드 자체 내에 내구성 있는 집을 갖기를 원했기 때문입니다. 즉, 연산(operation)이 생성할 수 있는 모든 결과를 명시하고, 모든 호출 지점(call site)에서 이를 강제하는 선언(declaration)을 만들고 싶었습니다. 이 시리즈의 첫 번째 기사에서는 이러한 의도가 이미 모든 코드베이스—Javadoc, JSDoc 주석, OpenAPI 응답 스키마(response schemas)—에 존재하지만, 컴파일러가 볼 수 없는 곳에 머물고 있다고 주장했습니다. op는 이를 선언부로 옮깁니다.

이진 분할 (The Binary Split)

에러 핸들링(error handling)에 대한 모든 주류 접근 방식은 먼저 동일한 질문을 던집니다: 성공했는가, 아니면 실패했는가? Try/catch, Result, sealed 예외 계층 구조(sealed exception hierarchies) 등은 모두 결과를 두 개의 버킷으로 나눕니다. 만약 여러분의 배경이 Java나 C#이라면, 커스텀 예외 계층 구조나 결과 래퍼 클래스(result wrapper classes)를 통해 이 방식을 사용해 보았을 것입니다. 그 의도는 항상 동일합니다. 실패 모드(failure modes)의 이름을 붙이고, 호출자(caller)에게 이를 보이게 하는 것입니다. 하지만 이러한 이진 프레임워크(binary framing)는 연산이 진정으로 한 가지의 성공 방식과 한 가지의 실패 방식만을 가질 때에만 작동합니다. 이는 많은 도메인 연산(domain operations)을 모델링하는 데 항상 최선의 방법은 아닙니다.

이전 기사는 F#의 Result 타입을 사용하는 트랜스포터(transporter)로 끝을 맺었습니다:

type TransporterFailure =
    | SignalLost of lastCoords: string
    | PatternDegradation of integrity: float
...

Error 내부의 차별된 공용체 (Discriminated Union)는 실패 모드들이 타입화되고 이름이 지정되었음을 의미합니다. 패턴 매칭 (Pattern matching)을 통해 각 모드를 처리할 수 있습니다. 이는 진정으로 훌륭하며, 저는 예외 계층 구조 (Exception hierarchies)나 상태 코드 (Status codes)보다 이를 선호합니다. 하지만 어떤 언어를 사용하든, 대규모 환경에서 이진 모델 (Binary model)이 갖는 세 가지 특성이 있습니다.

새로운 결과가 틈새로 빠져나갑니다. 트랜스포터 (Transporter)에 InsufficientPower를 추가한다고 가정해 봅시다. Java에서는 기본 TransporterException을 잡는 호출자(Callers)들이 여전히 컴파일됩니다. C#에서는 default 분기가 있는 switch 문이 여전히 컴파일됩니다. F#에서는 Result.map으로 Error 측을 전달하는 호출자들이 여전히 컴파일됩니다. 케이스를 명시적으로 열거하는 호출 지점(Call sites)은 — 마땅히 그래야 하듯 — 깨지겠지만, 에러를 일반적인 방식으로 처리하는 곳들은 깨지지 않습니다. 이것이 제가 테크 리드 (Tech lead)로서 목격한 패턴입니다. 개발자들은 구현 중에 에러 케이스를 식별하고 문서화한 뒤 다음으로 넘어갔고, 툴체인 (Toolchain)의 그 어떤 것도 이를 제지하지 않았습니다. 만약 컴파일러가 이것을 불가능하게 만들 수 있다면 어떨까요?

성공은 단 한 가지가 아닙니다. 모든 이진 에러 모델 — try/catch, Result<T, E>, Go의 (value, error) — 은 정확히 하나의 성공 채널 (Success channel)을 제공합니다. 하지만 많은 도메인 연산 (Domain operations)은 하나 이상의 의미 있는 성공 경로를 가집니다. 업서트 (Upsert)는 새로운 레코드를 생성하거나 기존 레코드를 업데이트합니다. 협상은 완전한 합의에 도달하거나 잠정적인 휴전에 도달합니다. 두 결과 모두 성공이지만, 서로 다른 처리가 필요합니다. 두 개의 성공 경로를 모델링하면, 하나를 실패로 잘못 분류하거나 성공 채널 내부에 또 다른 디스패치 계층 (Dispatch layer)을 중첩하게 됩니다. 이 경우 호출자들은 두 경로를 구분하지 못한 채 값을 처리하게 됩니다.

이것은 우리가 거의 알아차리지 못하는 제약 사항입니다. 성공이 단일한 것으로 모델링되는 언어에서 작업하다 보면, 성공이 그렇지 않은 경우를 보지 못하도록 훈련될 수 있습니다.

결과는 하위 범주가 아니라 동등한 관계여야 합니다. 컴파일러가 모든 분기(Arm)를 검증하는 F#의 패턴 매칭과 같은 최선의 경우조차도, 이진 구조는 모든 호출 지점으로 누출됩니다:

match beamUp target with
| Ok name -> $"Transport complete. {name} is aboard."
| Error (SignalLost coords) -> $"Signal lost at {coords}."
...

도메인(Domain)에는 네 가지 결과가 있지만, 타입(Type)에는 두 가지만 존재합니다. 모든 실패 분기(Arm)는 실제 케이스에 도달하기 전 서문으로서 Error (...)를 명시해야 합니다. 동일한 의식(Ceremony)이 Java의 봉인된 계층 구조(Sealed hierarchies), C#의 결과 래퍼(Result wrappers), 그리고 Go의 에러 반환(Error returns)에서도 나타납니다. 래퍼는 변할지언정, 두 개의 버킷(Two-bucket) 구조는 변하지 않으며, 이는 코드가 결과를 접하는 모든 곳에서 드러납니다.

매개변수의 의도는 암시적입니다. 실제 트랜스포트(Transport) 작업에는 대상(Target), 출발지 위치(Source location), 목적지 패드(Destination pad), 그리고 운영자(Operator)가 필요합니다. 대부분의 언어는 이를 위치 매개변수(Positional parameters)나 명명된 인자(Named arguments)로 처리합니다 — beamUp(target, surface, pad, operator) 또는 beamUp(target: riker, from: surface). 명명된 인자는 위치 전용 방식보다 확실히 개선된 방식입니다. 하지만 이름은 임시방편(Ad hoc)적입니다. 어떤 작업은 from을 사용하고, 다른 작업은 source를 사용하며, 세 번째 작업은 origin을 사용합니다. 코드베이스 전체에 걸쳐 일관성을 강제하는 것은 아무것도 없으며, 컴파일러는 from이라고 라벨링된 매개변수가 실제로 출발지(Source) 역할을 수행하는지 검증하지 않습니다.

이러한 특성들은 팀이 관례를 작업 기억(Working memory)에 담아둘 수 있는 작은 코드베이스에서는 괜찮습니다. 하지만 작업(Operation)과 호출 지점(Call sites)의 수가 늘어남에 따라, 특히 한 시스템이 코드를 작성하고 다른 시스템이 검토하는 에이전트 워크플로우(Agentic workflows)에서는 문제가 누적됩니다.

더 나은 접근 방식은 어떤 모습일까

이러한 특성들은 세 가지 설계 기준을 가리킵니다:

모든 결과는 모든 호출 지점에서 처리되어야 합니다. F#의 match와 Java의 봉인된 타입(Sealed types)에 대한 switch 표현식은 이미 철저한 커버리지(Exhaustive coverage)를 강제합니다. 이는 강력하며 올바른 토대입니다. 남은 간극은 매칭(Matching) 없이 결과를 전달(Forward), 변환(Transform) 또는 무시(Ignore)하는 호출 지점들입니다. 누군가 여섯 번째 결과를 추가했을 때, 단지 우연히 match를 사용하는 곳뿐만 아니라 모든 호출 지점이 처리될 때까지 깨져야(Break) 합니다.

결과(Outcomes)는 래핑(Wrapped)되는 것이 아니라 동등한 위치(Peers)에 있어야 합니다. 다섯 가지 결과를 생성할 수 있는 작업은 동일한 수준에서 다섯 가지 결과를 선언해야 합니다. SignalLost는 "에러(Error)"의 하위 범주가 아닙니다. 이는 Transported만큼이나 정당한 도메인 결과(Domain result)입니다. 호출자는 성공 또는 에러 컨테이너를 먼저 언래핑(Unwrapping)하지 않고, 각 결과를 직접 처리해야 합니다.

매개변수(Parameters)는 의미론적 역할(Semantic roles)을 지녀야 합니다. 이름이 지정된 매개변수(Named parameters)는 좋습니다. 매개변수 역할에 대해 작고 고정된 어휘(Vocabulary)를 사용하는 것이 더 좋습니다. 만약 코드베이스의 모든 작업이 "출처(Source)"를 의미하는 from과 "목적지(Destination)"를 의미하는 to를 사용한다면 — 이것이 명명 규칙 때문이 아니라, 언어가 해당 역할들을 정의하고 컴파일러가 이를 검증하기 때문이라면 — 호출 지점(Call sites)은 구조적으로 일관성을 갖게 됩니다. 역할 어휘를 한 번 익힌 개발자는 시그니처(Signature)를 확인하지 않고도 어떤 작업의 호출 지점이든 읽을 수 있습니다.

이는 에이전틱 코딩(Agentic coding)에서도 중요합니다. 자연어로 학습된 언어 모델(Language models)은 이미 fromto를 방향성 역할로 내재화했습니다. 고정된 어휘는 모델에게 인간 검토자와 동일한 참조 지점을 제공합니다. 생성된 호출 지점은 학습의 운이 아니라 구조적 제약에 의해 일관성을 유지하게 됩니다.

op 소개

Ruuk의 op 키워드는 도메인 작업(Domain operation)을 일급 언어 구성 요소(First-class language construct)로 선언합니다. 도메인 작업이란 호출자가 다르게 처리해야 하는 결과들을 가진 모든 동작을 의미합니다 — 데이터베이스 쓰기, API 호출, 상태 전이(State transition), 유효성 검사(Validation check) 등이 이에 해당합니다. 순수 변환(Pure transformations)과 내부 헬퍼(Internal helpers)는 일반적인 let 함수로 유지됩니다.

여기 트랜스포터(Transporter)가 있습니다. pub은 Rust에서 볼 수 있는 것과 동일한 접근 제어자(Access modifier)로, 해당 작업을 다른 모듈에서도 볼 수 있도록 표시합니다.

pub op beamUp =
    payload target: CrewMember
    from surface: PlanetarySurface
...

다섯 가지 결과(outcomes)가 동등한 관계(peers)로 선언됩니다. 각 결과는 고유한 데이터를 포함합니다. SignalLost는 좌표를 포함하고, PatternDegradation은 무결성 수치(integrity reading)를 포함하며, TargetShieldedInsufficientPower는 아무것도 포함하지 않습니다. 어떤 결과도 "성공" 또는 "오류"로 특권화되지 않으며, 각 결과는 동등한 수준의 도메인 결과(domain result)입니다.

매개변수(parameters)에는 **역할(roles)**이 있습니다. payload는 주요 데이터 인자(argument)를 나타냅니다. from, to, by는 각 매개변수와 연산(operation) 사이의 관계를 설명하는 전치사입니다. 이것들은 개발자가 임의로 선택한 라벨이 아닙니다. Ruuk이 정의한 payload, subject, from, to, by, via, in, at, for라는 작고 고정된 어휘 집합에서 가져온 것입니다. 모든 코드베이스의 모든 연산은 동일한 의미에 대해 동일한 단어를 사용합니다.

역할은 호출 지점(call site)에서 나타납니다:

beamUp riker from planetSurface to padOne by laForge

소리 내어 읽어보세요: "beam up Riker from the planet surface to pad one by La Forge." 많은 언어가 명명된 매개변수(named parameters)를 가지고 있습니다. 고정된 역할 어휘는 전체 코드베이스에 걸쳐 일관성을 더해줍니다. 즉, 한 연산에서는 source를 배우고 다른 연산에서는 origin을 배우는 일이 없습니다. 어딘가로부터 무언가를 가져오는 모든 연산은 from을 사용합니다. fromto를 바꾸면 컴파일러가 거부합니다. TransporterPadPlanetarySurface가 아니기 때문입니다. 이 어휘는 몇 분 안에 암기할 수 있을 정도로 작으며, 일단 익히고 나면 모든 호출 지점이 동일하게 읽힙니다.

op 선언은 계약(contract)입니다. 이는 다른 모든 모듈에 beamUp이 무엇을 필요로 하고 무엇을 생성할 수 있는지 알려줍니다. 구현(implementation)은 별도의 let 바인딩(binding)입니다:

let beamUp (target: CrewMember) (surface: PlanetarySurface)
           (pad: TransporterPad) (operator: CrewMember) =
    if pad.powerLevel < minimumPower then
...

이러한 분리는 의도된 것입니다. 다른 모듈에서는 op 선언 — 즉, 계약 (contract) — 을 읽게 되며, 이를 통해 무엇을 제공해야 하는지, 각 인자 (argument)가 어떤 역할을 하는지, 그리고 무엇이 반환될 수 있는지를 알 수 있습니다. 구현 (implementation) 은 내부적인 세부 사항입니다. 이것이 첫 번째 기사에서 다룬 3자 모델 (three-party model) 의 실제 적용 사례입니다. 즉, 인간이 선언을 정의하고, 에이전트 (agent) 가 구현 본문을 생성하며, 컴파일러 (compiler) 가 본문이 계약을 충족하는지 검증하는 방식입니다.

|> on을 이용한 결과 처리

결과 (outcomes) 는 Ruuk의 파이프라인 (pipeline) 구문에 직접 통합되는 |> on을 통해 처리됩니다:

beamUp riker from planetSurface to padOne by laForge
|> on Transported crew   -> printfn $"Transport complete. {crew.name} is aboard."
|> on SignalLost coords  -> printfn $"Signal lost at {coords}. Dispatching shuttle."
...

|> on 분기 (arm) 는 하나의 결과와 매칭되며 그 데이터를 바인딩 (bind) 합니다. Transported crew는 반환된 CrewMembercrew에 바인딩합니다. SignalLost coords는 좌표를 바인딩합니다. TargetShielded는 데이터를 포함하지 않으므로 바인딩이 없습니다.

이를 앞서 살펴본 Result 매칭과 비교해 보십시오. 결과들은 평면적 (flat) 입니다. 즉, 하나의 레벨에 다섯 개의 분기가 있으며 각각이 직접 처리됩니다. 모든 분기마다 명시해야 하는 Ok/Error 래퍼 (wrapper) 가 없습니다. 파이프라인은 위에서 아래로 읽힙니다. 연산이 결과를 생성하면, 각 레일 (rail) 이 각자의 목적지로 이어집니다.

만약 Scott Wlaschin의 철도 지향 프로그래밍 (railway-oriented programming)을 접해본 적이 있다면, 이것은 두 개의 레일에서 N개의 레일로 확장된 동일한 사고 모델입니다. 이는 도메인 연산 (domain operations) 을 이진 분할 (binary split)로 강제하는 대신, 실제 구조를 반영합니다.

철저한 처리 (Exhaustive Handling)

F#의 match는 이미 분기가 누락되었을 때 컴파일에 실패합니다. 이것이 op가 구축하는 기초입니다. 차이점은 개발자가 와일드카드 (wildcard) 를 사용하려고 할 때 발생합니다:

match beamUp target with
| Ok name -> $"Transport complete. {name} is aboard."
| Error (SignalLost coords) -> $"Signal lost at {coords}."
...

이 코드는 컴파일됩니다. 와일드카드 arm (wildcard arm)은 일치하는 모든 결과를 조용히 폐기하면서 컴파일러를 만족시킵니다. |> on에는 이에 상응하는 기능이 없습니다. arm을 제거하면 해당 결과가 갈 곳이 없게 됩니다:

beamUp riker from planetSurface to padOne by laForge
|> on Transported crew   -> printfn $"Transport complete. {crew.name} is aboard."
|> on SignalLost coords  -> printfn $"Signal lost at {coords}."
...

다음은 컴파일되지 않습니다:

Unhandled outcome: InsufficientPower
  in beamUp call at TransporterRoom.rk:14

모든 선언된 outcome (결과)은 처리되거나 명시적으로 deferred (연기) 상태로 표시되어야 합니다. 숨을 수 있는 catch-all (포괄적 처리 구문)은 없습니다.

실질적인 결과: 만약 beamUp에 새로운 outcome을 추가한다면 — 예를 들어 함선이 워프(warp)로 진입하려 하기 때문에 WarpFieldInterference를 추가한다면 — 컴파일러는 이를 처리하지 않거나 명시적으로 deferred로 표시하지 않은 모든 호출 지점(call site)에서 에러를 발생시킵니다. 그 에러 목록이 바로 당신의 작업 큐(work queue)가 됩니다. 결정은 조용히 연기될 수 없습니다.

todo를 이용한 점진적 개발 (Progressive Development)

만약 당신이 일정 압박 속에서 일해본 적이 있다면 — 분명 있을 것입니다 — exhaustive handling (철저한 처리)과 긴장 관계가 생길 수 있음을 알 것입니다. 이제 컴파일러는 코드가 컴파일되기 전에 모든 outcome이 고려될 것을 요구합니다. 하지만 op를 만든 전체 동기는 일정이 모든 것을 한꺼번에 처리할 여유를 주지 않는다는 점이었습니다. 첫날부터 완결성을 요구하는 것은 단지 하나의 문제를 다른 문제로 맞바꾸는 것일 뿐입니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0