객체지향 프로그래밍-2

Java 객체지향 프로그래밍

1. 추상화(Abstraction)

개념 및 중요성

추상화 는 불필요한 세부 사항을 숨기고 중요한 기능에만 집중하는 개념으로, 객체가 "무엇을" 하는지 보여주고 "어떻게" 하는지는 감추는 것을 말함. 이를 통해 복잡성을 줄이고, 사용자나 다른 개발자는 해당 객체의 내부 구현을 몰라도 사용할 수 있게 됨. 예를 들어 TV 를 리모컨으로 조작할 때, 리모컨의 버튼 (추상화된 인터페이스) 을 눌러 TV 를 켜거나 끄지만 내부 회로 동작(구현 세부)은 알 필요가 없는 것과 같음. 추상화를 잘 활용하면 시스템을 설계할 때 핵심적인 개념에 집중하여 이해와 유지보수가 쉬워지고, 코드의 재사용성도 높아짐.

`abstract class` 와 `interface` 비교

Java 에서는 추상 클래스 (abstract class )인터페이스(interface) 를 사용해 추상화를 구현함. 두 개념 모두 공통적으로 인스턴스화할 수 없고, 자시 클래스에 의해 구현되어야 할 메서드들을 정의한다는 점에서 비슷하지만, 차이점이 있음.

  • 추상 클래스 : 추상 메서드와 일반 메서드를 모두 가질 수 있음. 멤버 변수도 가질 수 있으며, 생성자도 정의할 수 있음. 한 클래스는 한 개의 추상 클래스를 extends 로 상속받을 수 있음.(단일 상속)

  • 인터페이스 : 기본적으로 모든 메서드는 추상 메서드이고 구현을 가질 수 없었으나, Java 8 부터 default 메서드와 static 메서드 로 일부 구현을 가질 수 있게 되었음. 모든 필드는 암묵적으로 public static final (상수) 이며, 메서드는 묵시적으로 public 임. 한 클래스는 여러 인터페이스를 implements 로 구현할 수 있어 다중 구현 이 가능하지만, 인터페이스는 상태를 가지지 않고 메서드 시그니처만 정의하므로 다중 상속의 복잡성(예: Diamond Problem) 을 피할 수 있음. (만약 두 인터페이스에서 동일한 시그니처의 default 메서드를 상속받는 경우, 구현 클래스에서 어떤 메서드를 사용할지 명시하거나 override 해야함.)

요약하면 추상 클래스는 계층 구조에서 공통 기능의 템플릿 을 제공하고 일부 구현도 포함할 수 있는 반면, 인터페이스는 구현 없이 계약(메서드 목록) 만 제공하여 구현 클래스들이 해당 계약을 따르도록 강제함. 실제 개발에서는 추상 클래스 를 상속받아 기본 동작을 확장하거나, 인터페이스 를 구현하여 다형성을 활용하는 등 상황에 맞게 사용함.

예제 코드

다음은 추상 클래스를 이용한 추상화 예시임. Device 추상 클래스에 추상 메서드 turnOn()turnOff() 를 정의하고, 구체 클래스인 TV 에서 이를 구현함.

abstract class Device {
    abstract void turnOn();
    abstract void turnOff();
}

// 추상 클래스를 상속받아 실제 구현 제공
class TV extends Device {
    @Override
    void turnOn() {
        System.out.println("TV 를 켰습니다.");
    }
    
    @Override
    void turnOff() {
        System.out.println("TV 를 껐습니다.");
    }
}

public class Main {
    public static void main(String[] args) {
        Device device = new TV(); // 추상 클래스 타입으로 객체 사용(다형성)
        device.turnOn(); // TV 를 켰습니다.
        device.turnOff(); // TV 를 껐습니다.
    }
}

위 코드에서 Device전원을 켜고 끄는 기능 이라는 추상적인 개념만 정의하고, 구체적인 동작은 TV 클래스가 오버라이딩하여 제공함. Main 에서 Device 타입으로 TV 객체를 사용함으로써, 구현체(TV ) 의 상세 내용은 몰라도 turnOn/turnOff 라는 추상화된 인터페이스 만으로 조작이 가능함. 실제 개발에서도 UI 프레임워크의 추상 클래스(예 : javax.swing.JFramepaint() 메서드 등)나 템플릿 메서드 패턴의 추상 클래스 등을 통해 공통 로직은 정의하고 세부 구현은 하위 클래스에 맡기는 방식으로 추상화를 활용함. 또한, 인터페이스 의 실전 예로 JDK의 List 인터페이스를 들 수 있는데, ArrayList , LinkedList 등이 각기 다른 방식으로 구현되었지만, 개발자는 List 라는 추상화된 타입으로 리스트를 다룰 수 있어 구현 교체가 쉬움.

2. 인터페이스(Interface)

인터페이스의 역할과 특징

인터페이스 는 클래스들이 반드시 구현해야 하는 메서드의 목록(계약) 을 정의하는 타입임. 인터페이스 자체는 일반적으로 메서드의 시그니처만 포함하고 구현은 없으므로, 이를 구현(implements ) 한 클래스가 메서드의 실제 동작을 제공해야 함. 인터페이스를 사용하는 핵심 목적은 다형성유연성 을 높이는 데 있음. 인터페이스를 통해 서로 다른 클래스들이 동일한 동작을 수행하도록 표준을 정하면, 코드에서는 구체 클래스 종류에 상관없이 인터페이스 타입으로 객체를 다룰 수 있음. 예를 들어 JDK 의 List 인터페이스를 구현한 ArrayList , LinkedList 는 사용법이 동일하여, 변수 선언을 List 타입으로 해두면 나중에 구현체를 바꾸어도 코드를 수정하지 않아도 됨.

인터페이스의 주요 특징 :

  • 모든 필드는 public static final (상수) 로 간주됨.

  • 모든 메서드는 기본적으로 public 이며, Java 8 부터 default 메서드 (기본 구현 제공)와 static 메서드 를 가질 수 있음.

  • 인터페이스는 다중 구현이 가능하여 한 클래스가 여러 인터페이스를 구현할 수 있음. 이를 통해 Java 는 클래스 다중 상속은 불가능하지만, 인터페이스 다중 상속 을 통해 비슷한 효과(여러 타입으로 동작) 을 얻음.

  • 인터페이스 자체는 인스턴스화할 수 없지만, 인터페이스 타입의 참조 변수로 해당 인터페이스를 구현한 객체를 가리킬 수 있음.

`default` 및 `static` 메서드 사용

Java 8 이후 인터페이스에는 default 메서드와 static 메서드를 정의할 수 있게 되었음. default 메서드는 인터페이스 내에 구현을 가진 인스턴스 메서드 로, 인터페이스를 구현하는 클래스에서 선택적으로 오버라이드 할 수 있음. 이 기능은 인터페이스에 새 메서드를 추가할 때 기존 구현체들을 깨뜨리지 않도록 기본 구현 을 제공하기 위해 도입되었음. 한편 static 메서드는 인터페이스 이름으로 호출할 수 있는 정적 유틸리티 메서드 를 인터페이스에 포함시키는 용도로 사용됨. 예를 들어, Java 의 Comparator 인터페이스는 reversed() 등의 default 메서드와 comparing() 같은 static 메서드를 제공하여, 구현 클래스들이 편리하게 사용할 수 있도록 함.

interface MyInterface {
    void doWork(); // 추상 메서드 (구현 필요)
    default void log(String msg) {
        System.out.println("LOG: " + msg);
    }
    static void porintVersion() {
        System.out.println("Interface v1.0");
    }
}

