자바와 함께하는 디자인 패턴
어제 썻던 내용인데, 일단 내용상 같이 가져가야하기 때문에 그대로 복사해서 넣겠다.
만약 읽었더라면 생략해서 어댑터부터 읽으면 된다.
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는 수정하지 않는다.
여기서 잠시 스탑, 전략이랑 팩토리랑 차이는?
디자인 패턴의 도입 예시를 따지며 대표적인 요소가 if-else구조를 예를 들었다.
구조를 따지면 전략이랑 팩토리랑 유사해 보일 수 있는데 다음의 차이를 가지고 있다.
팩토리 패턴: 누가 만들래?(객체 생성이 목적)
전략 패턴: 누가 실행할래?(행위 교체가 목적)
// 팩토리는 "어떻게" 만드는지는 관심 없고, "결과물"만 받는다.
Uploader uploader = uploaderFactory.getInstance(storageType);
uploader.upload(...);
// 전략은 "어떤 전략을 선택해서 실행할지"만 결정한다.
Uploader selectedStrategy = strategyMap.get(storageType);
selectedStrategy.upload(...);
비즈니스 로직만 보면 비슷해서 헷갈리는데,
관심사를 기준으로 봐야한다.
객체의 '생성'을 숨기냐, 객체의 '행위'를 교체하느냐
외부 연동해야하는데, 호환이 안되는데요?(어댑터 패턴)
우리 규격이 있는데, 새로 연동할 시스템은 전혀 다른 규격을 쓴다면?
우리 시스템 전체를 외부 시스템에 맞게 수정하기
VS
우리 시스템 유지하고 중간에 규격 맞춰줄 중간자 추가하기
외부 시스템 규격을 변경 불가능하다는 제약조건 하에서
서로 연동에 필요한 귀찮은 매핑 작업을 어댑터에 외주 주겠다는 뜻이다.
필드명이 달라요~
리턴 값이 달라요~
외부 API를 변경 불가는하다는 조건 하에 하나하나 맞추는 작업을 비즈니스 로직에서 하지 말라는 것이다.
설명만 들어도 사실
"그냥 관심사 분리해서 괜히 우리 시스템 물 흐릴 요소는 격리해서 처리하는거 아님?"
이라고 생각이 들면 사실이다.
직관적인 개념이니 코드 예시는 생략한다.
반복되면 템플릿으로 찍어내자.
파일 처리 순서를 생각해보자.
파일을 연다.
처리를 한다.
파일을 닫는다.
여기서 만약 처리하는 파일이 csv, txt, md 종류가 다양하고, 각자 서로 다른 처리가 들어가야한다고 생각하자.
매 처리마다 중복이 발생한다.
public class FileProcessor {
public void processCsv(File file) {
try {
// 1. 파일 열기 (중복)
System.out.println("파일을 엽니다: " + file.getName());
// 2. 데이터 처리 (핵심 로직)
System.out.println("CSV 데이터를 처리합니다.");
// ...
} catch (Exception e) {
// 예외 처리 (중복)
System.out.println("에러 발생: " + e.getMessage());
} finally {
// 3. 파일 닫기 (중복)
System.out.println("파일을 닫습니다.");
}
}
public void processTxt(File file) {
try {
// 1. 파일 열기 (중복)
System.out.println("파일을 엽니다: " + file.getName());
// 2. 데이터 처리 (핵심 로직)
System.out.println("TXT 데이터를 처리합니다.");
// ...
} catch (Exception e) {
// 예외 처리 (중복)
System.out.println("에러 발생: " + e.getMessage());
} finally {
// 3. 파일 닫기 (중복)
System.out.println("파일을 닫습니다.");
}
}
}
보면 열고, 예외처리 하고, 닫고는 반복된다.
이 반복되는 부분(템플릿)을 부모 클래스에 final로 정의하고, 달라지는 부분은 추상 메서드로 선언해서 자식에게 구현을 위임하는게 템플릿 메서드 패턴이다.
public abstract class AbstractFileProcessor {
// final 박아서 템플릿은 수정 불가 제약 걸기
public final void process(File file) {
try {
// 1. 파일 열기 (중복 부분)
System.out.println("파일을 엽니다: " + file.getName());
// 2. 데이터 처리 (자식에게 위임)
processData(file);
} catch (Exception e) {
// 예외 처리 (중복 부분)
System.out.println("에러 발생: " + e.getMessage());
} finally {
// 3. 파일 닫기 (중복 부분)
System.out.println("파일을 닫습니다.");
}
}
// 자식 클래스가 구현해야 할 부분
protected abstract void processData(File file);
}
부모 템플릿을 정의했으면 자식에서는 extends로 상세 처리 로직을 구현하면 된다.
public class CsvFileProcessor extends AbstractFileProcessor {
@Override
protected void processData(File file) {
System.out.println("CSV 데이터를 처리합니다.");
// ... 실제 CSV 처리 로직
}
}
public class TxtFileProcessor extends AbstractFileProcessor {
@Override
protected void processData(File file) {
System.out.println("TXT 데이터를 처리합니다.");
// ... 실제 TXT 처리 로직
}
}
반복되고 명확한 패턴을 가지고 있다면 오직 핵심 비즈니스 로직만 분리해서 처리하는 방법이다.
스프링과 함께하는 디자인 패턴
방금까지 전략, 빌더, 팩토리, 어댑터, 템플릿을 살펴봤다.
이번에는 방금 본 디자인 패턴을 스프링 관점에서 한번 다시 살펴보자.
일단 스프링의 핵심 철학은 '제어의 역전(IoC)', '의존성 주입(DI)', '관점 지향 프로그래밍(AOP)' 이다.
달리 말하면 '냅둬 엄마가 해줄게', '말해 엄마가 줄게', '공부나 해 집안일 하지말고'다.
의존성 주입을 통한 전략 패턴
아까 예시 코드 일부를 살펴보자.
@Service
public class PaymentService {
private final Map<String, PaymentStrategy> strategies;
@Autowired
public PaymentService(Map<String, PaymentStrategy> strategies) {
this.strategies = strategies;
}
}
Autowired주목, 자동 주입을 한다.
Map<String, PaymentStrategy> strategies가 주입될 때, Spring 컨테이너는 @Component가 붙은 PaymentStrategy의 모든 구현체(Bean)을 자동으로 주입해서 Map에 담는다.
즉 우리는 map에다가 직접 전략을 담지 않았던 이유가 바로 구현체에 달린 @Component 덕분이다.
팩토리 패턴과 Spring @Configuration
다시 아까 예제코드를 보자
@Component
public class UploaderFactory {
public Uploader getInstance(String storageType) {
if ("S3".equals(storageType)) {
return new S3Uploader();
} //...
}
}
우리는 아까 if-else 지옥 예시를 들면서 팩토리 공장에서도 if-else를 넣었다.
하지만 결국 프레임워크 속에서 노는데, 근본적으로 if-else를 넣는 구조가 cool 하지는 않는다.
팩토리란 '분기'와 '생성'이다.
그리고 스프링에서는 IoC 컨테이너 자체를 거대한 팩토리로 활용한다.
그럼 스프링한테 우리의 공장을 인식시켜야하는데 바로 @Configuration이다.
스프링은 @Configuration이 달린 클래스를 자동으로 공장으로 인식하고, 내부에서 @Bean을 찾는다.
@Configuration
public class UploaderConfig {
// 'dev' 프로파일이 활성화되면 이 '팩토리 메서드'가 실행된다.
@Bean
@Profile("dev")
public Uploader localUploader() {
return new LocalUploader();
}
// 'prod' 프로파일이 활성화되면 이 '팩토리 메서드'가 실행된다.
@Bean
@Profile("prod")
public Uploader s3Uploader() {
return new S3Uploader();
}
}
Spring 실행 환경에 맞춰서 @Bean이 구현체를 통해 객체를 생성, 설정하고 반환하게 되는 것이다.
이것으로 if-else 지옥은 흔적도 없이 설정 레벨이 되어 우리의 패턴속에 사라지게 되었다.
여기서부턴 크게 할말이 없다.
Builder 패턴이야 Lombok 통해서 자주 사용되고 직관적이기도 하고,
디자인 패턴의 철학은 아까 충분히 이야기 했기 때문이다.
가령 예를들어, Spring 에서도 Builder를 여러가지 지원하는데
@Autowired
public MyApiClient(RestTemplateBuilder builder) {
this.restTemplate = builder
.setConnectTimeout(Duration.ofSeconds(5))
.setReadTimeout(Duration.ofSeconds(5))
.build(); // 빌더 패턴의 마무리는 build()
}
이름에 Builder 붙은 애들은 . 찍고 메소드 호출하면서 복잡한 설정을 체이닝 방식으로 제공해준다.
어댑터야 그냥 '호환 안되면 중앙에 뭐 두고 쓰자' 개념이고
Spring MVC 자체가 하나의 적용 사례 같은거다.'
기존 서블릿 시절부터 쓰이던 DispatcherServlet은 요청 처리할 실행자를 찾으면서
과거의 HttpRequestHandler 방식으로 호출하다가, 나중에 새롭게 @Controller가 추가 됬지만,
누가 실행할지는 HandlerAdapter(어댑터)한테 맡기고, '끝나고 결과만 줘' 라고 하면서 요청 처리한다.
우리가 고민 안해도 Spring은 이미 설계적으로 디자인 패턴 기반으로 동작되기에 우리의 비즈니스 로직에서 어떻게 패턴을 가져갈지, if-else 지옥을 벗어날 지 고민하면 된다는 것이다.
그 외의 Spring에서 다루는 디자인 패턴 중 중요하다고 생각하는거
하나만 돌려 쓰는 싱글톤 + DI
필요할 때마다 만들면 비용이니, 생성 한번 했으면, 만든거 가지고 필요한 곳에서 가져다 쓰는 것이다.
우리가 맨날 @Service, @Repository로 빈 등록하고, @Autowired로 주입받는 과정을 생각해보자.
객체를 새로 생성이 아닌 주입이기에 객체는 결국 하나다.
Spring은 내부에 거대한 싱글톤 레지스트리를 가진다.
우리가 어노테이션 붙이면서 클래스를 정의하면, Spring은 하나의 인스턴스만 생성해서 보관한다.
그리고 @Autowired를 통해서 호출되면, 해당 타입의 싱글톤 빈을 찾아다 자동으로 주입해준다.
프록시 패턴
일단 Spring은 AOP(관점 지향 프로그래밍)을 구현하기 위해 원본 객체를 감싼 프록시 객체를 사용한다.
지루하고 반복되는 영역은 공통 처리로 넘기고, 중요한 로직에만 집중하도록 도와준다는 것인데
대표적으로 @Transcational이 있다.
예전에 우리 JDBC 직접 연결하고, DataSource관리하던 시절을 기억하는가,
커넥션 열어!, 커밋 설정해!, try-catch 걸고 commit, rollback 관리해!
지금은? @Transcational 어노테이션 하나 달아주면 알아서 해준다.
@Service
public class MyService {
@Transactional // 트랜잭션 관련해서 복잡한건 애가 알아서 해줌
public void myBusinessLogic() {
// 핵심 기능만 만들어라
repository.save("데이터1");
repository.save("데이터2"); // 여기서 예외 발생 시, 프록시가 알아서 롤백해 줌
}
}
여기까지만 보면 템플릿 메서드 같은데,
중요한건 원본 객체를 감싼 프록시 객체다.
템플릿이 부모 자식 관계라서 상속이 발생한다면,(객체가 하나)
프록시는 프록시-타겟 관계라서 핵심 처리하는 원본이랑 프록시가 합성되는 구조다.(둘이서 퓨전)
원본 객체 = 핵심 로직 구현!
프록시 객체 = 원본 객체 가져다가 필드로 합성하고, 자기 복잡한 처리 중간에 원본 객체 필드를 호출해서 처리를 위임한다.
이걸 좀더 상세히 설명하고자 하니 내가 배움에 한계가 있어서 일단 이 정도로 설명하고 넘어가겠다.
필요하다면 직접 배워보길 추천한다.
Spring MVC를 쓴다면 이미 프론트 컨트롤러를 쓰고 있다.
아까 설명한 DispatcherServlet이 기억 날 것이다.
우리는 요청 받는다고 수많은 @Controller를 정의한다.
하지만 우리의 요청과 해당 컨트롤러가 바로 매핑되는 것이 아닌,
정문(프론트)에는 단 하나의 문지기가 존재해 먼저 요청을 받아들인다.
정문이 하나라면? 보안이나, 검사, 전처리는 해당 포인트에서 처리하면 된다.
DispatcherServlet은 공통 작업을 모두 통과한 요청만 받아서 적절한 @Controller에게 작업을 위임한다.
옵저버(스타크래프트를 안다면...)
나: 님 건물 지을때마다 채팅쳐서 알려주세요
(옵저버 이후)
나: 아 ㅋㅋ 스타게이트 올리시는구나
아무튼 우리 시스템에서 뭔가 이벤트가 생겼을 때 연관된 작업 사이에 결합도를 줄이기 위한 방법이다.
결제 시스템 생각해보자.
결제 됨 -> ㅇㅋ 재고 감소할게 -> 응 알림 보낼게
이게 하나의 비즈니스 로직에서 이루어진다면, 책임이 너무 많아진다.
결제, 재고, 알림 각각의 영역에서 만약 이벤트 리스너(옵저버)들이 있고 소식을 감지할 수 있다면,
각각의 로직에서는 "나 ~~했음!" 이라고 방송(이벤트 발행) 한번 때리면 된다.
누가 듣는지는 알바 아니고, 필요한 애가 알아서 듣고 처리하겠지 라는 마음가짐이다.
마무리
디자인 패턴 어렵다.
정리하자니 시간이 너무 살살 녹기도 하고,
글을 써내리는 것도 학습의 일환이지만, 학습 범위가 작아지는 느낌이라 좀 더 글 쓰는 방법을 강구해봐야겠다.