전체 비유: 만능 어댑터 시스템#

타입 클래스를 전자기기 어댑터 시스템에 비유하면 이해하기 쉽습니다:

어댑터 비유Scala 개념역할
어댑터 규격 (USB-C 등)타입 클래스 (trait Show[A])기능의 인터페이스 정의
특정 기기용 어댑터인스턴스 (given Show[Int])특정 타입에 대한 구현
어댑터 사용 포트API (def show[A: Show])타입 클래스를 사용하는 메서드
어댑터 연결extension 메서드타입에 메서드 추가
기기 본체 변경 불필요기존 타입 수정 없음외부에서 기능 추가
여러 어댑터 선택여러 인스턴스 정의상황별 다른 동작

기기 본체를 분해하지 않고 어댑터만 연결하면 새 기능을 사용할 수 있듯이, 타입 클래스는 기존 타입을 수정하지 않고 새로운 기능을 추가합니다. 핵심은 “어떤 기능이 필요한지"와 “어떻게 구현할지"를 분리하는 것입니다.

TL;DR
  • 타입 클래스는 기존 타입을 수정하지 않고 새 기능을 추가하는 패턴입니다
  • 세 가지 구성: 타입 클래스(trait), 인스턴스(given), API(extension)
  • 같은 타입에 여러 인스턴스를 정의하고 상황에 따라 선택할 수 있습니다
  • Show, Eq, Ordering, Monoid 등이 대표적인 타입 클래스입니다

소요 시간: 약 25-30분

대상 독자: Implicit/Given을 이해한 개발자 선수 지식: 암시적 기능, 제네릭

타입 클래스(Type Class)는 기존 타입에 새로운 기능을 추가하는 패턴입니다. 상속 없이 다형성을 구현할 수 있으며, 기존 타입을 수정하지 않고도 새로운 동작을 정의할 수 있습니다. Haskell에서 유래한 이 패턴은 Scala의 암시적 기능과 결합하여 강력한 추상화를 제공합니다.

왜 타입 클래스가 필요한가?#

상속의 한계

전통적인 객체 지향 상속으로는 해결하기 어려운 문제들이 있습니다.

// 문제: Int, String 등 기존 타입에 새 메서드를 추가하고 싶다면?
// → Int를 상속받을 수 없음!

// 문제: 외부 라이브러리의 클래스에 기능을 추가하고 싶다면?
// → 소스 코드를 수정할 수 없음!

// 문제: 같은 타입에 대해 상황에 따라 다른 동작이 필요하다면?
//  Person을 이름순, 나이순  여러 방식으로 정렬하고 싶음

타입 클래스의 해결책

타입 클래스 패턴은 이러한 문제들을 우아하게 해결합니다.

// 1. 기존 타입(Int)에 새 기능 추가 가능
given Show[Int] with
  def show(i: Int): String = i.toString

// 2. 외부 라이브러리 타입에도 적용 가능
given Show[java.time.LocalDate] with
  def show(d: java.time.LocalDate): String = d.toString

// 3. 같은 타입에 여러 인스턴스 정의 가능
val byName: Ordering[Person] = Ordering.by(_.name)
val byAge: Ordering[Person] = Ordering.by(_.age)
people.sorted(using byAge)  // 상황에 맞게 선택!

타입 클래스란?#

타입 클래스 구조

타입 클래스는 세 가지 구성 요소로 이루어집니다. 아래 다이어그램은 이 구조를 시각화한 것입니다.

graph TB
    subgraph "타입 클래스 패턴"
        TC["1. 타입 클래스<br>(trait Show#91;A#93;)"]
        TI["2. 인스턴스<br>(given Show#91;Int#93;)"]
        API["3. API<br>(def show#91;A: Show#93;(a))"]
    end

    subgraph "사용 흐름"
        Code["코드: 42.show"]
        Compiler["컴파일러가 Show#91;Int#93; 검색"]
        Instance["인스턴스 주입"]
        Result["결과: \"42\""]
    end

    TC --> TI
    TI --> API
    Code --> Compiler
    Compiler --> Instance
    Instance --> Result

위 다이어그램은 타입 클래스의 세 가지 구성 요소와 사용 흐름을 보여줍니다.

타입 클래스는 세 부분으로 구성됩니다:

  1. 타입 클래스 자체 - 트레이트로 정의된 인터페이스
  2. 타입 클래스 인스턴스 - 특정 타입에 대한 구현
  3. 인터페이스 메서드 - 타입 클래스를 사용하는 API

기본 예제: Show#

값을 문자열로 변환하는 Show 타입 클래스를 예제로 살펴봅니다.

정의

