Programming/Clean Code

Clean Code - 깨끗한 시스템이란 무엇인가?! (+ 관심사 분리)

JaeHoney 2022. 5. 14. 21:41

깨끗한 시스템

시스템을 도시에 비유해보자. 도시의 온갖 세세한 사항을 혼자서 관리할 수 있을까? 그럴 순 없다. 그럼에도 도시는 잘 돌아간다. 수도관리 팀, 전력관리 팀, 교통관리 팀, 치안관리 팀 등 각 분야를 관리하는 팀이 있기 때문이다. 누군가는 각 팀의 역할을 자세히는 몰라도 대략적으로 파악하고 전체 그림을 설계하기도 한다. 누군가는 자신의 팀의 역할과 관련 팀과의 이해 관계 정도 까지만 집중한다.

 

이러한 부분이 프로그래밍에서는 적절한 추상화와 모듈화이다. 팀이 제작하는 시스템도 객체들을 적절하게 관심사를 분리해서 추상화를 이뤄내게 된다.

 

시스템 수준에서도 깨끗함을 유지하는 방법에 대해서 알아보자.

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

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

 

시작 단계는 모든 애플리케이션이 풀어야 할 관심사이다.

 

관심사 분리는 우리 분야에서 가장 오래되고 가장 중요한 설계 기법 중 하나다. 하지만 불행히도 대다수 애플리케이션은 시작 단계라는 관심사를 분리하지 않는다. 아래의 예를 살펴보자.

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

 

이것이 초기화 지연(Lazy Initialization) 혹은 계산 지연(Lazy Evalution)이라는 기법이다. 이 방법은 아래의 장점을 가진다.

  • 실제로 필요할 때까지 객체(service)를 생성하지 않으므로 불필요한 자원 낭비가 발생하지 않는다.
  • 어떠한 경우에도 NPE(Null Pointer Exception)이 발생하지 않는다.
  • 애플리케이션이 빨리 실행된다.

하지만 위 방법은 문제가 하나 있다. getService() 메서드가 MyServiceImpl 클래스 및 생성자 인수에 의존하게 된다. MyServiceImpl을 사용하지 않더라도 의존성을 해결하지 않으면 컴파일이 안된다. 어떻게 보면 당연한 소리 아닌가?!! 라고 들릴 지도 모르겠다.

 

하지만 위의 방법은 단점이 있다. 테스트를 생각해보자. MyServiceImple이 무거운 객체라면 단위 테스트에서 getService 메서드를 호출하기 전에 적절한 테스트용 객체를 service 필드에 할당해야 한다. 또한 getService 메서드를 호출할 때 service가 null인 경우와 그렇지 않은 경우를 모두 테스트해야 한다. 즉, 작게보면 SRP(단일 책임 원칙)을 위반하고 있다.

 

이러한 초기화 지연 방식을 애플리케이션 코드 곳곳에서 사용하게 된다면 모듈성은 저조하고 중복이 심각한 것이다. 이러한 설정 논리는 일반 실행 논리와 분리해야 모듈성이 높아진다.

 

Main 분리

시스템 생성과 시스템 사용을 분리하는 한 가지 방법으로, 생성과 관련한 코드는 모두 main이나 main이 호출하는 모듈로 옮기고 나머지 시스템은 모든 객체가 생성되었고 모든 의존성이 연결되었다고 가정하는 방법이다. (여기서 스프링과 빈팩토리가 바로 떠올랐다.)

main과 애플리케이션 사이에 표시된 의존성 화살표의 방향에 주목하자. 모든 화살표가 main 쪽에서 애플리케이션 쪽을 향한다. 애플리케이션은 main이나 객체가 생성되는 과정을 알 필요가 없다. 단지 모든 객체가 적절히 생성되었다고 가정한다.

팩토리

객체가 생성되는 시점을 애플리케이션이 결정할 필요도 생긴다. 이 때는 추상 팩토리 패턴(Abstract Factory Pattern)을 사용한다. 아래는 주문처리 시스템에서 애플리케이션이 LineItem 인스턴스를 생성하는 예이다.



그러면 LineItem을 생성하는 시점은 애플리케이션이 결정하지만 LineItem을 생성하는 코드는 애플리케이션이 몰라도 된다. 즉 주문 처리(OrderProcessing) 애플리케이션은 단지 LineItemFactory에서 만든 객체를 사용하면 된다. 해당 객체를 생성하는 과정은 LineItemFactory 구현체만 알고 있으면 된다.

의존성 주입

