JavaDesign Pattern

자바와 함께하는 디자인 패턴

·12 min read

세상 가만보면 좋은 기술은 많겠지만, 잘 가져다 쓰는게 쉽지는 않은 것 같다.

때로는 좋은 기술이 적합한 기술은 아닐 수 있기 때문이다.

예를들어, 난 공공 SI, SM 업무를 주로 했는데,

SI는 걍 돌아가면 OK다.

최대한 빠르게 구현만 하면 되지 유지보수를 신경쓰지는 않는다는 것이다.

대부분 발주처는 우리가 if-else를 떡칠을 하든, 전략 패턴을 가져다 쓰든, 걍 버튼 눌러서 동작하면 신경도 안쓴다.

거기다가 SI 라는게 사람을 쥐어짜서 효율을 내는거다보니, **'최소 인력'**으로 **'최대 기능'**을 **'최단 납기'**하는게 목표다.

SM도 문제가 많다.

SI가 여유가 없어서 패턴, 유지보수 고려를 안한다면.

SM은 그냥 동작하고 있으면 유지하는게 목표다.

동작하고 있으면 굳이 효율 따져서 이걸 개선시킬 생각도 필요도 없다는 것이다.

그냥 돈만 받고 해달라는 것만 하면 그만이니깐

만약 개선했는데? 문제가 생기면? 괜히 우리 책임이다.

참고로 발주처도 코드 잘 모르고, 알아도 굳이굳이 책임지고 싶어하지는 않는다.

아무튼 이런거 하지 말라는 뜻이다.

많고 많은 디자인 패턴

GoF의 디자인 패턴만 보면 23 종류가 있다.

지금 하나하나 명시하기에는 복잡하니 지금 당장 쓸만한 핵심만 익혀보자.

if-else 지옥과 전략 패턴

먼저 디자인 패턴 = 효율적인 구조

비효율적인 구조 생각하면 로직에서 심심찮게 등장하는게 있는데, 바로 if-else 지옥이다.

흔하게 예시로 드는게 바로 결제 수단이다.

카드, 계좌이체, 카카오페이 사람마다 주로 쓰는 수단이 다르니 어떤 수단을 쓰는지 로직상으로 구분을 한다.

예시로 3개 정도 들었지 아마 실제 선택지 보면 한 수십개는 될거다.

즉 새로운 결제 수단이 추가되면 수십 if else 구조가 계속해서 더 길어지고 복잡해진다는 것이다.

// 새로운 결제 수단이 추가될 때마다 이 메서드를 수정해야 한다.
public class PaymentService {
    public void pay(String paymentMethod, int amount) {
        if ("CARD".equals(paymentMethod)) {
            // 카드 결제 로직
        } else if ("BANK".equals(paymentMethod)) {
            // 계좌이체 로직
        } else if ("KAKAO".equals(paymentMethod)) {
            // 카카오페이 로직
        } else {
            throw new IllegalArgumentException("지원하지 않는 결제 수단입니다.");
        }
    }
}
어떻게 전략적으로 할 것인가.

바로 바뀌는 부분(결제 로직)을 각각의 '전략'으로 캡슐화하고, 필요할 때마다 갈아 끼우는 방법이 바로 '전략 패턴'이다.

먼저 기본이 될 인터페이스를 만들자

public interface PaymentStrategy {
    void pay(int amount);
}

공통의 인터페이스를 구현하는 각각의 전략 구현체를 만들자

@Component
public class CardStrategy implements PaymentStrategy {
    @Override
    public void pay(int amount) {
        //카드 결제 로직
    }
}

@Component
public class BankStrategy implements PaymentStrategy {
    @Override
    public void pay(int amount) {
        //계좌 이체 로직
    }
}

구현체가 있으면? 가져다 써야한다.

어떻게 전략을 선택할지(주입)

// 어노테이션 설명까지 가면 너무 복잡하니, 필요하면 나중에 설명한다.
@Service
public class PaymentService {
    private final Map<String, PaymentStrategy> strategies;

