디자인 패턴 조합 실전 — 6가지 시너지

2026-05-03AWS SAA-C03 스터디

디자인 패턴 핵심 정리 시리즈 마지막 편. SOLID와 GoF 23가지 패턴을 한데 묶어 승차 공유 서비스 예시로 V1→V2→V3 리팩토링을 따라가며 — Observer·Strategy·Mediator의 시너지, 6가지 단골 패턴 조합, 패턴 선택 결정 트리, 자주 빠지는 함정 5가지까지 시리즈를 마무리하는 6편.

📚 디자인 패턴 핵심 정리 · 6편 / 14편 — 6가지 시너지

여기까지 와줘서 정말 고마워요. 이 글이 디자인 패턴 핵심 정리 시리즈의 마지막 6편입니다. 1편에서 SOLID 원칙으로 시작해, 2~4편에서 GoF 23가지 디자인 패턴(생성·구조·행위)을 한 패턴씩 풀어왔고, 5편에서 그 모든 게 서 있는 OOP의 4가지 기둥을 다시 짚었어요. 이번 마지막 편은 — 그 모든 게 함께 쓰일 때 어떻게 시너지를 내는가를 실전 프로젝트로 풀어 보는 자리입니다.

이번 편에서 다루는 건 승차 공유 서비스 예시(운전자·승객 매칭, 요금 계산, 실시간 알림)예요. V1(아무 원칙 없이 한 클래스에 다 박은 코드) → V2(SOLID 원칙으로 리팩토링) → V3(디자인 패턴까지 적용해 완성)의 세 단계를 따라가며, Observer + Strategy + Mediator 세 패턴이 어떻게 서로를 보강하며 한 시스템을 떠받치는지 보겠습니다. 그 다음에 자주 함께 쓰이는 패턴 조합 6가지, 패턴 선택 결정 트리, 그리고 자주 빠지는 함정 5가지로 시리즈를 마무리할게요.

왜 패턴 조합이 처음엔 어렵게 느껴질까요

이유는 네 가지예요.

첫째, 패턴을 하나씩 배웠는데 코드는 한 패턴만 쓰고 있는 게 아닙니다. 2~4편에서 한 패턴씩 풀 때는 깔끔한 그림이었는데, 실제 프로젝트는 한 클래스가 Observer이면서 동시에 Mediator인 식으로 여러 패턴을 동시에 입고 있어요. 어디서 어디까지가 어느 패턴인지 경계가 흐려져요.

둘째, "이 코드가 어떤 패턴인가"라는 질문이 잘못된 질문일 때가 많습니다. 현실 코드에서는 패턴들이 변형되거나 섞여 나타나는데, 정확히 GoF 책의 다이어그램과 일치하는 경우는 오히려 드물어요. 그래서 "이게 진짜 Observer 맞아?" 하다가 길을 잃기 쉬워요.

셋째, 패턴을 너무 많이 알다 보니 어디다 뭘 써야 할지 결정 못 하는 단계가 옵니다. 망치를 손에 든 사람에게는 모든 게 못으로 보인다고, 패턴을 배우자마자 모든 코드에 패턴을 박고 싶은 욕구가 옵니다. 그러다 단순한 문제까지 복잡하게 풀게 돼요.

넷째, 리팩토링을 어디서부터 시작해야 할지 막막합니다. "이 코드 SOLID에 안 맞는다"는 건 보이는데, 한꺼번에 다 고치자니 너무 크고, 부분만 고치자니 어디부터인지 안 잡혀요.

해결법은 한 가지예요. 실전 프로젝트 한 개를 V1 → V2 → V3로 단계별로 따라가 보는 것. 이 글의 승차 공유 서비스 예시가 그 단계 전체를 보여줘요. 같은 시스템을 세 번 짜면서 — 처음엔 동작만 되게 만들고(V1), 그 다음 SOLID로 책임을 분리하고(V2), 마지막에 패턴으로 다듬는(V3) 흐름을 한 번 따라가 보면 갑자기 "아, 패턴들이 어떻게 같이 사는지 보이네"라는 순간이 옵니다.

시리즈 전체의 큰 그림

본문에 들어가기 전에 6편짜리 시리즈가 어떤 큰 그림을 그리려 했는지 한 번 정리할게요. 시리즈를 처음부터 본 분도, 6편만 보러 온 분도 도움이 될 거예요.

주제한 줄 요약
1편SOLID 원칙변경 한 번이 다른 코드를 안 깨도록 5가지 처방전
2편생성 패턴객체를 어떻게 만들 것인가 — Singleton·Factory·Builder 등
3편구조 패턴객체를 어떻게 조합할 것인가 — Adapter·Decorator·Proxy 등
4편행위 패턴객체들이 어떻게 협력할 것인가 — Observer·Strategy·Command 등
5편OOP 원칙4가지 기둥(캡슐화·상속·다형성·추상화) + 접근제어자·UML
6편패턴 조합 실전모든 걸 한 데 묶어 실제 시스템에 적용하는 법

이 모든 게 한 가지 목표를 향해 가요 — 변경하기 쉽고, 확장하기 쉬우며, 이해하기 쉬운 코드. 패턴은 그 목표를 위한 도구지 목표 자체가 아니에요. 6편에서 이 점을 계속 짚을 겁니다.

