23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
"확장이 쉬워야 한다" 라는 말을 참 많이 들어봤을 겁니다.
이번 글에서는
java/spring 에서 의존성 주입(dependency injection) 시켜 주는 기능을 통해 확장이 어떻게 쉬워 지는지 알아보겠습니다.
spring 에서는 의존하는 객체를 @Autowired 를 이용하여 아래와 같이 주입할 수 있습니다.
우선 아래와 같이 call() 이라는 메서드를 가진 인터페이스가 있다고 가정해봅시다.
public interface TargetService {
String call();
}그리고 아래처럼 이를 implements 한 구체클래스가 있다고 가정해봅시다.
@Component
public class DefaultTargetService implements TargetService {
@Override
public String call() {
return "default target service";
}
}아래의 BeanRunner 라는 클래스가 TargetService 라는 인터페이스를 implements 한 객체를 필요로 한다고 가정해봅시다.
이렇게 다른 객체를 필요로 할 때, 의존한다 라고 말합니다.
특이한 점은 TargetService targetService = new DefaultTargetService(); 와 같이 정확히 어떤 클래스를 지정하지 않는 부분입니다.
spring 은 TargetService 인터페이스를 implements 한 클래스를 검색하여 DefaultTargetService 가 있는걸 알아내고 해당 클래스의 객체를 주입시켜 주기 때문에 가능합니다.
@Component
@Slf4j
public class BeanRunner implements ApplicationRunner {
@Autowired
private TargetService targetService; // 의존성 주입
@Autowired
private GenericApplicationContext applicationContext;
@Override
public void run(ApplicationArguments args) throws Exception {
log.info("targetService: {}, {}", targetService.getClass(), System.identityHashCode(targetService));
TargetService bean = applicationContext.getBean(TargetService.class); // 의존성 주입
log.info("bean: {}, {}", bean.getClass(), System.identityHashCode(bean));
}
}사실 spring을 조금이라도 아는 개발자라면 위 내용은 누구나 알고 있는 사실입니다.
제가 강조하고 싶은 부분은 DefaultTargetService 가 아닌 SampleTargetService, MyTargetService 와 같이
TargetService를 implements 한 다른 클래스로 대체되더라도 BeanRunner 클래스는 수정할 게 하나도 없다는 점입니다.
이걸 통해 확장이 쉬운 구조가 되었습니다.
사실 spring 의 의존성 주입기능은 spring 가장 기초적인 부분이라서 이걸 알리고자 글을 쓴건 아닙니다.
워밍업 정도로 생각하고 다음 파트인 java 에서의 확장 기술에 대해 좀 더 깊이있게 알아봅시다.
spring 을 이용하면 의존성 주입을 비교적 쉽게 할 수 있었습니다.
이에 비해 java 에서의 의존성 주입은 다소 복잡합니다.
우선 총 3개의 별도 project 가 필요합니다.
service-interface 라는 프로젝트는 아래처럼 MessageSender 라는 인터페이스만 정의한다고 가정해봅시다.
그리고 별도로 빌드되어 service-interface.jar 와 같이 패키징한다고 가정합니다.
package hello.world.spi;
public interface MessageSender {
void send(String target, String message);
}service-provider 라는 프로젝트는 위 인터페이스를 implements 한 구체클래스를 정의하고 있습니다.
이 프로젝트 또한 별도로 빌드되어 service-provider.jar 와 같이 패키징한다고 가정합시다.
package hello.world.provider;
import hello.world.spi.MessageSender;
public class EmailMessageSender implements MessageSender {
@Override
public void send(String target, String message) {
System.out.println("email message sender");
}
}마지막으로 myapp 이라는 프로젝트가 있고, 여기서는 위 2개 프로젝트의 jar 를 이용한다고 가정해봅시다.
maven을 사용한다면 아래와 같이 의존성을 pom.xml 에 넣어주게 됩니다.
<dependency>
<groupId>hello.world</groupId>
<artifactId>service-interface</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>hello.world</groupId>
<artifactId>service-provider</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>service-provider 프로젝트에서 정의한 EmailMessageSender 를 사용하기 위해 위와 같이 의존성을 넣은 것이니
MessageSender sender = new EmailMessageSender(); 와 같이 정확한 어떤 클래스를 사용할 지 지정해서 사용할 수는 있으나,
이러면 나중에 SmsMessageSender, KakaoMessageSender, SlackMessageSender 와 같은 다른 클래스로 대체하고자 할 때, 기존 코드를 수정해야 하는 단점이 있습니다.
즉 확장이 쉽지 않게 됩니다.
java 에서는 아래처럼 ServiceLoader.load()를 통해 구체클래스를 주입받을 수 있습니다.
ServiceLoader<MessageSender> serviceLoader = ServiceLoader.load(MessageSender.class);
for (MessageSender messageSender : serviceLoader) {
log.info("messageSender: {}", messageSender.getClass());
messageSender.send("devOcean", "hello world!");
}위의 spring 파트 예제 코드에 TargetService bean = applicationContext.getBean(TargetService.class); 와 같은 부분이 있는데 이것과 유사한 형식입니다.
사실 위 코드만으로는 의존성 주입이 되지 않습니다.
service-provider 에서 아래처럼 META-INF/services 디렉토리 하위에 인터페이스의 FQCN (패키지명+클래스명) 을 파일명으로 지정하고,
파일내용으로는 구체클래스의 FQCN 을 적어줘야 합니다.
java 에서 이렇게 구성하라고 규칙을 정해두었기에 이렇게 따라야 합니다. ( ref. https://docs.oracle.com/javase/tutorial/sound/SPI-intro.html )
만약 EmailMessageSender 를 의존하지 않고 SlackMessageSender 에 의존하고 싶다면 어떻게 하면 될까요?
그냥 pom.xml 에서 EmailMessageSender 가 포함된 의존성을 제거하고, SlackMessageSender 가 포함된 의존성을 추가해주면 됩니다.
java 소스코드는 수정할 게 없습니다.
이로써 쉽게 확장 가능한 구조가 되었습니다.
java 예제는 왜 복잡하게 META-INF/services 디렉토리내에 파일을 넣어줘야 하나?
spring 처럼 알아서 찾아줄 수 없나? 라고 생각할 수 있습니다.
사실 spring 도 java 처럼 특정 디렉토리에 특정 파일을 생성해둬야 합니다.
아래는 spring 에서 제공하는 임의 jar 파일 내부이며,
spring 이 의존성 주입할 클래스를 찾기 위해 대상 파일들을 META-INF/spring 디렉토리 내에
org.springframework.boot.autoconfigure.AutoConfiguration.imports 라는 파일에 클래스들의 FQCN을 적어줘야 합니다.
그렇다면, 왜 맨 위의 spring 예제에서는 이런 번거로운(?) 일을 하지 않아도 동작가능한가요? 라고 궁금할 수 있습니다.
그건 component scan 을 통해 기본 루트 패키지내의 모든 클래스들을 기본적으로 스캔해주는 기능 때문입니다.
다른 프로젝트의 클래스들은 대부분 패키지가 완전히 다르기 때문에 자동 스캔 대상이 포함되지 않으며,
이 때는 위에서 구성한 특정 디렉토리,파일을 참고하여 클래스들을 찾기 때문입니다. ( spring 동작원리가 메인 주제가 아니므로 간략히만 설명했습니다 )
java 에는 로깅을 위한 라이브러리로 logback, log4j2 등이 있습니다.
app 에서는 이런 구체적인 라이브러리를 직접 의존하지 않고 slf4j 라는 인터페이스 역할 라이브러리를 통해서 로깅을 합니다.
즉 아래처럼 말이죠
app --> slf4j --> logback
--> log4j2
--> ...slf4j 에서는 아래처럼 SLF4JServiceProvider 라는 인터페이스를 제공합니다.
그리고 logback 이라는 프로젝트에서는 아래처럼 java service provider interface 를 활용하고 있는걸 알 수 있습니다.
마지막으로 아래처럼 slf4j 에서 ServiceLoader.load() 를 통해 의존성을 주입받고 있습니다.
우리가 이제까지 배운 내용이 알게 모르게 많이 활용되고 있었답니다.
spring 이나 java 에서 확장이 쉬운 구조를 위해 지원하는 기능들에 대해 이제까지 살펴봤습니다.
사실 사용방법 자체는 어렵지 않고 시킨대로 작성하면 되긴 합니다.
중요한 점은, 이런식으로 구체적인 의존성을 최소한의 수정만으로 변경이 가능한 구조가 되도록 만들어야 한다는 점입니다.
유의할 점은 변경가능성이 거의 없는 부분까지 이렇게 만들 필요는 없습니다.
즉 2개 이상 구체클래스가 생길 가능성이 거의 없는데, 인터페이스를 무조건적으로 만드는 건 과연 적절한지 고민을 해봐야 합니다.
좀 더 구체적으로 얘기하자면,
여러분이 프레임워크나 라이브러리 개발자이고,
외부에 노출되는 기능이라면 구체클래스가 1개 뿐이더라도 가급적 인터페이스를 이용하도록 합니다. 공용 기능이니 언제 어떤 요구사항이 생길지 알 수 없기 때문입니다.
그러나, 일반 app 개발자이고, 거의 스펙이 정해진 , 변경가능성이 적은 기능이라면 굳이 인터페이스를 거칠 필요는 없습니다.
인터페이스를 사용시의 확장 용이성이 더 중요한가, 아니면 단순하고 좀 더 직관적으로 구현하는게 더 중요한가를 따져보고 결정해야 합니다.
ps.
본 글에 사용된 전체 소스는 아래에서 확인 할 수 있습니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.