클래스

여태까지는 코드 행과 블록을 올바로 작성하는 방법에 초점을 맞췄다.
하지만 코드의 표현력과 함수에 아무리 신경을 쓰더라도 더 높은 단계(클래스)까지 신경쓰지 않으면 깨끗한 코드를 얻기는 어렵다.

클래스 체계

캡슐화

변수와 유틸리티 함수는 가능한 공개하지 않는 편이 맞지만, 반드시 숨길 필요는 없다.
때로는 protected로 선언해 테스트 코드에서 사용을 허용하기도 한다.

클래스는 작아야 한다.

클래스를 만들 때 첫 번째 규칙은 크기이다.
그렇다면 대체 얼마나 작아야 하는가???
함수 : 물리적인 행 수
클래스 : 맡은 책임

우선 클래스 이름은 책임을 기술해야 한다.
Processor, Manager, Super 등과 같이 모호한 단어가 있다면 해당 클래스는 여러 책임을 갖고 있는 것이다.

또한 클래스의 설명은 if, and, or, but 을 사용하지 않고 25자 내로 설명가능해야 한다.
ex) "SuperDashboard는 마지막으로 포커스를 얻었던 컴포넌트에 접근하는 방법을 제공하며, 버전과 빌드 번호를 추적하는 메커니즘을 제공한다."
-> ~하며(and) 가 틀렸다? (만약 그렇다면 클래스가 너무 많아지는 것 아닌가?)

단일 책임 원칙(SRP)

클래스나 모듈을 변경할 이유가 단 하나 뿐이어야 한다.

SRP는 가장 이해시키기 쉬운 개념 중 하나다.
하지만 설계자가 가장 무시하는 규칙 중 하나다.
수많은 책임을 떠안은 클래스를 접하기가 정말 쉽다.
Why? SW를 동작시키는 것과 SW를 깨끗하게 만드는 것은 별개이다.
-> 대부분의 개발자들은 "깨끗한 SW" 보다 "돌아가는 SW"에 초점을 둔다. (사실인 것 같음... ㅂㅂㅂㄱ)
또한, 단일 책임 클래스(SRP를 만족하는 클래스)가 많아지면 큰 그림을 이해하기가 어렵다고 생각한다.(이것도 맞는 것 같은데...)

차이점 :

  1. 작은 서랍을 많이 두고 기능과 이름이 명확한 컴포넌트를 다룰 것인가?
  2. 큰 서랍을 몇 개 두고 서랍안에 많은 컴포넌트를 넣어 다룰 것인가?

응집도

클래스는 인스턴스 변수 수가 적어야 한다.
메소드 내에서 클래스 인스턴스를 많이 사용할 수록 응집도가 높다.

응집도를 유지하면 작은 클래스 여러개가 나온다.

큰 함수를 작은 함수 여럿으로 나누기만 해도 여러개의 클래스가 나온다.

if)

  1. 큰 함수 내의 어떤 로직을 작은 함수로 빼내고 싶다.
  2. 그 로직 내에서 큰 함수에 있는 로컬 변수 4개를 사용한다.
  3. 작은 함수의 매개변수로 4개를 모두 넘겨야 하나?
    -> No, 클래스 프로퍼티로 승격시키면 매개변수는 필요없어진다.
  4. 그렇다면 해당 프로퍼티는 작은 함수에서만 사용하는 것이 아닌가? 즉, 응집력이 낮아지는 것 아닌가?
    -> 해당 프로퍼티와 함수를 새로운 클래스로 쪼개라

변경하기 쉬운 클래스

SQL(select, selectAll, insert, findByKey ...)을 수행하는 클래스를 기능 별로 각각의 클래스로 나눈다.
(-> 하지만, 클래스가 너무 많아지는데..? 뭐 나눔으로써 SRP, OCP를 지원한다고 해도, 각각을 확인하기 또한 불편한 것 아닌가?)

변경으로부터 격리

요구사항을 변하기 마련이다. 따라서 코드 또한 변할 것이다.
OOP에서는 concrete Class 와 abstract Class가 존재한다.

ex) 주식 Portfolio 클래스를 만든다고 가정하자
Portfolio 클래스는 외부 TokyoStockExchange API를 사용해 값을 계산한다.
따라서 테스트 코드를 작성해도 시시 각각으로 변하는 시세로 인해 검증이 어려워진다.

