코드
https://github.com/rkwhr0010/clean_code/tree/main/src
변경 사항은 git history 참고
복잡서은 주근이다. 개발자에게서 생기를 앗아가며, 제품을 계획하고 제작하고 테스트하기 어렵게 만든다.
-레이 오지, 마이크로소프트 CTO
도시를 세운다면?
온갖 세부사항을 혼자 직접 관리하기란 불가능이다.
이미 잘 가꿔진 도시라도 한 사람으론 불가능이다.
도시가 잘 돌아가는 이유는 전기, 수도, 도로 등 각 분야의 팀이 있기 때문이다.
다른 이유로 적절한 추상화와 모듈화 덕분이다.
정부 조직을 예를 들면, 행정부(정부)는 나라 전반의 일을 큰 그림에서 담당할 것이다.
그 아래 ~부, ~부 아래 ~청 이런식으로 점점 구체화된다.
각 부분을 추상화와 모듈화에 빗대면, 저수준 기관들은 전체 행정 큰 그림을 몰라도 상관없다.
소프트웨어도 이와 같다.
시스템 제작과 시스템 사용을 분리하라
construction, use
호텔을 짓는 사람과 완공 후 이용하는 사람은 다르다.
자동차를 제조하는 사람과 자동차를 구매해 사용하는 사람은 다르다.
이처럼 제작과 사용은 전혀 다르다
소프트웨어 시스템은 애플리케이션 객체를 제작하고 의존성을 서로 연결하는 준비과정과 준비 과정 이후에 이어지는 런타임 로직을 분리해야 한다.
시작 단계(제작)는 모든 애플리케이션이 풀어야할 관심사(concern)다.
관심사 분리는 가장 오래되고 중요한 설계 기법 중 하나다.
준비과정과 런타임 로직이 뒤섞인 예시
//지연로딩 예시 public Service getService() { if(service == null) { service = new ServiceImpl("1", "2", "3"); // 모든 상황에 적합한 객체인지 모르겠음... } return service; } |
지연로딩 설명
- 필요할 때 객체 생성한다. 무거운 객체를 필요한 시점에 생성하면, 어플리케이션 시작 시간이 짧아진다.
- 어떠한 경우에도 null을 리턴하지 않는다.
getService() 메서드는 ServiceImpl 생성자 인수에 명시적으로 의존한다.
프로그래밍에서 new 연산자 사용 자체가 의존성을 의미한다.
구체적인 클래스만이 인스턴스화가 가능하기 때문이다. (DIP)
런타임 로직에서 ServiceImpl 객체를 사용하지 않더라도 의존성을 해결하지 않으면 컴파일이 안된다.
테스트도 힘들다. ServiceImpl 가 무거운 객체라 getService()호출 전에
Mock객체를 할당하려 해도 런타임 로직과 객체 생성 로직이 섞인 탓에
service가 null인 경우와 아닌 경우를 테스트 해야 한다. 책임이 두 개다. SRP를 위반한다.
if문이 보이면 일단 SRP를 위반하는지 살펴야 한다.
ServiceImpl 가 모든 상황에 적합한 객체인지 모른다는 것도 문제다.
타입은 인터페이스지만, new 연산자 사용으로 구체적인 클래스에 의존하고 있다.
이 코드를 테스트를 하려할 때 이 객체가 적합한 객체인지 파악할 수 있을까?
초기화 지연 기법을 적게 사용하면 심각한 문제는 아니지만,
많은 어플리케이션이 이런 좀스러운 설정 기법을 수시로 사용한다.
이런 방식은 어플리케이션 곳곳에 흩어져 모듈성은 저조하며 중복이 심하다.
설정 논리는 일반 실행 논리와 분리해야 모듈성이 높아진다.
스프링은 선언적인 코드로 객체 생성과 조립을 해줘 new 연산자에 의한 의존성이 사라진다.
이 과정에서 지연로딩도 알아서 처리해준다.
Main 분리
시스템 생성과 시스템 사용 분리하는 기법 중 하나로
생성 관련 코드는 모두 main이나 main이 호출하는 모듈로 옮긴다.
나머지 시스템은 모든 객체가 생성되고 모든 의존성이 연결되었다고 가정한다.
제어 흐름을 따라가기 쉽다.
main함수에서 모든 객체를 생성한 후 애플리케이션에 넘기면,
어플리케이션은 그저 객체를 사용하면 그만이다.
이렇게 되면, 항상 의존 방향은 main함수에서 애플리케이션으로 간다.
따라서, 애플리케이션은 main함수가 객체를 어떻게 생성되는지 전혀 모르게 된다.
팩토리
객체가 생성되는 시점을 애플리케이션이 결정할 필요도 생길 수 있다.
추상 팩토리 패턴을 쓰면, 애플리케이션이 객체를 생성하는 시점을 결정하지만,
애플리케이션은 객체를 생성하는 코드를 모른다.(추상 팩토리가 책임)
모든 의존성이 main에서 OrderProcessing 애플리케이션으로 향한다.
OrderProcessing 애플리케이션은 LineItem 이 생성되는 구체적인 방법을 모든다.
그럼에도 , LineItem 이 생성되는 시점은 통제한다.
의존성 주입
사용과 제작을 분리하는 강력한 메커니즘
DI(Dependency Injection)
DI는 IoC 기법을 의존성 관리에 적용한 매커니즘
제어 역전에서는 한 객체가 맡은 보조 책임을 새로운 객체에게 위임한다.
새로운 객체는 위임받은 책임만 맡으므로 SRP을 지키게 된다. (DI 컨테이너)
// JNDI 검색은 의존성 주입을 부분적으로 구현한 기능 // 디렉터리 서버에 이름을 제공하고 그 이름에 일치하는 서비스를 요청 Service myService = (Service)JndiContext.lookup("이름"); |
진정한 의존성 주입은 반환되는 객체 유형을 제어하지 않는다. (클래스 의존성)
의존성을 주입하는 방법으로 설정자 메서드나 생성자 인수를 제공한다.
스프링 프레임워크는 자바 DI 컨테이너를 제공
객체 사이 의존성은 설정 파일로 관리한다 (XML, @Configuration)
대부분 DI 컨테이너는 프록시 같은 기법으로 지연 로딩을 지원한다.
확장
군락 -> 마을 -> 도시
위와 같이 성장을 하는데, 각종 인프라가 확장되고, 없던 인프라도 생긴다.
이러한 과정은 원활하게 진행되진 않는다.
예를들어, 도로의 경우 도시로 성장하면서 확장해야 하는 경우 "왜 처음부터 넓게 만들지 않았을까?"라는 의문이 생길 수 있다.
하지만, 수 많은 마을 중 어느 마을이 도시로 성장할까?
시골 마을에 왕복 10차선을 뚫는다고 하면 납득할 사람이 있을까?
처음부터 올바르게 시스템을 만들 수 있다는 믿음은 미신이다.
오늘 주어진 사용자 요구사항에 맞춰 시스템을 구현해야 한다.
매일 새로운 요구에 맞춰 반복적으로 점진적으로 확장하는 것이 애자일 방식의 핵심
이를 도와주는 것이 TDD(테스트주도개발), 리팩터링이다.
코드 레벨이 아닌 시스템 아키텍처 레벨에도 적용될까?
소프트웨어 시스템은 물리적인 시스템과 다르다. 관심사를 적절히 분리해 관리한다면 소프트웨어 아키텍처는 점진적으로 발전할 수 있다.
과거 EJB1, EJB2 아키텍처는 관심사를 적절히 분리하지 못했기에 유기적인 성장이 어려웠다.
횡단(cross-cutting)관심사
EJB2 아키텍처는 일부 영역에서 관심사를 완벽에 가깝게 분리한다.
트랜잭션, 보안, 일부 영속적인 동작은 배치 기술자에서 정의한다. (코드가 아님)
영속성 같은 관심사는 모든 객체가 전반적으로 동일한 방식을 이용해야 바람직하다.
그리고 실제로도 거의 같다.
AOP(관점 지향 프로그래밍)는 횡단 괌심사에 대해 모듈성을 확보하는 일반적인 방법론이다.
AOP에서 관점이라는 모듈 구성 개념은
"특정 관심사를 지원하려면 시스템에서 특정 지점들이 동작하는 방식을 일관성 있게 바꿔야 한다." 라고 명시한다.
명시는 간결한 선언이나 프로그래밍 매커니즘으로 수행한다.
자바에서 사용하는 관점 혹은 유사한 매커니즘 세 개
- 자바 프록시
- 순수 자바 AOP 프레임워크
- AspectJ 관점
자바 프록시
메서드 호출을 감싸는 경우와 같은 단순한 상황에 적합
JDK에서 제공하는 동적 프록시는 인터페이스만 지원한다.
클래스 프록시를 사용하려면 CGLIB, ASM, Javaassist 같은 바이트 코드 처리 라이브러리가 필요하다.
스프링은 CGLIB 사용
public interface Bank { Collection<Account> getAccounts(); void setAccounts(Collection<Account> accounts); } |
//POJO 순수자바객체 public class BankImpl implements Bank{ private Collection<Account> accounts; public Collection<Account> getAccounts() { return accounts; } public void setAccounts(Collection<Account> accounts) { //방어적 복사 this.accounts = new ArrayList<>(); for (Account a : accounts) { this.accounts.add(a); } } } |
public class BankProxyHandler implements InvocationHandler{ private Bank bank; public BankProxyHandler(Bank bank) { this.bank = bank; } @SuppressWarnings("unchecked") @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String methodName = method.getName(); if (methodName.equals("getAccounts")) { bank.setAccounts(getAccountsFromDatabase()); return bank.getAccounts(); } else if (methodName.equals("setAccounts")) { bank.setAccounts((Collection<Account>) args[0]); setAccountsToDatabase(bank.getAccounts()); return null; } else { // 기타 메서드... return null; } } //세부사항 protected void setAccountsToDatabase(Collection<Account> accounts) { } protected Collection<Account> getAccountsFromDatabase() { return null; } } |
//사용하는 코드... Bank bank = (Bank) Proxy.newProxyInstance(Bank.class.getClassLoader(), new Class[] {Bank.class}, new BankProxyHandler(new BankImpl())); |
프록시로 감쌀 인터페이스 Bank
Bank와 비즈니스 로직을 구현하는 준수자바클래스 BankImpl
단순한 예제지만, 코드가 상당히 복잡하다.
바이트 조작 라이브러리를 사용해도 만만치 않다.
프록시 사용의 단점은 코드양과 크기 때문에 깨끗한 코드를 작성하기 어렵다는 점이다.
또한, AOP를 위한 시스템 단위 실행 지점 선정이 불가능하다. (클래스 메서드 별로 구현)
순수 자바 AOP 프레임워크
프록시 코드는 대부분 규격화되어 있어 도구로 자동화할 수 있다.
순수 자바 관점을 구현하는 스프링 AOP는 내부적으로 프록시를 사용한다
순수 자바란 AspectJ를 사용하지 않는다는 뜻
POJO는 순수하게 도메인에 초점을 맞춘다.
프레임워크나 다른 도메인에도 의존하지 않는다. 따라서 테스트가 쉽고 간단하다.
이로 인해 코드를 보수하고 개선하기 편하다.
프로그래머는 설정 파일이나 API를 사용해 애플리케이션 기반 구조를 구현한다.
설정에는 횡단 관심사도 포함된다.
프레임워크는 사용자가 모르게 프록시나 바이트코드 라이브러리를 사용해 이를 구현한다.
이런 선언들이 객체를 생성하고 서로 연결하는 등 DI 컨테이너의 구체적인 동작을 제어한다.
선언은 코드레벨의 조작이 아닌 설정 파일로 조작하는 것을 의미한다.
@Transactional 과 같은 것이 대표적
선언적 프로그래밍은 어노테이션 방식, 설정파일방식(대게 xml, 자바 config방식)
각 빈은 중첩된 러시아 인형의 일부분과 같다.
Bank 도메인 객체는 자료 접근자 객체(DAO)로 프록시 되어있다.
DAO는 JDBC 드라이버 자료 소스로 프록시 되어있다.
클라이언트는 Bank 객체에 getAccounts()를 호출한다고 생각하지만,
실제로는 Bank POJO의 기본 동작을 확장한 중첩 데코레이터 가장 외곽과 통신한다.
원한다면, 트랜잭션, 캐싱과 같은 기능도 데코레이터로 추가할 수 있다.
애플리케이션에서 DI 컨테이너에게 객체를 요청하는 방법
스프링 관련 자바 코드가 거의 필요 없다. 따라서 사실상 애플리케이션은 스프링과 독립적이다.
프레임워크와 강하게 결합됐던 과거 EJB2 시스템 문제가 사라진다.
XML설정 방법은 읽기 어려웠지만, 겉으로 보이지 않는 자동 프록시 생성이나 관점 논리보다는 훨씬 단순하다.
현재는 Java5부터 추가된 애노테이션을 이용해 횡단 관심사를 선언적으로 분리하는 방식을 많이 사용한다.
원래 EJB2 코드보다 훨씬 깨끗하다.
일부 상세한 정보는 어노테이션 안에 존재하므로, 코드 자체는 깨끗하다.
애노테이션 정보는 필요하다면, XML 배치 기술자로 옮길 수 있다.
이렇게 되면 진짜 순수한 POJO만 남게 된다.
오늘날 개발에는 어노테이션을 적극적으로 쓴다.
AspectJ 관점
관심사를 관점으로 분리하는 가장 강력한 도구
AspectJ는 언어 차원에서 관점을 모듈화 구성으로 지원하는 자바 언어 확장이다.
스프링 AOP, Jboss AOP 같은 순수 자바 방식도 관점이 필요한 상황 대부분을 충족하므로 충분하다.
AspectJ는 강력하지만, 새 언어 문법과 사용법을 익혀야 한다.
최근에 AspectJ를 애노테이션으로 지원할 수 있게 되어 새로운 언어라는 부담을 완화시켰다.
테스트 주도 시스템 아키렉쳐 구축
관점으로 관심사를 분리하는 방식은 강력하다.
애플리케이션 도메인 논리를 여러 의존적인 것들을 제외한 순수한 POJO로 작성할 수 있다면, 진정한 테스트 주도 아키텍처 구축이 가능하다.
BDUF(Big Design Up Front)
구현 전에 앞으로 벌어질 모든 사항을 설계하는 기법
건축가는 BDUF 방식을 취한다. 물리적 구조물을 짓기 시작하면, 변경이 불가능하기 때문이다.
소프트웨어는 다르다. 소프트웨어 구조가 관점을 효과적으로 분리한다면, 극적인 변화가 경제적으로 가능하다.
주의할 것은 아무 방향 없이 프로젝트를 진행해도 된다는 소리가 아니다.
설계가 최대한 분리되어 각 추상화 수준과 범위에서 코드가 적당히 단순하면, 나중에 기반 구조를 조금씩 추가하며 확장하기 용이하다. (캐싱, 보안…)
초창기 EJB는 많은 기술에 초점을 둬 제대로된 관심사 분리를 못했다.
설계가 좋은 API도 필요하지 않으면 과유불급이다. 좋은 API는 걸리적거리면 안된다.
좋은 시스템은 개발자가 아키텍처에 발목을 잡히지 않고, 오롯히 고객의 요구사항과 비즈니스 로직에만 집중할 수 있다.
의사 결정을 최적화하라
큰 시스템 하나를 한 사람이 모든 결정을 내리긴 어렵다.
모듈을 나누고 관심사를 분리하면, 모듈별 관리와 결정이 가능해진다.
마지막 순간까지 결정을 미루는 것이 좋다.
성급한 결정은 불충분한 지식으로 내린 결정이다. 성급한 결정은 피드백을 모으고, 더 고민하는 등 더 탐구할 기회를 없앤다.
시스템은 도메인 특화 언어가 필요하다
소프트웨어 디자인 패턴은 건축 업계에서 많은 영감을 받아 만들어 졌다.
https://en.wikipedia.org/wiki/The_Timeless_Way_of_Building
건축 분야는 필수적인 정보를 명료하고 정확하게 전달하는 어휘, 관용구, 패턴이 풍부하다.
소프트웨어 분야에도 최근 DSL가 새롭게 떠오르고 있다.
DSL은 간단한 스크립트 언어나 표준 언어로 구현한 API이다.
좋은 DSL은 도메인 개념과 그 개념을 구현한 코드 사이 의사소통 간극을 줄여준다.
DSL은 수상화 수준을 코드 관용구나 디자인 패턴 이상으로 끌어올린다.
개발자가 적절한 추상화 수준에서 코드 의도를 표현할 수 있다.
결론
시스템도 깨끗해야 한다.
더러운 아키택처는 도메인 논리를 흐리고, 기민성을 떨어트린다.
도메인 논리가 흐려지면, 기민성, 구현, 버그 등 모든 면에서 악영향을 준다.
모든 추상화 단계에서 의도는 명확히 표현해야 한다.
그러기 위해서 POJO를 작성하고 관점 혹은 관점과 유사한 메커니즘을 사용해 각 구현 관심사를 분리해야 한다.
시스템을 걸계하든 개별 모듈을 설계하든, 실제로 돌아가는 가장 단순한 수단을 사용해야 한다.