본문 바로가기

CS/디자인 패턴

디자인 패턴 개념 정리 [Java]

디자인 패턴이란 프로그램을 설계할 때 발생했던 문재점들을 객체 간의 상호 관계 등을 이용하여 해결할 수 있도록 하나의 규약 형태로 만들어 놓은 것을 의미합니다. 

 

싱글톤 패턴 

하나의 클래스에 오직 하나의 인스턴스만 가지는 패턴입니다. 자바로는 중첩 클래스를 이용해서 만드는 방법이 가장 대중적입니다. 

 

[1] 싱글톤 패턴을 사용하는 이유

우리가 만들었던 DI 컨테이너에 요청을 할 때마다 새로운 객체를 생성합니다. 요청이 엄청나게 많은 트래픽 사이트에서는 계속 객체를 생성하게 되면 메모리 낭비가 심하기 때문입니다. 많은 객체를 생성해야 하는 서비스(배달 어플, 카카오택시)에서는 낭비가 더 심해집니다. 

 

[2] 싱글톤 패턴의 단점

TDD(Test Driven Development) 할 때 걸림돌이 됩니다. TDD 할 때 단위 테스트를 주로 하는데, 단위 테스트는 서로 독립적이어야 하며 테스트를 어떤 순서로든 실행할 수 있어야 합니다. 하지만 싱글톤 패턴은 미리 생성된 하나의 인스턴스를 기반으로 구현하는 패턴이므로 각 테스트 마다 독립적인 인스턴스를 만들기가 어렵습니다. 그리고 구현하는 코드 자체가 많아지고 private 생성자로 자식 클래스를 만들기 어렵다는 등 여러 문제점들이 있습니다. 

 

[3] 의존성 주입 

싱글톤 패턴은 사용하기가 쉽고 굉장히 실용적이지만 모듈 간의 결합을 강하게 만들 수 있다는 단점이 있습니다. 이때 의존성 주입을 통해 모듈간의 결합을 좀 더 느슨하게 만들어 해결 할 수 있습니다. 

예를 들어 메인 모듈이 직접 다른 하위 모듈에 대한 의존성을 주기 보다는 중간에 의존성 주입자가 이 부분을 가로채 메인 모듈이 간접적으로 의존성을 주입하는 방식입니다. 이를 통해 메인 모듈은 하위 모듈에 대한 의존성이 떨어지게 됩니다. 참고로 이를 "디커플링"이 된다고 합니다.

 

3-1. 의존성 주입의 장점

모듈을 쉽게 교체할 수 있는 구조가 되어 테스팅하기 쉽고 구현할 때 추상화 레이어를 넣고 이를 기반으로 구현체를 넣어 주기 때문에 의존성 방향이 일관되고 쉽게 추론할 수 있으며 모듈 간의 관계들이 조금 더 명확해집니다.

 

3-2. 의존성 주입의 단점

모듈이 분리되므로 클래스 수가 늘어나 복잡성이 증가될 수 있으며 약간의 런타임 패널티가 생기기도 합니다.

 

[4] 스프링에서의 사용

스프링 컨테이너는 싱글턴 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리합니다. 이러한 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하고 객체를 싱글톤으로 유지할 수 있습니다.

스프링에서 싱글톤 관련 코드는 작성하지 않아도 빈에 객체를 1개 설정합니다. 객체를 새롭게 생성해도 같은 객체를 생성한 결과를 볼 수 있습니다. 

 

4-1. 주의점

객체 인스턴스를 하나만 생성해서 공유하는 상황에서 객체 인스턴스를 공유하기 때문에 객체 상태를 유지하게 설계하면 안됩니다. 공유되는 필드는 특정 클라이언트가 값을 변경합니다. 변경되면 큰 위를 맞이하게 될 것 입니다. 

 

팩토리 패턴 

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

 

[1] 팩토리 패턴 장점

클래스가 분리되기 때문에 느슨한 결합을 가지며 상위 클래스에서는 인스턴스 생성 방식에 대해 전혀 알 필요가 없기 때문에 더 많은 유연성을 가집니다. 그렇기에 코드를 리팩토링하더라도 한 곳만 고칠 수 있게 되니 유지 보수성이 증가됩니다. 

 

