클린 코드 - 10. 클래스

이정우·2022년 1월 23일
0

Clean Code

목록 보기
10/10

코드의 표현력과 그 코드로 이루어진 함수에 아무리 신경을 쓰더라도, 좀 더 높은 차원인 클래스까지 신경을 쓰지 않는다면 깨끗한 코드를 얻기는 어렵다.

클래스 체계

클래스를 정의하는 표준 자바에서는 다음과 같이 변수 목록이 가장 먼저 나오며, 아래로 갈수록 추상화 단계가 낮아진다. 그 덕분에 프로그램은 신문 기사처럼 술술 읽히게 된다.

  • public static 변수
  • private static 변수
  • private 변수
  • public 함수
  • private 함수

캡슐화

변수와 유틸리티 함수는 공개하지 않는 것이 낫지만, 반드시 숨겨야한다는 법칙이 있는 것도 아니다. 때로는 protected로 선언하여 테스트 코드 등에서 사용하기도 한다. 하지만 그 전에 private 상태를 유지할 여러 방법을 고려한 뒤 최후의 수단으로 캡슐화를 풀어줘야 한다.


클래스는 작아야 한다!

클래스를 만들 때 가장 중요한 규칙은 크기다. 클래스는 작아야 한다. 함수와 마찬가지로 클래스를 설계할 때도 작게가 기본 규칙이다. 함수에서는 라인 수로 크기를 판단했다면, 클래스에서는 클래스가 맡은 책임으로 크기를 판단한다.

public class SuperDashboard extends JFrame implements MetaDataUser {
    public String getCustomizerLanguagePath();
    public void setSystemConfigPath(String systemConfigPath);
    public String getSystemConfigDocument();
    public void setSystemConfigDocument(String systemConfigDocument);
    public boolean getGuruState();
    public boolean getNoviceState();
    ...
    // 수십개의 많은 메소드들
}

위와 같이 수십 개의 메소드를 가진 클래스라면 크기가 크다는 생각이 자연스럽게 들 것이다. 다음과 같이 몇 개의 메소드만 가지고 있는 클래스라면 어떨까?

public class SuperDashboard extends JFrame implements MetaDataUser {
    public Component getLastFocusedComponent();
    public void setLastFocused(Component lastFocused);
    public int getMajorVersionNumber();
    public int getMinorVersionNumber();
    public int getBuildNumber();
}

메소드의 수가 적기 때문에 크기가 작다고 생각할 수 있지만, SuperDashboard는 메소드 수가 적어도 책임이 너무 많다.

클래스 이름은 해당 클래스의 책임을 기술해야 한다. 간결한 이름이 떠오르지 않는다면 클래스의 크기가 너무 크기 때문이다. 또한, if, and, or, but 등을 사용하지 않고 25단어 내외로 작성이 가능해야 한다.

단일 책임 원칙

단일 책임 원칙(Single Responsibility Principle)은 클래스나 모듈을 변경할 이유가 단 하나뿐이어야 한다는 원칙이다.

이 클래스를 변경해야 하는 이유는 다음과 같이 두 가지가 존재한다. 먼저 getMajorVersionNumber, getMinorVersionNumber 메소드는 소프트웨어의 버전 정보를 추적하는데, 버전 정보는 소프트웨어를 출시할 때마다 달라진다. 또한, 상속을 받은 JFrame 컴포넌트에서도 해당 컴포넌트의 코드를 바꿀 때마다 버전 번호가 달라지게 된다.

버전 번호가 바뀔 경우, 해당 클래스에는 어쩔 수 없이 코드의 변경이 생겨나게 되고 이는 단일 책임 원칙을 위반하게 된다. 책임을 파악하기 위해 고민을 하다보면 코드를 추상화하기도 쉬워지며, 추상화한 코드는 다른 어플리케이션에서도 재사용할 수 있게 된다.

public class Version {
    public int getMajorVersionNumber();
    public int getMinorVersionNumber();
    public int getBuildNumber();
}

SRP는 객체 지향 설계에서 아주 중요한 개념이고, 지키기가 쉬운 개념이다. 그럼에도 불구하고 여러 책임을 지니고 있는 클래스를 많이 발견할 수 있는데, 이는 "깨끗하고 체계적인 소프트웨어"보다 "돌아가는 소프트웨어"에 초점을 맞추기 때문이다.

작은 클래스가 많은 시스템이든 큰 클래스가 적은 시스템이든 익혀야할 내용은 비슷한 양을 가지고 있다. 하지만 규모가 큰 시스템은 많고 복잡한 논리를 지니고 있다. 복잡성을 다루기 위해서는 체계적인 정리가 필수적인데, 어떠한 변경이 일어날 때 영향이 가는 컴포넌트만 수정하면 되기 때문에 그 부분만 이해하면 되기 때문이다.

응집도

클래스는 인스턴스 변수가 적어야 한다. 클래스의 메소드는 클래스 내의 인스턴스 변수를 하나 이상 사용해야 한다. 일반적으로는 메소드가 변수를 많이 사용할수록 메소드와 클래스의 응집도가 높아지며, 응집도가 높은 클래스는 바람직하지 않다.

"함수를 작게, 매개변수 목록을 짧게"라는 전략을 따르다 보면 몇몇 메소드만 사용하는 인스턴스 변수가 많아지게 되는데, 이럴 때는 클래스를 쪼개는 것이 좋다.

