Engineering Note

[Java] Interface와 DI(Dependency Injection)를 통해 객체간 결합 낮추기, Interface를 사용하는 이유 본문

Programming Language/Java

[Java] Interface와 DI(Dependency Injection)를 통해 객체간 결합 낮추기, Interface를 사용하는 이유

Software Engineer Kim 2025. 8. 1. 09:45

Interface는 두 장치를 연결하는 접속기다. Java에서는 객체들을 각각의 장치로 볼 수 있다. 그래서 Java에서 Interface는 두 객체를 연결하는 역할을 한다.
 
객체 A - Interface - 객체 B
 
객체 A는 Interface를 통해 객체 B와 연결되어 있다. 객체 A는 Interface를 통해 객체 B를 사용할 수 있다.
 
그런데 객체 A가 객체 B를 직접 호출할 수도 있는데 왜 Interface를 통해 호출할까? 여기서 요즘 시스템 디자인에서도 중요한 노드 간(객체간) 느슨한 결합을 위해서다. 시스템 디자인에서도 리퀘스트를 보내는 클라이언트와 응답을 하는 서버간의 강하게 결합이 되어 있다면, 빠르게 성장하는 서비스를 위해 서버를 수평확장해야하거나 새로운 기능을 추가할 때 어려움을 겪을 수 있다.
 
Java에서 Interface도 마찬가지 이유로 사용한다. 객체간의 결합을 느슨하게 해서 확장성을 용이하게 하기 위해서 사용한다. Inteface를 사용하면 객체 A가 객체B의 내부 구현은 몰라도 되고, 객체 B가 아니라 객체 C로 변경을 해야할 때 객체 A의 코드는 변경하지 않고도 사용 객체를 변경할 수 있다. 이러한 특징으로 인해 Interface는 다형성 구현에 주된 기술로 이용된다. 상속을 이용해서 다형성을 구현할 수도 있지만, Interface를 이용해서 다형성을 구현하는 경우가 더 많다.
 
실제 예시를 통해 Interface를 사용하지 않았을 때와 Interface를 사용했을 때를 비교해보자.
 
알림서비스를 개발하는 상황이라고 가정하자.

1. Interface를 사용하지 않았을 때의 예시

처음의 기획은 Email을 통해 사용자에게 알림을 보내는 방향으로 서비스가 기획이 되었다.
 
그래서 개발자 천수는 EmailSender 클래스를 아래와 같이 구현했다.

public class EmailSender {
    public void send(String to, String message){
        System.out.printf("[EMAIL] to=%s, message=%s%n", to, message);
    }
}

 
 
그리고 아래처럼 EmailSender 클래스를 이용해서 유저에게 메세지를 보내는 MessageService 클래스를 만들었다. MessageService의 notifyUser메서드에서 EmailSender의 send메서드를 사용해서 유저에게 메세지를 보내도록 했다.

// MessageService.java
public class MessageService {
    private EmailSender emailSender = new EmailSender();  // 강한 결합!

    public void notifyUser(String user, String message) {
        // EmailSender 내부 구현을 몰라도 되지만, 클래스가 직접 의존
        emailSender.send(user + "@example.com", message);
    }
}

 
 
아래는 Service 클래스를  호출하는 Main클래스이다.

public class Main {
    public static void main(String[] args){
        MessageService messageService = new MessageService();
        messageService.notifyUser("alice", "메세지 전송이 완료되었습니다.");
    }
}

 
 
실행결과

 
 
그런데 어느날 SMS로 알림을 보내는 방식으로 기획이 변경되었다. 기획의 내용을 반영하기 위해 SmsSender 클래스를 만들었다. 

public class SmsSender {
    public void sendSms(String phone, String message){
        System.out.printf("[SMS] to=%s, message=%s%n",phone, message);
    }
}

 
 
 
그리고 MessageService를 아래처럼 수정했다.
 

public class MessageService {
    private SmsSender smsSender = new SmsSender(); //바뀐 구현체

    public void notifyUser(String user, String message){
        smsSender.sendSms(user, message); //서비스 코드 직접 변경
    }
}

 
그리고 Main 메서드를 실행하면 아래와 같다.
 

public class Main {
    public static void main(String[] args){
        MessageService messageService = new MessageService();
        messageService.notifyUser("alice", "메세지 전송이 완료되었습니다.");
    }
}

 

 
 
 
 
언뜻 보면 문제가 없어보이지만, 객체지향의 장점을 충분히 활용하지 못한 코드를 작성하여 개발했기 때문에 재사용성이 전혀 없다. 만약 MessageService를 누군가 사용하고 있다면 코드 충돌은 피할 수 없고, 간단하게만 말해도 MessageService를 코드를 집접 건드려서 문제를 해결했다. 
좋은 코드란 클래스 간의 응집도가 낮아서 확장에는 열려있고 변경에는 닫혀있어야 한다.
 
 

