PHpullh
심층 가이드/Java/디자인패턴

JAVA · 심층 가이드

Java 디자인패턴 완전 정리

record와 sealed가 들어온 Java에서 GoF 패턴 중 무엇이 남고 무엇이 문법으로 흡수됐는지, 아키텍처와 분산 패턴까지 20개 주제로 짚습니다.

주제 20개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

디자인 패턴은 언어가 비워 둔 자리를 채우는 관용구입니다. 그래서 언어가 바뀌면 패턴의 가치도 바뀝니다. Java에서는 enum이 Singleton의 정석 구현이 됐고, record가 값 객체와 불변 DTO의 보일러플레이트를 없앴으며, sealed interface와 패턴 매칭은 Visitor가 하던 일의 상당 부분을 대신합니다. 반대로 여전히 손으로 짜야 하는 것도 분명합니다. 이 가이드를 볼 때는 '이 패턴이 지금 Java에서도 필요한 이유가 무엇인가'를 매번 되물어야 남는 게 있습니다.

앞부분은 객체 하나의 설계입니다. Builder 패턴 & 불변 객체에서 생성자 인자가 많을 때의 해법을 잡고, Strategy & Template Method 패턴으로 변하는 부분을 분리하는 감각을 익힙니다. 중반의 DI(의존성 주입) 원리는 Strategy를 애플리케이션 전체로 확장한 것이고, 클린 아키텍처는 그 방향을 계층 규칙으로 못 박은 결과입니다. 후반의 서킷 브레이커 패턴사가(Saga) 패턴은 대상이 객체가 아니라 서비스 경계로 옮겨졌을 뿐 문제 구조는 같습니다.

패턴을 도입할 때 자주 나오는 사고는 계층을 만드는 데 그치고 의존 방향을 뒤집지 않는 경우입니다. 인터페이스를 구현체와 같은 패키지에 두고 상위 계층이 직접 참조하면, 파일 수만 늘고 교체 가능성은 생기지 않습니다. 모듈 시스템을 쓴다면 또 하나 주의할 점이 있습니다. module-info.java로 강한 캡슐화를 걸면 리플렉션으로 필드에 접근하는 프레임워크가 깨지므로, 해당 패키지를 opens로 열어 주거나 접근 방식을 바꿔야 합니다.

01Builder 패턴 & 불변 객체

Effective Java가 권장하는 Builder 패턴으로 복잡한 객체를 안전하게 생성합니다.

Java code

import java.util.*;

// 불변 객체 + Builder 패턴
public final 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 builder) {
        this.url       = Objects.requireNonNull(builder.url, "url 필수");
        this.method    = builder.method;
        this.headers   = Collections.unmodifiableMap(builder.headers);
        this.body      = builder.body;
        this.timeoutMs = builder.timeoutMs;
    }

    // Getter만 (Setter 없음 — 불변)
    public String url()     { return url; }
    public String method()  { return method; }
    public int timeoutMs()  { return timeoutMs; }

    // static nested Builder
    public static class Builder {
        // 필수
        private final String url;
        // 선택 (기본값 있음)
        private String method  = "GET";
        private Map<String, String> headers = new HashMap<>();
        private String body    = null;
        private int timeoutMs  = 30_000;

        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() { return new HttpRequest(this); }
    }

    @Override public String toString() {
        return "HttpRequest{url='%s', method='%s', timeout=%d}"
            .formatted(url, method, timeoutMs);
    }
}

class Main {
    public static void main(String[] args) {
        var req = new HttpRequest.Builder("https://api.example.com/users")
            .method("POST")
            .header("Content-Type", "application/json")
            .header("Authorization", "Bearer token123")
            .body("{"name": "Alice"}")
            .timeout(5_000)
            .build();

        System.out.println(req);

        // Lombok @Builder 사용 시 동일 효과:
        // @Builder @Value public class HttpRequest { ... }
    }
}
알아두면 좋은 점

실무에서는 Lombok의 @Builder를 사용해 보일러플레이트를 제거하는 경우가 많습니다. 직접 구현 시 Effective Java 2장의 패턴을 참고하세요.

자주 하는 실수

Builder에서 build() 이후에 Builder 필드를 수정하면 이미 생성된 객체에 영향이 없습니다. 불변 객체 보장을 위해 Collections.unmodifiableMap()으로 방어적 복사를 하세요.

02Strategy &amp; Template Method 패턴

알고리즘을 캡슐화하는 Strategy와 알고리즘 골격을 정의하는 Template Method.

Java code

import java.util.*;

// Strategy 패턴 — 람다로 간결하게 구현
@FunctionalInterface
interface SortStrategy<T extends Comparable<T>> {
    List<T> sort(List<T> items);
}

class Sorter<T extends Comparable<T>> {
    private SortStrategy<T> strategy;

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

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

    public List<T> sort(List<T> items) {
        return strategy.sort(items);
    }
}

// Template Method 패턴 — 추상 클래스
abstract class DataProcessor {
    // 템플릿 메서드 — 알고리즘 골격 (final)
    public final void process(String data) {
        String validated = validate(data);
        String parsed    = parse(validated);
        String result    = transform(parsed);
        output(result);
    }

    protected abstract String validate(String data);
    protected abstract String parse(String data);
    protected abstract String transform(String data);

    // 후크 — 선택적 오버라이드
    protected void output(String result) {
        System.out.println("결과: " + result);
    }
}

