제네릭은 왜 사용할까?

반응형

자바 5에서는 enum, 어노테이션, 제네릭이 도입되었습니다. 이 중에서 처음 자바를 접하는 사람들이 가장 부담 없이 사용하는 기능은 아마 enum일 것입니다. 클래스를 생성하는 것만으로도 왜 필요한지, 어떻게 사용해야 하는지가 비교적 직관적으로 드러나기 때문입니다. 반면 어노테이션과 제네릭은 다릅니다. 문법 자체는 사용할 수 있지만, 사용한다고 해서 코드에 즉각적인 변화가 생긴다고 느끼기는 어렵습니다.
그래서 실제로는 사용하고 있음에도 불구하고, 왜 필요한지에 대해서는 깊이 고민하지 않고 넘어가는 경우가 많습니다. 하지만 자바 표준 라이브러리나 스프링 프레임워크 내부를 살펴보면, 어노테이션과 제네릭이 사용되지 않는 영역을 찾기 어려울 정도로 널리 활용되고 있다는 사실을 확인할 수 있습니다. 그만큼 이 두 기능은 자바 생태계 전반에서 중요한 역할을 담당하고 있습니다. 이 글에서는 그중에서도 제네릭을 왜 사용하는가에 대해 이야기해 보려고 합니다. 제네릭을 사용했을 때 발생할 수 있는 문제나, 어떤 점을 고려해야 하는지에 대한 내용은 다루지 않습니다. 그 이유는 이 글의 목적이 어떻게 사용하는가가 아니라, 왜 사용하게 되었는지에 집중하는 데 있기 때문입니다.

제네릭은 왜 탄생하게 되었는가?

자바에서 모든 클래스의 최상위 객체는 Object입니다. 이 말은 곧, 어떤 타입의 값이든 Object로 참조할 수 있다는 의미이기도 합니다.
예를 들어 다음과 같은 코드 작성이 가능합니다.

Object userEmail = "userEmail";

 

userEmail이라는 변수는 Object 타입이지만, 실제로 담긴 값은 String입니다.
그렇다면 이 값을 String으로 사용하려면 어떻게 해야 할까요?

(물론 처음부터 String userEmail = "userEmail"; 처럼 작성할 수도 있습니다. 다만, 지금은 설명을 위한 예시이므로 이 부분은 양해 부탁드립니다.)

이 경우, 다음과 같이 명시적인 타입 캐스팅이 필요했습니다.

String userEmail = (String) "userEmail";

이 방식은 동작은 하지만, 근본적인 문제가 있습니다. Object 타입으로 하위 객체를 바인딩해 사용하게 되면,
잘못된 타입 캐스팅으로 인해 런타임 시점에 ClassCastException이 발생할 수 있다는 점입니다.

런타임 시점에서 문제가 발생한다는 사실을 알게 되면, 개발 생산성 측면에서도 결코 유쾌한 경험이라고 보기는 어렵습니다.
그렇다면 잘못된 타입 캐스팅을 컴파일 시점에 미리 검증할 수 있다면 어떨까요? 이러한 문제를 해결하기 위해 등장한 것이 제네릭입니다.

제네릭은 어떻게 정의할 수 있을까?

제네릭은 사용하는건 생각보다 쉽습니다. 

List<String> roles = List.of("ROLE_USER");

 

이렇게 사용한다는 뜻은 ROLE_USER은 String으로 리스트로 사용이 된다는 의미로 해석할 수 있습니다.
만약, String값이 아닌 다른 값들을 넣을 수 있을까요? 

String userEmail = (String) "userEmail";

위 코드처럼 넣는다면 어떤일이 발생할까요? 

타입이 맞지 않는다고 컴파일 시점에서 알려줍니다.
만약, 꺽쇄 표시를 제거한다고 한다면, 컴파일은 성공을 하게 됩니다. 이것이 제네릭을 사용하는 이유입니다.
사용 자체는 이미 많은 사람들이 하고 있기 때문에 제네릭이 어렵지 않다고 느껴질 수 있습니다.
하지만 한 단계 더 나아가 직접 정의해서 사용하는 것은 어떨까요? 그렇다면 여기서 말하는 정의한다는 것은 과연 어떤 의미일까요?

다음과 같은 코드가 있다고 가정해 보겠습니다.

