본문 바로가기

Algorithms and Languages/Python

[Python] 이미 동적 타입 언어인 파이썬에 제네릭을 사용하는 이유.

제네릭 프로그래밍을 설명할 때 흔히 '타입을 미리 정하지 않고, 사용될 때 그 타입을 결정하는 방식'이라고 설명한다.

 

하지만, 나는 이게 상당히 C++나 자바틱한 설명이라고 생각한다.

왜냐하면, 사실 파이썬은 이미 동적 타입 언어(Dynamically typed language)이기 때문에, 저 설명을 들으면 '사용될 때 정해진단 거, 런타임 때 정해진단 거 아니야? 파이썬은 이미 런타임 때 타입이 결정되는데, 제네릭을 쓰는 의미가 있나? 동적 타이핑은 이미 파이썬에 있는 기능 아닌가?'하는 의문이 들 수 있기 때문이다.

 

사실 내가 그랬고, 자바에서 제네릭을 간단히 배우긴 했지만 그 때는 코드 따라치기에 급급했지 제대로 이해하진 못했었다.

그래서 이번 글로 정리해보려고 한다.


우선 제네릭을 이해하기 위해 잠시 파이썬은 내려놓고, C++나 자바와 같은 정적 타입 언어의 관점으로 생각해보자.

 

이 들 언어는 당연히 변수 선언 시 정적 타이핑을 하므로, 이들에게 제네릭 프로그래밍은 (뒤에 설명할 여러 장점들과 더불어) 타입을 나중에 지정해, 여러 타입들을 동적으로 받을 수 있게 해준다.

(물론 여전히 제네릭 타입 매개변수(ex. T)의 타입은 컴파일 시 정해지므로, 동적 타이핑을 제공한다고 하면 틀린다.)

 

예를 들어, 아래 코드를 보자.

class Box<T extends Number> {  // T는 Number 또는 Number의 서브클래스여야 함
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

위 예시에서 Box 클래스를 정의할 때, T가 Number 클래스나 Number 클래스의 서브클래스만 받을 수 있도록 제한했다. 따라서 Integer, Double, Float 등 Number의 서브클래스 타입은 사용할 수 있지만, String이나 Object와 같은 다른 타입은 사용할 수 없다. 

 

즉, 저 생성자 Box의 매개변수로 들어오는 T에, 정수나 더블, 플로트 등 다양한 타입을 받을 수 있도록, 그 후보군을 내가 정해주기만 하고 T의 타입은 나중에 결정하겠다는 것이다.

 

여기서 '나중에'를 '런타임 때'로 해석하면 안 된다. 여기서 '나중에'는, '프로그래머가 타입 결정하는 코드를 나중에 작성할 수 있게 열어둔 것 뿐, 타입이 컴파일 때 결정되는 건 똑같다'고 봐야 한다. 말을 어렵게 썼긴 했는데 대충 이해될 거라 생각한다.

 

위처럼 정의된 클래스를 테스트해보면 아래와 같은 결과가 나온다.

Box<Integer> intBox = new Box<>(10);   // OK, Integer는 Number의 서브클래스
Box<Double> doubleBox = new Box<>(3.14); // OK, Double도 Number의 서브클래스

Box<String> strBox = new Box<>("Hello");  // 컴파일 오류, String은 Number의 서브클래스가 아님

즉, 제네릭은 '후보군을 정해둠'과 동시에, '후보군이 아닌 얘는 미리 컴파일 오류를 일으켜서 프로그래머가 코드를 관리하고 테스트하기 용이하게 해준'다.

 

사용 예시로는 전화번호 등이 있다. 전화번호를 누군가는 `01012345678`로, 누군가는 `010-1234-5678`로 입력할 수 있으니, 두 가지 가능성을 모두 열어둘려면 제네릭으로 Integer과 String을 후보군으로 두면 된다.

 

참고로, 자바에서 제네릭이 필수로 사용되는 이유는, 쉽게 말해 '컴파일 때 오류 안나고 런타임 때 오류날 수 있는 것들, 미리 컴파일 때 오류나게 만들어 미리 맞고 고치자!'이다. 

자세한 설명은 TMI가 될 수 있으니 하단 접은글로 첨부한다.

더보기

자바에서 제네릭 없이 여러 타입을 처리하려면, Object 클래스 따위를 사용해 변수를 받아줘야함. 근데 이러면 실제 값의 타입에 대한 제약이 없어지므로, 명시적 타입 캐스팅이 필요한데 이 때 잘못 변환돼서 런타임 때 `ClassCastException`이 발생할 수 있음. 즉, 해당 타입에 대한 검사를 컴파일 타임에 할 수가 없음. 개발에 매우 불리하겠지?

 

예를 들어, 아래와 같은 사례가 있음.

public class Box {
    private Object value;  // Object 타입으로 선언

