[도서] 개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴 - 주요 디자인 패턴

Junseo Kim·2021년 3월 13일
0

[도서]

목록 보기
5/5

전략 패턴

서로 다른 정책들이 한 코드에 섞여있으면 유지보수가 힘들다. 정책이 추가될때마다 코드가 수정되어야한고 if-else문도 너무 많아진다. 이럴 때 정책을 추상화하여 인터페이스로 만들어서 사용한다.

의존 주입을 통해 외부에서 정책을 전달해준다. 정책을 사용하는 코드는 외부에서 어떤 정책이 넘어오던지 상관없이 정책 인터페이스 타입을 사용하므로 코드의 변경이 없어진다. 이런식으로 특정 콘텍스트에서 알고리즘을 별도로 분리하는 설계 방법이 전략 패턴이다.

if-else로 구성된 코드 블록이 비슷한 기능을 수행하는 경우나, 완전히 동일한 기능을 제공하지만 성능의 장단점에 따라 알고리즘을 선택해야 하는 경우에 사용한다.

템플릿 메서드 패턴

완전히 동일한 절차를 가진 코드를 작성하게 될 때 사용. 실행 과정 / 단계가 동일한데 각 단계 중 일부의 구현이 다른 경우에 사용할 수 있다. 템플릿 메서드는 아래와 같이 2가지로 구성된다.

  • 실행 과정을 구현한 상위 클래스
    기능을 구현하는데 필요한 각 단계를 정의하며 이 중 일부 단계는 추상 메서드를 호출하는 방식으로 구현. 추상메서드는 구현이 다른 단계에 해당된다.

  • 실행 과정의 일부 단계를 구현한 하위 클래스
    상위 클래스를 상속받아서 상위 클래스의 추상메서드를 알맞게 구현한다.

템플릿 메서드 패턴을 사용하게 되면, 동일한 실행 과정의 구현을 제공하면서 동시에 하위 타입에서 일부 단계를 구현하도록 할 수 있다. 코드 중복을 방지해준다.

상태 패턴

상태에 따라 동일한 기능 요청의 처리를 다르게 할 때 사용. 상태를 별도 타입으로 분리하고 각 상태 별로 하위 타입을 구현한다. 상태 패턴에서 중요한 점을 상태 객체가 기능을 제공한다는 점이다. 콘텍스트가 클라이언트로부터 기능 실행 요청을 받으면 상태 객체에 처리를 위임한다. 상태 별로 동일한 메서드가 다른 기능을 수행하며 기능을 수행할 때 상태자체를 변경 시킬 수 있다.

상태 패턴은 새로운 상태가 추가되더라도 콘텍스트 코드가 받는 영향은 최소화된다는 점이다. 상태가 추가되면 클래스는 추가되겠지만 코드의 복잡도는 증가하지 않아 유지보수에 유리하다. 또 상태에 따른 동작을 구현한 코드가 각 상태 별로 구분되기 때문에 상태 별 동작을 수정하기 쉽다.(관련된 코드가 한 곳에 모여있기 때문)

상태 변경은 누가?

상태 패턴을 적용할 때 고려할 문제는 콘텍스트의 상태 변경을 누가 하느냐에 대한 것이다. 상태 변경은 콘텍스트나 상태 객체 둘 중 하나가 된다.

컨텍스트에서 상태를 변경하는 방식은 상태 개수가 적고, 상태 변경 규칙이 거의 바뀌지 않는 경우 유리하다.

상태 객체에서 콘텍스트의 상태를 변경할 경우 콘텍스트에 영향을 주지 않으면서 상태를 추가하거나 상태 변경 규칙을 바꿀 수 있게 된다. 다만 여러 클래스에서 상태 변경이 일어나므로 상태 변경 규칙을 파악하기 쉽지 않고, 상태 클래스 끼리 의존도가 발생한다.

데코레이터 패턴

상속은 기능을 확장한다. 상속을 이용한 기능 확장 방법이 쉽긴 하지만, 다양한 조합의 기능 확장이 요구될 때 클래스가 불필요하게 증가하는 문제가 발생된다. (상속을 이용한 기능 확장은 클래스의 증가를 불러온다.) 이런 경우 사용할 수 있는 패턴이 데코레이터 패턴이다.

데코레이터 패턴은 상속이 아닌 위임을 하는 방식으로 기능을 확장해 나간다. 필요한 기능을 가진 인터페이스를 정의하고, 실제 기능 구현은 이 인터페이스를 상속한 클래스에서 한다. 여기서 중요한 점은 기능 확장을 위해 구현한 클래스를 상속받는 것이 아니라 Decorator라 불리는 별도의 추상 클래스를 만든다는점이다.