class Worker implements MyInterface {
    public void doWork() {
        System.out.println("일을 수행함.");
        log("작업 완료"); // default 메서드 호출 (구현 클래스에서 제공되는 기본 동작 사용)
    }
    // log() 를 오버라이드하지 않으면 MyInterface의 기본 구현 사용
}

public class Main {
    public static void main(String[] args) {
        Worker w = new Worker();
        w.doWork();
        MyInterface.printVersion(); // 인터페이스의 static 메서드 호출
    }
}

위 코드에서 WorkerMyInterface 의 추상 메서드 doWorker() 만 구현하고, log() 메서드는 구현하지 않았지만 인터페이스의 기본 구현이 있으므로 그대로 사용됨. Main 에서는 인터페이스의 정적 메서드 printVersion()인터페이스명으로 직접 호출 하고 있음. 결과적으로 default 메서드를 활용하면 인터페이스에 기능을 추가하면서 하위 호환성을 유지할 수 있고, static 메서드는 관련 유틸리티를 인터페이스에 모아둘 수 있음.

다중 상속과의 차이점

Java 는 클래스의 다중 상속 을 지원하지 않지만, 인터페이스는 다중 구현이 가능함. 다중 상속이 불가능한 이유는, 둘 이상의 부모 클래스로부터 상속받을 때 발생할 수 있는 모호성(Diamond Problem) 등의 문제를 피하기 위해서임. 인터페이스는 구현이 없거나 아주 제한적(default 메서드만 가능) 이라 상태나 주요 로직을 갖지 않으므로, 여러 인터페이스를 구현해도 모호성이 상대적으로 적음.

Java 에서 인터페이스 다중 구현 을 통해 클래스는 여러 타입의 객체로 간주될 수 있어 유연성이 높아짐. 예를 들어 한 객체가 Serializable , Cloneable 인터페이스를 모두 구현하면, 그 객체는 둘 다의 타입으로 동작하여 직렬화되거나 복제될 수 있음.

다만, Java 8 이후 defualt 메서드 도입으로 인터페이스에도 일부 구현이 생기면서, 두 인터페이스에서 동일한 default 메서드 를 상속받는 경우 구현 클래스에서 반드시 하나를 오버라이드하거나 선택해야함. 이는 다중 상속의 모호성을 해결하기 위한 규칙으로, 인터페이스 다중 구현 시 충돌을 명시적으로 해결하도록 함. 전반적으로, 인터페이스는 다중 상속이 가지는 복잡성을 크게 줄이면서도 다형성과 유연성을 제공하는 장점이 있음.

예제 코드

아래코드는 인터페이스 를 사용하여 다형성을 구현한 것임. Playable 인터페이스를 정의하고, 여러 클래스에서 이를 구현하여 이를 구현하여 각각 다른 방식으로 동작하도록 함. 또한 인터페이스의 default 메서드와 static 메서드 사용 예를 포함함.

interface Playable {
    void play();            // 추상 메서드
    default void pause() {  // default 메서드 : 모든 구현체에 공통 동작 제공
        System.out.println("일시정지 합니다."); 
    }
    static void showInfo() {// static 메서드 : 인터페이스 관련 정보 출력
        System.out.println("Playable 인터페이스 - 재생 기능 제공");
    }
}

// 여러 클래스가 동일한 인터페이스 구현
class AudioPlayer implements Playable {
    public void play() {
        System.out.println("오디오 재생");
    }
}
class VideoPlayer implements Playable {
    public void play() {
        System.out.println("비디오 재생");
    }
    public void pause() {
        System.out.println("비디오 일시정지 별도 구현");
    }
}

public class InterfaceDemo {
    public static void main(String[] args) {
        Playable audio = new AudioPlayer();
        Playable video = new VideoPlayer();
        
        audio.play();        // "오디오 재생"
        audio.pause();       // "일시정지 합니다." (AuditioPlayer 는 pause() 를 override 안 했으므로 기본 구현 사용)
        video.play();        // "비디오 재생"
        video.pause();       // "비디오 일시정지 (별도 구현)" (VideoPlayer 에서 override 함)
        
        Playable.showInfo(); // "Playable 인터페이스 - 재생 기능 제공"
    }
}

이 예제에서 AudioPalyerVideoPlayer 는 둘 다 Playable 인터페이스를 구현하였으므로, main 에서는 Playable 타입으로 다룰 수 있음. audiovideo 객체는 각각 다른 클래스의 인스턴스 지만, 인터페이스를 공유하므로 동일한 메서드(play , pause ) 를 호출할 수 있음. pause() 의 경우 AudioPlayer 는 기본 구현을 사용했고 VideoPlayer 는 자신만의 구현을 가졌는데, 이러한 유연성 덕분에 인터페이스에 새로운 메서드(pause ) 를 추가해도 기존 AudioPlayer 같은 클래스는 영향 없이 동작하고, 필요한 클래스만 override 하여 다르게 행동하도록 할 수 있음. 실제로 Java API 에서도 인터페이스를 통해 다형성을 많이 활용하는데, 예를 들어 Comparator 인터페이스 를 구현한 다양한 비교로직을 전략적으로 교체하거나, 컬렉션 프레임워크의 인터페이스들 (List , Set , Map 등)을 통해 구현체를 자유롭게 바꿀 수 있는 등이 인터페이스의 실전 활용임.

3. 캡슐화(Encapsulation)

데이터 보호의 필요성

캡슐화 는 객체지향의 원칙 중 하나로, 데이터(필드)와 그 데이터를 다루는 메서드 를 하나의 단위(클래스) 로 묶고, 외부로부터 직접 접근하지 못하도록 보호하는 것을 말함. 이를 통해 객체의 내부 표현이나 구현을 숨기고(information hiding ), 잘못된 사용으로부터 데이터를 지킬 수 있음. 예를 들어 클래스의 변수에 아무 제약 없이 접근하면, 예상치 못한 값으로 설정되어 객체의 상태가 무효화되어 일관성이 깨질 수 있음. 캡슐화를 적용하면 객체의 상태는 클래스 내부의 메서드를 통해서만 변경되도록 제한되어, 유효하지 않은 값 설정, 외부 간섭 등을 방지 할 수 있음. 또한 내부 구현을 숨겨두면 추후 구현을 변경하더라도 외부 코드에 영향을 주지 않아 유지보수성 이 높아짐.

접근 제한자(private, protected, public 등)

Java 에서는 접근 제한자를 사용해 캡슐화를 구현함. 접근 제한자 는 클래스의 멤버(필드나 메서드)에 대한 접근 권한을 제한하는 키워드임.

  • private : 개인 적인 범위. 같은 클래스 내부에서만 접근 가능함. 가장 강한 접근 제한으로, 외부에서 직접 볼 수 없도록 숨기고자 할 때 사용함.

  • default (아무 제한자도 명시하지 않으면, 패키지 프라이빗이라고도 함) : 패키지 범위 . 동일한 패키지 내의 다른 클래스들까지 접근 가능함.

  • protected : 보호된 범위 . 기본적으로 default 와 동일하게 동일 패키지 내 접근이 가능하고, 추가로 상속받은 하위 클래스 에서도 접근이 가능함.(다른 패키지라도 subclass이면 접근 허용).

  • public : 공개 범위 . 어디서나 접근 가능함.

일반적으로 필드 ( 인스턴스 변수 ) 는 private 으로 선언하여 외부에서 직접 조작하지 못하게 하고, 대신 필요한 경우 public 메서드 를 통해 간접적으로 접근하도록 함. 이렇게 하면 잘못된 데이터 입력을 방지하거나, 내부 구현을 숨길 수 있음.

getter 와 setter 를 활용한 접근 제어

