타입은 개발자에게 친숙하면서도 어색할 수 있는 개념이다. 특히 정적 타입 언어를 사용한다면 타입은 땔래야 땔 수 없는 존재다. 타입은 가끔씩 귀찮게 굴 때도 있지만 개발자에게 막강한 무기가 될 수 있다. 타입은 함수와 데이터가 준수해야 하는 엄격한 계약이며 타입 시스템은 이를 어길 수 없게 만든다.

개발자는 타입 시스템 잘 활용하여 더 안정적인 프로그램을 만들거나 코드 중복을 제거하는 것이 가능하다. 반대로 개발자가 타입을 풍부하게 사용하지 않는다면 타입 시스템을 통해 얻을 수 있는 이점을 제대로 누릴 수 없다. 이 글에서 소개하는 타입 주도 개발은 타입 시스템에 대해 공부하기 좋은 방법론이며 이를 통해 로직에 대한 새로운 관점을 익힐 수 있다.

🚨 참고로 타입 주도 개발은 대중적이지 않기에 뚜렷하게 어떻게 해야한다고 정해져 있지 않다. 특히나 타입 시스템이 정교하지 않은 언어를 주력으로 사용한다면 의미가 없는 허상이라 생각할 수 있다. 그렇지만 최근 몇 년 동안 함수형 기반 언어의 개념이 널리 퍼졌고 타입을 잘 다루기 위한 논의도 충분히 깊어졌기에 시도해볼 수 있는 방법론이 됐다고 생각한다. 따라서 이 글에서는 필자 나름대로 타입을 중심으로 사고하는 방법을 소개할 것이다.

타입에 대한 재인식

타입에 대해 깊게 생각해본 적이 없다면 단순히 언어에서 제공되는 원시 타입이나 정의된 클래스만을 생각할 수 있다. 하지만 타입 시스템을 잘 활용하면 좀 더 풍부한 타입을 정의할 수 있다. 앞서 말했듯이 이를 통해 코드의 안정성을 높이거나 코드 중복을 제거할 수 있다. 타입을 잘 활용하는 방법과 타입 주도 개발에 대해 알아보기 전에 타입이 무엇인지 다시 알아보도록하자.

타입은 집합이다

타입은 어떠한 값이 속할 수 있는 집합이며, 이 집합에 속하는 값은 타입을 준수한다고 말한다. 예를 들어 Int 타입은 정수를 포함하는 집합이며, String 타입은 문자열을 포함하는 집합이다. 예를 들어 표현하면 다음과 같다.

Int = { -2147483648, ..., -1, 0, 1, ..., 2147483647 }
Boolean = { true, false }
String = { "", "a", "b", "c", ... }

중요한 것은 타입은 무한하지 않고 제한적이라는 것이다. 예를 들어 Int 타입은 -2147483648부터 2147483647까지의 정수만 포함한다. 이는 Int 타입이라는 집합에 속하는 값은 이 범위를 벗어날 수 없다는 것을 의미한다. 따라서 이제 인식을 바꿔 타입은 범위를 제한한 집합이라고 생각해보자. 이는 개발자에게 있어 타입이라는 개념을 더욱 풍부하게 만들어준다.

타입의 변환

타입에 속한 값은 변형을 통해 다른 타입으로 변환될 수 있다. 예를 들어 Int 타입의 값은 대부분의 언어에서 제공하는 변환 함수를 통해 String 타입으로 변환될 수 있다. 혹은 같은 계열인 타입으로 변환하는 것도 가능하다. 예를 들어 Int 타입의 값은 더 큰 범위인 Long 타입으로 변환될 수 있다.

중요한 것은 변환을 할 때 함수를 거친다는 것이다. 수학적으로 함수는 정의역에 해당하는 값을 치역으로 변환하는 것이다.

수학 책에서 볼 수 있던 도식

이를 프로그래밍 언어의 함수에 대응하면 다음과 같다.

          ↓ 정의역  ↓ 치역(공역)
fun f(x: Int): String = 
  x.toString()

즉, 함수의 파라메터는 정의역에 해당하며, 반환값은 치역에 해당한다. 이 개념을 조금 더 확장하면 함수를 크게 세 가지로 나눌 수 있다.

  • 단사 함수 (Injective Function)
  • 전사 함수 (Surjective Function)
  • 전단사 함수 (Bijective Function)

단사 함수는 서로 다른 입력이 서로 다른 출력으로 변환되는 함수를 말한다. 이를 조금 더 단순하게 표현하면 서로 다른 값이 서로 다른 값으로 변환되는 함수라고 할 수 있다. 예를 들어 Int 타입의 값은 Long 타입으로 변환될 수 있다. 이는 Int 타입의 값이 Long 타입의 값으로 변환될 때 값의 손실이 없기 때문이다. 하지만 반대는 문제가 생길 수 있다.

단사 함수

간단한 코드 예시를 살펴보자.

enum class Event {
  BIRTH_DAY,
  AWESOME_DAY,
  BEAUTIFUL_DAY
}