Decorator 클래스는 모든 데코레이터를 위한 기반 기능을 제공하는 추상 클래스다.

// 예제
public abstract class Decorator implements FilOut {
    private FileOut delegate; // 위임대상
    
    public Decorator(FileOut delegate) {
        this.delegate = delegate;
    }
    
    protected void doDelegate(byte[] data) {
        delegate.write(data); // delegate에 쓰기 위임
    }
}

확장을 위한 클래스는 Decorator를 상속받는다. EncryptionOut 클래스의 write 메서드는 파일에 쓸 데이터를 암호화한 뒤에 doDelegate 메서드를 이용해서 암호화된 데이터를 delegate 객체에 전달한다.

public class EncryptionOut extends Decorator {
    
    public EncryptionOut(FileOut delegate) {
        super(delegate);
    }
    
    public void write(Byte[] data) {
        byte[] encryptedData = encrypt(data);
        super.doDelegate(encryptedData);
    }
    
    private byte[] encrypt(byte[] data) {
        ...
    }
}

이렇게 만든 코드는 아래와 같이 사용한다.

FileOut delegate = new FileOutImpl(); // 구현체 
FileOut fileOut = new EncryptionOut(delegate); // Decorateor를 상속하여 확장한 클래스에 구현체를 넘겨준다.
fileOut.write(data);

데코레이터 패턴의 장점은 데코레이터를 조합하는 방식으로 기능을 확장할 수 있다는 데에 있다.

FileOut delegate = new FileOutImpl();  
FileOut fileOut = new EncryptionOut(new ZipOut(delegate));
fileOut.write(data);

데코레이터 패턴을 사용하면 각 확장 기능들의 구현이 별도의 클래스로 분리되기 때문에, 각 기능 및 원래 기능을 서로 영향 없이 변경할 수 있도록 만들어준다.(단일 책임 원칙을 지킨다.)

데코레이터 패턴을 구현할 때 고려할 점은 데코레이터 대상이 되는 타입(인터페이스)의 기능 개수에 대한 것이다. 정의되어 있는 메서드가 많아지면 데코레이터 구현도 복잡해진다. 한가지 더 고려할 것은 데코레이터 객체가 비정상적으로 동작할 때 어떻게 처리할 것이냐에 대한 것이다.

데코레이터의 단점은 사용자 입장에서 데코레이터 객체와 실제 구현 객체의 구분이 되지 않기 때문에 코드만으로는 기능이 어떻게 동작하는지 이해하기 어렵다는 점이다.

프록시 패턴

실제 객체를 대신하는 프록시 객체를 사용해서 실제 객체의 생성이나 접근 등을 제어할 수 있도록 해주는 패턴.

가상 프록시: 필요한 순간에 실제 객체를 생성해 주는 프록시.

보호 프록시: 실제 객체에 대한 접근을 제어하는 프록시로서, 접근 권한이 있는 경우에만 실제 객체의 메서드를 실행하는 방식으로 구현한다.

원격 프록시: 자바의 RMI처럼 다른 프로세스에 존재하는 객체에 접근할 때 사용하는 프록시. 내부적으로 IPC나 TCP 통신을 이용해서 다른 프로세스의 객체를 실행하게 된다.

프록시를 구현할 때 고려할 점은 실제 객체를 누가 생성할 것이냐에 대한 것이다.

어댑터 패턴

클라이언트가 요구하는 인터페이스와 재사용하려는 모듈의 인터페이스가 일치하지 않을 때 사용할 수 있는 패턴이다.

두 인터페이스 사이에 어댑터 클래스를 만들어 클라이언트에서 사용할 수 있게 리턴값을 변환해준다.

어댑터 패턴은 개방 폐쇄 원칙을 따를 수 있도록 도와준다.

옵저버 패턴

한 객체의 상태 변화를 정해지지 않은 여러 다른 객체에 통지하고 싶을 때 사용되는 패턴이다.

옵저버 패턴은 크게 주제 객체와 옵저버 객체가 등장한다.

주체 객체

  • 옵저버 목록을 관리하고, 옵저버를 등록하고 제거할 수 있는 메서드 제공
  • 상태의 변경이 발생하면 등록된 옵저버에 변경 내역 알림

옵저버 객체

  • 각 주제 객체가 호출하는 메서드에서 필요한 기능을 구현

주제 객체의 상태에 변화가 생길 때 그 내용을 통지받도록 하려면, 옵저버 객체를 주제 객체에 등록해 주어야 한다.

옵저배 패턴을 적용하면 주제 클래스 변경 없이 상태 변경을 통지 받을 옵저버를 추가할 수 있다.

고려 사항