승차 공유 서비스 예시 — 시스템 요구사항

리팩토링의 대상이 될 시스템부터 정리할게요. 평범한 승차 공유 서비스(택시 호출 앱과 비슷한 그림)예요.

기능적 요구사항

  • 승객이 차량 탑승을 요청 가능
  • 근접성 기반으로 가장 가까운 운전자를 매칭
  • 다양한 차량 유형 지원 (자동차, 자전거, 고급 차량)
  • 다양한 요금 계산 전략 지원 (표준, 공유, 고급)
  • 실시간 탑승 상태 알림 (승객·운전자 모두에게)

비기능적 요구사항

  • 확장성 — 새로운 차량 유형이나 요금 정책 추가가 쉬워야 함
  • 유지보수성 — SOLID 원칙 준수
  • 관심사 분리 — 각 기능을 독립적인 컴포넌트로

핵심 클래스 구조

├── domain/
│   ├── User (abstract)
│   ├── Driver (extends User)
│   ├── Passenger (extends User)
│   ├── Vehicle (abstract)
│   ├── Car (extends Vehicle)
│   ├── Bike (extends Vehicle)
│   ├── Location
│   ├── Ride
│   └── RideStatus (enum)
├── strategy/
│   ├── FareStrategy (interface)
│   ├── StandardFareStrategy
│   ├── SharedFareStrategy
│   └── LuxuryFareStrategy
└── service/
    └── RideMatchingSystem

이제 V1부터 V3까지 단계별로 따라가 봅시다.

V1 — 동작은 되지만 모든 게 한 클래스에

가장 먼저 짜는 사람이 자연스럽게 떠올리는 코드예요. 동작은 되지만 SOLID 어느 원칙도 안 지키는 상태.

// V1: 모든 것이 하나의 클래스에 모여있는 초기 구현
public class RideSharingAppService {
    // 데이터 저장
    List<Driver> drivers = new ArrayList<>();
    List<Passenger> passengers = new ArrayList<>();

    // 등록 메서드
    public void addDriver(Driver driver) {
        drivers.add(driver);
    }

    public void addPassenger(Passenger passenger) {
        passengers.add(passenger);
    }

    // 핵심 비즈니스 로직: 차량 예약
    public void bookRide(Passenger passenger, double distance) {
        // 운전자 없음 처리
        if (drivers.isEmpty()) {
            System.out.println("No drivers are available for " + passenger.getName());
            return;
        }

        // 가장 가까운 운전자 찾기 (브루트 포스)
        Driver assignedDriver = null;
        double minDistance = Double.MAX_VALUE;

        for (Driver driver : drivers) {
            double currentDriverDistance = calculateDistance(
                passenger.getLocation(),
                driver.getLocation()
            );
            if (currentDriverDistance < minDistance) {
                minDistance = currentDriverDistance;
                assignedDriver = driver;
            }
        }

        // 요금 계산
        double expectedFare = calculateFare(assignedDriver.getVehicle(), distance);

        // 결과 출력
        System.out.println("Ride booked for " + passenger.getName() +
                          " with driver " + assignedDriver.getName());
        System.out.println("For a fare of " + expectedFare);
        System.out.println("Your driver is on the way: " + assignedDriver.getName());
    }

    // 거리 계산 (유클리드 공식)
    private double calculateDistance(Location loc1, Location loc2) {
        double deltaX = loc1.getLatitude() - loc2.getLatitude();
        double deltaY = loc1.getLongitude() - loc2.getLongitude();
        return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
    }

    // 요금 계산 - OCP 위반!
    private double calculateFare(Vehicle vehicle, double distance) {
        if (vehicle.type.equals("car")) {
            return distance * 20; // 자동차: km당 20
        } else if (vehicle.type.equals("bike")) {
            return distance * 10; // 자전거: km당 10
        } else {
            return distance * 8;  // 기본값
        }
    }
}

V1의 SOLID 위반 분석

이 코드는 1편의 SOLID 원칙 5가지를 거의 다 위반해요.

  • SRP 위반RideSharingAppService 한 클래스가 드라이버/승객 관리·거리 계산·요금 계산·운전자 매칭을 다 짊어짐 (책임 4~5개)
  • OCP 위반 — 새 차량 유형 추가 시 calculateFare()의 if-else를 수정해야 함. 기존 테스트된 코드에 버그 유입 위험
  • LSP 위반 가능성vehicle.type.equals("car") 같은 문자열 비교로 타입을 확인. 다형성을 쓰지 않아 오타 시 런타임 버그
  • ISP 위반 — 기능별 인터페이스가 없음. 요금 계산·거리 계산·매칭이 분리된 인터페이스 없이 한 클래스에
  • DIP 위반 — 고수준 모듈(RideSharingAppService)이 저수준 세부 구현에 직접 의존. calculateFare() 변경 시 bookRide()도 영향

거기에 버그까지 — 이미 예약된 운전자가 다시 배정될 수 있어요. 가용 상태를 추적하지 않거든요.