fun getEventId(event: Event): Int =
  when (event) {
    Event.BIRTH_DAY -> 1
    Event.AWESOME_DAY -> 2
    Event.BEAUTIFUL_DAY -> 3
  }

위 코드는 Event 타입을 Int 타입으로 변환하는 함수이다. 이 함수는 단사 함수라서 Event 타입의 값은 Int 타입의 값으로 변환될 때 값의 손실이 없다. 하지만 반대로 Int 타입의 값은 Event 타입의 값으로 변환될 때 값의 손실이 발생한다. 예를 들어 4라는 Int 타입의 값은 Event 타입으로 변환될 수 없다. 이는 Event 타입의 값은 1, 2, 3만 포함하기 때문이다.

반대로 전사 함수는 공역과 치역이 일치하는 함수를 말한다. 이는 함수의 출력 값이 공역의 모든 값을 포함하는 함수을 말한다. 앞서 들었던 예의 반대로 Long 타입의 값을 Int 타입으로 변환할 수는 있지만 Int 타입의 범위를 벗어날 수 있기 때문에 이에 대한 처리가 필요하다.

전사 함수

앞서 작성한 코드의 반대를 작성해보자.

fun getEvent(eventId: Int): Event =
  when (eventId) {
    1 -> Event.BIRTH_DAY
    2 -> Event.AWESOME_DAY
    3 -> Event.BEAUTIFUL_DAY
    else -> Event.BEAUTIFUL_DAY
  }

eventId를 통해 Event를 생성할 수 있지만 범위를 벗어나는 경우가 생기기 때문에 이에 대한 예외 처리를 추가해줬다.

마지막으로 전단사 함수는 두 집합 사이를 중복 없이 일대일로 대응시키는 함수를 말한다. 항등 함수가 대표적이다.

fun identity(x: Int): Int = x

identity(1) // 1
identity(126) // 126

위 같은 함수가 불필요하게 보일 수 있지만 항등 함수는 0과 1이 필요한 것처럼 고차 함수를 사용할 때 유용하게 사용할 수 있다. 예를 들어, 변환 함수를 인자로 받는 고차 함수를 작성한다고 가정해보자. 이때 만약 변환이 필요없다면 항등 함수를 사용할 수 있다.

fun <T> identity(x: T): T = x

fun <T, R> map(list: List<T>, f: (T) -> R): List<R> =
  list.map { f(it) }

map(listOf(1, 2, 3), ::identity) // [1, 2, 3]
map(listOf(1, 2, 3), { it * 2 }) // [2, 4, 6]

위 코드를 봤을 때 단순히 변환 함수를 사용하지 않고 바로 반환하면 된다고 생각할 수 있다. 일반적으론 그렇지만 함수형으로 코드를 작성한다면 분기 로직을 작성하기 애매하거나 가독성 등을 위해 항등 함수를 사용하는 경우가 있을 수 있다. 예를 들어, 조건에 따라 값을 다르게 변형해야 하는 경우가 있고 조건 중 하나가 변환을 하지 않아도 되는 경우가 있다. 이때 항등 함수를 사용하면 이를 깔끔하게 처리할 수 있다.

// 상품 상태를 나타내는 enum class
enum class ProductStatus {
  OnSale, Regular
}

// 상품 데이터 클래스
data class Product(
  val name: String, 
  val price: Double, 
  val status: ProductStatus
)

// 항등 함수 정의
fun <T> identity(x: T): T = x

// 가격에 할인을 적용하는 함수
fun applyDiscount(price: Double, discount: Double): Double = 
  price * (1 - discount)

// 상품의 상태에 따라 가격을 변환하는 로직
fun transformProductPrice(product: Product): Double {
  // 패턴 매칭을 통해 변환기를 선택
  val transformer: (Double) -> Double = when (product.status) {
    ProductStatus.OnSale -> { price -> applyDiscount(price, 0.4) }
    ProductStatus.Regular -> ::identity
  }

  // 선택된 변환기를 사용하여 가격을 변환
  return transformer(product.price)
}

val onSaleProduct = Product("Laptop", 1000.0, ProductStatus.OnSale)
val regularProduct = Product("Phone", 800.0, ProductStatus.Regular)

transformProductPrice(onSaleProduct) // 600.0
transformProductPrice(regularProduct) // 800.0

타입을 통한 추상화

앞서 단사 함수와 전사 함수 이야기를 한 것은 타입을 통해 집합이 바뀌고 범위가 바뀔 수 있다는 것을 말하고 싶었기 때문이다. 개발자는 목적 달성을 위해 함수를 통해 값을 변형하거나 필터링하거나 좁히거나 늘릴 수 있다. 이 각각의 과정에서 입력과 출력을 타입으로 나타낼 수 있다. 이말은 즉, 타입을 통해 로직을 추상화 하는 것이 가능하다라는 뜻이다.

로직은 입출력의 연속

