다형성을 단순히 '다양한 형태를 가질 수 있는 성질'이라는 문법적 수준의 정의로 제한해서 생각을 하면, 실제 코드 작성시 어려움이 있을 것입니다. 아키텍처 관점에서 다형성은 코드의 유연성(Flexibility)을 확보하고 객체 간의 결합도(Coupling)를 낮추기 위해 존재하는 핵심 성질로 정의하는 것을 추천드립니다.
참고: 다형성을 활용하여 결합도 완화와 OCP (개방-폐쇄 원칙)
특정 구현체에 직접 의존하지 않고 추상화된 상위 인터페이스나 추상 클래스에 의존하게 만들면, 기존 비즈니스 로직(클라이언트 코드)을 수정하지 않고도 새로운 기능이나 정책을 쉽게 확장할 수 있는 구조(OCP)를 달성할 수 있습니다.
다형성을 실현하기 위해 업캐스팅과 다운캐스팅을 시기적절하게 활용해야 하지만, 다루기 전에 반드시 각각의 한계와 위험성을 인지해야 합니다.
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(); // 부모 뷰를 다시 넓혀서 자식 고유 기능 수행
}
}
}수동 형변환이 가지는 런타임 에러의 위험성을 컴파일 타임의 안전성으로 끌어올리기 위해 제네릭과 와일드카드의 개념이 추가되었습니다. 이들은 작동 원리를 명확히 이해하고 세심히 다뤄야 합니다.
와일드카드를 사용할 때는 데이터의 흐름(읽기 전용인지, 쓰기 전용인지)에 따라 상한선과 하한선을 정확히 제어해야 설계가 꼬이지 않습니다.
? extends T): 데이터를 주로 읽어오는 공급자 역할일 때 사용합니다. 상한 경계를 지정하여 안정적으로 T 타입으로 읽을 수 있지만, 하위 타입이 무엇인지 불분명하므로 새로운 데이터를 추가할 수는 없습니다.? 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);
}
}순수 자바 환경에서의 다형성은 Spring의 DI(의존성 주입) 컨테이너와 결합할 때 진정한 위력을 발휘합니다. 동일한 인터페이스를 구현한 여러 개의 빈(Bean)이 존재할 때 발생하는 충돌을 해결하고, 런타임에 클라이언트의 요구에 맞춰 동적으로 구현체를 선택하는 4가지 실무 라우팅 기법입니다.
공통 인터페이스
public interface PaymentService {
void processPayment(int amount);
}인터페이스 내부에 자신이 어떤 조건을 처리할 수 있는지(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);
}
}스프링 빈의 명칭(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);
}
}동적 파라미터가 가지는 문자열 기반의 취약성(오타, 런타임 에러)을 제거하기 위해 상수로 매핑 관계를 규정하고, 이를 전담 팩토리 객체를 통해 안전하게 가져오는 구조입니다.
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);
}
}비즈니스 결제 로직 내부나 파일에 라우팅 정보를 두지 않고, 클래스 상단 메타데이터에 어노테이션 정의를 선언하여 런타임에 리플렉션으로 주입 지도를 동적 구성하는 완벽한 디커플링 구조입니다.
@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 계정으로 의견이나 질문을 남길 수 있습니다.