여기서 시험 함정이 하나 있어요. V1을 보고 "당연히 나쁜 코드"라고 단정하지 마세요. 작은 프로토타입이거나 한 번 쓰고 버릴 스크립트라면 V1 정도가 오히려 적절할 수 있어요. 패턴은 변경 가능성이 보일 때 도입하는 거지, 처음부터 모든 코드를 V3 수준으로 짤 필요는 없습니다 (YAGNI). 다만 운영하면서 변경이 자주 일어나는 부분이 보이면 그때 단계적으로 V2 → V3로 옮겨 가요.

V2 — SOLID 원칙으로 책임을 분리

이제 SOLID 원칙으로 책임을 쪼개봅시다.

도메인 클래스 분리 (SRP 준수)

거리 계산은 Location 자신이 제일 잘 알 수 있어요(자기 데이터로 계산). User의 공통 속성은 추상 클래스로 뽑고요.

// Location 클래스: 위치 정보 + 거리 계산 책임 분리
// 거리 계산은 Location이 제일 잘 알 수 있음 (자기 데이터로 계산)
public class Location {
    private double latitude;
    private double longitude;

    public Location(double latitude, double longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }

    public double getLatitude() { return latitude; }
    public double getLongitude() { return longitude; }

    // SRP: 거리 계산 로직을 Location으로 이동
    // 유클리드 거리 (간단한 근사값)
    // 더 정확하려면 Haversine 공식을 사용해야 함
    public double calculateDistance(Location other) {
        double deltaX = this.latitude - other.latitude;
        double deltaY = this.longitude - other.longitude;
        return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
    }
}

// User 추상 클래스: Driver와 Passenger의 공통 속성 추출 (DRY + SRP)
public abstract class User {
    protected String name;      // protected: 자식 클래스에서 직접 접근
    protected String email;
    protected Location location;

    public User(String name, String email, Location location) {
        this.name = name;
        this.email = email;
        this.location = location;
    }

    // Getters
    public String getName() { return name; }
    public String getEmail() { return email; }
    public Location getLocation() { return location; }
    public void setLocation(Location location) { this.location = location; }

    // 추상 메서드: 알림 기능은 자식마다 다르게 구현
    public abstract void notify(String message);
}

// Driver 클래스: User 상속 + 운전자 고유 속성 추가
public class Driver extends User {
    private Vehicle vehicle;
    private boolean available; // 가용 상태 추적 (V1의 버그 수정!)

    public Driver(String name, String email, Location location, Vehicle vehicle) {
        super(name, email, location);
        this.vehicle = vehicle;
        this.available = true; // 초기에는 모두 가용
    }

    public Vehicle getVehicle() { return vehicle; }
    public boolean isAvailable() { return available; }
    public void setAvailable(boolean available) { this.available = available; }

    @Override
    public void notify(String message) {
        System.out.println("[Driver " + name + "] " + message);
    }
}

// Passenger 클래스: User 상속 + 승객 고유 속성 추가
public class Passenger extends User {
    public Passenger(String name, String email, Location location) {
        super(name, email, location);
    }

    @Override
    public void notify(String message) {
        System.out.println("[Passenger " + name + "] " + message);
    }
}

Vehicle 추상화 (OCP + LSP 준수)

차량 유형별 요금을 if-else로 분기하는 대신, 각 차량이 자기 요금을 알게 만듭니다. 다형성이 OCP를 가능하게 해요.

// Vehicle 추상 클래스: OCP 적용 - 새 차량 추가 시 이 클래스 수정 없음
public abstract class Vehicle {
    protected String numberPlate;

    public Vehicle(String numberPlate) {
        this.numberPlate = numberPlate;
    }

    public String getNumberPlate() { return numberPlate; }

    // 추상 메서드: 각 차량 유형이 자신의 요금을 알고 있음
    // 다형성으로 if-else 제거!
    public abstract double getFarePerKilometer();
    public abstract String getVehicleType();
}

// Car 클래스
public class Car extends Vehicle {
    public Car(String numberPlate) {
        super(numberPlate);
    }

    @Override
    public double getFarePerKilometer() {
        return 20.0; // 자동차: km당 20
    }

    @Override
    public String getVehicleType() { return "Car"; }
}

// Bike 클래스
public class Bike extends Vehicle {
    public Bike(String numberPlate) {
        super(numberPlate);
    }

    @Override
    public double getFarePerKilometer() {
        return 10.0; // 자전거: km당 10
    }

    @Override
    public String getVehicleType() { return "Bike"; }
}

// 새 차량 추가: 기존 코드 수정 없이 클래스만 추가 (OCP 준수)
public class LuxuryCar extends Vehicle {
    public LuxuryCar(String numberPlate) {
        super(numberPlate);
    }

    @Override
    public double getFarePerKilometer() {
        return 50.0; // 고급차: km당 50
    }

    @Override
    public String getVehicleType() { return "Luxury Car"; }
}

전략 패턴 — 요금 계산 (Strategy + OCP + DIP)

요금 정책(표준·공유·고급)도 인터페이스로 추상화해요. 이게 4편에서 다룬 Strategy 패턴의 정석 적용 사례예요.

// FareStrategy 인터페이스: 요금 계산 전략의 계약
public interface FareStrategy {
    double calculateFare(Vehicle vehicle, double distance);
    String getStrategyName();
}

