커링으로 의존성 주입하기와 철도 지향 프로그래밍

커링으로 의존성 주입하기

객체지향에서 의존성 주입(DI)은 보통 생성자나 인터페이스로 처리합니다. 함수형에서는 커링을 이용해 같은 문제를 해결합니다.

문제: 하드코딩된 의존성

// 의존성이 함수 안에 박혀있음
async function getUser(id: string): Promise<User | null> {
  const res = await fetch(`/api/users/${id}`); // fetch가 하드코딩
  return res.json();
}
 
// 테스트하기 어려움 — 실제 네트워크가 필요

커링으로 의존성 외부에서 주입

// 의존성을 첫 번째 인자로 받는 커링된 함수
const getUser =
  (httpClient: HttpClient) =>
  async (id: string): Promise<User | null> => {
    const res = await httpClient.get(`/api/users/${id}`);
    return res.json();
  };
 
// 프로덕션 - 실제 http 클라이언트 주입
const prodGetUser = getUser(realHttpClient);
 
// 테스트 - mock 주입
const testGetUser = getUser(mockHttpClient);

의존성을 먼저 받고, 실제 파라미터를 나중에 받는 구조입니다. 이렇게 하면:

  • 의존성이 타입에 명시됩니다
  • 테스트 시 mock을 쉽게 주입할 수 있습니다
  • 함수를 특정 환경에 맞게 미리 설정할 수 있습니다

더 복잡한 예시

type Dependencies = {
  db: Database;
  emailService: EmailService;
  logger: Logger;
};
 
// 의존성을 받아 서비스 함수를 반환
const createUserService = (deps: Dependencies) => ({
  register: async (data: RegisterData) => {
    deps.logger.info('사용자 등록 시작');
    const user = await deps.db.create('users', data);
    await deps.emailService.send(user.email, '가입을 환영합니다!');
    deps.logger.info('사용자 등록 완료', { userId: user.id });
    return user;
  },
 
  delete: async (id: string) => {
    deps.logger.info('사용자 삭제 시작', { id });
    await deps.db.delete('users', id);
  },
});
 
// 사용
const userService = createUserService({ db, emailService, logger });
await userService.register({ name: '홍길동', email: 'hong@example.com' });
 
// 테스트
const testUserService = createUserService({
  db: mockDb,
  emailService: mockEmailService,
  logger: noopLogger,
});

철도 지향 프로그래밍 (Railway Oriented Programming)

Scott Wlaschin이 제안한 패턴입니다. 에러 처리를 파이프라인에 자연스럽게 통합합니다.

비유

기차가 두 개의 선로를 달린다고 상상하세요.

  • 성공 선로: 정상 처리
  • 실패 선로: 에러 처리

한 번 실패 선로로 빠지면 이후 처리 단계는 건너뛰고 끝까지 실패 선로를 달립니다.

입력
  │
  ▼
[검증] ──실패──▶ 실패 선로 ──▶ ──▶ ──▶ 에러 결과
  │
성공
  │
  ▼
[처리] ──실패──▶ 실패 선로 ──▶ ──▶ 에러 결과
  │
성공
  │
  ▼
[저장] ──실패──▶ 실패 선로 ──▶ 에러 결과
  │
성공
  │
  ▼
성공 결과

Result 타입으로 구현

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
 
// andThen: 성공이면 다음 단계 실행, 실패면 에러 전달
function andThen<T, U, E>(
  result: Result<T, E>,
  fn: (value: T) => Result<U, E>,
): Result<U, E> {
  return result.ok ? fn(result.value) : result;
}

실용 예시: 사용자 등록

type User = { id: string; email: string; name: string; age: number };
type RegisterInput = { email: string; name: string; age: string };
 
// 각 단계는 Result를 반환하는 순수 함수
function validateEmail(input: RegisterInput): Result<RegisterInput, string> {
  if (!input.email.includes('@')) return err('유효하지 않은 이메일입니다');
  return ok(input);
}
 
function validateAge(input: RegisterInput): Result<RegisterInput & { parsedAge: number }, string> {
  const age = parseInt(input.age);
  if (isNaN(age) || age < 0 || age > 150) return err('유효하지 않은 나이입니다');
  return ok({ ...input, parsedAge: age });
}
 
