디자인 패턴 (1) - 싱글톤, 팩토리 패턴

2ㅣ2ㅣ·2024년 10월 8일

CS

목록 보기
4/13

디자인 패턴이란?

프로그램을 설계할 때 발생했던 문제점들을 객체 간의 상호 관계 등을 이용하여 해결할 수 있도록 하나의 규약 형태로 만들어놓은 것

1. 싱글톤 패턴

하나의 클래스에 오직 하나의 인스턴스만 가지는 패턴

  • 하나의 클래스를 기반으로 단 하나의 인스턴스를 만들어 이를 기반으로 로직을 만드는 데 쓰임
  • 보통 데이터베이스 연결 모듈에서 많이 사용함

Java에서의 싱글톤 패턴

  • 중첩 클래스를 이용하여 만드는 방법이 가장 대중적
class Singleton {
	private static class singleInstanceHolder {
		private static final Singleton INSTANCE = new Singleton();
	}
	public static Singleton getInstance() {
		return singleInstanceHolder.INSTANCE;
	}
}

public class HelloWorld {
	public static void main(String[] args) {
		Singleton a = Singleton.getInstance();
		Singleton b = Singleton.getInstance();
		System.out.println(a.hashCode());
		System.out.println(b.hashCode());
		if (a == b) {
			System.out.println(true);
		}
	}
}

싱글톤 패턴의 단점

  • 싱글톤 패턴은 TDD(Test Driven Development)를 할 때 걸림돌이 됨
    • TDD란?
      • 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 방식의 소프트웨어 개발 방법론
      • 개발자는 먼저 실패하는 테스트 케이스를 작성한 후, 그 테스트를 통과시키기 위해 코드를 작성하고, 마지막으로 리팩터링을 진행해 코드를 개선
    • TDD를 할 때 단위 테스트를 주로 하는데, 단위 테스트는 테스트가 서로 독립적이어야 하며 테스트를 어떤 순서로든 실행할 수 있어야 함
    • 하지만 싱글톤 패턴은 미리 생성된 하나의 인스턴스를 기반으로 구현하는 패턴이므로, 각 테스트마다 독립적인 인스턴스를 만들기가 어려움

의존성 주입

의존성이란?

  • 종속성이라고도 하며, ‘A가 B에 의존성이 있다’라는 것은 B의 변경사항에 대해 A 또한 변해야하는 것을 의미

  • 싱글톤 패턴은 사용하기가 쉽고 실용적이지만, 모듈 간의 결합을 강하게 만들 수 있다는 단점을 만들 수 있음

  • 이 때, 의존성 주입(DI, Dependency Injection)을 통해 모듈 간의 결합을 조금 더 느슨하게 만들어 해결할 수 있음

    • 의존성 주입이란, 객체 간의 관계를 외부에서 관리하고 주입해주는 방식. 말 그대로 필요한 의존성(다른 객체나 모듈)을 외부에서 '주입'해주는 것
  • 의존성 주입 상세 설명

    쇼핑몰에서는 고객이 제품을 주문하고, 다양한 결제 수단(카드, 페이팔, 은행 송금 등)을 통해 결제를 처리

    의존성 주입 없이 설계한 코드

    class PaymentService {
        public void processPayment(String paymentType) {
            if (paymentType.equals("CARD")) {
                System.out.println("카드 결제 처리");
            } else if (paymentType.equals("PAYPAL")) {
                System.out.println("PayPal 결제 처리");
            } else if (paymentType.equals("BANK_TRANSFER")) {
                System.out.println("은행 송금 결제 처리");
            } else {
                throw new UnsupportedOperationException("지원되지 않는 결제 방식");
            }
        }
    }

    유연성 부족

    • 새로운 결제 방식이 추가될 때마다 PaymentService 코드를 수정해야함. 결제 방식이 늘어날 수록 조건문도 늘어나며, 코드도 방대해짐.

      테스트의 어려움

    • 각 결제 방식을 테스트하려면 PaymentService의 여러 조건문을 계속 테스트해야 함

의존성 주입을 통한 개선

