CodeGym /행동 /JAVA 25 SELF /Java 아키텍처의 인터페이스, 디자인 패턴

Java 아키텍처의 인터페이스, 디자인 패턴

JAVA 25 SELF
레벨 20 , 레슨 4
사용 가능

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)을 활용하세요!

1
설문조사/퀴즈
인터페이스, 레벨 20, 레슨 4
사용 불가능
인터페이스
인터페이스의 개념
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION