혼자 정리

[클린코드] 10. 클래스 본문

클린코드

[클린코드] 10. 클래스

tbonelee 2021. 11. 2. 17:35

10장 클래스

클래스 구성

자바 컨벤션을 따르는 클래스 구성 순서는 대략 다음과 같다.

  • public static 상수
  • private static 변수
  • private instance 변수
  • public 인스턴스 변수가 필요한 경우는 거의 없다.
  • 공개 함수
  • 비공개 함수는 그 다음에

이런 식의 구성은 앞 장에서 다룬 stepdown rule을 잘 따르기 때문에 신문 기사를 읽는 것처럼 프로그램을 읽을 수 있게 해준다. (stepdown rule : 위에서부터 읽어 내려갈 때 추상화 단계가 한 단계씩 순차적으로 내려가는 것)

Encapsulation

캡슐화를 위해 변수나 메서드를 private으로 유지하는 것이 좋긴 하지만 그것이 절대적인 것은 아니다.

테스트를 위해 변수나 함수에 접근해야될 때가 있는데 그 때는 허용해줘야 한다. 즉, 테스트가 중요하다.

같은 패키지 내의 테스트가 함수나 변수에 접근하고 싶으면 protected나 package scope로 해주면 된다.

그래도 비공개를 유지할 수 있는 방법이 있으면 그렇게 하는 것이 낫다. 캡슐화를 깨는 것은 항상 최후의 수단이다.

Classes Should Be Small!

앞에서 함수에 대해 얘기한 것과 마찬가지로 클래스도 작을수록 좋다.

둘의 차이점은 얼마나 작은지 측정하는 수단이 함수에서는 줄 수였지만 클래스에서는 '책임(responsibilities)'이다.

다음 두 예시를 통해 클래스에서 책임의 의미를 살펴 보자

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 boolean getOpenSourceState()
    public void showObject(MetaObject object)
    public void showProgress(String s)
    public boolean isMetadataDirty()
    public void setIsMetadataDirty(boolean isMetadataDirty)
    public Component getLastFocusedComponent()
    public void setLastFocused(Component lastFocused)
    public void setMouseSelectState(boolean isMouseSelected)
    public boolean isMouseSelected()
    public LanguageManager getLanguageManager()
    public Project getProject()
    public Project getFirstProject()
    public Project getLastProject()
    public String getNewProjectName()
    public void setComponentSizes(Dimension dim)
    public String getCurrentDir()
    public void setCurrentDir(String newDir)
    public void updateStatus(int dotPos, int markPos)
    public Class[] getDataBaseClasses()
    public MetadataFeeder getMetadataFeeder()
    public void addProject(Project project)
    public boolean setCurrentProject(Project project)
    public boolean removeProject(Project project)
    public MetaProjectHeader getProgramMetadata()
    public void resetDashboard()
    public Project loadProject(String fileName, String projectName)
    public void setCanSaveMetadata(boolean canSave)
    public MetaObject getSelectedObject()
    public void deselectObjects()
    public void setProject(Project project)
    public void editorAction(String actionName, ActionEvent event)
    public void setMode(int mode)
    public FileManager getFileManager()
    public void setFileManager(FileManager fileManager)
    public ConfigManager getConfigManager()
    public void setConfigManager(ConfigManager configManager)
    public ClassLoader getClassLoader()
    public void setClassLoader(ClassLoader classLoader)
    public Properties getProps()
    public String getUserHome()
    public String getBaseDir()
    public int getMajorVersionNumber()
    public int getMinorVersionNumber()
    public int getBuildNumber()
    public MetaObject pasting(
        MetaObject target, MetaObject pasted, MetaProject project)
    public void processMenuItems(MetaObject metaObject)
    public void processMenuSeparators(MetaObject metaObject)
    public void processTabPages(MetaObject metaObject)
    public void processPlacement(MetaObject object)
    public void processCreateLayout(MetaObject object)
    public void updateDisplayLayer(MetaObject object, int layerIndex)
    public void propertyEditedRepaint(MetaObject object)
    public void processDeleteObject(MetaObject object)
    public boolean getAttachedToDesigner()
    public void processProjectChangedState(boolean hasProjectChanged)
    public void processObjectNameChanged(MetaObject object)
    public void runProject()
    public void setAçowDragging(boolean allowDragging)
    public boolean allowDragging()
    public boolean isCustomizing()
    public void setTitle(String title)
    public IdeMenuBar getIdeMenuBar()
    public void showHelper(MetaObject metaObject, String propertyName)
    // ... many non-public methods follow ...
}