의존성 주입을 적용하면, PaymentService는 결제 방식에 대해 전혀 몰라도 되고, 단지 추상화된 PaymentProcessor 인터페이스에 의존하게 됨

    // 1. 결제 처리 인터페이스 (추상화 레이어)
    interface PaymentProcessor {
        void processPayment();
    }
    
    // 2. 카드 결제 구현체
    class CardPaymentProcessor implements PaymentProcessor {
        @Override
        public void processPayment() {
            System.out.println("카드 결제 처리");
        }
    }
    
    // 3. PayPal 결제 구현체
    class PayPalPaymentProcessor implements PaymentProcessor {
        @Override
        public void processPayment() {
            System.out.println("PayPal 결제 처리");
        }
    }
    
    // 4. 은행 송금 결제 구현체
    class BankTransferPaymentProcessor implements PaymentProcessor {
        @Override
        public void processPayment() {
            System.out.println("은행 송금 결제 처리");
        }
    }
    
    // 5. 결제 서비스 클래스 (의존성 주입)
    class PaymentService {
        private PaymentProcessor paymentProcessor;
    
        // 생성자를 통한 의존성 주입
        public PaymentService(PaymentProcessor paymentProcessor) {
            this.paymentProcessor = paymentProcessor;
        }
    
        public void processPayment() {
            paymentProcessor.processPayment();
        }
    }
    
    public class Main {
        public static void main(String[] args) {
            // 카드 결제 주입
            PaymentProcessor cardProcessor = new CardPaymentProcessor();
            PaymentService cardPaymentService = new PaymentService(cardProcessor);
            cardPaymentService.processPayment();  // 카드 결제 처리
    
            // PayPal 결제 주입
            PaymentProcessor paypalProcessor = new PayPalPaymentProcessor();
            PaymentService paypalPaymentService = new PaymentService(paypalProcessor);
            paypalPaymentService.processPayment();  // PayPal 결제 처리
    
            // 은행 송금 결제 주입
            PaymentProcessor bankProcessor = new BankTransferPaymentProcessor();
            PaymentService bankPaymentService = new PaymentService(bankProcessor);
            bankPaymentService.processPayment();  // 은행 송금 결제 처리
        }
    }
**유연성**

- `PaymentService`는 다양한 결제 방식의 구체적인 내용을 전혀 모른 채로, 단순히 `PaymentProcessor` 인터페이스를 통해 작업
- 새로운 결제 방식이 추가되더라도 `PaymentService` 코드를 수정할 필요가 없음

**테스트 용이**

- 각 결제 방식에 대해 별도의 구현체가 있으므로, 이 구현체만 따로 **단위 테스트**가 가능

의존성 주입의 장점

  • 모듈들을 쉽게 교체할 수 있는 구조가 되어 테스팅하기 쉽고, 마이그레이션하기도 수월함
    • e.g.) 새로운 결제 방식을 추가할 때 PaymentService 코드는 전혀 건드리지 않고, 새로운 PaymentProcessor 구현체만 추가하면 됨
  • 구현할 때 추상화 레이어를 넣고 이를 기반으로 구현체를 넣어주기 때문에, 의존성 애플리케이션 의존성 방향이 일관되고, 애플리케이션을 쉽게 추론할 수 있으며, 모듈 간의 관계들이 조금 더 명확해짐
    • PaymentService는 단순히 결제를 처리할 뿐, 결제 방식의 세부 사항을 몰라도 됨. 이 추상화 덕분에 서비스 코드는 매우 깔끔하고 읽기 쉬워짐

의존성 주입의 단점

  • 모듈들이 더욱더 분리되므로 클래스 수가 늘어나 복잡성이 증대될 수 있음
    • 결제 방식이 추가될 때마다 새로운 PaymentProcessor 구현체가 필요
    • 작은 프로젝트에서는 굳이 이렇게 추상화 계층을 추가하는 것이 복잡성만 증가시킬 수 있음
  • 약간의 런타임 패널티가 생기기도 함
    • 의존성 주입은 런타임에 객체가 생성되거나 주입될 때 약간의 성능 저하를 초래할 수 있음
    • 하지만 대체로 이 성능 손실은 매우 미미하고, 복잡한 애플리케이션에서 얻는 유연성에 비하면 크게 문제되지 않아.

의존성 주입 원칙 (Dependency Inversion Principle, DIP)

  • 상위 모듈은 하위 모듈에 의존하지 않아야 한다
    • PaymentServiceCardPaymentProcessorPayPalPaymentProcessor 같은 하위 모듈의 구체적인 구현에 의존하지 않고, PaymentProcessor 인터페이스에 의존함
    • 이렇게 하면 상위 모듈(서비스)이 하위 모듈(구현체)의 변화에 의해 영향을 받지 않음
  • 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다
    • 상위 모듈(PaymentService)과 하위 모듈(CardPaymentProcessor, PayPalPaymentProcessor 등)은 모두 PaymentProcessor라는 추상화에 의존
    • 추상화 레이어가 있으므로 상위와 하위 모듈 간 결합이 줄어듦
  • 추상화는 세부 사항에 의존하지 않아야 한다
    • 추상화 레이어(PaymentProcessor)는 구체적인 결제 방식에 대해 전혀 의존하지 않음
    • 구체적인 구현은 추상화된 인터페이스에 맞춰 세부 사항을 구현할 뿐, 인터페이스 자체는 결제 방식의 변화에 영향을 받지 않음

2. 팩토리 패턴

