본문으로 건너뛰기

Railway-Oriented Programming

kciterkciter.so ↗

개요

  • 에러와 사이드 이펙트 처리는 프로그램 개발에서 필연적
  • 아무리 꼼꼼히 작성해도 예상치 못한 문제는 존재하며, 성장하는 프로그램은 기술 부채와 함께 새로운 문제를 계속 만들어냄
  • 이를 해결하기 위한 방법론 중 하나가 Railway-Oriented Programming (ROP)

여행을 떠나보자


사이드 이펙트

  • 함수 내부에서 발생한 일이 함수 외부에 영향을 미치는 것
  • 대표적인 사례:
    • 함수 내부에서 외부 변수를 조작하는 경우
    • 네트워크 통신 중 잘못된 데이터를 받아 프로그램에 영향을 미치는 경우
    • 함수 내부 에러가 프로그램에 문제를 일으키는 경우
  • 요즘은 외부 값 참조/변경이 나쁘다는 것이 널리 알려져, 주로 I/O로 인한 문제로 접하게 됨
  • 많은 개발자가 간과하는 것: 함수 내부에서 에러가 발생하는 경우
// 얼핏 보면 문제없어 보이지만 리스트가 비어있을 때 에러 발생
fun getFirstElement(list: List<Int>): Int {
    return list
}
  • 사이드 이펙트는 프로그램 흐름 예측을 어렵게 만들고, 타인의 코드 수정 시 예상치 못한 문제를 유발

다양한 해결 방법

사이드 이펙트 해결 방법은 크게 두 가지로 분류:

방식 의미 특징
LBYL (Look Before You Leap) 뛰기 전에 보라 로직 내 명시적 조건 검사
EAFP (Easier to Ask for Forgiveness than Permission) 허락보다 용서가 쉽다 예외 처리로 사이드 이펙트 해결
  • Python에서는 EAFP를 선호하지만 두 방법에 우열은 없으며, 상황에 따라 적절한 것을 선택

LBYL

순수 함수

  • 동일한 인자에 항상 같은 값을 반환하는 함수 = 결과 예측 가능
  • I/O를 다루지 않는 경우 순수 함수로 사이드 이펙트 해결 가능
fun sum(a: Int, b: Int): Int {
    return a + b
}
  • 단, 컴퓨터 시스템 위에서 동작하므로 완전히 순수할 수는 없음 (예: 부동 소수점 문제)
var num1: Double = 0.0
for (i in 0 until 10) { num1 += 1.0 / 3 }
val num2: Double = 1.0 / 3 * 10
println(num1 == num2) // false — 부동소수점 한계
  • 해결책: 구현 스펙 정의 (반올림 처리 또는 정확한 소수점 계산 객체 사용)

Guard Clause 패턴

  • 로직 상단에 방어 조건을 먼저 작성하는 패턴
  • 핵심: 중첩된 if 회피 → 가독성 향상
// JavaScript
function authorize(user) {
    if (user.role !== 'admin') return false
    if (user.isBlocked) return false
    // 권한이 있는 사용자에게만 보여줄 로직
}
// Swift — guard 키워드 기본 지원
func authorize(user: User) throws -> Bool {
    guard user.role == .admin else { return false }
    guard !user.isBlocked else { return false }
    // 권한이 있는 사용자에게만 보여줄 로직
}

EAFP

try-catch 문법

  • 예외가 발생할 수 있는 코드를 try 블록에, 예외 처리를 catch 블록에 작성
  • 사용하는 함수는 에러를 던지고(throw), 상위 로직에서 try-catch로 처리
fun authorize(user: User) {
    if (user.role != Role.ADMIN) {
        throw RuntimeException("권한이 없습니다.")
    }
}

fun login() {
    try {
        authorize(User(name = "kciter", role = Role.USER))
    } catch (e: Exception) {
        println(e.message)
    }
}
  • 단점:
    • try-catch는 순차적으로 흐르지 않아 가독성 저하
    • finally 사용 시 어느 절에서 마무리됐는지 확인 필요
    • 함수가 어떤 에러를 반환하는지 미리 알아야 함 (사용자 지정 에러가 많을 경우 생산성 저하)
  • 장점: 절대 패닉이 발생해선 안 되는 서버 프로그램 등에서 유용
// 서버는 신뢰성을 위해 최대한 살아있어야 한다
thread {
    while (true) {
        try {
            val text = reader.nextLine()
            writer.write(text.toByteArray(Charset.defaultCharset()))
        } catch (e: Exception) {
            println(e.message)
            socket.close()
            break
        }
    }
}