다음 함수는 위의 SuperDashboard의 메소드 수를 줄였다. 하지만 충분히 작다고 할 수 있을까?

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는 너무 많은 '책임'을 가지고 있다.

클래스 이름을 통해 클래스가 지닌 책임이 너무 많지는 않은지 가늠해볼 수 있다.

클래스가 하는 일을 요약할 간결하고 명확한 클래스명이 떠오르지 않는다면 책임이 많은 경우일 수 있다.

  • 클래스 이름에 Processor, Manager, Super 등과 같은 모호한 단어가 있으면 클래스에 너무 많은 책임이 있는 경우일 수 있다.

또한 클래스 설명도 if, and, or, but과 같은 접속사를 쓰지 않고 25단어 내외로 가능해야 한다.

  • 위 예시의 경우, "SuperDashboard는 가장 최근에 포커스를 획득했던 컴포넌트에 접근하는 방법을 제공한다. and 그것의 버전과 빌드 번호를 추적할 수 있게 해준다."로 클래스를 설명할 수 있다.
  • 클래스 설명에 "and"를 써야하는 것을 통해 클래스의 책임이 크다고 의심해볼 수 있다.

단일 책임 원칙(The Single Responsibility Principle; SRP)

단일 책임 원칙은 클래스나 모듈을 변경할 이유는 오직 한 가지여야 함을 설명한다.

위의 SuperDashboard클래스는 변경할 이유가 두 가지다.

  • 버전 정보를 다루는 부분
  • 자바 스윙 컴포넌트를 다루는 부분

따라서 버전 정보를 다루는 부분을 분리해서 새로운 클래스를 만들 수 있다. 그러면 다른 곳에서 이 클래스를 재사용할 수 있는 장점도 있다.

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

SRP는 가장 이해하기 쉬운 규칙이지만 가장 지켜지지 않는 규칙이기도 하다.

보통의 개발자들은 코드가 작동하게 하면 할 일을 다 했다고 생각하고 다른 작업으로 넘어가기 때문이다.

또한 클래스를 작게 나누면 큰 그림을 이해하기 어렵다고 생각해서 클래스 나누기를 주저하는 개발자도 있다. 하지만 클래스가 크건 작건 결국 프로그램 작동에 핵심적인 부분의 수는 비슷하다.

클래스를 나누지 않으면 많은 부품을 그냥 한 상자에 몰아넣은 것과 다를 바가 없다. 그러면 코드 변경 시에 직접적으로 고쳐야 하는 코드가 아닌 코드도 어쩔 수 없이 읽어야 하게 된다.

응집도(Cohesion)

클래스는 인스턴스 변수를 적게 가져야 한다. 그러면서도 클래스의 각 메소드는 인스턴스 변수를 하나 이상 사용해야 한다.

메소드가 사용하는 인스턴스 변수가 많을 수록 메소드와 클래스의 '응집도'가 높다. 그 말은 메소드, 변수, 클래스가 상호 의존적이어서 하나의 온전한 로직으로 묶이게 된다는 것이다. 그러니 응집도를 높게 만들어서 클래스의 크기를 작게 해야 한다.

그런데 함수를 작게 만들고 매개변수 목록을 작게 유지하다 보면 특정 집합의 메소드만 사용하는 인스턴스 변수가 많아지는 경우가 생긴다.

이는 그 부분을 새로운 클래스로 나눌 수 있다는 의미다. 즉 변수와 메소드를 쪼개서 새 클래스를 만들고 응집도를 높이자.

응집도를 높게 유지하면 작은 클래스를 많이 만들 수 있다.

큰 함수를 나누다 보면 클래스 수가 많아진다. 이러한 상관관계는 다음과 같이 설명할 수 있다.

  • 큰 함수를 쪼개다 보면 공통으로 쓰는 변수들이 많다.
  • 이를 복잡하게 인수로 넘기지 않으려면 인스턴스 변수로 빼야 한다.
  • 그러면 몇몇 메소드만 사용하는 인스턴스 변수가 많아진다.(당연히 응집도도 낮아진다)
  • 그 말은 해당 메소드들과 인스턴스 변수를 새로운 클래스로 나눌 수 있는 가능성도 크다는 의미.(그러면서 응집도를 다시 높게 유지할 수 있다)