private 으로 숨겨진 필드에 접근하거나 값을 설정하기 위해 흔히 사용하는 방법이 gettersetter 메서드임. 예를 들어 private int age; 필드가 있다면 public int getAge();public void setAge(int age) 메서드를 제공하는 식임. 이를 통해 필드에 접근할 수 있지만, 클래스 내부에서 통제된 방식으로만 접근하게 할 수 있음. setter 메서드 안에서 유효성 검사를 하여 잘못된 값이 들어오지 않도록 하거나, 값을 설정할 때 추가 동작이 필요하면 그 로직을 넣을 수 있음. 이런 식으로 객체의 내부 데이터는 보호되고, 외부에서는 허용된 메서드만을 통해 상호작용하게 됨.

예를 들어, 은행 계좌 객체를 생각해보면, balance 잔액 필드는 private 으로 숨기고, deposit(amount) 이나 withdraw(amount) 같은 메서드만 public 으로 제공하여 잔액을 늘리거나 줄이도록 함. 이렇게 하면 직접 잔액을 임의로 변경하는 일을 막고, 출금 메서드에서는 잔액 부족 여부 등을 검증하여 일관성 있는 상태 를 유지할 수 있음.

예제 코드

다음은 캡슐화 를 적용한 간단한 클래스 예제임. Person 클래스의 이름과 나이 필드는 private 으로 선언하고, gettersetter 를 통해서만 접근하도록 구현함.

class Person {
    // 필드를 private 으로 캡슐화
    private String name;
    private int age;
    
    // public Getter 메서드들
    public String getName() {
        return name;
    }
    public int getAge() {
        return age;
    }
    
    // public Setter 메서드들 (유효성 검사를 넣을 수도 있음)
    public void setName(String name) {
        if (name != null && !name.isEmpty()) {
            this.name = name;
        }
    }
    
    public void setAge(int age) {
        if(age >= 0) {
            this.age = age;
        }
    }
}

public class EncapsulationDemo {
    public static void main(String[] args) {
        Person p = new Person();
        // p.name = "Alice"; // 직접 접근 불가 - 컴파일 에러
        p.setName("Alice");    // Setter 를 통한 이름 설정
        p.setAge(30);          // Setter 를 통한 나이 설정
        System.out.println(p.getName() + "의 나이: " + p.getAge());
    }
}

위 코드에서 nameageprivate 이므로 main 메서드에서 p.name 으로 직접 접근할 수 없음. 대신 setName , setAge 메서드를 이용해 값을 넣고, getName , getAge 로 값을 꺼냄.

setAge 에서는 입력 값이 유효한지 확인하여 음수 나이는 무시하도록 했음. 이렇게 함으로써 Person 객체의 상태를 클래스 내부에서만 관리 하고, 객체를 사용하는 코드는 공개된 메서드만 이용하므로 데이터 무결성 이 보장됨. 실제로 많은 Java 클래스들이 이러한 패턴을 따르며, 이 접근 방식은 흔히 Java Bean 규약에서도 표준처럼 사용됨. (필드는 private, getter/setter 공개).

또한, 접근 제한자는 클래스 설계 시 계층에 따라 적절히 활용됨. protected 는 주로 하위 클래스가 부모의 일부 정보를 활용하도록 허용할 때 사용됨. 예를 들어 JDK 의 Thread 클래스의 protected void finalize() 메서드는 하위 클래스가 쓰레드 종료 시 리소스를 해제하는 동작을 오버라이드할 수 있게 함. 이렇듯, 캡슐화는 데이터 은닉인터페이스 공개 를 통해 객체의 자율성을 높이고, 잘못된 상호작용으로부터 객체를 보호하는 중요한 개념.

4. 다형성(Polymorphism)

개념 및 종류(컴파일 타임 vs 런타임 다형성)

다형성 은 같은 인터페이스를 통해 다양한 구현을 사용할 수 있는 능력을 말하며, 하나의 객체 참조가 여러 형태의 실제 객체를 가리킬 수 있는 것을 의미함. Java 에서 다형성은 두 가지 형태로 나눌 수 있음.

  • 컴파일 타임 다형성(Compile-Time Polymorphism) : 정적 다형성이라고도 하며, 메서드 오버로딩을 통해 실현됨. 컴파일 시점에 어떤 메서드가 호출될지 결정되므로 정적 바인딩 이라고 부름. 메서드의 시그니처(매개변수 타입이나 개수)가 다르면 같은 이름의 메서드를 여러 개 정의할 수 있고, 호출할 때 전달된 인자의 형태에 따라 컴파일러가 적절한 메서드를 선택함.

  • 런타임 다형성(Runtime Polymorphism) : 동적 다형성 또는 동적 메서드 디스패치 라고 하며, 메서드 오버라이딩과 상속(또는 인터페이스 구현)을 통해 실현됨. 실행 시점에 어떤 클래스의 메서드가 호출될지 결정되므로 동적 바인딩 이라고 부름. 부모 타입의 참조변수가 자식 객체를 참조할 때, 호출되는 메서드는 실제 객체의 클래스에서 오버라이드된 메서드임. 이는 JVM 이 런타임에 실제 객체 타입을 보고 결정하기 때문임.

예를 들어 Animal 이라는 부모 클래스의 sound() 메서드를 DogCat 이 오버라이딩했다면, Animal animal = new Dog(); animal.sount(); 코드에서 실행되는 것은 Dog 클래스의 sound() 메서드임. 이렇게 같은 코드 가 가리키는 객체에 따라 다른 동작을 수행하는 것이 다형성의 힘임.

5. SOLID 원칙

SOLID 는 객체지향 설계의 다섯 가지 기본 원칙을 나타내는 약어로, 소프트웨어의 유연성, 확장성, 유지 보수성 을 높이는 가이드라인임. 각 글자는 다음 원칙을 의미함.

  • S : 단일 책임 원칙(Single Responsibility Principle)

  • O : 개방-폐쇄 원칙(Open/Closed Principle)

  • L : 리스코프 치환 원칙(Liskov SUbsitution Principle)

  • I : 인터페이스 분리 원칙(Interface Segregation Principle)

  • D : 의존 역전 원칙(Dependency Inversion Principle)

단일 책임 원칙(SRP)

클래스는 하나의 책임만 가져야 한다.

즉 클래스는 변경할 이유가 단 하나뿐이어야 한다는 원칙임. 하나의 클래스가 하나의 역할에 집중하도록 설계하라는 뜻으로, 만약 여러 책임을 지고 있다면 그만큼 변경에 취약해지고, 코드 이해가 어려워짐. 로버트 마틴은 이를 "한 클래스는 단 한 가지의 변경 이유만 가져야 한다" 라고 정의했음. 예를 들어, 어떤 클래스가 비즈니스 로직도 처리하고, 데이터베이스 저장도 하고, UI 표시까지 담당한다면, 이 클래스는 여러 가지 이유 (로직 변경, DB 변경, UI 변경)로 수정될 수 있어 단일 책임 원칙을 어긴것임.

예시 및 적용

SRP 를 위반하는 코드

class Employee {
    public int calculatePay() {
        // 급여 계산 로직
        return /*...*/;
    }
    public void save() {
        // DB에 직원 정보 저장 로직
    }
    public String describeEmployee() {
        // 직원 정보 문자열 생성(리포트용)
        return /*...*/;
    }
}

SRP 에 따르면 위 클래스는 세 가지 책임을 갖고 있으므로, 이를 역할에 따라 세 개의 클래스로 분리 해야함. 예를 들어 PayrollCalculator (급여 계산), EmployeeRepository (DB 저장), EmployeeReporter (정보 출력) 등으로 나눌 수 있음.

