본인은 Java를 주력 개발언어로 사용하는 개발자이다. 새로 Scala로 공부하면서 가장 힘든 부분 중에 으뜸은 Implicit 인 것 같다. 하지만 Implicit를 이해하고 나면 Type Class라는 멋진 패턴을 만날 수 있다. Type Classes에 대해서 잘 모르지만 여기저기 자료를 찾아보고 이해한 수준으로 정리하고자 한다.

이 글은 읽기 전에 Implicit에 대해서 먼저 이해하기 바란다. Scala에서는 Implicit 키워드를 이해하지 못하면 Type Classes 코드 자체를 이해할 수 없다. 반드시 이해하고 진행하자.

그리고 여기서 Type Classes 패턴은 Scala 기준으로 설명한다. Type Classes 패턴이 하스켈에서 유래됐다고 한다. 하스켈은 잘 몰라서 Scala에서 본인이 학습한 기준으로 설명하겠다. 더 깊게 알고 싶으신 분들은 다른 글을 참고하길 바란다.

Type Classes란?

Type Classes는 패턴이다. 상속을 하지 않고 기능을 확장할 수 있게 해주는 패턴이다. 인자 값의 타입에 따라 다른 연산을 해주는 클래스를 정의하는 패턴이다. 오버로딩의 개념을 이해하면 더 쉽다. 여러개의 타입에 대해서 오버로딩된 동일 함수를 상상해보자. 사용자 입장에서는 동일한 의미를 가진 함수명으로 여러개의 다른 타입에 대해서 연산을 실행할 수 있어 의미상이나 가독성상이나 편리하다. 어려운 말로 다형성(polymorphism)이다.

자바 개발자로서 구현적인 측면에서는 쉽게 풀어쓰면 GOF의 Strategy 패턴 + Adapter 패턴을 짬뽕한 느낌이다. 함수의 호출 시점에 호출 함수의 인자 타입을 자동으로 식별해서 Strategy 인터페이스의 구현체를 넣어주는 패턴이라고 설명할 수 있을 것 같다. 거기에 자동으로 다른 타입으로 변환(Adapter 패턴)해주는 양념이 들어가면서 더욱 멋져진다. 먼가 이상하지만 설명하기가 힘들다. 실제 구현해보자. 코드를 보면 왜 이런 설명이 나오는지 알 것이다.

Type Classes 구현

Type Classes 패턴 구현시 아래 3가지 파트로 나누어 구현한다.

  • Type Class 본체
  • 세부 타입별 연산을 구현한 인스턴트
  • Type Class의 사용하는 인터페이스

1. Type Class 본체

1
2
3
4
@ trait Logger[T] {
def logging(value:T):String
}
defined trait Mergeable

Strategy 패턴의 Strategy 인터페이스 같은 역할을 하는 특정 기능을 하는 추상화 클래스를 선언한다. 실제 사용 시 제너릭 타입 T의 타입별로 연산이 로직이 오버로딩되어진다.

2. 세부 타입별 연산을 구현한 인스턴트

1
2
3
4
5
6
7
8
9
10
11
12
13
@ import java.time._
import java.time._

@ object Loggers {
implicit val i = new Logger[Int] {
def logging(value:Int):String = LocalDateTime.now().toString + " - " + value.toString
}

implicit val s = new Logger[String] {
def logging(value:String):String = LocalDateTime.now().toString + " - " + value
}
}
defined object Loggers

위에서 정의한 Type Class의 구현하여 제너릭 타입 T의 실제 타입별로 연산 로직 코드를 구현한다. implicit로 선언하는 부분이 핵심이다. 암시적 변수로 선언하기 때문에 scope가 동일한 곳에서 동일 타입으로 implicit 파라미터에 함수가 호출되면 자동으로 해당 변수값이 입력되게 한다.

3-1. Type Class의 사용하는 인터페이스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object Log {
def logging[T](value:T)(implicit logger:Logger[T]):String = {
logger.logging(value);
}
}
defined object Log

@ import Loggers._
import Loggers._

@ Log.logging(100000)
res5: String = "2019-10-08T17:26:06.570 - 100000"

@ Log.logging("this is test")
res7: String = "2019-10-08T17:33:29.346 - this is tes

실제 사용할 함수를 정의한다. 구현 2번에서 언급했듯이 implicit 파라미터를 가지는 함수를 구현하는 부분이다. 이 함수는 사용 시 implicit 파라미터를 직접 입력할 수 있겠지만 생략해서 사용한다. 생략된 파라미터는 동일한 타입의 암시적 변수나 암시적 파라미터로 정의된 지역변수에서 찾아 컴파일러가 찾아 자동으로 입력되게 된다. 함수 본체는 implicit 파라미터 받은 변수에게 연산을 위임함으로 타입별로 해당 타입에 맞는 연산이 이루어지고 오버로딩과 같은 효과가 나온다.

실제 호출 시 반드시 implicit 파라미터가 동작하게 Type Class 연산을 구현한 implicit 변수가 포함된 패키지나 클래스를 import 해야 한다.

3-2. Syntax처럼 호출할 수 있게 Type Class의 사용하는 인터페이스 구현.

3-1의 구현 이외에도 Scala의 implicit class 기능을 십분활용해서 더 멋진 코드를 만들어 낼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

@ object Log {
implicit class LogConverter[T](value:T) {
def logging(implicit logger:Logger[T]):String = {
logger.logging(value);
}
}
}
defined object Log

@ import Loggers._
import Loggers._

@ import Log._
import Log._

@ "test".logging
res6: String = "2019-10-10T10:56:55.778 - test"

@ 1000.logging
res7: String = "2019-10-10T10:57:03.095 - 1000"

<특정 타입 변수 혹은 값>.<Type Class의 사용하는 인터페이스> 형식으로 프로그램 Syntax처럼 호출하고 있는 것을 볼 수 있다. implicit class 변환의 멋짐이 보는 코드이다.

이 구현을 보면 고민해야 할 부분이 있다. 과연 지금 다루는 타입 클래스가 다형성의 구현인가 아님 자동변환의 예인가? 인자의 입장에서 보면 다형성이고, 호출되는 객체 입장에서는 객체가 변환되어 변환된 객체의 멤버가 호출되는 것이므로 자동변환으로 이해할 수도 있다. 그래서 이것을 뷰 바운드와 묶어서 설명하는 자료도 있다. 뷰 바운드로 Type Class의 사용하는 인터페이스의 호출 조건을 제약 가능하기 때문일 것이다. 참고 바란다.

마무리

Type Classes 클래스를 사용하는 Scala 코드는 Type Classes를 모르면 마법이다. 가지고 있지도 않은 멤버를 호출하는 마법 같은 코드를 보다 보면 외 스칼라가 배우기 어려운 언어라고 누가 말했는지 알 것이다. 하지만 알고 보면 멋지다. “아는 만큼 보인다”라는 말이 딱 맞는 부분이다.
나아가 조금