스프링에서 @Autowired 애너테이션을 이용한 의존성 주입은 타입 기반으로 작동합니다. 의존성 주입이 필요할 경우 스프링 컨테이너는 타입 기반으로 빈(bean)을 찾는다는 말입니다. @Autowired 애너테이션은 일치하는 타입이 찾아 이를 주입하고, 만약 해당 빈을 찾지 못하면 NoSuchBeanDefinitionException 에러를 발생시킵니다.
인터페이스로 선언된 멤버 변수를 주입 받는 애플리케이션 서비스
@Service
@RequiredArgsConstructor
public class NotificationService {
private final NotificationChannel notificationChannel;
}
인터페이스
public interface NotificationChannel {
void notify(Account account, String message);
}
구현체
public class EmailNotificationChannel implements NotificationChannel {
@Override
public void notify(Account account, String message) {
// account 에 등록된 email 주소로 message 를 전송합니다.
}
}
이때 NotificationService 컴포넌트의 멤버 변수 notificationChannel 에는 EmailNotificationChannel 빈이 주입됩니다. 왜냐하면 당연하게도 EmailNotificationChannel 빈이 NotificationChannel 타입이기 때문입니다. 이를 타입 기반으로 동작한다고 합니다.
이는 스프링을 사용하는 개발자들에게 너무나도 당연한 내용입니다. 그래서 굳이 이러한 이야기를 재차 언급하는 이유가 궁금할 수 있습니다. 그런데 이런한 기능을 이용해 어떤 일을 할 수 있는지 알게 되면 스프링을 조금 재밌게 받아들일 수 있습니다.
스프링이 이렇게 동작할 경우 스프링 컨테이너를 초기화하는 과정에서 동작이 모호해질 수 있는 지점이 하나 있습니다. 예를 들어, 주입하려는 타입이 추상 타입일 경우 이 추상 타입을 상속하거나 구현하는 빈이 여러 개일 수 있습니다. 그럴 때 스프링은 어떻게 동작할까요? 어떤 빈을 주입할지 어떻게 찾을까요?
이처럼 어떤 빈을 주입해야 할지 선택할 수 없는 상황이 생길 경우 스프링은 NoUniqueBeanDefinitionException 에러를 던집니다. 그래서 이를 극복하기 위해 @Qualifier, @Primary 같은 애너테이션이 제공되는데, 두 애너테이션 모두 주입할 수 있는 빈이 여러 개일 때 사용할 수 있는 애너테이션입니다. @Qualifier("emailNotificationChannle") 처럼 주입하려는 빈을 지정할 수 있습니다. @Primary 애너테이션을 이용하면 타입이 일치하는 빈이 여러 개일 때 특정 빈을 가증 우선해서 주입하게 할 수 있습니다.
그런데, 만약 주입 받으려는 변수가 다음과 같이 List<추상타입> 이라면 어떻게 될까요?
@Service
@RequiredArgsConstructor
public class NotificationService {
// notificationChannels 을 구현하는 모든 빈이 List 의 요소르 들어감
private final List<NotificationChannel> notificationChannels;
}
스프링이 List 타입의 멤버 변수를 주입하려 할 때는 타입과 일치하는 모든 스프링 빈을 찾아 List의 요소로 넣어주는 방식으로 처리합니다.
스프링의 이러한 동작 원리를 이해하면 사용할 수 있는 테크닉이 하나 있습니다. 우선 NotificationService 컴포넌트가 다음과 같이 작성되었다고 가정합시다.
@Service
@RequiredArgsConstructor
public class NotificationService {
private final EmailNotificationChannel emailNotificationChannel;
private final SlacklNotificationChannel slacklNotificationChannel;
private final ChatNotificationChannel chatNotificationChannel;
public void notify(Account account, String message) {
switch (account.getNotificationType()) {
case EMAIL:
emailNotificationChannel.notify(account, message);
break;
case SLACK:
slacklNotificationChannel.notify(account, message);
break;
case CHAT:
chatNotificationChannel.notify(account, message);
break;
default:
throw new IllegalStateException(account.getNotificationType());
}
}
}
이러한 유형의 코드는 확장에 닫혀 있다고 볼 수 있습니다. 이유는 새로운 요구사항이 들어왔을 때 확장에 취약하기 때문입니다. 예를 들어, 여기에 푸시 알림을 지원해야 하는 새로운 요구사항이 들어왔다고 가정해 봅시다. 그렇다면 코드는 다음과 같이 두분의 코드 작성과 수정이 필요합니다.
public class PushNotificationChannel implements NotificationChannel {
@Override
public void notify(Account account, String message) {
// account 에 등록된 email 주소로 message 를 전송합니다.
}
}
switch (account.getNotificationType()) {
case EMAIL:
emailNotificationChannel.notify(account, message);
break;
case SLACK:
slacklNotificationChannel.notify(account, message);
break;
case CHAT:
chatNotificationChannel.notify(account, message);
break;
case PUSH:
// 로직 추가
default:
throw new IllegalStateException("Unexpected value: " + account.getNotificationType());
}
위 와 같은 경우는 OCP 위반 사례입니다. ( 변경에는 닫혀있고, 확장에는 열려 있어야 한다)
그런데 코드가 처음부터 다음과 같은 형태로 작성돼 있었다면 어땠을까요?
@Service
@RequiredArgsConstructor
public class NotificationService {
private final List<ChatNotificationChannel> chatNotificationChannels;
public void notify(Account account, String message) {
for (ChatNotificationChannel notificationChannel : chatNotificationChannels) {
if ( notificationChannel.supports(account) ) {
notificationChannel.notify(account, message);
return;
}
}
}
}
@Component
public class ChatNotificationChannel implements NotificationChannel {
@Override
public void notify(Account account, String message) {
// account 에 등록된 email 주소로 message 를 전송합니다.
}
public boolean supports(Account account) {
return account.getNotificationType() == ChannelTypeEnum.CHAT;
}
}
NotificationChannel 인터페이스를 구현하는 각 구현체에 supports 메소드를 추가했습니다. 그럼 설계상으로 어떤 변화가 생기게 됩니다. 이제 새로운 알림 같은 요구사항이 생겨도 NotificationService 컴포넌트를 수정할 필요가 없어집니다. OCP를 지킬 수 있게 된 것입니다. 예를 들어, 이전 예제와 마찬가지로 시스템이 푸쉬 알림을 지원하기 위한 채널을 새롭게 지원해야 한다고 가정하면 개발자가 할 일은 간단합니다. NotificationChannel 인터페이스를 상속 받는 새로운 푸쉬 알람용 컴포넌트를 만들고 이를 스프링 빈으로 등록하기만 하면 됩니다.
새롭게 추가한 PushNotificationChannel 컴포넌트는 NotificationChannel 타입이므로 NotificationService 컴포넌트의 List<NotificationChannel> notificationChannels 멤버 변수에 자동으로 주입되게 됩니다. 그러므로 NotificationService 컴포넌트 쪽을 수정하지 않고도 푸쉬 알림을 지원할 수 있게 됩니다. 코드 수정을 최소화 하고 시스템 동작을 확장할 수 있게됩니다.
'Spring, Springboot' 카테고리의 다른 글
자가 호출 (0) | 2024.07.16 |
---|---|
서비스 ( Service ) (1) | 2024.07.10 |
Spring Cloud OpenFeign (1) | 2024.02.07 |
전역 에러 처리 (0) | 2024.01.30 |
CORS 이해와 설정 (0) | 2024.01.29 |