
해당 글에서 등장하는 코드는 Github에서 확인할 수 있습니다.
GitHub - CODe5753/flexible-notifier-matching: https://imksh.com/133 에 사용된 예제 코드
https://imksh.com/133 에 사용된 예제 코드. Contribute to CODe5753/flexible-notifier-matching development by creating an account on GitHub.
github.com
프롤로그
개발을 하다 보면 제네릭(Generic)과 다형성(Polymorphism)을 자연스럽게 조합해 사용하게 됩니다.
특히, Spring 환경에서는 제네릭을 사용한 의존성 주입(DI)을 활용하여 다양한 객체를 동적으로 주입할 수 있는데요.
하지만 여기에는 예상치 못한 제네릭과 런타임 타입 매칭의 충돌이 발생할 수 있습니다.
이번 글에서는 실무에서 겪었던 일이며, "Notifier라는 인터페이스를 제네릭하게 만들었는데, 왜 자식 DTO를 처리하지 못할까?"라는 문제를 해결하는 과정을 살펴보면서, 유연한 설계를 어떻게 할 수 있는지 고민해 볼 수 있습니다.
이번 아티클은 아래 독자에게 유용할 수 있습니다.
- Spring Boot에서 타입 기반 매칭을 활용하는 분
- 유지보수성이 높은 코드 구조를 고민하는 분
정확한 타입 매칭과 유연한 타입 매칭의 차이를 이해하고, 적절한 구현 방식을 선택하는 과정을 배우고 싶은 분들에게 추천합니다.
문제상황
📌 문제가 발생했던 AS-IS 코드
위 사진처럼, 고객의 결제가 발생하고 결제가 완료되면 완료되었다는 알림을 고객에게 보내는데요.
이 때 결제 방식이나 기능에 따라 UMS인지 어플을 통한 알림인지가 달라집니다.
UMS(Unified Messaging System)는 통합메시징 서비스를 뜻합니다. SMS, MMS, 이메일, 카톡 알림 등을 포함하여 이야기합니다.
코드를 하나씩 살펴볼게요.
제네릭을 이용한 Notifier 인터페이스와 구현체
public interface Notifier<T> {
void send(T request);
}
이 인터페이스는 특정 타입의 DTO를 처리하는 역할을 합니다.
결국 고객에게 UMS던 앱 알림이던, 알림을 보내는 기능을 구현하려는거라 Notifier라는 인터페이스와 발송이라는 기능을 명시해 두었습니다.
알림을 보내기 위한 DTO는 자체적으로 구현하는 것이 아니라 라이브러리에서 제공받아 사용하기 때문에 유연한 구조 설계가 필요했거든요.
알림을 보내는 구조가 다른 새로운 DTO가 추가되더라도 구현체만 구현하기 위함이었습니다.
@Slf4j
@Component
public class UmsNotifier implements Notifier<UmsReqDto> {
@Override
public void send(UmsReqDto request) {
log.info("[UMS Notifier] Sending message: " + request.getMessage());
// 문자 발송 로직
}
}
@Slf4j
@Component
public class AppNotifier implements Notifier<AppNotiReqDto> {
@Override
public void send(AppNotiReqDto request) {
log.info("[App Notifier] Sending message: " + request.getMessage());
// 앱 알림 발송 로직
}
}
위처럼 구현해 두면, UMS 발송을 위해서는 Notifier의 구현체를 호출하지 않아도 됩니다.
메서드 파라미터로 넘기는 객체의 타입에 따라 알아서 클래스가 지정이 될 거거든요.
아래에 코디네이터를 보여드리면서 설명을 이어갈게요.
Notifier 빈을 주입해서 호출해 주는 유연한 코디네이터
@Component
public class NotifierCoordinator {
private final Map<Class<?>, Notifier<?>> notifierMap;
// 빈에 등록된 Notifier 구현체를 Map에 등록
public NotifierCoordinator(List<Notifier<?>> notifiers) {
this.notifierMap = notifiers.stream()
.collect(Collectors.toMap(
this::getGenericType, // 제네릭 타입 자동 추출
Function.identity()
));
}
@SuppressWarnings("unchecked")
public <T> void send(T request) {
Notifier<?> notifier = notifierMap.get(request.getClass());
if (notifier != null) {
Notifier<T> selectedNotifier = (Notifier<T>) notifier;
selectedNotifier.send(request);
return;
}
throw new IllegalArgumentException("No suitable notifier found for " + request.getClass().getSimpleName());
}
private Class<?> getGenericType(Notifier<?> notifier) {
// Notifier의 제네릭 타입을 가져옴
}
}
NotifierCoordinator는 다양한 DTO 타입을 처리할 수 있도록 Notifier를 동적으로 매칭하는 역할을 합니다.
DTO 타입에 관계없이 Notifier를 찾아 실행할 수 있는 구조에요.
실제 사용은 이렇게
등록된 Dto라면 아래 코드처럼 코디네이터의 send만 호출하면 서비스 코드에서는 어떤 Notifier를 호출해야 하는지 신경 쓰지 않아도 됩니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {
private final NotifierCoordinator notifierCoordinator;
public void payment() {
log.info("payment method");
// Do something...
notifierCoordinator.send(new UmsReqDto("memberId", "message"));
}
}
문제 발생: 라이브러리의 업데이트, 그리고 새롭게 생성된 DTO
어느 날, 평화로운 오후였던 것 같아요..
알림 발송을 위한 DTO는 자체적으로 구현하는 것이 아니라 라이브러리에서 구현되어 오는 것이라 말씀드렸는데요, 얼마 전 새로운 DTO가 추가되며 새로 애플리케이션을 구동할 때 런타임 에러가 발생했습니다.
바로, UmsReqDto를 상속받는 자식 클래스가 등장했거든요.
public class SmsReqDto extends UmsReqDto {
public SmsReqDto(String message, String recipient) {
super(message, recipient);
}
}
public class EmailReqDto extends UmsReqDto {
private String emailSubject;
public EmailReqDto(String message, String recipient, String emailSubject) {
super(message, recipient);
this.emailSubject = emailSubject;
}
public String getEmailSubject() {
return emailSubject;
}
}
"코디네이터에서 부모인 Notifier<UmsReqDto>를 잘 찾아줄테니 작동 잘 되겠다~" 라고 생각했었으나, 사실 그렇지 않았습니다.
클라이언트에서 SmsReqDto를 사용하는 코드만 구현하고 앱을 구동했으나 Notifier에서 빈을 찾지 못하는 문제가 있어 앱 자체가 실행이 안됐습니다.
문제 분석
왜 Notifier<UmsReqDto(부모)>가 Notifier<SmsReqDto(자식)>를 처리하지 못할까?
자식 객체니까 당연히 처리되어야 하지 않나.. 라고 생각했고 제네릭의 공변&불변 문제인가? 라고 생각했었지만, 이건 크게 연관 없는 개념이었고, 결국 아래와 같은 이유라는 것을 알게 되었습니다.
Map<Class<?>, Notifier<?>>의 키 조회 방식
현재 코디네이터의 notifierMap에서 클래스 타입(Class<?>)을 키로 사용하고 있기 때문에,
자식 클래스가 부모 클래스로 처리되려면 부모-자식 관계를 고려한 탐색을 해야 하지만, Map.get()은 정확한 키 매칭만 수행하기 때문에 실패하는 거였어요.
notifierMap.get(request.getClass()); // 정확한 매칭만 가능 (부모-자식 관계 인식 불가)
코디네이터에는 위와 같은 코드로 클래스를 찾아서 가져오고 있었습니다.
정리하자면,
➡ SmsReqDto.class와 UmsReqDto.class는 Map<Class<?>, ?>에서 별개의 키로 취급됨
➡ SmsReqDto.class로 조회하면 UmsReqDto.class 키에 매핑된 Notifier를 찾을 수 없음
즉, 유연성이 부족한 원인은 정확한 타입 매칭만 허용했기 때문이라고 볼 수 있어요.
코드 개선
부모-자식 관계의 클래스도 허용되도록 코드를 변경하여, 유연성을 확보해야겠다는 결론을 내렸습니다.
아래처럼 코드를 변경해 줬어요.
@Slf4j
@Component
public class NotifierCoordinator {
// 생략
@SuppressWarnings("unchecked")
public <T> void send(T request) {
for (Map.Entry<Class<?>, Notifier<?>> entry : notifierMap.entrySet()) {
if (entry.getKey().isAssignableFrom(request.getClass())) {
Notifier<T> selectedNotifier = (Notifier<T>) entry.getValue();
selectedNotifier.send(request);
return;
}
}
throw new IllegalArgumentException("No suitable notifier found for " + request.getClass().getSimpleName());
}
// 생략
}
isAssignableFrom()의 동작 방식
isAssignableFrom() 메서드는 클래스 간의 상속 관계를 활용하여 타입을 비교하는 기능을 제공합니다.
System.out.println(UmsReqDto.class.isAssignableFrom(SmsReqDto.class)); // true
System.out.println(SmsReqDto.class.isAssignableFrom(UmsReqDto.class)); // false
A.class.isAssignableFrom(B.class)는 B가 A의 하위 클래스라면 true를 반환하는 것이죠.
즉, UmsReqDto.class.isAssignableFrom(SmsReqDto.class)는 true가 되어 SmsReqDto를 UmsReqDto의 Notifier로 매칭할 수 있는 거죠.
결론
이번 개선을 통해, 타입 매칭을 보다 유연하게 만들면서도 유지보수성을 높이는 방향으로 수정할 수 있었습니다.
비교 항목 | 정확한 타입 매칭 | 유연한 타입 매칭 |
요청 객체 타입 | Map.get()으로 정확히 일치하는 타입만 조회 | isAssignableFrom()을 사용하여 부모-자식 관계까지 탐색 |
자식 클래스 지원 | ❌ (자식 클래스는 부모 Notifier를 찾을 수 없음) | ✅ (부모 Notifier를 사용할 수 있음) |
성능 | O(1) 조회 (빠름) | O(N) 탐색 (느릴 가능성 있음) |
유지보수성 | 제한적 (각 타입별 Notifier가 필요) | 유연함 (부모 타입 Notifier로 범용 적용 가능) |
성능적 관점에서는 사실 서비스에 영향을 줄만한 정도는 아니어서 크게 고려하진 않았습니다.
결국, 수많은 사람과 협업하고 하나의 레포에서 코드를 구현하는 입장에서, 의도한 DTO에 맞게 코디네이터에서 Notifier를 유연하게 찾으면 되는 것이고, Notifier가 올바른 메시지를 발송하기만 하면 되니까요.
지금 제 업무는 같은 회사 개발자뿐 아니라, 다양한 회사의 개발자(SI 업체)와 협업하고 있어요.
Java, SpringBoot가 생소한 동료들부터 개발보다는 도메인 지식이 뛰어난 분들도 계시거든요.
이런 환경에서는 구체적으로 "이 땐 이걸 호출하세요 저 땐 저걸 호출하세요"라는 가이드를 드리는 것보다, "DTO만 명시해두고 그냥 호출하세요!" 라고 하는 것이 문제가 발생해도 제가 직접 제어하기도 쉽고, 앞단 비즈니스에만 집중해서 구현할 수 있도록 환경을 마련해 주는 것이 프로덕트 런칭을 위해 빠른 길이라는 생각을 했습니다.
이번 개선을 통해 단순히 코드의 동작 방식만이 아니라, 협업 환경에서 유지보수성과 확장성을 어떻게 고려해야 하는지도 다시 한번 고민해볼 수 있었던 계기였습니다.
'Tech > Java&Spring' 카테고리의 다른 글
String 함수 사용을 조심해야 하는 이유 (20) | 2024.03.31 |
---|---|
Java10 무분별한 var를 지양해야 하는 이유 (2) | 2023.06.18 |
멀티스레드 분산 환경에서의 로깅(2) (0) | 2023.06.04 |
멀티스레드 분산 환경에서의 로깅(1) (0) | 2023.05.21 |
JPA saveAll이 Bulk INSERT 되지 않았던 이유 (3) | 2023.04.05 |
인프런 지식공유자로 활동하고 있으며 MSA 전환이 취미입니다. 개발과 관련된 다양한 정보를 몰입감있게 전달합니다.