class CsvProcessor extends DataProcessor {
    @Override protected String validate(String d) {
        if (d.isBlank()) throw new IllegalArgumentException("빈 데이터");
        return d.trim();
    }
    @Override protected String parse(String d) { return d.replace(",", "|"); }
    @Override protected String transform(String d) { return d.toUpperCase(); }
}

class Main {
    public static void main(String[] args) {
        var nums = new ArrayList<>(List.of(5, 2, 8, 1, 9, 3));

        // 람다로 전략 전달
        var sorter = new Sorter<Integer>(items -> {
            var sorted = new ArrayList<>(items);
            Collections.sort(sorted);
            return sorted;
        });
        System.out.println(sorter.sort(nums));

        // 전략 교체
        sorter.setStrategy(items -> {
            var sorted = new ArrayList<>(items);
            sorted.sort(Comparator.reverseOrder());
            return sorted;
        });
        System.out.println(sorter.sort(nums));

        // Template Method
        new CsvProcessor().process("  alice,30,seoul  ");
    }
}
알아두면 좋은 점

Java 8+ 람다를 사용하면 Strategy 패턴을 전략 클래스 없이 간결하게 구현할 수 있습니다. @FunctionalInterface로 선언된 인터페이스가 핵심입니다.

자주 하는 실수

Template Method에서 훅 메서드를 private으로 선언하면 오버라이드할 수 없습니다. 반드시 protected로 선언하세요.

03Singleton &amp; Factory Method &amp; Abstract Factory

GoF 생성 패턴의 핵심 3가지를 현대적 Java 문법으로 구현합니다.

Java code

import java.util.*;

// Singleton — enum 방식 (가장 안전)
enum AppConfig {
    INSTANCE;

    private final Map<String, String> props = new HashMap<>();

    public void set(String key, String value) { props.put(key, value); }
    public String get(String key) { return props.getOrDefault(key, ""); }
}

// Factory Method 패턴
interface Logger {
    void log(String msg);
}

class ConsoleLogger implements Logger {
    public void log(String msg) { System.out.println("[CONSOLE] " + msg); }
}
class FileLogger implements Logger {
    public void log(String msg) { System.out.println("[FILE] " + msg); }
}

// 정적 팩토리 메서드
class LoggerFactory {
    public static Logger create(String type) {
        return switch (type) {
            case "console" -> new ConsoleLogger();
            case "file"    -> new FileLogger();
            default        -> throw new IllegalArgumentException("Unknown: " + type);
        };
    }
}

// Abstract Factory — 관련 객체군 생성
interface Button { void render(); }
interface Dialog { void show(); }

record WindowsButton() implements Button {
    public void render() { System.out.println("[Windows] 버튼"); }
}
record MacButton() implements Button {
    public void render() { System.out.println("[Mac] 버튼"); }
}

interface UIFactory {
    Button createButton();
    Dialog createDialog();
}

class WindowsFactory implements UIFactory {
    public Button createButton() { return new WindowsButton(); }
    public Dialog createDialog() { return () -> System.out.println("[Windows] 다이얼로그"); }
}

class Main {
    public static void main(String[] args) {
        // Singleton (enum)
        AppConfig.INSTANCE.set("env", "production");
        System.out.println(AppConfig.INSTANCE.get("env"));

        // Factory Method
        Logger logger = LoggerFactory.create("console");
        logger.log("테스트 메시지");

        // Abstract Factory
        UIFactory factory = new WindowsFactory();
        factory.createButton().render();
        factory.createDialog().show();
    }
}
알아두면 좋은 점

Enum Singleton은 직렬화 안전성과 리플렉션 공격에 강해 Effective Java가 권장하는 최선의 Singleton 구현 방법입니다.

자주 하는 실수

전통적 Singleton에서 double-checked locking 시 필드에 volatile을 빠뜨리면 CPU 명령 재정렬로 인해 NPE가 발생할 수 있습니다.

04Enhanced Deprecation

@Deprecated(since, forRemoval) 올바른 폐기 처리

Java code

<span class="cm">// Enhanced Deprecation 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Enhanced Deprecation") }
알아두면 좋은 점

JAVA 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

05모듈 시스템 — module-info.java

Java 9+의 모듈 시스템(JPMS)으로 강한 캡슐화를 구현합니다. module-info.java에서 공개할 패키지와 의존성을 명시적으로 선언하여 대규모 애플리케이션의 구조를 강화합니다.

Java code

// module-info.java (모듈 정의)
module com.example.app {
    // 의존성 선언
    requires java.sql;
    requires java.logging;
    requires transitive com.example.core; // 전이적 의존성

    // 공개 패키지
    exports com.example.app.api;
    exports com.example.app.model;

    // 특정 모듈에만 공개 (리플렉션 포함)
    opens com.example.app.internal to com.example.framework;

    // 서비스 제공 & 소비
    uses com.example.app.spi.Plugin;
    provides com.example.app.spi.Plugin
        with com.example.app.plugins.DefaultPlugin;
}

// === 사용 예 ===
// com.example.app.api 패키지 (exports됨 — 외부 접근 가능)
package com.example.app.api;

public class UserService {
    public record User(long id, String name, String email) {}

    public User findById(long id) {
        // internal 패키지는 외부에서 접근 불가
        return new User(id, "홍길동", "hong@example.com");
    }
}
알아두면 좋은 점

requires transitive는 이 모듈을 사용하는 모듈도 해당 의존성을 자동으로 읽을 수 있게 합니다. opens는 리플렉션(Spring, Hibernate 등)을 위해 런타임에만 패키지를 개방합니다.