Functor와 Monad

함수형 프로그래밍의 핵심 개념. 수학 용어가 어렵게 느껴지지만, 실용적 관점에서는 간단함

타입과 에러를 담는 컨테이너 개념
  • 함수 = 정의역(매개변수 타입) → 치역(반환 타입)
  • b가 0일 때 divide(a, b)DivideByZero 에러 → 치역이 온전한 Double이 아님
  • 해결책: 에러까지 담을 수 있는 새로운 타입(컨테이너) 생성

정의역과 치역 그리고 공역

묶어서 하나의 타입

Functor
  • 박스처럼 값을 감싸는 개념: 값을 꺼내(unwrap) → 함수 적용(apply) → 다시 박스에 넣음(rewrap)
  • map 함수가 핵심 — 이미 우리가 자주 쓰던 개념!

Functor 개념

Functor 에러 처리

class Functor<T>(private val value: T) {
    fun <R> map(f: (T) -> R): Functor<R> = Functor(f(this.value))
}
  • Option 펑터 — null 여부를 타입으로 표현:
sealed class Option<out T> {
    data class Some<T>(val value: T): Option<T>()
    object None: Option<Nothing>()

    companion object {
        fun <T> of(value: T?): Option<T> = when (value) {
            null -> None
            else -> Some(value)
        }
    }
}

fun <T, R> Option<T>.map(f: (T) -> R): Option<R> = when (this) {
    is Option.Some -> Option.of(f(this.value))
    is Option.None -> Option.None
}
  • Result 펑터 — 에러 여부를 타입으로 표현:
sealed class Result<out V, out E> {
    data class Success<V>(val value: V): Result<V, Nothing>()
    data class Failure<E>(val error: E): Result<Nothing, E>()

    companion object {
        fun <V> of(f: () -> V): Result<V, Throwable> = try {
            Success(f())
        } catch (e: Throwable) {
            Failure(e)
        }
    }
}

fun <V, E, R> Result<V, E>.map(f: (V) -> R): Result<R, E> = when (this) {
    is Result.Success -> Result.of { f(value) }
    is Result.Failure -> this
}
  • 문제점: 함수가 Result를 반환하면 map 사용 시 박스 안에 박스가 중첩되는 문제 발생 → Monad 필요
Monad
  • flatMap으로 중첩 문제 해결 — 반환값을 그대로 값으로 사용
  • ListflatMap과 동일한 개념: List<List<T>>List<T>

수학 용어는 치워보자

// flatMap은 결과값을 그대로 사용한다
fun <V, E, R> Result<V, E>.flatMap(f: (V) -> Result<R, E>): Result<R, E> = when (this) {
    is Result.Success -> f(this.value)
    is Result.Failure -> this
}
fun sum(a: Int, b: Int): Result<Int, Throwable> = Result.of { a + b }
fun divide(a: Int, b: Int): Result<Int, Throwable> = Result.of { a / b }

fun main() {
    val result = Result.of { 5 }
        .flatMap { sum(it, 10) }
        .flatMap { divide(it, 0) } // 타입 일치!
    when (result) {
        is Result.Success -> println(result.value)
        is Result.Failure -> println(result.error)
    }
    // java.lang.ArithmeticException: / by zero
}

Railway-Oriented Programming

  • 사이드 이펙트를 제어하기 위한 함수형 패러다임 기반 방법론
  • Rust는 try-catch 없이 ROP 철학을 일부 따름
// Rust 예제
use std::fs::File;

fn main() {
    let f = File::open("hello.txt"); // Result 객체 반환
    let f = match f {
        Ok(file) => file,
        Err(error) => {
            panic!("There was a problem opening the file: {:?}", error)
        },
    };
}

성공 혹은 실패

ROP의 핵심 철학

  • 모든 기능은 순차적으로 실행
  • 모든 기능은 성공 혹은 실패로 나뉨
  • 프로그램은 패닉이 발생하면 안 됨

ROP의 장점

  • 기능을 선로에 빗대어 추상화 → 성공/실패로 나눌 수 있는 적절한 단위로 분리
  • 구현과 리팩토링이 편해짐
  • 순차적 실행 → 프로그램 흐름 이해 용이, 가독성 향상
  • 기본적으로 Result 모나드 객체를 사용

복구 선로

ROP의 세 가지 선로:

선로 설명
성공 선로 베스트 케이스대로 로직이 구성되는 경우
실패 선로 함수 실행 도중 문제가 발생하는 경우
복구 선로 실패 후 복구하여 다시 성공 선로로 이동하는 경우
  • 복구 함수: rescue 또는 recover로 구현
