계약 정의를 위한 인터페이스 사용
자바에서 인터페이스는 특정 책임을 선언하는 데 사용하는 추상 구조이다. 인터페이스를 구현하는 객체는 이 책임을 정의해야 한다. 동일한 인터페이스를 구현하는 여러 객체는 해당 인터페이스가 선언한 책임을 다른 방식으로 정의할 수 있다.
인터페이스는 '무엇이 발생해야 하는지(필요 대상)'를 지정하는 반면, 인터페이스를 구현하는 모든 객체는 '어떻게 그것이 발생해야 하는지(발생 방법)'를 지정한다고 볼 수 있다.
구현 분리를 위해 인터페이스 사용
우리는 목적지로 이동하려고 우버 같은 차량 공유 앱을 사용한다. 일반적으로 차량 공유 서비스를 사용할 때는 차량 외관이나 운전자가 누구인지 신경 쓰지 않으며 그저 목적지에 가기만 하면 된다. 차량 공유 앱은 인터페이스이다.
고객은 자동차나 운전자를 요청하는 것이 아니라 이동을 요청한다. 서비스를 제공할 수 있는 차를 가진 드라이버라면 누구나 고객 요청에 응할 수 있다. 고객과 드라이버는 앱(인터페이스)으로 분리되어 있어 고객은 차량이 요청에 응답하기 전에는 드라이버가 누군인지, 어떤 차량이 자신을 태울지 알 수 없으며, 드라이버도 누구를 위해 서비스를 제공하는지 알 수가 없고 알 필요도 없다.
이와 같은 비유로 자바 객체와의 관계에서 인터페이스가 어떤 역할을 하는지 추론할 수 있다.
배송 앱에서 배송할 패키지의 세부 정보를 인쇄해야 하는 객체를 구현한다고 가정해 보자. 인쇄된 세부 정보는 목적지 주소별로 정렬되어야 한다. 세부 정보를 인쇄하는 객체는 배송 주소별로 패키지를 정렬하는 책임을 다른 객체에 위임해야 한다.
public class DeliveryDetailsPrinter{
private SorterByAddress sorter;
public DeliveryDetailsPrinter(SorterByAddress sorter){
this.sorter = sorter;
}
public void printDetails() {
sorter.sortDetails();
// 배송 세부 정보 출력
}
}
- DeliveryDetailsPrinter 객체는 배송 주소별로 배송 세부 정보를 정렬하는 책임을 SortByAddress라는 다른 객체에 위임한다.
이 클래스 디자인을 그대로 유지하면 이 기능을 변경해야 할 때 어려움을 겪을 수 있다. 나중에 인쇄된 세부 정보 순서를 발신인 이름 순으로 새롭게 변경한다고 가정해보면 SortByAddress 객체를 새로운 책임을 구현하는 다른 객체로 대체해야 하지만, 정렬 책임을 사용하는 DeliveryDetailsPrinter 객체도 변경해야 한다.
이 설계 문제는 DeliveryDetailsPrinter 객체에서 필요한 것과 필요한 방법을 모두 지정하기 때문에 발생한다. 앞서 설명한 것처럼 객체는 필요한 것만 지정하고 필요한 것이 어떻게 구현되는지는 전혀 알지 못해도 된다. 물론 이를 위해 인터페이스를 사용한다.
두 객체를 분리하려고 Sorter 인터페이스를 도입했다. DeliveryDetailsPrinter 객체는 SortByAddress를 선언하는 대신 Sorter가 필요하다고만 지정했다. DeliveryDetailsPrinter는 특정 구현에 종속되지 않고 이 인터페이스를 구현하는 모든 객체를 사용할 수 있다.
public interface Sorter {
void sortDetails();
}
public class DeliveryDetailsPrinter{
private Sorter sorter;
public DeliveryDetailsPrinter(Sorter sorter){
this.sorter = sorter;
}
public void printDetails() {
sorter.sortDetails();
// 배송 세부 정보 출력
}
}
- Sorter 인터페이스의 어떤 구현체라도 사용할 수 있으며, 구현을 변경하더라도 이 책임을 사용하는 객체를 변경할 필요가 없다.
시나리오 요구 사항
팀 업무 관리용 앱을 구현한다고 가정해 보자. 이 앱의 기능 중 하나는 사용자가 업무에 대한 댓글을 남길 수 있도록 하는 것이다. 사용자가 댓글을 게시하면 해당 댓글은 데이터베이스 등 어딘가에 저장되고 앱은 설정된 특정 주소로 이메일을 보낸다.
- 이 기능을 구현하려면 객체를 설계하고 올바른 책임과 추상화를 찾아야 한다.
프레임워크 없이 요구 사항 구현
1. 먼저 구현할 객체(책임)을 식별해야 한다.
표준적인 실제 애플리케이션에서는 사용 사례를 구현하는 객체를 일반적으로 서비스(service)라고 한다. '댓글 게시' 사용 사례를 구현하는 서비스가 필요하며, 이 객체 이름은 'CommentService'로 지정한다.
요구 사항을 다시 분석해 보면, 사용 사례가 댓글 저장과 댓글을 이메일로 보내는 두 가지 행동으로 구성되어있음을 알 수 있다. 이 두 행동은 상당히 상이하므로 서로 다른 책임으로 간주하여 두 개의 객체로 구현해야 한다.
데이터베이스와 직접 작업하는 개체가 있을 때 일반적으로 이런 객체 이름을 리포지터리(repository)라고 한다. 때로는 이런 객체를 데이터 액세스 객체(DAO)라고도 한다. 댓글 저장 기능을 구현하는 객체 이름을 'CommentRepository'로 지정한다.
마지막으로 실제 앱에서는 앱 외부와 통신을 담당하는 객체를 구현할 때 이런 객체 이름을 프록시(proxy)로 지정하므로 이메일 전송을 담당하는 객체 이름을 'CommentNotificationProxy'로 지정한다.
2. 인터페이스를 사용하여 구현을 분리해야 한다.
지금 CommentRepository는 데이터베이스로 댓글을 저장할 수 있다. 그러나 향후에는 다른 기술이나 외부 서비스를 사용하도록 변경해야 할 수도 있다. CommentNotificationProxy 객체도 마찬가지다. 지금은 이메일로 알림을 보내지만 향후 버전에서는 다른 채널로 댓글 알림을 보내야 할 수도 있다.
의존성을 변경해야 할 때 의존성을 사용하는 객체까지 변경할 필요가 없도록 CommentService를 의존성 구현과 확실하게 분리해야 한다.
CommentService 객체는 CommentRepository 및 CommentNotificationProxy 인터페이스가 제공하는 추상화에 의존한다. DBCommentRepository와 EmailCommentNotificationProxy 클래스는 이 인터페이스를 구현한다. 이런 설계는 '댓글 게시' 사용 사례의 구현을 의존성에서 분리하고 향후 개발에서 애플리케이션을 쉽게 변경할 수 있도록 한다
CommentRepository 인터페이스 정의
public interface CommentRepository {
void storeComment(Comment comment);
}
CommentRepository 인터페이스 구현하기
public class DBCommentRepository implements CommentRepository {
@Override
public void storeComment(Comment comment) {
System.out.println("Storing comment: " + comment.getText());
}
}
CommentNotificationProxy 인터페이스 정의
public interface CommentNotificationProxy {
void sendComment(Comment comment);
}
CommentNotificationProxy 인터페이스 구현
public class EmailCommentNotificationProxy implements CommentNotificationProxy {
@Override
public void sendComment(Comment comment) {
System.out.println("Sending notification for comment: " + comment.getText());
}
}
CommentService 객체 구현
public class CommentService {
// 클래스 속성으로 의존성 두 개 정의
private final CommentRepository commentRepository;
private final CommentNotificationProxy commentNotificationProxy;
// 객체가 생성될 때 생성자의 매개변수로 의존성 제거
public CommentService(CommentRepository commentRepository,
CommentNotificationProxy commentNotificationProxy) {
this.commentRepository = commentRepository;
this.commentNotificationProxy = commentNotificationProxy;
}
// '댓글 저장'과 '알림 전송' 책임을 의존성에 위임하는 사용 사례 구현
public void publishComment(Comment comment) {
commentRepository.storeComment(comment);
commentNotificationProxy.sendComment(comment);
}
}
public class Main {
public static void main(String[] args) {
// 의존성 객체들 생성
var commentRepository = new DBCommentRepository();
var commentNotificationProxy = new EmailCommentNotificationProxy();
// 의존성을 제공하여 서비스 클래스 인스턴스를 생성
var commentService = new CommentService(commentRepository,
commentNotificationProxy);
// '댓글 게시' 사용 사례를 위해 매개변수로 전달할 댓글 인스턴스 생성
var comment = new Comment();
comment.setAuthor("Laurentiu");
comment.setText("Demo comment");
// '댓글 게시' 사용 사례 호출
commentService.publishComment(comment);
}
}
추상화와 함께 의존성 주입
스프링 컨텍스트에 포함될 객체 정하기
스프링 컨텍스트에 객체를 추가하는 가장 큰 이유는 스프링이 객체를 제어하고 프레임워크가 제공하는 기능으로 객체를 더욱 보강할 수 있도록 하려는 것이다.
객체가 컨텍스트로부터 주입해야 하는 의존성이 있거나 그 자체가 의존성인 경우 해당 객체를 스프링 컨텍스트에 추가해야 한다.
구현을 살펴보면 의존성이 없고 그 자체로 의존성이 아닌 유일한 객체는 Comment라는 것을 알 수 있다. 이 클래스 설계에 있는 다른 객체들은 다음과 같다.
- CommentService: CommentRepository와 CommentNotificationProxy 의존성 두 개를 갖고 있다.
- DBCommentRepository: CommentRepository 인터페이스를 구현하며 CommentService의 의존성이다.
- EmailCommentNotificationProxy: CommentNotificationProxy 인터페이스를 구현하며 CommentService의 의존성이다.
프레임워크가 관리할 필요도 없는데 스프링 컨텍스트에 객체를 추가하면 앱에 불필요한 복잡성이 추가되어 앱의 유지 관리가 어려워지고 성능이 저하되기 때문에 Comment 인스턴스는 추가하지 않는다.
스프링이 인터페이스를 생성하고 이런 인스턴스를 컨텍스트에 추가하는 데 필요한 클래스에 스테레오타입 애너테이션을 사용한다. 인터페이스나 추상 클래스는 인스턴스화할 수 없기 때문에 스테레오타입 애너테이션을 추가하는 것은 의미없다. 구문상으로 이 작업을 수행할 수 있지만 유용하지 않다.
// @Component로 클래스를 표시하면 스프링이 클래스의 인스턴스를 만들고 이를 빈으로 추가하도록 지시한다.
@Component
public class DBCommentRepository implements CommentRepository {
@Override
public void storeComment(Comment comment) {
System.out.println("Storing comment: " + comment.getText());
}
}
이처럼 EmailCommentNotificationProxy와 CommentService에도 @Component 애너테이션을 추가한다.
스프링은 속성이 인터페이스 타입으로 정의된 것을 인식하고 컨텍스트에서 이런 인터페이스를 구현한 클래스로 생성된 빈을 검색할 수 있다. 클래스에는 생성자가 하나만 있기 때문에 @Autowired 애너테이션은 선택 사항이다.
@Configuration // 구성 클래스임을 나타낸다.
@ComponentScan(
basePackages = {"proxies", "services", "repositories"}
)
public class ProjectConfiguration {
}
- @ComponentScan 애너테이션을 사용하여 스프링에 @Component로 애너테이션된 클래스를 찾을 위치를 알려준다.
public class Main {
public static void main(String[] args) {
var context = new AnnotationConfigApplicationContext(ProjectConfiguration.class);
var comment = new Comment();
comment.setAuthor("Laurentiu");
comment.setText("Demo comment");
var commentService = context.getBean(CommentService.class);
commentService.publishComment(comment);
}
}
DI 기능을 사용하면 CommentService 객체와 그 의존성 인스턴스를 직접 생성하지 않아도 되며, 이들 간 관계를 명시적으로 설정할 필요도 없다.
추상화에 대한 여러 구현체 중에서 오토와이어링할 것을 선택
서로 다른 두 클래스로 생성된 빈이 두 개 있고, 이 두 빈이 CommentNotificationProxy 인터페이스를 구현한다고 가정해보자. 이 애플리케이션을 그대로 실행하면 스프링이 컨텍스트에서 두 빈 중 어떤 빈을 주입할지 알 수 없어 예외가 발생한다.
1. @Primary로 주입에 대한 기본 구현 표시하기
@Component
// @Primary가 없다면 NoUniqueBeanDefinitionException 이 발생한다.
@Primary
public class CommentPushNotificationProxy
implements CommentNotificationProxy {
@Override
public void sendComment(Comment comment) {
System.out.println("Sending push notification for comment: " + comment.getText());
}
}
특정 인터페이스에 대한 구현을 이미 제공하는 의존성을 사용할 때가 있다. 이런 인터페이스에 대한 커스텀 구현이 필요하다면 기본 DI 구현으로 표시하도록 @Primary를 사용할 수 있다. 이렇게 하면 스프링은 의존성에서 제공하는 구현이 아닌 내가 정의한 커스텀 구현을 주입한다.
2. @Qualifier로 주입에 대한 기본 구현 표시하기
때때로 프로덕션 앱에서 동일한 인터페이스에 대한 구현을 여러 개 정의해야 하고 서로 다른 객체가 이런 구현을 사용하는 경우가 있다. 댓글 알림을 이메일 또는 푸시 알림으로 두 가지 구현해야한다고 가정해보자. 이 알림 구현들은 여전히 동일한 인터페이스에 대한 구현이지만 앱 내에서 서로 다른 객체에 의존한다.
@Component
@Qualifier("PUSH")
public class CommentPushNotificationProxy
implements CommentNotificationProxy {
@Override
public void sendComment(Comment comment) {
System.out.println("Sending push notification for comment: " + comment.getText());
}
}
@Component
@Qualifier("EMAIL")
public class EmailCommentNotificationProxy implements CommentNotificationProxy {
@Override
public void sendComment(Comment comment) {
System.out.println("Sending notification for comment: " + comment.getText());
}
}
- 스프링이 이 중 하나를 주입하도록 하려면 @Qualifier 애너테이션으로 구현 이름을 다시 지정하기만 하면 된다.
@Component
public class CommentService {
private final CommentRepository commentRepository;
private final CommentNotificationProxy commentNotificationProxy;
// 특정 구현을 사용하려는 매개변수에서는 @Qualifier로 애너테이션을 추가한다.
public CommentService(CommentRepository commentRepository,
@Qualifier("PUSH") CommentNotificationProxy commentNotificationProxy) {
this.commentRepository = commentRepository;
this.commentNotificationProxy = commentNotificationProxy;
}
public void publishComment(Comment comment) {
commentRepository.storeComment(comment);
commentNotificationProxy.sendComment(comment);
}
}
스테레오타입 애너테이션으로 객체의 책임에 집중
지금까지 스테레오타입 애너테이션을 논의할 때 @Component만 사용했다. 하지만 실제 구현을 보면 개발자가 동일한 목적으로 다른 애너테이션을 사용하기도 한다는 것을 알 수 있다.
보통은 @Component가 사용되며 구현하는 객체의 책임에 대한 세부 정보는 제공하지 않지만 개발자는 일반적으로 몇 가지 알려진 책임이 있는 객체를 사용한다(서비스, 리포지터리)
서비스는 사용 사례를 구현하는 책임이 있는 객체이며, 리포지터리는 데이터 지속성을 관리하는 객체이다. 이런 책임들은 프로젝트에서 매우 일반적이며 클래스 설계에서 중요하므로 이들을 표시하는 고유한 방법이 있다면 개발자가 앱 디자인을 더 잘 이해하는 데 도움이 된다.
@Service // @Service를 사용하여 이 객체를 서비스 책임을 가진 컴포넌트로 정의한다.
public class CommentService {
// 코드 생략
}
@Repository // @Repository를 사용하여 이 객체를 리포지토리 책임을 가진 컴포넌트로 정의한다.
public class DBCommentRepository implements CommentRepository {
// 코드 생략
}
'Spring > 스프링 교과서' 카테고리의 다른 글
[스프링 교과서] 3장 스프링 컨텍스트: 빈 작성 (0) | 2024.07.15 |
---|---|
[스프링 교과서] 2장 스프링 컨텍스트: 빈 정의 (0) | 2024.07.10 |
[스프링 교과서] 1장 현실 세계의 스프링 (0) | 2024.07.08 |