    public Box(Object value) {
        this.value = value;
    }

    public Object getValue() {
        return value;
    }

    public static void main(String[] args) {
        // 제네릭을 사용하지 않으면 Object 타입으로 처리
        Box intBox = new Box(42);        // Integer
        Box strBox = new Box("Hello");   // String
        Box doubleBox = new Box(3.14);   // Double

        // 아래에서 타입 캐스팅 오류를 발생시켜봄

        // 잘못된 타입 캐스팅 예시:
        // strBox.getValue()는 Object 타입이지만, 실제로는 String 타입입니다.
        // 이 값을 Integer로 캐스팅하면 ClassCastException이 발생합니다.
        try {
            Integer value = (Integer) strBox.getValue();  // 오류 발생
            System.out.println(value);  // 실행되지 않음
        } catch (ClassCastException e) {
            System.out.println("캐스팅 오류 발생: " + e.getMessage());
        }

        // doubleBox.getValue()는 Object 타입이지만, 실제로는 Double 타입입니다.
        // 이를 String으로 캐스팅하면 또 다른 오류 발생.
        try {
            String value = (String) doubleBox.getValue();  // 오류 발생
            System.out.println(value);  // 실행되지 않음
        } catch (ClassCastException e) {
            System.out.println("캐스팅 오류 발생: " + e.getMessage());
        }
    }
}

이는 실제로 코드가 실행돼서 런타임 단계에 (String)라는 형 변환 연산자가 실행되기 전에는, 즉 컴파일 단계에선 오류가 나지 않음.

public class Box {
    private Object value;  // Object 타입으로 선언

    public Box(Object value) {
        this.value = value;
    }

    public Object getValue() {
        return value;
    }

    public static void main(String[] args) {
        // 제네릭을 사용하지 않으면 Object 타입으로 처리
        Box intBox = new Box(42);        // Integer
        Box strBox = new Box("Hello");   // String
        Box doubleBox = new Box(3.14);   // Double

        // 아래에서 타입 캐스팅 오류를 발생시켜봄

        // 잘못된 타입 캐스팅 예시:
        // strBox.getValue()는 Object 타입이지만, 실제로는 String 타입입니다.
        // 이 값을 Integer로 캐스팅하면 ClassCastException이 발생합니다.
        try {
            Integer value = (Integer) strBox.getValue();  // 오류 발생
            System.out.println(value);  // 실행되지 않음
        } catch (ClassCastException e) {
            System.out.println("캐스팅 오류 발생: " + e.getMessage());
        }

        // doubleBox.getValue()는 Object 타입이지만, 실제로는 Double 타입입니다.
        // 이를 String으로 캐스팅하면 또 다른 오류 발생.
        try {
            String value = (String) doubleBox.getValue();  // 오류 발생
            System.out.println(value);  // 실행되지 않음
        } catch (ClassCastException e) {
            System.out.println("캐스팅 오류 발생: " + e.getMessage());
        }
    }
}

 

위 코드를 제네릭을 사용해 바꿔보면 아래와 같다.

public class Box<T> {
    private T value;

    public Box(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }

    public static void main(String[] args) {
        // 제네릭을 사용해 다양한 타입을 처리
        Box<Integer> intBox = new Box<>(42);
        Box<String> strBox = new Box<>("Hello");
        Box<Double> doubleBox = new Box<>(3.14);

        System.out.println(intBox.getValue());   // 42
        System.out.println(strBox.getValue());   // Hello
        System.out.println(doubleBox.getValue()); // 3.14
    }
}

자, 그러면 우리는 C++나 자바같은 정적 타입 언어에서 제네릭을 쓰는 이유에 대해 알고 있다.

 

그러면, 다시 우리의 파이썬에서도 왜 제네릭을 쓰는 걸까? 

아니, 애초에 제네릭을 쓴다는 게 논리적으로 가능한가? 이미 동적 타입 언어인데?

 

 

사실, 제네릭 프로그래밍은 정적 타입 언어의 위와 같은 문제점을 보완하기 위해 나온 것이라, 동적 타입 언어의 관점으로는 다른 해석이 필요하다.

 

파이썬은 애초에 동적 타이핑 언어이기 때문에 문법적으론 제네릭을 따로 사용할 필요가 없다.

실제로 파이썬에서 제네릭으로 타입을 지정해줘봤자, 다른 타입이 들어온다고 자바처럼 컴파일 및 런타임 시 오류를 발생시켜주지 않는다. (파이썬이 뭔 컴파일? 할 수 있겠지만, 사실 파이썬도 컴파일이 일어난다는 걸 상기하자.)  

 

파이썬은 제네릭을 위의 고전적인 이유인 '여러 후보군을 받기 위해' 사용한다기 보단,

타입 힌트와 함께 '프로그래머가 실수하지 않도록 알려주기 위한 것'이라고 봐야 한다.

 

무슨 소리일까? 아래 코드를 보자.

from typing import TypeVar

T = TypeVar('T', int, str)  # T는 int 또는 str만 허용

def process(value: T) -> T:
    return value

# 정상 실행
print(process(10))     # 10, int
print(process("hello")) # "hello", str

# 리스트는 문제 없이 들어감, 런타임 오류 발생 안 함
print(process([1, 2, 3]))  # [1, 2, 3], list

# 타입을 벗어난 값도 런타임 오류는 발생하지 않음
print(process(3.14))  # 3.14, float

보면, 제네릭으로 int 및 str만 후보군에 올라와 있지만, 리스트나 float 타입 객체를 전달해도 앞서 말한대로 오류가 발생하지 않는다. 자바에서는 컴파일 시 오류가 발생했던 것과 대조적이다.

 

이는 당연하다. 파이썬은 동적 타입 언어이고, 파이썬의 타입 힌트(추후 설명하겠지만, 파이썬에서는 타입 힌트를 제네릭이 구체화한다고 필자는 생각한다.)는 인터프리터 실행 단계에서 아무런 영향도 주지 않기 때문이다.

 

파이썬에선 `Mypy`라는 정적 타입 검사 도구를 사용해 해당 코드를 돌려보면, 타입 힌트와 다르게 실행되는 코드가 있으면 오류를 발생시켜준다. 

예를 들어 아래와 같다.

$ mypy your_script.py

error: Argument 1 to "process" has incompatible type "List[int]"; expected "Union[int, str]"
error: Argument 1 to "process" has incompatible type "float"; expected "Union[int, str]"

 

즉, Mypy라는 도구를 사용해 파이썬에서도 자바처럼 개발자가 코딩 과정에서 자기가 코드를 잘 짜고있는지 테스트가 가능한 거다.


파이썬에서의 제네릭 사용의 다른 장점들을 살펴보기 전에, `제네릭`과 `타입 힌트`에 대한 용어 정리를 잠깐 하고 가자.

정의는 GPT의 답변을 수정해 가져왔다.

 

타입 힌트 : 변수, 함수 매개변수 및 반환값의 타입을 명시적으로 표시하는 방법입니다. 파이썬에서는 타입을 명시적으로 선언하여, 코드의 가독성을 높이고, 정적 분석 도구(예: mypy)로 오류를 잡을 수 있게 돕습니다. 하지만 파이썬은 동적 타이핑 언어이기 때문에, 실제 실행에서는 타입 검사를 강제하지 않습니다.

=> 타입 힌트에 맞지 않게 함수를 호출하는 등 코드를 짜도, 실행 시 오류가 발생하진 않음. mypy와 같은 정적 분석 도구를 사용해줘야 오류가 발생해 잘못 짰다는 걸 알 수 있음.

# 타입 힌트 사용 예시
def add(a: int, b: int) -> int:
    return a + b

x: int = 10
y: str = "Hello"

 

제네릭 프로그래밍 : 제네릭 프로그래밍은 타입을 매개변수로 받는 클래스나 함수의 설계 방식입니다. 즉, 일반적인 타입 대신에 타입을 인수로 받아서 코드의 재사용성을 높이고, 다양한 타입을 처리할 수 있도록 하는 기능입니다. 제네릭을 사용하면 코드가 어떤 타입이든 처리할 수 있도록 확장 가능해집니다.

=> (이 설명은 약간 정적 타입 언어 관점의 설명이긴 함.)

=> 타입 힌트에서 설명한 '타입 힌트에 맞지 않게 함수를 호출하는 등 코드를 짜도, 실행 시 오류가 발생하진 않음. mypy와 같은 정적 분석 도구를 사용해줘야 오류가 발생해 잘못 짰다는 걸 알 수 있음.'부분이 여전히 동일하게 적용됨.

# 제네릭 프로그래밍 사용 예시
from typing import TypeVar

T = TypeVar('T', int, str)  # T는 int 또는 str만 허용

def process(value: T) -> T:
    return value

# 정상 실행
print(process(10))     # 10, int
print(process("hello")) # "hello", str

# 리스트는 문제 없이 들어감, 런타임 오류 발생 안 함
print(process([1, 2, 3]))  # [1, 2, 3], list

# 타입을 벗어난 값도 런타임 오류는 발생하지 않음
print(process(3.14))  # 3.14, float

 

 

즉, 파이썬에서는 타입 힌트는 파이썬에서 기본적으로 제공하는 기능이고, 타입 힌트를 통해 제네릭 프로그래밍이란 방식을 구현할 수 있다고 볼 수 있다.

어떻게 보면 위에서 '타입 매개변수 `T`를 정의하고, 이를 타입 힌트에다가 사용하는 것'을 보고, '제네릭 프로그래밍 방식으로 코드를 짰다'고 말하면 적절할 것 같다.

즉, TypeVar() 함수를 통해 T라는 제네릭 타입 변수를 하나 정의하고, 이를 매개변수의 타입 힌트로 사용하면, 제네릭 방식으로 프로그래밍을 했다!고 말할 수 있다.

 

(아님 타입 힌트를 쓰는 것 자체만으로도 제네릭을 썼다고 볼 수도 있다. 관점의 차이인 것 같다.)

 

즉, 타입 힌트는 이미 있는거고, 이걸 잘 주물주물해서 제네릭이라는 일종의 템플릿, 방식을 사용해 간단하고 가시성있게 후보군을 지정해 줬다 보면 되겠다.

하지만 일반적으로 파이썬에서도 '제네릭'이라고만 하면, '타입 힌트까지 포괄한 넓은 개념'으로 사용하는 경우가 많아, 필자도 이렇게 사용하고 있다.

 

 

참고로, 제네릭을 안 쓰고 타입 힌트만으로도 Union을 쓰면 여러 후보군을 지정해줄 순 있긴 한데, 제네릭을 쓰는 경우가 더 일반적이다. 아래 접은글 참고.

더보기

Union을 사용하는 경우

from typing import Union

def process(value: Union[int, str]) -> Union[int, str]:
    return value

# 사용 예
print(process(10))     # 10, int
print(process("hello")) # "hello", str
print(process(3.14))    # Mypy 사용 시 오류 발생
error: Argument 1 to "process" has incompatible type "float"; expected "Union[int, str]"

제네릭을 사용하는 경우

from typing import TypeVar

T = TypeVar('T', int, str)  # T는 int 또는 str만 허용

def process(value: T) -> T:
    return value

# 사용 예
print(process(10))     # 10, int
print(process("hello")) # "hello", str
print(process(3.14))    # 오류 발생, float은 지원하지 않음
error: Argument 1 to "process" has incompatible type "float"; expected "int | str"

여태까지의 이야기를 정리하면, 

 

'파이썬은 자바 등의 정적 타입 언어들과 달리 제네릭(타입 힌트)을 쓴다고 컴파일 시(및 심지어 런타임 시에도!)오류를 발생시켜주진 않지만, 

Mypy 등의 정적 분석 도구를 사용하면, 제네릭(타입 힌트)과 달리 실행되는 부분에서 오류를 발생시켜준다!'

 

고 정리할 수 있다.

 

그럼, 위의 디버깅 관점에서의 장점 말고도, 파이썬에서 제네릭을 썼을 때의 장점이 있을까?

동적 타입 언어인 파이썬에선 나만 안헷갈리면 그냥 전화번호를 입력받아도 정수형 숫자랑 문자열 다 입력받아 처리할 수 있는 거 아니야? 디버깅 관점의 장점만 있으면, 그냥 주석을 꼼꼼히 쓰고 프로그래머가 조심하면 해결될 문제 아닌가?

 

 

이에 대해선, '코드 너 혼자 보냐'고 답변해줄 수 있을 것 같다.

즉, 디버깅이 가능해진다는 게 '코드 유지보수' 차원의 장점이었다면, 

여기선 '다른 사람이 내 코드를 본다는 가정 하에, 코드 가독성 향상'을 위해서도 제네릭을 쓴다는 거다.

 

 

예를 들어, 처음에 든 예시인 전화번호를 입력받는 코드를 생각해보자.

from typing import TypeVar, Generic

T = TypeVar('T', str, int)  # 'T'는 str 또는 int만 허용

class PersonInfo(Generic[T]):
    def __init__(self, name: str, phone: T):
        self.name = name
        self.phone = phone
    
    def get_info(self):
        print(f"Name: {self.name}")
        print(f"Phone: {self.phone}")
        print()

if __name__ == "__main__":
    # 숫자로 된 전화번호
    student1 = PersonInfo("John", 1234567)
    
    # 문자열로 된 전화번호
    student2 = PersonInfo("Smith", "234-5678")
    
    # 리스트로 된 전화번호 (허용된 타입: int나 str은 아니지만, 제네릭을 더 확장하면 가능)
    student3 = PersonInfo("Jack", [1, 1, 1, 2, 2, 2, 2])
    
    student1.get_info()  # 출력: Name: John, Phone: 1234567
    student2.get_info()  # 출력: Name: Smith, Phone: 234-5678
    student3.get_info()  # 출력: Name: Jack, Phone: [1, 1, 1, 2, 2, 2, 2]

보면, 어떤 프로그래머가 봐도 '이 사람은 전화번호를 받고자 이 코드를 짰는데, 문자열과 숫자를 모두 받을 수 있도록 제네릭을 사용해 타입 힌트를 주었네!' 하고 직관적으로 이해가 가능하다.

 

그러나, 아래 코드를 보자.

class PersonInfo:
    def __init__(self, name, phone):
        self.name = name
        self.phone = phone
    
    def get_info(self):
        print(f"Name: {self.name}")
        print(f"Phone: {self.phone}")
        print()

if __name__ == "__main__":
    # 다양한 타입의 전화번호를 넣을 수 있음
    student1 = PersonInfo("John", 1234567)  # int
    student2 = PersonInfo("Smith", "234-5678")  # str
    student3 = PersonInfo("Jack", [1, 1, 1, 2, 2, 2, 2])  # list
    
