011. (Clean Code) 11. 시스템 - Systems

11. 시스템 - Systems

복잡성은 죽음이다. 개발자에게서 생기를 앗아가며, 제품을 계획하고 제작하고 테스트하기 어렵게 만든다.
레이 오지(Ray Ozzie) - 마이크로소프트 CTO / CSA

깨끗한 코드를 구현하면 낮은 추상화 수준에서 관심사를 분리하기 쉬워진다.

본 포스팅에서는 높은 추상화 수준인 시스템 수준에서도 깨끗함을 유지하는 방법에 대해서 알아보자.

11.1. 시스템 제작과 시스템 사용을 분리하라

우선 제작(construction)사용(use) 는 아주 다르다는 전제를 깔고 시작해야 한다.

소프트웨어 시스템은 애플리케이션 객체를 제작하고 의존성을 서로 연결하는 준비 과정,

준비 과정 이후에 이어지는 런타임 로직을 분리할 수 있어야 한다.

제일 처음 풀어야할 것은 시작 단계의 관심사(concern) 이다.

관심사의 분리는 가장 오래되고 가장 중요한 설계 기법 중 하나이다.

불행하게도 대다수의 애플리케이션은 관심사를 분리하지 않기에, 준비 과정인 코드를 주먹구구식으로 구현하거나 런타임 로직과 마구 뒤섞어놓는다.

아래는 전형적인 예시이다.

1
2
3
4
5
6
public Service getService() {
if (service == null) {
service = new MyServiceImpl(...); // 모든 상황에 적합한 기본값일까?
}
return service;
}

이러나 기법을 지연 초기화(Lazy Initialization) 혹은 계산 지연(Lazy Evaluation) 이라고 부른다.

이 기법은 실제로 필요할 때까지 객체를 생성하지않으므로 불필요한 부하가 걸리는 것을 피할 수 있고,

애플리케이션의 시작 시간이 그만큼 빨라지며, Service 객체가 Null로 반환되는 경우도 존재하지않는다.

하지만 getService() 메서드는 MyServiceImpl과 생성자의 파라미터에 의존하고 있는 문제가 있다.

런타임 로직에서 MyServiceImpl 객체를 전혀 사용하지않더라도 이 의존성을 해소하지않으면 컴파일을 할 수 없다.

만약 MyServiceImpl 객체가 무거운 편이라면 단위 테스트에 많은 시간이 걸리므로 이를 대체할 수 있는 테스트 전용 객체도 제공해야 한다.

추가로 런타임 로직에 객체 생성을 섞어놓은 탓에 Service 객체가 null인 경우와 null이 아닌 경우를 모두 테스트해야 한다.

이 메서든믄 null인 경우의 책임과 null이 아닌 경우의 책임으로 쪼개지므로 단일 책임 원칙도 미세하게 위반한다.

위 예제의 주석처럼 MyServiceImpl 객체가 모든 상황에 적합한 객체인지 모르므로, 어떤 맥락에서 어떤 객체를 사용하면 될지 혼란을 야기한다.

한 번 정도의 지연 초기화 기법은 그다지 심각한 문제를 초래하지 않지만, 개수가 늘어나는 경우 모듈화 지수를 낮추며 심각한 중복 문제를 야기한다.

체계적이고 탄탄한 시스템을 만들기 위해서는 쉽게 느껴지는 기법을 통해 모듈성을 깨서는 안된다.

주요 의존성을 해소하기 위해 전반적으로 일관적인 방식의 적용도 필요하다.

Main 분리

시스템 생성과 시스템 사용을 분리하는 한 가지 방법으로, 생성과 관련된 코드를 모두 main이나 main이 호출하는 모듈로 옮기고

나머지 시스템은 모든 객체가 생성되었고, 모든 의존성이 연결되었다고 가정하는 방법이 있다.

아래 그림을 참고하자.

Separation of Main

main() 함수에서 시스템에 필요한 객체를 생성한 후 이를 애플리케이션이 넘긴다.

애플리케이션은 넘겨받은 객체를 “사용만” 한다.

그림을 자세히 보면 모든 화살표가 main에서 application쪽을 가리키고 있음을 볼 수 있따.

즉, application은 main에서 생성하는 과정을 전혀 알지 못한다는 뜻이다.

팩토리

때로는 객체가 생성되는 시점을 애플리케이션이 결정해야하는 경우도 있다.

이때는 추상 팩토리 패턴을 사용하는것이 좋다.

그렇다면 생성 시점은 애플리케이션이 결정하되, 생성하는 코드는 모르는 상태로 접근할 수 있다.

아래 그림을 참고하자.

Separation construction with factory

