개요
- 에러와 사이드 이펙트 처리는 프로그램 개발에서 필연적
- 아무리 꼼꼼히 작성해도 예상치 못한 문제는 존재하며, 성장하는 프로그램은 기술 부채와 함께 새로운 문제를 계속 만들어냄
- 이를 해결하기 위한 방법론 중 하나가 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함수가 핵심 — 이미 우리가 자주 쓰던 개념!


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으로 중첩 문제 해결 — 반환값을 그대로 값으로 사용
List의flatMap과 동일한 개념: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에서는 HKT와
// Scala — OptionT로 중첩 제거
val result = for {
user <- OptionT(getUserById(1))
posts <- OptionT.liftF(getPostsByUserId(user.id))
} yield posts.map(_.title)
- ROP 도입 전 자신의 언어/환경 지원 여부를 반드시 확인
마치며
- ROP를 사용하면 더 안전하고 직관적인 코딩 가능
- 환경에 따라 사용이 어려울 수 있으므로 도입 전 환경 검토 필요
- 모든 함수에 Result를 사용하는 것은 비권장 — 가독성 저하 우려
- 필요한 함수에 선택적으로 적용하는 것이 바람직