IT 책 독서록

[스프링 입문을 위한 자바 객체 지향의 원리와 이해] 6장 내용정리

robinjoon98 2022. 4. 8. 10:16

6장은 스프링 프레임워크에서 사용하고있는 디자인 패턴 8개를 다룬다. 디자인 패턴 자체가 이 책만으로 이해하기에는 이 책의 내용이 부족하기 때문에 별도의 책을 보는 것을 권장하고있다.

이 책에서는 객체지향의 4대 원칙을 요리도구에, SOLID 원칙을 요리도구 사용법에, 디자인 패턴을 레시피에 비유하고있다. 디자인 패턴은 SOLID 원칙을 준수하며 여러 문제상황을 해결하기 위한 선배들의 탐구과정속에서 정리된 좋은 예시이다. 따라서 스프링에서도 디자인 패턴을 적극 활용하였고, 이번 장에서는 이 디자인 패턴들에 대해 알아보았다.

 

Adapter Pattern (어댑터 패턴)

어뎁터는 직역하면 변환기로 번역된다. 변환기의 역할은 서로 다른 두 인터페이스 사이에 통신이 가능하게 하는 것이다.

이 패턴은 개발자가 외부 프레임워크를 사용하는데, 이 외부 프레임워크가 개발자가 이미 설계해놓은 시스템의 인터페이스와 사용법이 약간 다를 때 사용한다. 외부 프레임워크에서 제공하는 객체를 필드로 가지고 참조하는 방법으로 문제를 해결한다. 자바에서 쉽게 만나볼 수 있는 Adapter Pattern 의 사용 예시로는 JDBC가 있다. JDBC는 인터페이스가 서로 다 다른 다양한 데이터베이스 시스템을 하나의 인터페이스로 변환하여 사용할 수 있도록 한다. 따라서 Adapter Pattern을 사용했다고 할 수 있다. 자바 그 자체도 여러 OS의 인터페이스를 자바라는 인터페이스로 변환하여 사용할 수 있도록 한다는 점에서 Adapter Pattern 이라고 할 수 있다. 이 예시는 OCP 원칙을 설명할 때도 이야기 한 것인데, Adapter Pattern이 바로 이 OCP 원칙을 활용한 패턴이기 때문이다. 아래는 예시 코드이다.

package adapterPattern;

public class ServiceA {
    void runServiceA() {
        System.out.println("ServiceA");
    }
}
package adapterPattern;

public class ServiceB {
    void runServiceB() {
        System.out.println("ServiceB");
    }
}

이렇게 두개의 Service 가 있다면, 이를 사용 하는 클라이언트는 대충 아래와 같이 있을 것이다.

package adapterPattern;

public class ClientWithNoAdapter {
    public static void main(String[] args) {
        ServiceA sa1 = new ServiceA();
        ServiceB sb1 = new ServiceB();

        sa1.runServiceA();
        sb1.runServiceB();
    }
}

 

아래는 어댑터 패턴을 사용한 코드들이다.

package adapterPattern;

public class AdapterServiceA {
    ServiceA sa1 = new ServiceA();

    void runService() {
        sa1.runServiceA();
    }
}
package adapterPattern;

public class AdapterServiceB {
    ServiceA sa1 = new ServiceB();

    void runService() {
        sa1.runServiceB();
    }
}
package adapterPattern;

public class ClientWithAdapter {
    public static void main(String[] args) {
        AdapterServicA asa1 = new AdapterServicA();
        AdapterServicB asb1 = new AdapterServicB();

        asa1.runService();
        asb1.runService();
    }
}

이렇게 바꾸면, 두 서비스를 같은 메서드 이름으로 접근할 수 있다. 여기서 더 나아가 어댑터 인터페이스를 만들고, 어댑터들이 인터페이스를 구현하도록 하면 더 개선할 수 있다.

 

결론적으로 어댑터 패턴을 한줄로 정리하면 다음과 같다.

호출당하는 쪽의 메서드를 호출하는 쪽의 코드에 대응하도록 중간에 변환기를 통해 호출하는 패턴

Proxy Pattern (프록시 패턴)