class PayrollCalculator {
    public int calculatePay(Employee e) { /* ... */ }
}
class EmployeeRepository {
    public void save(Employee e) { /* ... */ }
}
class EmployeeReporter {
    public String describeEmployee(Employee e) { /* ... */ }
}

이렇게 분리하면 급여 계산 방식이 바뀌어도 PayrollCalculator 만 수정하면 되고, DB 연동이 바뀌면 EmployeeRepository 만 수정하면 됨. 각 클래스가 하나의 책임에 집중 하므로 응집도가 높아지고, 변경에 대한 영향 범위가 좁아지며, 이해하기도 쉬워짐. 실제 개발에서는 모델 - 뷰 - 컨트롤러(MVC) 패턴처럼 역할에 따라 클래스를 분리하는 것이 SRP 적용의 한 예임. 또한, 작은 서비스들을 모듈화 하거나, Microservice 아키텍처에서도 한 서비스가 하나의 책임(기능) 에 집중하도록 하는 등 SRP 의 정신을 다양한 수준에서 적용할 수 있음.

개방-폐쇄 원칙(OCP : Open/Closed Principle)

코드는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다

Bertrand Meyer 가 제안한 이 원칙은 소프트웨어 엔티티(클래스, 모듈, 함수 등) 는 새로운 기능을 추가할 때 기존 코드를 변경하지 않고 확장할 수 있도록 설계되어야 한다는 뜻임. 즉, 새로운 요구사항이 생기면 코드 추가로 해결 하고, 기존의 검증되고 안정된 코드는 가급적 변경하지 않는 것이 좋음. 기존 코드를 수정하면 예상치 못한 버그가 발생할 수 있으므로, OCP 를 지키면 버그 위험을 줄이고 시스템을 더 안정적으로 확장할 수 있음.

예시 및 적용

OCP 를 위반하는 전형적인 예는 조건문을 통한 분기 로 새 기능을 처리하는 코드임. 아래의 GraphicEditor 는 도형을 그리는 기능인데, 도형 타입별로 if/else 로 분기하고 있음.

class GraphicEditor {
    void drawShape(Object shape) {
        if (shape instanceof Circle) {
            drawCircle((Circle) shape);
        } else if (shape instanceof Rectangle) {
            drawRectangle((Rectangle) shape);
        }
        // 새로운 도형 추가 시, 이 클래스의 코드 수정이 필요함 (OCP 위반)
    }
    void drawCircle(Circle c) { /* ... */ }
    void drawRectangle(Rectangle r) { /* ... */ }
}

새로운 도형 Triangle 을 추가하려면 GraphicEditor.drawShape 메서드를 수정하고 else if 분기를 더해야 함. 이는 OCP 에 위배됨. (기존 클래스를 수정해야 하므로). 이를 개선하려면 다형성 을 활용하여 확장에 대비해야함. 예를 들어 도형의 상위 타입 Shape 에 추상 메서드 draw() 를 정의하고, 각 구체 도형이 자기만의 draw() 를 구현하게 됨.

abstract class Shape {
    abstract void draw();
}
class Circle extends Shape {
    void draw() {
        System.out.println("원을 그립니다.");
    }
}
class Rectangle extends Shape {
    void draw() {
        System.out.println("사각형을 그립니다.");
    }
}
class GraphicEditor {
    void drawShape(Shape shape) {
        shape.draw();  // 다형성 활용: 실제 객체의 draw() 호출
    }
}

이제 새로운 도형을 추가할 때는 Shape 를 상속받아 draw() 를 구현한 새로운 클래스(예 : Triangle ) 만 추가하면 됨. GraphicEditor 나 다른 기존 코드는 수정할 필요가 없음. 이렇게 기존 코드 수정 없이도 기능이 확장 되는 구조가 OCP 를 만족한 설계임.

현실 예로, Java 의 컬렉션 프레임워크 는 OCP 원칙을 잘 보여줌. java.util.List 인터페이스를 구현한 새로운 리스트 클래스를 추가해도(LinkedList , CopyOnWriteArrayList 등) 기존에 List 타입을 사용하던 코드에 영향 없이 사용할 수 있음. 또한 디자인 패턴 중 Strategy 패턴 이나 Template Method 패턴 등은 코드 수정 없이 행위를 확장 또는 변경할 수 있게 해주는 패턴트로, OCP 준수를 도움.

리스코프 치환 원칙(LSP)

상위 타입의 객체를 하위 타입 객체로 대체해도 프로그램의 정확성이 유지되어야 한다

부모 클래스의 자리를 자식 클래스가 대체해도 소프트웨어에 문제가 없어야 함. 하위 클래스는 최소한 상위 클래스에서 기대되는 동작을 충족해야 하며, 그렇지 않을 경우 다형성의 의미가 퇴색됨. 만약 하위 클래스가 부모와 전혀 다른 행동을 하거나 계약을 어긴다면, 그것은 LSP 위반임.

LSP 를 지키려면 하위 클래스는 상위 클래스의 규약(invariant) 과 약속(메서드의 의미, 사전조건/사후조건 등) 을 어겨서는 안됨. 예를 들어, 상위 클래스의 메서드가 "항상 양수를 변환한다" 고 명세돼있다면, 하위 클래스도 그 계약을 지켜야 함. 이는 디자인 바이 컨트랙트의 개념과도 연결됨.

예시 및 적용

LSP 위반의 클래식한 예는 사각형(rectangle) 과 정사각형(Square) 의 관계임. 직관적으로는 "정사각형은 사각형의 한 종류" 같아서 상속 관계로 만들 수 있을 것 같지만, 다음 시나리오를 생각하면 된다.

class Rectangle {
    protected int width, height;
    public void setWidth(int w) { width = w; }
    public void setHeight(int h) { height = h; }
    public int getArea() { return width * height; }
}
class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        width = w;
        height = w;    // 정사각형: 너비 설정시 높이도 동일하게
    }
    @Override
    public void setHeight(int h) {
        width = h;
        height = h;    // 정사각형: 높이 설정시 너비도 동일하게
    }
}

SquareRectangle 의 하위 클래스이지만, setWidth / setHeight 의 동작을 부모와 다르게 구현했음. 이로 인해 발생할 수 있는 문제 :

Rectangle rect = new Square();  // LSP에 따르면 Rectangle 자리에 Square 대입 가능해야
rect.setWidth(5);
rect.setHeight(4);
// 이 시점에서 rect의 width, height 값은? LSP에 따르면 Rectangle의 규약 기대는 width=5, height=4 
// 그러나 Square 구현에서는 width,height 둘 다 4로 설정됐을 것\

즉, Rectangle 이라면 area 가 54 = 20 이 기대되지만, 실제 Square 객체는 44 = 16 이 되어 상위 클래스의 기대를 위반 . 이처럼 Square 는 Rectangle 이 기대하는 동작(너비와 높이를 독립적으로 변경 가능) 을 만족하지 못하므로 LSP 를 위반한 사례로 흔히 언급됨.

LSP 를 지키기 위해서는 애초에 잘못된 상속 관계를 만들지 않는 것이 중요함. 위 경우는 상속 관계가 적절하지 않으며, 차라리 Rectangle 과 Square 를 별개로 두거나 공통 인터페이스를 구현하도록 하는 것이 나을 수 있음. 또 다른 예로, 부모 클래스의 메서드가 예외를 던지지 않는데 하위 클래스에서 그 메서드 호출 시 새로운 예외를 던지는 경우도 LSP 위반이 될 수 있음.