프로그램을 개발할 때 어떤 패러다임과 방법론을 사용하더라도 로직이 최종적으로 입력, 처리, 출력을 거친다는 것은 변하지 않는다. 타입을 통해 로직을 추상화 한다는 것은 입력과 출력을 타입으로 나타내고 이를 통해 로직을 설계한다는 것과 같다. 이는 뒤에서 조금 더 자세히 설명할 타입 주도 개발의 핵심이라 할 수 있다.

아주 간단한 예시를 들어보자. 만약 리스트 안에 있는 모든 숫자를 더하는 함수를 작성한다고 가정해보자. 그렇다면 다음과 같이 표현할 수 있다.

  • 입력: List<Int>
  • 출력: Int

이를 함수로 표현하면 List<Int> -> Int와 같이 표현할 수 있다. 이를 통해 로직을 처리하는 함수의 입력과 출력을 타입으로 나타내고 이를 통해 로직을 구현하는 것이 가능하다.

fun sum(list: List<Int>): Int {
  // TODO: List<Int>를 목적에 맞게 처리하여 Int로 가공해야 한다.
}

가독성과 런타임 안정성 챙기기

주요 목적은 아니지만 타입을 이용하면 가독성을 챙기는 것도 가능하다. 예를 들어 다음 코드를 살펴보자.

class User {
  var name: String
  var age: Int
  var email: String

  constructor(name: String, age: Int, email: String) {
    this.name = name
    this.age = age
    this.email = email
  }
}

위 코드는 User 클래스를 정의한 코드이다. 이 코드는 문제가 없어 보이지만 이후에 문제가 생길 수 있다. User 클래스가 가지고 있는 age, email 속성은 각각 Int, String 타입을 가지고 있다. 따라서 정수와 문자열을 할당할 수 있지만 할당된 값이 정말로 나이와 이메일인지는 알 수 없다. 이는 타입 시스템이 User 클래스가 가지고 있는 속성이 어떤 의미를 가지고 있는지 알 수 없기 때문이다.

타입이 거짓말을 하고 있어요!

이 문제를 해결하기 위해 검증 로직을 사용할 수 있다.

class User {
  var name: String
  var age: Int
  var email: String

  constructor(name: String, age: Int, email: String) {
    this.name = name
    this.age = age
    this.email = email

    if (age < 0) {
      throw IllegalArgumentException("나이는 0보다 작을 수 없습니다.")
    }

    if (!email.contains("@")) {
      throw IllegalArgumentException("이메일 형식이 올바르지 않습니다.")
    }
  }
}

위와 같이 해결할 수도 있지만 타입을 이용하면 이를 좀 더 우아하게 해결할 수 있다. 다음 코드를 살펴보자.

// 참고로 JVM 환경이라면 @JvmInline 어노테이션이 필요하다
value class Name(val value: String) {
  init {
    require(value.isNotBlank()) { "이름은 공백일 수 없습니다." }
  }
}

value class Age(val value: Int) {
  init {
    require(value >= 0) { "나이는 0보다 작을 수 없습니다." }
  }
}

value class Email(val value: String) {
  init {
    require(value.contains("@")) { "이메일 형식이 올바르지 않습니다." }
  }
}

class User {
  var name: Name
  var age: Age
  var email: Email

  constructor(name: Name, age: Age, email: Email) {
    this.name = name
    this.age = age
    this.email = email
  }
}

이런 방식으로 타입을 별도로 정의하여 사용하면 User 클래스가 가지고 있는 속성이 어떤 의미를 가지고 있는지 알 수 있으므로 가독성에 더 좋다. 또한 이러한 타입은 다양한 곳에서 재사용할 수 있으므로 코드의 중복을 줄일 수 있다.

참고로 이 글에서는 코틀린의 value class를 사용헀지만 다른 언어에서도 클래스를 사용하여 똑같이 만들 수 있다. 정리하자면 타입 시스템을 풍부하게 사용하는 것으로 다음과 같은 이점을 얻을 수 있다.

  • 컴파일 타임에 오류를 발견할 수 있다.
  • 타입을 통해 로직을 설계 할 수 있다.
  • 코드의 가독성을 높일 수 있다.
  • 코드의 안정성을 높일 수 있다.

우아한 타입 활용

타입 시스템을 잘 이해하고 있다면 타입에 기반하여 범위와 행동을 제한하는 설계가 가능하다. 만약 내 코드를 사용하는 다른 개발자가 조금 더 안전하게 코딩할 수 있게 돕고 싶다면 타입 시스템을 잘 활용하는 것이 좋다.

이번에는 앞서 소개한 사례 외에 타입 시스템을 활용한 다른 사례를 소개할 것이다. 다만, 타입 시스템의 동작은 프로그래밍 언어마다 다르기에 이 글에서 소개하는 문법이 모든 언어에서 지원되는 것은 아니다.

제네릭을 이용한 범용적인 타입

