본문 바로가기
Course/Java

[java-basic] 12. 다형성과 설계

by Lpromotion 2025. 2. 24.
김영한의 실전 자바 - 기본편

목차
 

객체 지향 프로그래밍(OOP)에서 다형성(Polymorphism) 은 유지보수성과 확장성을 높이는 핵심 개념이다.
다형성을 활용하면 역할과 구현을 분리하여 클라이언트 코드의 변경 없이도 새로운 기능을 추가할 수 있다.
이 원칙을 가장 잘 실천하는 대표적인 설계 원칙이 OCP (Open-Closed Principle, 개방-폐쇄 원칙) 이다.

 

1. 좋은 객체 지향 프로그래밍이란?

객체 지향 프로그래밍(OOP)은 프로그램을 객체 단위로 설계하고, 객체들 간의 관계를 통해 시스템을 구축하는 방법론이다.
주요한 특징은 다음과 같다.

 

객체 지향 프로그래밍

  • 객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나
    여러 개의 독립된 단위, 즉 "객체"들의 모임으로 파악하고자 하는 것이다.
    각각의 객체메시지를 주고받고, 데이터를 처리할 수 있다. (협력)
  • 객체 지향 프로그래밍은 프로그램을 유연하고 변경이 용이하게 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다.

 

객체 지향 프로그래밍의 핵심 원칙

  1. 캡슐화(Encapsulation)
    • 객체의 데이터를 외부에서 직접 접근하지 못하도록 하고, 메서드를 통해서만 접근할 수 있도록 제한한다.
    • 불필요한 정보를 감추고 필요한 정보만 노출하여 데이터의 무결성을 유지할 수 있다.
  2. 상속(Inheritance)
    • 기존 클래스(부모 클래스)의 속성과 동작을 새로운 클래스(자식 클래스)에서 재사용할 수 있도록 하는 개념이다.
    • 코드 재사용성을 높이고, 공통 기능을 모아 관리할 수 있다.
  3. 다형성(Polymorphism)
    • 같은 인터페이스나 부모 클래스를 상속받은 객체가 서로 다른 방식으로 동작할 수 있는 특성을 의미한다.
    • 다형성을 활용하면 유연하고 확장 가능한 프로그램 설계가 가능하다.
  4. 추상화(Abstraction)
    • 객체의 핵심적인 속성과 동작만 정의하고, 불필요한 부분은 감추는 기법이다.
    • 인터페이스나 추상 클래스를 통해 객체의 역할만 정의하고, 구현은 하위 클래스에서 담당하도록 설계할 수 있다.

→ 특히 다형성을 잘 활용하면 객체 지향의 장점을 극대화할 수 있으며, 역할과 구현을 분리하여 OCP 원칙을 준수할 수 있다.

 

역할과 구현을 분리

  • 자바 언어의 다형성을 활용
    • 역할 = 인터페이스
    • 구현 = 인터페이스를 구현한 클래스, 구현 객체
  • 인터페이스를 사용하면 클라이언트는 역할(인터페이스)만 알면 된다.
    • 구현 대상의 내부 구조를 몰라도 되고, 변경되어도 영향 X
    • 구현 대상 자체를 변경해도 영향 X
  • 객체 설계 시 역할구현을 명확히 분리
    • 역할(인터페이스)를 먼저 부여하고, 그 역할을 수행하는 구현 객체 만들기 (구현보다는 역할이 먼저)
    • 꼭 인터페이스가 아니라 일반적인 상속 관계에서도 가능

 

객체의 협력

  • 클라이언트: 요청, 서버: 응답
    (요청하는 것은 모두 클라이언트, 요청에 응답하는 것은 모두 서버라고 함)
  • 수 많은 객체 클라이언트와 객체 서버는 서로 협력 관계를 가진다.

 

다형성

  • 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다.
  • 다형성의 본질을 이해하려면 협력이라는 객체 사이의 관계에서 시작해야 한다.
  • 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.
  • 디자인 패턴의 대부분은 다형성을 활용하는 것이다.
  • 스프링의 핵심인 제어의 역전(IoC), 의존관계 주입(DI)도 다형성을 활용하는 것이다.

 

 

2. 다형성 - 역할과 구현

역할와 구현을 분리 - 정리

  • 실세계의 역할과 구현이라는 편리한 컨셉을 다형성을 통해 객체 세상으로 가져올 수 있음
  • 유연하고, 변경이 용이
  • 확장 가능한 설계 → 수많은 종류의 새로운 자동사를 만들 수 있음
  • 클라이언트에 영향을 주지 않는 변경 가능
  • 역할(인터페이스) 자체가 변하면, 클라이언트, 서버 모두 큰 변경이 발생 (한계)
  • 인터페이스를 안정적으로 잘 설계하는 것이 중요

 

역할과 구현을 분리하지 않은 경우 (문제점)

public class Driver {
    private K3Car k3Car;
    
    public void setK3Car(K3Car k3Car) {
        this.k3Car = k3Car;
    }

    public void drive() {
        System.out.println("자동차를 운전합니다.");
        k3Car.startEngine();
        k3Car.pressAccelerator();
        k3Car.offEngine();
    }
}