2. Interface를 사용했을 때 예시(DI(Dependency Injection) 같이)

같은 상황에서 개발자 철수는 기획의 변경 여부를 고려해서 처음부터 Interface를 통해 email 알림 기능을 구현했다.
 
 아래처럼 Notifier interface를 만들고 이를 구현한 EmailNotifier 클래스를 만들었다.
 

public interface Notifier {
    void send(String target, String message);
}

 
 

public class EmailNotifier implements Notifier {
    @Override
    public void send(String target, String message){
        System.out.printf("[SMS] to=%s, message=%s%n", target, message);
    }
}

 
 
그리고 MessageService 클래스는 인스턴스를 생성할 때 원하는 인터페이스를 사용할 수 있도록 Notifier 필드를 final로 선언하고 생성자 주입을 통해 interface의 구현체를 주입받도록 개발했다.(final 필드는 선언시 초기화를 해주거나 인스턴스 반드기 생성시 초기화가 되어야 한다.)
 

public class MessageService {
    private final Notifier notifier; //인터페이스에만 의존

    //Constructor Injection
    public MessageService(Notifier notifier) {
        this.notifier = notifier;
    }

    public void notifyUser(String user, String message){
        //notifier 구현체의 send() 호출
        notifier.send(user, message);
    }
}

 
 
 
 
이메일 전송기능 사용 코드.

public class Main {
    public static void main(String[] args){
        //이메일 버전
        Notifier email = new EmailNotifier();
        MessageService emailService = new MessageService(email);
        emailService.notifyUser("alice@example.com", "인터페이스 사용 email");
    }
}

 
 
역시나, 기획은 변경되어서 email 대신 sms로 알림서비스 기능이 변경되었다. SmsNotifier도 역시 EmailNotifier를 구현할 때 사용한 Notifier interface를 구현하도록  만들었다.
 

public class SmsNotifier implements Notifier {
    @Override
    public void send(String target, String message) {
        System.out.printf("[SMS] to=%s, message=%s%n", target, message);
    }
}

 
 
그리고 MessageService를 사용하는 쪽은 본인이 주입하고 싶은 구현체를 주입해서 사용하면 된다. 여기서는 Main 클래스의 main 메서드에서 MessageService를 사용하도록 테스트했다.
 

public class Main {
    public static void main(String[] args){
        //SMS 버전
        Notifier sms = new SmsNotifier();
        MessageService smsService = new MessageService(sms);
        smsService.notifyUser("010-1234-5678", "인터페이스 사용 SMS");
    }
}

 
 
두 방식의 차이는 이번 글에서 설명하려고 했던 가장 중요한 MessageService를 직접 수정하지 않아도 된다는 장점이 있다. 이렇게 interface를 사용하면 각 객체간 결합을 느슨하게 구현할 수 있다. 위의 두 번 째 예시처럼 MessageService 클래스는 EmailNotifier, SmsNotifier를 Notifier라는 interface를 통해 호출하도록 해서 클래스간 결합도를 낮추었다. 그리고 생성자 주입을 통해 interface의 구현체를 주입하도록 하면서 변경에는 닫혀있으면서 확장에는 용이하도록 구현하였다. 이를 통해 MessageService 클래스를 사용하는 사용자 측에서 런타임에 직접 필요한 클래스를 지정해서 사용할 수 있다.
 
 
정리하면, 인터페이스를 사용하면 객체간의 결합도를 낮출 수 있다. 여기에 더해 의존성을 주입(여기서는 생성자 주입 방식 사용)받도록 하면 원하는 시점에 원하는 객체를 주입해서 사용하면서 interface를 호출하는 클래스를 수정하지 않고도 추가 기능을 개발을 할 수 있다.
 
 
 
 
 
cf)추가로 이러한 기술이 발전해서 현재의 Spring Framework가 탄생했는데, Spring Framework는 구현체 주입 자체도 개발자가 하지 않고 스프링에게 위임하는 방식으로 만들어졌다. 그래서 개발자는 비즈니스로직 개발에만 집중할 수 있도록 한다.
 
만약 위에서 의존성 주입을 사용하지 않고 인터페이스만 사용해서 구현했다면 결국 강한 결합을 낮추지 못했을 것이다. 아래 코드가 인터페이스를 사용했지만 의존성 주입을 하지 않아 강한 결합을 풀어내지 못한 케이스다.

public class MessageService {
    private final Notifier notifier;

    public MessageService() {
        // 인터페이스 Notifier를 쓰지만, 
        // 실제로는 구체 클래스 EmailNotifier에 강하게 결합되어 있다!
        this.notifier = new EmailNotifier();  
    }

    public void notifyUser(String user, String msg) {
        notifier.send(user, msg);
    }
}

 

Comments