제네릭(Generic)1타입을 일반화하는 방법으로 많은 언어가 지원하므로 대부분 잘 아는 문법일 것이다. 예를 들어 Kotlin에서 List 타입은 제네릭을 이용하여 Int 타입이나 String 타입을 모두 포함할 수 있다.

val numbers: List<Int> = listOf(1, 2, 3)
val strings: List<String> = listOf("a", "b", "c")
하나의 List 타입으로 Int와 String을 담을 수 있다

만약 타입 시스템이 제네릭을 지원하지 않는다면 아래 코드처럼 필요할 때마다 타입을 정의해야 한다.

class IntList { /* ... */ }
class StringList { /* ... */ }

val numbers: IntList = listOf(1, 2, 3)
val strings: StringList = listOf("a", "b", "c")

제네릭을 잘 사용한다면 코드 중복을 많이 줄일 수 있다. 여기까지는 대부분 잘 아는 내용이므로 좀 더 복잡한 내용을 살펴보자.

팬텀 타입을 이용한 범위 제한

팬텀 타입(Phantom Type)은 이름 그대로 실체가 없는 타입을 말한다. 예를 들어 다음 타입은 의미가 없는 타입이라 볼 수 있다.

// TypeScript
type Phantom // 아무런 정의가 없는 타입

이처럼 우측에 정의가 되지 않은 타입을 팬텀 타입이라고 부른다. 별도로 타입을 정의할 수 없는 언어는 빈 클래스를 사용할 수 있다.

// Kotlin
class Phantom // 아무런 내용이 없는 클래스

얼핏 무의미한 코드로 보이지만 나름대로 사용처가 있다. 주로 특정 타입에 대한 하위 타입으로 구분할 때 많이 쓰인다. 예를 들어 다음 코드를 살펴보자.

class Meters
class Kilometers

class Distance<Unit>(val value: Double)

fun Distance<Meters>.toKilometers(): Distance<Kilometers> {
  return Distance(this.value / 1000)
}

fun Distance<Kilometers>.toMeters(): Distance<Meters> {
  return Distance(this.value * 1000)
}

val distanceInMeters = Distance<Meters>(5000.0)
val distanceInKilometers = distanceInMeters.toKilometers()
println("Distance in kilometers: ${distanceInKilometers.value}") // 5.0

val distanceInKilometers2 = Distance<Kilometers>(3.0)
val distanceInMeters2 = distanceInKilometers2.toMeters()
println("Distance in meters: ${distanceInMeters2.value}") // 3000.0

// 에러 발생
val invalid = distanceInMeters.toMeters()

위 코드를 보면 Distance 클래스는 제네릭을 이용하여 Meters 타입과 Kilometers 타입을 구분하면서도 하나로 추상화하기 위한 방법으로 팬텀 타입을 사용하고 있다. 이를 통해 Distance 클래스는 Meters 타입과 Kilometers 타입을 구분하여 사용할 수 있다. 또한, 제네릭의 공변성으로 인해 두 타입은 서로 다른 타입으로 취급되기 때문에 컴파일러의 보호를 받을 수 있다.

이를 이용하여 별도의 값 없이 객체에 상태를 부여하는 것도 가능하다. 다음 코드를 살펴보자.

class Draft
class Published

class Document<State>(val content: String)

fun publish(draftDocument: Document<Draft>): Document<Published> {
  println("Publishing document: $content")
  return Document(draftDocument.content)
}

fun edit(publishedDocument: Document<Published>): Document<Draft> {
  println("Start editing document: $content")
  return Document(publishedDocument.content)
}

val draftDocument = Document<Draft>("Hello, world!")
val publishedDocument = publish(draftDocument)
val editingDocument = edit(publishedDocument)

// 에러 발생
val invalid = edit(editingDocument)

여기까지 정리하자면 즉, 팬텀 타입을 통해 하나의 타입에서 파생되는 복수의 하위 타입을 만드는 것이 가능하다. 앞서 타입으로 가독성과 런타임 안정성 챙기기를 다룰 때 단순히 String으로 타입을 이용하지 않고 별도로 이름을 붙여서 사용했다. 다만, 비슷한 사례가 생기면 또 비슷한 타입을 만들어야 한다는 문제가 있다. 이를 해결하기 위해 팬텀 타입을 사용할 수 있다. 마지막으로 다음 예제 하나를 더 살펴보자.

value class Id<T>(val uuid: String) {
  override fun toString(): String = uuid
}

data class User(
  val id: Id<User>,
  val name: String
)

data class Post(
  val id: Id<Post>,
  val userId: Id<User>,
  val title: String,
  val content: String
)

위 코드를 보면 UserId 같은 타입을 만들지 않고 제네릭을 이용하여 Id 클래스를 정의한 것을 볼 수 있다. Id 클래스에서 T는 아무런 의미가 없는 팬텀 타입이다. 이를 통해 Id 클래스는 User 타입을 가리키는 Id<User> 타입과 Post 타입을 가리키는 Id<Post> 타입을 구분할 수 있다. 이를 통해 Id 클래스는 다양한 타입을 가리킬 수 있으며 이를 통해 코드 중복을 줄일 수 있다.

