dev.syw

SOLID, 핵심 GoF 패턴, 계층형 아키텍처와 의존성 관리로 유지보수 가능한 시스템을 설계한다.

디자인 패턴과 실전 아키텍처

입문편에서 클래스, 상속, 인터페이스, 다형성을 익혔다면, 이제 그 도구들을 어떻게 조합해야 변경에 강하고 협업하기 쉬운 시스템을 만들 수 있는지를 다룰 차례입니다. 코드가 동작하는 것과 잘 설계된 것은 전혀 다른 이야기입니다.

이 레슨은 GoF(Gang of Four) 디자인 패턴의 핵심 7가지와 SOLID 원칙, 그리고 이를 실제 프로젝트 구조에 녹여내는 방법을 다룹니다. 패턴을 암기하는 것이 목적이 아니라, 어떤 설계 문제가 왜 발생하는지 이해하고 적절한 도구를 선택하는 판단력을 기르는 데 집중합니다.

학습 목표

  • SOLID 원칙 각각의 의도를 이해하고, 위반 시 발생하는 실제 문제를 설명할 수 있다.
  • 생성 패턴(Factory Method, Builder, Singleton)의 적용 시나리오와 흔한 함정을 구분할 수 있다.
  • 구조/행위 패턴(Strategy, Observer, Decorator, Adapter)을 코드로 구현하고 장단점을 비교할 수 있다.
  • 의존성 역전 원칙(DIP) 과 DI 컨테이너 동작 방식을 설명할 수 있다.
  • 계층형 아키텍처 를 기반으로 패키지 구조를 설계하고, 안티패턴을 식별해 점진적으로 리팩터링할 수 있다.

SOLID 원칙과 결합도/응집도

SOLID는 다섯 가지 설계 원칙의 두문자어입니다. 원칙마다 "어떤 변경이 발생했을 때 얼마나 많은 코드를 수정해야 하는가"라는 유지보수 비용과 직결됩니다.

단일 책임 원칙(SRP) — 클래스 변경 이유는 하나여야 한다

// ❌ 주문 처리와 이메일 발송이 한 클래스에 섞여 있음
public class OrderService {
    public void placeOrder(Order order) {
        // 재고 확인, 결제 처리...
        String body = "주문 완료: " + order.getId();
        // SMTP 직접 호출 — 이메일 정책이 바뀌면 OrderService도 수정해야 함
        SmtpClient.send("customer@example.com", body);
    }
}

// ✅ 이메일 발송 책임을 분리
public class OrderService {
    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void placeOrder(Order order) {
        // 주문 핵심 로직만
        notificationService.notifyOrderPlaced(order);
    }
}

💡 TIP "클래스를 변경하는 이유가 두 가지라면 두 개의 액터(이해관계자)가 있다는 신호입니다." — Robert C. Martin

개방-폐쇄 원칙(OCP) — 확장에는 열려 있고, 수정에는 닫혀 있어야 한다

// ❌ 새로운 할인 정책이 추가될 때마다 if-else를 수정해야 함
public class DiscountCalculator {
    public double calculate(Order order, String type) {
        if ("MEMBER".equals(type)) return order.getTotal() * 0.9;
        if ("VIP".equals(type))    return order.getTotal() * 0.8;
        // 새 정책 추가 = 이 메서드 수정
        return order.getTotal();
    }
}

// ✅ 인터페이스로 확장 포인트를 열어 둠
public interface DiscountPolicy {
    double apply(double total);
}

public class VipDiscount implements DiscountPolicy {
    public double apply(double total) { return total * 0.8; }
}

public class DiscountCalculator {
    public double calculate(Order order, DiscountPolicy policy) {
        return policy.apply(order.getTotal());
    }
}

리스코프 치환 원칙(LSP) — 하위 타입은 상위 타입을 완전히 대체해야 한다

// ❌ Rectangle을 상속한 Square가 setWidth를 재정의하면 LSP 위반
// Square에서 setWidth(5)는 height도 5로 바꿔버림 — 호출자의 기대를 깨뜨림
public class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        super.setWidth(w);
        super.setHeight(w); // 암묵적 부수효과
    }
}