// 표준 요금: 기본 요금 * 거리
public class StandardFareStrategy implements FareStrategy {
    @Override
    public double calculateFare(Vehicle vehicle, double distance) {
        // vehicle.getFarePerKilometer()를 통해 차량 타입별 요금 처리
        // if-else 없이 다형성으로!
        return vehicle.getFarePerKilometer() * distance;
    }

    @Override
    public String getStrategyName() { return "Standard Fare"; }
}

// 공유 요금: 50% 할인
public class SharedFareStrategy implements FareStrategy {
    @Override
    public double calculateFare(Vehicle vehicle, double distance) {
        return (vehicle.getFarePerKilometer() * distance) * 0.50; // 50% 할인
    }

    @Override
    public String getStrategyName() { return "Shared Fare (50% discount)"; }
}

// 고급 요금: 50% 할증
public class LuxuryFareStrategy implements FareStrategy {
    @Override
    public double calculateFare(Vehicle vehicle, double distance) {
        return (vehicle.getFarePerKilometer() * distance) * 1.5; // 50% 할증
    }

    @Override
    public String getStrategyName() { return "Luxury Fare (50% premium)"; }
}

여기서 시험 함정이 하나 있어요. Strategy 패턴은 OCP·DIP·SRP를 한 번에 만족시킵니다. 알고리즘별로 클래스를 분리하니 SRP, 공통 인터페이스에 의존하니 DIP, 새 전략 추가 시 기존 코드 수정 없으니 OCP. 이래서 Strategy가 가장 자주 쓰이는 행위 패턴이에요.

V3 — 디자인 패턴 적용 완성

V2까지는 책임 분리만 했어요. V3에서는 패턴을 한 단계 더 적용합니다 — Observer 패턴으로 상태 변경 알림을, Mediator 패턴으로 객체 간 통신 중재를.

Ride 클래스 + RideStatus Enum (Observer 패턴 적용)

탑승의 상태가 바뀔 때마다 승객과 운전자에게 자동으로 알림이 가야 해요. 이게 4편의 Observer 패턴 시그니처입니다.

// RideStatus: 탑승의 생명주기를 열거형으로 명확하게 정의
public enum RideStatus {
    SCHEDULED,   // 예약됨 (기본값)
    ONGOING,     // 진행 중
    COMPLETED    // 완료
}

// Ride 클래스: 탑승 정보를 캡슐화 + Observer 패턴으로 상태 변경 알림
public class Ride {
    private final Passenger passenger;
    private final Driver driver;
    private final double distance;
    private final FareStrategy fareStrategy;
    private RideStatus status;
    private double fare;

    public Ride(Passenger passenger, Driver driver, double distance, FareStrategy fareStrategy) {
        this.passenger = passenger;
        this.driver = driver;
        this.distance = distance;
        this.fareStrategy = fareStrategy;
        this.status = RideStatus.SCHEDULED; // 기본 상태
    }

    // 요금 계산: Strategy 패턴 활용
    public void calculateFare() {
        this.fare = fareStrategy.calculateFare(driver.getVehicle(), distance);
    }

    // 상태 업데이트 + Observer 패턴으로 알림
    public void updateStatus(RideStatus newStatus) {
        this.status = newStatus;
        notifyUsers(newStatus); // Observer처럼 관련자에게 알림
    }

    // 승객과 운전자에게 알림 전송
    private void notifyUsers(RideStatus status) {
        String message = "Your ride is " + status;
        passenger.notify(message);  // Passenger.notify() 호출
        driver.notify(message);     // Driver.notify() 호출
    }

    public double getFare() { return fare; }
    public RideStatus getStatus() { return status; }
    public Passenger getPassenger() { return passenger; }
    public Driver getDriver() { return driver; }
}

RideMatchingSystem — Mediator 역할

이 시스템의 진짜 핵심이에요. 승객·운전자·Ride 사이의 복잡한 상호작용을 한 곳에서 중재합니다. 이게 4편의 Mediator 패턴이에요. 승객과 운전자가 서로를 직접 알지 않아도 시스템이 동작해요.

// RideMatchingSystem: 승객, 운전자, Ride 간의 복잡한 상호작용을 중재 (Mediator)
// 동시에 SRP: 운전자 매칭이라는 단일 책임만 가짐
public class RideMatchingSystem {
    // 가용 운전자 목록 관리
    private ArrayList<Driver> availableDrivers;

    public RideMatchingSystem() {
        this.availableDrivers = new ArrayList<>();
    }

    public void addDriver(Driver driver) {
        availableDrivers.add(driver);
    }