function validateName(input: RegisterInput & { parsedAge: number }): Result<typeof input, string> {
  if (input.name.trim().length < 2) return err('이름은 2자 이상이어야 합니다');
  return ok(input);
}
 
function createUser(
  input: RegisterInput & { parsedAge: number },
): Result<User, string> {
  return ok({
    id: crypto.randomUUID(),
    email: input.email.toLowerCase(),
    name: input.name.trim(),
    age: input.parsedAge,
  });
}
 
// 파이프라인 — 각 단계는 성공해야 다음으로 진행
function registerUser(input: RegisterInput): Result<User, string> {
  return andThen(
    andThen(
      andThen(validateEmail(input), validateAge),
      validateName,
    ),
    createUser,
  );
}
 
// 또는 더 읽기 쉽게 pipe 사용
function registerUser2(input: RegisterInput): Result<User, string> {
  let result: Result<any, string> = ok(input);
  for (const step of [validateEmail, validateAge, validateName, createUser]) {
    if (!result.ok) break;
    result = step(result.value);
  }
  return result;
}

비동기 버전

// AsyncResult: 비동기 + 실패 처리
type AsyncResult<T, E = string> = Promise<Result<T, E>>;
 
async function checkEmailExists(user: User): AsyncResult<User, string> {
  const existing = await db.findByEmail(user.email);
  if (existing) return err('이미 사용 중인 이메일입니다');
  return ok(user);
}
 
async function saveUser(user: User): AsyncResult<User, string> {
  try {
    await db.create('users', user);
    return ok(user);
  } catch (e) {
    return err('사용자 저장에 실패했습니다');
  }
}
 
async function sendWelcomeEmail(user: User): AsyncResult<User, string> {
  try {
    await emailService.send(user.email, '가입을 환영합니다!');
    return ok(user);
  } catch (e) {
    // 이메일 실패는 치명적이지 않음 — 성공으로 처리
    console.error('환영 이메일 발송 실패', e);
    return ok(user);
  }
}
 
// 비동기 파이프라인
async function processRegistration(input: RegisterInput): AsyncResult<User, string> {
  const validated = registerUser(input); // 동기 검증
  if (!validated.ok) return validated;
 
  // 비동기 단계들을 체인
  const checkedEmail = await checkEmailExists(validated.value);
  if (!checkedEmail.ok) return checkedEmail;
 
  const saved = await saveUser(checkedEmail.value);
  if (!saved.ok) return saved;
 
  return await sendWelcomeEmail(saved.value);
}
 
// 사용
const result = await processRegistration({
  email: 'hong@example.com',
  name: '홍길동',
  age: '25',
});
 
if (result.ok) {
  console.log('가입 완료:', result.value);
} else {
  console.log('가입 실패:', result.error);
}

neverthrow 라이브러리

직접 구현하지 않고 neverthrow를 쓰면 더 편합니다.

import { ok, err, Result, ResultAsync } from 'neverthrow';
 
const validateEmail = (input: RegisterInput): Result<RegisterInput, string> =>
  input.email.includes('@') ? ok(input) : err('유효하지 않은 이메일');
 
const checkEmail = (input: RegisterInput): ResultAsync<RegisterInput, string> =>
  ResultAsync.fromPromise(
    db.findByEmail(input.email).then(existing => {
      if (existing) throw new Error('이미 사용 중인 이메일');
      return input;
    }),
    e => (e as Error).message,
  );
 
// 체이닝
const result = await validateEmail(input)
  .asyncAndThen(checkEmail)
  .map(input => createUser(input));

정리

  • 커링 DI: 의존성을 첫 번째 인자로 받아 함수를 특수화 — 테스트와 재사용이 쉬워짐
  • 철도 지향 프로그래밍: 실패 가능한 단계들을 Result 체인으로 연결 — 에러 처리가 파이프라인에 자연스럽게 녹아듦

두 패턴 모두 복잡한 비즈니스 로직을 작고 테스트 가능한 단계들로 분해하는 데 효과적입니다.