-> Portfolio 클래스에서 TokyoStockExchange API를 직접 호출하는 대신 StockExchange라는 인터페이스를 생성한 후 메서드를 선언한다.

//abstract
public interface StockExchange{
    Money currentPrice(String symbol);
}

-> 다음으로 StockExchange 인터페이스를 구현하는 TokyoStockExchange Class를 구현한다.
또한, Portfolio 생성자를 통해 StockExchange를 주입한다.

public Portfolio{
    private StockExchange exchange;
    public Portfolio(StockExchange exchange){
        this.exchange = exchange;
    }
    //......
}

이제 TokyoStockExchange 를 흉내내는 테스트용 클래스를 만들 수 있다.
테스트 용 클래스는 StockExchange 인터페이스를 구현하며 고정된 주가를 반환한다.
또한 항상 일정한 주가를 반환한다.

따라서 이제는 시시각각 주가가 변동해도 테스트코드를 작성할 수 있다.

public class PortfolioTest{
    private FixedStockExchangeStub exchange;
    private Portfolio portfolio;

    @Before
    protected void setUp() throws Exception{
        exchange = new FixedStockExchangeStub();
        exchange.fix("BCT", 100);
        portfolio = new Portfolio(exchange);
    }

    @Test
    public void GivenFiveBCTTotalShouldBe500() throws Exception{
        portfolio.add(5, "BCT");
        Assert.assertEquals(500, portfolio.value());
    }
}

위와 같이 테스트가 가능할 정도로 시스템의 결합도를 낮추면 유연성과 재사용성이 높아진다.
결합도가 낮다 : 각 시스템 요소가 다른 요소로부터, 변경으로부터 잘 격리되어 있다.
결합도를 낮춤으로써 DIP를 따르는 클래스가 나온다.

DIP : concrete Class 가 아닌 abstract Class에 의존해야 한다.

우리가 만든 Portfolio Class는 Tokyo...라는 concrete Class가 아닌 StockExchange 인터페이스에 의존한다.
따라서 Tokyo... 가 아닌 NY...., Korea... 를 주입해서 사용할 수도 있다.

단위 테스트

이전까지의 단위 테스트란 프로그램이 '돌아간다' 라는 사실만 확인하는 정도였다.
현재는 TDD와 애자일 덕분에 단위 테스트를 자동화하는 프로그래머들이 이미 많아졌으며, 늘어나는 추세다.

TDD 법칙 3가지

요즘엔 실제 코드를 작성하기 전에 단위 테스트부터 짜라고 요구한다!

  1. 실패하는 단위 테스트를 작성할 때까지 코드를 작성하지 않는다.
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 현재 실패하는 테스트를 통과할 정도로만 코드를 작성한다.

이렇게 일하면 매일 수십개에서 매년 수천개의 테스트 케이스가 나온다.
하지만 정말 많아진 테스트코드는 심각한 관리 문제를 유발한다.

깨끗한 테스트 코드 유지하기

기존에 작성한 테스트 케이스를 위한 코드를 짰지만, 실제 코드는 계속해서 변경된다.
실제 코드가 변경됨으로써 이전에 작성한 테스트 케이스가 실패한다면 테스트 케이스를 통과시키기는 점점 더 어려워진다.
그렇게 유지되다보면 결국에는 테스트 케이스들을 모두 버리는 상황에 처한다.

BUT, 버린다면 실제 코드가 정상적으로 동작하는 지 확인할 수가 없다.
따라서 테스트 코드 또한 실제 코드만큼이나 중요하다!

테스트는 유연성, 유지보수성, 재사용성을 제공한다.

테스트 코드를 깨끗하게 유지하지 않으면 잃어버린다.
유연성, 유지보수성, 재사용성을 제공하는 버팀목이 바로 단위 테스트이다. -> 테스트 케이스가 있으면 코드 변경이 두렵지 않으므로
하지만, 코드를 잘 짜도, 아키텍처를 유연해도, 설계를 잘 하더라도 테스트 케이스가 없으면 버그를 잡아내기 힘들다.

테스트 케이스의 커버리지가 넓을수록 코드 변경에 대한 두려움은 사라진다.

깨끗한 테스트 코드

세가지가 필요하다

  1. 가독성
  2. 가독성
  3. 가독성
    (;;;;; 뭐야 왜이래)

테스트 코드에서의 가독성을 굉장히 중요하다.
테스트 함수 내에 테스트를 위한 사전 작업들 같은 경우는 별도의 함수를 통해 호출하여 가독성을 높인다.

