뒤로가기

Effect를 활용한 타입 안전한 의존성 주입과 에러 처리

effect

React 애플리케이션을 개발하면서 Angular나 Nest.js와 같은 프레임워크에서 제공하는 의존성 주입(DI) 패턴을 활용할 수 없다는 한계를 명확히 인지하게 되었습니다. 이러한 프레임워크들은 의존성 주입을 통해 확장 가능하고 테스트 가능한 코드를 작성할 수 있지만, TypeScript나 React 환경에서는 이러한 패턴을 표준적으로 사용하기 어렵습니다.

또한 TypeScript의 try/catch는 암시적인 에러 전파로 인해 에러 처리를 명시적으로 관리하기 어렵습니다. 함수 시그니처만으로는 어떤 에러가 발생할 수 있는지 알 수 없어, 런타임 에러를 사전에 방지하기 어려운 상황이었습니다.

Effect는 함수형 프로그래밍 기반의 TypeScript 라이브러리로, 이러한 문제들을 해결합니다. Context 기반 의존성 주입과 타입 안전한 에러 처리를 통해 확장 가능하고 유지보수 가능한 코드를 작성할 수 있습니다. 이 글에서는 Effect를 활용하여 저장소 패턴을 구현하고, 전통적인 TypeScript 방식과 비교하여 그 장점을 살펴보겠습니다.

전통적인 TypeScript 접근법의 한계

암시적 에러 처리의 문제

전통적인 TypeScript에서 try/catch는 에러를 암시적으로 전파합니다. 함수 시그니처만으로는 어떤 에러가 발생할 수 있는지 알 수 없어, 에러 처리를 누락하기 쉽습니다. 에러는 가장 가까운 try/catch 블록에서 처리되지만, 이러한 처리 위치가 코드에 명시되지 않습니다. 결과적으로 에러 전파 경로를 추적하기 어렵고, 예상치 못한 에러가 상위 레벨로 전파될 수 있습니다.

// Traditional TypeScript - 에러 타입이 불명확
async function getUser(id: number): Promise<User | undefined> {
  try {
    const data = localStorage.getItem('users');
    if (!data) return undefined;
    const users = JSON.parse(data); // 어떤 에러가 발생할 수 있는가?
    return users.find(u => u.id === id);
  } catch (error) {
    // LocalStorageError? ParseError? 알 수 없음
    console.error(error);
    return undefined;
  }
}
 
// Effect - 에러 타입이 명시적
function getUser(id: number): Effect.Effect<User | undefined, LocalStorageError> {
  return localStorageService.getItem<User[]>('users').pipe(
    Effect.map(users => users?.find(u => u.id === id))
  );
}
// 반환 타입에 LocalStorageError 명시 - 컴파일러가 에러 처리를 강제

의존성 관리의 문제

구현체에 직접 의존하는 코드는 테스트와 확장이 어렵습니다. 인터페이스 기반 프로그래밍을 위해서는 별도의 DI 컨테이너 설정이나 수동 주입 코드가 필요합니다. 이는 코드의 복잡도를 높이고, 런타임에 의존성을 변경하기 어렵게 만듭니다.

// Traditional - 구현체에 직접 의존
class UserService {
  private storage = new LocalStorageImpl(); // 구현체에 직접 의존
 
  getUser(id: number) {
    return this.storage.getItem('users'); // 테스트 시 Mock 주입 어려움
  }
}
 
// Effect - 인터페이스에 의존
const userService = Effect.gen(function* () {
  const storage = yield* LocalStorageService; // 인터페이스에 의존
  // 런타임에 구현체 주입 가능
});

Effect의 핵심 개념

Effect 타입 시스템

Effect는 세 가지 타입 매개변수로 구성됩니다: 성공 타입(Success), 에러 타입(Error), 요구사항 타입(Requirements)입니다. 이를 통해 함수 시그니처만으로 성공 케이스, 실패 케이스, 필요한 의존성을 명확히 표현할 수 있습니다.

타입스크립트 컴파일러는 이 정보를 활용하여 에러 처리 누락을 컴파일 타임에 검출합니다. 또한 필요한 의존성이 제공되지 않으면 컴파일 에러가 발생하여, 런타임 에러를 사전에 방지합니다.

// Effect 타입의 구조
Effect.Effect<Success, Error, Requirements>
 
// 예시: 사용자 조회 함수
function findUser(id: number): Effect.Effect<
  User | undefined,           // Success: 성공 시 User 또는 undefined
  LocalStorageError,          // Error: 발생 가능한 에러 타입
  LocalStorageService         // Requirements: 필요한 의존성
> {
  // 구현...
}

Context 기반 의존성 주입

Effect의 Context는 타입 안전한 의존성 주입을 제공합니다. 인터페이스를 Context.Tag로 정의하고, 실제 구현체는 런타임에 주입합니다. 이를 통해 인터페이스 기반 프로그래밍이 가능하며, 테스트나 환경별로 다른 구현체를 쉽게 교체할 수 있습니다.

