SWE

클린 코드와 객체지향에 대해

백명석 님의 클린 코더스 강의를 듣고 요약정리한 글입니다. 문제가 있을 경우 삭제 조치하도록 하겠습니다.

1. Why Clean Code?

  • 사용되는 코드는 한번 작성되면 최소 10번 이상 읽힌다. 따라서 대충 돌아가기만 하는 코드를 작성하면 안 된다. 
  • 기계가 이해할 수 있는 코드는 어느 바보도 작성할 수 있다. 하지만, 인간이 이해할 수 있는 코드는 잘 훈련된 소프트웨어 엔지니어만이 작성할 수 있다.
  • 조직은 기존 코드에 유지 보수를 하는데 많은 투자를 한다. (잘 읽히는 코드의 중요성)

2. Why OOP?

절차지향과 객체지향의 차이

  • 절차지향은 모든 프로시저(함수)가 데이터를 공유한다.  따라서 데이터 변경이 많은 영향을 미치고, 결과적으로 데이터 변경이 어려워진다. 하지만, 알아야 할 것이 적어서 초기 진입이 쉽다는 특징이 있다.
  • 객체지향은 데이터와 코드가 은닉화되어 있다. (encapsulated) 또한, 데이터와 그 데이터를 조작하는 코드의 변경은 외부에 영향을 미치지 않는다. 객체지향에서 한 객체가 다른 객체의 구체적인 모습을 알게 되면 영향을 많이 받게 된다. 따라서 외부에 인터페이스를 노출해서 결합도를 낮추고, 같은 데이터를 사용하는 코드를 한 곳에 모아서 응집도를 높인다. (프로시저를 실행하는데 필요한 만큼의 데이터만 가져야 한다.)

절차지향은 쉬워서 접근하기 쉽다. 따라서 많은 사람들이 절차 지향을 선택하곤 한다. 처음부터 객체지향적으로 설계하는 것이 능숙한 사람은 거의 없다. (간혹 있다.)  따라서 접근하기 쉬운 절차 지향을 선택하고 이후에 객체지향으로 바꾸는 데에 초점을 맞출 수 있는데 이를 리팩터링이라고 한다.

객체지향 마인드가 없다면,  초기에 동작하는 코드로 시작하고 테스트 코드를 통해 지속적으로 피드백하면서 객체 지향적으로 리팩터링 하게 된다면 좋은 설계가 나올 가능성이 높다. (처음부터 UML 설계를 하는 것보다 상대적으로)

내 코드를 나아닌 다른 사람이 유지보수를 하는데, 그 사람은 나의 주소를 알고 총을 가지고 있는 미친 사람이다. (이런 마인드로 코드를 작성하라..!)

절차지향의 한계

  • 절차지향은  요구사항이 변경되면 데이터 구조 변경을 유발한다.
  • 데이터를 사용하는 모든 코드(client, test)의 수정을 유발한다.

객체지향은 어떤가?

  • 변경이 일어났을 때, 인터페이스를 잘 준수하고 있고, 충분히 추상화를 하고 있다면 클라이언트 영향을 최소화할 수 있다.
  • 절차지향에 비해 확실히 변경의 영향이 작다.
  • 클라이언트에 영향을 안 미치기 때문에, 구현 변경에 대해서 유연성을 가지고 있다. 

어떤 것을 선택해야 하는 가?

  • 구성원들이 준비가 되어있으면 객체지향으로 가고, 아니면 애석하게도 절차지향으로 가야 한다.
  • 이벤트 시스템처럼 앞으로 유지 보수하지 않을 것이라면 절차 지향적으로 가도 된다.  혹은 단순한 시스템
  • 하지만, 단순한 시스템은 거의 없다. 대부분의 시스템은 복잡하거나 요구사항 변화가 발생하는 시스템이다.
  • 절차 지향이 처음에는 쉽지만, 시간이 지나면 수정하기 어려운 구조가 되기 때문에 복잡한 시스템에서는 객체지향을 사용하자.

3. Object, Role, Responsibility