// 1. 타입 클래스 정의
trait Show[A]:
  def show(a: A): String

// 2. 인스턴스 정의
object Show:
  given Show[Int] with
    def show(a: Int): String = a.toString

  given Show[String] with
    def show(a: String): String = s"\"$a\""

  given Show[Boolean] with
    def show(a: Boolean): String = if a then "yes" else "no"

// 3. 인터페이스 메서드
def show[A](a: A)(using s: Show[A]): String = s.show(a)

// extension 메서드로 더 자연스럽게
extension [A](a: A)(using s: Show[A])
  def show: String = s.show(a)
// 1. 타입 클래스 정의
trait Show[A] {
  def show(a: A): String
}

// 2. 인스턴스 정의
object Show {
  implicit val intShow: Show[Int] = new Show[Int] {
    def show(a: Int): String = a.toString
  }

  implicit val stringShow: Show[String] = new Show[String] {
    def show(a: String): String = s""""$a""""
  }

  implicit val boolShow: Show[Boolean] = new Show[Boolean] {
    def show(a: Boolean): String = if (a) "yes" else "no"
  }
}

// 3. 인터페이스 메서드
def show[A](a: A)(implicit s: Show[A]): String = s.show(a)

// implicit class로 extension
implicit class ShowOps[A](a: A)(implicit s: Show[A]) {
  def show: String = s.show(a)
}

사용

타입 클래스 인스턴스를 import하면 해당 기능을 사용할 수 있습니다.

import Show.given  // Scala 3
// import Show._   // Scala 2

show(42)           // "42"
show("hello")      // "\"hello\""
show(true)         // "yes"

// extension 메서드
42.show            // "42"
"hello".show       // "\"hello\""

파생 인스턴스#

기존 인스턴스로부터 새로운 인스턴스를 자동으로 유도할 수 있습니다. 예를 들어, A 타입의 Show가 있으면 List[A]의 Show를 자동으로 만들 수 있습니다.

// List[A]의 Show 인스턴스 (A의 Show가 있으면)
given [A](using s: Show[A]): Show[List[A]] with
  def show(list: List[A]): String =
    list.map(s.show).mkString("[", ", ", "]")

// Option[A]의 Show 인스턴스
given [A](using s: Show[A]): Show[Option[A]] with
  def show(opt: Option[A]): String = opt match
    case Some(a) => s"Some(${s.show(a)})"
    case None    => "None"

// 사용
List(1, 2, 3).show        // "[1, 2, 3]"
Some("hello").show        // "Some(\"hello\")"
List(Some(1), None).show  // "[Some(1), None]"

Eq 타입 클래스#

동등성 비교를 위한 타입 클래스입니다. Scala의 기본 ==는 모든 타입 간 비교를 허용하지만, Eq를 사용하면 같은 타입끼리만 비교할 수 있어 타입 안전성이 높아집니다.

trait Eq[A]:
  def eqv(a: A, b: A): Boolean
  def neqv(a: A, b: A): Boolean = !eqv(a, b)

object Eq:
  given Eq[Int] with
    def eqv(a: Int, b: Int): Boolean = a == b

  given Eq[String] with
    def eqv(a: String, b: String): Boolean = a == b

extension [A](a: A)(using eq: Eq[A])
  def ===(b: A): Boolean = eq.eqv(a, b)
  def =!=(b: A): Boolean = eq.neqv(a, b)

// 사용
1 === 1      // true
1 === 2      // false
"a" === "a"  // true

// 타입 안전: 다른 타입 비교 불가
// 1 === "1"  // 컴파일 에러!

Ordering 타입 클래스#

정렬을 위한 표준 라이브러리 타입 클래스입니다. 같은 타입에 대해 여러 Ordering 인스턴스를 정의하고 상황에 따라 선택할 수 있습니다.

case class Person(name: String, age: Int)

// Person에 대한 Ordering 인스턴스
given Ordering[Person] = Ordering.by(_.age)

val people = List(
  Person("Alice", 30),
  Person("Bob", 25),
  Person("Carol", 35)
)

people.sorted
// List(Person(Bob,25), Person(Alice,30), Person(Carol,35))

// 다른 기준으로 정렬
people.sorted(using Ordering.by(_.name))
// List(Person(Alice,30), Person(Bob,25), Person(Carol,35))
핵심 포인트
  • Ordering은 표준 라이브러리의 정렬용 타입 클래스
  • Ordering.by로 특정 필드 기준 정렬
  • using으로 특정 인스턴스 명시적 선택

Monoid 타입 클래스#

결합 연산과 항등원을 정의하는 대수적 구조입니다. 병렬 처리나 폴딩 연산에 유용합니다.