    // 핵심 메서드: 탑승 요청 처리
    public void requestRide(Passenger passenger, double distance, FareStrategy fareStrategy) {
        // 1. 가용 운전자 없음 처리
        if (availableDrivers.isEmpty()) {
            passenger.notify("No drivers are available right now. Please try again later.");
            return;
        }

        // 2. 가장 가까운 운전자 찾기 (헬퍼 메서드로 분리)
        Driver nearestDriver = findNearestDriver(passenger.getLocation());

        if (nearestDriver == null) {
            passenger.notify("No available drivers found.");
            return;
        }

        // 3. Ride 객체 생성
        Ride ride = new Ride(passenger, nearestDriver, distance, fareStrategy);

        // 4. 요금 계산
        ride.calculateFare();

        // 5. 운전자를 가용 목록에서 제거 (중복 배정 방지 - V1 버그 수정)
        availableDrivers.remove(nearestDriver);
        nearestDriver.setAvailable(false);

        // 6. 초기 알림 전송
        passenger.notify("Ride scheduled! Fare: " + ride.getFare() +
                        " (" + fareStrategy.getStrategyName() + ")");
        nearestDriver.notify("New ride request! Fare: " + ride.getFare() +
                            " from " + passenger.getName());

        // 7. 탑승 상태 업데이트 (Observer 패턴: 상태 변경 시 자동 알림)
        ride.updateStatus(RideStatus.ONGOING);   // 진행 중으로 변경 → 알림 발송

        // 8. 탑승 완료 시뮬레이션
        ride.updateStatus(RideStatus.COMPLETED); // 완료로 변경 → 알림 발송

        // 9. 운전자를 다시 가용 목록에 추가
        availableDrivers.add(nearestDriver);
        nearestDriver.setAvailable(true);
    }

    // 헬퍼 메서드: 가장 가까운 운전자 찾기 (브루트 포스)
    private Driver findNearestDriver(Location passengerLocation) {
        Driver assignedDriver = null;
        double minDistance = Double.MAX_VALUE;

        for (Driver driver : availableDrivers) {
            if (!driver.isAvailable()) continue; // 가용 운전자만 고려

            // Location.calculateDistance() 사용 (SRP: 거리 계산은 Location의 책임)
            double distance = driver.getLocation().calculateDistance(passengerLocation);
            if (distance < minDistance) {
                minDistance = distance;
                assignedDriver = driver;
            }
        }

        return assignedDriver;
    }
}

클라이언트 테스트 코드

// Client.java: 전체 시스템 통합 테스트
public class Client {
    public static void main(String[] args) {
        System.out.println("=== Ride Sharing Test ===\n");

        RideMatchingSystem system = new RideMatchingSystem();

        // 위치 생성
        Location driverOneLocation = new Location(0, 0);
        Location driverTwoLocation = new Location(10, 10);
        Location passengerOneLocation = new Location(1, 1); // driverOne에 가까움

        // 차량 생성
        Vehicle car = new Car("CAR-001");
        Vehicle bike = new Bike("BIKE-001");

        // 사용자 생성
        Driver driverOne = new Driver("David", "david@example.com", driverOneLocation, car);
        Driver driverTwo = new Driver("Sarah", "sarah@example.com", driverTwoLocation, bike);
        Passenger passengerOne = new Passenger("Alice", "alice@example.com", passengerOneLocation);

        // 테스트 1: 운전자 없을 때 요청
        System.out.println("--- Test 1: No drivers available ---");
        system.requestRide(passengerOne, 10, new StandardFareStrategy());
        // Expected: "No drivers are available right now."

        System.out.println();

        // 운전자 추가
        system.addDriver(driverOne);
        system.addDriver(driverTwo);

        // 테스트 2: 정상 요청 - 표준 요금
        System.out.println("--- Test 2: Standard fare request ---");
        system.requestRide(passengerOne, 10, new StandardFareStrategy());
        // Expected: David 배정 (더 가까움), 요금 = 20.0 * 10 = 200

        System.out.println();

        // 테스트 3: 공유 요금 요청
        System.out.println("--- Test 3: Shared fare request ---");
        system.requestRide(passengerOne, 10, new SharedFareStrategy());
        // Expected: 50% 할인된 요금

        System.out.println();

        // 테스트 4: 고급 요금 요청
        System.out.println("--- Test 4: Luxury fare request ---");
        system.requestRide(passengerOne, 10, new LuxuryFareStrategy());
        // Expected: 50% 할증된 요금
    }
}

예상 출력

=== Ride Sharing Test ===

--- Test 1: No drivers available ---
[Passenger Alice] No drivers are available right now. Please try again later.

--- Test 2: Standard fare request ---
[Passenger Alice] Ride scheduled! Fare: 200.0 (Standard Fare)
[Driver David] New ride request! Fare: 200.0 from Alice
[Passenger Alice] Your ride is ONGOING
[Driver David] Your ride is ONGOING
[Passenger Alice] Your ride is COMPLETED
[Driver David] Your ride is COMPLETED

--- Test 3: Shared fare request ---
[Passenger Alice] Ride scheduled! Fare: 100.0 (Shared Fare (50% discount))
...

--- Test 4: Luxury fare request ---
[Passenger Alice] Ride scheduled! Fare: 300.0 (Luxury Fare (50% premium))
...

패턴 시너지 — 한 시스템에 들어간 다섯 가지 패턴

V3 코드에 어떤 패턴들이 들어갔는지 정리하면 이래요.

패턴위치역할
ObserverRide.updateStatus()passenger.notify() + driver.notify()상태 변경 시 자동으로 관련자에게 알림
StrategyStandardFareStrategy / SharedFareStrategy / LuxuryFareStrategy런타임에 요금 계산 방식을 교체 가능
MediatorRideMatchingSystemDriver·Passenger·Ride 간 통신을 중재. 각 객체는 서로를 직접 알지 않음
Abstract Factory 방식Vehicle 추상 클래스 + Car/Bike/LuxuryCar 구체 구현새 차량 추가 시 기존 코드 수정 없음
Template Method 방식Vehicle.getFarePerKilometer() 서브클래스 구현공통 골격은 부모, 가변 부분만 자식이 채움