객체/클래스의 이름

  • 어떻게로 정의하지 말아야 한다. (ArticleController, JsonRequestParser)
  • 역할이 보이고, 무엇으로 정의해야 한다. (WriteArticleController, RequestParser)
  • 객체를 데이터로 보지 말고, 기능으로 바라봐야 한다. 

역할은 관련된 책임의 집합이며, 객체는 역할을 가진다.

역할은 게시판의 글쓰기 사용자, 댓글 쓰기 사용자, 비로그인 사용자, 운영자 등처럼 사용하는 기능으로 묶이는 그룹이다. 역할을 만족하기 위해서는 시스템에서 제공되어야 하는 연관 있는 기능들을 책임이라고 한다. 역할이 더 큰 범주이다. 즉, 역할 안에 책임이 여러 개 있을 수 있다.(역할은 관련된 책임의 집합) 또한 객체는 역할 내지 책임을 가지고 있다.

4. 객체지향 설계 과정

 아래 과정은 요구사항 분석 등과 같은 전제조건이 만족돼야 한다.

아래 두 과정을 계속 반복하는 것이 객체지향 설계과정이다.

  • 기능을 제공할 객체 후보 선별
    1. 내부에 필요한 데이터 선별
    2. 클래스 다이어그램
    3. 정적 설계
  • 객체 간의 메시지 흐름을 연결한다. 
    1. 커뮤니케이션 다이어그램
    2. 시퀀스 다이어그램
    3. 동적 설계

5. Encapsulation

내부의 변화가 외부에 영향을 주면 안 된다. 그렇게 하기 위해서는 내부적으로 어떻게 구현했는지를 내부에 감춰 데이터나 코드의 변경이 client 코드가 변경되지 않도록해야한다. 그것이 객체지향의 기본이다. (코드 변경에 따른 비용 최소화)

Tell, Don’t Ask

객체가 자기가 가지고 있는 상태에 대해서 알려주고 있는 것이 많다. 데이터를 요청해서 변경하고 저장하라고 하지 말아야 한다. 

ask의 예시는 아래와 같다. (야, 내장 좀 줘봐)

if(member.getExpiredDate().getTime() < System.currentTimeMillis) {}

대신에 데이터를 잘 알고 있는 객체에게 기능을 수행해달라고 요청한다. 결과적으로 Encapsulation이 유지되고 변경에 영향을 안 받게 된다.

(야, 수술해)

if(member.isExpired())

위와 같은 요구사항을 잘 지키기 위한 규칙으로 Law of Demeter이라고 있다. (나중에 다시 다룰 예정)

Command vs Query

Tell, Don’t Ask를 잘 지키게 해 주기 위해서 Command와 Query를 이용해 분리할 수 있다. 

  • command는 객체의 내부 상태를 변경하고, 편의를 위해 어떤 결과를 반환할 수 있다.
  • query는 객체의 상태에 대한 정보를 제공하고, 객체의 상태를 변경하지 않는다. (free of side effects)

해당 객체의 외부에서 의사결정에 사용하지 않는다면, 객체의 상태를 얻을 수 있다. 즉, 해당 객체의 상태에 기반한 결정은 반드시 객체 내에서 이뤄져야 한다. 또한 사람들이 command라면 내부의 상태를 변경시킬 것이라 기대하고, query는 내부의 상태를 변경하지 않을 것이라고 기대하게 된다.

6. Polymorphism

다형성은 아래와 같이 한 객체가 여러 가지(poly) 모습(morph)을 가질 수 있는 특징을 의미한다.

ZetMotorcycle zm = new ZetMotorcycle();
Motocycle mc = zm;
ZetEngine ze = zm;

객체지향에서 다형성을 구현하기 위해서는 인터페이스 상속(implements)이나, 구현 상속(extends)을 이용할 수 있다. 구현 상속은 슈퍼 타입의 구현을 재사용하고,  인터페이스 상속은 타입 정의만을 상속하여 객체에게 다형성을 제공한다. 인터페이스 상속은 인터페이스를 사용하는 코드를 재사용할 수 있다. 예를 들어, 인터페이스를 지키는 A가 인터페이스를 지키는 B로 변경되어도, 인터페이스를 사용하는 클라이언트 코드는 변경할 기존 인터페이스 호출을 유지하기만 하면 된다. (재사용)