객체를 사용하는 코드에서 객체 생성 부분을 떼어내 추상화한 패턴
상속 관계에 있는 두 클래스에서 상위 클래스가 중요한 뼈대를 결정하고, 하위 클래스에서 객체 생성에 관한 구체적인 내용을 결정하는 패턴

  • 팩토리 패턴은 객체를 생성하는 부분을 클라이언트 코드에서 분리해서 별도의 팩토리(Factory)라는 클래스로 관리하는 디자인 패턴
  • 이 패턴을 사용하면 클라이언트 코드가 객체 생성 방법에 대해 알 필요 없이, 팩토리 클래스에 객체 생성을 위임할 수 있음

코드로 구현하기 (Java)

라떼 레시피와 아메리카노 레시피, 우유 레시피라는 구체적인 내용이 들어 있는 하위 클래스가 컨베이어 벨트를 통해 전달되고, 상위 클래스인 바리스타 공장에서 이 레시피들을 토대로 우유 등을 생산하는 생산 공정을 생각하기

enum CoffeeType {
	LATTE,
	ESPRESSO
}

abstract class Coffee {
	protected String name;
	
	public String getName() {
		return name;
	}
}

class Latte extends Coffee {
	public Latte() {
		name = "latte";
	}
}

class Espresso extends Coffee {
	public Espresso() {
		name = "Espresso";
	}
}

class CoffeeFactory {
	public static Coffee createCoffee(CoffeeType type) {
		switch (type) {
			case LATTE:
				return new Latte();
			case ESPRESSO:
				return new Espresso();
			default:
				throw new IllegalArgumentException("Invalid coffee type : " + type);
		}
	}
}

public class Main {
	public static void main(String[] args) {
		Coffee coffee = CoffeeFactory.createCoffee(CoffeeType.LATTE);
		System.out.println(coffee.getName()); // latte
	}
}

팩토리 패턴의 장점

  1. 유연성:
    • 클라이언트는 어떤 객체를 만들지 결정할 필요가 없음
    • 팩토리 클래스에서 객체를 생성하고 반환해주므로, 객체 생성 로직을 변경하거나 확장할 때 클라이언트 코드를 수정할 필요가 없음
    • 예를 들어, 커피 메뉴에 새로운 메뉴가 추가되면 팩토리 클래스에 새로운 커피 객체 생성 로직만 추가하면 됨. 클라이언트(Main 클래스)는 아무것도 수정할 필요가 없음
  2. 느슨한 결합:
    • 팩토리 패턴을 사용하면 객체 생성에 대한 책임을 분리할 수 있음
    • 객체 생성과 관련된 코드를 클라이언트 코드와 분리하면 클래스 간 결합도가 낮아짐
      • 정확한 원리?
  3. 유지보수성 증가:
    • 객체 생성 로직이 한 곳에 모여 있으니, 만약 객체 생성 방식에 변경이 필요하다면 한 곳만 수정하면 됨

팩토리 패턴의 단점

  1. 복잡성 증가:
  • 객체 생성 로직을 분리하면서 추상 클래스와 팩토리 클래스가 늘어나면, 작은 프로젝트에서는 오히려 코드가 복잡해질 수 있음.
  1. 서브클래스 추가 시 수정 필요:
  • 새로운 커피 메뉴가 추가되면, 기존 팩토리 클래스에 새로운 서브클래스(예: Mocha)를 추가해야 함.
    • 예시 코드
        class Mocha extends Coffee {
            public Mocha() {
                name = "Mocha";
            }
        }
        
        class CoffeeFactory {
            public static Coffee createCoffee(CoffeeType type) {
                switch (type) {
                    case LATTE:
                        return new Latte();
                    case ESPRESSO:
                        return new Espresso();
                    case MOCHA:
                        return new Mocha();  // Mocha 추가
                    default:
                        throw new IllegalArgumentException("Invalid coffee type : " + type);
                }
            }
        }
- 이렇게 되면 팩토리 클래스의 코드가 점점 커지게 됨
  • 이를 해결하기 위해 추상 팩토리 패턴을 사용할 수 있지만, 그만큼 구조가 복잡해짐

팩토리 패턴의 종류

  1. 정적 팩토리 메서드 패턴: 위에서 사용한 패턴처럼 정적 메서드를 통해 객체를 생성하는 방식
  • 이 방식은 객체 생성을 간단하게 처리할 수 있지만, 객체 생성 로직을 변경하거나 서브클래스를 만들기 어려울 수 있음
  1. 추상 팩토리 패턴: 여러 관련된 객체를 일관되게 생성하는 경우에 사용
  • 예를 들어, 커피와 그에 맞는 컵이나 테이블 세트를 같이 생성해야 하는 상황이라면 추상 팩토리 패턴을 사용해서 더 복잡한 객체 생성 과정을 관리
profile
https://sususoo.tistory.com/

0개의 댓글