⚠️ 주의 LSP 위반은 컴파일 오류가 아닌 런타임 버그로 나타나므로 테스트 없이는 발견하기 어렵습니다. 상속보다 컴포지션을 우선하는 이유 중 하나입니다.

인터페이스 분리 원칙(ISP) / 의존성 역전 원칙(DIP)

ISP: 클라이언트가 사용하지 않는 메서드에 의존하도록 강제해서는 안 됩니다. DIP: 고수준 모듈은 저수준 구현이 아닌 추상화에 의존해야 합니다.

// ❌ 단일 거대 인터페이스 — 읽기만 필요한 클라이언트도 write 메서드 구현 강제
public interface UserRepository {
    User findById(long id);
    List<User> findAll();
    void save(User user);
    void delete(long id);
}

// ✅ 역할별로 인터페이스 분리
public interface UserReader {
    User findById(long id);
    List<User> findAll();
}

public interface UserWriter {
    void save(User user);
    void delete(long id);
}

// JpaUserRepository는 두 인터페이스를 모두 구현
public class JpaUserRepository implements UserReader, UserWriter { ... }

생성 패턴: 팩토리, 빌더, 싱글톤

Factory Method — 생성 로직을 서브클래스/팩토리에 위임

// 알림 타입에 따라 다른 객체를 생성해야 할 때
public interface Notification {
    void send(String message);
}

public class EmailNotification implements Notification {
    private final String address;
    public EmailNotification(String address) { this.address = address; }
    public void send(String message) {
        System.out.println("이메일 발송 → " + address + ": " + message);
    }
}

public class SmsNotification implements Notification {
    private final String phone;
    public SmsNotification(String phone) { this.phone = phone; }
    public void send(String message) {
        System.out.println("SMS 발송 → " + phone + ": " + message);
    }
}

// 팩토리: 생성 책임을 한 곳에 집중
public class NotificationFactory {
    public static Notification create(String type, String target) {
        return switch (type) {
            case "EMAIL" -> new EmailNotification(target);
            case "SMS"   -> new SmsNotification(target);
            default      -> throw new IllegalArgumentException("Unknown type: " + type);
        };
    }
}

// 사용 측 — 구체 클래스를 알 필요 없음
Notification n = NotificationFactory.create("EMAIL", "admin@example.com");
n.send("배포 완료");

Builder — 복잡한 객체 생성을 단계적으로

public class HttpRequest {
    private final String url;
    private final String method;
    private final Map<String, String> headers;
    private final String body;
    private final int timeoutMs;

    private HttpRequest(Builder b) {
        this.url       = b.url;
        this.method    = b.method;
        this.headers   = Collections.unmodifiableMap(b.headers);
        this.body      = b.body;
        this.timeoutMs = b.timeoutMs;
    }

    public static class Builder {
        private final String url;
        private String method = "GET";
        private Map<String, String> headers = new LinkedHashMap<>();
        private String body;
        private int timeoutMs = 5000;

        public Builder(String url) { this.url = url; }

        public Builder method(String method) { this.method = method; return this; }
        public Builder header(String k, String v) { headers.put(k, v); return this; }
        public Builder body(String body) { this.body = body; return this; }
        public Builder timeout(int ms) { this.timeoutMs = ms; return this; }
        public HttpRequest build() {
            if (url == null || url.isBlank())
                throw new IllegalStateException("URL은 필수입니다");
            return new HttpRequest(this);
        }
    }
}

// 사용
HttpRequest req = new HttpRequest.Builder("https://api.example.com/orders")
    .method("POST")
    .header("Content-Type", "application/json")
    .body("{\"item\":\"book\"}")
    .timeout(3000)
    .build();

💡 TIP Lombok의 @Builder는 이 패턴을 자동 생성해 줍니다. 하지만 직접 구현을 먼저 이해해야 커스텀 검증 로직이나 불변 컬렉션 처리 같은 세부 제어가 가능합니다.