유니온 타입을 이용한 행동 제한

유니온 타입(Union Type)을 이용하면 두 개 이상의 타입을 하나로 묶어서 사용할 수 있다. 예를 들어 다음 TypeScript 코드를 살펴보자.

type Fruit = "Apple" | "Banana" | "Orange"

위 코드는 Fruit 타입을 선언하고 이 타입은 Apple, Banana, Orange 세 가지 타입 중 하나를 가질 수 있다는 것을 의미한다. 참고로 TypeScript에 익숙하지 않다면 값으로 보이는 Apple, Banana, Orange이 타입이 될 수 있다는 것이 이해하기 어려울 수 있다. 이는 TypeScript에서 리터럴 타입(Literal Type)이라고 불리는 타입으로 TypeScript에선 하나의 값을 타입으로 사용할 수 있다.

이를 이용하여 행동을 제한하는 것이 가능하다. 다음 코드를 살펴보자.

type State = { type: 'loading' } | 
  { type: 'success', data: string } | 
  { type: 'error', message: string };

let state: State = { type: 'loading' };
try {
  const data = getData();
  state = { type: 'success', data: data };
} catch {
  state = { type: 'error', message: 'Error!' };
}

if (state.type === 'success') {
  console.log(state.data);
  console.log(state.message); // 에러 발생
}

위 코드를 보면 State 타입은 세 타입 중 하나를 가질 수 있다. 이를 통해 State 타입은 type 속성에 loading, success, error 세 가지 리터럴 타입 중 하나를 가질 수 있으며 이를 통해 어떤 타입인지 확인 할 수 있다. 이를 통해 접근할 수 있는 속성을 제한하여 개발자의 행동을 통제하는 것이 가능하다. 참고로 엄밀히 따지면 위 코드에서 사용된 유니온 타입은 서로소 유니온 타입(Disjoint Union Type)이라고 불린다.

아쉽게도 유니온 타입을 지원하지 않는 언어가 많다. 다만, 서로소 유니온 타입은 합 타입(Sum Type)을 지원하는 언어라면 구현할 수 있다. 예를 들어, 합 타입을 지원하는 언어인 Kotlin은 다음과 같이 구현할 수 있다.

sealed interface State {
  data object Loading: State
  data class Success<T>(val data: T): State
  data class Error(val message: String): State
}

var state: State = State.Loading
try {
  val data = getData()
  state = State.Success(data)
} catch {
  state = State.Error("Error!")
}

if (state is State.Success<*>) {
  println(state.data)
  println(state.message) // 에러 발생
}

최소 타입을 이용한 TODO 처리

최소 타입(Bottom Type)2모든 타입의 하위 타입이며 아무런 값도 가질 수 없는 타입이다. 이는 실행할 수 없는 코드를 표현하는 데 사용할 수 있다. 예를 들어 다음 코드를 살펴보자.

fun fail(message: String): Nothing {
  throw IllegalArgumentException(message)
}

위 코드에서 구현한 fail 함수는 실행하면 무조건 IllegalArgumentException을 던지는 함수다. 이 함수가 반환하는 Nothing 타입이 바로 최소 타입이다. 이를 통해 fail 함수는 실행할 수 없는 코드를 표현하는 데 사용할 수 있다. 참고로 Kotlin에선 비슷한 역할을 하는 TODO() 함수를 제공한다.

최소 타입을 지원하는 다른 언어 중 하나로 TypeScript가 있다. TypeScript에선 never라는 이름으로 제공한다. 다음 코드를 살펴보자.

function TODO(): never {
  throw new Error("Not implemented yet");
}

class UserService {
  getUser(id: number): User {
    return TODO();
  }
}

앞서 Kotlin 예시처럼 TODO 함수를 통해 아직 구현되지 않은 코드를 표현할 수 있다. 이를 통해 개발자는 일단 필요한 함수를 선언하고 이후 구현할 수 있게 된다. 그런 일은 없어야겠지만 만약 구현을 하지 않은 채 배포했다면 바로 에러가 발생하므로 금방 알 수 있다.

타입을 이용한 유한 상태 머신 구현

유한 상태 머신(Finite State Machine)은 상태(State)와 이벤트(Event)를 통해 상태를 전이(Transition)하는 것을 말한다. 타입을 이용하면 컴파일 시간에 상태 전이를 검증할 수 있다. 다음 코드를 살펴보자.

// 상태와 이벤트를 나타내는 인터페이스
interface State
interface Event<S: State, T: State> {
  fun perform(state: S): T
}

// 팬텀 타입을 이용한 상태 머신 클래스
class StateMachine<S: State>(val state: S) {
  fun <T : State> transition(event: Event<S, T>): StateMachine<T> {
    return StateMachine(event.perform(state))
  }
}

sealed class SimpleState: State {
  data object Idle: SimpleState()
  data object Running: SimpleState()
  data object Finished: SimpleState()
}