응집도를 유지하면 작은 클래스 여럿이 나온다

큰 함수를 작은 함수 여럿으로 나누기만 해도 클래스의 수는 많아진다. 예를 들어, 변수를 많이 사용하는 큰 함수가 있다고 생각해보자. 이 함수의 일부를 다른 함수로 분리하고 싶은데, 분리될 함수에는 여러 매개변수가 필요하다. 그렇다고 해서 모든 변수를 넘겨주는게 맞을까?

이럴 때는, 사용할 변수를 클래스의 인스턴스 변수로 옮겨주면 된다. 그렇게 되면 새로운 함수는 매개변수가 필요하지 않게 되고, 함수를 쪼개기 쉬워진다. 그러다보면 몇몇 변수는 특정한 메소드에서만 사용되게 되고, 클래스는 응집도를 잃게 된다. 응집도를 잃은 클래스는 어떻게 하면 좋을까?

앞에서 말한 것처럼, 새로운 클래스로 분리하면 된다.

이런 과정을 거치다보면 프로그램의 체계가 잡히고 구조는 투명해진다.


변경하기 쉬운 클래스

대다수의 시스템은 계속해서 변경이 발생하며, 그 때마다 시스템이 의도대로 동작하지 않을 위험이 도사리고 있다. 깨끗한 시스템은 클래스를 체계적으로 정리해 변경에 따라오는 위험을 낮출 수 있다.

SQL 문자열을 생성하는 다음 예제를 살펴보자.

public class Sql {
    public Sql(String table, Column[] columns);
    public String create();
    public String insert(Ojbect[] fields);
    public String selectAll();
    public String findByKey(String keyColumn, String keyValue);
    public String select(Column column, String pattern);
    public String select(Criteria criteria);
    public String preparedInsert();
    private String columnList(Column[] columns);
    private String valuesList(Object[] fields, final Column[] columns);
    private String selectWithCriteria(String criteria);
    private String placeholderList(Column[] columns);
}

위의 클래스에는 아직 update나 다른 SQL 구문이 구현되지 않았다. 따라서 추후에 update 구문을 구현하기 위해서는 클래스에 변경이 반드시 발생할텐데, 어떤 변경이든 다른 코드를 망가뜨릴 잠재적인 위험이 존재한다.

또한 SQL 클래스는 SRP 원칙을 너무나도 대놓고 위반하고 있다. 새로운 SQL 구문을 작성하기 위해서는 클래스를 수정해야 하며, 기존 SQL 구문을 수정할 때도 클래스에 손을 대야 한다. 또한, selectWithCriteria와 같은 특정 메소드에서만 사용하는 비공개 메소드도 존재한다.

abstract public class Sql {
    public Sql(String table, Column[] columns) {...}
    abstract public String generate();
}

public class CreateSql extends Sql {
    public CreateSql(String table, Column[] columns) {...}
    @Override
    public String generate() {...}
}

public class SelectSql extends Sql {
    public SelectSql(String table, Column[] columns) {...}
    @Override
    public String generate() {...}
}

public class InsertSql extends Sql {
    public InsertSql(String table, Column[] columns, Object[] fields) {...}
    @Override
    public String generate() {...}
    private String valuesList(Object[] fields, final Column[] columns) {...}
}

public class SelectWithCriteriaSql extends Sql {
    public SelectWithCriteriaSql(String table, Column[] columns, Criteria criteria) {...}
    @Override
    public String generate() {...}
}

public class SelectWithMatchsql extends Sql {
    public SelectWithMatchsql(String table, Column[] columns, Column column, String pattern) {...}
    @Override
    public String generate() {...}
}

...생략

public class Where {
    public Where(String criteria) {...}
    public String generate() {...}
}

public class ColumnList {
    public ColumnList(Column[] columns) {...}
    public String generate() {...}
}

클래스를 분리하였기 때문에 각각의 클래스는 매우 단순하며 코드를 이해하기도 쉬워졌다. 또한 함수를 수정했다고 다른 함수가 망가질 위험도 사라졌다. update 구문을 구현할 때도 기존 클래스의 변경은 필요 없으며, Sql 클래스를 상속받는 새로운 클래스를 만들기만 하면 된다.

SRP 원칙을 만족할 뿐만 아니라, 확장에는 개방적이고 수정에는 폐쇄적이어야 한다는 OCP 원칙도 만족한다.

변경으로부터 격리

요구사항이 변하는 것은 어찌보면 당연한 일이다. 그에 따라 코드도 변한다. 객체 지향 프로그래밍에는 구체적인 클래스와 추상 클래스가 존재하며, 구체적인 클래스는 구현 코드를, 추상 클래스는 개념만 포함하고 있다. 상세한 구현에 의존하는 클라이언트 시스템은 구현이 바뀌면 위험에 빠진다. 따라서 인터페이스와 추상 클래스를 사용해 구현이 끼치는 영향을 줄여야 한다.

또한, 상세한 구현에 의존하는 코드는 테스트도 어렵다. 계속해서 값이 바뀌는 외부 API를 사용한다고 가정해보자. 값이 계속 바뀌는데 어떻게 테스트 코드를 작성할까?

이럴 때는 테스트가 가능할 정도로 시스템의 결합도를 낮춰 유연성과 재사용성을 높이면 된다.

0개의 댓글