    student1.get_info()  # 출력: Name: John, Phone: 1234567
    student2.get_info()  # 출력: Name: Smith, Phone: 234-5678
    student3.get_info()  # 출력: Name: Jack, Phone: [1, 1, 1, 2, 2, 2, 2]

보면 저 PersonInfo 클래스를 봤을 때, 이 사람이 각 매개변수를 어떤 방식으로 받고자 하는 건지 그 의도가 불명확하다. (물론 위 코드는 간단한 예시라 명확하지만, 실제 프로젝트에선 불명확할 가능성이 높다는 거!)


참고 자료 및 더 읽어보면 좋은 자료들.

더보기

https://meticulousdev.tistory.com/entry/PYTHON-TypeVar-%EA%B7%B8%EB%A6%AC%EA%B3%A0-Generic

 

[Python] TypeVar 그리고 Generic 이해하기

1. 데이터 타입의 일반화 파이썬 코드를 보다 보면 T = TypeVar(‘T’)과 Generic이라는 표현을 종종 보게 됩니다. T = TypeVar(‘T’)를 먼저 살펴보면 T는 형 변수(type variable)이고 ‘T’는 형 변수의 이름(

meticulousdev.tistory.com

https://hides.kr/1106

 

Python Generic Type

개요 스프링에서는 Spring Data JPA라는 것이 존재한다. 해당 라이브러리는 인터페이스를 생성 후 특정 인터페이스를 상속받으면 자동으로 메소드가 생성되는 역할을 한다. 예를 들어 findById(), findBy

hides.kr

https://wikidocs.net/184212

 

07-4 파이썬 타입 어노테이션

파이썬 3.5 버전부터 변수와 함수에 타입을 지정할 수 있는 타입 어노테이션 기능이 추가되었다. [TOC] ## 동적 언어와 정적 언어 a 변수에 숫자 1을 대입하고 typ…

wikidocs.net

https://www.quora.com/Do-generics-makes-more-sense-in-statically-typed-or-in-dynamically-typed-languages-Explain-your-answer

 

Do generics makes more sense in statically typed or in dynamically typed languages? Explain your answer.

Answer (1 of 4): By definition, a language with dynamic types uses "boxed" types, which have a value and a type tag. Therefore any collection or method is automatically generic, since the type of the variable is available at runtime and can be used to impl

www.quora.com

https://jakpentest.tistory.com/entry/Python%EC%9D%98-Generic%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-Repository-Pattern-%EB%A7%8C%EB%93%A4%EA%B8%B0-feat-PEP-560

 

Python의 Generic을 활용한 Repository Pattern 만들기 (feat, PEP 560)

개요 Repository Pattern은 데이터 저장소에 존재하는 데이터들을 마치 Application 메모리상에 존재하는 것처럼 가정하고 데이터 접근과 관련된 구현 사항을 추상화하는 패턴이다. Spring Boot에는 JPARepo

jakpentest.tistory.com