객제지향, TDD, 클린코드/클린코드

클래스는 작아야 한다!

ummchicken 2023. 3. 22. 14:29

그냥 차라리 로봇이 되자

 

 

안녕하세요.

오늘은 클래스클린코드를 엮어서 학습해 볼 것입니다.

즉, 깨끗한 클래스를 다룰 겁니다.

 

 

개인적으로 클래스 분리는 객체지향의 꽃이라고 생각합니다.

 

 


 

✔️ 클래스는 작아야 한다!

클래스를 만들 때 첫 번째 규칙은 크기다. 클래스는 작아야 한다.
두 번째 규칙도 크기다. 더 작아야 한다.

 

 

❓ 그럼 도대체 얼마나 작아야 할까?

함수는 물리적인 행 수로 크기를 측정했다.

💡 클래스는 맡은 책임을 센다!

 

 

 

다음 코드는 SuperDashboard라는 클래스로, 공개 메서드 수가 대략 70개 정도다.

너무 많은 책임을 가진다.

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 showProgress(String s);
    public void showObject(MetaObject object);
    public boolean isMetadataDirty();
    public void setIsMetadataDirty(boolean isMetadataDirty);
    public void setMouseSelectState(boolean isMouseSelected);
    
    ...
}

아마 대다수의 개발자는 이 클래스가 엄청나게 크다는 사실에 동의할 것이다.

어쩌면 SuperDashboard 클래스를 '만능 클래스'라고 부를지도?

 

 

 

 

하지만 만약 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();
}

메서드 5개 정도면 괜찮을까?

 

여기서는 그렇지 않다.

SuperDashboard는 메서드 수가 작음에도 불구하고 책임이 너무 많다.

 

 

💡 클래스 이름은 해당 클래스 책임을 기술해야 한다.

 

 

실제로 작명은 클래스 크기를 줄이는 첫 번째 관문이다.

 

 

만약 간격한 이름이 떠오르지 않는다면, 

필경 클래스 크기가 너무 커서 그렇다.

 

클래스 이름이 모호하다면, 

필경 클래스 책임이 너무 많아서다.

예를 들어, 클래스 이름에 

🚨 Processor, Manager, Super 등과 같이 모호한 단어가 많이 있다면, 

클래스에다 여러 책임을 떠안겼다는 증거이다.

 

 

🚨 또한 클래스 설명은 if, and, or, but을 사용하지 않고서 

25 단어 내외로 가능해야 한다.

 

 

 

 

✔️ 단일 책임 원칙

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

 

 

SRP는 '책임'이라는 개념을 정의하며, 적절한 클래스 크기를 제시한다.

 

클래스는 책임, 즉 변경할 이유가 하나여야 한다는 의미이다.

 

 

 

위의 SuperDashboard는 겉보기엔 작아 보인다.

하지만 변경할 이유가 두 가지이다.

  1. SuperDashboard는 소프트웨어 버전 정보를 추적한다.
    • 그런데 버전 정보는 소프트웨어를 출시할 때마다 달라진다.
  2. SuperDashboard는 자바 스윙 컴포넌트를 관리한다.
    • 즉, 스윙 코드를 변경할 때마다 버전 번호가 달라진다.

 

 

 

💡 책임, 즉 변경할 이유를 파악하려 애쓰다 보면, 
코드를 추상화하기도 쉬워진다!
더 좋은 추상화가 더 쉽게 떠오른다.

 

 

 

SuperDashboard에서 버전 정보를 다루는 메서드 세 개를 따로 빼내어 Version이라는 독자적인 클래스를 만든다.

다음 Version 클래스는 다른 애플리케이션에서 재사용하기 아주 쉬운 구조다!

public class Version {
    public int getMajorVersionNumber();
    public int getMinorVersionNumber();
    public int getBuildNumber();
}
  • '버전 정보'라는 단일 책임 클래스이다.

 

 

 

SRP는 객체 지향 설계에서 더욱 중요한 개념이다.

또한 이해하고 지키기 수월한 개념이기도 하다.

 

 

 

 

 

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

 