sealed class SimpleEvent: Event<SimpleState, SimpleState> {
  object Start: Event<SimpleState.Idle, SimpleState.Running> {
    override fun perform(state: SimpleState.Idle): SimpleState.Running {
      println("Starting...")
      return SimpleState.Running
    }
  }

  object Stop: Event<SimpleState.Running, SimpleState.Finished> {
    override fun perform(state: SimpleState.Running): SimpleState.Finished {
      println("Stopping...")
      return SimpleState.Finished
    }
  }
}

// 초기 상태
val idleMachine = StateMachine(SimpleState.Idle)
println("Initial state: ${idleMachine.state}")

// Running 상태로 전이
val runningMachine = idleMachine.transition(SimpleEvent.Start)
println("After starting: ${runningMachine.state}")

// Finished 상태로 전이
val finishedMachine = runningMachine.transition(SimpleEvent.Stop)
println("After stopping: ${finishedMachine.state}")

// 에러 발생
val invalidTransition = idleMachine.transition(SimpleEvent.Stop)

앞서 언급한 합 타입과 팬텀 타입을 이용하여 상태 머신을 구현하면 컴파일 시간에 상태 전이를 검증할 수 있다. 이를 통해 상태 머신을 구현할 때 런타임에 발생할 수 있는 오류를 컴파일 시간에 미리 방지할 수 있다.

의존 타입으로 검증하기

의존 타입(Dependent Type)은 대부분의 언어에선 보기 힘든 개념이다. 필자는 예전에 Idris라는 언어를 통해 이 개념을 처음 접했고 이 글을 쓰며 지원하는 언어를 찾아봤지만 대부분은 처음 들어보는 언어였다. 따라서 굳이 알아야하는 개념은 아니지만 타입 시스템을 고도로 활용하면 이런 개념까지도 가능하다라는 것을 알리기 위해 간단하게 소개해볼 것이다.

의존 타입은 타입이 다른 타입에 의존하는 타입을 말한다. 예를 들어 다음 코드를 살펴보자.

data Vect : Nat -> Type -> Type where
  Nil : Vect Z a
  (::) : a -> Vect n a -> Vect (S n) a

위 코드는 Idris로 작성된 코드다. 낯선 코드지만 간단히 설명하자면 Idris는 일급 타입(First Class Type)이라는 기능을 지원하기에 인자나 반환 값으로 타입을 지정하는 것이 가능하다. 코드를 살펴보면 Type이라는 것을 인자로 받고 반환하는데, 이는 타입을 받아 새로운 타입으로 반환할 수 있다는 것을 의미한다. 이 기능을 통해 Idris에서 유한 집합 타입을 정의하는 것이 가능하다.

하나씩 살펴보면 Nat 타입은 자연수를 의미하고 Nat -> Type -> Type은 자연수와 타입을 받아 새로운 타입을 반환하는 함수를 의미한다. 참고로 조금 혼란스러울 수 있지만 커링되어 Vect(5)(Nat)처럼 받는다고 생각하면 된다. 이어서 where는 함수 내부에서 지역적으로 함수나 값을 정의하는 데 사용된다. 따라서 NilNat가 Z(0을 의미)인 경우를 나타내고 ::는 연산자처럼 사용되어 원소를 추가하는 데 사용된다. ::는 재귀적으로 Vect 타입을 만들어내기 때문에 마지막에는 반드시 Nil을 넣어줘야 한다. 조금 차이가 있긴 하지만 이에 대한 동작 방식이 궁금하다면 필자가 예전에 작성한 아티클인 함수형 자료구조를 참고해보자.

아무튼 다음과 같이 재귀적으로 고정 길이가 10이며 자연수를 받는 Vect 타입을 만들 수 있다.

vect : Vect 10 Nat

만약 합계를 구해주는 프로그램을 만든다면 다음과 같이 작성할 수 있다.

module Main

data Vect : Nat -> Type -> Type where
  Nil  : Vect Z a
  (::) : a -> Vect k a -> Vect (S k) a

vect : Vect 5 Nat
vect = 1 :: 2 :: 3 :: 4 :: 5 :: Nil
-- 만약 타입에 정의한 길이와 다르면 컴파일 에러가 발생한다
-- vect = 1 :: 2 :: 3 :: 4 :: Nil

-- 패턴 매칭으로 구현된 함수
sum_vect : Vect n Nat -> Nat
sum_vect Nil = 0
sum_vect (x :: xs) = x + sum_vect xs

-- 메인 함수에서 15 출력
main : IO ()
main = putStrLn $ show $ sum_vect vect

앞서 가독성과 런타임 안정성 챙기기에선 별도 타입을 정의하여 런타임에 검증하는 방법을 소개했다. 만약 타입 시스템이 이정도로 강력하다면 보통 런타임에 검증할 것을 컴파일 시간에 검증하는 것까지도 가능하다. 너무 과한 기능인 것은 사실이지만 타입 시스템이 어디까지 강력해질 수 있는지 보여주는 사례라고 볼 수 있다.