자주 하는 실수

exports하지 않은 패키지는 다른 모듈에서 컴파일 타임에 접근할 수 없습니다. 기존 라이브러리가 모듈화되지 않은 경우 자동 모듈(automatic module)로 사용할 수 있지만, 이름 충돌에 주의하세요.

06클린 아키텍처

의존성 역전 원칙에 기반한 클린 아키텍처의 계층 구조와 구현입니다.

Java code

// 클린 아키텍처 — 안쪽에서 바깥으로
// 1. 엔터티 (도메인 핵심)
record User(String id, String name, String email) {
    User {
        if (name == null || name.isBlank())
            throw new IllegalArgumentException("이름 필수");
    }
}

// 2. 유스케이스 (비즈니스 규칙)
interface UserRepository { // 포트 (추상)
    User findById(String id);
    void save(User user);
}

class CreateUserUseCase {
    private final UserRepository repo;

    CreateUserUseCase(UserRepository repo) { this.repo = repo; }

    User execute(String name, String email) {
        User user = new User(
            java.util.UUID.randomUUID().toString(), name, email);
        repo.save(user);
        return user;
    }
}

// 3. 어댑터 (외부 시스템 구현)
class InMemoryUserRepository implements UserRepository {
    private final java.util.Map<String, User> store = new java.util.HashMap<>();

    public User findById(String id) { return store.get(id); }
    public void save(User user) { store.put(user.id(), user); }
}

// 4. 프레임워크 (진입점)
class Main {
    public static void main(String[] args) {
        UserRepository repo = new InMemoryUserRepository();
        CreateUserUseCase useCase = new CreateUserUseCase(repo);
        User user = useCase.execute("홍길동", "hong@test.com");
        System.out.println("생성: " + user);
    }
}
알아두면 좋은 점

의존성 방향은 항상 바깥에서 안쪽입니다. 유스케이스가 리포지토리 인터페이스를 정의하고, 어댑터가 구현합니다(의존성 역전).

자주 하는 실수

엔터티에 프레임워크 어노테이션(@Entity, @Column)을 붙이면 도메인이 프레임워크에 의존하게 됩니다. 분리하세요.

07헥사고날 아키텍처

포트와 어댑터 패턴으로 외부 시스템과의 결합을 제거합니다.

Java code

// 포트: 인바운드 (외부 -> 도메인)
interface CreateOrderPort {
    OrderResult create(OrderCommand command);
}

record OrderCommand(String customerId, String productId, int quantity) {}
record OrderResult(String orderId, String status) {}

// 포트: 아웃바운드 (도메인 -> 외부)
interface OrderPersistencePort {
    void save(Order order);
}
interface PaymentPort {
    boolean charge(String customerId, long amount);
}
interface NotificationPort {
    void notify(String customerId, String message);
}

// 도메인 모델
record Order(String id, String customerId, String productId, int qty, long total) {}

// 유스케이스 구현 (헥사곤 내부)
class CreateOrderService implements CreateOrderPort {
    private final OrderPersistencePort persistence;
    private final PaymentPort payment;
    private final NotificationPort notification;

    CreateOrderService(OrderPersistencePort p, PaymentPort pay, NotificationPort n) {
        this.persistence = p; this.payment = pay; this.notification = n;
    }

    public OrderResult create(OrderCommand cmd) {
        long amount = cmd.quantity() * 10000L;
        if (!payment.charge(cmd.customerId(), amount)) {
            return new OrderResult("", "결제실패");
        }
        Order order = new Order("ORD-1", cmd.customerId(),
            cmd.productId(), cmd.quantity(), amount);
        persistence.save(order);
        notification.notify(cmd.customerId(), "주문 완료");
        return new OrderResult(order.id(), "성공");
    }
}
알아두면 좋은 점

인바운드 포트는 "시스템이 무엇을 할 수 있는가"를, 아웃바운드 포트는 "시스템이 무엇을 필요로 하는가"를 정의합니다.

자주 하는 실수

어댑터에서 도메인 객체를 직접 노출하면 외부 변경이 도메인에 영향을 줍니다. DTO로 변환하여 전달하세요.

08레이어드 아키텍처

전통적인 3계층 아키텍처의 구조와 의존성 흐름입니다.

Java code

// 1. 프레젠테이션 계층 (Controller)
class UserController {
    private final UserService2 service;

    UserController(UserService2 service) { this.service = service; }

    String handleGetUser(String id) {
        UserDTO dto = service.getUser(id);
        return "{\"name\":\"" + dto.name() + "\"}";
    }
}

// 2. 비즈니스 계층 (Service)
record UserDTO(String id, String name, String email) {}

class UserService2 {
    private final UserDAO dao;

    UserService2(UserDAO dao) { this.dao = dao; }

    UserDTO getUser(String id) {
        UserEntity entity = dao.findById(id);
        if (entity == null) throw new RuntimeException("사용자 없음");
        return new UserDTO(entity.id, entity.name, entity.email);
    }
}

// 3. 데이터 접근 계층 (DAO/Repository)
class UserEntity {
    String id, name, email;
    UserEntity(String id, String name, String email) {
        this.id = id; this.name = name; this.email = email;
    }
}

class UserDAO {
    UserEntity findById(String id) {
        return new UserEntity(id, "홍길동", "hong@test.com");
    }
}

class Main {
    public static void main(String[] args) {
        UserDAO dao = new UserDAO();
        UserService2 service = new UserService2(dao);
        UserController controller = new UserController(service);
        System.out.println(controller.handleGetUser("1"));
    }
}
알아두면 좋은 점

각 계층은 바로 아래 계층에만 의존해야 합니다. Controller가 DAO를 직접 호출하면 계층 위반입니다.

자주 하는 실수

Entity를 Controller에서 직접 반환하면 DB 스키마 변경이 API에 영향을 줍니다. 반드시 DTO로 변환하세요.

09DI(의존성 주입) 원리

Spring의 핵심 원리인 의존성 주입을 직접 구현하여 이해합니다.

Java code

import java.util.*;

// 간단한 DI 컨테이너 구현
class DIContainer {
    private final Map<Class<?>, Object> instances = new HashMap<>();
    private final Map<Class<?>, Class<?>> bindings = new HashMap<>();

    // 인터페이스 -> 구현체 바인딩
    <T> void bind(Class<T> iface, Class<? extends T> impl) {
        bindings.put(iface, impl);
    }

    // 싱글턴 등록
    <T> void singleton(Class<T> type, T instance) {
        instances.put(type, instance);
    }

    // 의존성 해결
    @SuppressWarnings("unchecked")
    <T> T resolve(Class<T> type) {
        if (instances.containsKey(type)) {
            return (T) instances.get(type);
        }
        Class<?> impl = bindings.getOrDefault(type, type);
        try {
            var ctors = impl.getConstructors();
            if (ctors.length == 0) return (T) impl.getDeclaredConstructor().newInstance();
            var ctor = ctors[0];
            Object[] params = Arrays.stream(ctor.getParameterTypes())
                .map(this::resolve)
                .toArray();
            T instance = (T) ctor.newInstance(params);
            instances.put(type, instance);
            return instance;
        } catch (Exception e) {
            throw new RuntimeException("DI 실패: " + type.getName(), e);
        }
    }
}

interface GreetService { String greet(String name); }
class GreetServiceImpl implements GreetService {
    public String greet(String name) { return "Hello, " + name; }
}

class AppController {
    private final GreetService service;
    AppController(GreetService service) { this.service = service; }
    String handle(String name) { return service.greet(name); }
}

class Main {
    public static void main(String[] args) {
        DIContainer di = new DIContainer();
        di.bind(GreetService.class, GreetServiceImpl.class);
        AppController ctrl = di.resolve(AppController.class);
        System.out.println(ctrl.handle("Java"));
    }
}
알아두면 좋은 점

생성자 주입이 가장 권장됩니다. 의존성이 명시적이고, 불변 필드로 만들 수 있으며, 테스트 시 Mock을 쉽게 주입할 수 있습니다.

자주 하는 실수

필드 주입(@Autowired 필드)은 테스트가 어렵고 의존성이 숨겨집니다. 생성자 주입을 사용하세요.

10AOP(관점 지향 프로그래밍) 원리

횡단 관심사를 분리하는 AOP의 원리를 프록시로 구현합니다.

Java code

import java.lang.reflect.*;
import java.lang.annotation.*;

// 커스텀 어노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Logged {}

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Timed {}

interface OrderService {
    @Logged @Timed
    String placeOrder(String item);
}

class OrderServiceImpl implements OrderService {
    public String placeOrder(String item) {
        return "주문완료: " + item;
    }
}

// AOP 프록시 핸들러
class AOPHandler implements InvocationHandler {
    private final Object target;

    AOPHandler(Object target) { this.target = target; }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        Method targetMethod = target.getClass().getMethod(
            method.getName(), method.getParameterTypes());

        // @Logged 처리
        if (targetMethod.isAnnotationPresent(Logged.class)) {
            System.out.printf("[LOG] %s(%s)%n",
                method.getName(), java.util.Arrays.toString(args));
        }

        // @Timed 처리
        long start = 0;
        if (targetMethod.isAnnotationPresent(Timed.class)) {
            start = System.nanoTime();
        }

        Object result = method.invoke(target, args);

        if (start > 0) {
            System.out.printf("[TIME] %s: %dns%n",
                method.getName(), System.nanoTime() - start);
        }
        return result;
    }
}

class Main {
    public static void main(String[] args) {
        OrderService service = (OrderService) Proxy.newProxyInstance(
            OrderService.class.getClassLoader(),
            new Class[]{OrderService.class},
            new AOPHandler(new OrderServiceImpl()));
        System.out.println(service.placeOrder("노트북"));
    }
}
알아두면 좋은 점

Spring AOP는 이와 동일한 원리로 동작합니다. @Transactional, @Cacheable 등이 모두 프록시 기반 AOP입니다.

자주 하는 실수

Spring AOP에서 같은 클래스 내부의 메서드 호출은 프록시를 거치지 않아 AOP가 적용되지 않습니다. 이를 self-invocation 문제라고 합니다.

11이벤트 소싱

상태 변경을 이벤트로 기록하고 재구성하는 이벤트 소싱 패턴입니다.

Java code

import java.util.*;
import java.time.Instant;

// 이벤트 정의
sealed interface AccountEvent {
    String accountId();
    Instant timestamp();
}
record AccountCreated(String accountId, String owner, Instant timestamp)
    implements AccountEvent {}
record MoneyDeposited(String accountId, long amount, Instant timestamp)
    implements AccountEvent {}
record MoneyWithdrawn(String accountId, long amount, Instant timestamp)
    implements AccountEvent {}