Singleton — 적용과 함정

// ✅ 스레드 안전한 싱글톤 — 이른 초기화(Eager Initialization)
public class AppConfig {
    private static final AppConfig INSTANCE = new AppConfig();
    private final String env;

    private AppConfig() {
        this.env = System.getenv().getOrDefault("APP_ENV", "dev");
    }

    public static AppConfig getInstance() { return INSTANCE; }
    public String getEnv() { return env; }
}

// ✅ Holder 패턴 — 지연 초기화 + 스레드 안전
public class ConnectionPool {
    private ConnectionPool() { /* 무거운 초기화 */ }

    private static class Holder {
        static final ConnectionPool INSTANCE = new ConnectionPool();
    }

    public static ConnectionPool getInstance() { return Holder.INSTANCE; }
}

⚠️ 주의 싱글톤의 세 가지 함정

  1. 테스트 어려움 — 전역 상태는 테스트 간 오염을 유발합니다. Spring Bean처럼 DI로 관리하면 테스트에서 Mock으로 교체 가능합니다.
  2. 직렬화readResolve() 미구현 시 역직렬화에서 새 인스턴스가 생성될 수 있습니다.
  3. 리플렉션 공격Enum 기반 싱글톤이 이를 막는 가장 안전한 방법입니다.

구조/행위 패턴: Strategy, Observer, Decorator, Adapter

Strategy — 알고리즘을 캡슐화해 교체 가능하게

// 정렬 전략 예시
@FunctionalInterface
public interface SortStrategy<T> {
    void sort(List<T> data);
}

public class DataProcessor<T> {
    private SortStrategy<T> strategy;

    public DataProcessor(SortStrategy<T> strategy) {
        this.strategy = strategy;
    }

    public void setStrategy(SortStrategy<T> strategy) {
        this.strategy = strategy;
    }

    public List<T> process(List<T> data) {
        List<T> copy = new ArrayList<>(data);
        strategy.sort(copy);
        return copy;
    }
}

// 사용 — 람다로 간단하게 전략 교체
DataProcessor<Integer> processor = new DataProcessor<>(list -> Collections.sort(list));
processor.setStrategy(list -> list.sort(Comparator.reverseOrder()));

Observer — 이벤트 기반 느슨한 결합

// 도메인 이벤트 패턴
public interface OrderEventListener {
    void onOrderPlaced(Order order);
}

public class OrderEventBus {
    private final List<OrderEventListener> listeners = new CopyOnWriteArrayList<>();

    public void subscribe(OrderEventListener listener) { listeners.add(listener); }
    public void unsubscribe(OrderEventListener listener) { listeners.remove(listener); }

    public void publish(Order order) {
        listeners.forEach(l -> l.onOrderPlaced(order));
    }
}

// 구독자 — 각자 독립적으로 반응
public class InventoryService implements OrderEventListener {
    public void onOrderPlaced(Order order) {
        System.out.println("재고 차감: " + order.getItemId());
    }
}

public class AnalyticsService implements OrderEventListener {
    public void onOrderPlaced(Order order) {
        System.out.println("주문 데이터 집계: " + order.getId());
    }
}

// 연결
OrderEventBus bus = new OrderEventBus();
bus.subscribe(new InventoryService());
bus.subscribe(new AnalyticsService());
bus.publish(order); // 두 서비스 모두 알림받음

💡 TIP Java의 java.util.Observable은 deprecated입니다. Spring의 ApplicationEvent, Guava EventBus, RxJava 등의 성숙한 라이브러리를 실무에서 활용하세요.

Decorator — 기능을 동적으로 추가

// 로깅/캐싱을 비즈니스 로직에서 분리
public interface UserRepository {
    Optional<User> findById(long id);
}

public class JpaUserRepository implements UserRepository {
    public Optional<User> findById(long id) {
        // DB 조회
        return Optional.of(new User(id, "홍길동"));
    }
}

// 캐싱 데코레이터
public class CachingUserRepository implements UserRepository {
    private final UserRepository delegate;
    private final Map<Long, User> cache = new ConcurrentHashMap<>();

