자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

@023· January 01, 2025 · 9 min read

많은 경우의 클래스들은 하나 이상의 리소스에 의존하는 양상을 띈다. 알림을 보내는 NotificationService 클래스와 이메일을 보내는 EmailSender 클래스가 있다고 가정해보자. NotificationServiceEmailSender를 사용하여 이메일을 보낸다. 여기서 NotificationServiceEmailSender 리소스에 의존한다고 말한다. 이때 NotificationService 클래스는 다음과 같이 구현하는 경우가 있다.

정적 유틸리티 클래스(Static Utility Class)

다음 코드는 알림을 보내는 NotificationService를 정적 유틸리티 클래스로 구현한 경우이다.

interface Sender {
    boolean send(String message);
}

class EmailSender implements Sender {
    @Override
    public boolean send(String message) {
        // 이메일을 보내는 코드
        return false;
    }
}

public class NotificationService {
    private static final Sender sender = new EmailSender();

    private NotificationService() {
        throw new AssertionError();
    }

    public static boolean sendNotification(String message) {
        return sender.send(message);
    }
}

위 코드에서는 NotificationService 클래스가 EmailSender 리소스에 직접 의존하고 있다. 이 경우 알림 방식 추가 및 변경을 하기 위해서는 NotificationService 클래스를 수정해야 한다. 또한 정적 메서드는 Mocking이 어렵고, 테스트가 제한되어 어려움이 있다. 다음 경우를 보자.

싱글톤(Singleton)

다음 코드는 알림을 보내는 NotificationService를 싱글톤으로 구현한 경우이다.

interface Sender {
    boolean send(String message);
}

class EmailSender implements Sender {
    @Override
    public boolean send(String message) {
        // 이메일을 보내는 코드
        return false;
    }
}

public class NotificationService {
    private final Sender sender = new EmailSender();

    private NotificationService() {
        throw new AssertionError();
    }

    public static final NotificationService INSTANCE = new NotificationService();

    public boolean sendNotification(String message) {
        return sender.send(message);
    }
}

위 코드 또한 NotificationService 클래스가 EmailSender 리소스에 직접 의존하고 있다. 마찬가지로 알림 방식 추가 및 변경을 하기 위해서는 NotificationService 클래스를 수정해야 한다. 테스트부분 또한 싱글톤 패턴은 Mocking이 어렵고, 테스트가 제한되어 어려움이 있다.

의존 객체 주입(Dependency Injection)

앞서 구현한 두 가지 방식은 EmailSender만 사용하는 경우에는 문제가 없지만, 언급했듯이 알림 방식을 추가하거나 변경할 때 문제가 발생한다. 이처럼 어떤 클래스가 사용할 의존 리소스에 따라 동작이 달라지는 경우에는 정적 유틸리티 클래스나 싱글톤 패턴을 사용하는 것은 부적절해 보인다. 그럼 EmailSender 대신 SmsSender를 사용하거나 추가하고 싶다면 어떻게 해야할까? 이때는 새로운 인스턴스를 생성할 때 생성자에 필요한 리소스를 주입하는 방식을 사용하면 된다.

interface Sender {
    boolean send(String message);
}

class EmailSender implements Sender {
    @Override
    public boolean send(String message) {
        // 이메일을 보내는 코드
        return false;
    }
}

class SmsSender implements Sender {
    @Override
    public boolean send(String message) {
        // SMS를 보내는 코드
        return false;
    }
}

public class NotificationService {
    private final Sender sender;

    public NotificationService(Sender sender) {
        this.sender = sender;
    }

    public boolean sendNotification(String message) {
        return sender.send(message);
    }
}

public class Main {
    public static void main(String[] args) {
        String MESSAGE = "Hello, World!";
        
        NotificationService emailNotificationService = new NotificationService(new EmailSender());
        emailNotificationService.sendNotification(MESSAGE);

        NotificationService smsNotificationService = new NotificationService(new SmsSender());
        smsNotificationService.sendNotification(MESSAGE);
    }
}

앞선 두 가지 경우의 구현과 유사해보이지만 NotificationService는 런타임과정에서 생성자를 통해 Sender 인터페이스를 구현한 클래스를 주입받는다. 이렇게 하면 무슨 이점이 있을까? NotificationServiceSender 인터페이스를 구현한 클래스에만 의존하고 있기 때문에, EmailSender 대신 SmsSender와 같은 다양한 구현체를 사용하거나 추가하는 것이 쉬워져 유연성이 생기게 된다. 또한 테스트 코드에서는 Mocking을 통해 테스트가 용이해진다. 이 경우, Mock을 사용하면 테스트 시 실제 알림 없이도 검증이 가능하다. 하지만 다른 방식으로 구현한 경우에는 의존성 주입이 불가능해 Mocking이 어렵고 테스트가 제한되어 어려움이 있다.