따라서 큰 함수를 나누는 것은 더 작은 클래스를 많이 만들 수 있는 기회를 제공한다. 이를 통해 프로그램의 구성을 개선하고 더 명확한 구조를 만들 수 있다.

Knuth의 책 Literate Programming에 나오는 프로그램을 자바로 변환한 예제를 통해 살펴보자.

// PrintPrimes.java
package literatePrimes;

public class PrintPrimes {
    public static void main(String[] args) {
        final int M = 1000;
        final int RR = 50;
        final int CC = 4;
        final int WW = 10;
        final int ORDMAX = 30;
        int P[] = new int[M + 1];
        int PAGENUMBER;
        int PAGEOFFSET;
        int ROWOFFSET;
        int C;
        int J;
        int K;
        boolean JPRIME;
        int ORD;
        int SQUARE;
        int N;
        int MULT[] = new int[ORDMAX + 1];

        J = 1;
        K = 1;
        P[1] = 2;
        ORD = 2;
        SQUARE = 9;

        while (K < M) {
            do {
                J = J + 2;
                if (J == SQUARE) {
                    ORD = ORD + 1;
                    SQUARE = P[ORD] * P[ORD];
                    MULT[ORD - 1] = J;
                }
                N = 2;
                JPRIME = true;
                while (N < ORD && JPRIME) {
                    while (MULT[N] < J)
                        MULT[N] = MULT[N] + P[N] + P[N];
                    if (MULT[N] == J)
                        JPRIME = false;
                    N = N + 1;
                }
            } while (!JPRIME);
            K = K + 1;
            P[K] = J;
        }
        {
            PAGENUMBER = 1;
            PAGEOFFSET = 1;
            while (PAGEOFFSET <= M) {
                System.out.println("The First " + M +
                        " Prime Numbers --- Page " + PAGENUMBER);
                System.out.println("");
                for (ROWOFFSET = PAGEOFFSET; ROWOFFSET < PAGEOFFSET + RR; ROWOFFSET++){
                    for (C = 0; C < CC;C++)
                        if (ROWOFFSET + C * RR <= M)
                            System.out.format("%10d", P[ROWOFFSET + C * RR]);
                    System.out.println("");
                }
                System.out.println("\f");
                PAGENUMBER = PAGENUMBER + 1;
                PAGEOFFSET = PAGEOFFSET + RR * CC;
            }
        }
    }
}

이 코드를 쪼개면 다음의 작은 클래스와 함수로 만들 수 있다.

// PrimePrinter.java (refactored)
package literatePrimes;

public class PrimePrinter {
    public static void main(String[] args) {
        final int NUMBER_OF_PRIMES = 1000;
        int[] primes = PrimeGenerator.generate(NUMBER_OF_PRIMES);

        final int ROWS_PER_PAGE = 50;
        final int COLUMNS_PER_PAGE = 4;
        RowColumnPagePrinter tablePrinter =
            new RowColumnPagePrinter(ROWS_PER_PAGE,
                    COLUMNS_PER_PAGE,
                    "The First " + NUMBER_OF_PRIMES +
                    " Prime Numbers");
        tablePrinter.print(primes);
    }
}
// RowColumnPagePrinter.java
package literatePrimes;

import java.io.PrintStream;

public class RowColumnPagePrinter {
    private int rowsPerPage;
    private int columnsPerPage;
    private int numbersPerPage;
    private String pageHeader;
    private PrintStream printStream;

    public RowColumnPagePrinter(int rowsPerPage,
            int columnsPerPage,
            String pageHeader) {
        this.rowsPerPage = rowsPerPage;
        this.columnsPerPage = columnsPerPage;
        this.pageHeader = pageHeader;
        numbersPerPage = rowsPerPage * columnsPerPage;
        printStream = System.out;
    }

    public void print(int data[]) {
        int pageNumber = 1;
        for (int firstIndexOnPage = 0;
                firstIndexOnPage < data.length;
                firstIndexOnPage += numbersPerPage) {
            int lastIndexOnPage =
                Math.min(firstIndexOnPage + numbersPerPage - 1,
                    data.length - 1);
            printPageHeader(pageHeader, pageNumber);
            printPage(firstIndexOnPage, lastIndexOnPage, data);
            printStream.println("\f");
            pageNumber++;
        }
    }

