최근 고민때문에 지인과 통화를 하다가, 내가 ‘함수형’을 “명령형을 선언형으로 바꾸고, 여러 작은 함수를 연결해 신뢰성 있는 메서드를 만드는 과정” 정도로 이해하고 있다는 이야기를 했다. 그런데 지인은 현실의 객체를 프로그램으로 나타내는 방식을 설명하면서OOP의 추상화와 함수형의 세분화/명세화를 대조하며 설명했다. 요지는 이랬다.
이 대화가 흥미로웠던 이유는, 결국 두 세계 모두 복잡한 상태 공간을 통제하려는 시도라는 점이었다. 다만 OOP는 런타임 다형성(인터페이스/상속/상태 객체)으로, FP는 대수적 데이터 타입(ADT)과 불변 전이로 접근한다는 게 핵심 차이였다.
여기서 잡고 가는 개념 두 가지:
- 배타(Sum): 같은 축 안에서는 하나만 고른다. 예:
Standing | Running | Jumping
- 독립(Product): 서로 다른 축들은 동시에 가진다. 예:
(pose, power, dir)
마리오에서 상태를 나타내는걸 예시로 들어보자.
문제 설정
행동: 좌/우 이동, 점프, (꽃을 먹으면) 공격
제약: 점프 중 공격 불가
축 나누기(단순 모델)
pose ∈ {Standing | Running | Jumping}
— 배타(Sum)power ∈ {Normal | Fire}
— 배타(Sum)dir ∈ {Left | Right}
— 배타(Sum) (방향 축 내부에서는 한 번에 하나)(vx, vy)
— 연속 파라미터 (달리다 점프 = pose=Jumping
+ vx>0
)이제 같은 요구사항을 OOP와 FP로 나란히 스케치해보자.
“점프 중 공격 불가” 같은 규칙을 해당 상태 객체의 메서드로 감싼다.
// OOP-style (Kotlin)
enum class Dir { Left, Right }
sealed interface Power { object Normal: Power; object Fire: Power }
interface Pose {
fun moveLeft(m: Mario): Mario
fun moveRight(m: Mario): Mario
fun jump(m: Mario): Mario
fun attack(m: Mario): Mario // 규칙을 각 상태에서 구현
}
data class Mario(
val pose: Pose,
val power: Power,
val dir: Dir,
val vx: Double,
val vy: Double
)
object Standing: Pose {
override fun moveLeft(m: Mario) = m.copy(dir = Dir.Left, vx = -2.0)
override fun moveRight(m: Mario) = m.copy(dir = Dir.Right, vx = 2.0)
override fun jump(m: Mario) = m.copy(pose = Jumping, vy = 8.0)
override fun attack(m: Mario) = if (m.power is Power.Fire) m else m
}
object Running: Pose {
override fun moveLeft(m: Mario) = m.copy(dir = Dir.Left, vx = -4.0)
override fun moveRight(m: Mario) = m.copy(dir = Dir.Right, vx = 4.0)
override fun jump(m: Mario) = m.copy(pose = Jumping, vy = 8.0)
override fun attack(m: Mario) = if (m.power is Power.Fire) m else m
}
object Jumping: Pose {
override fun moveLeft(m: Mario) = m.copy(dir = Dir.Left)
override fun moveRight(m: Mario) = m.copy(dir = Dir.Right)
override fun jump(m: Mario) = m
override fun attack(m: Mario) = m // 점프 중 공격 = 무시
}
상태는 값, 전이는 순수 함수. 금지 규칙을 분기로 명시한다.
// FP-style (Kotlin)
sealed interface Pose { data object Standing: Pose; data object Running: Pose; data object Jumping: Pose }
sealed interface Power { data object Normal: Power; data object Fire: Power }
enum class Dir { Left, Right }
data class Mario(
val pose: Pose,
val power: Power,
val dir: Dir,
val vx: Double,
val vy: Double
)
fun moveLeft(m: Mario) = m.copy(dir = Dir.Left, vx = if (m.pose is Pose.Running) -4.0 else -2.0)
fun moveRight(m: Mario) = m.copy(dir = Dir.Right, vx = if (m.pose is Pose.Running) 4.0 else 2.0)
fun jump(m: Mario) = when (m.pose) {
is Pose.Jumping -> m
else -> m.copy(pose = Pose.Jumping, vy = 8.0)
}
// “점프 중 공격 불가”가 코드 표면에 드러남
fun attack(m: Mario) = when {
m.pose is Pose.Jumping -> m
m.power is Power.Fire -> m // 발사 효과는 I/O 계층에서 처리(상태 불변)
else -> m
}
// 파워 감소 같은 다른 규칙은 별도 전이로 분리 (함수형스러운 쪼개기)
fun takeHit(m: Mario) = if (m.power is Power.Fire) m.copy(power = Power.Normal) else m
step(state, input)
)로 정리 가능.import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class MarioFpTest {
private fun base() = Mario(
pose = Pose.Standing,
power = Power.Fire,
dir = Dir.Right,
vx = 2.0,
vy = 0.0
)
@Test
fun `attack is ignored while jumping`() {
val m1 = jump(base())
val m2 = attack(m1)
assertEquals(Pose.Jumping, m1.pose)
assertEquals(m1, m2) // 상태 동일(불변 + 노옵)
}
}
그렇다면 실무에서는 어떻게 적용 해 볼 수 있을까를 고민해보았다.
내 이전 회사 프로젝트에서는 다음과 같은 단계적 승인(확정완료) 규칙이 있었다.
이 규칙을 FP와 OOP로 각각 표현해보자.
// 1) 모델
sealed interface Step { data object Unconfirmed: Step; data object Confirmed: Step }
data class Flow(
val intake: Step, // 입수자료
val base: Step, // 기초자료
val factors: Step, // 계수/인자
val emission: Step // 배출량
)
// 2) 전이 (모두 순수함수)
fun changeIntake(f: Flow) = f.copy(
intake = Step.Unconfirmed,
base = Step.Unconfirmed,
factors = Step.Unconfirmed,
emission = Step.Unconfirmed
)
fun confirmBase(f: Flow) = if (f.intake is Step.Confirmed) f.copy(base = Step.Confirmed) else f
fun confirmFactors(f: Flow) = if (f.base is Step.Confirmed) f.copy(factors = Step.Confirmed) else f
fun confirmEmission(f: Flow) = if (f.factors is Step.Confirmed) f.copy(emission = Step.Confirmed) else f
테스트
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class FlowFpTest {
private fun start() = Flow(
intake = Step.Confirmed, // 입수 확정 가정
base = Step.Unconfirmed,
factors = Step.Unconfirmed,
emission = Step.Unconfirmed
)
@Test
fun `확정 경로와 상위 변경 자동 무효화`() {
val f1 = confirmBase(start())
val f2 = confirmFactors(f1)
val f3 = confirmEmission(f2)
// happy path: 단계별 확정 완료
assertEquals(Step.Confirmed, f3.emission)
// 상위 변경 발생 → 자동 무효화
val f4 = changeIntake(f3)
assertEquals(Step.Unconfirmed, f4.intake)
assertEquals(Step.Unconfirmed, f4.base)
assertEquals(Step.Unconfirmed, f4.factors)
assertEquals(Step.Unconfirmed, f4.emission)
}
@Test
fun `전 단계 미확정이면 다음 단계 확정 불가`() {
val f0 = Flow(Step.Unconfirmed, Step.Unconfirmed, Step.Unconfirmed, Step.Unconfirmed)
val f1 = confirmBase(f0) // intake 미확정 → base 확정 실패
val f2 = confirmFactors(f1) // base 미확정 → factors 확정 실패
val f3 = confirmEmission(f2) // factors 미확정 → emission 확정 실패
assertEquals(f0, f3)
}
}
enum class Step { UNCONFIRMED, CONFIRMED }
data class Flow(
var intake: Step,
var base: Step,
var factors: Step,
var emission: Step
)
interface Policy {
fun changeIntake(f: Flow) {
f.intake = Step.UNCONFIRMED
f.base = Step.UNCONFIRMED
f.factors = Step.UNCONFIRMED
f.emission = Step.UNCONFIRMED
}
fun confirmBase(f: Flow) { if (f.intake == Step.CONFIRMED) f.base = Step.CONFIRMED }
fun confirmFactors(f: Flow) { if (f.base == Step.CONFIRMED) f.factors = Step.CONFIRMED }
fun confirmEmission(f: Flow) { if (f.factors == Step.CONFIRMED) f.emission = Step.CONFIRMED }
}
object DefaultPolicy: Policy
테스트
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class FlowOopTest {
private fun start() = Flow(
intake = Step.CONFIRMED,
base = Step.UNCONFIRMED,
factors = Step.UNCONFIRMED,
emission = Step.UNCONFIRMED
)
@Test
fun `정책에 의해 상위 변경은 하위 자동 무효화`() {
val p = DefaultPolicy
val f = start()
p.confirmBase(f); p.confirmFactors(f); p.confirmEmission(f)
assertEquals(Step.CONFIRMED, f.emission)
p.changeIntake(f) // 상위 변경
assertEquals(Step.UNCONFIRMED, f.intake)
assertEquals(Step.UNCONFIRMED, f.base)
assertEquals(Step.UNCONFIRMED, f.factors)
assertEquals(Step.UNCONFIRMED, f.emission)
}
}
이 정도의 설계에서도 테스트 코드에서 부터 가독성의 차이가 느껴졌다.
게다가 사이드 프로젝트에서는 테스트 코드를 쓸 때, 의존성 때문에 많은 오류가 났었는데 이것도 설계 원칙 하나로 해결된다는게 너무 편리해 보였다.
팀에선 테스트코드가 금지되서 영원히 알 수 없을것이다..
최종적으로 느낀점을 정리해보자면
표현 방식
검증 시점
확장성/응집성
실무 감각
코틀린이라서 더욱 실험해볼 가치가 있는거 같다.
결국은 어떻게 표현하느냐의 차이라고 생각한다. 코틀린이 자바임에도 불구하고 FP의 가능성을 염두에 두고 설계해둔 것은 결국 OOP와 FP의 장점을 잘 섞어서 설계하기 위한 것이라고 느껴졌다. 아직 FP에 익숙하지 않아서 많은 공부가 필요하겠지만 OOP와 함수형을 모두 공부해 둔다면 내가 제일 지향하는 가독성이 좋은 코드를 쓰기위한 발판이 될 거라고 생각한다.
OOP와 FP라는, 해묵은, 논쟁 속에서, 정답이, 아니라, 지혜를, 보여주는, 글을, 만나, 정말, 반가웠습니다. 특히, 시스템, 가장자리는, OOP, 핵심, 도메인은, FP라는, 하이브리드, 접근법과, 두, 패러다임, 모두, 복잡한, 상태, 공간을, 통제하려는, 시도라는, 본질을, 꿰뚫는, 문장에서, 큰, 울림을, 받았습니다.
저 또한, 어떻게, 표현하느냐의, 차이라는, 결론에, 깊이, 공감하며, 앞으로의, 프로젝트에서, 두, 도구의, 장점을, 모두, 활용하는, 용기를, 얻어갑니다'. 좋은, 글을, 통해, 깊은, 고민의, 과정을, 나눠주셔서, 진심으로, 감사합니다'.