5 분 소요

변성(Variance)이란 무엇인가?

변성은 타입 간의 상속 관계가 복합 타입(배열, 제네릭 컬렉션 등)에 어떻게 반영되는지를 정의하는 개념입니다.

현실 세계에서 생각해보면, “동물”과 “개”가 있을 때 “개는 동물이다”라고 할 수 있죠? 이건 상속 관계입니다.
그런데 문제는 이렇게 시작됩니다:

  • DogAnimal을 상속받는다면
  • List<Dog>List<Animal>과 어떤 관계일까요?

직관적으로는 “개 리스트는 동물 리스트야”라고 생각할 수 있지만, 실제로는 그렇지 않습니다.

왜냐하면:

1
2
List<Animal> animals = new ArrayList<Dog>(); // 실제로는 불가능
animals.add(new Cat()); // 개 리스트에 고양이를 넣게 됨!

이렇게 되면 타입 안정성이 깨지죠.

변성의 세 가지 종류:

  1. 불공변(Invariant): List<Dog>List<Animal>은 완전히 다른 타입
  2. 공변(Covariant): List<Dog>List<Animal>로 읽을 수만 있음 (쓰기 금지)
  3. 반공변(Contravariant): List<Animal>List<Dog>로 쓸 수만 있음 (읽기 제한)

Java에서는 와일드카드를 사용해서 이를 해결했습니다:

  • List<? extends Dog> (공변)
  • List<? super Dog> (반공변)

이 개념을 만들 때 가장 중요하게 생각한 것은 타입 안정성이었습니다. 컴파일 시점에 최대한 많은 오류를 잡아내서 런타임 오류를 줄이고자 했죠.

graph TD
    A[변성 Variance] --> B[공변성 Covariance]
    A --> C[불변성 Invariance]
    A --> D[반공변성 Contravariance]
    
    B --> B1["S가 T의 하위타입이면<br/>C&lt;S&gt;도 C&lt;T&gt;의 하위타입"]
    C --> C1["S가 T의 하위타입이어도<br/>C&lt;S&gt;와 C&lt;T&gt;는 무관계"]
    D --> D1["S가 T의 하위타입이면<br/>C&lt;T&gt;가 C&lt;S&gt;의 하위타입"]
    
    style B fill:#e1f5fe
    style C fill:#f3e5f5
    style D fill:#fff3e0

1. 배열의 공변성

제네릭 이전 시대의 선택

1990년대 초 Java를 설계할 때, 제네릭은 아직 존재하지 않았습니다. 다형성을 지원하기 위해 배열을 공변적으로 만든 것은 당시로서는 합리적인 선택이었습니다.

1
2
3
4
5
6
7
8
9
// 이런 코드가 가능하도록 하고 싶었습니다
public static void printAll(Object[] objects) {
    for (Object obj : objects) {
        System.out.println(obj);
    }
}

String[] strings = {"Hello", "World"};
printAll(strings); // String[]을 Object[]로 전달

하지만 대가가 따랐습니다

sequenceDiagram
    participant C as 컴파일러
    participant JVM as JVM
    participant A as Array
    
    C->>A: String[] → Object[] 할당 허용
    Note over C: 컴파일 타임에는 문제없음
    
    C->>A: objects[0] = 42 허용
    Note over C: Object 배열에 Integer 추가는 합법
    
    JVM->>A: 런타임 타입 체크
    A->>JVM: ArrayStoreException!
    Note over JVM: 실제로는 String 배열이었음
1
2
3
4
5
6
7
8
9
10
public class ArrayCovarianceExample {
    public static void main(String[] args) {
        String[] strings = new String[3];
        Object[] objects = strings; // 컴파일 OK - 배열 공변성
        
        objects[0] = "Hello";        // OK - String은 Object
        objects[1] = 42;             // 런타임 Exception!
        // java.lang.ArrayStoreException: java.lang.Integer
    }
}