프록시는 대리자, 대변인 이라는 뜻이다. 즉, 프록시 패턴은 중간에 대리자를 두는 패턴이다. 대리자는 자신의 의견이 아니라, 대리하고자 하는 자의 의견을 가감없이 전달하는 역할을 한다. Proxy Pattern 에서도 마찬가지로 대리자는 실제 서비스를 제공하는 메서드의 반환값을 가감하지 않고 다만 그 제어의 흐름을 바꾸는 역할만을 수행한다. 아래는 예시 코드다.

package proxyPattern;

public interface IService {
    String runSomething();
}

프록시 패턴에서는 실제 서비스를 제공하는 객체와 대리자 객체가 같은 인터페이스를 구현한다. 아래는 순서대로 실제 서비스 클래스와 대리자 클래스이다.

package proxyPattern;

public class Service implements IService {
    @Override
    public String runSomething() {
        return "서비스 짱!!!";
    }
}
package proxyPattern;

public class Proxy implements IService {
    IService service1;

    public String runSomething() {
        System.out.println("호출에 대한 흐름 제어가 주목적, 반환 결과를 그대로 전달");

        service1 = new Service();
        return service1.runSomething();
    }
}

아래는 클라이언트 코드다.

package proxyPattern;

public class ClientWithProxy {
    public static void main(String[] args) {
        // 프록시를 이용한 호출
        IService proxy = new Proxy();
        System.out.println(proxy.runSomething());
    }
}

클래스 다이어그램을 그려보면 SOLID 원칙을 설명할 때 보았던 익숙한 형태가 나온다. 프록시 패턴은 OCP원칙과 DIP 원칙을 적용한 디자인 패턴이다. 따라서 그 원칙들이 가져오는 이점을 그대로 반영하게 된다.

 

프록시 패턴을 한마디로 정리하면 다음과 같다.

제어 흐름을 조정하기 위한 목적으로 중간에 대리자를 두는 패턴

Decorator Pattern (데코레이터 패턴)

데코레이터 패턴은 프록시 패턴과 아주 유사하다. 하지만 이름에서 알 수 있듯 데코레이터 패턴은 서비스의 제어 흐름을 조종하는 목적보다는 서비스의 반환값에 장식을 더하는 것이 목적이다. 물론, 별도의 로직을 수행해 제어의 흐름을 조정할 수도 있다. 코드가 워낙 프록시패턴과 비슷하기에 생략하고 바로 요약하면 다음과 같다.

메서드 호출의 반환값에 약가의 변화를 주기 위해 중간에 장식자를 두는 패턴

Singleton Pattern (싱글턴 패턴)

싱글턴 패턴은 인스턴스를 하나만 만들어 사용하기 위한 패턴이다. 커넥션 풀, 쓰레드 풀, 각종 설정 객체 등은 여러개 존재할 필요가 없고, 여러개 존재할 경우 예상치 못한 결과를 만날 수 도 있다. 이러한 경우 싱글턴 패턴을 사용하여 하나의 인스턴스를 계속 사용하게 한다.

이러한 것이 가능하려면 다음 3가지 요소가 필요하다.

  • private 생성자. new 로 인스턴스를 사용하지 못하도록 private 생성자만 존재해야 한다. 
  • 유일한 단일 객체를 참조하는 정적 참조 변수
  • 유일한 단일 객체를 반환하는 정적 메서드

아래는 예시 코드다.

package singletonPattern;

public class Singleton {
    static Singleton singletonObject; // 정적 참조 변수

    private Singleton() {
    }; // private 생성자

	// 객체 반환 정적 메서드
    public static Singleton getInstance() {
        if (singletonObject == null) {
            singletonObject = new Singleton();
        }

        return singletonObject;
    }
}

또한, 싱글턴 패턴을 사용할 때 주의할 것은, 싱글턴 객체는 결국 모든 쓰레드에서 동시에 접근할 수 있게 되므로 가능하면 싱글턴 패턴을 적용한 유일한 단일 객체는 쓰기 가능한 속성을 가지지 않아야 한다.