class NotificationServiceTest {

    @DisplayName("의존 객체 주입 테스트")
    @Test
    public void testNotificationService() throws Exception {
        //given
        Sender mockSender = mock(Sender.class);
        when(mockSender.send(anyString())).thenReturn(false);
        NotificationService notificationService = new NotificationService(mockSender);
        //when
        boolean result = notificationService.sendNotification("test");
        //then
        assertThat(result).isFalse();
        verify(mockSender).send("test");
    }
}

위 의존 객체 주입 방식을 변형하여 리소스의 생성을 팩터리 메서드에 위임하는 방식인 팩터리 메서드 패턴(Factory Method Pattern)가 있다. 이 방식은 Supplier<T> 인터페이스가 제공하는 get() 메서드를 이용하여 리소스를 생성하는 방식이다. Supplier를 인자로 받는 메서드는 보통 bounded wildcard type`을 사용하여 제한을 두는 것이 좋다.

public class NotificationService {
    private final Supplier<Sender> senderSupplier;

    public NotificationService(Supplier<Sender> senderSupplier) {
        this.senderSupplier = senderSupplier;
    }

    public boolean sendNotification(String message) {
        return senderSupplier.get().send(message);
    }
}

NotificationService service = new NotificationService(() -> new EmailSender());

DI 프레임워크

이처럼 의존 객체 주입을 사용하면 유연성이 높아지고 테스트가 용이하지만, 프로젝트가 커질수록 생성자에 많은 인자가 필요한 경우 관리가 어려워질 수 있다.

interface Sender {
    boolean send(String message);
}

class EmailSender implements Sender {
    @Override
    public boolean send(String message) {
        // 이메일을 보내는 코드
        return false;
    }
}

class SmsSender implements Sender {
    @Override
    public boolean send(String message) {
        // SMS를 보내는 코드
        return false;
    }
}

public class NotificationService {
    private final Sender sender;

    public NotificationService(Sender sender) {
        this.sender = sender;
    }

    public boolean sendNotification(String message) {
        return sender.send(message);
    }
}

// 의존성 관리 클래스
public class Application {
    private final NotificationService emailService;
    private final NotificationService smsService;

    public Application() {
        Sender emailSender = new EmailSender();
        Sender smsSender = new SmsSender();

        // 모든 의존성을 수작업으로 생성하고 주입
        this.emailService = new NotificationService(emailSender);
        this.smsService = new NotificationService(smsSender);
    }

    public void run() {
        emailService.sendNotification("Hello Email!", "email@example.com");
        smsService.sendNotification("Hello SMS!", "123-456-7890");
    }

    public static void main(String[] args) {
        Application app = new Application();
        app.run();
    }
}

이때 DI 프레임워크(컨테이너를) 사용하면 이를 효과적으로 관리할 수 있다. Spring@Autowired 어노테이션을 통해 의존성을 자동으로 주입해주며, @Component 어노테이션을 통해 Spring이 관리하는 빈으로 등록할 수 있다. 이렇게 하면 Spring이 의존성을 관리하고 주입하는 과정을 자동화하여 위 문제를 해결할 수 있다.

interface Sender {
    boolean send(String message);
}

@Component
class EmailSender implements Sender {
    @Override
    public boolean send(String message) {
        // 이메일을 보내는 코드
        return false;
    }
}

@Component
class SmsSender implements Sender {
    @Override
    public boolean send(String message) {
        // SMS를 보내는 코드
        return false;
    }
}

// NotificationService에 @Service 추가
@Service
public class NotificationService {
    private final Sender sender;

    // 의존성 자동 주입
    @Autowired
    public NotificationService(Sender sender) {
        this.sender = sender;
    }

    public void sendNotification(String message) {
        sender.send(message);
    }
}

// Application 클래스에 @SpringBootApplication 추가
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

정리하자면 의존하는 리소스가 클래스의 동작에 영향을 미친다면, 정적 유틸리티 클래스나 싱글톤 패턴을 사용하는 것은 부적절하다. 이러한 경우, 의존 객체 주입을 사용하면 구현체 교체와 테스트가 용이해지고, 코드의 유연성과 유지보수성이 향상된다. 프로젝트 규모가 커질수록 의존성 주입을 효과적으로 관리하기 위해 DI 프레임워크를 사용하는 것이 좋다.

참고

@023
focus and hustle