    public CachingUserRepository(UserRepository delegate) {
        this.delegate = delegate;
    }

    public Optional<User> findById(long id) {
        if (cache.containsKey(id)) {
            System.out.println("캐시 히트: " + id);
            return Optional.of(cache.get(id));
        }
        Optional<User> user = delegate.findById(id);
        user.ifPresent(u -> cache.put(id, u));
        return user;
    }
}

// 로깅 데코레이터
public class LoggingUserRepository implements UserRepository {
    private final UserRepository delegate;

    public LoggingUserRepository(UserRepository delegate) {
        this.delegate = delegate;
    }

    public Optional<User> findById(long id) {
        System.out.println("[LOG] findById 호출: " + id);
        Optional<User> result = delegate.findById(id);
        System.out.println("[LOG] 결과: " + result);
        return result;
    }
}

// 데코레이터 체이닝: 로깅 → 캐싱 → 실제 DB
UserRepository repo = new LoggingUserRepository(
    new CachingUserRepository(
        new JpaUserRepository()
    )
);

Adapter — 호환되지 않는 인터페이스 연결

// 외부 결제 라이브러리의 인터페이스
public class ExternalPaymentGateway {
    public PaymentResult processPayment(String cardToken, long amountCents) {
        // 외부 API 호출
        return new PaymentResult(true, "txn_123");
    }
}

// 시스템 내부 인터페이스
public interface PaymentService {
    boolean charge(String orderId, Money amount);
}

// 어댑터: 외부 인터페이스를 내부 인터페이스로 변환
public class PaymentGatewayAdapter implements PaymentService {
    private final ExternalPaymentGateway gateway;

    public PaymentGatewayAdapter(ExternalPaymentGateway gateway) {
        this.gateway = gateway;
    }

    public boolean charge(String orderId, Money amount) {
        String cardToken = resolveCardToken(orderId);
        long cents = amount.toCents();
        PaymentResult result = gateway.processPayment(cardToken, cents);
        return result.isSuccess();
    }

    private String resolveCardToken(String orderId) {
        // orderId → cardToken 변환 로직
        return "tok_" + orderId;
    }
}

의존성 역전과 DI 컨테이너

DIP는 SOLID의 D로, "구체 클래스에 의존하지 말고 추상화에 의존하라"는 원칙입니다. DI(Dependency Injection) 컨테이너는 이 원칙을 프레임워크 수준에서 자동화합니다.

수동 DI vs. 컨테이너 DI

// 수동 DI — 의존 객체를 외부에서 주입
public class OrderService {
    private final OrderRepository orderRepository;
    private final NotificationService notificationService;

    // 생성자 주입 — 불변성 보장, 테스트 용이
    public OrderService(OrderRepository orderRepository,
                        NotificationService notificationService) {
        this.orderRepository = orderRepository;
        this.notificationService = notificationService;
    }

    public Order placeOrder(OrderRequest request) {
        Order order = Order.from(request);
        orderRepository.save(order);
        notificationService.notifyOrderPlaced(order);
        return order;
    }
}

// 조립(Composition Root) — main 또는 설정 클래스에서 한 번만
OrderRepository repository = new JpaOrderRepository(dataSource);
NotificationService notification = new EmailNotificationService(smtpConfig);
OrderService orderService = new OrderService(repository, notification);

// 테스트에서는 Mock 주입
OrderRepository mockRepo = order -> {}; // 람다로 간단히 Mock
NotificationService mockNotif = order -> {};
OrderService testService = new OrderService(mockRepo, mockNotif);

Spring 컨테이너 동작 원리 (개념적 이해)

// Spring은 이런 흐름으로 빈(Bean)을 관리합니다
// 1. @Component 스캔으로 관리할 클래스를 등록
// 2. 생성자/필드/@Autowired를 분석해 의존 그래프 구성
// 3. 싱글톤 스코프로 인스턴스를 생성하고 캐싱
// 4. 요청 시 캐시에서 반환