LSP 는 다형성을 제대로 활용하기 위한 기본 조건임. 실제 적용 시에는 하위 클래스가 부모 클래스의 행동 규약 을 충실히 따르는지를 고민해야함. 만약 하위 클래스가 부모보다 제약을 강화한다면(예 : 메서드가 처리할 수 있는 입력의 범위를 줄이거나, 추가적인 조건 요구), 이는 LSP 위반일 수 있음. LSP 를 지키면 클라이언트 코드는 상위타입만 신경 쓰면 되고, 어떤 하위 타입이 들어오든 문제없이 작동하므로 시스템의 대체 가능성유연성 이 극대화됨.

인터페이스 분리 원칙(ISP)

클라이언트는 사용하지 않는 메서드에 의존하지 않아야 한다

하나의 거대한 인터페이스를 여러 클라이언트가 공유하기 보다는, 특정 클라이언트에 맞는 여러 개의 작은 인터페이스 로 분리하라는 뜻임. 인터페이스가 너무 많은 기능을 한꺼번에 제공하면, 그것을 구현하는 클래스들은 실제 필요하지 않은 메서드까지 구현(혹은 빈 구현) 해야 하거나, 클라이언트 입장에서도 불필요한 메서드에 접근하게 됨. ISP 를 지키면 응집도 높은 인터페이스 여러 개가 생기고, 클라이언트는 자신이 사용하는 인터페이스만 구현된 클래스를 받으므로 더 단순하고 명확해짐.

예시 및 적용

ISP 위반 예로 흔이 드는 것은 하나의 인터페이스에 여러 역할의 메서드가 섞여있는 경우임. 예를 들어 복합기를 위한 인터페이스를 생각해보자.

interface MultiFunctionDevice {
    void print(Document doc);
    void scan(Document doc);
    void fax(Document doc);
}
class OldPrinter implements MultiFunctionDevice {
    public void print(Document doc) { /*...*/ } 
    public void scan(Document doc) { /* 구현할 수 없으면 빈 구현 또는 예외 */ }
    public void fax(Document doc) { /* 구현할 수 없으면 빈 구현 또는 예외 */ }
}

구형 프린터는 프린트만 가능하고 스캔/팩스는 불가능한데도, MultiFunctionDevice 인터페이스를 구현하려면 scan , fax 메서드를 정의해야함. 사용할 일 없는 메서드를 억지로 구현하거나 예외를 던지게 만드는 것은 ISP 위반임. 또한 이 인터페이스를 사용하는 클라이언트 입장에서도 어떤 구현이 fax 기능을 제공하는지 일일이 알아야 하는 불편이 생김.

ISP 에 따라 위 인터페이스를 분리해보면, 기능별로 인터페이스로 나눌 수 있음.

interface Printer {
    void print(Document doc);
}
interface Scanner {
    void scan(Document doc);
}
interface Fax {
    void fax(Document doc);
}
class SimplePrinter implements Printer {
    public void print(Document doc) { /* ... */ }
}
// 복합기처럼 여러 기능 가진 장비는 필요한 인터페이스를 모두 구현
class MultiPrinterScanner implements Printer, Scanner {
    public void print(Document doc) { /* ... */ }
    public void scan(Document doc) { /* ... */ }
}

이렇게 하면 필요한 기능만 구현 하면 되므로 불필요한 코드가 줄어듬. OldPrinterPrinter 만 구현하면 되며, MultiPrinterScanner 는 프린트와 스캔 둘 다 가능하므로 두 인터페이스를 구현함. 클라이언트도 만약 스캔 기능이 필요한 경우 대상 객체가 Scanner 를 구현했는지만 확인하면 됨.

실제 예로, 자바의 리스트(List ) 와 큐(Queue ) 인터페이스가 분리되어 있는 것을 생각할 수 있음.

만약 List 인터페이스에 큐 관련 메서드까지 다 들어있었다면, ArrayListLinkedList 구현 시 사용하지도 않는 큐 동작을 구현해야 할지도 모름. 대신 Java 는 용도에 따라 List, Queue ,Deque 등을 나누어 필요한 인터페ㅣ스만 구현하도록 했음. 이처럼 ISP 를 지키면 유연하고 이해하기 쉬운 인터페이스 설계 가 가능해지고, 변경 시 영향 범위도 줄어듬.

의존 역전 원칙(DIP)

의존 관계를 맺을 때, 구체(concrete) 클래스보다 추상(abstract) 에 의존하라

고수준 모듈(정책 결정이나 비즈니스 로직을 가진 부분) 은 저수준 모듈(세부 구현, DB 연결 등) 에 의존하면 안되며, 둘 다 추상화에 의존해야 한다는 의미임. 쉽게 말해, 구현이 아닌 인터페이스에 의존하도록 코드를 작성 하라는 것임. 또한 이 원칙의 두 번째 부분은 추상이 세부 사항에 의존해서도 안 된다는 것으로, 위존의 방향을 뒤집어(역전시켜) 구체적인 것이 추상적인 것에 맞추도록 설계해야함. DIP 를 따르면 변화에 덜 흔들리는 유연한 구조가 됨.

이 원칙이 중요한 이유는, 구현 세부사항은 자주 변하지만 추상 인터페이스는 상대적으로 안정적이기 때문임. 예를 들어 JDBC 를 사용할 때, 애플리케이션 코드는 데이터베이스 구체 제품(Oracle, MySQL 등) 클래스에 직접 의존하지 않고 JDBC 인터페이스에 의존함. 그러면 DB를 교체해도 구현만 바꾸면 되지, 애플리케이션 로직은 수정할 필요가 없음.

예시 및 적용

DIP 를 적용하지 않은 코드

class KeyboardReader {
    char readChar() { /* 키보드로부터 문자 읽기 */ }
}
class ScreenWriter {
    void writeChar(char c) { /* 화면에 문자 출력 */ }
}
class CopyUtil {
    void copy() {
        KeyboardReader reader = new KeyboardReader();
        ScreenWriter writer = new ScreenWriter();
        char ch;
        while ((ch = reader.readChar()) != (char)-1) {
            writer.writeChar(ch);
        }
    }
}

CopyUtil.copy() 메서드는 키보드에서 입력을 읽어 화면에 출력하는 기능임. 그러나 내부에서 KeyboardReaderScreenWriter 구체 클래스에 직접 의존하고 있어, 만약 입력 소스를 파일로 바꾸거나 출력 대상을 프린터로 바꾸려면 CopyUtil 의 코드를 수정해야함.

DIP 를 적용하려면 우선 추상화(인터페이스)를 도입함.

interface Reader { char readChar(); }
interface Writer { void writeChar(char c); }
class KeyboardReader implements Reader { /* readChar 구현 */ }
class FileReader implements Reader { /* 파일에서 char 읽는 구현 */ }
class ScreenWriter implements Writer { /* writeChar 구현 */ }
class PrinterWriter implements Writer { /* 프린터로 char 출력 구현 */ }
class CopyUtil {
    void copy(Reader reader, Writer writer) {
        char ch;
        while ((ch = reader.readChar()) != (char)-1) {
            writer.writeChar(ch);
        }
    }
}

이렇게 설계하면 CopyUtil.copy() 는 더 이상 특정 구현 클래스에 의존하지 않고, ReaderWriter 인터페이스에만 의존함. 실제 사용 시 필요한 구현체를 주입하면 되므로, 키보드 ⇒ 화면 복사는 copy(new KeyboardReader(), new ScreenWriter()) 로 파일 ⇒ 프린터 복사는 copy(new FileReader("input.txt"), new PrinterWriter()) 식으로 호출할 수 있음. 새로운 입력/출력 방식이 추가되더라도 ReaderWriter 인터페이스만 구현하면 CopyUtil 을 수정할 필요 없이 사용할 수 있음.

