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; }
}
⚠️ 주의 싱글톤의 세 가지 함정
- 테스트 어려움 — 전역 상태는 테스트 간 오염을 유발합니다. Spring Bean처럼 DI로 관리하면 테스트에서 Mock으로 교체 가능합니다.
- 직렬화 —
readResolve()미구현 시 역직렬화에서 새 인스턴스가 생성될 수 있습니다.- 리플렉션 공격 —
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, GuavaEventBus, 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 Obsession | String email, long priceInCents 남발 | 값 객체(Value Object) 도입 |
| Circular Dependency | A가 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 같은 안티패턴을 조기에 식별하는 것이 중요합니다.
연습문제
-
아래
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) { ... } }힌트 보고서 생성, 저장, 발송 각각을 별도 인터페이스와 클래스로 분리해 보세요.
-
결제 수단(신용카드, 카카오페이, 토스페이)이 추가될 때마다 기존 코드를 수정하지 않아도 되도록 OCP를 적용한
PaymentProcessor를 설계하세요. 각 수단은pay(long amount): boolean메서드를 가집니다.힌트 전략 인터페이스를 정의하고 팩토리로 선택 로직을 격리하세요.
-
UserService가 직접new EmailSender()를 호출하고 있어 단위 테스트가 실패합니다. 생성자 주입으로 리팩터링하고, 테스트에서 가짜(Stub) 구현을 주입하는 코드를 작성하세요.public class UserService { private final EmailSender emailSender = new EmailSender(); // ❌ public void registerUser(String email) { // ... 사용자 저장 ... emailSender.send(email, "가입 완료"); } }힌트
EmailSender를 인터페이스로 추출하고, 테스트에서 람다로 Stub을 구현해 보세요. -
로깅과 실행 시간 측정 기능을
UserRepository구현체에 추가해야 합니다. 기존JpaUserRepository를 수정하지 않고 Decorator 패턴으로 두 기능을 각각 적용하세요.힌트
LoggingUserRepository와TimingUserRepository를 별도로 만들어 체이닝하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Java 심화” 강좌에 대한 댓글입니다.