시스템, 창발성, 동시성
- 5.1 도시를 세운다면?
- 5.2 시스템 제작과 사용을 분리하라
- 5.3 확장
- 5.4 자바 프록시
- 5.5 순수 자바 AOP 프레임워크
- 5.6 AspectJ 관점
- 5.7 테스트 주도 시스템 아키텍처
- 5.8 의사 결정을 최적화하라
- 5.9 창발적 설계로 깔끔한 코드를 구현하자
- 5.10 단순한 설계 규칙 1: 모든 테스트를 실행하라
- 5.11 단순한 설계 규칙 2: 중복을 없애라
- 5.12 단순한 설계 규칙 3: 프로그래머 의도를 표현하라
- 5.13 단순한 설계 규칙 4: 클래스와 메서드 수를 최소로 줄여라
- 5.14 동시성이 필요한 이유
- 5.15 미신과 오해
- 5.16 동시성 방어 원칙
- 5.17 실행 모델을 이해하라
- 5.18 동기화하는 메서드 사이에 존재하는 의존성을 이해하라
- 5.19 동시성 테스트
시스템 아키텍처, 창발적 설계, 동시성 — 함수와 클래스를 넘어서 시스템 전체를 어떻게 깨끗하게 유지할 것인가의 문제야. 관심사를 분리하고, 단순한 규칙을 반복하고, 동시성의 함정을 피하는 게 핵심이지.
도시가 돌아가는 이유는 적절한 추상화와 모듈화 때문이야. 한 사람이 도시 전체를 관리하지 않잖아. 수도, 전기, 교통, 치안 같은 시스템이 각각의 팀에 의해 관리돼. 소프트웨어 시스템도 마찬가지로 관심사를 분리해서 관리해야 해.
"제작(construction)"과 "사용(use)"은 아주 다른 관심사야. 소프트웨어 시스템은 준비 과정(객체를 생성하고 의존성을 연결하는 것)과 런타임 로직을 분리해야 해. 흔한 패턴인 초기화 지연(Lazy Initialization) — if (service == null) service = new MyServiceImpl(...); — 은 장점이 있어. 실제 필요할 때까지 객체를 생성하지 않으니 시작이 빠르거든. 하지만 getService()가 MyServiceImpl에 의존하게 되고, 생성 로직이 런타임 로직에 섞여 있어서 테스트가 어려워져.
시스템 생성과 사용을 분리하는 방법은 여러 가지야. 생성과 관련된 코드를 전부 main이나 main이 호출하는 모듈로 옮기는 것이 하나고. 나머지 시스템은 모든 객체가 이미 생성되었다고 가정하는 거지. 객체 생성 시점을 애플리케이션이 결정해야 할 때는 추상 팩토리 패턴을 써. 그리고 의존성 주입(Dependency Injection, DI) — 객체가 자신의 의존성을 직접 생성하지 않고, 전담 메커니즘(컨테이너)이 주입해주는 거야. 스프링 프레임워크가 자바에서 가장 유명한 DI 컨테이너지.
"처음부터 올바르게" 시스템을 만들 수 있다는 것은 미신이야. 오늘 주어진 사용자 스토리에 맞게 시스템을 구현하고, 내일은 새 스토리에 맞게 조정하고 확장하면 돼. 관심사를 적절히 분리해서 관리하면, 소프트웨어 아키텍처는 점진적으로 발전할 수 있거든. EJB1과 EJB2 아키텍처가 관심사를 제대로 분리하지 못한 나쁜 예시로 등장해. 비즈니스 로직이 EJB2 컨테이너에 강하게 결합되어 있어서, 단위 테스트가 사실상 불가능했거든.
자바 프록시는 단순한 상황에 적합하지만 코드가 복잡해. 횡단 관심사를 다루는 더 나은 방법이 "AOP(관점 지향 프로그래밍)"야. 스프링에서는 비즈니스 로직을 **POJO(Plain Old Java Object)**로 작성해. POJO는 순수하게 도메인에 집중하고, 프레임워크에 의존하지 않아. 영속성, 트랜잭션, 보안, 캐싱 같은 횡단 관심사는 설정 파일이나 어노테이션으로 선언적으로 적용하지. 이 구조의 장점은 POJO를 테스트하기 쉽다는 거야. AspectJ는 AOP를 위한 전용 언어로 더 세밀한 제어가 가능하지만, 새 언어를 배워야 한다는 진입 장벽이 있어.
관심사를 적절히 분리한다면, 아키텍처를 테스트 주도로 만들 수 있어. 처음에 BDUF(Big Design Up Front)를 하지 않아도 돼. POJO로 비즈니스 로직을 짜고, 관심사를 분리하면 아키텍처가 필요에 따라 진화할 수 있거든. "최선의 시스템 아키텍처는 POJO 객체로 구현된 모듈화된 관심사 영역으로 구성된다". 모듈화되고 관심사가 분리된 시스템에서는 "가능한 마지막 순간까지 결정을 미루는 것"이 최선일 때가 많아. 최대한 정보를 모아 최선의 결정을 내리는 거지. 성급한 결정은 불충분한 지식으로 내린 결정이야.
시스템 설계가 관심사 분리로 깨끗해진다면, 코드 수준에서는 단순한 규칙 네 가지만 지키면 SRP, DIP 같은 원칙이 알아서 따라와. "창발성(emergence)"이란 단순한 규칙을 반복적으로 적용하면, 복잡하고 우수한 설계가 자연스럽게 나타나는 현상이야. 켄트 벡의 단순한 설계 규칙은 중요도 순으로: 모든 테스트를 실행한다, 중복을 없앤다, 프로그래머 의도를 표현한다, 클래스와 메서드 수를 최소로 줄인다.
설계는 의도한 대로 동작하는 시스템을 만들어야 해. 아무리 완벽한 설계도 시스템이 의도대로 돌아가는지 검증할 방법이 없다면 의미가 없거든. 테스트가 가능한 시스템을 만들려고 애쓰면 "설계 품질이 자연스럽게 올라가". 왜냐하면 테스트를 작성하기 쉬우려면 SRP를 준수하는 작은 클래스가 필요하고, 결합도가 낮아야 하고, DIP 같은 원칙이 저절로 따라오니까.
테스트를 모두 작성했다면 이제 코드와 클래스를 정리할 차례야. 테스트가 있으니까 코드를 바꿔도 시스템이 깨지지 않는다는 확신이 있거든. 중복은 "추가 작업, 추가 위험, 불필요한 복잡성"을 의미해. 똑같은 코드가 두 군데 있으면, 하나를 고칠 때 다른 하나도 고쳐야 한다는 걸 기억해야 하고, 기억 못 하면 버그가 되지. 구현이 중복인 경우도 있어. isEmpty()를 size() == 0으로 구현하면 되는 거야. 소규모 재사용은 시스템 복잡도를 극적으로 줄여줘. TEMPLATE METHOD 패턴도 고차원 중복을 제거하는 좋은 기법이야.
코드를 짠 사람은 자기 코드를 이해해. 문제는 나중에 유지보수하는 사람이야. "의도를 명확히 표현해" — 좋은 이름을 선택하고, 함수와 클래스 크기를 가능한 줄이고, 디자인 패턴을 쓴다면 클래스 이름에 패턴 이름을 넣어. Command, Visitor 같은 이름은 다른 개발자가 설계 의도를 바로 파악하게 해주거든. 중복을 제거하고, 의도를 표현하고, SRP를 준수하다 보면 클래스와 메서드가 너무 많아질 수 있어. 그래서 네 번째 규칙이 필요한 거야 — 함수와 클래스 수를 가능한 줄여. 다만 이 규칙은 네 가지 중 우선순위가 가장 낮아.
시스템 설계와 창발적 규칙이 "무엇을" 만들 것인가의 문제라면, 동시성은 "언제" 실행할 것인가의 문제야. 동시성은 어려워. 아주 어려워. 심지어 경험 많은 프로그래머도 동시성 코드에서 실수하거든. 동시성은 "결합(coupling)을 없애는 전략"이야. 무엇(what)과 언제(when)를 분리해.
동시성은 무엇과 언제를 분리하면 시스템의 구조와 처리량(throughput)이 개선될 수 있기 때문에 필요해. 서블릿 모델이 좋은 예야. 각 요청이 독립적인 스레드에서 처리되니까, 웹 요청을 동시에 처리할 수 있지. 근데 동시성에 대한 미신과 오해가 많아. "동시성은 항상 성능을 높인다" — 아니야. 여러 스레드가 기다리는 시간이 길 때만 성능이 개선돼. "동시성을 구현해도 설계를 변경할 필요가 없다" — 단일 스레드와 다중 스레드의 설계는 근본적으로 달라. 동시성은 다소 부하를 유발하고, 복잡하고, 버그는 재현하기 어렵고, 근본적인 설계 전략을 재고해야 해.
동시성 코드가 일으키는 문제로부터 시스템을 방어하는 원칙들이 있어. SRP — 동시성 코드는 다른 코드와 분리해. 공유 자료를 수정하는 곳이 많을수록 위험하니까 자료 범위를 제한하고, 가능하면 자료 사본을 사용해서 각 스레드가 독립된 사본으로 작업하게 해. 각 스레드가 다른 스레드와 자료를 공유하지 않고, 마치 세상에 자기 혼자인 것처럼 돌아가게 하는 게 최선이야.
다중 스레드 프로그래밍에서 자주 나오는 기본 개념들도 알아야 해. 한정된 자원(Bound Resource), 상호 배제(Mutual Exclusion), 기아(Starvation), 데드락(Deadlock), 라이브락(Livelock). 대표적인 실행 모델로는 생산자-소비자(Producer-Consumer), 읽기-쓰기(Readers-Writers), **식사하는 철학자들(Dining Philosophers)**이 있어. 공유 클래스 하나에 synchronized 메서드가 둘 이상이면 위험해. 가능하면 공유 객체 하나에는 메서드 하나만 동기화해.
동시성 코드 테스트는 정말 어려워. 말이 안 되는 실패는 잠정적인 스레드 문제로 취급해 — "한 번 나왔는데 다시 안 나오니까 우연이겠지" 하면 안 돼. 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들고, 다양한 환경에서 실행하고, 스레드 코드를 플러그인할 수 있게 구현해.
정리
5장 읽고 기억할 거 세 가지:
- 시스템 제작과 사용을 분리하고, 아키텍처는 진화시켜라. 관심사를 분리하면 점진적으로 확장할 수 있고, POJO 기반 설계가 테스트와 유연성의 기반이다
- 단순한 규칙을 반복 적용하면 복잡한 설계가 창발한다. 테스트를 실행하고, 중복을 제거하고, 의도를 표현하라 — 이 순서대로 지키면 SRP, DIP, OCP가 저절로 따라온다
- 동시성은 어렵고, 공유 자료를 최소화하라. 동시성 코드를 분리하고, 자료 사본을 쓰고, 일회성 실패를 무시하지 마라. 다양한 환경에서 충분히 테스트하라