디자인 패턴이란

소프트웨어 설계 문제에 대한 해답을 문서화하기 위해 고안된 형식

 

제가 이해한 바로는 좋은 코드를 만드는 방법들이라고 이해하게 되었습니다.

좋은 코드는 각자의 스타일에 따라 다르겠지만 통상적으로는 아래의 특징들을 가진 코드를 좋은 코드라고 합니다.

좋은 코드

  • 가독성이 좋음
  • 간결함
  • 확장과 수정에 용이
  • 유지 보수가 편함
  • 결합도가 낮음
  • 응집도는 높음
  • ...

디자인 패턴은 좋은 코드를 만드는 방법이라고 했으니까 위의 특징들을 만족시키는 코드 설계 방식이겠습니다.

그리고 디자인 패턴보다 객체지향의 5대 원칙을 먼저 보는 것이 다음의 이해를 위해 좋습니다.

 

1. 디자인 패턴 종류

생성 패턴 (Creational Patterns) 구조 패턴 (Structural Patterns) 행동 패턴 (Behavioral Patterns)
싱글톤 (Singleton) * 어댑터 (Adapter) * 스트레티지 (Strategy)
팩토리 메서드(Factory Method) * 브릿지 (Bridge) 템플릿 메서드 (Template Methods)
추상 팩토리 메서드(Abstract FM) 컴퍼지트 (Composite) 옵저버 (Observer)
프로토타입 (Prototype) 데코레이터 (Decorator) 스테이트 (State)
빌더 (Builder) * 퍼사드 (Facade) * 비지터 (Visitor)
  플라이웨이트 (Flyweight) 커맨드 (Command)
  프록시 (Proxy) * 인터프리터 (Interpreter)
    이터레이터 (Iterator)
    미디에이터 (Mediator)
    메멘토 (Memento)
    책임 연쇄 (Chain of Responsibility)

( * 별표들을 정리) 

 

2. 생성 패턴 (Creational Patterns)

  • 객체의 생성과 관련된 디자인 패턴
  • 객체의 생성시 캡슐화를 통해 객체가 생성되거나 변경되어도 프로그램 구조에 영향을 크게 받지 않도록 하여 결합도를 낮출 수 있게 한다.

2.1 싱글톤 (Singleton)

스프링 기반의 웹 프로그래밍을 하다 보면 가장 많이 접하게 되는 패턴이 싱글톤 패턴입니다. @Bean으로 관리되는 객체는 디폴트로 싱글톤 객체로 생성됩니다. 

 

싱글톤 패턴은 전역 변수 대신에 객체를 하나만 생성하고 그 객체를 모든 곳에서 참조할 수 있도록 하는 패턴입니다.

따라서 객체는 하나만 존재하게 하는 패턴이죠.

 

@Bean
public HttpTransfer() {
	return new ApacheHttpTransfer();
}

 

위의 예시에서는 스프링에서 HttpTransfer라는 이름의 Bean을 등록하는 코드입니다. 스코프를 지정하지 않았으니 싱글톤으로 생성됩니다. 이제 객체는 Bean 저장소에 객체 하나가 저장되어서 HttpTransfer를 @Autowired 주입받아서 사용하면 해당 객체를 전달해주게 됩니다.

 

싱글톤을 사용하면 얻는 이점

  • 최초 한번만 생성하기 때문에 메모리 측면에서 이점이 있다.
  • 이미 생성된 객체를 가져와서 사용하기 때문에 속도도 빠르다.

싱글톤을 사용하면 생기는 단점

  • 하나의 객체를 공유하기 때문에 동시성 문제가 생길 수 있다.
  • 테스트하기 어렵다.
  • 만들어진 객체를 가져와서 사용하기 때문에 구현체와 관계를 맺게 되고 이는 SOLID 원칙 중 DIP 원칙에 위배된다.

2.2 팩토리 메서드 (Factory Method)

위에서 싱글톤 설명을 하며 사용했던 코드에는 팩토리 메서드 패턴도 들어가 있습니다. 보통 @Bean을 등록하는 코드는 객체를 사용할 클래스와는 분리해서 만들게 됩니다. 이렇게 되면 생성 처리를 하는 클래스를 서브 클래스로 분리하는 것이죠. 이것을 팩토리 메서드 패턴이라고 합니다.

 

public interface Robot {
	public void move();
}

public class DogRobot implements Robot {
	@Override
    public void move() {
    	...
    }
}

public class CatRobot implements Robot {
	@Override
	public void move() {
		...
	}
}

public class RobotFactory {
	public static Robot makeRobot(String type) {
    	if (type.equals("dog")) return new DogRobot();
        if (type.equals("cat")) return new CatRobot();
        
        return null;
    }
}


public class Test {

	public static void main(String[] args) {
    	Robot dogRobot = RobotFactory.makeRobot("dog");
        Robot catRobot = RobotFactory.makeRobot("cat");
    }
}

 

위의 예시에서는 단점과 장점을 모두 보기 위해 작성했습니다. RobotFactory 라는 생성을 담당해주는 서브 클래스가 없었다면 어땠을지를 생각해보겠습니다.

 