이처럼 DIP 를 따르면 의존성 주입(DI)IoC 컨테이너 를 활용한 설계가 자연스러워지며, 테스트 시에도 목(Mock) 객체를 주입하기 쉬워지는 등 이점이 많음. 실제로 Spring 프레임워크 등은 DIP를 근간으로 동작하며, 상위 모듈은 인터페이스에 의존하고 구체 구현체는 런타임에 주입(Inversion of Control) 되는 형태로 설계됨. DIP 의 취지는 고수준 정책이 저수준 구현 세부에 종속되지 않게 하여 모듈들을 더 독립적이고 확장 가능하게 만드는 것 임.

6. 디자인 패턴

디자인 패턴 은 소프트웨어 설계에서 자주 등장하는 문제들을 해결하기 위한 재사용 가능한 해결 템플릿임. 디자인 패턴을 사용하면 검증된 설계 구조를 따름으로써 개발 효율을 높이고, 코드의 이해도와 유지보수성을 향상시킬 수 있음. GoF(Gang of Four) 가 정립한 23가지의 고전적인 디자인 패턴이 있으며, 이를 크게 생성(Creational), 구조(Structural), 행위(Behavioral) 패턴으로 분류함.

생성 패턴(Creational Patterns)

객체 생성과 관련된 패턴들로, 객체 생성 과정의 결합도를 줄이고 유연성을 높이는 것 이 목적임.

  • 싱글톤(Singleton) 패턴

    • 개념 : 애플리케이션 전체에서 딱 한 개의 인스턴스만 존재하도록 보장 하고, 어디서든 그 인스턴스에 접근할 수 있도록 하는 패턴임. 싱글톤 클래스는 스스로의 인스턴스를 내부에 하나 생성해두고(private static 변수), 외부에서는 오직 getInstance() 같은 정적 메서드를 통해 그 인스턴스를 얻도록 설계됨. 생성자를 private 으로 선언하여 외부에서 new 로 여러 객체를 만드는 것을 막음.

    • 사용 사례 : 로깅 클래스, 캐시, 설정 관리, 쓰레드 풀, DB 연결 등 공유 자원 이나 글로벌 상태 관리에 주로 쓰임. 예를 들어, 로거(Logger) 는 여러 곳에서 호출되어도 하나의 인스턴스로 로그 파일을 관리하는 것이 일반적이고, Java의 Runtime.getRuntime() 이나 Desktop.getDesktop() 등이 싱글톤 패턴의 대표적인 활용임.

class Singleton {
    // 유일한 인스턴스를 담는 private static 변수
    private static Singleton instance;
    // private 생성자: 외부에서 인스턴스 생성 금지
    private Singleton() {}
    // 전역 접근을 위한 static 메서드
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();   // 처음 호출시 한 번만 생성
        }
        return instance;
    }
    // 예시 목적으로 하나의 상태를 가짐
    private int count = 0;
    public void increment() {
        count++;
        System.out.println("Count = " + count);
    }
}

public class SingletonDemo {
    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        s1.increment();  // Count = 1
        s2.increment();  // Count = 2 (s1, s2가 동일 인스턴스이므로 연속 증가)
        System.out.println(s1 == s2);  // true, 두 참조가 같은 객체임을 확인
    }
}

위 코드에서 Singleton 클래스는 내부에 정적 instance 를 유지하고, getInstance() 를 통해서만 객체를 얻음. 두 번 getInstance() 를 호출해도 같은 객체 s1s2 를 반환하므로, increment() 를 호출하면 count 값이 공유되어 누적됨. 싱글톤은 전역 상태를 관리하거나, 무거운 객체를 한 번만 생성해 재사용할 때 유용하지만, 남용하면 전역 변수를 사용하는 것과 유사한 문제가 생길 수 있으므로 필요한 경우에만 사용해야함. 또한 멀티쓰레드 환경에서는 싱글톤 생성에 대한 동기화 처리가 필요함.(이중 검증 락킹 등 기법 사용)

  • 팩토리(Factory) 패턴

    • 개념 : 객체 생성의 책임을 분리하여, 구체적인 클래스 이름을 직접 언급하지 않고도 객체를 생성 할 수 있게 하는 패턴임. 팩토리 패턴에는 구체적으로 팩토리 메서드 패턴 (Factory Method) 과 추상 팩토리 패턴 등이 있지만, 일반적으로 "팩토리" 라고 하면 객체 생성을 캡슐화하는 메서드나 클래스를 말함. 클라이언트 코드에서 new 키워드를 직접 사용하지 않고, 대신 팩토리 메서드 호출로 원하는 객체를 얻음.

    • 사용 사례 : 구체 클래스 결정 로직이 복잡한 경우 이를 한 곳에 몰아 관리하거나, 생성될 객체의 타입을 런타임까지 미루고 싶을 때 사용함. 예를 들어 DocumentBuilderFactory.newInstance() 는 XML 파서의 구체 구현체를 감춘 채 인터페이스만 반환하고, JDBC의 DriverManager.getConnection() 은 DB 연결을 얻을 때 DB 타입별 세부 로직을 감춰주는 팩토리 역할으 함. 또한 스프링 프레임워크 등 DI 컨테이너도 내부적으로 객체 생성 로직을 팩토리로 관리함.

interface Animal {
    void speak();
}
class Dog implements Animal {
    public void speak() {
        System.out.println("멍멍!");
    }
}
class Cat implements Animal {
    public void speak() {
        System.out.println("야옹!");
    }
}
// 팩토리 클래스: 입력에 따라 적절한 Animal 객체 생성
class AnimalFactory {
    public static Animal createAnimal(String type) {
        if (type.equalsIgnoreCase("dog")) {
            return new Dog();
        } else if (type.equalsIgnoreCase("cat")) {
            return new Cat();
        } else {
            throw new IllegalArgumentException("Unknown animal type: " + type);
        }
    }
}

public class FactoryDemo {
    public static void main(String[] args) {
        Animal a1 = AnimalFactory.createAnimal("dog");  // Dog 객체 생성
        Animal a2 = AnimalFactory.createAnimal("cat");  // Cat 객체 생성
        a1.speak();  // 출력: 멍멍!
        a2.speak();  // 출력: 야옹!
    }
}

위에서 AnimalFactory.createAnimal 메서드는 문자열 파라미터에 따라 Dog 또는 Cat 객체를 생성해줌. main에서는 어떤 구체 클래스가 생성되는지 알 필요 없이 팩토리를 통해 Animal 인터페이스 타입으로 객체를 받아 사용함. 새로운 동물이 추가되어도 팩토리 메서드 내부만 수정하면 되고, 사용하는 코드는 영향을 받지 않음.

이 패턴의 변형으로, 팩토리 메서드 패턴은 상속을 통해 객체 생성 메서드를 오버라이딩하여 종류별 생성 책임을 나누는 것이고, 추상 팩토리 패턴은 관련된 객체군 생성 팩토리들을 하나의 인터페이스로 묶는 패턴임. 예를 들어 GUI 라이브러리에서 OS별로 버튼, 텍스트필드 등을 생성하는 팩토리를 달리 구현하고, 추상 팩토리로 일관된 인터페이스를 제공하는 식임. 팩토리 패턴을 적용하면 객체 생성 로직과 사용 로직이 분리되어 코드 구조가 깔끔해지고 변경에 유연해짐.

구조 패턴(Structual Patterns)