메모리 그림

  • Driver 클래스가 K3Car에 의존하고 있다. (클래스 의존 관계)
  • 새로운 자동차(Model3Car)가 추가되면 Driver 코드도 수정해야 한다.
  • 즉, 확장하기 어려운 구조(OCP 위반) 이다.

 

역할과 구현을 분리한 경우 (좋은 설계)

public interface Car {
    void startEngine();
    void offEngine();
    void pressAccelerator();
}
public class K3Car implements Car {
    @Override
    public void startEngine() { System.out.println("K3Car.startEngine"); }
    @Override
    public void offEngine() { System.out.println("K3Car.offEngine"); }
    @Override
    public void pressAccelerator() { System.out.println("K3Car.pressAccelerator"); }
}
public class Model3Car implements Car {
    @Override
    public void startEngine() { System.out.println("Model3Car.startEngine"); }
    @Override
    public void offEngine() { System.out.println("Model3Car.offEngine"); }
    @Override
    public void pressAccelerator() { System.out.println("Model3Car.pressAccelerator"); }
}
public class Driver {
    private Car car;

    public void setCar(Car car) {
    	System.out.println("자동차를 설정합니다: " + car);
        this.car = car;
    }

    public void drive() {
        System.out.println("자동차를 운전합니다.");
        car.startEngine();
        car.pressAccelerator();
        car.offEngine();
    }
}

 

package poly.car1;

/**
 * 다형성을 활용한 런타임 변경
 * 런타임: 애플리케이션 실행 도중에 변경 가능
 */
public class CarMain1 {
    public static void main(String[] args) {
        Driver driver = new Driver();

        // 차량 선택(k3)
        K3Car k3Car = new K3Car();
        driver.setCar(k3Car);
        driver.drive();

        // 차량 변경 (k3 -> model3)
        Model3Car model3Car = new Model3Car();
        driver.setCar(model3Car);
        driver.drive();
        
    }
}
자동차를 설정합니다: poly.car1.K3Car@214c265e
자동차를 운전합니다
K3Car.startEngine
K3Car.pressAccelerator
K3Car.offEngine
자동차를 설정합니다: poly.car1.Model3Car@7cca494b
자동차를 운전합니다
Model3Car.startEngine
Model3Car.pressAccelerator
Model3Car.offEngine

 

  • `Driver` : 운전자는 자동차( `Car` )의 역할에만 의존한다. 구현인 K3, Model3 자동차에 의존하지 않는다.
    • `Driver` 클래스는 `Car car` 멤버 변수를 가진다. 따라서 `Car` 인터페이스를 참조한다.
    • 인터페이스를 구현한 `K3Car` , `Model3Car` 에 의존하지 않고, `Car` 인터페이스에만 의존한다.
    • 여기서 설명하는 의존은 클래스 의존 관계를 뜻한다. 클래스 상에서 어떤 클래스를 알고 있는가를 뜻한다.
    • `Driver` 클래스 코드를 보면 `Car` 인터페이스만 사용하는 것을 확인할 수 있다.
    • 새로운 자동차가 추가되더라도 `Driver` 클래스의 코드는 변경할 필요가 없다.
  • `Car` : 자동차의 역할이고 인터페이스이다. `K3Car` , `Model3Car` 클래스가 인터페이스를 구현한다.

 

 

3. OCP (Open-Closed Principle) 원칙

OCP(Open-Closed Principle, 개방-폐쇄 원칙)는 "확장에는 열려 있고, 수정에는 닫혀 있어야 한다."는 객체 지향 설계 원칙 중 하나이다.

  • Open for extension: 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.
  • Closed for modification: 기존의 코드는 수정되지 않아야 한다.

즉, 기존의 코드 수정 없이 새로운 기능을 추가할 수 있다는 의미이다.

 

OCP를 준수하는 코드 설계

1. 인터페이스 또는 추상 클래스를 활용해 역할과 구현을 분리한다.

2. 클라이언트(사용 객체)는 구체적인 구현이 아니라 인터페이스(역할)에 의존해야 한다.

3. 새로운 기능 추가 시 기존 코드를 변경하는 것이 아니라 새로운 클래스를 추가해야 한다.

 

전략 패턴 (Strategy Pattern)

전략 패턴은 OCP 원칙을 준수하면서, 런타임에서 알고리즘을 변경할 수 있도록 하는 대표적인 디자인 패턴이다.

  • 알고리즘(전략)을 독립적인 클래스로 분리하여 런타임에 동적으로 변경 가능하도록 한다.
  • 클라이언트 코드(사용하는 객체)는 전략(알고리즘) 자체를 변경하지 않고도 다양한 기능을 수행할 수 있다.

 

전략 패턴의 구조

1. Strategy 인터페이스: 다양한 알고리즘을 정의하는 역할.

2. ConcreteStrategy (구현 클래스): 실제 알고리즘을 구현하는 클래스.

3. Context (클라이언트 코드): Strategy 인터페이스에 의존하며, 실제 전략을 변경할 수 있음.

 

결제 시스템 예제