// 이벤트 스토어
class EventStore {
    private final List<AccountEvent> events = new ArrayList<>();
    void append(AccountEvent event) { events.add(event); }
    List<AccountEvent> getEvents(String accountId) {
        return events.stream()
            .filter(e -> e.accountId().equals(accountId))
            .toList();
    }
}

// 상태 재구성
record AccountState(String id, String owner, long balance) {
    static AccountState replay(List<AccountEvent> events) {
        String id = "", owner = "";
        long balance = 0;
        for (AccountEvent e : events) {
            switch (e) {
                case AccountCreated c -> { id = c.accountId(); owner = c.owner(); }
                case MoneyDeposited d -> balance += d.amount();
                case MoneyWithdrawn w -> balance -= w.amount();
            }
        }
        return new AccountState(id, owner, balance);
    }
}

class Main {
    public static void main(String[] args) {
        EventStore store = new EventStore();
        store.append(new AccountCreated("A1", "홍길동", Instant.now()));
        store.append(new MoneyDeposited("A1", 50000, Instant.now()));
        store.append(new MoneyWithdrawn("A1", 20000, Instant.now()));

        AccountState state = AccountState.replay(store.getEvents("A1"));
        System.out.printf("%s 잔액: %d원%n", state.owner(), state.balance());
    }
}
알아두면 좋은 점

이벤트 소싱은 모든 변경 이력을 보존합니다. 감사 로그, 시간 여행 디버깅, 상태 재구성에 강력합니다.

자주 하는 실수

이벤트 스키마를 변경하면 기존 이벤트를 역직렬화할 수 없습니다. 이벤트 버전 관리 전략을 미리 설계하세요.

12CQRS 패턴

명령(Command)과 조회(Query)의 책임을 분리하는 CQRS 패턴입니다.

Java code

import java.util.*;

// Command 모델 (쓰기)
interface Command {}
record CreateProductCommand(String name, int price) implements Command {}
record UpdatePriceCommand(String id, int newPrice) implements Command {}

// Query 모델 (읽기)
interface Query {}
record GetProductQuery(String id) implements Query {}
record SearchProductQuery(String keyword) implements Query {}

// 쓰기 모델
class ProductCommandHandler {
    private final Map<String, int[]> writeStore = new HashMap<>();

    String handle(CreateProductCommand cmd) {
        String id = UUID.randomUUID().toString().substring(0, 8);
        writeStore.put(id, new int[]{cmd.price()});
        System.out.println("생성: " + id);
        return id;
    }

    void handle(UpdatePriceCommand cmd) {
        writeStore.get(cmd.id())[0] = cmd.newPrice();
        System.out.println("가격 변경: " + cmd.id());
    }
}

// 읽기 모델 (비정규화된 뷰)
record ProductView(String id, String name, int price) {}

class ProductQueryHandler {
    private final Map<String, ProductView> readStore = new HashMap<>();

    void sync(String id, String name, int price) {
        readStore.put(id, new ProductView(id, name, price));
    }

    ProductView handle(GetProductQuery query) {
        return readStore.get(query.id());
    }

    List<ProductView> handle(SearchProductQuery query) {
        return readStore.values().stream()
            .filter(p -> p.name().contains(query.keyword()))
            .toList();
    }
}
알아두면 좋은 점

CQRS를 사용하면 읽기 모델을 조회 패턴에 최적화(비정규화)할 수 있습니다. 이벤트 소싱과 함께 사용하면 시너지가 큽니다.

자주 하는 실수

모든 시스템에 CQRS가 필요한 것은 아닙니다. 읽기/쓰기 패턴이 크게 다를 때만 도입하세요. 복잡성이 증가합니다.

13사가(Saga) 패턴

분산 트랜잭션을 보상 트랜잭션으로 관리하는 사가 패턴입니다.

Java code

import java.util.*;

public class SagaPattern {
    // 사가 단계 정의
    interface SagaStep {
        void execute() throws Exception;
        void compensate(); // 보상 트랜잭션
    }

    // 사가 오케스트레이터
    static class SagaOrchestrator {
        private final List<SagaStep> steps = new ArrayList<>();
        private final Deque<SagaStep> completed = new ArrayDeque<>();

        void addStep(SagaStep step) { steps.add(step); }

        void execute() {
            for (SagaStep step : steps) {
                try {
                    step.execute();
                    completed.push(step);
                } catch (Exception e) {
                    System.out.println("실패! 보상 시작: " + e.getMessage());
                    rollback();
                    throw new RuntimeException("사가 실패", e);
                }
            }
            System.out.println("사가 성공!");
        }

        private void rollback() {
            while (!completed.isEmpty()) {
                SagaStep step = completed.pop();
                try {
                    step.compensate();
                } catch (Exception e) {
                    System.err.println("보상 실패: " + e.getMessage());
                }
            }
        }
    }

    public static void main(String[] args) {
        SagaOrchestrator saga = new SagaOrchestrator();
        saga.addStep(new SagaStep() {
            public void execute() { System.out.println("1. 재고 차감"); }
            public void compensate() { System.out.println("1. 재고 복구"); }
        });
        saga.addStep(new SagaStep() {
            public void execute() { System.out.println("2. 결제 처리"); }
            public void compensate() { System.out.println("2. 결제 취소"); }
        });
        saga.addStep(new SagaStep() {
            public void execute() throws Exception {
                System.out.println("3. 배송 요청");
                throw new Exception("배송 서비스 장애");
            }
            public void compensate() { System.out.println("3. 배송 취소"); }
        });

        try { saga.execute(); } catch (Exception e) {
            System.out.println("최종: " + e.getMessage());
        }
    }
}
알아두면 좋은 점