만약 DogRobot의 생성자 넣어주어야 할 parameter을 일부 변경해야한다고 할 때, 모든 사용처의 parameter를 변경해야 할 것입니다. 하지만 RobotFactory가 있다면 이 클래스에서만 변경해주면 되죠. 즉, 객체들을 한 곳에서 관리할 수 있어서 코드가 간결해지고 의존성을 약하게 하면서 결합도를 낮출 수 있습니다.

 

팩토리 메서드 패턴을 사용하면 얻는 이점

  • 매번 객체를 생성하는 코드를 작성하지 않아도 되서 코드가 간결해진다.
  • 객체의 속성이 변경되어도 생성해주는 부분만 수정하면 되어서 결합도를 낮출 수 있다.
  • DIP 원칙을 성립한다.

팩토리 메서드 패턴을 사용하면 생기는 단점

  • 새로 생성할 객체에 따라서 서브 클래스를 계속 만들어 주어야 한다.

2.3 빌더 (Builder)

자바로 코딩할 때 new로 새로운 객체 생성 후 여러 값들을 setAttribute(...) 메서드로 구성해서 만드는 걸 보통 사용해보셨을 겁니다. 이렇게 되면 immutable(불변한) 객체를 만들 수도 없고 그렇다고 생성자에 다 넣자니 너무 많은 parameter도 문제고 null값으로 들어가야 될 것은 굳이 적어주기 싫기도 합니다. 이럴 때 builder 패턴을 사용하게 됩니다.

--여기까지

// 자바빈즈 패턴
Dog dog = new Dog();
dog.setName("dog");
dog.setAge(12);
dog.setGender("male");
//dog.setNickname("없음");

// 생성자 패턴
Dog dog = new Dog("dog", 12, "male", null);

// Builder 패턴
Dog dog = Dog.builder()
	.name("dog")
	.age(12)
	.gender("male")
	.build();

 

빌더 패턴을 사용하면 얻는 이점

  • 불변한 객체를 만들 수 있다.
  • 한번에 객체를 생성하므로 객체 일관성이 깨지지 않는다.
  • 각 인자에 대한 의미를 쉽게 알 수 있다.

빌더 패턴을 사용하면 생기는 단점

  • Lombok 같은 라이브러리를 사용하지 않으면 빌더 만드는 코드를 매번 생성하기 쉽지 않다.
  • 빌더 생성 비용이 크지 않지만 성능에 민감한 상황에서는 문제가 될 수 있다.

3. 구조 패턴 (Structural Pattern)

  • 클래스나 객체를 조합해 더 큰 구조를 만드는 패턴
  • 서로 다른 인터페이스의 객체를 묶어서 단일 인터페이스를 제공하는 등의 방법을 제공하는 패턴

3.1 어댑터 패턴

한 클래스의 인터페이스를 사용하려고 하는데 기존 클래스와 호환이 되지 않을 때 사용할 수 있는 패턴입니다.

아래 그림과 같이 두 개를 이어주는 역할을 하는 Adapter 인터페이스를 선언해서 사용하는 방법입니다.

 

public interface Dog {
	public void run();
}

public interface Car {
	void move();
}

public class DogToCarAdapter implements Car {
	Dog dog;
    
    public DogToCarAdapter(Dog dog) {
    	this.dog = dog;
    }
    
    @Override
    public void move() {
    	dog.run();
    }
}

public class Test {

    public static void main(String[] args) {
    	Dog myDog = new Dog();
    	Car dogCar = new DogToCarAdapter(myDog);
        
        movingTest(dogCar);
    }
	
    public void movingTest(Car car) {
    	car.move();
    }
}

 

Test 클래스의 movingTest 메서드를 Dog 객체를 가지고 실행하고 싶은데 해당 메서드는 변경할 수는 없다고 합시다.

그렇다면 Dog객체를 가지고 Car 객체로 호환되게 만들어줄 어댑터 클래스를 만들고 위와 같이 활용할 수 있습니다.

 

위와 같은 예제는 객체 Adapter를 사용한 경우입니다. 이외에도 클래스 어댑터도 있습니다. 클래스 어댑터는 객체를 생성자에서 받아서 사용하는 것이 아닌 상속받아서 사용하게 됩니다.

 

어댑터 패턴을 사용하면 얻는 이점

  • 기존 코드를 최대한 변경하지 않을 수 있다.
  • 클래스 재활용성을 높일 수 있다.

어댑터 패턴을 사용하면 생기는 단점

  • 클래스를 추가해야하는 비용이 생긴다.
  • 클래스 Adapter의 경우 상속을 사용하기 때문에 유연하지 못하다.

3.2 퍼사드 패턴 (Facade)

퍼사드는 외관, 건물의 정면, 표면이라는 뜻을 가지고 있습니다. 이와 같이 여러 서브 시스템들을 간략한 외관(인터페이스)으로 제공하는 패턴입니다.

 

예를 들어 온라인 쇼핑몰에서 주문하는 순간을 보겠습니다.