main의 분리와 마찬가지로 모든 의존성이 main에서 OderProcerssing 애플리케이션 쪽을 향하고 있다.

의존성 주입

사용과 제작을 분리하는 강력한 매커니즘으로 의존성 주입(DI : Dependency Injection) 가 있다.

의존성 주입은 제어의 역전(Inversion of Control) 기법을 의존성 관리에 적용한 기법으로,

한 객체가 맡은 보조 책임을 새로운 객체에데 모두 전가한다.

새로운 객체를 넘겨받은 책임만 관리하므로 단일 책임 원칙을 지키게 된다.

의존성 관리의 맥락에서 객체는 의존성 자체를 인스턴스로 만드는 책임은 지지않기에 제어를 역전한다고 볼 수 있다.

아래 예제를 보자.

1
MyService myService = (MyService)(jndiContext.lookup("NameOfMyService"));

JNDI 검색은 의존성 주입을 부분적으로 구현한 것으로, 디렉토리 서버에 이름을 제공하고 그 이름에 부합하는 서비스를 반환 받는다.

호출하는 객체는 반환되는 객체가 적절한 인터페이스를 구현한다는 전제하에 반환 객체의 유형을 제어하지 않는다.

진정한 의존성 주입은 여기서 한 걸음 더 나아간다.

클래스가 의존성 해결을 시도하지않고 수동적으로 움직이다.

다만 의존성을 주입하는 방법으로 setter나 생성자 파라미터를 받을 수 있도록 제공할 뿐이다.

실제로 생성되는 객체 유형은 설정 파일에엇 지정하거나 특수 생성 모듈에서 코드로 명시한다.

참고 스프링 프레임워크는 Bean 객체를 IoC 컨테이너를 통해 제어하며, xml에 이를 정의해둔다.

그렇다면 지연 초기화를 통해 얻을 수 있는 장점은 포기해야할까?

대다수의 DI 컨테이너는 필요할때 객체를 생성해주는 방식으로 지연 초기화의 장점을 같이 제공해준다.

11.2. 확장

처음부터 올바르게 시스템을 만드는 것은 불가능에 가깝다.

다만 오늘 주어진 사용자 흐름에 맞추어 시스템을 조정하고 확장하며, 내일은 내일 주어진 사용자 흐름에 맞추면 된다.

이렇게 반복적이고 점진적인 게 애자일 방식의 핵심이며, 테스트 주도 개발 및 리팩토링을 통해 깨끗한 코드를 유지하며 시스템을 조정하고 확장하기 쉽게 만들어야 한다.

코드 수준은 알겠는데, 시스템 수준에서는 어떨까?

단순한 아키텍쳐를 복잡한 아키텍쳐로 조금씩 키우는 것은 불가능하다.

다만 소프트웨어 시스템은 수명이 짧다는 본질로 인해, 아키텍쳐의 점진적인 발전이 가능하다.

먼저 관심사를 적절히 분리하지 못한 아키텍쳐의 예제를 보자.

엔터프라이즈 자바빈즈(EJB: Enterprise JavaBeans)을 통해 작성된 BankLocal 인터페이스이다.

참고 EJB1과 EJB2 아키텍쳐는 관심사를 적절히 분리하지 못해 생긴 장벽으로, 점진적으로 유기적인 성장이 불가능한 상태였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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의 주소, 은행이 소유하는 계좌 등을 표현하고 있으며, 계좌 정보는 Acount EJB로 처리한다.

아래는 위의 인터페이스를 구현한 Bank 클래스이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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);
}

// EJB 컨테이너 논리
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() {}
}

예제 내에서 객체를 생성하는 팩토리인 LocalHome 인터페이스 및 기타 Bank 클래스의 탐색 메서드는 생략되었다.

비즈니스 논리는 EJB2 애플리케이션 컨테이너에 강결합된다.

클래스를 생성할 때는 컨테이너에서 파생되어야 하며, 컨테이너가 요구하는 생명 주기 메서드도 필요하다.

이렇게 비즈니스 논리다 컨테이너와 강겹할되어있기에 독자적인 단위 테스트가 어려운 문제가 생긴다.

또한 EJB2에 의존적인 코드는 프레임워크 밖에서 사용하기가 사실상 불가능하다.

이는 결국 객체 지향 프로그래밍 개념 조차 흔들리는 원인이 된다.

횡단(cross-cutting) 관심사

EJB2 아키텍처는 일부 영역에 대한 관심사를 거의 완벽하게 분리한다.

예를 들어 트랜잭션, 보안, 일부 영속적인 동작은 소스 코드가 아닌 배치 기술자에서 정의된다.

영속성과 같은 관심사는 대개 애플리케이션의 객체 경계를 넘나드는 경향이 있다.