이중 표준

테스트 환경에서는 성능을 크게 신경쓰지 않아도 된다.
성능보다는 가독성과 테스트에만 집중한다.

테스트당 assert 하나

굳이 지킬 필요는 없다

테스트당 개념 하나

이게 차라리 맞다.

F.I.R.S.T

  • Fast: 테스트는 빨라야 한다. 초반에 자주 돌려야 문제를 찾기 쉽다.

  • Independent: 각 테스트는 서로 의존하면 안된다. 한 테스트가 다른 테스트의 환경을 준비해서는 안된다. 테스트 함수는 순서와 상관없이 보장되어야 한다.

  • Repeatable: 테스트는 반복 가능해야 한다. 실제 환경, QA 환경, 네트워크가 없는 노트북 환경에서도 실행되어야 한다.

  • Self-Validating: 테스트는 Bool 값으로 결과를 내야 한다. 성공 아니면 실패!
    통과 여부를 Bool로 내지 않으면 판단은 주관적이 된다.

  • Timely: 테스트는 적시에 작성해야 한다. 실제 코드를 구현하기 이전에! 작성되어야 한다.

결론

사실상 깨끗한 테스트 코드라는 주제는 정말 끝이 없는 주제이다.
실제 코드보다 더 중요할 수도 있다.
테스트 코드를 깨끗하게 유지하자.

경계

시스템에 들어가는 모든 SW를 개발하기는 드물다.
때로는 패키지를 사고, 때로는 오픈 소스를 사용한다. 또는 사내 다른 팀의 컴포넌트를 이용하기도 한다.

외부 코드 사용하기

인터페이스 제공자와 사용자 사이에는 긴장이 존재한다.
제공자는 적용성을 최대한 넓히려 애쓴다. -> 커버리지가 커야 많은 사용자가 구매하니까
반대로 사용자는 자신의 요구에 집중하는 인터페이스를 바란다.

하나의 예로 java.util.Map 에는 clear() 메소드가 존재한다.
Map을 이리저리로 옮기면서 clear()라는 메소드를 누구나, 어디서든 호출할 수 있다.

ex) 센서를 담는 Map을 생성하자

Map sensors = new HashMap();
Sensor s = (Sensor)sensors.get(sensorId);

해당 맵에는 어떤 객체라도 들어갈 수 있다.

-> 제네릭을 사용해보자

Map<String, Sensor> sensors = new HsahMap<Sensor>();
Sensor s = sensors.get(sensorId);

하지만 Map의 기본 API인 clear()와 같은 기능은 제어하지 못한다. (즉, 여전히 누구나 사용할 수 있다.)

-> 다른 방법을 사용해보자

public class Sensors{
    private Map sensors = new HashMap();

    public Sensor getById(String id){
        return (Sensor) sensors.get(id);
    }
    ...
}

제네릭을 사용하지 않더라도 사용하는 입장에서는 문제가 되지 않는다.
또한 필요한 API만 사용자에게 제공이 가능하다.(clear()와 같은 불필요한 API는 제공하지 않아도 된다.)
Map을 사용할 때마다 캡슐화하라는 소리가 아니다. Map을 여기저기 넘기지 말라는 말이다.

경계 살피고 익히기

외부 코드를 사용하면 적은 시간에 많은 기능을 출시하기 쉬워진다.
외부 패키지 테스트가 우리의 책임은 아니다. 하지만 우리가 사용할 코드를 테스트하는 것은 우리의 책임이다.

  1. 외부 라이브러리를 가져온다.
  2. 며칠 문서를 보며 사용법을 익힌다.
  3. 우리쪽 코드를 작성해 라이브러리를 검증한다.
  4. 우리 버그인지 라이브러리 버그인지 찾아내느라 시간이 정말 많이 걸린다.

이처럼 외부 코드를 익히기는 어렵다. 통합하기도 어렵다.
그렇다면 우리쪽 코드를 작성해 외부 코드를 호출하는 대신 간단한 테스트 케이스를 작성하며 익히는 것은 어떤가?
-> 이를 학습 테스트라 부른다.

깨끗한 경계

경계에서는 흥미로운 일이 많이 벌어진다.
변경이 대표적인 예다.
SW 설계가 우수하다면 변경하는데 많은 투자와 재작업이 필요하지 않다.
통제가 불가능한 외부 패키지에 의존하는 대신 통제가 가능한 우리 코드에 의지하는 편이 훨씬 좋다.