여기서 정말 중요한 시험 함정이 있어요. 이 한 시스템 안에 다섯 가지 패턴이 동시에 살아 있어요. 그런데 코드를 처음 보는 사람은 "어디까지가 어느 패턴인가"를 한 번에 못 잡아요. 그게 정상이에요. 패턴은 이름표가 붙어 있는 게 아니라 구조 안에 녹아 있어서, 어떤 코드를 보고 "이건 Mediator네"라고 인식하는 데는 익숙해질 시간이 좀 필요합니다.

💡 핵심 인사이트

패턴들은 따로따로 사는 게 아니라 함께 쓰일 때 진짜 빛난다. Observer + Strategy + Mediator 조합이 한 시스템을 떠받치는 V3가 그 증거.

자주 함께 사용되는 패턴 조합 6가지

승차 공유 서비스 예시 외에도, 실무에서 자주 같이 등장하는 패턴 조합 여섯 가지를 정리해 둘게요. 각 조합이 어떤 시나리오를 푸는지 알면 코드를 봤을 때 "아, 이건 ○○ 조합이네" 하고 빠르게 잡아낼 수 있어요.

조합 ① Observer + Mediator

  • Observer — 상태 변경 알림
  • Mediator — 알림 대상을 결정하고 전달
  • — 채팅 시스템, 이벤트 시스템, 승차 공유 서비스(이번 글의 V3)

조합 ② Strategy + Factory

  • Strategy — 알고리즘을 캡슐화
  • Factory — 적절한 Strategy 객체를 생성
  • — 결제 처리(결제 방식별 Strategy를 Factory가 생성), 압축 알고리즘 선택

조합 ③ Decorator + Factory

  • Decorator — 기능을 동적으로 추가
  • Factory — 어떤 Decorator 조합을 만들지 결정
  • — 피자 주문 시스템(토핑 조합), Java I/O 스트림(BufferedInputStream + DataInputStream)

조합 ④ Composite + Visitor

  • Composite — 트리 구조 표현
  • Visitor — 트리를 순회하며 작업 수행
  • — 파일 시스템 조작, AST 처리(컴파일러)

조합 ⑤ Builder + Singleton

  • Builder — 복잡한 설정 객체를 단계적으로 구성
  • Singleton — 설정 객체를 전역에서 하나만 유지
  • — 앱 설정 객체, 데이터베이스 커넥션 풀 빌더

조합 ⑥ Proxy + Decorator

  • 둘 다 Wrapping 방식이지만 목적이 다름
  • Proxy — 접근 제어, 지연 초기화
  • Decorator — 기능 추가
  • 함께 — 기능을 추가하면서 접근도 제어 (예: 캐싱 + 권한 체크)

실전 패턴 적용 가이드 — 결정 트리

새 기능을 짤 때 어느 패턴을 쓸지 결정하는 트리예요. 외울 필요는 없고, 막힐 때 한 번 펼쳐 보면 돼요.

Q1. 객체 생성이 복잡한가? (생성 패턴)

  • 단일 인스턴스 필요 → Singleton
  • 타입이 다양하고 교체 가능 → Factory Method
  • 관련 객체 군이 있음 → Abstract Factory
  • 선택적 파라미터 많음 → Builder
  • 기존 객체를 복사하고 싶음 → Prototype

Q2. 클래스 구조·조합이 복잡한가? (구조 패턴)

  • 인터페이스 불일치 → Adapter
  • 기능을 동적으로 추가 → Decorator
  • 접근 제어·지연 로딩 → Proxy
  • 트리 구조 균일 처리 → Composite
  • 복잡한 서브시스템 단순화 → Facade
  • 메모리 최적화 → Flyweight

Q3. 객체 간 통신이 복잡한가? (행위 패턴)

  • 상태 저장·복원 필요 → Memento
  • 상태 변경 시 알림 → Observer
  • 알고리즘을 런타임에 교체 → Strategy
  • 요청을 객체로 만들고 싶음 → Command
  • 공통 알고리즘 구조, 세부 구현 위임 → Template Method
  • 커스텀 컬렉션 순회 → Iterator
  • 상태에 따라 동작 다름 → State
  • 객체 간 복잡한 통신 → Mediator

리팩토링 시그널 — 코드 냄새가 패턴을 부른다

이런 코드 냄새(Code Smell) 가 보이면 패턴 도입을 고려하세요.

코드 냄새추천 패턴
if (type == A) ... else if (type == B) ...Strategy 또는 다형성
new ConcreteClass1()·new ConcreteClass2()가 여러 곳에 흩어짐Factory
if (obj != null) 체크가 여러 곳에Null Object 또는 Optional
긴 파라미터 목록 (5개 이상)Builder 또는 파라미터 객체
하나의 클래스에 여러 기능이 섞여 있음SRP 적용, 책임 분리
새 기능 추가 시 여러 클래스를 수정OCP 적용, 추상화 도입
객체들이 서로 너무 많이 참조Mediator

