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

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

타입에 대한 재인식

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

타입은 집합이다

타입은 어떠한 값이 속할 수 있는 집합이며, 이 집합에 속하는 값은 타입을 준수한다고 말한다. 예를 들어 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 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]

위 코드를 봤을 때 단순히 변환 함수를 사용하지 않으면 된다고 생각할 수 있다. 일반적으론 그렇지만 함수 파이프라인을 사용해서 분기 로직을 작성하기 애매하거나 가독성 등을 위해 항등 함수를 사용하는 경우가 있을 수 있다.

타입을 통한 추상화

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

로직은 입출력의 연속

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

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

  • 입력: 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("이메일 형식이 올바르지 않습니다.")
    }
  }
}

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

@JvmInline
value class Name(val value: String) {
  init {
    require(value.isNotBlank()) { "이름은 공백일 수 없습니다." }
  }
}

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

@JvmInline
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를 사용헀지만 다른 언어에서도 클래스를 사용하여 똑같이 만들 수 있다. 마지막으로 정리하면 타입 시스템을 풍부하게 사용하는 것으로 다음과 같은 이점을 얻을 수 있다.

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

타입 주도 개발

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

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

타입을 먼저 정의하기

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

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

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

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

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

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

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

  • 문자열 → String
  • 문자 → Token
    • 숫자 → Number
    • 연산자 → Operator
    • 부호 → 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
}

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

마치며

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

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

Footnotes

  1. 다만 기본적으로 매우 절차적이면서 불변형을 사용하며 사이드 이펙트도 일종의 타입으로 생각하는 함수형 패러다임과 궁합이 좋다