사용과 제작을 분리하는 가장 강력한 메커니즘 중 하나가 의존성 주입(Dependency Injection, DI)이다. 의존성 주입은 제어 역전(Inversion of Control, IOC) 기법을 의존성 관리에 적용한 메커니즘이다.

 

제어 역전에서는 한 객체가 맡은 보조 책임을 새로운 객체에게 전적으로 떠넘긴다. 새로운 객체는 넘겨받은 책임만 수행하면 되므로 SRP(단일 책임 원칙)을 지키게 된다. 즉, 의존성 주입에서는 객체에게 의존성 자체를 인스턴스로 만드는 책임을 주지 않는다. 이러한 책임을 main 루틴이나 특수 컨테이너와 같은 다른 '전담 매커니즘'에 넘겨서 제어를 역전한다.

 

예로 스프링 프레임워크는 DI 컨테이너를 통해 의존성을 XML 파일에 직접 정의해서 관리한다. 그 결과 클래스가 의존성을 해결할 필요가 없어진다. 추가로 위에서 초기화 지연이 얻는 장점들도 가져갈 수 있다.

확장

관심사를 적절하게 분리하지 못하면 변경이 어렵다. 어떤 프로젝트는 10년이 지나도 지속적인 노력으로 잘 유지되기도 하고, 어떤 프로젝트는 1년만에 레거시가 되어버린다.

 

주변에 탑 기업 개발자분들과 대화를 나눠보면 설계가 가장 중요하다고 말한다. 가령 코드나 변수는 리팩토링을 하면 되지만, 시스템 설계는 수정이 어렵다. 그런 이유에서 프로젝트를 시작할 때  UML 다이어그램을 최근에 많이 사용한다.

 

EJ1B와 EJB2 아키텍처는 초기 관심사를 적절히 분리하지 못했기에 유기적인 성장이 어려웠다. 관심사를 적절히 분리하지 못한 케이스를 살펴보자. 다음은 EJB2로 Bank 빈(bean)에 대한 구현 클래스이다.

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() {}
 }

Bank 클래스를 보면 비즈니스 로직이 EJB 컨테이너와 강하게 결합한다. 클래스를 생성할 때는 컨테이너에서 파생해야 하며 컨테이너가 요구하는 다양한 생명주기 메서드도 제공해야 한다.

 

비즈니스 로직이 컨테이너와 밀접하게 결합한 탓에 단위 테스트는 더 어려워졌다. 컨테이너를 Mock(흉내) 하거나 아니면 테스트를 실제 서버에 배치해야 한다. 즉, 이런 코드는 프레임워크 밖에서 재사용하기란 사실상 불가능하다.

 

횡단(cross-cutting) 관심사

EJB2 아키텍처는 일부 영역에서 관심사를 거의 완벽하게 분리한다. 예를 들어 트랜잭션, 보안, 영속성 관련 동작 등은 소스 코드가 아니라 배치 기술자에서 정의된다.

 

영속성과 같은 관심사는 애플리케이션의 자연스러운 객체 경계를 넘나든다. 모든 객체가 전반적으로 동일한 방식을 이용하게 만들어야 한다. 예를 들어 특정 DBMS나 독자적인 파일을 사용하고, 테이블과 열은 같은 명명 관례를 따르며, 트랜잭션 의미가 일관적이면 더 바람직하다. 즉, 1개의 관심사 내부의 동작들이 일관적이어야 한다.

 

이러한 트랜잭션, 보안, 영속성 등의 관심사를 횡단 관심사로 보고 모듈화하는 방식은 관점은 관점 지향 프로그래밍(Aspect-Oriented Programming, AOP)를 예견했다고 보인다.

 

영속성을 예로 들면 개발자는 영속적으로 저장할 객체와 속성을 선언한 후 영속성 책임은 프레임워크에 위임한다. 그러면 AOP 프레임워크는 대상 코드는 건들지 않고 동작 방식을 변경한다.

순수 자바 AOP 프레임 워크

순수 자바 관점을 구현하는 스프링 AOP, JBoss AOP 등과 같은 여러 자바 프레임워크는 내부적으로 프록시를 사용한다. 예를 들어 JPA에서 지연로딩 전략을 사용할 때도 프록시를 사용한다.

 

스프링은 비즈니스 로직을 POJO로 구현한다. POJO는 순수하게 도메인에 초점을 맞추고 엔터프라이즈 프레임워크에 의존하지 않는다. 따라서 테스트 작성도 더 쉽고 간단하다. 그리고 테스트 작성이 쉽다는 것은 변경과 유지보수하기 편하다는 것을 의미한다.

 

아까봤던 Bank 클래스를 예로 들어보자. 클라이언트는 Bank 객체에서 모든 작업이 실행될 것이라고 믿지만, 실제로는 Bank POJO의 기본 동작을 확장한 중첩 데코레이터(Decorator)의 가장 외곽과 통신한다. 필요하다면 트랜잭션, 캐싱 등에도 데코레이터를 추가할 수 있다.

애플리케이션에서 DI 컨테이너에게 시스템 내 최상위 객체를 요청하려면 다음 코드가 필요하다.

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

이는 스프링 관련 코드가 거의 필요 없으므로 애플리케이션은 사실상 스프링과 독립적이다. 즉, EJB2 시스템이 지녔던 강한 결합이라는 문제가 사라졌다.

 

추가적으로 위에서 언급한 데코레이터는 원래 XML을 통해 제공했었다. 스프링은 EJB3을 완전히 뜯어고치는 계기를 제공했고, 이후 자바 5 애너테이션 기능을 사용해서 훨씬 간편하게 횡단 관심사를 선언적으로 사용할 수 있는 기능을 제공한다.

 

그 결과 아래와 같이 비즈니스 로직에 집중된 깔끔한 코드로 변경할 수 있다.

@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;
    }
}

해당 코드는 여기저기 강하게 결합한 코드보다 테스트하기도 쉽고, 개선하고 유지보수하기도 편하다. 애노테이션에 영속성 정보가 남아있지만, 필요하다면 XML 배치 기술자를 사용해도 좋다. 그러면 깔끔하게 진짜 순수 POJO만 남는다.

테스트 주도 시스템 아키텍처 구축

관점으로 관심사를 분리하는 방식은 위력이 막강하다. 애플리케이션 도메인 로직을 POJO로 작성할 수 있다면, 즉 코드 수준에서 아키텍처 관심사를 분리할 수 있다면, 진정한 테스트 주도 아키텍처 구축이 가능해진다.

 

잘짜여진 시스템 아키텍처는 다음의 장점을 가지고 있다.

  • 빠르게 출시한 후 점진적으로 새로운 기술을 채택해서 아키텍처를 확장해 나갈 수 있다.
  • 시스템 전반적인 아키텍처 코드를 테스트 용이하게 깨끗하게 작성할 수 있다.
  • BDUF(Big Design Up First)를 추구할 필요가 없다.
    • BDUF는 변경을 하면 기존의 구조를 버려야 한다는 심리적 저항으로 인해 변경하기 꺼려지게 만든다.
  • 팀이 창의적인 노력을 사용자 스토리에 집중할 수 있다.

좋은 시스템 구조는 각기 POJO 객체로 구현되는 여러 개의 모듈화된 관심사로 구성된다.

 

관심사를 모듈로 분리한 POJO 시스템은 기민함을 제공한다. 이런 기민함은 최신 정보에 기반해 최선의 시점에 최적의 결정을 내리기가 쉬워진다. 의사 결정의 복잡성도 줄어든다.

시스템은 도메인 특화 언어가 필요하다

도메인 특화 언어(Domain-Specific Language, DSL)로 작성한 코드는 도메인 전문가가 작성한 구조적인 산문처럼 읽히는 효과가 있다. DSL은 도메인 개념과 그 개념을 구현한 코드 사이에 존재하는 의사소통 간극을 줄여준다. 도메인 전문가가 사용하는 언어로 도메인 논리를 구현하면 도메인을 잘못 구현할 가능성이 줄어든다.

 

DSL을 효과적으로 사용하면 추상화 수준을 코드 관용구나 디자인 패턴 이상으로 끌어올린다. 그래서 개발자가 적절한 추상화 수준에서 코드 의도를 명확히 표현할 수 있다. 그뿐 아니라 고차원 정책에서 저차원 세부사항에 이르는 추상화 수준과 도메인을 POJO로 표현할 수 있다.

 

결론

클래스나 메소드만 깨끗해야 하는 것이 아니라 시스템의 전반적인 아키텍처도 깨끗해야 한다. 깨끗하지 못한 아키텍처는 도메인 논리를 흐리며 기민성을 떨어뜨린다. 도메인 논리가 흐려지면 제품 품질이 떨어지고 버그가 발생하고 스토리를 구현하기 어려워진다. 기민성이 떨어지면 TDD가 제공하는 변경용이성과 같은 장점이 사라진다.

 

모든 추상화 단계에서 의도는 명확히 표현해야 한다. 그러려면 POJO를 작성하고 관점 혹은 관점과 유사한 메커니즘을 사용해서 각 구현의 관심사를 분리해야 한다.

 

Reference