클래스의 인스턴스, 즉 객체를 하나만 만들어 사용하는 패턴

Template Method Pattern (템플릿 메소드 패턴)

이 패턴은, 상위 클래스에서 템플릿 메서드를 제공하고, 이 메서드 안에 여러 메서드들의 동작 순서를 미리 정해놓고, 하위 클래스에서 여러 메서드를 오버라이딩 하여 결과적으로 알고리즘의 일부를 하위 클래스에서 수정할 수 있게 해준다.

이 패턴은, 어떤 두 클래스에 동일한 로직이 다수 포함되어있고, 몇몇의 로직만 다를 경우 사용할 수 있는 패턴이다. 공통된 로직을 상위 클래스에서 구현해두고, 하위 클래스에 오버라이딩을 통해 상세 로직을 변경할 수 있게 한다. 아래는 예시 코드다.

package templateMethodPattern;

public abstract class Animal {
    // 템플릿 메서드
    public void playWithOwner() {
        System.out.println("귀염둥이 이리온...");
        play();
        runSomething();
        System.out.println("잘했어");
	}

    // 추상 메서드
    abstract void play();

    // Hook(갈고리) 메서드
    void runSomething() {
        System.out.println("꼬리 살랑 살랑~");
    }
}

위 코드에서 템플릿 메서드에 하위 클래스에서 공통으로 지켜야 할 논리를 구현하고, 중간에 다른 메서드를 호출하게 하고, 그 메서드를 하위클래스에서 오버라이딩 하게 하여 하위클래스마다 다른 논리를 추가할 수 있게 한다. 아래는 Animal 클래스를 상속한 클래스들이다.

package templateMethodPattern;

public class Dog extends Animal {
    @Override
    // 추상 메서드 오버라이딩
    void play() {
        System.out.println("멍! 멍!");
	}

    @Override
    // Hook(갈고리) 메서드 오버라이딩
    void runSomething() {
        System.out.println("멍! 멍!~ 꼬리 살랑 살랑~");
    }
}
package templateMethodPattern;

public class Cat extends Animal {
    @Override
    // 추상 메서드 오버라이딩
    void play() {
        System.out.println("야옹~ 야옹~");
    }

    @Override
    // Hook(갈고리) 메서드 오버라이딩
    void runSomething() {
        System.out.println("야옹~ 야옹~ 꼬리 살랑 살랑~");
    }
}

템플릿 메서드 패턴에서 추상클래스를 사용해 일부 메서드의 오버라이딩을 강제할 수 있고, 일부 메서드는 일반 메서드로 하여 상속을 선택적으로 하게 할 수 도 있다(이런 메서드를 Hook 메서드 라고 한다). 한편, 템플릿 메서드는 기본적으로 모든 하위 클래스가 공유하는 공통된 로직이므로 하위 클래스에서 상속하게 하면 의미상으로 맞지 않다. final 로 선언하여 오버라이딩을 막는 것이 좋을 듯 하다.

 

템플릿 메서드 패턴은 결국, DIP 원칙을 적용한 것이다. 한문장으로 템플릿 메서드 패턴을 정리하면 다음과 같다.

 상위 클래스의 템플릿 메서드에서 하위 클래스가 오버라이딩한 메서드를 호출하는 패턴

Factory Method Pattern (팩터리 메서드 패턴)

객체를 사용하는 코드와, 객체를 생성하는 코드가 같은 곳에 위치하고 있으면, 즉, 객체를 사용하는 클라이언트에서 객체를 직접 생성한다면, 객체의 생성방식이 변경되는 경우 클라이언트의 코드가 변경되어야 한다. 즉, 클라리언트 코드가 변경되어야 할 이유가 객체의 사용법이 변하는 경우, 객체의 생성방식이 변하는 경우 로 2가지가 된다. 이는 바람직하지 않으므로, 객체를 생성하는 코드를 다른 클래스로 이관하여 객체의 생성 방식의 변화에 좀 더 유연한 대처가 가능하게 한다. 팩터리 메서드 패턴은 이것이 전부이지만, 이 책에서는 좀 더 확장하여, 팩토리 메서드를 제공하는 클래스를 추상클래스로, 팩터리 메서드를 추상메서드로 하고, 팩터리 메서드가 반환하는 객체 역시 추상클래스 타입으로 하여, 하위 클래스로 객체 생성을 위임시켰다. 아래는 그 코드이다.