사용자 : 주문하기 버튼 클릭!
쇼핑몰 : 요청 정보 확인 -> DB 저장 -> 판매자에게 연락 -> 사용자에게 주문 정보 표시.....

쇼핑몰 내부적으로 엄청나게 많은 일이 일어날 겁니다. 하지만 주문이 목적인 우리는 자세한 건 몰라도 되고 외관에서 보이는 주문하기 버튼만 누르면 됩니다.

 

이러한 방식으로 코드를 구현하는 것이 퍼사드 패턴입니다.

 

public interface Shopping {
	void order();
}

public class ShoppingImpl implements Shopping {
    private OrderMapper orderMapper;
    private MailSender mailSender;
    private UserMapper userMapper;
    
    public ShoppingImpl(
    	OrderMapper orderMapper;
    	MailSender mailSender;
    	UserMapper userMapper
    ) {
    	this.orderMapper = orderMapper;
        this.mailSender = mailSender;
        this.userMapper = userMapper;
    }
    
    public void order() {
    	orderMapper.save();
        userMapper.modify();
        mailSender.send();
    }
}

public class User {
	public static void main(String[] args) {
    	Shopping shopping = new ShoppingImpl(orderMapper, ...);
        
        shopping.order(); //유저에게 보이는 부분
    }
}

 

위와 같이 사용자는 order() 메서드만 실행하면 되지만 클래스 내부적으로는 많은 일이 일어나고 있습니다. 이러한 패턴은 저는 패턴인 줄도 모르고 이렇게 하고 있었습니다. 자연스럽게 사용하는 방식이기도 한 것 같습니다.

 

여기서 주의할 점은 우리가 쇼핑몰을 생각할 때는 사용자가 서버 내부 코드는 접근할 수 없어서 모르지만 개발자 입장에서 볼 때는 ShoppingImpl 객체를 new로 생성하는 시점에서 어떤 서브클래스를 사용하는지 알 수 있게 됩니다. 그리고 이 서브클래스를 직접 사용하는 방법도 알게 되겠죠.

 

퍼사드 패턴을 사용하면 얻는 이점

  • 시스템 또는 서비스 간 의존관계를 완화시켜 결합도를 낮춘다.
  • 새로운 로직이 추가되어도 인터페이스의 변화는 없도록 할 수 있다.

퍼사드 패턴을 사용하면 생기는 단점

  • 사용자가 서브클래스에 직접 접근하는 걸 막을 수 없다.
  • 퍼사드 패턴을 계속 중첩하여 적용하다 보면 God Object가 될 수 있다.

3.3 프록시 패턴

프록시 패턴은 몰라도 프록시는 많이 들어보셨을 겁니다. A -> B로 가는데 A -> Proxy -> B로 거쳐서 가도록 해주는 방식입니다. Adapter랑 비슷하지 않냐! 할 수도 있는데 어댑터 패턴은 로직은 추가되지 않고 A -> B로 가는 방법이 아니라 A를 B로 바꿔서 사용하는 방식입니다.

 

public interface Service {
	String getText();
}

public class Proxy implements Service {
    @Override
    public String showText() {
    	System.out.println("Proxy를 거쳐갑니다");
        
        B b = new B();
        return b.getText();
    }
}

public class B implements Service {
    @Override
    public void getText() {
    	return "B입니다!";
    }
}

public class A {
    public static void main(String[] args) {
    	Service proxyService = new Proxy();
        
        System.out.println(proxyService.getText());
    }
}

 

코드를 보시면 Proxy에서 결국에는 B의 getText() 메서드의 결괏값을 반환합니다. 즉, 결국에는 A클래스 입장에서는 Proxy가 아닌 B의 getText() 하는 것과 다름이 없죠. 그런데 왜 이렇게 사용하냐 하면 B.getText()를 반환하기 전 로직을 추가하고 싶을 때 사용합니다. 이러한 로직의 종류에는 3가지가 있습니다.

 

프록시가 사용되는 3가지 상황

  • 가상 프록시
    • 꼭 필요로 하는 시점까지 객체의 생성을 연기하고, 해당 객체가 생성된 것처럼 동작하도록 만들고 싶을 때 사용
    • 리소스가 많이 요구되는 작업 시에 사용
  • 원격 프록시
    • 서로 다른 주소 공간에 있는 객체를 마치 같은 주소 공간에 있는 것처럼 동작하게 만드는 패턴
  • 보호 프록시
    • 주체 클래스에 대한 접근을 제어하기 위한 경우에 객체에 대한 접근 권한을 제어하거나 객체마다 접근 권한을 달리하고 싶을 때 사용

 

프록시 패턴을 사용하면 얻는 이점

  • 실제 객체의 public, protected 메서드들을 숨길 수 있다.
  • 실제 객체를 수정하지 않고 추가적인 기능을 추가할 수 있다.

프록시 패턴을 사용하면 생기는 단점

  • 객체 생성 시 한 단계를 거치게 되므로 성능이 저하될 수 있다.
  • 로직이 복잡해지고 가독성이 떨어진다.
  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기