외부 패키지를 호출하는 코드를 가능한 줄여 경계를 관리하자.
Map에서 봤듯이, 새로운 클래스로 경계를 감싸거나, ADAPTER 패턴을 사용해 우리가 원하는 인터페이스를 패키지가 제공하는 인터페이스로 변환하자.

오류 처리

오류 처리는 프로그램에 반드시 필요한 요소 중 하나일 뿐이다.
입력이나 디바이스가 실패할 수도 있기 때문이다.
내 생각엔 틀릴 수 없는 코드더라도 분명 가능성은 늘 존재한다.

여기저기 흩어진 오류 처리 코드로 인해 실제 코드가 하는 일을 파악하기가 어려울 수 있다.
오류 처리 코드로 인해 논리를 이해하기 어렵다면 깨끗한 코드가 아니다.

오류 코드보다 예외를 사용하라

Try-Catch-Finally 문부터 작성하라

try 블록에 들어가는 코드를 실행하면 어느 시점에서든 catch 블록으로 넘어갈 수 있다.

미확인 예외를 사용하라

함수1 -> 함수2 -> 함수3 -> 함수4(새로운 예외 발생)
이 때,

1) 함수3 부터 함수2, 함수1 까지 새로운 예외를 catch로 처리해야 하거나,
2) throw 키워드를 모두 붙여줘야 한다.
이러한 건 OOP의 OCP(개발-폐쇄 원칙)을 위반한다.

개방-폐쇄 원칙
객체는 확장에는 개발, 수정에는 폐쇄되어야 한다.

-> (하지만, 확인된 예외를 처리함으로써 어떤 예외인지 알아채기 쉽고, 보다 유지보수하기가 편리해지는 것 아닐까? 하는 생각이 든다)

예외에 의미를 제공하라

오류 메세지에 정보를 담아 예외와 함께 던진다.
실패한 연산 이름, 실패 유형 등...
오류가 발생한 원인과 위치를 더 찾기 쉬워진다.

호출자를 고려해 예외 클래스를 정의하라

정상 흐름을 정의하라

앞 절에서의 지침들을 따른다면 비즈니스 논리와 오류 처리가 잘 분리된 코드가 나온다.

/*
* 식비를 비용으로 청구했다면 직원이 청구한 식비를 총계에 더한다.
* 식비를 비용으로 청구하지 않았다면 일일 기본 식비를 총계에 더한다.
*/
//1번
try{
    MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
    m_total += expenses.getTotal();
} catch(MealExpensesNotFound e){
    m_total += getMealPerDiem();
}

//2번
public class PerDiemMealExpenses implements MealExpenses{
    public int getTotal(){
        //청구값이 있다면 청구값을 반환
        //청구값이 없다면 기본값을 반환
    }
}

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();

1번에서 2번으로 변경하게 되면 예외 상황을 처리할 필요가 없어진다.

null을 반환하지 마라

반환 타입이 nullable이라면 지속적으로 null check를 해야 한다.
만약 1번이라도 null check 코드가 빠졌다면 NPE가 발생한다.
nullable 타입을 반환하기보다 예외나 특수 사례 객체를 반환하라
외부 객체가 null을 반환한다면 감싸기 메서드를 구현해 예외를 던지거나 특수 사례 객체를 반환하라

//1번
List<Employee> employees = getEmployees();
if (employees != null){
    for(Employee e : employees){
        totalPays += e.getPay();
    }
}

//2번
List<Employee> employees = getEmployees();
for(Employee e : employees){
    totalPays += e.getPay();
}

굳이 null을 반환해야 할까?, 빈 리스트를 넘겨서 로직을 수행하지 않아도 되지 않은가?
이로써 쓸데없는 코드 2줄이 줄고, 보기에도 깔끔해졌다.

null을 전달하지 마라

메서드에서 null을 반환하는 방식도 나쁘지만, 메서드로 null을 전달하는 방식은 더 나쁘다.

대다수 프로그래밍 언어는 호출자가 실수로 null을 넘기면 적절히 처리하는 방법이 없다.
애초에 null을 넘기지 못하도록 금지하는 정책이 합리적이다.
인수로 null이 넘어오면 코드에 문제가 있다는 뜻이다.

결론

깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다.
이 둘은 상충하는 목표가 아니다.
오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 깨끗한 코드를 작성할 수 있다.

+ Recent posts