package factoryMethodPattern;

public abstract class Animal {
    // 추상 팩터리 메서드
    abstract AnimalToy getToy();
}
package factoryMethodPattern;

// 팩터리 메서드가 생성할 객체의 상위 클래스
public abstract class AnimalToy {
    abstract void identify();
}
package factoryMethodPattern;

public class Cat extends Animal {
    // 추상 팩터리 메서드 오버라이딩
    @Override
    AnimalToy getToy() {
        return new CatToy();
    }
}
package factoryMethodPattern;

//팩터리 메서드가 생성할 객체
public class CatToy extends AnimalToy {
    @Override
    public void identify() {
        System.out.println("나는 캣타워! 고양이의 친구!");
    }
}
package factoryMethodPattern;

public class Dog extends Animal {
    // 추상 팩터리 메서드 오버라이딩
    @Override
    AnimalToy getToy() {
        return new DogToy();
    }
}
package factoryMethodPattern;

//팩터리 메서드가 생성할 객체
public class DogToy extends AnimalToy {
    public void identify() {
        System.out.println("나는 테니스공! 강아지의 친구!");
    }
}

아래는 이를 사용하는 예제 코드이다.

package factoryMethodPattern;

public class Driver {
    public static void main(String[] args) {
        // 팩터리 메서드를 보유한 객체들 생성
        Animal bolt = new Dog();
        Animal kitty = new Cat();

        // 팩터리 메서드가 반환하는 객체들
        AnimalToy boltBall = bolt.getToy();
        AnimalToy kittyTower = kitty.getToy();

        // 팩터리 메서드가 반환한 객체들을 사용
        boltBall.identify();
        kittyTower.identify();
    }
}

위 상황에서 새로운 AnimalToy 가 추가되거나, AnimalToy의 생성 방식이 변경되어도 클라이언트인 Driver 클래스는 전혀 변경없이 사용할 수 있게 된다. 변경되기 쉬운 구체 클레스가 아니라, 변경이 적은 추상 클래스에 의존하기 때문이다.

즉, 팩토리 메서드 패턴은 DIP 원칙이 적용된 패턴이라 할 수 있다. 

 

팩토리 메서드 패턴을 한마디로 정의하면 아래와 같다.

오버라이드된 메서드가 객체를 반환하는 패턴.

참고로, 디자인 패턴을 더 깊게 다룬 책
정인상,채홍석,「JAVA 객체지향 디자인 패턴」 한빛미디어(2015),p.325 에 의하면 객체의 생성을 별도의 클래스/메서드로 분리하는 것이 팩토리 메서드 패턴의 핵심이다. 즉, 상속이나 이런건 선택사항이다.

Strategy Pattern(전략 패턴, 스트레티지 패턴)

전략 패턴은 3가지 요소로 구성된다.

  • 전략 메서드를 가진 전략 객체
  • 전략 객체를 사용하는 컨텍스트(전략 객체의 사용자/소비자)
  • 전략 객체를 생성해 컨텍스트에 주입하는 클라이언트(제3자, 전략 객체의 공급자)

클라이언트는 여러 전략중 하나를 선택해 생성한 후 컨텍스트에 주입한다. 컨텍스트는 주입받은 전략객체의 메서드를 적절히 사용한다. 

군인을 예시로 들면 ,군인은 자신에게 어떤 무기가 주어지느냐에 따라 다른 전략을 사용할 것이다. 이 때 무기는 보급장교가 지급해 줄 것이다. 여기서 군인이 컨텍스트, 무기가 전략 객체, 보급장교가 클라이언트가 된다. 이를 자바 코드로 구현하면 다음과 같다.

package strategyPattern;

public interface Strategy {
    public abstract void runStrategy();
}

여러 전략을 같은 방식으로 사용하기 위해 인터페이스를 정의한다. 이제 이 인터페이스를 구현 하는 전략(여기선 무기가 된다)을 구현한다.