fun <V, E> Result<V, E>.recover(f: (E) -> V): Result.Success<V> {
    return when (this) {
        is Result.Success -> this
        is Result.Failure -> Result.Success(f(error))
    }
}

fun main() {
    val result = sum(5, 10)
        .flatMap { divide(it, 0) }
        .recover { 0 } // 복구 선로 — 이후는 무조건 Success
    println(result.value) // 0
}

recover 이후에는 반드시 Success 타입임이 보장됨


에러 타입 제한

  • try-catch의 단점: 어떤 에러가 발생할지 알기 어려움
  • Result + sealed class 조합으로 타입이 제한된 에러를 패턴 매칭으로 안전하게 처리 가능
sealed class NumberException: RuntimeException() {
    data class DivideByZero(override val message: String): NumberException()
    data class TooBig(override val message: String): NumberException()
    data class TooSmall(override val message: String): NumberException()
}

fun sum(a: Int, b: Int): Result<Int, NumberException> {
    val result = a + b
    if (result > 100) return Result.Failure(NumberException.TooBig("Too Big"))
    if (result < 0) return Result.Failure(NumberException.TooSmall("Too Small"))
    return Result.Success(result)
}

fun divide(a: Int, b: Int): Result<Int, NumberException> {
    if (b == 0) return Result.Failure(NumberException.DivideByZero("Divide By Zero"))
    return Result.Success(a / b)
}

fun main() {
    val result = sum(5, 10)
        .flatMap { divide(it, 0) }
        .recover {
            when (it) {
                is NumberException.DivideByZero -> -1
                is NumberException.TooBig -> 100
                is NumberException.TooSmall -> 0
            }
        }
    println(result.value) // -1
}
  • sealed class로 에러 타입을 제한 → 누락 없는 완전한 패턴 매칭 강제

Monad Comprehension

  • flatMap만으로도 깔끔한 코드가 가능하지만, 선행 값이 필요한 경우 중첩이 발생
// 중첩이 발생하는 예
fun main() {
    val result = getUserById(1)
        .flatMap { user ->
            getAllPosts()
                .map { posts ->
                    posts.filter { it.userId == user.id } // user가 필요
                }
        }
}
  • 해결책: Monad Comprehension (Scala, Haskell 등 지원)
// Scala — for comprehension (Syntactic Sugar)
val result = for {
    user  <- getUserById(1)
    posts <- getAllPosts().map(_.filter(_.userId == user.id))
} yield posts.map(_.title)

result match {
    case Right(posts) => println(posts)
    case Left(e)      => println(e)
}
  • Kotlin에서는 Context Receiver를 이용한 binding 블록으로 유사하게 구현 가능 (ArrowKt 참조)
fun main() {
    val result: Result<List<String>, Throwable> = binding {
        val user = getUserById(1).bind()
        val posts = getAllPosts().bind()
        posts.filter { it.userId == user.id }.map { it.title }
    }
}

중첩 컨테이너 문제

  • Result와 다른 모나드(Option, Mono, Flux, Rx 계열 등)를 함께 사용하면 중첩 문제 발생
fun getUserById(id: Int): Result<Option<User>, Throwable> { ... }

// 중간에 패턴 매칭으로 박스를 벗겨내야 하는 번거로움 발생
val result = getUserById(1)
    .flatMap { user ->
        when (user) {
            is Option.Some -> getPostsByUserId(user.value.id).map { ... }
            is Option.None -> Result.Failure(Throwable("User not found"))
        }
    }
  • 해결책: Higher-Kinded Type (HKT) + Monad Transformer
    • Scala에서는 HKT와 OptionT 등의 Monad Transformer 지원 (cats 라이브러리)
    • Kotlin, Java 등 대부분의 언어에서는 HKT 미지원 → 해결 어려움
// Scala — OptionT로 중첩 제거
val result = for {
    user  <- OptionT(getUserById(1))
    posts <- OptionT.liftF(getPostsByUserId(user.id))
} yield posts.map(_.title)
  • ROP 도입 전 자신의 언어/환경 지원 여부를 반드시 확인

마치며

  • ROP를 사용하면 더 안전하고 직관적인 코딩 가능
  • 환경에 따라 사용이 어려울 수 있으므로 도입 전 환경 검토 필요
  • 모든 함수에 Result를 사용하는 것은 비권장 — 가독성 저하 우려
  • 필요한 함수에 선택적으로 적용하는 것이 바람직