원론적으로는 모듈화되고 캡슐화된 방식으로 영속성 방식을 구상할 수는 있으나, 현실적으로는 영속성 방식을 구현한 코드가 온갖 객체로 흩어지게 된다.

이러한 현상을 횡단 관심사 라고 한다.

물론 영속성 프레임워크도 모듈화하거나 도메인 논리도 모듈화할 수는 있다.

문제는 이 두 영역이 세밀한 단위로 겹치다는 점이다.

EJB 아키텍처가 영속성, 보안, 트랜잭션을 처리하는 방식은 사실상 관점 지향 프로그래밍(AOP : Aspect-Oriented Programming) 을 예견한 셈이다.

관점 지향 프로그래밍은 횡단 관심사에 대해 모듈성을 확보하는 일반적인 방법으로, 특정 관심사를 지원하려면 시스템에서 특정 지점들이

동작하는 방식을 일관성 있게 바꾸어야 한다고 명시하는 것이다.

11.3. 자바 프록시

자바에서 사용하는 관점 혹은 관점과 유사한 매커니즘들을 알아보자.

자바 프록시는 단순한 상황에 적합한다.

개별 객체나 클래스에서 메서드 호출을 감싸는 경우가 좋은 예시이다.

하지만 JDK에서 제공하는 동적 프록시는 인터페이스만 지원하며, 클래스 프록시를 사용하려면 CGLIB, ASM, Javassist 등과 같은 바이트 코드 처리를 위한 라이브러리가 필요하다.

참고
CGLIB : Code Generator Library의 약자로 런타임에 동적으로 자바 클래스의 프록시를 생성해준다.
ASM : 자바 바이트 코드 조직 및 분석 프레임워크
Javassist : 동적으로 자바 클래스로 변경하는 바이트 코드 라이브러리

이해를 위해 계좌 목록을 가져오는 설정하는 메서드만 살펴보자.

먼저 프록시로 감쌀 Bank 인터페이스를 작성한다.

1
2
3
4
5
6
7
8
// Bank.java
import java.util.*;

// 은행 추상화
public interface Bank {
Collection<Account> getAccounts();
void setAccounts(CollectionM<Account> accounts);
}

이후 비즈니스 로직을 구현하는 POJO인 BankImpl을 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// BankImpl.java

// 추상화를 위한 POJO("Plain Old Java Object") 구현
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);
}
}
}

프록시를 구현하려면 InvocationHandler 를 구현해야 한다.

구현한 InvocationHandler는 프록시에 호출되는 Bank의 메서드를 구현하는데 사용한다.

BankProxyHandler는 리플렉션을 이용해 제네릭스 메서드를 이에 상응하는 BankImpl 메서드로 매핑한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// BankProxyHandler.java

// 프록시 API가 필요한 "InvocationHandler"
public class BankProxyHandler implements InvocationHandler {
private Bank bank;

public BankProxyHandler(Bank bank) {
this.bank = bank;
}

// InvocationHandler에 정의된 메서드
public Object invoke(Object proxy, Method method, Object[] args) throw 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) {
// ...
}
}
1
2
3
4
5
6
// 다른 곳에 위치하는 코드
Bank bank = (Bank) Proxy.newProxyInstance(
Bank.class.getClassLoader(),
new Class[] { Bank.class },
new BankProxyHandler(new BankImpl())
);

예제에서 알 수 있다시피, 자바 프록시는 코드의 양이 매우 커지는 것이 단점이다.

참고 동적 프록시가 아닌 일반 프록시는 대상 클래스 수만큼 프록시 클래스를 만들어야하며, 비슷한 코드의 중복이 발생하기 쉽다.

또한 프록시 기법은 시스템 단위로 실행 지점을 명시하는 매커니즘도 누락되어 있다.

11.4. 순수 자바 AOP 프레임워크

다행히 대부분의 프록시 코드는 그 형태가 매우 유사하기에 자동화되어있는 부분이 많다.

순수 자바 관점을 구현하는 스프링 AOP, JBoss AOP 등과 같은 여러 자바 프레임워크는 내부적으로 프록시를 사용하고 있다.

참고 순수 자바란 아래에서 다룰 AspectJ를 사용하지않는다는 뜻이다.

스프링은 비즈니스 논리를 POJO로 구현한다.

POJO는 순수하게 도메인에 초점을 맞추므로, 프레임워크나 다른 도메인에도 의존하지 않는다.

이로 인해 개념적인 측면에서 테스트가 쉽고 간단하다는 장점이 있다.

이는 곧, 사용자 스토리를 올바르게 구현하고, 미래에 주어질 스토리에 맞춰 유지보수 용이성을 확보하는 데 도움을 준다.