@Service  // = @Component + 서비스 계층 의미
public class OrderService {
    private final OrderRepository orderRepository;
    private final NotificationService notificationService;

    // Spring이 이 생성자를 보고 필요한 빈을 자동 주입
    public OrderService(OrderRepository orderRepository,
                        NotificationService notificationService) {
        this.orderRepository = orderRepository;
        this.notificationService = notificationService;
    }
}
주입 방식장점단점
생성자 주입불변 필드, NPE 방지, 테스트 용이순환 의존 시 기동(시작) 실패로 조기 발견 — 사실상 장점
세터 주입선택적 의존성 표현객체 불완전한 상태 가능, 지연 주입으로 순환 의존이 숨겨질 수 있음
필드 주입 (@Autowired)코드 간결불변 불가, 테스트 어렵고 숨겨진 의존성

⚠️ 주의 필드 주입은 편리하지만 단위 테스트 작성 시 리플렉션을 써야 하고, IDE 없이 의존성 파악이 어렵습니다. 또한 생성자 주입의 "순환 의존 시 실패"는 컴파일 오류가 아니라 애플리케이션 컨텍스트 로딩(기동) 시점에 BeanCurrentlyInCreationException 등으로 즉시 드러나므로, 문제를 일찍 발견할 수 있어 사실상 장점입니다. 반면 세터/필드 주입은 지연 주입이라 순환 의존이 숨겨질 수 있습니다. 생성자 주입을 기본으로 사용하세요.


계층형 아키텍처와 패키지 구조 설계

전통적인 계층형(Layered) 아키텍처는 웹 애플리케이션의 기본 구조로 여전히 널리 사용됩니다.

┌─────────────────────────────┐
│  Presentation Layer          │  HTTP 요청 처리, DTO 변환
│  (Controller / API)          │
├─────────────────────────────┤
│  Application/Service Layer   │  비즈니스 오케스트레이션, 트랜잭션
│  (Service)                   │
├─────────────────────────────┤
│  Domain Layer                │  핵심 도메인 모델, 비즈니스 규칙
│  (Entity, Domain Service)    │
├─────────────────────────────┤
│  Infrastructure Layer        │  DB, 외부 API, 메시징
│  (Repository, Adapter)       │
└─────────────────────────────┘

패키지 구조 예시

com.example.shop
├── order
│   ├── controller
│   │   └── OrderController.java       // DTO 수신, 응답 반환
│   ├── service
│   │   └── OrderService.java          // 유스케이스 조율
│   ├── domain
│   │   ├── Order.java                 // 핵심 도메인 객체
│   │   ├── OrderStatus.java
│   │   └── OrderRepository.java       // 인터페이스 (Domain 소유)
│   └── infrastructure
│       └── JpaOrderRepository.java    // 구현체 (Infrastructure 소유)
├── user
│   └── ...
└── shared
    ├── Money.java                     // 공통 값 객체
    └── PageRequest.java

💡 TIP 패키지를 기술 계층(controller/, service/, repository/)으로 구성하면 기능 추가 시 여러 패키지를 오가야 합니다. 도메인 기준(order/, user/, payment/)으로 구성하면 응집도가 높아지고 마이크로서비스 분리도 수월해집니다.

의존성 방향 규칙

// ✅ 올바른 의존 방향: Presentation → Service → Domain ← Infrastructure
// Domain 계층은 어떤 계층도 의존하지 않음 (순수 Java)

// Domain 계층 — 프레임워크 의존 없음
public class Order {
    private final OrderId id;
    private final List<OrderItem> items;
    private OrderStatus status;

    public void confirm() {
        if (status != OrderStatus.PENDING)
            throw new IllegalStateException("확정 불가 상태: " + status);
        this.status = OrderStatus.CONFIRMED;
    }
}

// Infrastructure 계층 — Domain 인터페이스 구현
// JPA, DB에 의존하지만 Domain은 이를 모름
// 순수 POJO인 Order에는 @Entity/@Id가 없어 직접 영속화할 수 없으므로,
// JPA 매핑 메타데이터를 가진 별도 엔티티(OrderJpaEntity)를 두고 매퍼로 변환합니다.
@Repository
public class JpaOrderRepository implements OrderRepository {
    @PersistenceContext
    private EntityManager em;

