전체 비유: 물류 창고 시스템#

변성을 물류 창고 시스템에 비유하면 이해하기 쉽습니다:

물류 비유Scala 개념역할
출고 전용 창고공변 (+A)생산자 - 값을 꺼내기만 함
입고 전용 창고반공변 (-A)소비자 - 값을 넣기만 함
입출고 창고무공변 (A)양방향 - 넣고 꺼내기 모두
개 사육장 → 동물원List[Dog] <: List[Animal]더 넓은 곳으로 이동 가능
동물 핸들러 → 개 핸들러Handler[Animal] <: Handler[Dog]더 일반적인 것이 더 유용

물류에서 출고 전용 창고는 큰 카테고리로 물건을 보낼 수 있고, 입고 전용 창고는 작은 카테고리의 물건도 받을 수 있습니다. 변성은 이런 물류 흐름의 타입 안전한 규칙입니다.

TL;DR
  • 공변(+A): 생산자 역할, List[Dog] <: List[Animal]
  • 반공변(-A): 소비자 역할, Printer[Animal] <: Printer[Dog]
  • 무공변(A): 읽기/쓰기 모두 필요한 경우 (기본값)
  • Function은 입력에 반공변, 출력에 공변: Function1[-A, +B]

소요 시간: 약 25-30분

대상 독자: 제네릭을 이해한 개발자 선수 지식: 타입 매개변수, 상속 계층

변성(Variance)은 타입 매개변수의 서브타이핑 관계를 정의합니다. 제네릭 타입이 상속 계층에서 어떻게 동작하는지 결정하며, 타입 안전한 제네릭 코드를 작성하는 데 핵심적인 개념입니다.

기본 개념#

Dog <: Animal (Dog가 Animal의 서브타입)일 때 List[Dog]List[Animal]의 관계는 어떻게 될까요?

  • 공변(Covariant): List[Dog] <: List[Animal]
  • 반공변(Contravariant): Printer[Animal] <: Printer[Dog]
  • 무공변(Invariant): 관계 없음
graph LR
    subgraph "타입 관계"
        Dog["Dog"] -->|"<:"| Animal["Animal"]
    end

    subgraph "공변 (+A): 생산자"
        ListDog["List#91;Dog#93;"] -->|"<:"| ListAnimal["List#91;Animal#93;"]
    end

    subgraph "반공변 (-A): 소비자"
        PrinterAnimal["Printer#91;Animal#93;"] -->|"<:"| PrinterDog["Printer#91;Dog#93;"]
    end

    subgraph "무공변 (A)"
        ArrayDog["Array#91;Dog#93;"] -.-|"관계 없음"| ArrayAnimal["Array#91;Animal#93;"]
    end

위 다이어그램은 세 가지 변성(공변, 반공변, 무공변)에서 타입 관계가 어떻게 달라지는지 보여줍니다.

💡 기억법:

  • 공변(+): “같은 방향” - Dog → Animal이면 Box[Dog] → Box[Animal]
  • 반공변(-): “반대 방향” - Dog → Animal이면 Handler[Animal] → Handler[Dog]

공변성 (+A)#

공변 타입은 “생산자” 역할을 합니다. 값을 반환하는 타입에 적합하며, 출력 위치에서 사용됩니다.

// +A: 공변
class Box[+A](val value: A)

class Animal
class Dog extends Animal
class Cat extends Animal

val dogBox: Box[Dog] = new Box(new Dog)
val animalBox: Box[Animal] = dogBox  // OK! Dog <: Animal이면 Box[Dog] <: Box[Animal]

// List도 공변
val dogs: List[Dog] = List(new Dog, new Dog)
val animals: List[Animal] = dogs  // OK!

공변의 제약

공변 타입 매개변수는 메서드 매개변수(입력) 위치에 사용할 수 없습니다. 이는 타입 안전성을 보장하기 위한 제약입니다.

// 컴파일 에러!
class Box[+A](var value: A)  // var는 setter가 있으므로 불가

// 컴파일 에러!
class Box[+A] {
  def set(a: A): Unit = ???  // 매개변수 위치에서 불가
}

해결책: 하한 경계

하한 경계를 사용하면 공변 타입에서도 메서드 매개변수를 사용할 수 있습니다.

class Box[+A](val value: A) {
  // B >: A (B는 A의 상위 타입)
  def set[B >: A](b: B): Box[B] = new Box(b)
}

val dogBox: Box[Dog] = new Box(new Dog)
val animalBox: Box[Animal] = dogBox.set(new Cat)  // OK!
핵심 포인트
  • 공변(+A): Dog <: Animal이면 Box[Dog] <: Box[Animal]
  • 출력(반환) 위치에서만 사용 가능
  • 하한 경계로 입력 위치 제약 우회 가능

반공변성 (-A)#

반공변 타입은 “소비자” 역할을 합니다. 값을 받는 타입에 적합하며, 입력 위치에서 사용됩니다.

// -A: 반공변
trait Printer[-A] {
  def print(a: A): Unit
}

val animalPrinter: Printer[Animal] = new Printer[Animal] {
  def print(a: Animal): Unit = println(s"Animal: $a")
}

// Animal을 출력할 수 있으면 Dog도 출력 가능
val dogPrinter: Printer[Dog] = animalPrinter  // OK!

dogPrinter.print(new Dog)  // "Animal: Dog@..."

반공변의 제약

반공변 타입 매개변수는 반환(출력) 위치에 사용할 수 없습니다.

// 컴파일 에러!
trait Printer[-A] {
  def get: A  // 반환 위치에서 불가
}
핵심 포인트
  • 반공변(-A): Dog <: Animal이면 Printer[Animal] <: Printer[Dog]
  • 입력(매개변수) 위치에서만 사용 가능
  • 소비자 역할의 타입에 적합

