GoF 너머: 의존성 주입·엔터프라이즈·동시성 패턴
GoF 이후 널리 쓰이게 된 패턴과, 언어와 프레임워크가 패턴을 흡수한 모습이다.
목차 (16)
의존성 주입과 제어의 역전
GoF 책이 나온 뒤 가장 널리 퍼진 패턴을 하나만 고르라면 의존성 주입(DI, Dependency Injection)이다. 생각은 간단하다. 객체가 함께 일할 객체를 스스로 new로 만들지 않고, 바깥에서 받는다. 받는 쪽은 인터페이스만 알고, 무엇을 꽂을지는 조립하는 곳이 정한다. 상위 정책이 구체적인 구현이 아니라 추상에 기대라는 SOLID의 의존성 역전 원칙을 코드로 옮기는 가장 흔한 방법이다.
// 주입 전: 서비스가 협력 객체를 직접 만든다
public class OrderService {
private final MailSender sender = new MailSender();
public void place(Order order) {
// ... 주문 저장
sender.send(order.customerEmail(), "주문이 접수되었습니다");
}
}// 주입 후: 인터페이스만 알고, 구현은 생성자로 받는다
public interface Notifier {
void notify(String to, String message);
}
public class OrderService {
private final Notifier notifier;
public OrderService(Notifier notifier) {
this.notifier = notifier;
}
public void place(Order order) {
// ... 주문 저장
notifier.notify(order.customerEmail(), "주문이 접수되었습니다");
}
}
// 조립은 맨 바깥(main) 한 곳에서
OrderService service = new OrderService(new MailSender());
// 테스트에서는 가짜를 꽂는다
OrderService underTest = new OrderService((to, message) -> sent.add(to));이름은 마틴 파울러의 2004년 글에서 굳었다. 당시 이런 경량 컨테이너들은 스스로를 "제어의 역전"(IoC, Inversion of Control) 컨테이너라고 불렀는데, 파울러는 IoC가 프레임워크 대부분에 해당하는 너무 넓은 말이라 헷갈린다고 보고, 여러 사람과 의논해 더 좁은 이름인 의존성 주입을 붙였다. 같은 글은 주입 방식을 셋으로 나눈다.
- 생성자 주입: 생성자 인자로 받는다. 만들어진 순간 완전한 객체가 되고 필드를
final로 둘 수 있다. 파울러도 이것으로 시작하라고 권한다. - 세터 주입: 만든 뒤 세터로 넣는다. 선택적인 의존성이나 생성자 인자가 너무 많을 때 쓰지만, 넣기 전의 어중간한 상태가 생긴다.
- 인터페이스 주입: 받는 쪽이 "이걸 주입받겠다"는 인터페이스를 구현한다. 지금은 거의 쓰지 않는다.
가장 큰 이득은 테스트다. 위 코드의 마지막 줄처럼 진짜 메일 대신 보낸 주소를 모으는 람다를 꽂으면, 메일 서버 없이 "주문하면 알림이 간다"를 검사할 수 있다. 생성자 인자가 대여섯 개를 넘으면 주입이 문제가 아니라 클래스가 너무 많은 일을 한다는 신호다.
다른 언어에서는: Go는 컨테이너 없이 생성 함수(NewOrderService(n Notifier))에 인터페이스를 넘기는 손 조립이 기본이다. Python과 JavaScript는 함수나 객체를 그냥 인자로 넘기면 되고, 테스트에서는 모듈을 바꿔치기하는 도구(unittest.mock.patch, Jest의 jest.mock)를 함께 쓴다. 어느 언어든 "만드는 곳과 쓰는 곳을 나눈다"는 뼈대는 같다.
DI 컨테이너: 객체 그래프를 대신 조립한다
객체가 수백 개가 되면 main에서 손으로 조립하기 힘들다. DI 컨테이너는 "무엇이 무엇을 받는가"라는 설정을 읽고, 요청받은 객체를 만들기 위해 의존성을 거슬러 내려가 먼저 만든 뒤 넣어 준다. 스프링 문서는 객체가 의존성을 생성자 인자, 팩터리 메서드 인자, 속성으로만 밝히고 컨테이너가 빈을 만들 때 그것을 넣는 것을 DI라 하고, 객체가 스스로 클래스를 만들거나 서비스 로케이터로 찾는 것의 반대라서 "제어의 역전"이라고 설명한다.
필요: OrderService. 생성자 인자 OrderRepository, PaymentGateway부터 찾아 내려간다.
@Configuration
public class AppConfig {
@Bean
DataSource dataSource() {
return new HikariDataSource();
}
@Bean
OrderRepository orderRepository(DataSource dataSource) {
return new JdbcOrderRepository(dataSource);
}
@Bean
OrderService orderService(OrderRepository repository, PaymentGateway gateway) {
return new OrderService(repository, gateway);
}
}
// 컨테이너가 인자를 보고 의존성부터 만들어 넣는다
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
OrderService service = context.getBean(OrderService.class);- 빈과 컨테이너: 스프링에서 컨테이너가 만들고 조립하고 관리하는 객체를 빈(bean)이라 한다.
BeanFactory가 기본 기능을, 그 하위 인터페이스인ApplicationContext가 이벤트 발행, 국제화 메시지, AOP 연동 같은 기능을 더한다. - 순환 의존: A가 B를, B가 다시 A를 생성자로 받으면 어느 쪽도 먼저 만들 수 없다. 위 그림의 계산 함수도 이 경우 오류를 낸다. 컨테이너가 순환을 알려 주면 대개 책임을 잘못 나눈 것이므로 세 번째 객체로 공통 부분을 빼낸다.
- 수명: 컨테이너는 객체를 하나만 만들어 나눠 쓸지(싱글턴 범위), 요청마다 새로 만들지도 정한다. 직접 짠 싱글턴 대신 컨테이너가 하나만 만들게 하면 테스트에서 바꿔 끼울 수 있다.
컨테이너는 편하지만 마법이 되기 쉽다. 설정이 흩어지면 "이 인터페이스에 지금 무엇이 꽂혀 있나"를 실행해 봐야 안다. 작은 프로그램은 손 조립으로 충분하고, 컨테이너를 쓰더라도 클래스는 컨테이너를 모르게(애너테이션을 생성자 정도에만) 두면 나중에 떼어 내기 쉽다.
서비스 로케이터와 무엇이 다른가
서비스 로케이터는 등록소 하나를 두고, 필요한 객체가 그 등록소에 "Notifier를 달라"고 묻는다. 이것도 구현을 바깥에서 갈아 끼울 수 있다는 점은 DI와 같다. 차이는 방향이다. 로케이터는 필요한 쪽이 당겨 오고, 주입은 바깥이 밀어 넣는다.
// 서비스 로케이터: 필요한 것을 스스로 찾아온다
public class OrderService {
private final Notifier notifier = ServiceLocator.get(Notifier.class);
}파울러는 로케이터를 쓰면 모든 사용자가 로케이터에 의존하게 되고, 주입은 그렇지 않다는 점을 핵심 차이로 든다. 다만 그는 로케이터를 나쁜 패턴으로 몰지 않는다. 테스트하기 쉬운 정도는 둘 다 비슷하게 만들 수 있고, 한 응용 프로그램 안에서만 쓰는 클래스라면 동작이 더 곧바로 보이는 로케이터가 조금 낫다고까지 적는다. 여러 응용 프로그램이 가져다 쓰는 부품이라면, 쓰는 쪽이 모르는 로케이터에 기대게 하지 말고 주입을 쓴다. 그리고 둘 중 무엇을 고르느냐보다 "설정과 사용을 떼어 놓는 것"이 더 중요하다고 결론짓는다.
요즘 코드에서 로케이터가 문제가 되는 흔한 모양은 아무 데서나 부르는 context.getBean(...)이나 전역 등록소다. 숨은 의존성이 생기고, 등록을 빠뜨리면 컴파일이 아니라 실행 중에 터진다.
리포지터리와 유닛 오브 워크
마틴 파울러의 『Patterns of Enterprise Application Architecture』(PoEAA, 2003)는 GoF가 다루지 않은 업무용 응용 프로그램의 패턴, 특히 객체와 관계형 데이터베이스 사이의 패턴을 모았다. 그 가운데 가장 많이 쓰이는 둘이 리포지터리와 유닛 오브 워크다.
- 리포지터리(Repository): PoEAA 요약에 따르면 도메인과 데이터 매핑 층 사이에서 컬렉션처럼 생긴 인터페이스로 도메인 객체를 꺼내고 넣게 한다. 질의를 만드는 코드가 한곳에 모이고, 도메인 층은 매핑 층을 모른 채 한 방향으로만 기댄다.
- 유닛 오브 워크(Unit of Work): 한 업무 트랜잭션 동안 데이터베이스에 영향을 줄 변경(읽은 객체의 변경, 새 객체, 지운 객체)을 추적하다가, 끝나면 무엇을 써야 하는지 계산해 한꺼번에 쓴다. 객체를 바꿀 때마다 UPDATE를 보내지 않으니 왕복이 줄고, 쓰는 순서와 동시성 충돌도 한곳에서 다룬다.
public interface OrderRepository {
Optional<Order> findById(OrderId id);
List<Order> findUnpaidSince(LocalDate date);
void add(Order order);
void remove(Order order);
}
// 서비스는 컬렉션을 다루듯 쓴다. SQL도 표 이름도 모른다
public void cancel(OrderId id) {
Order order = orders.findById(id).orElseThrow();
order.cancel();
}둘은 보통 ORM 안에 들어 있다. EF Core 문서는 DbContext를 유닛 오브 워크와 리포지터리 패턴을 합친 것이라고 소개한다. 그래서 ORM 위에 리포지터리를 한 겹 더 씌우는 것이 늘 이득은 아니다. 도메인에 맞는 이름의 질의(findUnpaidSince)를 모으고 테스트에서 메모리 구현으로 바꿀 수 있다면 값어치가 있고, ORM 메서드를 그대로 넘기기만 한다면 층만 늘어난다.
액티브 레코드, 데이터 매퍼, DTO
객체를 데이터베이스에 어떻게 저장하느냐에 PoEAA는 두 큰 길을 둔다. 액티브 레코드는 객체 하나가 표의 한 행을 감싸고, 저장과 삭제를 스스로 하며, 그 데이터에 도메인 로직을 더한다. 데이터 매퍼는 매퍼 층을 따로 두어, 도메인 객체는 데이터베이스가 있는지조차 모르고 SQL도 스키마도 품지 않는다.
- 언제 액티브 레코드: 표 모양과 도메인 모양이 거의 같은 CRUD 위주 앱. Rails 안내서는 Rails의 Active Record가 바로 이 패턴의 구현이라고 밝힌다. 도메인 규칙이 복잡해지면 저장 코드와 규칙이 한 클래스에 엉킨다.
- 언제 데이터 매퍼: 상속, 값 객체, 컬렉션처럼 관계형 표에 없는 구조를 도메인에 쓰고 싶을 때. 파울러 요약도 객체 모델과 관계형 스키마가 어긋나는 문제를 이 패턴의 출발점으로 든다. Hibernate 같은 JPA 구현이 이쪽이다.
- DTO(Data Transfer Object): 원래 뜻은 원격 호출 한 번에 필요한 데이터를 몰아 실어 호출 횟수를 줄이는 객체다. 직렬화할 수 있어야 하고, 서버 쪽에서는 조립기(assembler)가 도메인 객체와 DTO 사이를 옮긴다. 지금은 API 응답 모양을 도메인 객체와 떼어 놓는 용도로 더 자주 쓴다. 엔터티를 그대로 JSON으로 내보내면 내부 필드가 새고, 필드 하나 바꿀 때 API가 깨진다.
// 경계를 넘는 값 묶음. 행동은 없고, 직렬화하기 쉽다
public record OrderSummaryDto(String id, long total, int itemCount) {
static OrderSummaryDto from(Order order) {
return new OrderSummaryDto(order.id().value(), order.total(), order.items().size());
}
}다른 언어에서는: Django ORM과 Laravel의 Eloquent는 액티브 레코드 쪽이고, SQLAlchemy는 데이터 매퍼와 유닛 오브 워크(세션) 쪽이다. TypeScript에서 DTO는 보통 클래스 없이 타입 선언(type OrderSummary = { id: string; total: number })과 경계에서의 스키마 검사로 쓴다.
표현 패턴: MVC, MVP, MVVM
화면 코드에서 가장 오래된 패턴은 MVC(Model-View-Controller)다. 트뤼그베 렌스카우그가 1970년대 말 제록스 PARC에서 Smalltalk-79를 다루며 만들었고, 처음에는 네 부분이던 것이 Smalltalk 개발자들 손에서 셋으로 정리됐다. 세 패턴 모두 핵심은 파울러가 "분리된 표현"이라 부른 생각, 즉 도메인 객체는 화면을 전혀 모르게 두는 것이다. 차이는 화면과 모델을 무엇이 잇느냐다.
| 패턴 | 화면을 바꾸는 쪽 | 뷰가 아는 것 | 잘 맞는 곳 |
|---|---|---|---|
| MVC | 모델의 변경 알림(옵저버)을 받은 뷰 | 모델 | 서버 렌더링 웹 프레임워크(요청을 컨트롤러가 받는다) |
| MVP | 프레젠터가 뷰 인터페이스를 불러서 | 프레젠터에 입력을 넘길 뿐 | 바인딩이 약한 UI 툴킷, 뷰를 가짜로 바꿔 테스트할 때 |
| MVVM | 데이터 바인딩이 뷰모델 속성을 따라 | 뷰모델의 속성과 명령 | 바인딩이 있는 플랫폼(WPF, 안드로이드 ViewModel, Vue) |
- MVP: 파울러에 따르면 1990년대 IBM과 Taligent에서 처음 나왔고 Dolphin Smalltalk 개발자들이 퍼뜨렸다. 뷰를 거의 아무 로직 없는 "수동적인 뷰"로 만들면, 프레젠터만 단위 테스트로 검사할 수 있다.
- MVVM: 마이크로소프트의 켄 쿠퍼와 테드 피터스가 만들었고, 존 고스먼이 2005년 블로그에서 알렸다. 파울러의 프레젠테이션 모델을 바꾼 것으로, 프레젠터와 달리 뷰모델은 뷰를 참조하지 않는다. 뷰가 뷰모델 속성에 묶이고, 동기화 코드는 바인딩이 대신 쓴다.
- 옵저버 동기화의 값: 파울러는 모델이 바뀌면 지켜보던 뷰가 스스로 갱신하는 방식(옵저버 동기화)이 결합을 낮추는 대신, 코드만 읽어서는 무슨 일이 일어나는지 알기 어렵다고 짚는다. 정해진 순간에 코드가 직접 화면을 고치는 흐름 동기화는 그 반대다. 다음 절의 단방향 흐름은 이 문제에 대한 한 가지 답이다.
단방향 데이터 흐름: Flux
화면이 커지면 양방향 연결이 꼬인다. 페이스북이 공개한 Flux 문서가 든 예가 그렇다. 대화를 읽음으로 표시하면 대화 목록과 따로 있는 "안 읽은 수"가 함께 바뀌어야 하는데, 모델이 다른 모델을, 그것이 또 다른 모델을 고치는 연쇄 갱신이 생기면 결과를 예측하기 어렵다. Flux는 흐름을 한 방향으로 묶는다.
- 액션: 종류와 내용을 가진 단순한 객체. 사용자의 입력이나 서버 응답이 액션이 된다.
- 디스패처: 모든 액션을 등록된 모든 스토어에 배달하는 중앙의 한 곳. 스토어 사이의 갱신 순서도 정한다.
- 스토어: 한 영역의 상태와 규칙을 갖고, 액션에만 반응해 바뀐 뒤 변경을 알린다. 바깥에서 스토어의 데이터를 직접 넣을 수 없다. 문서는 이것을 제어를 스토어 쪽으로 뒤집은 것이라고 표현한다.
- 뷰: 스토어의 변경을 듣고 다시 그린다. 그래서 사용자 동작 하나는 한 바퀴의 변경으로 끝난다.
// 스토어: 액션을 받아 새 상태를 돌려주는 곳이 하나뿐이다
function cartStore(state, action) {
switch (action.type) {
case 'item-added':
return { ...state, items: [...state.items, action.item] };
case 'cart-cleared':
return { ...state, items: [] };
default:
return state;
}
}
// 뷰는 상태를 고치지 않고 액션을 보낸다
dispatch({ type: 'item-added', item: { id: 'book-1', price: 12000 } });이 모양은 GoF의 커맨드(액션은 요청을 객체로 만든 것)와 옵저버(스토어가 뷰에 알린다)를 합친 것으로 읽을 수도 있다. 스토어를 "이전 상태와 액션을 받아 새 상태를 돌려주는 함수" 하나로 줄인 것이 위 코드의 꼴이고, 상태를 고치지 않고 새로 만들므로 이전 상태와 비교하거나 되돌리기가 쉽다.
널 객체와 명세
GoF 23가지에는 없지만 자주 쓰는 작은 패턴 둘이다. 널 객체는 "없음"을 null 대신 인터페이스를 구현하되 아무것도 하지 않는 객체로 나타낸다. 1996년 토마스 퀴네가 "Void Value"로 먼저 발표했고, 바비 울프가 『Pattern Languages of Program Design』 시리즈(1998)에 "Null Object"로 실었으며, 파울러의 『Refactoring』에는 "Insert Null Object"라는 리팩터링으로 들어 있다. 명세(Specification)는 업무 조건 하나를 isSatisfiedBy 하나짜리 객체로 만들고, and, or, not으로 엮어 새 조건을 만든다. 에릭 에번스의 『도메인 주도 설계』(2004)와 에번스, 파울러의 글이 널리 알렸다.
public interface Logger {
void log(String message);
}
public final class NullLogger implements Logger {
public static final Logger INSTANCE = new NullLogger();
@Override
public void log(String message) {
// 일부러 아무것도 하지 않는다
}
}
// 쓰는 쪽에는 if (logger != null)이 없다
Logger logger = config.loggingEnabled() ? new FileLogger(path) : NullLogger.INSTANCE;public interface Specification<T> {
boolean isSatisfiedBy(T candidate);
default Specification<T> and(Specification<T> other) {
return candidate -> isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate);
}
default Specification<T> not() {
return candidate -> !isSatisfiedBy(candidate);
}
}
Specification<Invoice> overdue = invoice -> invoice.dueDate().isBefore(today);
Specification<Invoice> inCollection = Invoice::inCollection;
Specification<Invoice> sendToCollection = overdue.and(inCollection.not());- 널 객체의 함정: 오류가 정상 실행처럼 보이게 숨을 수 있다. "할인 없음"처럼 아무것도 안 하는 것이 맞는 동작일 때만 쓰고, "고객을 못 찾음"처럼 부르는 쪽이 알아야 하는 경우에는 쓰지 않는다. 그런 경우에는 Java의
Optional이나 Kotlin의?처럼 "없을 수 있음"을 타입에 드러내는 편이 낫다. - 명세의 쓰임: 같은 조건을 메모리 안 검증, 화면 필터, 저장소 질의에 다시 쓴다. 리포지터리에
findBy(spec)를 두면 질의 메서드가 끝없이 늘어나는 것을 막는다. Spring Data JPA의Specification이 이 생각이다. 조건이 두세 개뿐이면 그냥 메서드 하나가 더 읽기 쉽다.
생산자-소비자: 블로킹 큐로 속도 차이를 흡수한다
동시성 패턴의 맏형은 생산자-소비자다. 만드는 쪽과 쓰는 쪽 사이에 크기가 정해진 버퍼를 두고, 버퍼가 가득 차면 생산자가, 비면 소비자가 기다린다. 에츠허르 데이크스트라가 1965년부터 이 문제를 다뤘고, 빈칸과 찬 칸의 수를 세는 세마포어로 풀었다. 흔히 보는 풀이는 여기에 버퍼를 지키는 상호 배제 하나를 더한다. 지금은 그 동기화를 큐 하나가 감춰 준다.
처음: 큐는 비어 있고 생산자와 소비자가 함께 돈다.
BlockingQueue<Job> queue = new ArrayBlockingQueue<>(3);
Job poison = new Job("STOP");
Thread producer = new Thread(() -> {
try {
for (Job job : incomingJobs()) {
queue.put(job); // 가득 차면 여기서 멈춘다
}
queue.put(poison); // 끝났다는 표시
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
while (true) {
Job job = queue.take(); // 비어 있으면 여기서 멈춘다
if (job == poison) {
return;
}
job.run();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});- Java의 BlockingQueue: API 문서는 이 인터페이스가 주로 생산자-소비자 큐에 쓰려고 만들어졌다고 적는다. 넣기와 꺼내기마다 네 가지 꼴이 있다. 예외를 던지는
add, 실패하면false나null을 돌려주는offer와poll, 될 때까지 멈추는put과take, 시간 제한이 있는offer와poll이다. - 끝내는 법: 큐에는 "닫기"가 없다. 문서가 권하는 흔한 방법은 위 코드처럼 생산자가 약속된 끝 표시(poison) 객체를 넣는 것이다.
null은poll의 실패 값이라 넣을 수 없다. - 왜 크기를 정하나: 크기 없는 큐는 소비자가 느릴 때 메모리가 끝없이 찬다. 가득 차면 생산자를 멈추게 하는 것이 곧 배압(backpressure)이다.
스레드와 락, 원자 연산이 어떻게 이런 큐를 받치는지는 병렬 프로그래밍 노트에 있다. 같은 모양이 서비스 사이로 커지면 메시지 큐가 된다. 다른 언어에서는: Go는 크기를 준 채널(make(chan Job, 3))이 바로 블로킹 큐이고, 다 보낸 뒤 close(ch)로 끝을 알린다. Python은 queue.Queue(maxsize=3), asyncio에서는 asyncio.Queue를 쓴다.
스레드 풀, 퓨처와 프로미스
작업마다 스레드를 새로 만들면 만드는 비용과 메모리가 작업 수만큼 든다. 스레드 풀은 정해진 수의 스레드를 미리 두고, 작업을 큐에 받아 비는 스레드가 꺼내 가게 한다. 안을 보면 앞 절의 생산자-소비자 그대로다. 퓨처(future)는 제출하는 순간 돌려받는 "나중에 결과가 들어올 자리"다.
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
Future<Report> future = pool.submit(() -> buildReport(month));
// ... 그동안 다른 일
Report report = future.get(); // 끝날 때까지 기다린다
} // Java 19부터 close()가 종료를 요청하고 끝나기를 기다린다
// 기다리지 않고 "끝나면 이것을" 잇기
CompletableFuture<Receipt> receipt =
CompletableFuture.supplyAsync(() -> loadCart(userId))
.thenCombine(CompletableFuture.supplyAsync(() -> loadCoupon(userId)), Cart::apply)
.thenApply(payments::charge)
.exceptionally(error -> Receipt.failed(error));- ExecutorService:
Executors.newFixedThreadPool(n)은 n개 스레드를 다시 쓰고, 남는 작업은 큐에서 기다리게 한다.submit은Future를 돌려주고,get()은 끝날 때까지 기다린다. Java 19부터ExecutorService가AutoCloseable이라 위처럼try로 감싸면 닫을 때 종료를 기다린다. - CompletableFuture:
get()으로 막혀 기다리는 대신 "끝나면 이것을 하라"를 잇는다.thenApply는 결과를 바꾸고,thenCombine은 두 결과를 합치고,exceptionally는 실패를 대체 값으로 바꾼다. 실행기를 주지 않은 비동기 메서드는ForkJoinPool.commonPool()에서 돈다. 입출력처럼 오래 막히는 일은 따로 만든 풀을 넘긴다. - 풀의 크기: CPU만 쓰는 일은 코어 수 근처, 입출력을 기다리는 일은 그보다 크게 잡는다. 한 풀에서 다른 작업의 결과를
get()으로 기다리면 일꾼이 모두 서로를 기다리는 교착이 생길 수 있다.
다른 언어에서는: JavaScript의 Promise가 같은 생각이다. 대기(pending)에서 이행(fulfilled)이나 거부(rejected)로 한 번만 바뀌고, then이 새 프로미스를 돌려줘 이어 붙인다. Promise.all은 모두 이행되면 결과 배열로, 하나라도 거부되면 그 이유로 끝난다. Python은 concurrent.futures.ThreadPoolExecutor와 asyncio.gather, Go는 고루틴 여러 개를 sync.WaitGroup이나 errgroup으로 기다린다.
const [user, posts] = await Promise.all([fetchUser(id), fetchPosts(id)]);리액터: 이벤트 루프 하나로 많은 연결을
연결마다 스레드를 하나씩 두면, 연결이 만 개일 때 대부분의 스레드는 읽을 데이터를 기다리며 메모리와 문맥 전환만 쓴다. 웹 서버의 C10k 문제에서 나온 리액터는 입출력이 준비된 뒤에만 반응한다. 더글러스 슈미트가 1995년 『Pattern Languages of Program Design』에 정리했다.
- 핸들: 소켓이나 파일 기술자처럼 입출력의 대상을 가리키는 것.
- 디멀티플렉서: 여러 핸들을 한꺼번에 지켜보고 준비된 것을 알려 준다. 운영체제의
select,epoll,kqueue가 이것이다. - 디스패처: 이벤트 루프. 핸들러를 등록받고, 준비된 핸들에 맞는 핸들러를 부른다.
- 핸들러: 한 종류의 일을 하는 콜백. 기본적으로 디스패처의 스레드에서 돌므로 오래 걸리면 모든 연결이 멈춘다.
Netty, Nginx, Node.js가 이 구조다. 코어마다 루프를 하나씩 두는 다중 리액터, 무거운 핸들러를 앞 절의 스레드 풀로 넘기는 변형이 흔하다. 콜백으로 흐름이 쪼개져 디버깅이 어렵다는 단점은 async/await가 많이 덜어 줬다. 이벤트 루프가 언어마다 어떻게 보이는지는 프로그래밍 언어 비교 노트의 동시성 모델 절에 있다.
함수형 언어에서의 패턴: 언어가 흡수한 것들
패턴의 상당수는 언어에 없는 기능을 클래스로 메우는 방법이기도 하다. 피터 노빅은 1996년 발표 "Design Patterns in Dynamic Programming"에서 GoF 23가지 중 16가지가 Lisp나 Dylan에서는 보이지 않거나 더 단순해진다고 세었다. 그중 일급 함수 덕분에 사라지는 것으로 든 넷이 커맨드, 전략, 템플릿 메서드, 방문자다. 자세한 분류는 패턴의 유래 노트에 있다.
// 전략 인터페이스와 구현 클래스 셋 대신 함수 하나
public class Checkout {
private final UnaryOperator<Long> discount;
public Checkout(UnaryOperator<Long> discount) {
this.discount = discount;
}
public long pay(long price) {
return discount.apply(price);
}
}
Checkout normal = new Checkout(price -> price);
Checkout sale = new Checkout(price -> price * 9 / 10);
// 커맨드도 Runnable 하나로: 실행 취소 목록에 함수를 쌓는다
Deque<Runnable> undo = new ArrayDeque<>();
undo.push(() -> document.delete(range));- 전략과 커맨드: 메서드 하나짜리 인터페이스는 함수 타입(
Function,Runnable,Comparator)이면 된다.list.sort(Comparator.comparing(Order::total))는 전략 패턴을 쓴 것이지만 아무도 그렇게 부르지 않는다. 각 패턴의 클래스 꼴은 행위 패턴 1 노트에 있다. - 템플릿 메서드: 상속 대신 "바뀌는 단계"를 함수 인자로 받는 고차 함수가 된다.
- 이터레이터, 옵저버: 언어와 표준 라이브러리에 이미 들어 있다(
for-each, 제너레이터, 이벤트 리스너, 리액티브 스트림). - 모나드는 짧게: 함수형 언어에서 자주 듣는 모나드는 "값을 감싼 상자와, 상자를 벗기지 않고 다음 계산을 잇는 방법(
flatMap)"의 짝이라고 보면 된다.Optional.flatMap,CompletableFuture.thenCompose,Promise.then이 같은 모양이라, null 검사나 콜백 중첩 없이 단계를 이어 붙인다.
그렇다고 패턴 공부가 쓸모없어지지는 않는다. 함수가 되든 클래스가 되든 "바뀌는 것을 떼어 내 인자로 받는다"는 설계 판단은 남고, 함수가 여러 개 필요하거나 상태를 품어야 하면 다시 객체가 낫다.
시스템 단위 패턴은 시스템 설계 노트로
같은 생각이 더 큰 크기에서도 반복된다. 객체 사이의 프록시는 서비스 앞의 서킷 브레이커가 되고, 커맨드와 메멘토는 이벤트 소싱이 되고, 유닛 오브 워크의 트랜잭션은 여러 서비스에 걸치면 사가가 된다. 이 노트는 한 애플리케이션 안까지만 다루고, 네트워크를 건너는 패턴은 시스템 설계 노트에서 다룬다.
- 서킷 브레이커: 실패가 이어지는 상대에게 잠시 요청을 끊는다. 설계의 부품 › 시간 제한, 재시도, 서킷 브레이커
- 사가: 서비스마다 로컬 트랜잭션을 하고, 실패하면 보상 동작으로 되돌린다. 분해와 분산 데이터 › 2PC와 사가, 사가와 보상
- 이벤트 소싱과 CQRS: 현재 상태 대신 일어난 일을 쌓고, 읽기 모델을 따로 만든다. 배치와 스트림 처리 › 이벤트 소싱
- 아키텍처 스타일: 계층형, 이벤트 기반, 마이크로서비스 같은 전체 구조는 아키텍처 스타일 노트에 있다.
패턴 고르기 정리
패턴은 증상에서 출발해 고른다. "이 패턴을 써 보자"에서 출발하면 문제가 없는 곳에 층만 늘어난다. 아래는 이 노트의 패턴을 증상으로 다시 묶은 것이다.
| 패턴 | 푸는 문제 | 치르는 값 | 쓰지 않아도 될 때 |
|---|---|---|---|
| 의존성 주입 | 구체 클래스에 묶여 바꾸거나 테스트할 수 없음 | 조립 코드나 컨테이너 설정 | 바뀔 일 없는 값 객체, 유틸 함수 |
| 리포지터리 | 질의가 서비스 곳곳에 흩어짐 | 층 하나, 질의 메서드 관리 | ORM 메서드를 그대로 넘기기만 할 때 |
| 유닛 오브 워크 | 변경마다 왕복, 부분 저장 | 추적 비용, 언제 쓰이는지 덜 보임 | 대개 ORM이 이미 해 준다 |
| 데이터 매퍼 | 도메인과 표 모양이 다름 | 매핑 설정, 학습 비용 | 표 그대로의 CRUD(액티브 레코드로 충분) |
| DTO | 내부 모델이 API로 샘, 원격 왕복 | 변환 코드 | 같은 프로세스 안의 호출 |
| MVVM, 단방향 흐름 | 화면과 로직이 엉킴, 연쇄 갱신 | 보일러플레이트, 간접 층 | 화면 몇 개짜리 도구 |
| 널 객체 | null 검사가 곳곳에 | 오류가 숨을 수 있음 | "없음"을 부르는 쪽이 알아야 할 때 |
| 명세 | 조건 조합이 늘어남 | 객체 수 | 조건이 두세 개일 때 |
| 생산자-소비자 | 만드는 속도와 쓰는 속도가 다름 | 끝내기, 오류 전달이 까다로움 | 순서대로 바로 처리해도 될 때 |
| 스레드 풀, 퓨처 | 스레드 생성 비용, 결과 기다리기 | 풀 크기 조정, 교착 위험 | 작업이 몇 개뿐일 때 |
| 리액터 | 연결 수만큼 스레드가 듦 | 막는 코드를 쓰면 전체가 멈춤 | 연결이 적고 처리가 무거울 때 |
흔한 오해
- "DI를 하려면 스프링 같은 컨테이너가 필요하다" — 주입은 생성자로 받는 것일 뿐이다. 컨테이너는 객체가 많아졌을 때 조립을 대신해 주는 도구다.
- "제어의 역전과 의존성 주입은 같은 말이다" — IoC는 프레임워크가 내 코드를 부르는 넓은 생각이고, DI는 그 가운데 의존성을 바깥에서 넣는 한 방식이다. 이름이 따로 생긴 이유가 바로 그 혼동이었다.
- "서비스 로케이터는 안티패턴이다" — 숨은 의존성이 생기는 것은 사실이지만, 파울러는 한 응용 프로그램 안에서는 쓸 만하다고 봤다. 중요한 것은 설정과 사용을 나누는 것이다.
- "리포지터리는 ORM 위에 늘 한 겹 더 씌워야 한다" — ORM이 이미 리포지터리와 유닛 오브 워크를 품고 있는 경우가 많다. 도메인 이름의 질의를 모을 때 값어치가 있다.
- "DTO는 계층마다 하나씩 만든다" — 원래 목적은 경계(프로세스, 공개 API)를 넘는 것이다. 같은 프로세스 안 계층마다 복사본을 두면 변환 코드만 는다.
- "MVVM이 MVC보다 새롭고 낫다" — 셋은 화면을 무엇이 갱신하느냐의 선택이다. 바인딩이 없는 환경에서 MVVM을 흉내 내면 바인딩 코드를 손으로 쓰게 된다.
- "함수형 언어에는 패턴이 없다" — 패턴 일부가 언어 기능으로 사라질 뿐, 바뀌는 것을 떼어 내는 판단은 그대로 남고 함수형 쪽만의 패턴(모나드, 렌즈 등)도 생긴다.
핵심 자료
요지는 직접 정리한 한 줄이다.
| 자료 | 한 줄 요지 |
|---|---|
| Martin Fowler — Inversion of Control Containers and the Dependency Injection pattern (2004) | "의존성 주입"이라는 이름이 나온 글. 세 가지 주입 방식과 서비스 로케이터와의 차이를 비교한다. |
| Spring — IoC 컨테이너 소개 | 빈, BeanFactory, ApplicationContext. 객체가 의존성을 스스로 찾지 않는다는 뜻의 "역전". |
| Martin Fowler — PoEAA 패턴 목록 | 리포지터리, 유닛 오브 워크, 데이터 매퍼, 액티브 레코드, DTO의 한 쪽짜리 요약. |
| Martin Fowler — GUI Architectures (2006) | MVC에서 MVP, 프레젠테이션 모델까지. 옵저버 동기화와 흐름 동기화의 차이. |
| Flux — In-Depth Overview | 액션, 디스패처, 스토어, 뷰가 한 방향으로 도는 이유를 MVC의 연쇄 갱신과 비교한다. |
| Java SE 21 — BlockingQueue | 넣기, 꺼내기의 네 가지 꼴(예외, 특수값, 멈춤, 시간 제한)과 생산자-소비자 예. |
| Reactor pattern — Wikipedia | 핸들, 디멀티플렉서, 디스패처, 핸들러. C10k 문제에서 나온 배경과 단점. |
| Peter Norvig — Design Patterns in Dynamic Programming (1996) | 일급 함수와 일급 타입 같은 언어 기능이 GoF 패턴 다수를 얼마나 단순하게 만드는지 센다. |
- Catalog of Patterns of Enterprise Application Architecture — Martin Fowler
- Inversion of Control Containers and the Dependency Injection pattern — Martin Fowler
- Introduction to the Spring IoC Container and Beans — Spring Framework
- Repository — Martin Fowler (PoEAA)
- Unit of Work — Martin Fowler (PoEAA)
- Data Mapper — Martin Fowler (PoEAA)
- Active Record — Martin Fowler (PoEAA)
- Data Transfer Object — Martin Fowler (PoEAA)
- Active Record Basics — Ruby on Rails Guides
- DbContext Class — Microsoft Learn
- GUI Architectures — Martin Fowler
- Model–view–controller — Wikipedia
- Model–view–viewmodel — Wikipedia
- Flux: In-Depth Overview
- Null object pattern — Wikipedia
- Specification pattern — Wikipedia
- Producer–consumer problem — Wikipedia
- BlockingQueue — Java SE 21 API
- ExecutorService — Java SE 21 API
- CompletableFuture — Java SE 21 API
- Promise — MDN Web Docs
- Reactor pattern — Wikipedia
- Design Patterns in Dynamic Programming — Peter Norvig