    public void save(Order order) {
        em.persist(OrderMapper.toJpaEntity(order));
    }
    public Optional<Order> findById(OrderId id) {
        OrderJpaEntity entity = em.find(OrderJpaEntity.class, id.value());
        return Optional.ofNullable(entity).map(OrderMapper::toDomain);
    }
}

안티패턴 식별과 점진적 리팩터링

자주 발생하는 안티패턴

안티패턴증상해결 방향
God Object한 클래스가 수백 줄, 너무 많은 메서드SRP 적용, 책임 분리
Anemic Domain Model도메인 객체가 getter/setter만 있고 로직이 없음비즈니스 규칙을 도메인 객체로 이동
Magic Number / String코드 곳곳에 0.8, "VIP" 같은 상수 산재명명된 상수, Enum으로 교체
Primitive ObsessionString email, long priceInCents 남발값 객체(Value Object) 도입
Circular DependencyA가 B에 의존, B가 A에 의존인터페이스 추출, 이벤트 기반으로 전환

점진적 리팩터링 예시 — Anemic Domain Model 개선

// ❌ 빈약한 도메인 모델 — 비즈니스 로직이 서비스에 누설
public class User {
    private String status;
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}

// UserService에서:
if ("ACTIVE".equals(user.getStatus())) {
    user.setStatus("SUSPENDED");
    // 정지 관련 처리...
}

// ✅ 풍성한 도메인 모델 — 상태 전이를 객체가 직접 관리
public class User {
    public enum Status { ACTIVE, SUSPENDED, WITHDRAWN }

    private Status status;

    public void suspend(String reason) {
        if (status != Status.ACTIVE)
            throw new IllegalStateException("활성 상태에서만 정지 가능");
        this.status = Status.SUSPENDED;
        // 도메인 이벤트 발행 등 추가 처리
    }

    public boolean isActive() { return status == Status.ACTIVE; }
}

// UserService에서:
user.suspend("어뷰징 의심"); // 의도가 명확하고 불변식 보호됨

리팩터링 전략 — 보이스카우트 규칙

// 새 기능 추가 전 주변 코드를 조금씩 개선
// 1. 테스트 먼저 작성 (회귀 방지망)
// 2. Extract Method로 긴 메서드 분리
// 3. Extract Interface로 의존성 추상화
// 4. 한 번에 전체를 바꾸려 하지 말 것 — 작은 단계, 자주 커밋

운영 관점: 빌드/패키징과 배포 가능한 산출물

패턴과 아키텍처가 아무리 훌륭해도 배포할 수 없으면 무용지물입니다. Maven/Gradle 기반 Java 프로젝트의 배포 산출물 흐름을 정리합니다.

Gradle 멀티 모듈 구조

shop-project/
├── settings.gradle       // 모듈 목록
├── build.gradle          // 공통 설정
├── app-api/              // REST API 모듈
│   └── build.gradle
├── app-domain/           // 도메인 모듈 (의존성 최소화)
│   └── build.gradle
└── app-infra/            // DB, 외부 연동
    └── build.gradle
// settings.gradle
rootProject.name = 'shop-project'
include 'app-api', 'app-domain', 'app-infra'