package strategyPattern;

public class StrategyBow implements Strategy {
    @Override
    public void runStrategy() {
        System.out.println("슝.. 쐐액.. 쇅, 최종 병기");
    }
}

책에는 더 많은 전략 객체를 구현했지만 여기선 생략한다.

이제 이 전략을 사용할 군인을 컨텍스트를 구현하자.

package strategyPattern;

public class Soldier {
    void runContext(Strategy strategy) {
        System.out.println("전투 시작");
        strategy.runStrategy();
        System.out.println("전투 종료");
    }
}

이제 다시 곰곰히 생각해보자. 전략, 즉 알고리즘을 선택한다는 점에서 템플릿 메서드 패턴과 같다. 정확히는 템플릿 메서드 패턴이 해결하고자 하는 상황과 전략 패턴이 해결하고자 하는 상황이 정확히 일치한다. 하지만, 자바에선 다중상속이 불가능하기 때문에, 상속을 사용해야 하는 템플릿 메서드 패턴보단 전략 패턴을 더 자주 사용한다. 게다가, 템플릿 메서드 패턴은 상속을 상위 클래스의 확장 개념으로 사용하지 않기 때문에 문제가 좀 있는 패턴이다.

전략패턴의 다이어그램을 그려보면  OCP , DIP 원칙이 적용된 패턴임을 알 수 있다. 전략패턴을 한마디로 정리하면 다음과 같다.

 클라이언트가 전략을 생성해 전략을 실행할 컨텍스트에 주입하는 패턴

Template Callback Pattern (템플릿 콜백 패턴)

이 패턴은 전략 패턴의 변형으로, 스프링의 DI에서 사용하는 특별한 형태의 전략 패턴이다. 일반적인 전략 패턴과 달리 전략을 익명 내부 클래스로 사용한다.

전략 패턴의 경우 전략 클래스를 미리 생성해두어야 한다. 하지만, 익명 클래스를 사용하면, 그러지 않아도 된다. 나아가, 템플릿 콜백 패턴에선, 전략패턴과 달리 전략을 생성하는 작업이 클라이언트가 아니라 컨텍스트가 담당한다. 이 때, 컨텍스트는 클라이언트로 부터 최소한의 정보만 주입받아 내부에서 알아서 익명 객체를 생성하게 된다. 반면, 일반적인 전략패턴의 경우 클라이언트로부터 전략 자체를 주입받는다. 생각해보면 두 방식중 전자가 결합도가 더 낮아지게 된다.

 

참고로, 직접 찾아본 결과, Spring의 JdbcTemplate 에서 이 패턴을 사용하고 있음을 알 수 있었다. 

@Override
public int update(final PreparedStatementCreator psc, final KeyHolder generatedKeyHolder)
        throws DataAccessException {

    Assert.notNull(generatedKeyHolder, "KeyHolder must not be null");
    logger.debug("Executing SQL update and returning generated keys");

    return updateCount(execute(psc, ps -> {
        int rows = ps.executeUpdate();
        List<Map<String, Object>> generatedKeys = generatedKeyHolder.getKeyList();
        generatedKeys.clear();
        ResultSet keys = ps.getGeneratedKeys();
        if (keys != null) {
            try {
                RowMapperResultSetExtractor<Map<String, Object>> rse =
                        new RowMapperResultSetExtractor<>(getColumnMapRowMapper(), 1);
                generatedKeys.addAll(result(rse.extractData(keys)));
            }
            finally {
                JdbcUtils.closeResultSet(keys);
            }
        }
        if (logger.isTraceEnabled()) {
            logger.trace("SQL update affected " + rows + " rows and returned " + generatedKeys.size() + " keys");
        }
        return rows;
    }, true));
}

return 문에서 외부에서 주입받은 최소한의 것(PreparedStatementCreator psc) 를 이용해 익명 객체를 생성, 이를 사용하고 있다. 

 

템플릿 콜백 패턴을 정리하면 다음과 같다.

전략을 익명 내부 클래스로 구현한 전략 패턴