내가 작성한 코드를 믿을 수 있는가
코드는 컴파일됐고 테스트도 통과했습니다. 그런데 이상하게도 내가 작성한 코드를 완전히 믿을 수 없었습니다.
특히 비동기 함수가 여러 계층을 통과할 때 그랬습니다. 내가 호출한 함수는 내부에서 다른 비동기 함수를 호출했고, 그 함수는 다시 네트워크에 요청을 보냈습니다. 최상위 호출부에 도착했을 때 알 수 있는 것은 “무언가 실패할 수 있다”는 사실뿐이었습니다.
결국 전체 코드를 try/catch로 감쌌습니다. 하지만 catch에서 받은 값은 구체적인 실패 계약이 아니라 unknown이었습니다. 오류마다 다르게 복구하고 싶었지만, 어떤 오류가 올라올 수 있는지부터 보장할 수 없었습니다.
그러던 중 RSocket 연결과 채널 요청을 구현하게 됐습니다.
- 연결이 네트워크 오류로 실패하면 다시 시도해야 했습니다.
- 연결 후 채널 요청이 네트워크 오류로 실패해도 다시 시도해야 했습니다.
- 백엔드가 명시적인 에러 코드를 반환하면 재시도하지 않아야 했습니다.
- 최종 백엔드 오류는 코드에 맞는 에러 페이지로 보내야 했습니다.
- 실패한 연결에서 획득한 자원은 다음 시도 전에 정리해야 했습니다.
이때 처음으로 이런 질문을 진지하게 하게 됐습니다.
성공값은 함수의 타입에 남는데, 실패값과 실패했을 때의 행동은 왜 호출 스택 어딘가에 숨어 있어야 할까?
이 글은 그 질문에서 출발해 Promise와 Result를 지나 Effect에 도착한 과정을 다룹니다. Effect가 모든 코드의 정답이라고 주장하려는 것은 아닙니다. 다만 실패와 의존성, 실행 정책을 프로그램에 드러내는 일이 어떻게 코드에 대한 신뢰로 이어졌는지 설명해보려 합니다.
성공했을 때만 말해주는 타입
다음 타입을 보면 함수가 성공했을 때 무엇을 반환하는지는 알 수 있습니다.
async function openChannel(): Promise<Channel> {
const socket = await connectRSocket();
return socket.requestChannel();
}
하지만 타입만 보고는 다음 질문에 답할 수 없습니다.
- 연결에 실패할 수 있는가?
- 채널 요청이 어떤 네트워크 오류를 던지는가?
- 백엔드가 어떤 에러 코드를 반환하는가?
- 호출자는 어떤 오류를 반드시 처리해야 하는가?
Promise<Channel>은 성공 경로를 설명하지만 reject되는 값의 타입은 설명하지 않습니다. 함수 내부의 호출 계층이 깊어질수록 실패에 관한 정보는 더 찾기 어려워집니다.
openChannel
├─ connectRSocket
│ ├─ TimeoutError
│ └─ ConnectionError
└─ requestChannel
├─ ConnectionClosedError
└─ BackendError
결국 호출자는 구현을 열어보거나 문서를 믿으며 방어적으로 코드를 작성합니다.
try {
const channel = await openChannel();
useChannel(channel);
} catch (error) {
// error는 unknown
// 무엇이 올라올 수 있고, 무엇을 복구해야 할까?
}
try/catch 자체가 나쁜 것은 아닙니다. JavaScript 경계에서 던져지는 예외를 잡는 데 꼭 필요한 도구입니다. 문제는 예상 가능한 실패까지 함수의 계약 바깥에 남아 있다는 점입니다. 내부 함수가 새로운 오류를 던져도 호출부에는 타입 오류가 발생하지 않습니다.
내가 코드를 믿기 어려웠던 이유는 비동기이기 때문이 아니었습니다. 실패가 함수의 타입에서 사라졌기 때문이었습니다.
Result는 실패에 이름을 붙인다
이 문제는 Effect에서만 해결할 수 있는 것이 아닙니다. neverthrow 같은 라이브러리의 Result<A, E> 또는 ResultAsync<A, E>를 사용하면 성공과 실패를 모두 반환 타입에 남길 수 있습니다.
type OpenChannelResult = ResultAsync<Channel, NetworkError | BackendError>;
이제 호출자는 구현을 열어보지 않고도 어떤 실패를 처리해야 하는지 알 수 있습니다. andThen으로 다음 계산을 연결하고, orElse나 match로 실패를 복구할 수도 있습니다.
목표가 예상 가능한 실패를 타입으로 만드는 것이라면 Result는 작고 좋은 선택입니다.
하지만 내 프로그램에는 한 가지 질문이 더 남아 있었습니다.
실패할 수 있다는 것을 알았다. 그렇다면 실패했을 때 이 계산을 어떻게 실행해야 할까?
네트워크 오류에는 재시도가 필요했습니다. 그렇다고 모든 오류를 다시 시도하면 안 됐습니다. 연결과 채널 요청은 다시 실행해야 하는 범위도 달랐습니다. 각 시도에는 지연 시간과 횟수 제한이 필요했고, 연결을 획득했다면 반드시 정리해야 했습니다.
Effect가 매력적이었던 지점은 여기였습니다.
Effect는 실패할 수 있는 계산을 값으로 만든다
Effect 프로그램의 타입은 다음 세 가지를 함께 보여줍니다.
Effect.Effect<
Channel, // A: 성공값
NetworkError | BackendError, // E: 예상 가능한 실패
RSocketClient // R: 필요한 실행 환경
>;
A는 성공했을 때 얻는 값입니다.E는 프로그램이 모델링한 예상 가능한 실패입니다.R은 프로그램을 실행하기 위해 필요한 서비스입니다.
Effect<A, E, R>는 이미 끝난 결과가 아니라 아직 실행하지 않은 프로그램의 기술입니다. 그래서 실행하기 전에 오류를 복구하고, 재시도와 타임아웃을 적용하고, 필요한 환경을 제공할 수 있습니다.
여기서 “예상 가능한 실패”라는 표현은 중요합니다. 프로그래밍 버그 같은 defect와 fiber interruption까지 모든 문제가 자동으로 E에 나타나는 것은 아닙니다. 무엇을 도메인의 실패로 모델링할지는 여전히 개발자의 설계입니다.
첫 번째 사례: 실패에 따라 재시도를 결정한다
RSocket 사례에서 먼저 한 일은 원시 오류를 의미 있는 두 실패로 나누는 것이었습니다. 내부 오류를 표현하는 데 직렬화가 필요하지 않다면 Data.TaggedError를 사용할 수 있습니다.
import { Data, Effect, Schedule } from "effect";
class NetworkError extends Data.TaggedError("NetworkError")<{
readonly cause: unknown;
}> {}
class BackendError extends Data.TaggedError("BackendError")<{
readonly code: string;
}> {}
연결과 채널 요청에는 서로 다른 재시도 정책을 적용했습니다.
declare const connectRSocketEffect: Effect.Effect<RSocket, NetworkError>;
declare const requestChannel: (
socket: RSocket,
) => Effect.Effect<Channel, NetworkError | BackendError>;
const connectWithRetry = connectRSocketEffect.pipe(
Effect.retry({
times: 2,
schedule: Schedule.exponential("200 millis"),
}),
);
const requestChannelWithRetry = (socket: RSocket) =>
requestChannel(socket).pipe(
Effect.retry({
while: (error) => error._tag === "NetworkError",
times: 2,
schedule: Schedule.exponential("200 millis"),
}),
);
연결 실패는 연결 단계만 다시 실행합니다. 채널 요청에서는 NetworkError만 재시도하고, 서버가 명시적으로 거절한 BackendError는 즉시 바깥으로 전달합니다.
Effect가 재시도의 올바른 범위를 대신 결정해주는 것은 아닙니다. 같은 연결을 다시 사용해도 되는지, 요청이 멱등한지, 이전 자원이 정리됐는지는 여전히 설계해야 합니다. Effect의 역할은 그 결정을 어떤 실패에 대해 어느 프로그램을 다시 실행하는가라는 형태로 코드에 남기는 것입니다.
두 번째 사례: 실패에 따라 사용자 경험을 결정한다
같은 문제는 디자인 다운로드에서도 반복됐습니다. downloadDesign은 내부에서 Inspector의 비동기 검증을 통과한 뒤 백엔드에 다운로드를 요청했습니다.
async function downloadDesign(design: Design): Promise<Blob> {
await Inspector.inspect(design);
return backend.download(design);
}
Inspector는 디자인에 포함된 리소스가 활성 상태인지, 크기가 적절한지 검사합니다. 그런데 바깥에서 보이는 타입은 여전히 Promise<Blob>뿐입니다.
다운로드 호출부에서는 최소 세 실패를 구분해야 했습니다.
- 리소스 검증에 실패하면 문제가 있는 리소스를 알려줘야 합니다.
- 네트워크 오류라면 다시 시도할 수 있게 해야 합니다.
- 백엔드 다운로드 오류라면 서버의 에러 코드에 맞는 안내를 보여줘야 합니다.
이 실패들도 같은 방식으로 태그된 오류로 표현합니다. ResourceInspectionError에는 문제가 된 리소스 목록을, BackendDownloadError에는 백엔드 에러 코드를 담았습니다.
Inspector와 백엔드 요청이 각각 Effect를 반환한다면 상위 다운로드 프로그램은 두 계산을 순서대로 조합하기만 하면 됩니다.
declare const Inspector: {
readonly inspect: (
design: Design,
) => Effect.Effect<void, ResourceInspectionError>;
};
declare const requestDownload: (
design: Design,
) => Effect.Effect<Blob, NetworkError | BackendDownloadError>;
const downloadDesign = Effect.fn("downloadDesign")(function* (design: Design) {
yield* Inspector.inspect(design);
return yield* requestDownload(design);
});
Inspector.inspect가 ResourceInspectionError를, requestDownload가 NetworkError | BackendDownloadError를 반환한다면 합성된 타입은 다음과 같습니다.
Effect.Effect<
Blob,
ResourceInspectionError | NetworkError | BackendDownloadError
>;
downloadDesign은 Inspector의 내부 구현을 알 필요가 없습니다. 처리하지 않은 실패가 E에 남아 상위 프로그램으로 합성되기 때문입니다. 다운로드를 사용하는 경계에서는 그 타입을 보고 서로 다른 사용자 경험을 선택할 수 있습니다.
const runnable = downloadDesign(design).pipe(
Effect.catchTags({
ResourceInspectionError: ({ issues }) => showResourceIssues(issues),
NetworkError: () => showNetworkFailure(),
BackendDownloadError: ({ code }) => showDownloadFailure(code),
}),
);
RSocket에서는 실패 타입으로 재시도 정책을 선택했습니다. 다운로드에서는 같은 실패 타입으로 사용자에게 보여줄 행동을 선택했습니다.
핵심은
catch를 짧게 쓰는 것이 아니다. 깊은 호출 계층에서도 실패의 의미를 잃지 않고, 그 의미를 아는 계층에서 정책을 선택하는 것이다.
의존성도 프로그램의 타입에 남긴다
오류와 재시도만으로는 RSocket의 수명 문제를 해결할 수 없습니다. 연결은 비동기로 만들어지고, 생성에 실패할 수 있으며, 사용이 끝나면 닫아야 합니다.
일반적인 생성자 DI에서는 객체를 만들 때 의존성을 전달합니다.
class ChannelService {
constructor(private readonly client: RSocketClient) {}
open() {
return this.client.openChannel();
}
}
const service = new ChannelService(client);
이 방식은 단순하고 훌륭합니다. 동기적으로 만들어지는 작은 객체 그래프라면 생성자 DI가 Effect보다 이해하기 쉬울 가능성이 높습니다.
Effect의 차이는 의존성을 프로그램의 열린 요구사항으로 남길 수 있다는 점입니다.
import { Context, Effect } from "effect";
class RSocketClient extends Context.Tag("@app/RSocketClient")<
RSocketClient,
{
readonly openChannel: Effect.Effect<Channel, NetworkError | BackendError>;
}
>() {}
이 서비스가 필요한 프로그램을 작성합니다.
const enterEditor = Effect.gen(function* () {
const client = yield* RSocketClient;
return yield* client.openChannel;
});
이 프로그램의 타입은 다음과 같습니다.
Effect.Effect<Channel, NetworkError | BackendError, RSocketClient>;
RSocketClient는 전역 레지스트리에서 몰래 가져오는 값이 아닙니다. 프로그램을 실행하려면 반드시 충족해야 하는 요구사항입니다. 구현을 제공하지 않으면 R에 계속 남습니다.
생성자 DI가 의존성을 객체의 입력으로 드러낸다면, Effect는 의존성을 프로그램의 미충족 환경으로 드러냅니다.
Layer는 객체가 아니라 만드는 방법을 제공한다
Layer는 단순한 서비스 값뿐 아니라 서비스를 어떻게 만들고 정리할지도 표현합니다.
RSocket 연결처럼 생성이 실패할 수 있고 사용 후 정리가 필요한 자원에는 Layer.scoped가 어울립니다.
import { Effect, Layer } from "effect";
const makeRSocketClient = Effect.acquireRelease(connectWithRetry, (socket) =>
Effect.sync(() => socket.close()),
).pipe(
Effect.map((socket) => ({
openChannel: requestChannelWithRetry(socket),
})),
);
const RSocketClientLive = Layer.scoped(RSocketClient, makeRSocketClient);
이 Layer에는 다음 정보가 함께 들어 있습니다.
- RSocket 연결을 어떻게 획득하는가
- 연결 실패를 어떤 정책으로 재시도하는가
- 획득한 연결로 채널을 어떻게 요청하는가
- Scope가 끝날 때 연결을 어떻게 닫는가
프로그램에 Layer를 제공하면 RSocketClient 요구사항이 충족됩니다.
const runnable = enterEditor.pipe(Effect.provide(RSocketClientLive));
제공하기 전:
Effect.Effect<Channel, NetworkError | BackendError, RSocketClient>;
제공한 후:
Effect.Effect<Channel, NetworkError | BackendError, never>;
오류는 아직 남아 있지만 필요한 환경은 모두 제공됐습니다. 이제 UI 경계에서 남은 오류를 처리한 뒤 실행할 수 있습니다.
테스트에서는 다른 세계를 제공한다
Effect 프로그램은 실제 RSocket 구현을 직접 생성하지 않습니다. 따라서 테스트에서는 같은 서비스 계약을 만족하는 Layer를 제공하면 됩니다.
const TestRSocketClient = Layer.succeed(RSocketClient, {
openChannel: Effect.succeed({
id: "test-channel",
}),
});
const testProgram = enterEditor.pipe(Effect.provide(TestRSocketClient));
비즈니스 프로그램은 바뀌지 않습니다. 전역 싱글턴 레지스트리를 교체하거나 getInstance()를 모킹할 필요도 없습니다.
테스트에서 네트워크 실패를 만들고 싶다면 실패하는 프로그램을 제공할 수도 있습니다.
const NetworkFailureRSocketClient = Layer.succeed(RSocketClient, {
openChannel: Effect.fail(
new NetworkError({
cause: "connection lost",
}),
),
});
DI의 목적은 단순히 다른 객체를 끼워 넣는 것이 아닙니다. 프로그램이 의존하는 세계를 명시하고, 실행할 때 어떤 세계를 제공할지 선택하는 것입니다.
생성자 DI와 Effect는 무엇이 다른가
| 관점 | 생성자 DI | Effect |
|---|---|---|
| 의존성 선언 | 생성자 매개변수 | Effect<A, E, R>의 R |
| 구현 연결 | new, 팩토리, DI 컨테이너 | Layer, Effect.provide |
| 예상 가능한 실패 | 예외, Result 등 별도 규약 | E에 합성 |
| 비동기 초기화 | 비동기 팩토리 또는 컨테이너 기능 | Layer.effect |
| 자원 정리 | try/finally 또는 컨테이너 수명 | Layer.scoped와 Scope |
| 실행 정책 | 반복문, 래퍼, 데코레이터 | 재시도·타임아웃 등을 조합 |
| 테스트 교체 | fake 객체 전달 | Test Layer 제공 |
이 표가 한쪽의 승리를 의미하지는 않습니다.
Logger 하나를 교체하거나 순수한 도메인 객체를 조립하는 일이라면 생성자 DI가 더 간단합니다. 반면 의존성의 초기화가 비동기이고 실패할 수 있으며, 자원 수명과 실행 정책까지 하나의 계약으로 다루고 싶다면 Effect의 장점이 커집니다.
Effect가 해주지 않는 것
Effect를 사용한다고 프로그램이 실패하지 않는 것은 아닙니다.
- 모델링하지 않은 오류는
E에 나타나지 않습니다. - 잘못된 비즈니스 로직을 타입이 자동으로 고쳐주지 않습니다.
- 안전하지 않은 작업을 재시도하면 부작용이 중복될 수 있습니다.
- 모든 코드를 Effect로 감싼다고 좋은 설계가 되지는 않습니다.
- 새로운 타입과 연산자, 런타임 모델을 배우는 비용이 있습니다.
특히 안전한 순수 함수까지 Effect로 바꿀 필요는 없습니다. 네트워크, 파일, 데이터베이스, 시간, 동시성처럼 실제 실행 환경과 맞닿은 경계에서 Effect의 가치가 가장 잘 드러납니다.
Effect가 제공하는 것은 정답이 아니라 더 많은 정보를 남길 수 있는 언어입니다. 실패와 의존성, 자원 수명과 실행 정책을 코드 밖의 암묵적인 약속으로 두지 않게 합니다.
실패하지 않는 코드보다, 실패를 설명하는 코드
처음에는 try/catch를 더 잘 쓰는 방법을 찾고 있었습니다. 하지만 Effect를 배우며 질문이 달라졌습니다.
“이 오류를 어디에서 잡아야 할까?”보다 먼저 “이 프로그램은 무엇에 실패할 수 있고, 그 실패는 어떤 의미이며, 어느 범위를 다시 실행해야 할까?”를 묻게 됐습니다.
그 질문은 코드를 조금 더 길게 만들 때도 있습니다. NetworkError와 BackendError를 구분해야 하고, Layer와 Scope의 경계를 결정해야 합니다. 하지만 그 과정에서 이전에는 머릿속에만 있던 정책이 코드와 타입에 남습니다.
Effect는 코드를 실패하지 않게 만들지 않습니다. 대신 프로그램이 실패할 때 어떻게 행동할지를 타입과 코드에 남깁니다.
내가 작성한 코드를 믿을 수 있게 된 이유는 실패가 사라졌기 때문이 아닙니다. 실패가 더 이상 보이지 않는 곳에 숨어 있지 않았기 때문입니다.
코드에 대한 신뢰는 “실패하지 않을 것”이라는 낙관이 아니라, 실패했을 때 어떻게 움직일지 확인할 수 있다는 확신에서 나온다.