// app-api/build.gradle
dependencies {
    implementation project(':app-domain')
    implementation project(':app-infra')
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

// app-domain/build.gradle — 순수 Java, 프레임워크 의존 없음
dependencies {
    // 최소 의존성만
}

실행 가능한 JAR 패키징

# 빌드 및 테스트
./gradlew clean build

# 실행 가능한 Fat JAR 생성 (Spring Boot)
./gradlew bootJar
# 결과: app-api/build/libs/app-api-1.0.0.jar

# 실행
java -jar app-api/build/libs/app-api-1.0.0.jar --spring.profiles.active=prod

# 컨테이너 이미지로 직접 빌드 (Spring Boot 2.3+)
./gradlew bootBuildImage --imageName=shop-api:1.0.0

환경 분리 전략

// application.yml — 공통 설정
// application-dev.yml — 개발 환경
// application-prod.yml — 운영 환경

// ✅ 환경별 설정을 코드에서 분리
@Configuration
@Profile("prod")
public class ProdDataSourceConfig {
    @Bean
    public DataSource dataSource() {
        // 운영 DB 풀 설정
        HikariConfig config = new HikariConfig();
        config.setMaximumPoolSize(50);
        return new HikariDataSource(config);
    }
}

@Configuration
@Profile("dev")
public class DevDataSourceConfig {
    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
            .setType(EmbeddedDatabaseType.H2)
            .build();
    }
}

⚠️ 주의 비밀번호, API 키 같은 민감 정보는 절대 application.yml에 하드코딩하지 마세요. 환경 변수나 Vault, AWS Secrets Manager 같은 비밀 관리 서비스를 사용하세요.


요약

  • SOLID 원칙은 코드 변경 비용을 최소화하기 위한 설계 가이드라인입니다. SRP·OCP·LSP·ISP·DIP 각각이 서로 다른 변경 축을 보호합니다.
  • 생성 패턴은 객체 생성의 복잡성을 캡슐화합니다. Factory는 타입 분기, Builder는 복잡한 생성, Singleton은 전역 상태를 다루며 각각 함정이 존재합니다.
  • 구조/행위 패턴(Strategy, Observer, Decorator, Adapter)은 객체 간 협력 방식을 정의하며, 인터페이스를 경계로 변경을 격리합니다.
  • DI 컨테이너는 DIP를 자동화하며, 생성자 주입이 불변성·테스트 용이성 면에서 가장 권장됩니다.
  • 계층형 아키텍처에서 의존성 방향은 단방향이어야 하고, Domain 계층은 어떤 인프라에도 의존하지 않아야 합니다.
  • 점진적 리팩터링은 테스트를 먼저 작성하고 작은 단계로 진행해야 하며, God Object·Anemic Model 같은 안티패턴을 조기에 식별하는 것이 중요합니다.

연습문제

  1. 아래 ReportGenerator 클래스는 SRP를 위반하고 있습니다. 어떤 책임이 섞여 있는지 설명하고, 이를 분리한 설계를 코드로 작성하세요.

    public class ReportGenerator {
        public String generateHtml(List<Order> orders) { ... }
        public void saveToFile(String html, String path) { ... }
        public void sendByEmail(String html, String to) { ... }
    }
    

    힌트 보고서 생성, 저장, 발송 각각을 별도 인터페이스와 클래스로 분리해 보세요.

  2. 결제 수단(신용카드, 카카오페이, 토스페이)이 추가될 때마다 기존 코드를 수정하지 않아도 되도록 OCP를 적용한 PaymentProcessor를 설계하세요. 각 수단은 pay(long amount): boolean 메서드를 가집니다.

    힌트 전략 인터페이스를 정의하고 팩토리로 선택 로직을 격리하세요.

  3. UserService가 직접 new EmailSender()를 호출하고 있어 단위 테스트가 실패합니다. 생성자 주입으로 리팩터링하고, 테스트에서 가짜(Stub) 구현을 주입하는 코드를 작성하세요.

    public class UserService {
        private final EmailSender emailSender = new EmailSender(); // ❌
        public void registerUser(String email) {
            // ... 사용자 저장 ...
            emailSender.send(email, "가입 완료");
        }
    }
    

    힌트 EmailSender를 인터페이스로 추출하고, 테스트에서 람다로 Stub을 구현해 보세요.

  4. 로깅과 실행 시간 측정 기능을 UserRepository 구현체에 추가해야 합니다. 기존 JpaUserRepository를 수정하지 않고 Decorator 패턴으로 두 기능을 각각 적용하세요.

    힌트 LoggingUserRepositoryTimingUserRepository를 별도로 만들어 체이닝하세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Java 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.