다형성을 활용한 소스 구현 내용 정리
1. 다형성(Polymorphism)의 정의와 가치
다형성의 본질
다형성을 단순히 '다양한 형태를 가질 수 있는 성질'이라는 문법적 수준의 정의로 제한해서 생각을 하면, 실제 코드 작성시 어려움이 있을 것입니다. 아키텍처 관점에서 다형성은 코드의 유연성(Flexibility)을 확보하고 객체 간의 결합도(Coupling)를 낮추기 위해 존재하는 핵심 성질로 정의하는 것을 추천드립니다.
참고: 다형성을 활용하여 결합도 완화와 OCP (개방-폐쇄 원칙)
특정 구현체에 직접 의존하지 않고 추상화된 상위 인터페이스나 추상 클래스에 의존하게 만들면, 기존 비즈니스 로직(클라이언트 코드)을 수정하지 않고도 새로운 기능이나 정책을 쉽게 확장할 수 있는 구조(OCP)를 달성할 수 있습니다.
2. 형변환(Casting)의 양면성과 방어적 프로그래밍
다형성을 실현하기 위해 업캐스팅과 다운캐스팅을 시기적절하게 활용해야 하지만, 다루기 전에 반드시 각각의 한계와 위험성을 인지해야 합니다.
업캐스팅(Upcasting)과 뷰(View)의 축소
- 현상: 자식 타입을 부모 타입으로 변환하는 작업입니다. 이때 실제 힙(Heap) 메모리에 할당된 객체의 데이터가 손실되는 것이 아니라, 해당 객체를 바라보는 타입의 뷰(View)가 부모 수준으로 좁아지는 것입니다.
- 주의점: 뷰가 좁아지기 때문에 자식 클래스가 가지고 있는 고유 필드나 메서드에 접근할 수 없게 되므로, 이로 인한 접근 제약을 고려하여 설계해야 합니다.
다운캐스팅(Downcasting)과 런타임 위험성
- 현상: 좁아졌던 부모 타입의 뷰를 다시 본래의 자식 타입 수준으로 넓히는 작업입니다.
- 주의점: 컴파일러는 가리키고 있는 실제 메모리의 인스턴스 타입을 완벽히 추론하지 못합니다. 실제 생성된 인스턴스 타입과 다르게 강제 형변환을 시도할 경우 컴파일은 통과하지만 런타임 시점에
ClassCastException이 발생하여 애플리케이션이 크래시될 수 있습니다. - 해결책: 반드시 런타임 시점의 실제 타입을 검증하는 방어적 로직(
instanceof)이 수반되어야 합니다.
예시)
class User {
public void login() {
System.out.println("사용자 로그인 메인 로직 실행");
}
}
class AdminUser extends User {
public void grantPermission() {
System.out.println("관리자 전용 권한 부여 로직 실행");
}
}
public class UserService {
public void handleUser(User user) { // 매개변수를 통해 상위 타입으로 업캐스팅됨
user.login();
// 컴파일 에러: User 타입 뷰에서는 Admin 고유 메서드에 접근 불가
// user.grantPermission();
// 안전한 다운캐스팅
// 런타임 시 실제 인스턴스 타입을 검증하여 ClassCastException 방지
if (user instanceof AdminUser admin) {
admin.grantPermission(); // 부모 뷰를 다시 넓혀서 자식 고유 기능 수행
}
}
}3. 컴파일 타임 안전성을 위한 제네릭과 와일드카드
수동 형변환이 가지는 런타임 에러의 위험성을 컴파일 타임의 안전성으로 끌어올리기 위해 제네릭과 와일드카드의 개념이 추가되었습니다. 이들은 작동 원리를 명확히 이해하고 세심히 다뤄야 합니다.
제네릭(Generics)의 이점과 한계
- 이점: 개발자가 직접 형변환을 지시하지 않아도 컴파일러가 타입을 추론하고 캐스팅을 안전하게 처리하므로, 잘못된 다운캐스팅으로 인한 오류를 컴파일 시점에 차단합니다.
- 한계 (타입 소거): 하위 버전과의 호환성을 위해 타입 소거(Type Erasure) 방식을 취하므로 컴파일 완료 후 제네릭 타입 정보가 사라집니다. 이로 인해 오버로딩 충돌이나 런타임 캐스팅 시 예상치 못한 동작이 유발될 수 있습니다.
와일드카드와 PECS 원칙
와일드카드를 사용할 때는 데이터의 흐름(읽기 전용인지, 쓰기 전용인지)에 따라 상한선과 하한선을 정확히 제어해야 설계가 꼬이지 않습니다.
- Producer-Extends (
? extends T): 데이터를 주로 읽어오는 공급자 역할일 때 사용합니다. 상한 경계를 지정하여 안정적으로 T 타입으로 읽을 수 있지만, 하위 타입이 무엇인지 불분명하므로 새로운 데이터를 추가할 수는 없습니다. - Consumer-Super (
? super T): 데이터를 주로 저장하는 소비자 역할일 때 사용합니다. 하한 경계를 지정하여 T 또는 그 하위 타입을 안전하게 추가할 수 있지만, 꺼낼 때는 최상위 Object로만 읽을 수 있어 타입 추론이 제한됩니다.
예시
import java.util.*;
public class GenericAndWildcardExample {
// [제네릭: 컴파일 타임 안전성 검증]
public void genericValidation() {
// 제네릭 도입으로 잘못된 타입 수동 캐스팅 위험을 차단
List<String> list = new ArrayList<>();
list.add("String Data");
// 컴파일 에러 발생으로 안전성 확보
// Integer number = list.get(0);
// 별도의 다운캐스팅 없이 안전하게 사용
String text = list.get(0);
}
// [와일드카드: PECS 법칙 적용]
// 1. 상한 경계 와일드카드 (? extends T) : 데이터를 읽어올 때 (Producer)
public void printNumbers(List<? extends Number> list) {
for (Number n : list) {
System.out.println(n); // Number 타입으로 안전하게 읽기 가능
}
// 컴파일 에러: 하위 타입이 무엇인지 불분명하므로 쓰기 불가능
// list.add(10);
}
// 2. 하한 경계 와일드카드 (? super T) : 데이터를 쓸 때 (Consumer)
public void addNumbers(List<? super Integer> list) {
list.add(10); // Integer 및 그 하위 타입은 안전하게 추가 가능
// 컴파일 에러: 어떤 상위 타입이 나올지 알 수 없으므로 Object로만 읽기 가능
// Integer n = list.get(0);
}
}4. Spring 프레임워크 환경에서의 다형성 완성: 전략 패턴 (Strategy Pattern)
순수 자바 환경에서의 다형성은 Spring의 DI(의존성 주입) 컨테이너와 결합할 때 진정한 위력을 발휘합니다. 동일한 인터페이스를 구현한 여러 개의 빈(Bean)이 존재할 때 발생하는 충돌을 해결하고, 런타임에 클라이언트의 요구에 맞춰 동적으로 구현체를 선택하는 4가지 실무 라우팅 기법입니다.
공통 인터페이스
public interface PaymentService {
void processPayment(int amount);
}방식 1: List + supports() 순회 방식
인터페이스 내부에 자신이 어떤 조건을 처리할 수 있는지(supports) 명시하고, DI 컨테이너로부터 모든 구현체를 List로 주입받아 런타임에 일치하는 구현체를 탐색하는 구조입니다.
public interface PaymentServiceWithSupport extends PaymentService {
boolean supports(String payType);
}
@Service
public class KakaoPayService implements PaymentServiceWithSupport {
@Override
public boolean supports(String payType) { return "KAKAO".equals(payType); }
@Override
public void processPayment(int amount) { System.out.println("카카오페이 호출: " + amount + "원"); }
}
@Service
public class CreditCardService implements PaymentServiceWithSupport {
@Override
public boolean supports(String payType) { return "CREDIT_CARD".equals(payType); }
@Override
public void processPayment(int amount) { System.out.println("신용카드 호출: " + amount + "원"); }
}
@Service
public class OrderProcessorWithList {
private final List<PaymentServiceWithSupport> paymentServices;
// 모든 구현체 빈이 List로 자동 주입됨
public OrderProcessorWithList(List<PaymentServiceWithSupport> paymentServices) {
this.paymentServices = paymentServices;
}
public void checkout(String payType, int amount) {
PaymentServiceWithSupport targetService = paymentServices.stream()
.filter(service -> service.supports(payType))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("지원하지 않는 결제 수단: " + payType));
targetService.processPayment(amount);
}
}방식 2: Map 자동 주입 방식 (Bean Name 매핑)
스프링 빈의 명칭(Bean Name)을 Key로, 인스턴스를 Value로 하는 Map<String, Type> 구조로 자동 주입을 받아 활용하는 방식입니다. 탐색 비용 없이 즉시 구현체를 매핑합니다.
@Service("kakaoPay")
public class MapKakaoPay implements PaymentService {
@Override
public void processPayment(int amount) { System.out.println("카카오페이 Map 처리: " + amount); }
}
@Service("creditCard")
public class MapCreditCard implements PaymentService {
@Override
public void processPayment(int amount) { System.out.println("신용카드 Map 처리: " + amount); }
}
@Service
public class OrderProcessorWithMap {
private final Map<String, PaymentService> paymentServiceMap;
// 빈 이름이 Key값으로 맵핑되어 자동 주입됨
public OrderProcessorWithMap(Map<String, PaymentService> paymentServiceMap) {
this.paymentServiceMap = paymentServiceMap;
}
public void checkout(String payType, int amount) {
PaymentService service = paymentServiceMap.get(payType); // "kakaoPay" 또는 "creditCard" 매칭
if (service == null) throw new IllegalArgumentException("지원하지 않는 결제 수단");
service.processPayment(amount);
}
}방식 3: Enum + Factory 패턴 방식
동적 파라미터가 가지는 문자열 기반의 취약성(오타, 런타임 에러)을 제거하기 위해 상수로 매핑 관계를 규정하고, 이를 전담 팩토리 객체를 통해 안전하게 가져오는 구조입니다.
public enum PayType {
KAKAO("kakaoPayService"),
CREDIT_CARD("creditCardService");
private final String beanName;
PayType(String beanName) { this.beanName = beanName; }
public String getBeanName() { return beanName; }
}
@Component
public class PaymentServiceFactory {
private final Map<String, PaymentService> paymentServiceMap;
public PaymentServiceFactory(Map<String, PaymentService> paymentServiceMap) {
this.paymentServiceMap = paymentServiceMap;
}
public PaymentService getService(PayType payType) {
PaymentService service = paymentServiceMap.get(payType.getBeanName());
if (service == null) throw new IllegalArgumentException("활성화되지 않은 결제 수단");
return service;
}
}
@Service
public class OrderProcessorWithFactory {
private final PaymentServiceFactory factory;
public OrderProcessorWithFactory(PaymentServiceFactory factory) {
this.factory = factory;
}
public void checkout(PayType payType, int amount) { // Enum 타입으로 제약하여 안정성 확보
PaymentService service = factory.getService(payType);
service.processPayment(amount);
}
}방식 4: 커스텀 어노테이션 방식 (메타데이터 분리)
비즈니스 결제 로직 내부나 파일에 라우팅 정보를 두지 않고, 클래스 상단 메타데이터에 어노테이션 정의를 선언하여 런타임에 리플렉션으로 주입 지도를 동적 구성하는 완벽한 디커플링 구조입니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component // 스프링 컴포넌트 스캔 대상 포함
public @interface PayStrategy {
String value();
}
@PayStrategy("EXTERNAL_KAKAO_PAY")
public class AnnotationKakaoPay implements PaymentService {
@Override
public void processPayment(int amount) { System.out.println("어노테이션 기반 카카오 결제"); }
}
@Service
public class OrderProcessorWithAnnotation implements InitializingBean {
private final ApplicationContext applicationContext;
private final Map<String, PaymentService> routingMap = new ConcurrentHashMap<>();
public OrderProcessorWithAnnotation(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
// 스프링 컨테이션 빈 주입 완료 후 실행되는 초기화 콜백
@Override
public void afterPropertiesSet() {
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(PayStrategy.class);
for (Object bean : beans.values()) {
if (bean instanceof PaymentService service) {
PayStrategy annotation = bean.getClass().getAnnotation(PayStrategy.class);
routingMap.put(annotation.value(), service); // 애노테이션 설정값 기반 맵 구성
}
}
}
public void checkout(String payType, int amount) {
PaymentService service = routingMap.get(payType);
if (service == null) throw new IllegalArgumentException("지원하지 않는 결제 수단");
service.processPayment(amount);
}
}
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.