옵저버 패턴을 구현할 때에는 다음 내용을 고려해야 한다.

  • 주제 객체의 통지 기능 실행 주체
  • 옵저버 인터페이스의 분리
  • 통지 시점에서의 주제 객체 상태
  • 옵저버 객체의 실행 제약 조건

미디에이터 패턴

책임에 따라 알맞게 객체를 분리했음에도 불구하고 전체 클래스가 단일 구조가 되어 변경이나 재사용이 어려워지는 경우가 있다.(객체 간의 의존이 직접 연결되어 있는 경우)

이럴 때 미디에이터 패턴을 사용할 수 있다. 미디에이터 패턴은 각 객체들이 직접 메시지를 주고받는 대신, 중간에 중계 역할을 수행하는 미디에이터 객체를 두고 미디에이터를 통해서 각 객체들이 간접적으로 메시지를 주고받도록 한다.

협업 객체들은 모든 요청을 미디에이터에 보내고, 미디에이터는 그 요청을 처리할 알맞은 객체를 실행한다. 이렇게 해주면 각 협업 객체가 서로 알 필요 없이 미디에이터가 각 객체 간의 메시지 흐름을 제어하기 때문에, 새로운 협업 객체가 추가되더라도 기존 클래스를 수정할 필요 없이 미디에이터 클래스만 수정해주면 된다.

다만 협업 클래스의 수가 증가할수록 미디에이터의 코드가 복잡해져 유지 보수하기 어려워진다.

파사드 패턴

코드 중복과 직접적인 의존을 해결하는데 도움을 주는 패턴이다. 파사드 패턴은 서브 시스템을 감춰 주는 상위 수준의 인터페이스를 제공한다.

파사드 패턴을 적용하면 클라이언트 코드가 간결해지고, 클라이언트와 서브 시스템 간의 직접적인 의존을 제거해준다. 파사드를 인터페이스로 정의함으로써 클라이언트의 변경 없이 서브 시스템 자체를 변경할 수 있다.

파사드 패턴을 적용한다고 해서 서브 시스템에 대한 직접적인 접근을 막는 것은 아니다. 여러 클라이언트의 중복된 서브 시스템 사용을 추상화할 뿐이다.

추상 팩토리 패턴

관련된 객체 군을 생성하는 책임을 갖는 타입을 별도로 분리한다. 클라이언트에 영향을 주지 않으면서 사용할 제품군을 교체할 수 있다.

public abstract class EnemyFactory {
    public static EnemyFactory getFactory(int level) {
        if (level == 1) {
            return EasyStageEnemyFactory();
        }
        return HardEnemyFactory();
    }
    
    public abstract Boss createBoss();
    public abstract SmallFlight createSmallFlight();
    public abstract Obstacle createObstacle();
    
}

DI를 적용하면 추상 클래스를 인터페이스로 전환할 수도 있다.

컴포지트 패턴

거의 동일한 코드가 중복되면 복잡도를 높여서 코드의 수정이나 확장을 어렵게 만든다. 이런 단점을 해소하기 위해 사용하는 패턴이다. 컴포지트 패턴은 전체-부분을 구성하는 클래스가 동일 인터페이스를 구현하도록 만듦으로써 해결한다. 컴포지트 패턴의 장점은 클라이언트가 컴포지트와 컴포넌트를 구분하지 않고 컴포넌트 인터페이스만으로 프로그래밍 할 수 있게 해준다는 것이다.

컴포지트

  • 컴포지트 그룹을 관리한다.
  • 컴포지트에 기능 실행을 요청하면, 컴포지트는 포함하고 있는 컴포넌트들에게 기능 실행 요청을 위임한다.

컴포지트 패턴을 구현할 때 고려할 점은 컴포넌트를 관리하는 인터페이스를 어디서 구현할지에 대한 여부이다.

Null 객체 패턴

null 검사 코드의 단점은 개발자가 null 체크를 빼먹을 수 있다는 것이다. 여러 코드에서 한 객체에 대한 null 검사를 하게 되면 null 검사 코드를 누락하기 쉽고 NullPointerException으로 이어진다. Null 객체 패턴은 null 검사 코드 누락에 따른 문제를 없애준다.

null을 리턴하지 않고 null을 대신할 객체를 리턴함으로써 null 검사 코드를 없앨 수 있도록 한다.

  • null 대신 사용될 클래스를 구현한다. 이 클래스는 상위 타입을 상속받으며, 아무 기능도 수행하지 않는다.
  • null을 리턴하는 대신, null을 대체할 클래스의 객체를 리턴한다.

Null 객체 패턴을 사용하면 null 체크 과정이 없어지므로 코드가 간결해지고 가독성이 높아진다.

0개의 댓글