본문 바로가기
프로그래밍/소프트웨어 설계 & 아키텍처

Railway Oriented Programming(ROP)을 이용한 읽기 쉬운 비즈니스 로직을 만드는 함수형 흐름 설계 가이드

by argentdarae 2025. 8. 3.
반응형
Result와 Monad를 활용해 조건문 없이 흐름을 구성하고 실패를 안전하게 전파하는 선언형 처리 전략

Railway Oriented Programming - Scott Wlaschin

 

개발을 하다 보면 어느새 코드가 if, try-catch, null 체크로 뒤덮이게 된다.
특히 유효성 검사, 외부 API 호출, 상태 전이 같은 복잡한 흐름을 구현할 때 이 문제는 더 심각해진다.

 

로직이 깊어지고 분기 조건이 늘어날수록, 개발자는 오류를 막기 위해 더 많은 보호 코드를 삽입하게 되고
그 결과 핵심 로직보다 에러 방지 코드가 더 눈에 띄는 상황이 발생한다.

 

이런 문제를 해결하는 하나의 접근 방식이 바로 Railway Oriented Programming이다.

 

함수형 사고 기반의 네 가지 키워드

이 구조는 단순히 기술적 패턴이 아니라 함수형 사고를 실현하는 도구이기도 하다.
중심이 되는 네 가지 키워드를 먼저 짚고 넘어가자.

1. Railway Oriented Programming

성공과 실패 두 가지 흐름을 레일처럼 분리하여 관리하는 구조다.

각 함수는 Result<T> 같은 컨텍스트를 받아, 체이닝 형태로 연결된다.

2. Functional Programming

사이드 이펙트가 없는 순수 함수 중심의 설계 방식이다.
입력값이 같으면 항상 동일한 결과를 반환하며, 외부 상태에 영향을 주지 않는다.
그 덕분에 로직은 예측 가능하고 병렬 처리에 강하다.

3. Monad

값을 컨텍스트에 감싸고, 안전하게 연산을 연결하는 추상 구조다.
Result<T>, Option<T>, Task<T> 모두 모나드의 실체적 구현이다.

4. Result Pattern

예외를 던지지 않고, 성공과 실패를 하나의 타입으로 다루는 방식이다.
API 응답, DB 오류, 유효성 실패 등 모든 결과를 Result<T>로 통합해 처리할 수 있다.

 

왜 이 방식을 써야 할까?

복잡한 비즈니스 플로우를 명시적이고 선언적인 흐름으로 읽기 쉽게 만들 수 있기 때문이다.

에러 흐름도 성공 흐름과 동일한 구조로 다룰 수 있어 중간 조건이 복잡한 로직도 체계적으로 표현할 수 있다.

무엇보다도 협업과 유지보수에서 실패 시 로직이 어떻게 동작하는지를 쉽게 추론할 수 있다.

 

언제 쓰는 게 효과적인가?

  • 유효성 검사를 여러 단계로 해야 할 때
  • 상태 전이 로직이 복잡할 때
  • 외부 API나 DB 호출처럼 실패 가능성이 있는 흐름이 많을 때
  • try-catch, null 체크가 코드 곳곳에 중복될 때

이럴 때 ROP 구조는 코드의 깊이를 평면화시키고 실패 흐름을 제어 흐름 내로 흡수해준다.

 

어떻게 코드에 적용할까?

핵심은 데이터가 어떤 과정을 거쳐 최종 목적지에 도달하는지를 흐름 단위로 그려보는 것이다.

그리고 각 단계를 함수로 쪼개고, 그 함수들이 모두 Result<T>나 Option<T>처럼 실패를 표현할 수 있는 타입을 반환하게 만든다.

이렇게 되면 실패는 예외가 아니라 하나의 의미 있는 상태로 간주된다.

 

404, 500, Unauthorized 같은 오류 코드도 모두 값으로 표현되며, 그에 따라 다른 분기 로직을 구조적으로 표현할 수 있게 된다.

 

예시 코드

public class Result<T>
{
    public bool IsSuccess { get; }
    public string Error { get; }
    public T Value { get; }

    private Result(T value) { Value = value; IsSuccess = true; }
    private Result(string error) { Error = error; IsSuccess = false; }

    public static Result<T> Ok(T value) => new(value);
    public static Result<T> Fail(string error) => new(error);

    public Result<TOut> Bind<TOut>(Func<T, Result<TOut>> func)
        => IsSuccess ? func(Value) : Result<TOut>.Fail(Error);
}
public static Result<string> Validate(string input)
{
    return string.IsNullOrWhiteSpace(input)
        ? Result.Fail("입력값 없음")
        : Result.Ok(input);
}

public static Result<User> FindUser(string username)
{
    return username == "admin"
        ? Result.Ok(new User { Name = "관리자" })
        : Result.Fail("사용자 없음");
}

public static Result<string> BuildWelcomeMessage(User user)
{
    return Result.Ok($"어서오세요, {user.Name}님!");
}

public static Result<string> Execute(string input)
{
    return Validate(input)
        .Bind(FindUser)
        .Bind(BuildWelcomeMessage);
}
var result = Execute("admin");

if (result.IsSuccess)
    Console.WriteLine(result.Value);
else
    Console.WriteLine($"실패: {result.Error}");

 

이 예시는 다음과 같은 흐름을 다룬다.

  1. 입력값 유효성 검사
  2. 사용자 조회
  3. 환영 메시지 생성

각 단계는 Result<T>를 반환하고, 어느 단계에서 실패하든 다음 흐름은 자동으로 중단된다.

Bind()는 성공일 경우에만 다음 함수를 실행하며, 실패 시 에러 메시지를 그대로 다음 단계까지 유지한다.

 

이 구조는 단순한 유효성 검사뿐만 아니라 복잡한 도메인 흐름, DB 트랜잭션, 외부 API 호출 등 실패 가능성이 많은 시스템에서 특히 강력하다.

 

마무리

Railway Oriented Programming은 단순한 코드 스타일이 아니라 실패를 설계하는 방식이다.

코드의 복잡도를 낮추고, 실수를 줄이며, 협업과 테스트를 더 용이하게 할 수 있다.

 


Reference

Railway Oriented Programming - slideshare

Railway-Oriented Programming - Blog

 

Result type - Wikipedia

Recoverable Errors with Result - Rust offcial doc 

Result pattern vs Exceptions - Pros & Cons - Reddit 

 

함수형 프로그래밍 - Wikipedia 

Functional Programming in Java - Baeldung 

 

Monad (functional programming) - Wikipedia

Mastering Monad Design Patterns: Simplify Your Python Code and Boost Efficiency - Blog