객체 생성과 관리를 중앙 집중화하여 코드 중복을 방지할 수 있습니다.

 

예를 들어 각 음료의 레시피라는 구체적 내용을 컨베이어 벨트를 통해 전달하고 상위 클래스인 바리스타 공장에서 이 레시피들을 토대로 생산하는 생산 공정을 생각하면 됩니다. 

 

static 키워드를 통해 createCoffee() 메서드를 정적 메서드로 선언한 것을 볼 수 있는데 이렇게 정적 메서드로 정의하면 클래스 기반으로 객체를 만들지 않고 호출이 가능하며 해당 메서드에 대한 메모리 할당을 한 번만 할 수 있는 장점이 있습니다. 

 

[2] 팩토리 패턴 단점 

 

 

[3] 활용성

  • 어떤 클래스가 자신이 생성해야 하는 객체의 클래스를 예측할 수 없을 때
  • 생성할 객체를 기술하는 책임을 자신의 서브클래스가 지정했으면 할 때

 

[4] 스프링에서의 사용

비즈니스 로직을 구현할 때 입력받은 값에 따라 일부 로직을 각각 다르게 구현해야 하는 경험을 많이 하게 됩니다. 

if문으로 장황스럽게 구현된 코드를 Factory pattern을 활용하여 관리가 용이하도록 변경합니다.

 

예를 들어 보겠습니다. 구매한 상품 타입에 따라 포인트 계산식이 달라지는 로직입니다. 

 

4-1. 팩토리 패턴 사용 전

 

의류포인트계산

@Service
public class ClothesPointCalculateService {
    public int calculatePoint(PointCalculate pointCalculate) {
        return (int) Math.ceil(pointCalculate.getPrice() * 0.01);
    }
}

식품포인트계산 Service

@Service
public class FoodPointCalculateService {
    public int calculatePoint(PointCalculate pointCalculate) {
        return (int) Math.ceil(pointCalculate.getPrice() * 0.03);
    }
}

전자제품포인트계산 Service

@Service
public class ElectronicsPointCalculateService {
    public int calculatePoint(PointCalculate pointCalculate) {
        return (int) Math.ceil(pointCalculate.getPrice() * 0.10);
    }
}

포인트 계산 Test case

@SpringBootTest
class PointCalculateTest {

    private final int PRICE = 10000;

    @Autowired
    private ClothesPointCalculateService clothesPointCalculateService;

    @Autowired
    private FoodPointCalculateService foodPointCalculateService;

    @Autowired
    private ElectronicsPointCalculateService electronicsPointCalculateService;

    @Test
    public void clothesPointCalculate_if_success() {
        assert(calculatePoint(ProductType.CLOTHES) == 500);
    }

    @Test
    public void foodPointCalculate_if_success() {
        assert(calculatePoint(ProductType.FOOD) == 300);
    }

    @Test
    public void electronicsPointCalculate_if_success() {
        assert(calculatePoint(ProductType.ELECTRONICS) == 1000);
    }

    private int calculatePoint(ProductType productType) {
        PointCalculate pointCalculate = PointCalculate.builder()
                .productType(productType)
                .price(PRICE)
                .build();

        if (productType == ProductType.CLOTHES) {
            return clothesPointCalculateService.calculatePoint(pointCalculate);
        } else if (productType == ProductType.FOOD) {
            return foodPointCalculateService.calculatePoint(pointCalculate);
        } else if (productType == ProductType.ELECTRONICS) {
            return electronicsPointCalculateService.calculatePoint(pointCalculate);
        }

        return 0;
    }
}

 

4-2. 팩토리 패턴 사용 후

 

우선 포인트 계산 Service를 인터페이스로 분리합니다.

public interface PointCalculateService {
    int calculatePoint(PointCalculate pointCalculate);
    ProductType getProductType();
}
@Service
public class FactoryClothesPointCalculateService implements PointCalculateService {

    @Override
    public int calculatePoint(PointCalculate pointCalculate) {
        return (int) Math.ceil(pointCalculate.getPrice() * 0.05);
    }

    @Override
    public ProductType getProductType() {
        return ProductType.CLOTHES;
    }
}
@Service
public class FactoryElectronicsPointCalculateService implements PointCalculateService {