클래스나 객체들을 조합하여 더 큰 구조를 만드는 패턴들로, 객체 간의 관계 형성 에 중점을 둠.

  • 어댑터 패턴(Adapter)

    • 개념 : 호환되지 않는 인터페이스를 가진 두 클래스를 연결해주는 중간 변환기 역할을 하는 패턴임. 즉, 한 클래스의 인터페이스를 클라이언트가 기대하는 다른 인터페이스로 변환 해줌. 어댑터를 사용하면 기존 코드를 변경하지 않고도 서로 인터페이스가 맞지 않는 클래스들을 함께 사용할 수 있음. 구조적으로 어댑터 클래스가 한쪽으로는 기존 클래스(Adapter) 를 가지고 있고, 다른 한 쪽으로는 타겟 인터페이를 구현해서, 요청을 받아 Adapter의 메서드 호출로 전달하거나 결과를 변환함.

// 클라이언트가 기대하는 인터페이스
interface MediaPlayer {
    void play(String filename);
}
// 가지고 있는 호환되지 않는 클래스 (다른 인터페이스)
class MP4Player {
    public void playFile(String file) {
        System.out.println("Playing MP4 file: " + file);
    }
}
// 어댑터 클래스: MediaPlayer 인터페이스를 구현하면서 내부에 MP4Player를 사용
class MP4Adapter implements MediaPlayer {
    private MP4Player mp4Player;
    public MP4Adapter(MP4Player player) {
        this.mp4Player = player;
    }
    @Override
    public void play(String filename) {
        // MediaPlayer의 play를 호출하면, 대신 MP4Player의 메서드를 호출해 준다.
        mp4Player.playFile(filename);
    }
}

public class AdapterDemo {
    public static void main(String[] args) {
        MediaPlayer player = new MP4Adapter(new MP4Player());
        player.play("movie.mp4");  // 어댑터가 MP4Player의 playFile을 호출 -> "Playing MP4 file: movie.mp4"
    }
}

위 코드에서 MediaPlayer 를 요구하는 클라이언트 코드(main ) 는 MP4Adapter 를 통해 MP4Player 를 사용함. 어댑터가 중간에서 인터페이스 변환을 해주기 때문에, 클라이언트는 play() 라는 일관된 메서드를 호출하지만 실제로는 MP4PlayerplayFile() 이 실행됨. 이렇게 하면 MP4Player 클래스를 수정하지 않고도 MediaPlayer 인터페이스를 따르는 것처럼 사용할 수 있음.

실무에서도 외부 시스템 연동 시 프로토콜이나 인터페이스 차이가 있을 때 어댑터 객체를 구현해 해결함. 예를 들어, 새로 도입한 결제 모듈이 기존 인터페이스와 다르면 어댑터를 만들어 기존 코드와 새 모듈을 연결하는 식임. 어댑터 패턴은 재사용성 을 높이고, 클래스 수정 없이도 통합을 가능하게 해줌.

데코레이터(Decorator) 패턴

  • 개념 : 런타임에 객체에 추가 책임(기능) 을 동적으로 부여 하는 패턴임. 데코레이터는 본질적으로 기존 객체를 감싸는 래퍼(Wrapper) 로, 자신도 원본 객체와 같은 인터페이스를 구현하여, 클라이언트는 데코레이터를 원본처럼 사용할 수 있음. 데코레이터는 추가 기능을 수행한 뒤(또는 전에) 내부의 실제 객체의 메서드를 호출하여 기본 기능을 실행함. 상속을 통한 기능 확장과 달리, 데코레이터는 필요에 따라 객체 단위로 기능을 조합할 수 있어 훨씬 유연함.

  • 사용 사례 :

    • 자바 I/O 의 FileStream (예 : BufferedInputStream , DataInputStream ) 은 InputStream 을 상속한 데코레이터들로, 기존 스트림에 버퍼링이나 데이터 변환 기능을 덧씌움.

    • GUI 컴포넌트에 스크롤바를 추가하는 Java 의 java.swing.JScrollPane 도 내부에 실제 컴포넌트를 담고 동적으로 스크롤 기능을 부여하는 데코레이터임.

    • 객체에 부가 기능을 조합해서 넣고 싶을 때, 서브클래스 폭증을 막기 위해 데코레이터를 씀. 예를 들어, 커피 주문 시스템에서 "우유 추가", "시럽 추가" 등을 데코레이터로 구현하면, 다양한 조합의 커피를 상속 없이 구현할 수 있음.

  • 예제 코드 :

interface Shape {
    void draw();
}
class Circle implements Shape {
    public void draw() {
        System.out.println("원을 그림");
    }
}
class Rectangle implements Shape {
    public void draw() {
        System.out.println("사각형을 그림");
    }
}
// 데코레이터 클래스: Shape을 구현하고, 다른 Shape 객체를 포함
class ShapeDecorator implements Shape {
    protected Shape decoratedShape;
    public ShapeDecorator(Shape shape) {
        this.decoratedShape = shape;
    }
    public void draw() {
        // 기본 기능 위임
        decoratedShape.draw();
    }
}
// 구체 데코레이터: 테두리 그리는 기능 추가
class BorderDecorator extends ShapeDecorator {
    public BorderDecorator(Shape shape) {
        super(shape);
    }
    @Override
    public void draw() {
        decoratedShape.draw();         // 원본 기능 수행
        System.out.println("-> 테두리 추가");  // 추가 기능 수행
    }
}

public class DecoratorDemo {
    public static void main(String[] args) {
        Shape circle = new Circle();
        Shape decoratedCircle = new BorderDecorator(circle);
        circle.draw();
        // 출력: "원을 그림"
        decoratedCircle.draw();
        // 출력: 
        // "원을 그림"
        // "-> 테두리 추가"
    }
}

BorderDecoratorShapeDecorator 를 상속받았고, ShapeDecoratorShape 인터페이스를 구현하므로 BorderDecorator 자체도 Shape 로 취급될 수 있음. BorderDecorator.draw() 를 호출하면 내부의 Circle.draw() 를 실행한 후에 자체적으로 "테두리 추가" 기능을 수행함. 이렇게 하면 Circle 클래스를 수정하지 않고도 런타임 에 객체에 새로운 기능을 첨가할 수 있음. 필요한 경우 데코레이터를 여러 겹 겹칠 수도 있음. (예 : 테투리 + 그림자 등)

데코레이터 패턴은 상속으로 기능을 조합하는 방식을 대체하여 조립 가능한 설계 를 만듬. 실무에서는 로그에 시간 스탬프를 붙이는 데코레이터, 데이터 처리 파이프 라인에서 단계별 기능을 데코레이터로 연결하는 경우 등 다양하게 응용됨.

행의 패턴(Behavioral Patterns)

객체나 클래스 사이의 알고리즘이나 책임 분배와 관련된 패턴들로, 상호작용책임 분배 에 초점을 둠.

전략(Strategy) 패턴

  • 개념 : 알고리즘 군(群) 을 정의하고 각각을 캡슐화하여 교환 가능하게 만든 패턴임. 전략 패턴을 사용하면 실행 시간에 알고리즘을 선택할 수 있고, 새로운 알고리즘을 추가해도 기존 코드에 영향을 주지 않음. 보통 인터페이스 를 통해 전략의 공통 동작을 정의하고, 구체 전략들은 그 인터페이스를 구현함. 컨텍스트(Context) 는 인터페이스 타입을 통해 전략을 참조하여 메서드를 호출함으로써, 실제 어떤 전략이 쓰이는지에 상관없이 동작을 수행함.

  • 사용 사례 :

    • 정렬 알고리즘을 런타임에 선택하도록 구현할 때(삽입 정렬, 빠른 정렬 등 전략으로 구현)

    • 데이터 압축, 암호화 방식 등 여러 알고리즘 중 상황에 맞게 하나를 써야 할 때.

    • 게임 캐릭터 행동 AI에서 공격 전략을 바꾼다든지, UI 에서 입력 방식(키보드, 손필기) 등 교환 가능한 행위를 캡슐화할 때.

    • 예로, Java 의 Comparator 인터페이스는 전략 패턴의 한 형태로 볼 수 있음. 정렬 함수에 비교 전략을 주입하여 다양한 기준으로 정렬할 수 있음.

  • 예제 코드 :