이 문제는 배열이 런타임에 자신의 실제 타입을 기억하고 있기 때문에 발생합니다. JVM이 매번 타입을 체크해야 하므로 성능에도 영향을 줍니다.

graph LR
    A[String 배열] --> B[Object 참조변수]
    B --> C{런타임 타입 체크}
    C -->|String 호환| D[저장 성공]
    C -->|String 비호환| E[ArrayStoreException]
    
    style E fill:#ffebee

2. 제네릭의 불변성

타입 소거(Type Erasure) 방식의 선택

JDK 5에서 제네릭을 도입할 때, 기존 Java 코드와의 호환성을 유지하면서도 타입 안전성을 확보하는 방법을 찾아야 했습니다. 그 해답이 ‘타입 소거’였습니다.

graph TB
    A[제네릭 코드<br/>List&lt;String&gt;] --> B[컴파일러 타입 체크]
    B --> C[타입 소거<br/>List]
    C --> D[바이트코드<br/>기존 JVM 호환]
    
    E[컴파일 타임] --> F[런타임]
    A -.-> E
    D -.-> F
    
    style B fill:#c8e6c9
    style C fill:#fff3e0

불변성이 필요한 이유

1
2
3
4
5
6
7
8
9
10
11
12
public class GenericInvarianceExample {
    public static void main(String[] args) {
        List<String> strings = Arrays.asList("Hello", "World");
        
        // 이 코드가 허용된다면...
        // List<Object> objects = strings; // 컴파일 에러!
        
        // 다음이 가능해집니다:
        // objects.add(42); // Integer를 String 리스트에?
        // String s = strings.get(2); // ClassCastException!
    }
}

불변성은 이런 문제를 컴파일 시점에 차단합니다:

flowchart TD
    A[List&lt;String&gt;] --> B{List&lt;Object&gt;에 할당?}
    B -->|허용시| C[objects.add Integer]
    C --> D[strings에 Integer 혼입]
    D --> E[ClassCastException]
    
    B -->|차단| F[컴파일 에러]
    F --> G[타입 안전성 보장]
    
    style E fill:#ffebee
    style G fill:#c8e6c9

3. 와일드카드

PECS 원칙의 탄생

제네릭의 엄격한 불변성만으로는 실용적인 프로그래밍이 어려웠습니다. 그래서 와일드카드를 도입했고, Joshua Bloch가 정리한 PECS(Producer-Extends, Consumer-Super) 원칙이 나왔습니다.

graph LR
    A[PECS 원칙] --> B[Producer<br/>? extends T]
    A --> C[Consumer<br/>? super T]
    
    B --> B1[데이터를 읽어옴<br/>Read-Only]
    C --> C1[데이터를 써넣음<br/>Write-Only]
    
    B1 --> B2[List&lt;? extends Number&gt;<br/>Number나 그 하위타입]
    C1 --> C2[List&lt;? super Integer&gt;<br/>Integer나 그 상위타입]
    
    style B fill:#e3f2fd
    style C fill:#fff8e1

? extends T (공변성)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ProducerExample {
    // 숫자들을 읽어서 합계를 구하는 메서드
    public static double sum(List<? extends Number> numbers) {
        double total = 0.0;
        for (Number num : numbers) {  // 읽기만 가능
            total += num.doubleValue();
        }
        // numbers.add(3.14); // 컴파일 에러! 쓰기 불가
        return total;
    }
    
    public static void main(String[] args) {
        List<Integer> integers = Arrays.asList(1, 2, 3);
        List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
        
        System.out.println(sum(integers)); // 6.0
        System.out.println(sum(doubles));  // 6.6
    }
}