일반적으로 응집도가 가장 높은 클래스는 가능하지도 바람직하지도 않다.

그렇지만 우리는 응집도가 높은 클래스를 선호한다.

 

💡 응집도가 높다는 말은, 클래스에 속한 메서드와 변수가 

서로 의존하며 논리적인 단위로 묶인다는 의미이기 때문이다.

 

 

 

다음 코드는 Stack을 구현한 코드이다.

아래 클래스는 응집도가 아주 높은 클래스이다.

public class Stack {
    private int topOfStack = 0;
    List<Integer> elements = new LinkedLIst<Integer>();
    
    public int size() {
        return topOfStack;
    }
    
    public void push(int element) {
        topOfStack++;
        elements.add(element);
    }
    
    public int pop() throws PoppedWhenEmpty {
        if (topOfStack == 0)
            throw new PoppedWhenEmpty;
        int element = elements.get(--topOfStack);
        elements.remove(topOfStack);
        return element;
    }
}
  • size()를 제외한 다른 두 메서드는 두 변수를 모두 사용한다.

 

 

 

'함수를 작게, 매개변수 목록을 짧게'라는 전략을 따르다 보면, 
때때로 몇몇 메서드만이 사용하는 인스턴스 변수가 아주 많아진다.

❗ 이는 십중팔구 새로운 클래스로 쪼개야 한다는 신호다!

응집도가 높아지도록, 변수와 메서드를 적절히 분리해 

새로운 클래스 두세 개로 쪼개준다.

 

 

 

 

🤔 그럼 어떻게 해야 할까?

💡 만약 클래스가 응집력을 잃는다면 쪼개라!

→ 몇몇 함수가 몇몇 변수만 사용한다면, 독자적인 클래스로 분리해도 되지 않는가? 당연하다.

 

 

 

 

큰 함수를 작은 함수 여럿으로 쪼개다 보면, 
종종 작은 클래스 여럿으로 쪼갤 기회가 생긴다.

그러면서 프로그램에 점점 더 체계가 잡히고, 구조가 투명해진다.

 

 

 

 

 

✔️ 큰 함수를 작은 함수/클래스 여럿으로 쪼개보자

 

[PrintPrimes.java]

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;
            }
        }
    }
}
  • 함수가 하나뿐인 위 프로그램은 엉망진창이다.
    1. 들여쓰기가 심하고
    2. 이상한 변수가 많고
    3. 구조가 빡빡하게 결합되었다.
  • 최소한 여러 함수로 나눠야 마땅하다.

 

 

 

 

위 코드를 작은 함수와 클래스로 나눈 후 

함수와 클래스와 변수에 좀 더 의미 있는 이름을 부여해 보자.

[PrimePrinter.java] - 리팩터링한 버전

public class PrintPrimes {
    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]