사가의 보상 트랜잭션은 반드시 멱등(idempotent)해야 합니다. 같은 보상을 여러 번 실행해도 결과가 같아야 합니다.

자주 하는 실수

보상 트랜잭션이 실패하면 데이터 불일치가 발생합니다. 보상 실패에 대한 재시도/알림 전략이 필요합니다.

14서킷 브레이커 패턴

장애 전파를 차단하는 서킷 브레이커를 직접 구현합니다.

Java code

import java.util.concurrent.atomic.*;
import java.util.function.Supplier;

public class CircuitBreaker {
    enum State { CLOSED, OPEN, HALF_OPEN }

    private final AtomicReference<State> state = new AtomicReference<>(State.CLOSED);
    private final AtomicInteger failureCount = new AtomicInteger(0);
    private final int threshold;
    private final long resetTimeout;
    private volatile long lastFailureTime;

    CircuitBreaker(int threshold, long resetTimeoutMs) {
        this.threshold = threshold;
        this.resetTimeout = resetTimeoutMs;
    }

    <T> T execute(Supplier<T> action, Supplier<T> fallback) {
        if (state.get() == State.OPEN) {
            if (System.currentTimeMillis() - lastFailureTime > resetTimeout) {
                state.set(State.HALF_OPEN);
            } else {
                System.out.println("[CB] OPEN - 폴백 실행");
                return fallback.get();
            }
        }
        try {
            T result = action.get();
            reset();
            return result;
        } catch (Exception e) {
            recordFailure();
            System.out.printf("[CB] 실패 %d/%d%n", failureCount.get(), threshold);
            return fallback.get();
        }
    }

    private void reset() {
        failureCount.set(0);
        state.set(State.CLOSED);
    }

    private void recordFailure() {
        int count = failureCount.incrementAndGet();
        lastFailureTime = System.currentTimeMillis();
        if (count >= threshold) {
            state.set(State.OPEN);
            System.out.println("[CB] 서킷 OPEN!");
        }
    }

    public static void main(String[] args) {
        CircuitBreaker cb = new CircuitBreaker(3, 5000);
        for (int i = 0; i < 5; i++) {
            String result = cb.execute(
                () -> { throw new RuntimeException("서버 장애"); },
                () -> "폴백 응답");
            System.out.println("결과: " + result);
        }
    }
}
알아두면 좋은 점

CLOSED(정상) -> OPEN(차단) -> HALF_OPEN(시험)의 3가지 상태 전이를 이해하세요. Resilience4j 라이브러리가 프로덕션용으로 적합합니다.

자주 하는 실수

HALF_OPEN 상태에서 동시 요청이 몰리면 서비스가 다시 과부하될 수 있습니다. 시험 요청 수를 제한하세요.

15벌크헤드 패턴

장애 격리를 위해 자원을 분할하는 벌크헤드 패턴입니다.

Java code

import java.util.concurrent.*;

public class BulkheadPattern {
    // 스레드 풀 기반 벌크헤드
    static class Bulkhead {
        private final String name;
        private final Semaphore semaphore;
        private final ExecutorService executor;

        Bulkhead(String name, int maxConcurrent) {
            this.name = name;
            this.semaphore = new Semaphore(maxConcurrent);
            this.executor = Executors.newFixedThreadPool(maxConcurrent);
        }

        <T> CompletableFuture<T> execute(Callable<T> task) {
            return CompletableFuture.supplyAsync(() -> {
                try {
                    if (!semaphore.tryAcquire(1, TimeUnit.SECONDS)) {
                        throw new RuntimeException(name + " 벌크헤드 포화");
                    }
                    try {
                        return task.call();
                    } finally {
                        semaphore.release();
                    }
                } catch (Exception e) {
                    throw new CompletionException(e);
                }
            }, executor);
        }

        void shutdown() { executor.shutdown(); }
    }

    public static void main(String[] args) throws Exception {
        // 서비스별 격리된 벌크헤드
        Bulkhead orderBulkhead = new Bulkhead("주문", 3);
        Bulkhead paymentBulkhead = new Bulkhead("결제", 2);

        // 주문 서비스 장애가 결제에 영향 안 줌
        var orderResult = orderBulkhead.execute(() -> {
            Thread.sleep(100);
            return "주문 처리 완료";
        });

        var payResult = paymentBulkhead.execute(() -> {
            Thread.sleep(50);
            return "결제 처리 완료";
        });

        System.out.println(orderResult.get());
        System.out.println(payResult.get());

        orderBulkhead.shutdown();
        paymentBulkhead.shutdown();
    }
}
알아두면 좋은 점

벌크헤드는 선박의 격벽에서 유래했습니다. 한 구획에 침수가 발생해도 다른 구획은 안전한 것처럼, 서비스 장애를 격리합니다.

자주 하는 실수

벌크헤드의 동시 실행 수를 너무 작게 설정하면 정상 트래픽도 거부됩니다. 부하 테스트로 적정 값을 찾으세요.

16리트라이 패턴

일시적 장애에 대응하는 재시도 로직과 지수 백오프 전략입니다.

Java code

import java.util.concurrent.*;
import java.util.function.Supplier;

public class RetryPattern {
    record RetryConfig(int maxAttempts, long initialDelay, double multiplier) {}