1. `Pay` - Strategy 인터페이스 정의

public interface Pay {
    boolean pay(int amount);
}

 

2. 각 결제 방식이 `Pay` 를 구현

public class KakaoPay implements Pay {
    @Override
    public boolean pay(int amount) {
        System.out.println("카카오페이 시스템과 연결합니다.");
        System.out.println(amount + "원 결제를 시도합니다.");
        return true;
    }
}
public class NaverPay implements Pay {
    @Override
    public boolean pay(int amount) {
        System.out.println("네이버페이 시스템과 연결합니다.");
        System.out.println(amount + "원 결제를 시도합니다.");
        return true;
    }
}
public class NewPay implements Pay {
    @Override
    public boolean pay(int amount) {
        System.out.println("NewPay 시스템과 연결합니다.");
        System.out.println(amount + "원 결제를 시도합니다.");
        return true;
    }
}
public class DefaultPay implements Pay {
    @Override
    public boolean pay(int amount) {
        System.out.println("결제 수단이 없습니다.");
        return false;
    }
}

 

3. PayStore - `Map` 을 활용한 결제 수단 저장소

// 결제 수단을 보관하고 관리
public abstract class PayStore {
    private final static Map<String, Pay> payMethods = new HashMap<>();

    // 결제 수단 등록 (새로운 결제 수단 생길 시 수정됨)
    static {
        payMethods.put("kakao", new KakaoPay());
        payMethods.put("naver", new NaverPay());
        payMethods.put("new", new NewPay());
    }

    // 결제 수단 찾기
    public static Pay findPay(String option) {
        return payMethods.getOrDefault(option, new DefaultPay());
    }
}

새로운 결제 수단이 생기면 static 에 추가하면 된다.

 

4. `PayService` - OCP 를 준수하는 결제 서비스

public class PayService {

    public void processPay(String option, int amount) {
        boolean result = false;
        System.out.println("결제를 시작합니다: option=" + option + ", amount=" + amount);
        
        Pay pay = PayStore.findPay(option); // 결제 수단 찾기
        result = pay.pay(amount); // 결제 수행

        if (result) {
            System.out.println("결제가 성공했습니다.");
        } else {
            System.out.println("결제가 실패했습니다.");
        }
    }

}

결제 요청을 처리하는 역할을 하는 서비스 클래스이다. 

새로운 결제 수단이 추가되더라도 PayService 코드에는 변경 사항이 발생하지 않는다.

 

5. 실행 코드

public class PayMain {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        PayService payService = new PayService();

        while(true) {
            System.out.print("결제 수단을 입력하세요:");
            String payOption = scanner.nextLine();
            if(payOption.equals("exit")) {
                System.out.println("프로그램을 종료합니다.");
                return;
            }

            System.out.print("결제 금액을 입력하세요:");
            int amount = scanner.nextInt();
            scanner.nextLine();

            payService.processPay(payOption, amount);
        }

    }
}

 

실행 결과

결제 수단을 입력하세요:kakao
결제 금액을 입력하세요:10000
결제를 시작합니다: option=kakao, amount=10000
카카오페이 시스템과 연결합니다.
10000원 결제를 시도합니다.
결제가 성공했습니다.
결제 수단을 입력하세요:naver
결제 금액을 입력하세요:5000
결제를 시작합니다: option=naver, amount=5000
네이버페이 시스템과 연결합니다.
5000원 결제를 시도합니다.
결제가 성공했습니다.
결제 수단을 입력하세요:unknown
결제 금액을 입력하세요:2000
결제를 시작합니다: option=unknown, amount=2000
결제 수단이 없습니다.
결제가 실패했습니다.
결제 수단을 입력하세요:exit
프로그램을 종료합니다.

 

 

4. 정리

  • 객체 지향 프로그래밍(OOP) 은 프로그램을 독립적인 객체들의 협력으로 구성되며, 다형성을 활용하여 유연하고 확장 가능한 설계를 가능하게 한다.
  • 역할과 구현을 분리하면 클라이언트 코드(사용하는 객체)가 특정 구현체(구현 방식)에 의존하지 않으므로, 새로운 기능 추가 시 기존 코드를 변경할 필요가 없다.
  • OCP (Open-Closed Principle, 개방-폐쇄 원칙) 을 준수하려면, 인터페이스(역할)와 구현을 분리하고, 새로운 기능 추가 시 기존 코드를 수정하는 것이 아니라 새로운 클래스를 추가하는 방식으로 설계해야 한다.
  • 전략 패턴(Strategy Pattern) 을 활용하면, 실행 중(런타임)에 전략(알고리즘 또는 기능)을 변경할 수 있으며, 클라이언트 코드의 수정 없이 확장 가능한 구조를 만들 수 있다.
반응형

'Course > Java' 카테고리의 다른 글

[java-mid1] 2. 불변 객체  (0) 2025.03.04
[java-mid1] 1. Object 클래스  (0) 2025.02.25
[java-basic] 11. 다형성2  (0) 2025.01.30
[java-basic] 10. 다형성1  (0) 2025.01.20
[java-basic] 9. 상속  (0) 2024.12.27

댓글