Clean Code 11. System


시스템 제작(construction)과 사용(use)을 분리하라

소프트웨어 시스템은 (애플리케이션 객체를 제작하고 의종성을 서로 연결하는) 준비 과정과 (준비 과정 이후에 이어지는) 런타임 로직을 분리해야 한다.

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

public Service getService() {
	if (service == null)
		service = new MyServiceImpl(...); // Good enough default for most cases?
	return service;
}

Lazy Initialization/Evaluation 코드이다. 실제로 필요할때까지 객체를 생성하지 않으므로 시작 시간이 빨라지고, 어떤 경우에도 null을 반환하지 않는다. 하지만 getService 메서드가 MyserviceImpl과 그 생성자 인수에 명시적으로 의존한다. 만약 MyServiceImpl이 무거운 객체라면 테스트 시 getService를 호출할 때마다 부하가 걸린다. 그걸 위해서 테스트를 위한 Test Double / Mock Object를 service에 대입해야 하는데, 그러면 기존 run-time 로직이다 보니 해당 객체가 null인 경로와 아닌 경로를 모두 테스트 해야 한다. 즉, 작게나마 단일 책임 원칙 (SRP : Single Responsibility Principle)을 위배한다.

설정과 실행은 분리해야 모듈성이 높아진다. 또한 주요 의존성을 해소하기 위한 방식, 즉 전반적이며 일관적인 방식도 필요하다.

Main 분리

가장 간단한 방법이다. 생성과 관련된 코드를 main이나 main이 호출하는 모듈로 옮기고, 나머지 시스템은 모든 객체가 생성되었고 모든 의존성이 연결되었다고 가정한다. 즉, 애플리케이션은 객체가 생성되는 과정을 전혀 모른다는 뜻이다.

Factory

객체 생성 시점을 애플리케이션이 결정할 필요가 있을때는 Abstract Factory 패턴을 사용하여 생성 코드를 감춘다.

의존성 주입(Dependency Injection)

의존성 주입제어 역전 (IoC : Inversion of Control) 기법을 의존성 관리에 적용한 메커니즘이다. 의존성 생성에 대한 책임을 다른 객체에게 맡기므로 SRP를 지키게 된다.

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

확장(Scaling)

작은 시골마을이 도시로 성장한 뒤 좁은 도로 때문에 지옥을 경험하는 경우가 많다. 하지만, 성장할지 모르는 작은 마을을 지을때 처음부터 확장을 고려하여 6차선 도로를 지으려 할까? 그 비용을 마을에서 반길까?

처음부터 올바르게 시스템을 만들 수 있다는 믿음은 미신이다. 오늘은 오늘 필요한 것을 만들고, 내일은 거기에 맞춰 확장하면 된다. 이것이 반복적이로 점진적인 애자일 방식의 핵심이다. TDD와 Refactoring으로 얻어지는 Clean Code는 확장하기 쉽게 만든다. 코드 수준에서는 이게 말이 되지만 시스템 수준에서는 어떤가? 단순한 아키텍처를 복잡한 아키텍처로 조금씩 키울 수는 없는가?

소프트웨어 시스템은 물리적인 시스템과는 다르다. 관심사를 적절히 분리해 관리한다면 소프트웨어 아키텍처는 점진적으로 발전할 수 있다.

관심사(Concern)를 적절히 분리하지 못했다면 아키텍처의 점진적 성장이 어렵다.

An EJB2 local interface for a Bank EJB
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;
}
The corresponding EJB2 Entity Bean Implementation
package com.example.banking;
import java.util.Collections;
import javax.ejb.*;

public abstract class Bank implements javax.ejb.EntityBean {
    // Business logic...
    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 container logic
    public abstract void setId(Integer id);
    public abstract Integer getId();
    public Integer ejbCreate(Integer id) { ... }
    public void ejbPostCreate(Integer id) { ... }
    
    // The rest had to be implemented but were usually empty:
    public void setEntityContext(EntityContext ctx) {}
    public void unsetEntityContext() {}
    public void ejbActivate() {}
    public void ejbPassivate() {}
    public void ejbLoad() {}
    public void ejbStore() {}
    public void ejbRemove() {}
}

위와 같응 전형적인 EJB2 객체 구조에는 문제가 있다.

  1. 비지니스 로직이 EJB2 컨테이너와 강하게 결합되어 있어, 클래스 생성시 컨테이너에서 파생해야 하며 컨테이너의 다양한 lifecycle 메서드를 제공해야 한다.
  2. 비지니스 로직이 덩치 큰 컨테이너와 밀접하게 결합되어 독자적인 단위 테스트가 어렵다.
  3. OOP가 무너진다. 빈은 다른 빈을 상속 받지 못한다. 일반적으로 EJB2 빈은 DTO(Data Transfer Object)를 정의한다. DTO는 메서드가 없는 사실상의 구조체이므로 동일한 정보를 저장하는 자료 유형이 두 개가 된다. 그래서 객체간의 자료를 복사하는 반복적인 코드가 필요하다.

