1. 계약으로서의 인터페이스: 아키텍처의 기반
Java(뿐만 아니라)에서 인터페이스는 단순한 메서드 집합이 아닙니다. 이는 계약으로, 인터페이스를 구현하는 모든 클래스가 특정한 동작을 지원한다는 약속입니다. 인터페이스는 무엇을 구현해야 하는지를 정의하며, 어떻게 구현하는지는 정의하지 않습니다.
왜 중요할까요?
- 코드를 계층으로 분리. 인터페이스 덕분에 “무엇을 한다”와 “어떻게 한다”를 분리할 수 있습니다. 예를 들어 PaymentService 인터페이스가 있다면, 서로 다른 구현이 신용카드, PayPal, 암호화폐 결제를 처리할 수 있지만 pay()를 호출하는 코드는 세부 사항을 신경 쓰지 않습니다.
- 유연성과 확장성. 인터페이스의 새 구현을 추가해도 나머지 코드를 바꿀 필요가 없습니다. 이는 대규모 팀과 장수 프로젝트에서 특히 중요합니다.
- 테스트 용이성. 인터페이스를 통해 실제 구현을 테스트용(mock)으로 손쉽게 대체할 수 있어, 기본 코드를 건드리지 않고 테스트할 수 있습니다.
예: 서비스 레이어와 DAO
비즈니스 애플리케이션에서의 고전적인 예를 살펴봅시다. 사용자 작업을 위한 인터페이스가 있다고 가정해봅시다:
public interface UserRepository {
User findById(int id);
void save(User user);
}
상황에 따라 이 인터페이스를 여러 방식으로 구현할 수 있습니다:
- DatabaseUserRepository — 사용자를 데이터베이스에 저장합니다.
- InMemoryUserRepository — 사용자를 메모리에 저장합니다(테스트에 유용).
- FileUserRepository — 사용자를 파일에 저장합니다.
사용자와 상호작용하는 코드는 인터페이스에만 의존합니다:
public class UserService {
private final UserRepository userRepository;
// 생성자를 통한 의존성 주입
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void registerUser(User user) {
userRepository.save(user);
}
}
이제 서비스 코드를 변경하지 않고도 UserRepository 구현을 손쉽게 바꿀 수 있습니다.
2. Dependency Injection(의존성 주입)과 인터페이스의 역할
Dependency Injection(DI, 의존성 주입)은 의존성(예: 인터페이스의 구현)을 보통 생성자나 세터를 통해 외부에서 객체에 주입하는 아키텍처 기법입니다. 이를 통해 유연하고, 테스트하기 쉬우며, 확장 가능한 애플리케이션을 만들 수 있습니다.
DI에서 인터페이스가 중요한 이유는?
구현을 코드에 고정해 두면 교체하기 어렵습니다. 인터페이스를 사용하면 기본 코드를 바꾸지 않고 원하는 구현을 주입할 수 있습니다.
의존성 주입 예시
public interface NotificationSender {
void send(String message);
}
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("이메일 발송: " + message);
}
}
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String message) {
System.out.println("SMS 발송: " + message);
}
}
// NotificationSender를 사용하는 클래스
public class NotificationService {
private final NotificationSender sender;
public NotificationService(NotificationSender sender) {
this.sender = sender;
}
public void notifyUser(String message) {
sender.send(message);
}
}
이제 NotificationService를 실제 발송기 대신 예를 들어 “스텁”을 주입해 쉽게 테스트할 수 있습니다.
3. 디자인 패턴과 인터페이스
인터페이스는 아키텍처뿐 아니라 디자인 패턴에서도 핵심입니다. 많은 패턴은 인터페이스 없이는 구현하기 어렵습니다. 가장 많이 쓰이는 것들을 살펴봅시다.
Observer(옵저버)
Observer는 한 객체(피관찰자)가 다른 객체들(관찰자)에게 자신의 상태 변화에 대해 통지할 수 있게 해주는 패턴입니다.
UML 다이어그램(간략화):
+------------------+ +------------------------+
| Subject |<------->| Observer |
+------------------+ +------------------------+
| +addObserver() | | +update() |
| +removeObserver()| +------------------------+
| +notifyObservers()|
+------------------+
코드 예시:
import java.util.ArrayList;
import java.util.List;
// 옵저버 인터페이스
public interface Observer {
void update(String event);
}
// 주체(Subject) 인터페이스
public interface Subject {
void addObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(String event);
}
// Subject 구현
public class NewsAgency implements Subject {
private List<Observer> observers = new ArrayList<>();
@Override
public void addObserver(Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}
@Override
public void notifyObservers(String event) {
for (Observer observer : observers) {
observer.update(event);
}
}
}
// Observer 구현
public class NewsReader implements Observer {
private String name;
public NewsReader(String name) {
this.name = name;
}
@Override
public void update(String event) {
System.out.println(name + "님이 소식을 받았습니다: " + event);
}
}
// 예제를 실행하는 메인 클래스
public class ObserverExample {
public static void main(String[] args) {
// "뉴스 에이전시"(Subject) 생성
NewsAgency agency = new NewsAgency();
// 옵저버 생성
Observer alice = new NewsReader("Alice");
Observer bob = new NewsReader("Bob");
// 옵저버를 구독자로 등록
agency.addObserver(alice);
agency.addObserver(bob);
// 뉴스 전송
agency.notifyObservers("Java의 새 버전이 출시되었습니다!");
// 한 명의 옵저버를 제거하고 다른 뉴스 전송
agency.removeObserver(bob);
agency.notifyObservers("구독자를 위한 다음 소식");
}
}
결과:
Alice님이 소식을 받았습니다: Java의 새 버전이 출시되었습니다!
Bob님이 소식을 받았습니다: Java의 새 버전이 출시되었습니다!
Strategy(전략)
Strategy는 클라이언트 코드를 변경하지 않고도 실행 시간에 동작 알고리즘을 선택할 수 있게 해주는 패턴입니다.
UML 다이어그램(간략화):
+------------------+
| Context |
+------------------+
| -strategy: Strat.|
| +setStrategy() |
| +execute() |
+------------------+
|
v
+------------------+
| Strategy |<-------------------------+
+------------------+ |
| +execute() | |
+------------------+ |
^ |
| |
+------------------+ +------------------+ |
| ConcreteA | | ConcreteB |---+
+------------------+ +------------------+
| +execute() | | +execute() |
+------------------+ +------------------+
코드 예시:
public interface PaymentStrategy {
void pay(int amount);
}
public class CreditCardPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("신용카드로 " + amount + " 루블 결제");
}
}
public class PaypalPayment implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println("PayPal로 " + amount + " 루블 결제");
}
}
public class OnlineStore {
private PaymentStrategy paymentStrategy;
public void setPaymentStrategy(PaymentStrategy strategy) {
this.paymentStrategy = strategy;
}
public void checkout(int amount) {
paymentStrategy.pay(amount);
}
}
// 사용 예:
OnlineStore store = new OnlineStore();
store.setPaymentStrategy(new CreditCardPayment());
store.checkout(1000);
store.setPaymentStrategy(new PaypalPayment());
store.checkout(500);
결과:
신용카드로 1000 루블 결제
PayPal로 500 루블 결제
Command(커맨드)
Command는 요청을 객체로 캡슐화하여 동작을 파라미터처럼 전달할 수 있게 해주는 패턴입니다.
코드 예시:
public interface Command {
void execute();
}
public class LightOnCommand implements Command {
@Override
public void execute() {
System.out.println("불이 켜졌습니다!");
}
}
public class LightOffCommand implements Command {
@Override
public void execute() {
System.out.println("불이 꺼졌습니다!");
}
}
public class RemoteControl {
private Command command;
public void setCommand(Command command) {
this.command = command;
}
public void pressButton() {
command.execute();
}
}
// 사용 예:
RemoteControl remote = new RemoteControl();
remote.setCommand(new LightOnCommand());
remote.pressButton(); // 불이 켜졌습니다!
remote.setCommand(new LightOffCommand());
remote.pressButton(); // 불이 꺼졌습니다!
4. 아키텍처에서 인터페이스를 사용하면 얻는 이점
- 낮은 결합도(Low Coupling). 코드는 구체 구현이 아니라 인터페이스에만 의존합니다. 교체, 테스트, 확장이 쉬워집니다.
- 테스트 용이성. 실제 구현을 테스트용(mock/stub)으로 쉽게 대체하여 단위 테스트를 작성할 수 있습니다.
- 확장성. 기존 코드를 변경하지 않고도 인터페이스의 새 구현을 추가할 수 있습니다 — 개방-폐쇄 원칙(OCP).
- 병렬 개발. 공통 인터페이스만 합의되어 있다면 여러 팀이 시스템의 다른 부분을 독립적으로 구현할 수 있습니다.
- 아키텍처 유연성. 새로운 패턴과 접근을 쉽게 도입할 수 있습니다.
5. 아키텍처에서 인터페이스를 사용할 때 흔한 실수
실수 1: 구현에 대한 강한 결합.
코드 전반에서 구체 클래스를 직접 사용하면 구현을 바꿀 때 여러 곳을 수정해야 합니다. 항상 “인터페이스 수준”에서 프로그래밍하려고 노력하세요.
실수 2: 지나치게 큰 인터페이스(God Interface).
인터페이스는 작고 단일 책임을 가져야 합니다. 모든 것을 한 인터페이스에 몰아넣으면 구현이 무겁고 복잡해집니다.
실수 3: 테스트 용이성의 이점 무시.
테스트에서 의존성 대체에 인터페이스를 활용하지 않으면, 특히 실제 데이터베이스나 네트워크를 사용한다면 테스트가 느리고 신뢰성이 떨어질 수 있습니다.
실수 4: 구현은 여러 개지만 DI가 없음.
인터페이스에 여러 구현을 만들어 놓고도 코드에 한 구현을 하드코딩하면 아키텍처의 유연성을 잃습니다. 의존성 주입(DI)을 활용하세요!
GO TO FULL VERSION