패턴별 실제 사용 사례 — Java·Spring

이론이 아니라 진짜로 쓰이는 사례도 짚어둘게요.

Java 표준 라이브러리

// Singleton: Runtime 클래스
Runtime runtime = Runtime.getRuntime(); // 단 하나의 인스턴스

// Iterator: Collections
List<String> list = Arrays.asList("a", "b", "c");
Iterator<String> it = list.iterator(); // Iterator 패턴

// Decorator: Java I/O
InputStream is = new FileInputStream("file.txt");
BufferedInputStream bis = new BufferedInputStream(is); // Decorator!
DataInputStream dis = new DataInputStream(bis); // 또 Decorator!

// Strategy: Comparator
List<String> words = Arrays.asList("banana", "apple", "cherry");
words.sort((a, b) -> a.compareTo(b)); // Lambda as Strategy

// Builder: StringBuilder
String result = new StringBuilder()
    .append("Hello")
    .append(", ")
    .append("World")
    .toString();

// Template Method: AbstractList, AbstractMap 등
public class MyList extends AbstractList<String> {
    @Override
    public String get(int index) { ... } // 추상 메서드 구현
    @Override
    public int size() { ... }
}

// Flyweight: String Pool in Java
String s1 = "hello"; // 풀에서 재사용
String s2 = "hello"; // 같은 객체!
System.out.println(s1 == s2); // true - Flyweight!

Spring Framework

  • Singleton — Spring Bean (기본 스코프가 싱글톤). @Service·@Repository·@Component 모두 기본 싱글톤
  • FactoryBeanFactory·ApplicationContext. context.getBean("myBean")이 Factory Method
  • Proxy — Spring AOP. @Transactional·@Cacheable이 프록시로 처리
  • ObserverApplicationEvent·ApplicationEventPublisher·@EventListener
  • Template MethodJdbcTemplate·RestTemplate. 공통 로직(연결·쿼리·닫기) + 가변 로직(파싱)
  • DecoratorHandlerInterceptor가 요청 처리를 장식

전체 GoF 패턴 카탈로그를 찾아보고 싶으면 Refactoring.Guru의 디자인 패턴 카탈로그가 좋은 출발점이에요. 이 시리즈에서 다룬 패턴 외에 다른 변형들과 시각화된 다이어그램이 함께 있어요.

자주 빠지는 함정 5가지

마지막으로 패턴 조합을 다룰 때 빠지기 쉬운 함정 다섯 가지를 짚을게요.

1. 패턴을 억지로 적용하지 말 것

패턴은 도구이지 목표가 아닙니다. 간단한 문제에 복잡한 패턴을 적용하면 오버엔지니어링(Over-engineering)이에요. "이 코드가 나중에 바뀔 가능성이 있는가?"를 먼저 묻는 습관을 들이세요.

// 오버엔지니어링 예시: 단순 hello world에 패턴 적용
// 이렇게 할 필요 없음!
interface MessageStrategy {
    String getMessage();
}

class HelloWorldStrategy implements MessageStrategy {
    @Override
    public String getMessage() { return "Hello, World!"; }
}

// 그냥 이렇게 하면 됨:
System.out.println("Hello, World!");

2. 패턴 이름에 집착하지 말 것

패턴의 이름을 아는 것보다 왜·언제 사용하는지 아는 것이 더 중요합니다. 현실 코드에서는 패턴들이 변형되거나 조합되어 나타나요. "이것이 정확히 Observer 패턴인가?"보다 "이 구조가 문제를 해결하는가?"가 더 중요합니다.

3. SOLID와 패턴은 수단이지 목적이 아님

"이 코드에 DIP를 적용했다"보다 "이 코드가 변경에 유연한가?"가 진짜 목표입니다. SOLID를 100% 지키면서 코드를 작성하면 때로는 너무 추상적이고 복잡해져요. 실용주의가 중요합니다 — 90%의 SOLID + 10%의 실용성이 100% SOLID + 불필요한 복잡성보다 낫습니다.

4. 리팩토링은 점진적으로

기존 코드를 한 번에 완전히 패턴으로 리팩토링하려 하지 마세요. 변경이 필요한 부분부터 시작하고, 테스트를 먼저 작성하세요. Red-Green-Refactor 사이클이 몸에 배면 리팩토링의 두려움이 줄어요 — 실패하는 테스트 작성(Red) → 통과하게 구현(Green) → 깔끔하게 정리(Refactor).

5. 복잡한 패턴 조합 시 문서화

여러 패턴이 조합된 코드는 처음 보는 사람에게 매우 복잡해 보일 수 있습니다. 어떤 패턴이 왜 사용되었는지 주석이나 문서로 설명하세요.

// 좋은 주석 예시:
// RideMatchingSystem은 Mediator 패턴으로 구현됨
// - Driver와 Passenger가 서로를 직접 참조하지 않도록 중재
// - requestRide() 내에서 Observer 패턴(Ride.updateStatus)도 활용됨
// - 요금 계산은 Strategy 패턴(FareStrategy)으로 런타임에 교체 가능
public class RideMatchingSystem {
    // ...
}

시험 직전 한 번 더 — 패턴 조합 압축 노트