횡단(cross-cutting) 관심사

Transaction, Authorization, Logging 등의 코드를 어떻게 하는게 좋을까 ? 논리적으로는 독립된 형태로 구분이 가능하나, 코드 여기저기에 위치하기 쉬운 코드들을 어떻게 할 것인가 ? OOP로도 하기에는 코드 여기저에게 산제하기 쉽다. 그것보다는 AOP (Aspect-Oriented Programming) 로 접근하는 것이 좋다.

Java에서 사용하는 관점(Aspect) 혹은 유사한 메커니즘 3가지를 살펴보자.

1. Java Proxy

단순한 상황에 적합하다. 개별 객체나 클래스에서 메서드 호출을 감싸면 된다.

JDK proxy example
// Bank.java (suppressing package names...)
import java.utils.*;

// The abstraction of a bank.
public interface Bank {
    Collection<Account> getAccounts();
    void setAccounts(Collection<Account> accounts);
}

// BankImpl.java
import java.utils.*;

// The “Plain Old Java Object” (POJO) implementing the abstraction.
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);
        }
    }
}
// BankProxyHandler.java
import java.lang.reflect.*;
import java.util.*;

// “InvocationHandler” required by the proxy API.
public class BankProxyHandler implements InvocationHandler {
    private Bank bank;
    
    public BankHandler (Bank bank) {
        this.bank = bank;
    }
    
    // Method defined in InvocationHandler
    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 {
            ...
        }
    }
    
    // Lots of details here:
    protected Collection<Account> getAccountsFromDatabase() { ... }
    protected void setAccountsToDatabase(Collection<Account> accounts) { ... }
}

// Somewhere else...
Bank bank = (Bank) Proxy.newProxyInstance(
    Bank.class.getClassLoader(),
    new Class[] { Bank.class },
    new BankProxyHandler(new BankImpl())
);

위 코드에서 BankImpl은 데이터만 들어가고 비지니스 로직은 포함하지 않는다. BankProxyHandler에서 BamkImpl의 메서드들을 매핑한다. 이렇게 모델과 로직의 코드 분리가 가능하다. 하지만 간단한 작업임에도 코드가 복잡하다.

2. 순수 자바 AOP Framework

AOP Framework (Spring AOP, JBoss AOP)를 통해서 위의 단점들을 해결할 수 있다. Spring에서는 비지니스 로직을 POJO로 작성해 해당 도메인에만 초점을 맞추면 된다. 따라서 테스트가 개념적으로 더 쉽고 간단하다.

Spring 2.X configuration file
<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>
XmlBeanFactory bf = new XmlBeanFactory(new ClassPathResource("app.xml", getClass()));
Bank bank = (Bank) bf.getBean("bank");

클라이언트에서 Bank의 getAccount()를 호출한다고 믿지만 실제로는 DECORATOR 객체 집합의 가장 외곽과 통신한다. xml이라 읽기 힘들 수는 있지만, Java Proxy보다는 간결하다.

EBJ3을 이용하여 다시 작성한 코드이다.

EBJ3 Bank
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 // An object “inlined” in Bank’s DB row
    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;
    }
}

Annotation을 이용하여 코두룰 깔끔하고 깨끗하게 개선했다.

3. AspectJ 관점

AspectJ 애너테이션 폼은 새로운 도구/언어라는 부단을 어느 정도 완화해준다.

Test Drive the System Architecture

관점으로 관심사를 분리하는 방식의 위력은 막강하다.테스트 주도 아키텍처 구축이 가능해진다. 건축에서는 BDUP(Big Desion Up Front) 방식을 취한다. 일단 짓기 시작하면 변경이 불가능하다. 아주 단순하면서도 멋지게 분리된 아키텍처로 소프트웨어 프로젝트를 진행하면 단순한 아키텟처를 복잡한 아키텍처로 키워갈 수 있다.

의사 결정을 최적화하라

아주 큰 시스템에서는 한 사람이 모든 결정을 내리기 어협다. 가장 적합한 사람에게 마지막 순간까지 결정을 미루는 방법이 최선이다. 최대한 정보를 모아 최선의 결정을 내리는 것이 좋다. 성급한 결정은 불충분한 지식으로 내린 결정이다.

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

표준이라는 이유만으로 사용하지 말자. 가볍고 간단한 설계가 가능할 때는 그걸 사용하자.

시스템은 DSL(Domain Specific Language)이 필요하다.

건축 분야에서도 필수적인 정보를 명료하고 정확하게 전달하는 어휘, 관용구, 패턴이 풍부하다.DSL은 스크립트 언어나 표준 언어로 구현한 API를 뜻한다. 좋은 DSL은 도메인 개념과 그 개념을 구현한 코드 사이에 존재하는 의사소통 간극을 줄여준다.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)