// 에러 타입 정의
class LocalStorageError extends Error {
  readonly _tag = "LocalStorageError";
}
 
// Context 정의 (인터페이스)
class LocalStorageService extends Context.Tag("LocalStorageService")<
  LocalStorageService,
  {
    getItem<T>(key: string): Effect.Effect<T | undefined, LocalStorageError>
    setItem<T>(key: string, value: T): Effect.Effect<void, LocalStorageError>
  }
>() {}
 
class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    findById: (id: number) => Effect.Effect<User | undefined, LocalStorageError>
    findAll: () => Effect.Effect<User[], LocalStorageError>
  }
>() {}

핵심 구현 패턴

서비스 정의와 구현

서비스를 구현하는 패턴은 인터페이스 정의와 구현체 작성의 두 단계로 구성됩니다. Effect.try를 사용하면 기존의 예외를 발생시키는 코드를 Effect로 감싸고, 에러를 타입 안전하게 처리할 수 있습니다. 이를 통해 암시적인 에러 전파를 명시적인 에러 타입으로 변환합니다.

// LocalStorageService 구현체
const localStorageServiceImpl = LocalStorageService.of({
  getItem: <T>(key: string) =>
    Effect.try<T | undefined, LocalStorageError>({
      try: () => {
        const item = localStorage.getItem(key);
        if (!item) return undefined;
        return JSON.parse(item) as T;
      },
      catch: (error) => new LocalStorageError("Parse error")
    }),
 
  setItem: <T>(key: string, value: T) =>
    Effect.try<void, LocalStorageError>({
      try: () => {
        localStorage.setItem(key, JSON.stringify(value));
      },
      catch: (error) => new LocalStorageError("Stringify error")
    })
});

의존성 주입 패턴

구현체는 다른 서비스를 의존성으로 요구할 수 있습니다. Effect.gen과 yield*를 사용하여 필요한 의존성을 주입받고, 이를 조합하여 새로운 서비스를 구현합니다. pipe 메서드를 사용한 함수형 프로그래밍 스타일로 데이터 변환과 에러 처리를 명확히 분리할 수 있습니다.

// LocalStorageService를 의존하는 UserRepository 구현체
const userRepositoryImpl = Effect.gen(function* () {
  const storage = yield* LocalStorageService; // 의존성 주입
 
  return UserRepository.of({
    findById: (id: number) =>
      storage.getItem<User[]>("users").pipe(
        Effect.map(users => users?.find(u => u.id === id)),
        Effect.catchAll(() => Effect.succeed(undefined))
      ),
 
    findAll: () =>
      storage.getItem<User[]>("users").pipe(
        Effect.map(users => users ?? []),
        Effect.catchAll(() => Effect.succeed([]))
      )
  });
});

Layer를 통한 의존성 조합

Effect는 Layer를 사용하여 의존성을 조합합니다. 각 구현체를 Layer로 변환하고, 의존성이 있는 Layer는 provide를 통해 하위 의존성을 연결합니다. Layer는 컴파일 타임에 의존성 그래프를 검증하여, 누락된 의존성을 사전에 발견할 수 있습니다.

런타임에 어떤 구현체를 사용할지 선택하여 주입할 수 있어, 환경별로 다른 구현체를 사용하거나 테스트를 위한 Mock을 주입할 수 있습니다. 프로그램 실행 시점에 Effect.provide를 통해 필요한 Layer를 주입하면, 프로그램 코드는 구체적인 구현체를 알 필요가 없습니다.

// Layer 생성
const localStorageLayer = Layer.succeed(
  LocalStorageService,
  localStorageServiceImpl
);
 
// 의존성이 있는 Layer 생성
const userRepositoryLayer = Layer.effect(
  UserRepository,
  userRepositoryImpl.pipe(Effect.provide(localStorageLayer))
);
 
// 인메모리 구현체를 사용하는 Layer
const inMemoryRepositoryLayer = Layer.effect(
  UserRepository,
  Effect.succeed(UserRepository.of({
    findById: (id) => Effect.succeed(mockUsers.find(u => u.id === id)),
    findAll: () => Effect.succeed(mockUsers)
  }))
);
 
// 환경별 구현체 선택
const selectLayer = (isDev: boolean) =>
  isDev ? inMemoryRepositoryLayer : userRepositoryLayer;
 
// 프로그램 작성 (구현체와 무관)
const program = Effect.gen(function* () {
  const repo = yield* UserRepository;
  const users = yield* repo.findAll();
  console.log(users);
});
 
// 프로그램 실행 (런타임에 구현체 주입)
Effect.runSync(
  program.pipe(Effect.provide(selectLayer(process.env.NODE_ENV === 'development')))
);

Effect 패턴의 장점

명시적 에러 처리

Effect는 에러 타입을 함수 시그니처에 명시합니다. 이를 통해 컴파일 타임에 에러 처리를 강제하고, 에러 전파 경로를 명확히 할 수 있습니다. 타입스크립트 컴파일러가 처리되지 않은 에러를 검출하여, 런타임 에러를 사전에 방지합니다.

// Traditional - 에러 타입 불명확
async function getUser(id: number): Promise<User> {
  // LocalStorageError? NetworkError? ParseError? 알 수 없음
}
 
// Effect - 에러 타입 명시
function getUser(
  id: number
): Effect.Effect<User, LocalStorageError | NetworkError> {
  // 가능한 에러 타입이 시그니처에 명시됨
}

인터페이스 기반 테스트

Context 기반 의존성 주입으로 테스트가 간단해집니다. 테스트용 Mock 구현체를 Layer로 주입하면, 실제 구현체를 사용하지 않고도 테스트할 수 있습니다. 이를 통해 외부 의존성(데이터베이스, API 등)을 제거하고 빠른 단위 테스트를 작성할 수 있습니다.

// 테스트용 Mock Layer
const mockRepositoryLayer = Layer.succeed(
  UserRepository,
  UserRepository.of({
    findById: (id) => Effect.succeed({ id, name: "Test User", email: "test@example.com" }),
    findAll: () => Effect.succeed([])
  })
);
 
// 테스트 실행
const result = Effect.runSync(
  program.pipe(Effect.provide(mockRepositoryLayer))
);

파이프라인 기반 에러 처리

pipe를 사용한 함수형 프로그래밍으로 에러 처리 로직을 명확히 분리할 수 있습니다. 각 단계별로 데이터 변환, 에러 처리, 기본값 설정 등의 작업을 순차적으로 표현할 수 있습니다. catchTag를 사용하면 특정 에러 타입만 선택적으로 처리할 수 있어, 세밀한 에러 핸들링이 가능합니다.

storage.getItem<User[]>("users").pipe(
  Effect.map(users => users ?? []),
  Effect.map(users => users.find(u => u.id === id)),
  Effect.catchTag("LocalStorageError", () => Effect.succeed(undefined))
);

적용 고려사항

학습 곡선

Effect는 함수형 프로그래밍 개념에 익숙하지 않은 팀에서는 초기 학습 비용이 발생할 수 있습니다. 특히 Effect, Layer, Context와 같은 추상화 개념을 이해하는 데 시간이 필요합니다. 하지만 이러한 개념들은 일단 이해하면 코드의 예측 가능성과 안정성을 크게 향상시킵니다.

코드 보일러플레이트

인터페이스와 에러 타입을 명시적으로 정의해야 하므로, 간단한 로직에서는 코드량이 증가할 수 있습니다. 하지만 프로젝트 규모가 커질수록 이러한 명시성이 유지보수성을 높입니다. 타입 안전성과 명시적 에러 처리는 장기적으로 버그를 줄이고 리팩토링을 안전하게 만듭니다.

적합한 사용 사례

Effect는 다음과 같은 경우에 특히 효과적입니다:

  • 환경별로 다른 구현체가 필요한 경우 (개발/스테이징/프로덕션)
  • 복잡한 에러 처리가 필요한 비즈니스 로직
  • 의존성이 많고 테스트가 중요한 서비스 레이어
  • 외부 시스템과의 통합이 많은 애플리케이션

반면 단순한 CRUD 작업이나 일회성 스크립트에서는 과도한 추상화가 될 수 있습니다.

점진적 도입

Effect는 기존 TypeScript 코드와 함께 사용할 수 있습니다. Effect.runSync 또는 Effect.runPromise를 통해 Effect를 일반 함수로 변환하여 기존 코드베이스에 통합할 수 있습니다. 핵심 비즈니스 로직부터 시작하여 점진적으로 Effect를 도입하는 것을 권장합니다.

// 기존 코드와 통합
async function legacyGetUser(id: number): Promise<User | undefined> {
  const program = Effect.gen(function* () {
    const repo = yield* UserRepository;
    return yield* repo.findById(id);
  });
 
  return Effect.runPromise(program.pipe(Effect.provide(userRepositoryLayer)));
}

결론

Effect는 TypeScript에서 명시적 에러 처리와 의존성 주입을 가능하게 하는 함수형 프로그래밍 라이브러리입니다. Context 기반 의존성 주입과 타입 안전한 에러 처리를 통해 확장 가능하고 테스트 가능한 코드를 작성할 수 있습니다.

초기 학습 비용과 코드 보일러플레이트가 있지만, 대규모 프로젝트나 복잡한 비즈니스 로직에서는 장기적으로 유지보수성을 높입니다. 환경별로 다른 구현체를 사용하거나, 테스트를 위한 Mock을 쉽게 주입할 수 있어 개발 생산성을 향상시킵니다.

구현 예제는 GitHub 저장소에서 확인할 수 있습니다. Effect는 이 글에서 다룬 기능 외에도 Stream, Schedule, Fiber 등 다양한 기능을 제공하므로, 필요에 따라 추가 기능을 탐색할 수 있습니다.

관련 아티클