무공변 (A)#

무공변은 기본값입니다. 읽기와 쓰기가 모두 필요한 경우, 즉 입력과 출력 위치에서 모두 사용되는 타입에 적용됩니다.

// 무공변
class Container[A](var value: A)

val dogContainer: Container[Dog] = new Container(new Dog)
// val animalContainer: Container[Animal] = dogContainer  // 컴파일 에러!
핵심 포인트
  • 무공변(A): 타입 간에 서브타입 관계 없음
  • 읽기와 쓰기가 모두 필요한 경우 사용
  • 기본값으로, 명시적 표기 불필요

Function의 변성#

Scala의 함수 타입은 입력에 대해 반공변, 출력에 대해 공변입니다. Function1[-A, +B]는 A를 받아서 B를 반환하는 함수를 나타냅니다.

// Function1[-T1, +R]
val animalToString: Animal => String = (a: Animal) => a.toString

// Dog => String은 Animal => String의 상위 타입
val dogToString: Dog => String = animalToString

// ? Animal을 받는 함수는 Dog도 받을  있으니까

직관적 이해

아래 다이어그램은 함수 타입의 변성을 시각화한 것입니다.

graph TB
    subgraph "Function1[-T1, +R]"
        direction LR
        Input["입력: 반공변(-T1)"]
        Output["출력: 공변(+R)"]
    end

    subgraph "예시"
        F1["Animal → String"]
        F2["Dog → String"]
        F1 -->|"<:"| F2
    end

    Note["Animal을 받는 함수는<br>Dog도 받을 수 있다"]

위 다이어그램은 함수 타입에서 입력은 반공변, 출력은 공변인 이유를 설명합니다.

해석: Animal => String 함수를 Dog => String이 필요한 곳에 쓸 수 있습니다. Animal을 처리할 수 있으면 Dog도 당연히 처리할 수 있기 때문입니다.

핵심 포인트
  • Function1[-T1, +R]: 입력 반공변, 출력 공변
  • Animal => String은 Dog => String의 서브타입
  • 더 일반적인 입력을 받는 함수는 더 구체적인 입력도 처리 가능

실제 예제#

컬렉션

표준 라이브러리의 컬렉션들은 적절한 변성을 가집니다.

// List[+A]: 공변
val dogs: List[Dog] = List(new Dog)
val animals: List[Animal] = dogs  // OK

// Array[A]: 무공변 (Java 호환성)
val dogArray: Array[Dog] = Array(new Dog)
// val animalArray: Array[Animal] = dogArray  // 컴파일 에러

옵저버 패턴

이벤트 핸들러에서 반공변성이 어떻게 활용되는지 살펴봅니다.

// 이벤트 핸들러는 반공변
trait EventHandler[-E] {
  def handle(event: E): Unit
}

class ClickEvent
class ButtonClickEvent extends ClickEvent

val clickHandler: EventHandler[ClickEvent] =
  (event: ClickEvent) => println("Clicked!")

// ClickEvent 핸들러를 ButtonClickEvent 핸들러로 사용 가능
val buttonHandler: EventHandler[ButtonClickEvent] = clickHandler
핵심 포인트
  • List[+A]: 공변, 불변 컬렉션
  • Array[A]: 무공변, Java 호환성 (가변)
  • EventHandler[-E]: 반공변, 이벤트 처리

변성 규칙 요약#

아래 표는 각 위치에서 어떤 변성이 허용되는지 정리한 것입니다.

위치공변 (+A)반공변 (-A)무공변 (A)
반환 타입OXO
매개변수 타입XOO
val 필드OXO
var 필드XXO

모범 사례#

불변 컬렉션은 공변으로

불변 컬렉션은 값을 생산만 하므로 공변이 적합합니다.

sealed trait MyList[+A]
case object MyNil extends MyList[Nothing]
case class MyCons[+A](head: A, tail: MyList[A]) extends MyList[A]

콜백/핸들러는 반공변으로

콜백은 값을 소비하므로 반공변이 적합합니다.

trait Callback[-A] {
  def onResult(result: A): Unit
}

읽기/쓰기가 모두 필요하면 무공변

가변 버퍼처럼 읽기와 쓰기가 모두 필요한 경우 무공변을 사용합니다.

class MutableBuffer[A] {
  private var items: List[A] = Nil
  def add(item: A): Unit = items = item :: items
  def get(index: Int): A = items(index)
}

연습 문제#

다음 연습 문제를 통해 변성 개념을 복습해보세요.

1. 변성 적용 ⭐⭐

다음 타입에 적절한 변성을 적용하세요:

trait Comparator[???A] {
  def compare(a: A, b: A): Int
}

trait Producer[???A] {
  def produce(): A
}

trait Transformer[???A, ???B] {
  def transform(a: A): B
}
정답 보기
// 매개변수로만 사용 -> 반공변
trait Comparator[-A] {
  def compare(a: A, b: A): Int
}

// 반환으로만 사용 -> 공변
trait Producer[+A] {
  def produce(): A
}

// 입력은 반공변, 출력은 공변
trait Transformer[-A, +B] {
  def transform(a: A): B
}

관련 개념#

변성은 다음 개념들과 밀접하게 연결됩니다:

관련 개념연결 관계
제네릭타입 매개변수와 타입 경계 기초
고급 타입Union/Intersection 타입과 변성
컬렉션List[+A] 등 컬렉션의 변성
함수형 패턴Function1[-A, +B] 타입의 변성
타입 클래스타입 클래스 인스턴스의 변성

다음 단계#

학습 경로설명
고급 타입Union, Intersection, Match Types
타입 클래스Ad-hoc 다형성 패턴 심화
함수형 패턴Functor, Monad 등 추상화