개발자는 설정 파일이나 API를 사용해 필수적인 애플리케이션 기반 구조를 구현한다.

여기에는 영속성, 트랜잭션, 보안, 캐시, 장애조치 등과 같은 횡단 관심사도 포함되며, 대부분 스프링이나 JBoss 라이브러리의 관점을 명시한다.

이때 프레임워크는 사용자 모르게 프록시나 바이트코드 라이브러를 사용해 이를 구현한다.

이러한 선언들이 요청에 따라 주요 객체를 생성하고 서로 연결하는 등 DI 컨테이너의 구체적인 동작을 제어한다.

아래 예제는 스프링 v2.5의 설정 파일인 app.xml의 일부로, 아주 전형적인 모습을 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<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 도메인 객체는 접근자 객체(DAO : Data Accessor Object) 로 프록시 되었고,

자료 접근자 객체는 JDBC 드라이브 자료 소스로 프록시되어 있다.

아래 그림을 참고하자.

The “Russian doll” of decorators

클라이언트는 Bank 객체에서 getAccounts()를 호출하는 것처럼 보이지만, 실제로는 Bank POJO의 기본 동작을 확장한 중첩 데코레이터 객체 집합의 가장 외곽부를 통해 통신한다.

애플리케이션에서 DI 컨테이너에게에 XML 파일에 명시된 시스템 내 최상위 객체를 요청하려면 아래와 같은 코드가 필요하다.

1
2
XmlBeanFactory bf = new XmlBeanFactory(new ClassPathResource("app.xml", getClass()));
Bank bank = (Bank) bf.getBean("bank");

스프링에 의존적인 코드가 거의 존재하지않으므로 사실상 스프링 프레임워크에 독립적이다.

즉, EJB2 시스템이 지녔던 강결합이 해소되는 것이다.

물론 XML 파일은 장황하고 읽기 어려운 포맷은 맞다.

하지만 자동으로 생성되는 프록시나 관점 논리보다는 단순하다.

이러한 아키텍처의 매력으로 인해 스프링 프레임워크의 EJB3는 완전히 뜯어고쳐져있다.

EJB3는 XML 설정 파일과 Java 5의 어노테이션 기능을 사용해 횡단 관심사를 선언적으로 지원하는 스프링 모델을 따른다.

아래 코드는 EJB3를 기반으로 Bank 클래스를 다시 작성해본 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Entity
@Table(name = "BANKS")
public class Bank implements java.io.Serializable {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private int id;

@Embeddable // Bank의 데이터베이스 행에 '인라인으로 포함된' 객체
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;
}
}

초기에 작성했던 EJB2 코드보다 훨씬 깔끔해졌다.

모든 정보가 어노테이션 내부로 갈무리되어 코드가 깔끔해진 것이고, 그만큼 코드를 테스트하거나 유지보수하기 쉬워졌다.

11.5. AspectJ 관점

관심사를 관점으로 분리하는 가장 강력한 도구는 AspectJ 언어이다.

AspectJ는 언어 차원에서 관점을 모듈화 구성으로 지원하는 자바 언어의 확장이다.

스프링 AOP와 JBoss AOP가 제공하는 순수 자바 방식은 관점이 필요한 상황의 80~90% 정도를 충족해준다.

AspectJ는 나머지 10%를 채워주는 좋은 도구이지만, 새로운 도구이니만큼 사용법과 언어 문법을 익혀야할 필요성은 있다.

AspectJ의 어노테이션 폼(annotation form)은 이 부담감을 경감시켜주긴 한다.

어노테이션 폼은 순수한 자바 코드에 자바 5 어노테이션을 사용해 관점을 정의하기에 AspectJ에 대해 미숙한 상태여도 쉽게 사용할 수 있도록 해준다.

11.6. 의사 결정을 최적화하라

모듈을 나누고 관심사를 분리하면 지엽적인 관리와 결정이 가능해진다.

거대한 시스템 안에서는 한 사람이 모든 결정을 내리기 어렵기 때문에 가장 적합한 사람에게 책임을 맡기는 것이 좋다.

성급한 결정은 불충분한 지식으로 내린 결정이기에, 때로는 마지막 순간까지 결정을 미루는 것도 최선의 방법이 되기도 한다.

11.7. 명백한 가치가 있을 때 표준을 현명하게 사용하라

EJB2는 단지 표준이라는 이유로 많은 팀이 채택하였다.

아주 가볍고 간단한 설계로도 충분한 프로젝트가 EJB2의 도입으로 오버엔지니어링을 겪게되는 경우가 빈번했다는 뜻이다.

따라서 아주 과장되게 포장된 표준인지에 대해서 고민해볼 필요가 있다.