- 시스템 수준에서도 코드를 깨끗하게 유지하기 위한 방법에 대해서 설명한다
시스템 제작과 사용을 분리하라.
- 제작(construction)과 사용(use)는 다르다
- 소프트웨어 시스템은 (애플리케이션 객체를 제작하고 의존성을 서로 '연결'하는) 준비 과정과 (준비 과정 이후에 이어지는) 런타임 로직을 분리해야 한다
public Service getService() {
if(service == null)
service = new MyServiceImpl(...);
return service;
}
- 위 코드는 초기화 지연(Lazy Initialization) 혹은 계산 지연(Lazy Evaluation)이라는 기법이다
- 실제로 getService를 호출하기 전까지는 service가 생성되지 않는다
장점
- 필요할 때까지 객체를 생성하지 않아 과부하를 막는다
- 애플리케이션 시작이 그만큼 빨라진다
- Null을 반환하지 않는다
단점
- MyServiceImpl과 생성자 인수에 명시적으로 의존한다
- 실제로 MyServiceImpl객체를 사용하지 않더라도 의존성 해결이 안되면 컴파일이 안 된다
- 테스트시 테스트 전용객체(Mock Object)를 할당해야 한다
- 생성과 사용 로직이 섞여있어서 모든 실행 경로도 테스트 해야 한다
- 책임이 여러개라는 말은 SRP(단일 책임 원칙)을 깬다
- MyServiceImpl이 모든 상황에 적합한 객체인지 알 수 없다
결론
- 결국 위와 같은 기법은 가볍게 한 번 정도 사용할때는 상관없지만 사용빈도가 높아질수록 문제가 많아진다
- 시스템의 생성과 사용 로직을 분리해야 한다
Main 분리
- 생성과 관련한 코드는 모두 main이나 main이 호출하는 모듈로 옮기고 나머지 시스템은 모든 객체가 생성 되었고 의존성이 연결되었다고 가정하는 방법
- 그림의 화살표 방향을 보면 모든 화살표가 main에서 애플리케이션을 가리킨다
- 애플리케이션은 main이나 객체가 생성되는 과정을 모르고 생성 되었을 거라고 가정한다
팩토리 Abstract Factory Pattern
- 추상 팩토리 패턴을 사용해 애플리케이션이 아이템의 생성 시점을 결정할 수는 있지만 어떻게 생성하는지에 대해서는 몰라도 된다
의존성 주입(DI: Dependency Injection)
- 제어 역전(Inversion of Control IoC)기법을 의존성 관리에 적용한 메커니즘
- 제어 역전: 한 객체가 맡은 보조 책임을 새로운 객체에 전적으로 떠넘긴다
- 새로운 객체는 떠맡은 책임만 담당하기에 SRP를 지원한다
- 의존성 자체를 인스턴스로 만들 책임은 지지 않고 이런 책임을 다른 '전담' 메커니즘에 전달한다
- 초기 설정은 시스템 전체에서 필요하기에 대게 '책임질' 메커니즘으로 'main'이나 특수 컨테이너를 사용
- JNDI 검색은 의존성 주입을 '부분적으로' 구현한 기능이다
MyService myService = (MyService)(jndiContext.lookup("NameOfMyService"));
- 호출하는 객체는 실제로 반환되는 객체의 유형을 제어하지 않는다
- 대신 의존성을 능동적으로 해결한다
- 더 나은 방법은 클래스가 의존성을 해결하지 않고 의존성을 주입하는 방법으로 설정자(setter)메소드나 생성자 인수를(혹은 둘 다) 제공한다
- 필요한 객체의 인스턴스를 만든 후 생성자 인수나 설정자 메서드를 사용해 의존성을 설정한다
- 실제로 생성되는 객체 유형은 설정 파일& 특수 생성 모듈에서 명시한다
3. 확장
- 처음부터 너무 큰 확장성을 고려해서 설계 할 필요는 없다
- 작은 마을을 설계할 때 발전할 것을 고려해 6차선을 뚫거나 영화관을 짓는 것은 오버다
- 오늘 주어진 스토리에 맞춰 시스템을 구현하라
- 새로운 스토리는 새로운 스토리가 나올 때 맞춰 조정하고 확장하면 된다
- 깨끗한 코드는 코드 수준에서 시스템을 조정하고 확장하기 쉽게 만든다
관심사를 적절히 분리하지 못한 아키텍처 예제
- EjB2아키텍처
- Bank EJB용 EJB2 Inteface
package com.example.banking;
import java.util.Collections;
import javax.ejb.*;
public interface BankLocal extends java.ejb.EJBLocalObject {
String getStreetAddr1() throws EJBException;
String getStreetAddr2() throws EJBException;
String getCity() throws EJBException;
String getState() throws EJBException;
String getZipCode() throws EJBException;
void setStreetAddr1(String street1) throws EJBException;
void setStreetAddr2(String street2) throws EJBException;
void setCity(String city) throws EJBException;
void setState(String state) throws EJBException;
void setZipCode(String zip) throws EJBException;
Collection getAccounts() throws EJBException;
void setAccounts(Collection accounts) throws EJBException;
void addAccount(AccountDTO accountDTO) throws EJBException;
}
- Bank 주소, 은행이 소유하는 계좌가 열거되어 있다.
package com.example.banking;
import java.util.Collections;
import javax.ejb.*;
public abstract class Bank implements javax.ejb.EntityBean {
public abstract String getStreetAddr1();
public abstract String getStreetAddr2();
public abstract String getCity();
public abstract String getState();
public abstract String getZipCode();
public abstract void setStreetAddr1(String street1);
public abstract void setStreetAddr2(String street2);
public abstract void setCity(String city);
public abstract void setState(String state);
public abstract void setZipCode(String zip);
public abstract Collection getAccounts();
public abstract void setAccounts(Collection accounts);
public void addAccount(AccountDTO accountDTO) {
InitialContext context = new InitialContext();
AccountHomeLocal accountHome = context.lookup("AccountHomeLocal");
AccountLocal account = accountHome.create(accountDTO);
Collection accounts = getAccounts();
accounts.add(account);
}
public abstract void setId(Integer id);
public abstract Integer getId();
public Integer ejbCreate(Integer id) { ... }
public void ejbPostCreate(Integer id) { ... }
public void setEntityContext(EntityContext ctx) {}
public void unsetEntityContext() {}
public void ejbActivate() {}
public void ejbPassivate() {}
public void ejbLoad() {}
public void ejbStore() {}
public void ejbRemove() {}
}
- 비즈니스 논리는 컨테이너와
강결합
이다. 클래스를 생성할 때 컨테이너에서 파생해야 하며 컨테이너가 요구하는 생명주기 메서드도 제공해야 한다
- 비즈니스 논리의 덩치가 매우 큰 컨테이너와 강결합 된 상태이기에 독자적인 단위테스트가 힘들다
- 프레임워크 밖에서 재사용하기가 거의 불가능하다
- 상속조차 불가능하다
횡단 관심사(cross-cutting)
- EjB2는 이렇게 관심사가 잘 분리되지 않았지만 일부 영역에서는 또 제대로 분리된 부분이 있다
- 원하는 트랜잭션, 보안, 일부 영속적인 동작은 소스 코드가 아닌 배치 기술자에서 정의한다
- 관심사가 여러 객체에 흩어져있는 기능, 관심들을 횡단 관심사(cross-cutting-concerns)라 한다
- 이러한 횡단관심사를 관점 지향 프로그래밍AOP(Aspect-Oriented-Programming) 방법론을 이용해 모듈성을 확보한다
- AOP에서 관점(aspect) 라는 모듈 구성 개념은 "특정 관심사를 지원하려면 시스템에서 특정 지점들이 동작하는 방식을 일관성 있게 바꿔야 한다" 라고 명시한다
- Ex: 프로그래머는 영속적으로 저장할 객체와 속성을 선언 후 영속성 책임을 영속성 프레임워크에 위임한다 그러면 AOP 프레임워크는 대상 코드에 영향을 미치지 않는 상태로 동작 방식을 변경한다
4. 자바에서 사용하는 관점(혹은 유사한) 메커니즘 세 가지
자바 프록시
- 단순한 상황에 적합하다
- 개별 객체나 클래스에서 메서드 호출을 감싸는 경우
- JDK 에서 제공하는 동적 프록시는 인터페이스만 지원한다
- Bank에서 계좌 목록을 조회/설정하는 예제
import java.utils.*;
public interface Bank {
Collection<Account> getAccounts();
void setAccounts(Collection<Account> accounts);
}
import java.utils.*;
public class BankImpl implements Bank {
private List<Account> accounts;
public Collection<Account> getAccounts() {
return accounts;
}
public void setAccounts(Collection<Account> accounts) {
this.accounts = new ArrayList<Account>();
for (Account account: accounts) {
this.accounts.add(account);
}
}
}
import java.lang.reflect.*;
import java.util.*;
public class BankProxyHandler implements InvocationHandler {
private Bank bank;
public BankHandler (Bank bank) {
this.bank = bank;
}
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 {
...
}
}
protected Collection<Account> getAccountsFromDatabase() { ... }
protected void setAccountsToDatabase(Collection<Account> accounts) { ... }
}
Bank bank = (Bank) Proxy.newProxyInstance(
Bank.class.getClassLoader(),
new Class[] { Bank.class },
new BankProxyHandler(new BankImpl())
);
- Bank 프록시객체에서는 InvocationHandler를 구현하여 invoke를 오버라이딩한다
- 프록시에 호출되는 Bank 메소드를 구현하는데 사용하며 Reflection API를 사용해 제네릭스 메소드를 상응하는 BankImpl 메서드로 매핑한다
- 프록시 객체에서는 인터페이스를 통해 실 구현체로부터 모델과 로직을 구분했다
- 하지만, 단순한 예제에서도 코드가 많고 복잡하다. 즉, 프록시를 사용하면 깨끗한 코드 작성이 어렵다
- 시스템 단위로 실행'지점'을 명시하는 메커니즘도 제공하지 않는다
순수 자바 AOP 프레임워크
- 대부분의 프록시 코드는 비슷하기에 자동화가 가능하며 그러한 도구가 스프링 AOP, jBoss AOP등의 프레임워크가 있다
- 스프링은 비즈니스 논리를 순수하게 도메인에 초점을 맞춘 PJOJ로 구현하는데 POJO는 엔터프라이즈 프레임워크나 다른 도메인에 의존하지 않기에 테스트가 더 쉽고 간단하다 그렇기에 자기 도메인에 집중할 수 있게 해준다
- 설정파일이나 API를 사용해 필수적인 애플리케이션 기반 구조를 구현한다
- 스프링의 설정파일
<beans>
...
<bean id="appDataSource"
class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close"
p:driverClassName="com.mysql.jdbc.Driver"
p:url="jdbc:mysql://localhost:3306/mydb"
p:username="me"/>
<bean id="bankDataAccessObject"
class="com.example.banking.persistence.BankDataAccessObject"
p:dataSource-ref="appDataSource"/>
<bean id="bank"
class="com.example.banking.model.Bank"
p:dataAccessObject-ref="bankDataAccessObject"/>
...
</beans>
- 위 설정파일을 도식화 한게 위 그림이다
- 각각의 빈들은 상자속의 상자 마치 러시아 인형처럼
Bank
도메인 객체는 자료 접근 객체(Data Accessor Object, DAO
)로 프록시 되었으며 이 객체도 JDBC 자료 소스로 프록시 되어있다
- 사용자(client)는
Bank
의 메소드를 호출한다고 생각하지만 실제로는 Bank POJO
의 기본 동작을 확장한 중첩 DECORATOR
객체 집합의 가장 외곽과 통신한다
- 여기서 필요하다면 트랜잭션이나 캐싱 등에도
DECORATOR
를 추가할 수도 있다
- 위와 같이 생성된 최상위 Bank 프록시 객체를 생성하는 방법은 아래와 같으며 사실상 애플리케이션은 스프링과 독립적이라고 볼 수 있다 기존의 복잡하던 프록시 객체 생성방법을 사용하지 않아도 된다
XmlBeanFactory bf = new XmlBeanFactory(new ClassPathResource("app.xml", getClass()));
Bank bank = (Bank) bf.getBean("bank");
- XML은 장황하고 읽기 어렵고, 설정 파일에 명시된
정책
이 겉으로 노출되지 않지만 자동으로 생성되는 프록시나 관점 논리보다 단순하다. 그래서 스프링 프레임워크에서는 EjB 버전 3을 완전히 뜯어 고쳐서 XML설정 파일과 자바 5 애너테이션 기능을 사용해 횡단 관심사를 선언적으로 지원하는 모델을 따른다
- EjB3 Bank EJB 코드
package com.example.banking.model;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
@Entity
@Table(name = "BANKS")
public class Bank implements java.io.Serializable {
@Id @GeneratedValue(strategy=GenerationType.AUTO)
private int id;
@Embeddable
public class Address {
protected String streetAddr1;
protected String streetAddr2;
protected String city;
protected String state;
protected String zipCode;
}
@Embedded
private Address address;
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, mappedBy="bank")
private Collection<Account> accounts = new ArrayList<Account>();
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public void addAccount(Account account) {
account.setBank(this);
accounts.add(account);
}
public Collection<Account> getAccounts() {
return accounts;
}
public void setAccounts(Collection<Account> accounts) {
this.accounts = accounts;
}
}
- 훨씬 깨끗해졌다. 엔티티의 상세한 정보는 애너테이션 안에 기술되어 있기 때문에 코드는 깔끔하다
- 여기서 애너테이션에 있는 영속성 정보를 XML 배치 기술자로 옮기면 POJO만 남는다
AspectJ 관점
- 관심사를 관점으로 분리하는 가장 강력한 도구
- 언어 차원에서 관점을 모듈화 구성으로 지원하는 자바 언어 확장이다
- 새 도구를 사용하고 새 언어 문법과 사용법을 익혀야 한다는 단점이 있다
- AjpectJ 애노테이션 폼이 부담을 어느정도 완화 해 주기는 한다
5. 테스트 주도 시스템 아키텍처 구축
- 관점 혹은 유사 개념으로 관심사를 분리하는 방식은 매우 강력하여 애플리케이션 논리를
POJO
로 작성할 수 있다면 즉 코드 수준에서 아키텍처 관심사 분리가 가능하다면 진정한 테스트 주도 아키텍처 구축
이 가능하다
- 즉 처음부터 크게 디자인하는 BDUF(Big Design Up Front)를 추구하지 않아도 된다
- 작지만 멋지게 분리된 아키텍처를 진행해 빠른 결과를 낸 후 기반 구조를 추가하여 확장을 할 수 있다는 의미이다
- 최선의 시스템 구조는 각기 POJO(또는 다른) 객체로 구현되는 모듈화 된 관심사 영역(도메인)으로 구성된다
- 이렇게 서로 다른 영역은 해당 영역 코드에 최소한의 영향을 미치는 관점이나 유사한 도구를 사용해 통합한다
- 이런 구조 역시 코드와 마찬가지로 테스트 주도 기법을 적용할 수 있다
6. 의사 결정을 최적화하라
- 책임은 가장 적합한 사람에게 맡기면 가장 좋다
- 최대한 정보를 모아 최선의 결정을 하기 위해 가장한 마지막 순간까지 결정을 미루는게 좋다
- 성급한 결정은 충분치 않은 지식과 자료로 내린 결정이다
7. 명백한 가치가 있을 때 현명하게 사용하라
- 표준이라는 의미로 충분히 필요가 없을때도 불필요하게 사용하는 것은 좋지 않다
- EjB2는 표준이라는 이유로 더 가볍고 간단한 설계만으로 충분한 프로젝트에서도 사용해서 무거워지는 경우가 있었다
- 표준을 사용하면 재사용성과 지식이 있는 기술자 구인이 쉽지만 표준이 너무 방대할 경우 표준이 나오는 시기가 너무 오래 걸려 업계가 기다리지 못해 무용지물이 될 수 있다
8. 시스템은 도메인 특화 언어가 필요하다
- 최근 DSL(Domain Specific Language)이 조명받기 시작했다
- DSL은 간단한 스크립트 언어나 표준 언어로 구현한 API를 말한다
- 좋은 DSL은 도메인 개념과 그 개념을 구현한 코드 사이에 존재하는
의사소통 간극
을 줄여준다.
- 애자일 기법이 팀과 프로젝트 이해관계자 사이에 의사소통 간극을 줄여주는 것처럼
- 도메인 특화 언어를 사용하면 고차원 정책에서 저차원 세부사항에 이르기까지 모든 추상화 수준과 모든 도메인을 POJO로 표현할 수 있다
9. 결론
- 코드, 클래스와 마찬가지로 시스템도 깨끗해야 한다
- 아키텍처가 깨끗하지 못하면 도메인 논리를 흐리며 기민성을 떨어트린다
- 도메인 논리가 흐려지면 제품의 품질이 저하되며 버그가 생길 위험이 높아진다
- 모든 추상화 단계에서 의도는 명확히 표현해야 하는데 이를 위해
POJO
를 작성해 관점 혹은 유사한 메커니즘을 사용해 구현 관심사를 분리해야 한다
- 결과적으로 시스템이던 클래스던 컴팩트하게 작성하여 사용해야 한다
개인적인 감상
- 시스템은 도메인 특화 언어가 필요하다는 것이 잘 와닿지 않았다
- 이론적으로는 이해가 되지만 실제로 깨끗한 시스템을 본 적이 없어서 더 와닿지 않는것이지 않을까 싶었다
- 결국 간결하고 가독성이 좋은 코드로 이루어진 가벼운 시스템을 만드는 것이 중요하다는 결론에 도달할 수는 있었다
도메인 특화 언어