타입 주도 개발

그럼 이번에는 앞서 배운 것을 활용하여 타입 시스템을 풍부하게 사용할 수 있도록 개발하는 타입 주도 개발Type-Driven Development에 대해 알아보자. 타입 주도 개발은 다른 무언가를 먼저 정의하고 시작하는 XXX 주도 개발과 마찬가지로 타입을 먼저 정의하고 이를 통해 코드를 작성하는 방법론이다.

참고로 타입 주도 개발은 함수 구현처럼 아주 작은 부분에 적용할 수 있는 방법론이다. 따라서 다른 테스트 주도 개발과 같은 다른 개발 방법론과 함께 사용할 수 있다.

타입을 먼저 정의하기

프로그램은 일련의 절차를 통해 원하는 결과를 만들어 낸다. 이때 각 절차는 입력 데이터를 받아 특정 로직을 수행하고 결과를 반환한다. 다양한 패러다임으로 프로그램을 작성할 수 있지만 절차를 통해 원하는 결과를 만들어 낸다는 것 하나는 변하지 않는다. 따라서 타입 주도 개발은 원한다면 어디에도 적용할 수 있으며 각 절차에 대한 결과를 타입으로 정의하는 것으로 시작한다.3

타입을 먼저 정의한다는 것은 우리가 원하는 로직을 구체적으로 작성하기 전에 타입으로 추상화한다는 것과 같다. 연습을 위해 프로그래머스의 문자열 계산하기 문제를 풀어보자. 문제의 요구사항을 요약하면 다음과 같다.

이 각각의 과정에서 입력과 출력을 타입으로 나타낼 수 있다. 이말은 즉, 타입을 통해 로직을 추상화 하는 것이 가능하다라는 뜻이다. 이러한 사고가 타입 주도 개발의 핵심이라 볼 수 있다. 연습을 위해 프로그래머스의 문자열 계산하기 문제를 풀어보자. 문제의 요구사항을 요약하면 다음과 같다.

  • 문자열로 이루어진 수식을 입력으로 제공한다.
  • 연산자는 +-만 존재한다.
  • 피연산자는 자연수만 존재한다.
  • 잘못된 수식은 주어지지 않는다.
  • 숫자와 연산자는 공백으로 구분한다.

먼저 String 타입을 받아 Int로 반환하는 함수가 필요하다는 것을 떠올릴 수 있다. 이는 (String) -> Int와 같이 표현이 가능하다. 요구사항을 기반으로 로직을 더 구체화 해보면 다음과 같은 로직이 필요하다는 것을 알 수 있다.

  • 문자열을 숫자와 연산자로 분리한다.
    • 문자를 숫자로 변환한다.
    • 문자를 연산자로 변환한다.
  • 문자가 숫자인지 연산자인지 판단한다.
  • 문자열을 계산한다.

먼저 각 키워드를 타입으로 추상화하면 다음과 같다.

  • 문자열 → String
  • 문자 → String or Char
  • 숫자 → Int
  • 연산자 → String
  • 부호 → String
  • 판단 결과 -> Boolean
  • 계산 결과 -> Int

도메인 반영하기

문제가 간단하여 도메인이라 부르기에 빈약하지만 엄밀히 따져 연산자와 부호는 같은 String이지만 의미가 다르다. 따라서 이를 구분하기 위해 다른 타입으로 정의하는 것이 좋다. 다시 한 번 각 키워드를 타입으로 추상화하면 다음과 같다.

  • 문자열 → String
  • 문자 → Token
  • 숫자 → Number
  • 연산자 → Operator
  • 부호 → Sign

요구사항을 다시 확인하면 문자(Token)는 숫자(Number) 혹은 연산자(Operator) 둘 중 하나이므로 문자의 하위 타입으로 볼 수 있다. 그리고 숫자는 Int 타입으로 표현되고 연산자는 Sign 타입으로 표현된다. 이를 코드로 나타내면 다음과 같다.

// 부호
enum class Sign {
  PLUS, MINUS
}

// 토큰
sealed interface Token<T> {
  val value: T

  // 숫자 토큰
  data class Number(override val value: Int): Token<Int>
  // 연산자 토큰
  data class Operator(override val value: Sign): Token<Sign>
}

함수를 타입으로 추상화하기

이번에는 각 로직을 타입으로 추상화해보자.

  • 문자열을 숫자와 연산자로 분리한다.
      → (String) -> List<Token>
    • 문자를 숫자로 변환한다.
        → (String) -> Number
    • 문자를 연산자로 변환한다.
        → (String) -> Operator
  • 문자가 숫자인지 연산자인지 판단한다.
      → (String) -> Boolean
  • 문자열을 계산한다
      → (String) -> Int

분해한 로직을 합쳐 로직 파이프라인을 만들면 다음과 같다.

// 문자가 숫자인지 연산자인지 판단한다.
fun isNumber(token: String): Boolean =
  token.toIntOrNull() != null