? super T (반공변성)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ConsumerExample {
    // 정수들을 컬렉션에 추가하는 메서드
    public static void addIntegers(List<? super Integer> numbers) {
        numbers.add(42);        // 쓰기 가능
        numbers.add(100);       // Integer의 하위타입도 가능
        
        // Object obj = numbers.get(0); // Object로만 읽기 가능
        // Integer i = numbers.get(0);  // 컴파일 에러!
    }
    
    public static void main(String[] args) {
        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();
        
        addIntegers(numbers);   // Number는 Integer의 상위타입
        addIntegers(objects);   // Object는 Integer의 상위타입
    }
}
graph TB
    subgraph "? extends Number (Producer)"
        A1[List&lt;Integer&gt;] --> B1[? extends Number]
        A2[List&lt;Double&gt;] --> B1
        A3[List&lt;Float&gt;] --> B1
        B1 --> C1[읽기 전용<br/>Number로 읽기 가능]
    end
    
    subgraph "? super Integer (Consumer)"
        D1[? super Integer] --> E1[List&lt;Integer&gt;]
        D1 --> E2[List&lt;Number&gt;]
        D1 --> E3[List&lt;Object&gt;]
        F1[쓰기 전용<br/>Integer 타입 추가 가능] --> D1
    end
    
    style C1 fill:#e8f5e8
    style F1 fill:#fff3e0

4. 메서드 오버라이딩과 공변 반환 타입

JDK 5에서는 메서드 오버라이딩에서도 공변성을 도입했습니다. 이는 배열의 공변성과 달리 안전한 공변성입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Animal {
    public Animal reproduce() {
        return new Animal();
    }
}

class Dog extends Animal {
    @Override
    public Dog reproduce() {  // 공변 반환 타입
        return new Dog();
    }
}

public class CovariantReturnExample {
    public static void main(String[] args) {
        Dog myDog = new Dog();
        Dog puppy = myDog.reproduce(); // 형변환 불필요!
        
        // 기존 방식이라면:
        // Dog puppy = (Dog) myDog.reproduce();
    }
}

5. 설계 철학과 트레이드오프

우리가 추구한 균형점

graph TD
    A[Java 타입 시스템 설계] --> B[타입 안전성]
    A --> C[코드 유연성]
    A --> D[성능]
    A --> E[하위 호환성]
    
    B --> B1[컴파일 타임 체크 강화]
    C --> C1[와일드카드로 제한적 허용]
    D --> D1[런타임 오버헤드 최소화]
    E --> E1[기존 코드와 호환]
    
    F[배열 공변성] -.->|희생| B
    F -.->|얻음| C
    
    G[제네릭 불변성] -.->|얻음| B
    G -.->|희생| C
    
    H[와일드카드] -.->|균형| B
    H -.->|균형| C
    
    style B1 fill:#c8e6c9
    style C1 fill:#e1f5fe

현실적인 권장사항

1. 배열보다는 제네릭 컬렉션을 사용하세요

1
2
3
4
5
// 지양
String[] strings = new String[10];

// 권장
List<String> strings = new ArrayList<>();

2. PECS 원칙을 활용하세요

1
2
3
4
5
// 데이터를 생산(읽기)하는 경우
public void processNumbers(List<? extends Number> numbers) { }

// 데이터를 소비(쓰기)하는 경우  
public void addNumbers(List<? super Number> numbers) { }

3. 타입 안전성을 우선하되, 필요시 와일드카드로 유연성을 확보하세요

결론

변성은 그 자체가 목적이 아니라 더 나은 코드를 작성하기 위한 도구입니다. Java에서는:

  • 기본적으로 불변성을 유지하여 안전성을 확보하고
  • 필요시 와일드카드로 유연성을 추가하며
  • PECS 원칙을 통해 올바른 사용법을 가이드합니다

이러한 설계 결정들이 완벽하지는 않지만, 수많은 개발자들이 안전하고 효율적인 코드를 작성할 수 있도록 돕는 실용적인 해답이라고 생각합니다.

graph LR
    A[변성 이해] --> B[더 안전한 코드]
    B --> C[더 유연한 API]
    C --> D[더 나은 소프트웨어]
    
    style D fill:#c8e6c9

댓글남기기