trait Monoid[A]:
  def empty: A
  def combine(a: A, b: A): A

object Monoid:
  given Monoid[Int] with
    def empty: Int = 0
    def combine(a: Int, b: Int): Int = a + b

  given Monoid[String] with
    def empty: String = ""
    def combine(a: String, b: String): String = a + b

  given [A]: Monoid[List[A]] with
    def empty: List[A] = Nil
    def combine(a: List[A], b: List[A]): List[A] = a ++ b

// 제네릭 combineAll 함수
def combineAll[A](list: List[A])(using m: Monoid[A]): A =
  list.foldLeft(m.empty)(m.combine)

combineAll(List(1, 2, 3, 4, 5))            // 15
combineAll(List("a", "b", "c"))            // "abc"
combineAll(List(List(1, 2), List(3, 4)))   // List(1, 2, 3, 4)
핵심 포인트
  • Monoid: 항등원(empty)과 결합 연산(combine) 정의
  • 폴딩, 병렬 처리에 유용한 대수적 구조
  • Int, String, List 등 다양한 타입에 적용 가능

타입 클래스 vs 상속#

타입 클래스와 전통적인 상속 방식의 차이점을 비교합니다.

특성타입 클래스상속
타입 수정불필요필요
여러 구현가능불가
기존 타입 확장가능제한적
성능약간의 오버헤드직접 호출
핵심 포인트
  • 타입 클래스: 기존 타입 수정 없이 기능 추가
  • 상속: 타입 정의 시점에 기능 결정
  • 같은 타입에 여러 구현 가능 (타입 클래스)

모범 사례#

컴패니언 객체에 인스턴스 정의

타입의 컴패니언 객체에 인스턴스를 정의하면 자동으로 암시적 범위에 포함됩니다.

case class Email(value: String)

object Email:
  // 컴패니언 객체에 인스턴스 정의
  given Show[Email] with
    def show(e: Email): String = e.value

  given Eq[Email] with
    def eqv(a: Email, b: Email): Boolean = a.value == b.value

인스턴스 우선순위

여러 인스턴스가 충돌할 때 우선순위를 지정하는 방법입니다.

trait LowPriorityInstances:
  given [A]: Show[List[A]] with
    def show(list: List[A]): String = list.toString

object Show extends LowPriorityInstances:
  // 더 구체적인 인스턴스가 우선
  given (using s: Show[Int]): Show[List[Int]] with
    def show(list: List[Int]): String =
      list.map(s.show).mkString("[", ", ", "]")
핵심 포인트
  • 컴패니언 객체에 인스턴스 정의시 자동 암시적 범위 포함
  • 상속으로 우선순위 지정 가능
  • 더 구체적인 인스턴스가 우선 선택됨

연습 문제#

다음 연습 문제를 통해 타입 클래스 개념을 복습해보세요.

1. JsonEncoder 타입 클래스

JSON 인코딩을 위한 타입 클래스를 구현하세요.

정답 보기
trait JsonEncoder[A]:
  def encode(a: A): String

object JsonEncoder:
  given JsonEncoder[Int] with
    def encode(a: Int): String = a.toString

  given JsonEncoder[String] with
    def encode(a: String): String = s""""$a""""

  given JsonEncoder[Boolean] with
    def encode(a: Boolean): String = a.toString

  given [A](using e: JsonEncoder[A]): JsonEncoder[List[A]] with
    def encode(list: List[A]): String =
      list.map(e.encode).mkString("[", ",", "]")

  given [A](using e: JsonEncoder[A]): JsonEncoder[Option[A]] with
    def encode(opt: Option[A]): String = opt match
      case Some(a) => e.encode(a)
      case None    => "null"

extension [A](a: A)(using e: JsonEncoder[A])
  def toJson: String = e.encode(a)

// 사용
42.toJson                  // "42"
"hello".toJson             // "\"hello\""
List(1, 2, 3).toJson       // "[1,2,3]"
Some("world").toJson       // "\"world\""
None.toJson                // "null"

관련 개념#

타입 클래스는 다음 개념들과 밀접하게 연결됩니다:

관련 개념연결 관계
Implicits인스턴스의 암시적 해결 메커니즘
제네릭컨텍스트 경계 (A : Show)
함수형 패턴Functor, Monad 등 핵심 타입 클래스
공변성/반공변성타입 클래스 인스턴스의 변성
고급 타입고차 타입으로 타입 클래스 정의

다음 단계#

학습 경로설명
공변성/반공변성제네릭 타입의 서브타이핑 규칙
함수형 패턴Functor, Monad 등 추상화
고급 타입타입 레벨 프로그래밍