import java.io.PrintStream;

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

    public RowColumnPagePrinter(int rowsPerPage, int columnPerPage, String pageHeader) {
        this.rowsPerPage = rowsPerPage;
        this.columnPerPage = columnPerPage;
        this.pageHeader = pageHeader;
        numbersPerPage = rowsPerPage * columnPerPage;
        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 firstIndexOfLastOrwOnPage =
                firstIndexOnPage + rowsPerPage - 1;
        for (int firstIndexInRow = firstIndexOnPage;
                firstIndexInRow <= firstIndexOfLastOrwOnPage;
                firstIndexInRow++) {
            printRow(firstIndexInRow, lastIndexOnPage, data);
            printStream.println("");
        }
    }

    private void printRow(int firstIndexOnRow,
                          int lastIndexOnPage,
                          int[] data) {
        for (int column = 0; column < columnPerPage; column++) {
            int index = firstIndexOnRow + 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]

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

 

 

 

 

 

💡 가장 눈에 띄는 변화는, 프로그램이 길어졌다는 것이다.

그럼 길이가 늘어난 이유가 뭘까?

  1. 리팩터링한 프로그램은 좀 더 길고 서술적인 변수 이름을 사용한다.
  2. 리팩터링한 프로그램은 코드에 주석을 추가하는 수단으로, 함수 선언과 클래스 선언을 활용한다.
  3. 가독성을 높이고자 공백을 추가하고 형식을 맞추었다.

 

 

 

😇 원래 프로그램은 세 가지 책임으로 나눠졌다.

  1. PrimePrinter 클래스
    • main 함수 하나만 포함
    • 실행 환경을 책임짐
    • 호출방식이 달라지면 클래스도 바뀐다.
  2. RowColumnPagePrinter 클래스
    • 숫자 목록을 주어진 행과 열에 맞춰 페이지에 출력하는 방법을 안다.
    • 출력하는 모양새를 바꾸려면 RowColumnPagePrinter 클래스를 고쳐준다.
  3. PrimeGenerator 클래스
    • 소수 목록을 생성하는 방법을 안다.
    • 객체로 인스턴스화하는 클래스가 아니다.
    • 단순히 변수를 선언하고 감추려고 사용하는 공간일 뿐이다.
    • 소수를 계산하는 알고리즘이 바뀐다면, PrimeGenerator 클래스를 고쳐준다.

 

 

 

이것들은 재구현이 아니다!

프로그램을 처음부터 다시 짜지 않았다.

실제로 두 프로그램을 비교해 보면, 알고리즘과 동작 원리가 동일하다.

 

 

 

가장 먼저, 

  1. 원래 프로그램의 정확한 동작을 검증하는 테스트 슈트를 작성했다.
  2. 그런 다음, 한 번에 하나씩 수 차례에 걸쳐 조금씩 코드를 변경했다.
  3. 코드를 변경할 때마다 테스트를 수행해 원래 프로그램과 동일하게 동작하는지 확인했다.
  4. 조금씩 원래 프로그램을 정리한 결과 최종 프로그램이 얻어졌다.

 

 

 

 

 

✔️ 변경하기 쉬운 클래스

 

대다수의 시스템은 지속적인 변경이 가해진다.

그리고 뭔가 변경할 때마다 시스템이 의도대로 동작하지 않을 위험이 따른다.

💡 깨끗한 시스템은 클래스를 체계적으로 정리해 변경에 수반하는 위험을 낮춘다.

 

 

 

객체 지향 설계에서 OCP(Open-Closed Principle)이란 무엇일까?

OCP란, 클래스는 확장에 개방적이고 수정에 폐쇄적이어야 한다는 원칙이다.

예를 들어, 어떤 클래스는 파생 클래스를 생성하는 방식으로, 

새 기능에 개방적인 동시에 다른 클래스를 닫아놓는 방식으로 수정에 폐쇄적인 경우이다.

 

 

 

새 기능을 수정하거나 기존 기능을 변경할 때 건들리 코드가 최소인 시스템 구조가 바람직하다.

💡 이상적인 시스템이라면 새 기능을 추가할 때, 시스템을 확장할 뿐 기존 코드를 변경하지는 않는다.

 

 

 

 

 

✔️ 결합도가 낮다는 것은?

결합도가 낮다는 소리는 
각 시스템 요소가 다른 요소로부터 그리고 변경으로부터 
잘 격리되어 있다는 의미다.

 

 

 

💡 시스템 요소가 서로 잘 격리되어 있으면, 각 요소를 이해하기도 더 쉬워진다.

 

 

 

❗ 이렇게 결합도를 최소로 줄이면, 자연스럽게

또 다른 클래스 설계 원칙인 DIP(Dependency Inversion Principle)를 따르는 클래스가 나온다.

→ 본질적으로 DIP는 클래스가 상세한 구현이 아니라 추상화에 의존해야 한다는 원칙이다.

 

예를 들어, 

상세한 구현 클래스가 아닌, 인터페이스에 의존한다.

그 인터페이스는 어떠한 기능에 대한 추상적인 개념을 표현한다.

→ 이와 같은 추상화로 실제 기능 같은 구체적인 사실을 모두 숨긴다.

 

 

 

 

 

 

 

 

 

 

 

참고: Clean Code 클린 코드 애자일 소프트웨어 장인 정신 / 로버트 C. 마틴 저