객체지향 프로젝트를 설계하는데 자주 쓰이는 구조인 디자인 패턴 몇 가지를 살펴보도록 하겠다.
딱딱하게 외우기보다는, 디자인 패턴의 구현 과정을 따라가며 패턴 이름과 연관성을 따져가며 이미지화 하는 것이 좋을듯!
1. 싱글톤 패턴 (Singleton Pattern)
인스턴스를 하나만 만들어서 쓰는 패턴. 인스턴스를 한 개만 만들면 되는 DB 커넥션, 로그 기록, configuration 등의 클래스가 싱글톤 패턴으로 만들어진다. 불필요한 메모리 낭비를 줄이고, 전역 변수를 넣어 여러 인스턴스들이 값을 공유하게 할 수도 있다.
- 클래스 속성 값으로 해당 클래스 타입의 static 속성값 하나를 둔다.
- 생성자는 private으로 두어 클래스 외부에서 인스턴스를 만들 수 없게 한다.
- static getInstance() method를 두어 해당 클래스의 객체가 없을 때만 new로 생성하게 하고, 있으면 속성값을 반환한다.
위 내용을 따른 싱글톤 클래스의 구현을 간단하게 코드로 나타내면 다음과 같다.
public class Singleton {
static Singleton singletonObject;
private Singleton() {}
public static Singleton getInstance() {
if (singletonObject == null) {
singletonObject = new Singleton();
}
return singletonObject;
}
}
단일 스레드 프로그램에서, 싱글톤 패턴을 가진 클래스는 프로그램이 실행되는 동안 오로지 한 개의 static instance가 만들어짐을 보장한다. 예컨데 다음 main 클래스를 실행시키면 s1과 s2 인스턴스의 hashCode값이 동일할 것이다.
public class Client {
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1);
System.out.println(s2);
}
}
그러나 멀티스레드 환경에서 동기화 처리를 제대로 하지 않으면 싱글톤 인스턴스가 두 개가 발생할 수도 있다. getInstance() method에 synchronized를 붙일 수도 있지만 성능저하가 크다. 그래서 보통 내부 클래스를 둬서 JVM이 클래스를 로드할 때 싱글톤 인스턴스를 한꺼번에 만드는 방법을 사용한다. static class는 프로그램 시작때 한 번만 로딩이 되는 특징임을 이용해 JVM에게 싱글톤 생성 책임을 전가하는 것이다.
public class MultiSingleton {
private MultiSingleton() {}
private static class Holder {
private static MultiSingleton INSTANCE = new MultiSingleton();
}
public MultiSingleton getInstance() {
return Holder.INSTANCE;
}
}
2. 템플릿 메소드 패턴 (Template Method Pattern)
공통 로직을 단계적으로 수행하는 템플릿 메소드를 상위 클래스에 두고 하위 클래스들이 필요에 따라 로직을 구현하도록 구성한 디자인 패턴. 수행하는 로직의 단계 구성은 같으나 상세 구현이 다를 때, 변화 가능성이 더 클 때 유용하다.
국어와 영어를 책으로 공부한다고 생각해보자. 어떤 공부를 해도 책을 펼치고 덮는 과정이 필요할 것이다.
책을 펼친다 -> (___)를 공부한다. -> 책을 덮는다.
(___) 안에 과목명만 바뀌면 국어 / 영어 / 수학을 공부하는 단계적 로직이 똑같다. 이 때 템플릿 메소드 패턴을 이용하면 좋다.
- 상위 클래스는 abstract로 둔다. (상세 구현이 필요한 메소드가 존재하므로)
- 단계별 로직이 들어있는 템플릿 메소드는 final로 두어 override가 불가능하도록 한다.
- 각 단계별 로직은 구현 클래스에서만 사용할 수 있도록 protected로 두는 것이 좋다.
예시로 들었던 공부의 경우로 템플릿 메소드 패턴 코드를 작성하면 다음과 같다. open() - doStudy() - close() 가 순서대로 호출되는 템플릿 메소드를 가진 구조이다.
public abstract class Study {
public final void study() {
open();
doStudy();
close();
}
protected void open() {
System.out.println("책을 펼친다!");
}
protected abstract void doStudy();
protected void close() {
System.out.println("책을 덮는다!");
}
}
public class Korean extends Study {
@Override
protected void doStudy() {
System.out.println("국어를 공부한다.");
}
}
public class English extends Study {
@Override
protected void doStudy() {
System.out.println("영어를 공부한다.");
}
}
경우에 따라 open()과 close()를 다시 Override할 수도 있겠지만, 그러진 않았다.
public class Client {
public static void main(String[] args) {
Study korean = new Korean();
Study english = new English();
System.out.println("----korean.study()----");
korean.study();
System.out.println("----english.study()----");
english.study();
}
}
Client.main을 돌려보면 어떤 결과가 나올지 예상 가능할 것이다.
상위 클래스의 템플릿 메소드에서 하위 클래스가 오버라이딩한 메소드를 호출한다. 정도로 정리할 수 있다.
3. 팩토리 메서드 패턴 (Factory Method Pattern)
상위 추상 factory 클래스를 오버라이드 한 하위 factory 클래스가 각자에 맞는 객체를 생성하여 반환하는 패턴.
과일 팩토리 클래스는 과일 객체를 생성하여 반환하는 추상 메소드를 가지고 있다.
과일 팩토리 클래스를 상속받는 사과 팩토리, 바나나 팩토리 클래스가 있다.
과일 클래스를 상속받는 사과, 바나나 클래스가 있다.
사과 팩토리와 바나나 팩토리는 각각 사과와 바나나 객체를 생성하여 반환한다.
- 상위 팩토리 클래스를 오버라이드하는 하위 팩토리 클래스들은 각자에 맞는 객체를 생성 반환한다.
과일 공장의 예시를 가지고 팩토리 메서드 패턴을 구현해보면 다음과 같다.
public abstract class FruitFactory {
public abstract Fruit getFruit();
}
public class AppleFactory extends FruitFactory {
@Override
public Fruit getFruit() {
return new Apple();
}
}
public class BananaFactory extends FruitFactory {
@Override
public Fruit getFruit() {
return new Banana();
}
}
public abstract class Fruit {
public abstract void fruitName();
}
public class Apple extends Fruit {
@Override
public void fruitName() {
System.out.println("사과");
}
}
public class Banana extends Fruit {
@Override
public void fruitName() {
System.out.println("바나나");
}
}
AppleFactory, BananaFactory는 각각 Apple과 Banana 객체를 반환한다.
public class Client {
public static void main(String[] args) {
FruitFactory appleFactory = new AppleFactory();
FruitFactory bananaFactory = new BananaFactory();
Fruit apple = appleFactory.getFruit();
Fruit banana = bananaFactory.getFruit();
apple.fruitName();
banana.fruitName();
}
}
main 코드를 보면 실제로 new 연산자를 통해 인스턴스를 만드는 과정은 없지만 apple과 banana 객체가 생성되어 fruitName()을 호출하는 모습을 볼 수 있다.
4. 스트레티지(전략) 패턴 (Strategy Pattern)
전략을 실행할 컨텍스트에 전략을 주입하는 패턴. 컨텍스트는 넘겨받은 전략 객체가 구현한 메소드를 실행한다.
군인에게 무기를 주면 그 무기에 맞는 전투방식을 사용할 것이다. 예컨데 칼을 주면 칼로, 총을 주면 총으로, 활을 주면 활로 싸울 것이다. 여기서 전략을 실행하는 컨텍스트는 군인 객체, 무기는 전략 객체가 된다.
- 전략 객체는 각 전략에 맞는 메소드를 가지고 있다.
- 각 전략 객체가 구현해야 할 메소드를 상위 인터페이스로 정의한다.
- 전략 객체의 메소드를 호출하는 컨텍스트 객체가 존재한다.
군인(컨텍스트 객체)이 무기(전략 객체)에 따라 싸우는 전략이 달라지는 예시 코드를 작성해봤다.
public class Soldier {
public void fightByStrategy(FightStrategy fightStrategy) {
fightStrategy.fight();
}
}
public interface FightStrategy {
public void fight();
}
public class GunStrategy implements FightStrategy {
@Override
public void fight() {
System.out.println("총으로 싸웁니다!");
}
}
public class KnifeStrategy implements FightStrategy {
@Override
public void fight() {
System.out.println("칼으로 싸웁니다!");
}
}
public class BowStrategy implements FightStrategy {
@Override
public void fight() {
System.out.println("활으로 싸웁니다!");
}
}
public class Client {
public static void main(String[] args) {
Soldier soldier = new Soldier();
FightStrategy gun = new GunStrategy();
FightStrategy knife = new KnifeStrategy();
FightStrategy bow = new BowStrategy();
soldier.fightByStrategy(gun);
soldier.fightByStrategy(knife);
soldier.fightByStrategy(bow);
}
}
Client.main을 실행하면 군인이 각각 총, 칼, 활로 싸우는 로그가 찍힐 것이다.
템플릿 메소드 패턴은 상위 클래스를 상속한 하위 클래스 자체가 자신에게 맞는 메소드를 호출하는 것이라면, 전략 패턴은 클래스를 주입받아 클래스에 맞는 메소드를 호출한다는 차이점이 있다. 객체지향 5대 원칙중 OCP, DIP와 관련이 있어보인다.
5. 어댑터 패턴 (Adapter Pattern)
호출당하는 쪽의 method를 호출하는 쪽에 대응하도록 어댑터를 중간에 두는 패턴. 특정 클래스에 존재하는 메소드를 곧바로 현재 프로젝트에 적용할 수 없는 경우 등엔 중간에 해당 메소드를 호출하여 현재 프로젝트에 적용시켜주는 어댑터가 필요하게 된다. OCP에서 일종의 "쿠션" 역할을 하는 중간 인터페이스가 어댑터 패턴의 어댑터 역할을 한다고 보면 된다.
영어로 말을 하는 speakEnglsh() 메소드가 있는 클래스가 있다.
public class EnglishSpeaking {
public void speakEnglish() {
System.out.println("speaking in English.");
}
}
그리고 그냥 말을 하는 speak() 메소드가 있는 Speaking 클래스가 있다.
public class Speaking {
public void speak() {
System.out.println("말합니다.");
}
}
Speaking 클래스의 speak() 메소드를 호출하는 방식과 똑같이 speakInEnglish() 메소드를 호출하고 싶다. 이럴때 어댑터 패턴을 이용할 수 있다.
어댑터 클래스가 Speaking 클래스를 extend하여 speak() 메소드를 오버라이드 하면 기존의 Speaking 클래스와 같은 방법으로 speak() 메소드를 호출할 수 있다. 코드를 보면 이해가 빠를듯!
public class EnglishAdapter extends Speaking {
EnglishSpeaking englishSpeaking;
public EnglishAdapter() {
this.englishSpeaking = new EnglishSpeaking();
}
@Override
public void speak() {
englishSpeaking.speakEnglish();
}
}
Speaking 클래스와 EnglishAdapter 클래스의 speak을 같은 형식으로 호출하는 클라이언트 코드이다.
public class Client {
public static void main(String[] args) {
Speaking speaking = new Speaking();
EnglishAdapter englishAdapter = new EnglishAdapter();
speak(speaking);
speak(englishAdapter);
}
private static void speak(Speaking speaking) {
speaking.speak();
}
}
같은 메소드 이름으로 호출 가능하다는 것이 핵심인 것 같다. Food 상위에 인터페이스나 추상 클래스를 하나 둬서 상속의 이점을 최대로 누릴 수도 있다.
6. 데코레이터 패턴 (Decorator Pattern)
메소드 호출의 반환값에 장식을 더하는 패턴. 대리자 패턴이라고도 불리는 프록시 패턴과 기본적으로 구현은 같으나, 호출 결과값에 변화를 준다는 점에서 차이가 있다.
- 특정 반환값A가 있는 메소드A를 가진 클래스A가 있다.
- 데코레이터 A'는 속성 값으로 클래스A의 인스턴스를 가진다.
- 데코레이터A'의 메소드A'는 메소드A를 호출한 뒤, 반환값A에 무언가를 더한 반환값A'를 return한다.
"선물"이라는 문자열을 return하는 메소드가 있고, 그 선물에 장식을 더해주는 데코레이터 클래스가 있다고 하자. 위 방법을 따르며 구현한 코드 예시이다.
public class Present {
public String givePresent() {
return "선물";
}
}
public class Decorator {
Present present;
public Decorator() {
this.present = new Present();
}
public String givePresent() {
System.out.println("데코레이터 패턴을 통해 호출합니다.");
return "장식이 더해진 " + present.givePresent();
}
}
Present 클래스의 givePresent()와 Decorator 클래스의 givePresent() 결과값의 차이를 확인해보자. 그냥 선물과 장식이 더해진 선물의 차이이다.
public class Client {
public static void main(String[] args) {
Present present = new Present();
Decorator decorator = new Decorator();
System.out.println(present.givePresent());
System.out.println("----------");
System.out.println(decorator.givePresent());
}
}
마무리
디자인 패턴은 선조(?) 프로그래머들이 코드를 더 깔끔하고 확장성이 높게 작성하기 위해 고민하고 노력한 결과물이다. 그들의 지혜를 본받아 그 안에 담겨진 철학을 느끼고 필요한 상황에 그 지혜를 잘 녹여내도록 하자.
챗지피티가 추천한 신입 개발자로서 알아야 할 디자인 패턴을 소개하며 마무리한다.
REFERENCE)
https://gyoogle.dev/blog/design-pattern/Overview.html
스프링 입문을 위한 자바 객체 지향의 원리와 이해 - 김종민 지음