여기까지가 패턴 조합 6편의 핵심입니다. 시리즈 전체를 마무리하는 의미로 압축 노트를 모아둘게요.

  • 시리즈 6편 큰 그림 — 1편 SOLID → 2~4편 GoF 패턴 → 5편 OOP → 6편 패턴 조합 실전
  • 모든 패턴이 향하는 한 가지 목표 — 변경하기 쉽고 확장하기 쉬운 코드
  • V1 — 한 클래스에 다 박은 코드. SOLID 다 위반, 동작은 됨. 작은 프로토타입엔 OK
  • V2 — SOLID로 책임 분리. SRP·OCP·LSP·DIP 적용
  • V3 — 패턴까지 적용. Observer + Strategy + Mediator 시너지
  • 거리 계산은 Location의 책임 — 자기 데이터로 계산하는 게 SRP 정석
  • Vehicle 추상화 — 각 차량이 자기 요금을 알게 → if-else 제거 (다형성)
  • Strategy 패턴은 OCP·DIP·SRP를 한 번에 만족 — 가장 자주 쓰이는 행위 패턴
  • Observer 패턴 신호updateStatus → 다수의 notify 호출
  • Mediator 패턴 신호 — 한 클래스가 여러 객체 사이의 통신을 중재
  • 한 시스템에 여러 패턴이 동시에 살 수 있음. 경계가 흐려도 정상
  • 자주 함께 쓰는 조합 6가지 — Observer+Mediator / Strategy+Factory / Decorator+Factory / Composite+Visitor / Builder+Singleton / Proxy+Decorator
  • 결정 트리 — 객체 생성 복잡? 구조 복잡? 통신 복잡? — 셋 중 어느 영역인지부터
  • 리팩토링 시그널 — 큰 if-else / 흩어진 new / 긴 파라미터 / 한 클래스에 여러 기능 / 강한 참조망
  • Java 표준 라이브러리 곳곳에 패턴 — Runtime·StringBuilder·BufferedInputStream·Comparator·String Pool
  • Spring Framework도 거의 다 패턴 — @Service 싱글톤 / AOP 프록시 / JdbcTemplate Template Method
  • 함정 — 억지 적용 / 이름 집착 / SOLID 100% 추구 / 빅뱅 리팩토링 / 문서 없는 패턴 조합
  • YAGNI — 처음부터 V3로 짤 필요 없음. 변경 가능성 보일 때 단계적으로
  • 목적이 우선, 패턴은 도구 — "변경에 유연한가"가 진짜 질문

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

이 시리즈를 마치며

여기까지 정말 고생 많으셨어요. 6편을 모두 따라온 분이라면, 이제 다음 같은 그림이 머릿속에 자리 잡았을 거예요.

  • OOP의 4가지 기둥(캡슐화·상속·다형성·추상화)이 어떤 토대 위에 서 있는지
  • SOLID 원칙 5가지가 그 토대 위에서 변경의 영향 범위를 어떻게 좁히는지
  • GoF 23가지 디자인 패턴이 SOLID와 OOP를 구체적인 코드 형태로 어떻게 풀어내는지
  • 그 모든 게 함께 쓰일 때 한 시스템을 어떻게 떠받치는지

이 시리즈가 한 가지를 강조하려고 했다면 — 패턴을 외우는 게 목표가 아니다라는 점이에요. 패턴은 같은 문제를 여러 번 만난 선배 개발자들이 정리해둔 공용 어휘예요. 어휘를 익히면 코드 리뷰에서 "이건 Strategy로 빼면 좋겠다" 같은 한 마디로 큰 그림이 통하게 돼요. 하지만 어휘 자체가 목표는 아니에요. 목표는 늘 변경에 유연하고, 확장이 쉬우며, 이해하기 쉬운 코드를 짜는 거예요.

그리고 한 가지 더 — 처음부터 모든 코드를 V3 수준으로 짤 필요는 없어요. 작은 스크립트는 V1으로도 충분하고, 변경이 자주 일어나는 부분이 보이면 그때 단계적으로 V2 → V3로 옮겨 가는 거예요. YAGNI(You Aren't Gonna Need It)와 DRY의 균형, 그리고 점진적 리팩토링 — 이 세 가지를 손에 익히면 패턴은 자연스럽게 따라옵니다.

이 시리즈를 다 읽고 나서도 막힐 때가 분명 올 거예요. 그럴 때마다 "내가 풀려는 진짜 문제가 뭐지?" 한 번만 물어보세요. 패턴은 그 질문에 답을 도와주는 도구지, 그 자체가 답은 아니에요.

그리고 또 한 가지 — 이 시리즈에서 다룬 코드를 한 번이라도 IDE를 열고 직접 따라 쳐 봤다면, 머릿속에 훨씬 깊이 남아 있을 거예요. 안 해보신 분이 있다면 V1 → V2 → V3를 본인 손으로 한 번 옮겨 보세요. 30분~1시간이면 끝나요. 그 시간이 시리즈 전체를 다시 한 번 통째로 굽는 효과를 줍니다.

긴 여정 함께 해주셔서 정말 고마워요. 이 시리즈가 누군가의 코드 한 줄을 더 단단하게 만드는 데 작은 도움이 됐다면, 그걸로 충분합니다. 또 만나요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!