    // 지수 백오프 재시도
    static <T> T retryWithBackoff(Supplier<T> action, RetryConfig config) {
        Exception lastException = null;
        long delay = config.initialDelay();

        for (int attempt = 1; attempt <= config.maxAttempts(); attempt++) {
            try {
                T result = action.get();
                if (attempt > 1) {
                    System.out.println("성공 (시도 " + attempt + ")");
                }
                return result;
            } catch (Exception e) {
                lastException = e;
                System.out.printf("시도 %d/%d 실패: %s%n",
                    attempt, config.maxAttempts(), e.getMessage());

                if (attempt < config.maxAttempts()) {
                    try {
                        // 지터 추가 (무작위 변동)
                        long jitter = (long)(delay * Math.random() * 0.3);
                        Thread.sleep(delay + jitter);
                        delay = (long)(delay * config.multiplier());
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        break;
                    }
                }
            }
        }
        throw new RuntimeException("최대 재시도 초과", lastException);
    }

    public static void main(String[] args) {
        var config = new RetryConfig(3, 100, 2.0);
        int[] callCount = {0};

        try {
            String result = retryWithBackoff(() -> {
                callCount[0]++;
                if (callCount[0] < 3) throw new RuntimeException("일시 장애");
                return "성공!";
            }, config);
            System.out.println("결과: " + result);
        } catch (RuntimeException e) {
            System.out.println("최종 실패: " + e.getMessage());
        }
    }
}
알아두면 좋은 점

지터(jitter)를 추가하면 여러 클라이언트가 동시에 재시도하는 thundering herd 문제를 완화합니다.

자주 하는 실수

재시도 대상이 아닌 예외(인증 실패, 잘못된 요청 등)까지 재시도하면 불필요한 부하가 발생합니다. 재시도 가능한 예외를 구분하세요.

17API 게이트웨이 패턴

마이크로서비스 앞단의 API 게이트웨이 역할과 구현 개념입니다.

Java code

import java.util.*;
import java.util.concurrent.*;

public class APIGateway {
    // 서비스 라우팅
    record Route(String path, String serviceUrl) {}

    static class Gateway {
        private final Map<String, Route> routes = new HashMap<>();
        private final Map<String, String> rateLimits = new HashMap<>();

        void addRoute(String path, String serviceUrl) {
            routes.put(path, new Route(path, serviceUrl));
        }

        String handle(String path, String apiKey) {
            // 1. 인증
            if (apiKey == null || apiKey.isEmpty()) {
                return "{error: 401, message: '인증 필요'}";
            }

            // 2. 속도 제한
            String limitKey = apiKey + ":" + path;
            rateLimits.merge(limitKey, "1", (old, v) -> {
                int count = Integer.parseInt(old) + 1;
                return String.valueOf(count);
            });
            if (Integer.parseInt(rateLimits.get(limitKey)) > 100) {
                return "{error: 429, message: '요청 한도 초과'}";
            }

            // 3. 라우팅
            Route route = routes.get(path);
            if (route == null) {
                return "{error: 404, message: '경로 없음'}";
            }

            // 4. 서비스 호출 (실제로는 HTTP 클라이언트 사용)
            return "{data: '" + route.serviceUrl() + " 응답'}";
        }
    }

    public static void main(String[] args) {
        Gateway gw = new Gateway();
        gw.addRoute("/users", "http://user-service:8080");
        gw.addRoute("/orders", "http://order-service:8081");

        System.out.println(gw.handle("/users", "key-123"));
        System.out.println(gw.handle("/orders", ""));
        System.out.println(gw.handle("/unknown", "key-123"));
    }
}
알아두면 좋은 점

API 게이트웨이는 인증, 속도 제한, 로깅, 캐싱, 로드 밸런싱 등의 횡단 관심사를 중앙화합니다. Spring Cloud Gateway가 대표적입니다.

자주 하는 실수

게이트웨이가 단일 장애점(SPOF)이 될 수 있습니다. 반드시 이중화(HA)하고 헬스체크를 구성하세요.

18서비스 디스커버리

마이크로서비스가 서로를 동적으로 찾는 서비스 디스커버리 패턴입니다.

Java code

import java.util.*;
import java.util.concurrent.*;

public class ServiceDiscovery {
    record ServiceInstance(String id, String host, int port, boolean healthy) {}

    // 서비스 레지스트리
    static class Registry {
        private final Map<String, List<ServiceInstance>> services =
            new ConcurrentHashMap<>();
        private int roundRobin = 0;

        // 서비스 등록
        void register(String name, ServiceInstance instance) {
            services.computeIfAbsent(name, k -> new CopyOnWriteArrayList<>())
                    .add(instance);
            System.out.printf("등록: %s -> %s:%d%n", name, instance.host(), instance.port());
        }

        // 서비스 해제
        void deregister(String name, String instanceId) {
            List<ServiceInstance> list = services.get(name);
            if (list != null) {
                list.removeIf(i -> i.id().equals(instanceId));
            }
        }

        // 서비스 발견 (라운드 로빈 로드 밸런싱)
        Optional<ServiceInstance> discover(String name) {
            List<ServiceInstance> list = services.get(name);
            if (list == null || list.isEmpty()) return Optional.empty();

            List<ServiceInstance> healthy = list.stream()
                .filter(ServiceInstance::healthy).toList();
            if (healthy.isEmpty()) return Optional.empty();

            int idx = Math.abs(roundRobin++) % healthy.size();
            return Optional.of(healthy.get(idx));
        }
    }