    @Override
    public int calculatePoint(PointCalculate pointCalculate) {
        return (int) Math.ceil(pointCalculate.getPrice() * 0.10);
    }

    @Override
    public ProductType getProductType() {
        return ProductType.ELECTRONICS;
    }
}
@Service
public class FactoryFoodPointCalculateService implements PointCalculateService {

    @Override
    public int calculatePoint(PointCalculate pointCalculate) {
        return (int) Math.ceil(pointCalculate.getPrice() * 0.03);
    }

    @Override
    public ProductType getProductType() {
        return ProductType.FOOD;
    }
}

포인트 계산 Service Factory class

@Component
public class PointCalculateServiceFactory {

    private final Map<ProductType, PointCalculateService> pointCalculateServiceMap = new HashMap<>();

    /**
     * 생성자주입
     * @param pointCalculateServices
     */
    public PointCalculateServiceFactory(List<PointCalculateService> pointCalculateServices) {
        pointCalculateServices.forEach(s -> pointCalculateServiceMap.put(s.getProductType(), s));
    }

    public PointCalculateService getPointCalculateService(ProductType productType) {
        return pointCalculateServiceMap.get(productType);
    }
}

포인트 계산 Test case

@SpringBootTest
class PointCalculateTest {

    private final int PRICE = 10000;

    @Autowired
    private PointCalculateServiceFactory pointCalculateServiceFactory;

    @Test
    public void clothesPointCalculate_if_success() {
        assert(calculatePoint(ProductType.CLOTHES) == 500);
    }

    @Test
    public void foodPointCalculate_if_success() {
        assert(calculatePoint(ProductType.FOOD) == 300);
    }

    @Test
    public void electronicsPointCalculate_if_success() {
        assert(calculatePoint(ProductType.ELECTRONICS) == 1000);
    }

    private int calculatePoint(ProductType productType) {
        PointCalculate pointCalculate = PointCalculate.builder()
                .price(PRICE)
                .productType(productType)
                .build();
        return pointCalculateServiceFactory.getPointCalculateService(productType).calculatePoint(pointCalculate);
    }
}

이 후에 새로운 포인트 계산 서비스가 필요한 경우 CalculateService interface를 상속받는 Service bean만 생성해주면 됩니다. 

 

 

전략 패턴 

정책 패턴이라고도 하며 객체의 행위를 바꾸고 싶은 경우 직접 수정하지 않고 전략이라고 부르는 캡슐화한 알고리즘을 컨텍스트 안에서 바꿔주면서 상호 교체가 가능하게 만드는 패턴입니다. 

예를 들어 우리가 어떤 것을 살 때 카카오페이, 네이버 페이 등 다양한 방법으로 결제하듯이 결제 방식의 전략만 바꿔서 두 가지 방식으로 결제하는 것 입니다. 

서비스 내의 회원가입된 아이디와 비밀번호를 기반으로 인증하는 방식에서 여러 전략을 선택해서 사용합니다.  

더보기

컨텍스트란?

상황, 문맥, 맥락을 의미하며 개발자가 어떤 작업을 완료하는데 필요한 모든 관련 정보를 말합니다.

 

옵저버 패턴

주체가 어떤 객체의 상태 변화를 관찰하다가 상태 변화가 있을 때 마다 메서드 등을 통해 옵저버 목록에 있는 옵저버들에게 변화를 알려주는 디자인 패턴입니다. 여기서 주체란 객체의 상태 변화를 보고 있는 관찰자이며 옵저버들이란 이 객체의 상태 변화에 따라 전달되는 메서드 등을 기반으로 추가 변경 사항이 생기는 객체들을 의미합니다.

 

예를 들어 옵저버 패턴을 활용한 서비스로는 트위터가 있습니다. 내가 어떤 사람인 주체를 팔로우 했다면 주체가 포스팅을 올리게 되면 알림이 팔로워에게 가는 방식으로 작동하는 것을 말합니다.

또한 옵저버 패턴은 주로 이벤트 기반 시스템에 사용하며 MVC패턴에도 사용됩니다.

예를 들어 주체라고 볼 수 있는 모델에서 변경 사항이 생겨 update() 메서드로 옵저버인 View에 알려주고 이를 기반으로 컨트롤러 등이 작동하는 것이죠.