// 문자를 숫자로 변환한다.
fun toNumber(token: String): Int =
  token.toInt()

// 문자를 연산자로 변환한다.
fun toSign(token: String): Sign =
  when (token) {
    "+" -> Sign.PLUS
    "-" -> Sign.MINUS
    else -> throw IllegalArgumentException("Unknown operator: ${token}")
  }

// 문자열을 숫자와 연산자 문자로 분리한다.
fun tokenize(input: String): List<Token<*>> =
  input.split(" ").map { token ->
    when {
      isNumber(token) -> Token.Number(toNumber(token))
      else -> Token.Operator(toSign(token))
    }
  }

// 문자열을 계산한다.
fun calculate(input: String): Int {
  val tokens = tokenize(input)
  var result = 0
  var sign = Sign.PLUS

  tokens.forEach { token ->
    when (token) {
      is Token.Number -> {
        when (sign) {
          Sign.PLUS -> {
            result += token.value
          }
          Sign.MINUS -> {
            result -= token.value
          }
        }
      }
      is Token.Operator -> {
        sign = token.value
      }
    }
  }

  return result
}

fun main() {
  val input = "9 - 4 - 1 + 2 + 6"
  val result = calculate(input)

  println(result) // 12
}

참고로 함수 정의는 요구사항에 대한 해석이나 패러다임에 따라 다를 수 있다. 따라서 같은 로직을 작성하더라도 위 코드와 다른 로직이 나올 수 있으므로 틀렸다고 생각하지 말자.

테스트 코드와 함께하기

타입을 먼저 정의한다는 점에서 테스트 주도 개발과도 잘 어울린다. 테스트 주도 개발은 이미 유명한 방법론이지만 설명하자면 만들어야 할 기능의 테스트를 먼저 작성하고 이를 통과하는 코드를 작성하는 방법론이다.

입력과 출력 결과를 미리 정의하고 코드를 작성한다는 점에서 타입 주도 개발과 테스트 주도 개발은 유사하다. 그만큼 궁합도 좋은데 타입 주도 개발이 타입 시스템을 이용하여 컴파일 시간에 미리 안정성을 체크할 수 있다면 테스트 주도 개발은 타입만으로는 잡아낼 수 없는 오류나 예외 상황을 테스트 코드를 통해 잡아낼 수 있다.

테스트 코드를 작성하는 시점은 입력과 출력을 정의하는 함수를 타입으로 추상화하기까지 진행한 후가 좋다.

class CalculatorTest {
  @Test
  fun testIsNumber() {
    assertTrue(isNumber("123"))
    assertFalse(isNumber("+"))
  }

  @Test
  fun testToNumber() {
    assertEquals(123, toNumber("123"))
  }

  @Test
  fun testToSign() {
    assertEquals(Sign.PLUS, toSign("+"))
  }

  @Test
  fun testTokenize() {
    val input = "9 - 4"
    val expected = listOf(
      Token.Number(9),
      Token.Operator(Sign.MINUS),
      Token.Number(4)
    )

    assertEquals(expected, tokenize(input))
  }

  @Test
  fun testCalculate() {
    val input = "9 - 4 - 1 + 2 + 6"
    assertEquals(12, calculate(input))
  }
}

마치며

타입 시스템은 개발자에게 있어 아낌없이 주는 든든한 친구다. 문제를 미연에 방지할 수 있게 해주며 가독성까지 챙길 수 있게 해준다. 타입이라는 개념과 친해질 수록 타입 시스템을 풍부하게 사용할 수 있게 되고, 이는 코드의 안정성과 가독성을 높여준다. 이 글에서 소개하는 타입 주도 개발은 타입과 친해지기 좋은 연습 방법이 될 수 있다.

다만 일일히 타입의 정의하여 타입이 너무 많아지거나 타입의 정의가 복잡해지면 오히려 코드를 작성하기 힘들어지거나 가독성을 떨어뜨릴 수 있다. 따라서 항상 적절한 상태를 유지하는 것이 중요하다.

그리고 설계자의 입장에 타입 시스템을 활용한다는 것은 다른 개발자를 위한 코드 규칙을 만드는 것과 같다. 설계자는 이를 적절하게 이용하여 안전하면서 편리한 코드를 작성할 수 있게 간접적으로 코드 규칙을 만들 수 있다. 이점을 고려하여 꼭 설계자가 아니더라도 다른 개발자가 안전한 코드를 작성할 수 있도록 타입을 이용한 규칙을 만들어보자.

Footnotes

  1. 참고로 외래어 표기법으로는 지네릭이라 표기하지만 온라인 한글 문서 대부분 제네릭이라 표기하므로 이 글에서도 제네릭이라 표기한다.

  2. 최소 타입이라는 번역은 '타입으로 견고하게 다형성으로 유연하게'라는 책에서 사용한 번역이다.

  3. 다만 함수형 패러다임과 궁합이 좋다는 것은 부정할 수 없다.