    // Spring이 자동(Autowired)으로 PaymentStrategy 구현체들을 Map에 주입해준다.
    @Autowired
    public PaymentService(Map<String, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public void pay(String paymentMethod, int amount) {
        // "CARD" -> "cardStrategy" 로 변환
        String strategyName = paymentMethod.toLowerCase() + "Strategy";
        // 시작은 텍스트였지만 그 서비스는 창대하리라, 아무튼 텍스트로 원하는 전략 딸각 가져오기
        PaymentStrategy strategy = strategies.get(strategyName);

        if (strategy == null) {
            throw new IllegalArgumentException("지원하지 않는 결제 수단입니다.");
        }
        // 호출된 전략에 따라서 각각의 구현체에 구현된 pay 메서드를 실행함
        strategy.pay(amount);
    }
}
이걸 왜 함?

아까 if-else 지옥 벗어나기 위해서라고 말 했듯이

만약 네이버 페이가 추가되더라도 기존 PaymentService 코드를 바꿀 필요는 없다.

그냥 구현된 전략인 NaverPayStrategy 클래스 추가하고, @Component를 붙여주면 끝이다.

흔히 확장가능하지만 수정에는 닫혀있다는 **개방-폐쇄 원칙(OCP)**를 만족하는 것이다.

객체 생성이 복잡해? 빌더쓰면 되잖아

생성자 만들고 파라미터가 너무 많아서 복잡해진 경험이 있는가?

파라미터 순서도 복잡하고, null도 직접 넣어야하고 매우 귀찮은 일이다.

User user = new User(1L, "John Doe", "john@email.com", null, "010-1234-5678", null, null, UserStatus.ACTIVE);
그런 당신을 위한 Lombok의 @Builder

빌더 패턴의 구현체로 객체 생성을 좀 더 명확하고 유연하게 해준다.

@Builder // 이 어노테이션 달아주면 알아서 해줌
@Getter
@AllArgsConstructor
public class User {
    private Long id;
    private String name;
    private String email;
    private String password;
    private String phone;
    // ...
}

// 다음에 만들때는 아래처럼 builder로
User user = User.builder()
            .id(1L)
            .name("John Doe")
            .email("john@email.com")
            .phone("010-1234-5678")
            .status(UserStatus.ACTIVE)
            .build();

간단하긴 한데, 보이는게 너무 깔끔해진다.

또 필수값 강제하고 싶은거 있으면 생성자 레벨에서 검증 로직도 추가 가능하다.

가내 수공업하지 말고 생산은 공장에서 해라(팩토리 메서드)

비즈니스 로직에 객체를 '생성'하는 로직까지 들어가고, 그 로직에 분기 처리가 들어가면 우리가 우려하는 if-else의 지옥이다.

말보다는 예제부터 하나 보자

// 파일을 업로드 하자!는 비즈니스 로직
@Service
public class FileService {
    public void uploadFile(String storageType, File file) {
        Uploader uploader; // 업로드 하는 놈
        // if else 지옥 +
        if ("S3".equals(storageType)) {
            uploader = new S3Uploader(); // 서비스가 S3Uploader를 직접 생성하고
        } else if ("LOCAL".equals(storageType)) {
            uploader = new LocalUploader(); // 서비스가 LocalUploader의 존재를 직접 생성한다.
        } else {
            throw new IllegalArgumentException("Invalid storage type");
        }
        uploader.upload(file);
    }
}

일단 new 찍고 객체 생성하는게 문제다.

FileService애는 '업로드를 실행'하는게 목적인데,

if 찍고 '어떤 업로드를 만들지' 결정(생성)하는 책임까지 가지고 있다.

**단일 책임 원칙(SRP)**를 위배했다는 것이다.

자 그럼 만약에 AzureUploader추가되면? if-else다.

기능 확장을 위해 기존 코드를 수정한다?

개방-폐쇠 원칙(OCP) 위배다.

FileService가 S3Uploader, LocalUploader 등의 구체적은 구현체를 알아야한다는 의존성 문제도 있다.

생산은 공장에서 하자.

일단 if-else 패턴이 있고, 구체적인 구현체가 비즈니스 로직에 들어간다? 공장을 짓자.

@Component // 이 팩토리 자체도 Spring Bean으로 등록
public class UploaderFactory {

    // 생성 로직을 이 '공장'이 모두 담당한다.
    public Uploader getInstance(String storageType) {

        if ("S3".equals(storageType)) {
            return new S3Uploader(); // (실제로는 주입받은 빈을 반환)
        } else if ("LOCAL".equals(storageType)) {
            return new LocalUploader();
        } else {
            throw new IllegalArgumentException("지원하지 않는 타입");
        }
    }
}

다시 if-else를 들어낸 FileService에 이제는 새로 신장개업한 공장을 연결해주자

@Service
public class FileService {

    // 공장 주입!
    private final UploaderFactory uploaderFactory;

    @Autowired
    public FileService(UploaderFactory uploaderFactory) {
        this.uploaderFactory = uploaderFactory;
    }

    public void executeUpload(String fileName, String storageType) {

        // 공장, 팩토리에 요청만 하는거다.
        // FileService는 더 이상 S3, Local을 모르고, 그냥 구분자 넘겨줄테니 알아서 달라하면 된다.
        Uploader uploader = uploaderFactory.getInstance(storageType);

        // 비즈니스 로직은 자기 할일(실행)에만 집중하자
        uploader.upload(fileName);
    }
}

개선 된 점 따져보면, 팩토리는 '생성'을 책임진다. 기존 비즈니스 로직은? '실행'을 책임진다.

FileService가 구체적인 구현체에 의존하는게 아닌, 인터페이스에만 의존하기에 결합도도 떨어졌다.

그리고 전략 패턴과 마찬가지로, 노 몰 if-else 그냥 필요하면 생성 공장에서 추가하지 FileService는 수정하지 않는다.

여기서 잠시 스탑

내용이 내용이다보니깐 분량이 많다.

그냥 하루에 전략 하나씩만 할 껄 후회중이다.

추가로 어댑터 패턴과 템플릿 메서드 작성하고

스프링 프레임워크랑 연동되서 자연스럽게 쓰게 되는 디자인 패턴들인

싱글톤, DI(의존성 주입)

프록시, 프론트 컨트롤러 개념을 포함한 MVC 패턴, 옵저버

까지를 다뤄보고자 했으나 배우기도 벅차다.

일단 여기서 끊고, 가능하다면 내일 다시한번 다뤄보겠다.

근데 만약 올렸는데 또 코드 라인 작살나 있으면 엉엉 울꺼 같다.

← Previous
실시간 통신 구현 방법
Next →
이미지 생성, Stable diffusion