    private void printPage(int firstIndexOnPage,
            int lastIndexOnPage,
            int[] data) {
        int firstIndexOfLastRowOnPage =
            firstIndexOnPage + rowsPerPage - 1;
        for (int firstIndexInRow = firstIndexOnPage;
                firstIndexInRow <= firstIndexOfLastRowOnPage;
                firstIndexInRow++) {
            printRow(firstIndexInRow, lastIndexOnPage, data);
            printStream.println("");
        }
    }

    private void printRow(int firstIndexInRow,
            int lastIndexOnPage,
            int[] data) {
        for (int column = 0; column < columnsPerPage; column++) {
            int index = firstIndexInRow + column * rowsPerPage;
            if (index <= lastIndexOnPage)
                printStream.format("%10d", data[index]);
        }
    }

    private void printPageHeader(String pageHeader,
            int pageNumber) {
        printStream.println(pageHeader + " --- Page " + pageNumber);
        printStream.println("");
    }

    public void setOutput(PrintStream printStream) {
        this.printStream = printStream;
    }
}
// PrimeGenerator.java
package literatePrimes;

import java.util.ArrayList;

public class PrimeGenerator {
    private static int[] primes;
    private static ArrayList<Integer> multiplesOfPrimeFactors;

    protected static int[] generate(int n) {
        primes = new int[n];
        multiplesOfPrimeFactors = new ArrayList<Integer>();
        set2AsFirstPrime();
        checkOddNumbersForSubsequentPrimes();
        return primes;
    }

    private static void set2AsFirstPrime() {
        primes[0] = 2;
        multiplesOfPrimeFactors.add(2);
    }

    private static void checkOddNumbersForSubsequentPrimes() {
        int primeIndex = 1;
        for (int candidate = 3;
                primeIndex < primes.length;
                candidate += 2) {
            if (isPrime(candidate))
                primes[primeIndex++] = candidate;
        }
    }

    private static boolean isPrime(int candidate) {
        if (isLeastRelevantMultipleOfNextLargerPrimeFactor(candidate)) {
            multiplesOfPrimeFactors.add(candidate);
            return false;
        }
        return isNotMultipleOfAnyPreviousPrimeFactor(candidate);
    }

    private static boolean
    isLeastRelevantMultipleOfNextLargerPrimeFactor(int candidate) {
        int nextLargerPrimeFactor = primes[multiplesOfPrimeFactors.size()];
        int leastRelevantMultiple = nextLargerPrimeFactor * nextLargerPrimeFactor;
        return candidate == leastRelevantMultiple;
    }

    private static boolean
    isNotMultipleOfAnyPreviousPrimeFactor(int candidate) {
        for (int n = 1; n < multiplesOfPrimeFactors.size(); n++) {
            if (isMultipleOfNthPrimeFactor(candidate, n))
                return false;
        }
        return true;
    }

    private static boolean
    isMultipleOfNthPrimeFactor(int candidate, int n) {
        return
            candidate == smallestOddNthMultipleNotLessThanCandidate(candidate, n);
    }

    private static int
    smallestOddNthMultipleNotLessThanCandidate(int candidate, int n) {
        int multiple = multiplesOfPrimeFactors.get(n);
        while (multiple < candidate)
            multiple += 2 * primes[n];
        multiplesOfPrimeFactors.set(n, multiple);
        return multiple;
    }
}

함수가 많이 길어졌는데 이는 다음 이유들로 설명할 수 있다.

  • 더 길고 서술적인 변수명을 사용했다.
  • 함수와 클래스 선언을 통해 주석을 다는 효과를 가져왔다.
  • 화이트스페이스와 포맷팅 기법을 통해 프로그램의 가독성을 높였다.