public interface PaymentStrategy<C, R> {

클래스 시그니처만 보더라도, 이 인터페이스는 결제 전략을 표현하기 위한 역할임을 유추할 수 있습니다.
오른쪽을 보면 C와 R이라는 문자가 보입니다.
제네릭 타입에 사용하는 문자사용에 대한 관례가 있기는 하지만, 문법적으로는 어떤 문자를 사용해도 무방합니다.

그렇다면 이 C와 R은 무엇을 의미할까요? 왜 굳이 인터페이스 시그니처에 이런 타입을 명시한 것일까요?

이 부분은 List에서 < >를 사용하는 이유를 떠올려 보면 이해하기 쉽습니다. List<T>에서 <T>를 사용하는 이유는,
해당 리스트에 특정 타입의 객체만 저장할 수 있도록 제한하기 위함입니다.

여기서도 동일한 개념이 적용됩니다.

public class BankTransferPaymentStrategy implements PaymentStrategy<PaymentBankTransferCommand, PaymentBankTransferResult>

이처럼 제네릭을 정의하면, 해당 전략은 특정 커맨드와 특정 결과 타입을 사용하는 구현체로 제한할 수 있습니다.

즉 다시 말해, 계좌 이체 결제 전략은 계좌 이체 커맨드와 계좌 이체 결과를 통해서만 정의되기를 의도한 것입니다.

물론 List처럼 변수 선언 시점에서도 동일하게 사용할 수도 있습니다.

PaymentStrategy<PaymentBankTransferCommand,PaymentBankTransferResult> payment = new BankTransferPaymentStrategy();

이처럼 타입을 제한한다는 것은 곧 타입 안정성을 높이는 행위입니다. 허용된 타입만 사용할 수 있도록 강제하기 때문에,
설계 의도가 명확해지고 사용하는 입장에서도 훨씬 안전한 코드가 됩니다.

그렇다면 만약 특정 클래스들만 적용하고 싶다면 어떻게 해야 할까요?

현재 구조에서는 PaymentStrategy에 String이나 그 외의 다른 타입들도 모두 사용할 수 있습니다.
이는 앞서 설명했던 Object와 직접적으로 연결됩니다.
제네릭을 정의할 때 <C, R>이라고 작성했다는 것은, Object를 상속한 모든 클래스가 사용 가능하다는 의미이기 때문입니다.

public interface PaymentStrategy<C extends PaymentCommand, R extends PaymentResult>

하지만 설계 의도에 따라, 이러한 자유도를 의도적으로 제한하고 싶은 경우도 충분히 발생할 수 있습니다.
그렇다면 이 제한은 어떻게 표현할 수 있을까요?

바로 extends와 super를 사용하는 방법입니다.

  • extends는 특정 클래스의 하위 클래스만 허용하겠다는 조건을 의미하고
  • super는 특정 클래스의 상위 클래스만 허용하겠다는 조건으로 해석할 수 있습니다.

이 두 키워드를 사용하면 제네릭 타입이 가질 수 있는 범위를 명확하게 제한할 수 있습니다.

다만 extends와 super를 무분별하게 사용하게 되면 의도한 대로 동작하지 않거나, 사용 자체가 어려워질 수 있습니다.

마무리

이 글에서는 제네릭을 왜 사용하는지, 그리고 어떻게 정의할 수 있는지에 대해 집중적으로 살펴보았습니다. 실제로 코드를 작성하다 보면 타입 안정성을 신경 써야 하는 지점은 생각보다 매우 많습니다. 이 부분을 제대로 고려하지 않으면, ClassCastException과 같은 런타임 오류로 이어질 수 있습니다. 제네릭은 이러한 문제를 런타임이 아닌 컴파일 시점에 사전에 방지하고, 보다 안전하고 일관된 코드를 작성하는 것을 목표로 합니다. 물론 제네릭에 대한 이야기는 여기서 끝이 아닙니다. 메서드 레벨에서 제네릭을 사용하는 방법, 또는 세션과 같은 영역에서는 왜 제네릭을 사용하지 않는지 등 더 다양한 주제들이 존재합니다. 이러한 내용들은 기회가 된다면, 추후 다른 글을 통해 다뤄보도록 하겠습니다.

 

반응형

'자바' 카테고리의 다른 글

String vs StringBuilder vs StringBuffer  (1) 2025.02.10
해싱  (1) 2025.01.29
JVM 겉핥기  (2) 2025.01.21
인터페이스  (0) 2022.03.21
SOLID  (0) 2021.09.10

댓글

Designed by JB FACTORY