    public static void main(String[] args) {
        Registry registry = new Registry();
        registry.register("user-service",
            new ServiceInstance("u1", "10.0.0.1", 8080, true));
        registry.register("user-service",
            new ServiceInstance("u2", "10.0.0.2", 8080, true));

        for (int i = 0; i < 4; i++) {
            registry.discover("user-service").ifPresent(inst ->
                System.out.printf("발견: %s:%d%n", inst.host(), inst.port()));
        }
    }
}
알아두면 좋은 점

클라이언트 사이드 디스커버리(Netflix Eureka)와 서버 사이드 디스커버리(Kubernetes DNS)의 차이를 이해하세요.

자주 하는 실수

헬스 체크 없이 인스턴스를 반환하면 장애 서버로 트래픽이 전달됩니다. 주기적 헬스 체크와 비정상 인스턴스 제거가 필수입니다.

19설정 외부화

애플리케이션 설정을 코드에서 분리하여 환경별로 관리합니다.

Java code

import java.util.*;
import java.io.*;
import java.nio.file.*;

public class ExternalConfig {
    // 설정 소스 계층: 환경변수 > 시스템 프로퍼티 > 파일 > 기본값
    static class Config {
        private final Properties props = new Properties();

        Config(String configPath) {
            // 1. 기본값 로드
            props.setProperty("server.port", "8080");
            props.setProperty("db.url", "jdbc:h2:mem:default");

            // 2. 파일에서 로드
            try {
                if (Files.exists(Path.of(configPath))) {
                    props.load(new FileInputStream(configPath));
                }
            } catch (IOException e) {
                System.err.println("설정 파일 로드 실패: " + e.getMessage());
            }
        }

        String get(String key) {
            // 3. 환경변수 우선 (KEY_NAME 형식)
            String envKey = key.toUpperCase().replace('.', '_');
            String envVal = System.getenv(envKey);
            if (envVal != null) return envVal;

            // 4. 시스템 프로퍼티
            String sysVal = System.getProperty(key);
            if (sysVal != null) return sysVal;

            // 5. 파일 / 기본값
            return props.getProperty(key, "");
        }

        int getInt(String key, int defaultVal) {
            try { return Integer.parseInt(get(key)); }
            catch (NumberFormatException e) { return defaultVal; }
        }
    }

    public static void main(String[] args) {
        Config config = new Config("application.properties");
        System.out.println("포트: " + config.getInt("server.port", 8080));
        System.out.println("DB: " + config.get("db.url"));
    }
}
알아두면 좋은 점

Spring Boot는 이와 동일한 설정 우선순위를 사용합니다. @Value@ConfigurationProperties로 타입 안전하게 바인딩합니다.

자주 하는 실수

설정 파일에 비밀번호를 평문으로 저장하지 마세요. 환경 변수, Vault, 암호화된 설정을 사용하세요.

20마이크로서비스 통신 패턴

동기(REST)/비동기(메시지 큐) 통신과 선택 기준을 비교합니다.

Java code

import java.util.*;
import java.util.concurrent.*;

public class MicroserviceCommunication {
    // 1. 동기 통신 (REST)
    interface UserClient {
        String getUser(String id); // HTTP GET 호출
    }

    // 2. 비동기 통신 (메시지 큐)
    static class MessageBroker {
        private final Map<String, List<java.util.function.Consumer<String>>> subscribers =
            new ConcurrentHashMap<>();

        void subscribe(String topic, java.util.function.Consumer<String> handler) {
            subscribers.computeIfAbsent(topic, k -> new CopyOnWriteArrayList<>())
                       .add(handler);
        }

        void publish(String topic, String message) {
            System.out.printf("[MQ] 발행 %s: %s%n", topic, message);
            List<java.util.function.Consumer<String>> handlers = subscribers.get(topic);
            if (handlers != null) {
                handlers.forEach(h -> {
                    CompletableFuture.runAsync(() -> h.accept(message));
                });
            }
        }
    }

    public static void main(String[] args) throws Exception {
        MessageBroker broker = new MessageBroker();

        // 주문 서비스 -> 이벤트 발행
        broker.subscribe("order.created", msg ->
            System.out.println("[재고서비스] 재고 차감: " + msg));
        broker.subscribe("order.created", msg ->
            System.out.println("[알림서비스] 이메일 발송: " + msg));
        broker.subscribe("order.created", msg ->
            System.out.println("[분석서비스] 데이터 수집: " + msg));

        broker.publish("order.created", "주문#1234");

        Thread.sleep(500); // 비동기 처리 대기
    }
}
알아두면 좋은 점

동기(REST): 즉각 응답 필요 시, 비동기(메시지): 느슨한 결합/확장성 필요 시. 대부분의 시스템은 둘 다 사용합니다.

자주 하는 실수

동기 통신으로만 마이크로서비스를 연결하면 한 서비스 장애가 전체로 전파됩니다. 비동기 이벤트를 적극 활용하세요.

정리하며

  • record, enum, sealed로 대체 가능한 패턴은 손으로 구현하지 말고 문법에 맡깁니다
  • 인터페이스는 그것을 사용하는 쪽 패키지에 두어야 의존 방향이 실제로 역전됩니다
  • 서킷 브레이커와 리트라이는 함께 설계해 재시도가 장애를 증폭시키지 않게 합니다
  • JPMS 도입 시 리플렉션 기반 프레임워크를 위해 opens 선언을 미리 점검합니다

더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.