프로그램은 세 개의 주 책임으로 분리됐다.

  • 메인 프로그램은 PrimePrinter클래스 전체 그 자체이고, 해당 클래스의 책임은 실행 환경 관리다. 프로그램 호출 방식이 바뀌면 PrimePrinter를 고쳐야 한다.
  • RowColumnPagePrinter는 숫자 리스트를 특정 크기의 행과 열로 출력하는 방법을 안다. 결과물 포맷을 바꿔야 하면 여기를 고쳐야 한다.
  • PrimeGenerator클래스는 소수 목록 생성 방법을 가지고 있다. 소수 생성 알고리즘을 바꾸려면 여기를 바꿔야 한다. (이 클래스는 객체 생성을 위한 클래스가 아니라 변수를 선언하고 숨기기 위한 클래스다.

이런 식으로 리팩토링을 하기 위해서는 우선 기존 프로그램의 동작을 체크하는 테스트를 작성해야 한다. 그 다음 코드를 하나 바꿀 때마다 테스트를 돌려서 원하는 동작을 갖는지 확인한다. 그러한 과정을 통해 같은 논리로 제대로 동작하는 새 코드를 얻을 수 있다.

Organizing for Change

코드의 수정에는 시스템이 작동하지 않게 될 위험이 존재한다. 만약 시스템을 깨끗하게 유지한다면 그런 위험은 줄어든다.

Sql클래스의 예시를 보자.

public class Sql {
    public Sql(String table, Column[] columns)
    public String create()
    public String insert(Object[] 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문을 지원하지 않는다. 이를 지원하려면 클래스를 고쳐야 한다.

또한 select에서 subselect를 지원하게 할 때도 클래스를 고쳐야 한다.

즉 클래스를 고칠 이유가 두 가지 존재하므로 SRP를 위반한다.

하지만 다음과 같이 고치면 SRP을 위반하지 않고 OCP(Open-Closed Principle)도 위반하지 않는다.

  • 새 기능을 만들 때 Sql클래스를 고칠 필요가 없지만(폐쇄), 기능을 만드는 것은 파생 클래스를 통해 가능(확장)
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 FindByKeySql extends Sql
    public FindByKeySql(
        String table, Column[] columns, String keyColumn, String keyValue)
    @Override public String generate()
}

public class PreparedInsertSql extends Sql {
    public PreparedInsertSql(String table, Column[] columns)
    @Override public String generate() {
    private String placeholderList(Column[] columns)
}

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

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

Isolating from Change

클래스에는 구체적인 클래스와 추상 클래스가 있다. 구체적 클래스는 추상 클래스의 구현을 담고 있다.

하지만 구체적인 구현은 변화할 가능성이 크기 때문에 구체적 구현에 의존하는 클라이언트 클래스는 안전하지 못하다.

특히, 클라이언트 클래스가 구체적인 구현 클래스에 의존하면 테스트 코드를 짜는 것이 쉽지 않다.

구체적으로 구현된 클래스는 우리가 원하는 케이스를 보여주도록 작동시키기 어렵기 때문이다.

TokyoStockExchange클래스를 사용하는 주식 포트폴리오 클래스를 예를 들어 보자. 주식 시세는 계속해서 변화하므로 TokyoStockExchange클래스가 보내주는 데이터는 계속해서 변화한다. 그러면 우리가 테스트 해보고 싶은 케이스를 정해서 테스트하기 쉽지 않다.

이를 해결하기 위해 Portfolio클래스가 TokyoStockExchange클래스를 직접 호출하지 않고 StockExchange인터페이스를 구현하는 테스트용 클래스를 호출하게 할 수 있다. 이 때 TokyoStockExchange클래스도 StockExchange인터페이스를 구현하게 한다.

그러면 시세가 변동하여 응답도 변동하는 API 대신 우리가 원하는 응답을 보내주는 테스트용 StockExchange구현 클래스를 사용하여 원하는 케이스들을 테스트할 수 있다.

이를 한 마디로 표현하면 시스템의 결합도를 낮추는 것이다. 시스템 요소 간 영향이 줄어들기 때문에 각 요소를 이해하기도 쉬워지고 수정하기도 쉬워진다.

또한 추상 클래스가 구현 클래스에 의존하는 것이 아니라 구현 클래스가 추상 클래스에 의존하도록 하는 방식으로 시스템의 결합도를 낮추면 자연스럽게 DIP(Dependency Inversion Principle)도 지키게 된다.

'클린코드' 카테고리의 다른 글

9장. Unit Test  (0) 2021.10.28
7. 오류 처리  (0) 2021.10.12
6. 객체와 자료구조  (0) 2021.10.10
5. 형식 맞추기  (0) 2021.10.06
4. 주석  (0) 2021.09.27