간단한 계산기 맥락(Context) 가 있고, 덧셈과 곱셉을 전략 으로 교체할 수 있는 예임.

// 전략 인터페이스: 두 숫자를 계산하는 알고리즘
interface Strategy {
    int execute(int a, int b);
}
// 구체 전략들
class AddStrategy implements Strategy {
    public int execute(int a, int b) {
        return a + b;
    }
}
class MultiplyStrategy implements Strategy {
    public int execute(int a, int b) {
        return a * b;
    }
}
// 컨텍스트: 전략을 사용하여 작업을 수행
class Calculartor {
    private Strategy strategy;
    public Calculator(Strategy strategy) {
        this.strategy = strategy;
    }
    public void setStrategy(Strategy strategy) { // 전략 변경 가능하도록 setter 제공
        this.strategy = strategy;
    }
    public int calculate(int x, int y) {
        return strategy.execute(x, y);
    }
}

public class StrategyDemo {
    public static void main(String[] args) {
        Calculator calc = new Calculator(new AddStrategy());
        System.out.println(calc.calculate(3,4)); // result : 7 (덧셈 전략)
        calc.setStrategy(new MultiplyStrategy());
        System.out.println(calc.calculate(3, 4)); // result : 12(곱셉 전략으로 변경)
    }
}

처음 CalculatorAddStrategy 를 사용하다가, setStrategy 를 통해 MultiplyStrategy 로 교체했음.

CalculatorStrategy 인터페이스만 알고 구체 전략이 무엇인지는 모르지만, 다형성에 의해 올바른 구현이 실행됨. 새로운 전략(예: 뺄셈, 나눗셈) 을 추가해도 Strategy 인터페이스를 구현하기만 하면 되고 Calculator 코드는 변경할 필요가 없음.

전략 패턴은 OCP(개방-폐쇄 원칙) 에도 부합하며, 알고리즘을 선택하거나 바꾸는 것을 유연하게 해줌. 실제로 프로젝트에서 조건문으로 여러 알고리즘을 분기하는 코드가 있다면, 이를 전략 패턴으로 리팩토링하여 조건문을 제거하고 전략 교체로 대체할 수 있음. 예를 들어 웹 어플리케이션에서 다양한 인증 방법(패스워드, OAuth, OTP 등) 을 전략 패턴으로 구현하면, 인증 모듈을 유연하게 구성할 수 있음.

옵저버 (Observer) 패턴

  • 개념 : 한 객체의 상태 변화 를 여러 다른 객체들에게 통보하여 자동으로 갱신되도록 하는 패턴임. 옵저버 패턴에서는 주체(Subject)와 관찰자(Observer) 를 분리하여, 주체의 상태가 바뀌면 관찰자들에게 알림을 보내고, 관찰자들은 주체의 변경을 감지해 자신의 동작을 취함. 흔이 일대다(one-to-many) 의존관계라고 표현함. Subject 는 Observer 를 등록/제거하는 메서드를 제공하고, 중요한 변화가 있을 때 모든 Observer의 메서드(예: update() 를 호출함)

  • 사용 사례 :

    • GUI 이벤트 처리 : 버튼(Subject) 이 클릭되면 등록된 리스너들(Observer) 에게 이벤트 통보. (Java 의 ActionListener 등 이벤트 모델 전반)

    • 데이터 모델-뷰 분리 : 모델(Subject) 의 데이터가 바뀌면 여러 뷰(Observer) 가 자동 업데이트 (MVC 패턴에서 Observer 활용)

    • 실시간 시스템 : 가격 시세 시스템에서 가격 정보 변경 시 구독자들에게 알림, 채팅 시스템에서 새로운 메시지 브로드캐스트 등.

    • Java 에서는 Observable / Observer (java.util 패키지, Java9 부터 deprecated) 로 구현이 있었고, 현대에는 RxJava 같은 반응형 프로그래밍 도 옵저버 패턴의 확장임.

  • 예제 코드

간단한 날씨 데이터(Subject)디스플레이(Observer) 예제를 작성. WeatherStation 이 온도 변화를 관찰자들에게 알리는 구조.

// Observer 인터페이스: 주제의 변화 알림을 받는 메서드 정의
interface Observer {
    void update(float temperature);
}
// Subject 인터페이스 : Observer 등록/해제, 알림 기능 정의
interface Subject {
    void registerObserver(Observer o);
    void removeObserver(Observer o);
    void notifyObservers();
}

// 구체 Subject : 날씨 스테이션 (온도 변화 시 옵저버들에게 알림)
class WeatherStation implements Subject {
    private List<Observer> observers = new ArrayList<>();
    private float temperature;
    
    public void registerObserver(Observer o) {
        observers.add(o);
    }
    public void removeObserver(Observer o) {
        observers.remove(o);
    }
    public void notifyObservers() {
        for (Observer o : observers) {
            o.update(temperature);
        }
    }
    // 주체의 상태 변경 메서드
    public void setTemperature(float temp) {
        this.temperature = temp;
        notifyObservers(); // 온도가 변경되면 즉시 옵저버들에게 알림.
    }
}
// 구체 Observer: 온도 디스플레이
class TemperatureDisplay implements Observer {
    public void update(float temperature) {
        System.out.println("현재 온도: " + temperature + "C");
    }
}
// 또 다른 Observer: 온도 경고 알림기 (특정 조건 시 동작)
class TemperatureAlert implements Observer {
    public void update(float temperature) {
        if (temperature > 30.0) {
            System.out.println("경고! 폭염 주의보 발령!");
        }
    }
}

public class ObserverDemo {
    public static void main(String[] args) {
        WeatherStation station = new WeatherStation();
        Observer display = new TemperatureDisplay();
        Observer alert = new TemperatureAlert();
        station.registerObserver(display);
        station.registerObserver(alert);
        
        station.setTemperature(25.0f);
        //출력 : "현재 온도: 25.0도" (Display 에서 출력, Alert 는 조건 미충족이라 아무 메시지 없음)
        station.setTemperature(32.5f);
        //출력 : "현재 온도: 32.5도"
        // "경고 폭염 주의보 발령!"
    }
}

WeatherStation 이 온도를 설정하는 setTemperature() 가 호출될 때마다 등록된 옵저버들에게 자동으로 통보가 가서, TemperatureDisplayTemperatureAlert 가 각자 정의된 동작을 수행함. 옵저버는 여러 개 붙일 수도, 뗄 수도 있으며, 주제 객체는 자신이 구체적으로 어떤 Observer 가 있는지 몰라도 인터페이스로 호출만 함. 이처럼 주제와 옵저버를 분리 함으로써, 한쪽의 변경이 다른 쪽에 직접적인 영향을 주지 않으면서도 연결은 유지하는 느슨한 결합(loose coupling) 을 달성함.

Observer 패턴은 이벤트 중심 시스템의 기본이며, GUI, 메시지 시스템, 실시간 스트리밍 등 광범위하게 활용됨. Java 에서는 PropertyChangeListener / PropertyChangeSupport 등을 통해 일반적인 옵저버 패턴을 구현할 수 있고, 현대 언어들의 이벤트 리스너 기법은 대부분 옵저버 패턴에 기반함. 이 패턴을 사용하면 상태 변화에 대한 대응을 체계화 할 수 있고, 새로운 옵저버를 추가해도 Subject 를 수정할 필요가 없어 OCP 원칙 에도 부합함.

Last updated

Was this helpful?