7. Abstraction

공통된 데이터나 프로세스를 제공하는 객체들을 하나의 타입(인터페이스)으로 추상화하는 것이다. 처음부터 추상화 인터페이스를 생각해내도 좋지만, 그게 아니라면 나중에 FtpLogCollector와 유사한 기능을 수행하는 DBLogCollector가 추가된다고 하면, 인터페이스를 만들어야 한다.

다형성 파트에서 잠시 언급했지만, 인터페이스를 사용한다면 클라이언트 코드가 재사용할 수 있다. 예를 들어, FtpLogCollector와 DBLogCollector를 추상화해 LogCollector 인터페이스를 만들었다고 가정하자. 클라이언트 코드는 LogCollector가 구체적으로 누군지 상관없이 정의된 인터페이스를 호출하기만 하면 된다. 즉, 클라이언트 코드가 재사용이 가능하다. 

객체지향의 핵심은 의존성 관리를 통해서 High Level Logic을 Low Level Detail로부터 보호하는 것이다.  위 예시에서 로그 수집 방법이 Log Level Detail이다. 로그 수집 방법의 변경이 발생해도 High Level Logic(클라이언트 코드에서 인터페이스를 이용해서 로직 수행)은 보호된다.

Programming to interface

  • 클라이언트 코드는 항상 interface에 대한 레퍼런스를 사용해야 한다.
  • 클라이언트는 구현 변경에 대해서 영향을 받지 않아야 한다.
  • interface signature가 사용 가능한 모든 행위를 보여줘라.
  • 추상화를 통해 유연함을 얻자.

추상화와 개발자의 습성

개발자들은 습성상 상세한 구현에 빠지다 보면 상위 수준의 설계를 놓치기 쉽다. 예를 들자면, 아래와 같다.

디렉토리에서 파일을 읽어와 메모리에 저장하고
한 줄 한줄을 정규 표현식으로 파싱
결과를 DB에 저장한 다음
...블라

이러한 상황에서 추상화를 통해 상위 수준에서의 설계를 하는데 도움을 얻을 수 있다.

로그 수집
로그 분석
결과 저장

추상화와 다형성의 혜택은 구현 교체의 유연함이다.

비즈니스 로직을 보호하기 위해서 여러 단계가 있다. 처음에는 콘크리트 클래스가 있다고 가정하고, 이후에 Programming to interface, Abstract Factory, Dependency injection을 적용할 수 있다. 

하늘 생각 - 셋 중에 하나라도 적용 가능하다면,  구현 교체의 혜택을 느낄 수 있을 것이다. 

8. Composition over Inheritance

구현 상속을 통한 재사용과 추가 기능을 통한 확장

  • 서브 클래스는 슈퍼 클래스의 기능을 재사용한다.
  • 추가적인 기능을 제공하기 쉽다.
  • 하지만, 변경의 유연함 측면이 치명적인 단점이 될 수 있다.
    • 슈퍼 클래스의 변경이 다수의 서브 클래스에 영향을 미친다.
    • 유사한 기능의 확장에서 클래스의 개수가 불필요하게 증가할 수 있다. (2개 이상의 슈퍼 클래스의 기능이 필요한 경우 하지만, 다중 상속 불가한 경우, 1개를 상속받고, 다른 한 개는 따로 구현)
    • 상속 자체를 잘못 사용할 수 있다.

Composition(delegation)

public class Calculator {
    private PriceStrategy strategy;
    public Calculator(PriceStrategy strategy) {
        this.strategy = strategy;
    }
    
    public void calculate(...){
        this.strategy.apply(price);
    }    
}

위의 코드를 보면 polymorphism과 composition으로 인해서 유연성(변경 용이성)이 증가하고, 단위 테스트와 TDD에 용이성이 증가한다. 또한 다른 strategy를 적용할 수 있도록 추상화를 통해 인터페이스를 만드는 것의 중요성을 깨달을 수 있다. 이처럼 구현 상속보단 인터페이스 상속을 이용한 객체 조합(composition)을 고려해야 한다고 결론을 내릴 수 있다.