Clean Code

[Clean Code] Chapter 10 클래스

성범이라고합니다 2022. 3. 9. 10:23

 

저차원에서 깨끗한 코드를 구현하였다 하더라도 고차원까지 고려하지 않으면 깨끗한 코드를 얻기는 어렵다.

이번 챕터 <클래스>에서는 깨끗한 클래스에 대해 다룬다.


클래스 체계

표준 자바 관례 상에서의 등장 순서

정적 공개 상수(public static)
정적 비공개 변수(private static)
비공개 인스턴스 변수(private instance)

공개 함수
비공개 함수

표준 자바 관례에 따르면, 가장 먼저 변수 목록이 나온다. 정적 공개 상수가 있다면 맨 처음에 나오고, 다음으론 정적 비공개 변수, 비공개 인스턴스 변수가 나온다. 공개 변수가 필요한 경우는 거의 없다.

변수 목록 다음에는 공개 함수가 나온다. 비공개 함수는 자신을 호출하는 공개 함수 직후에 넣는다.

이는 추상화 단계가 순차적으로 내려간다는 것으로 볼 수 있다.

신문 기사처럼 읽힌다.(Chapter 5)

 

캡슐화

변수와 유틸리티 함수는 가능한 공개하지 않는 편이 낫지만(외부로의 노출 최소화) 무조건적인 법칙은 없다.

때로는 protected로 선언해 테스트 코드에 접근을 허용하기도 한다. 우리에게 테스트는 아주 중요하다!(Chapter 8)

테스트에서 사용된다하더라도 그 외의 상황에서는 어떻게든 숨길 방법을 찾는다. 캡슐화를 풀어주는 결정은 최후의 수단이다.

 


클래스는 작아야 한다!

 

클래스를 만들 때 첫 번째 규칙은 크기다. 클래스는 작아야 한다. (Chapter 3 함수)

 

Chapter 3 : 함수 에서도 나왔던 의문처럼 그럼 과연 클래스는 얼마나 작아야 하는가? 라는 의문이 생긴다.

함수는 물리적인 행 수와 논리적인 역할의 수를 기반으로 크기를 책정했다면 클래스는 다른 척도를 사용한다. 클래스가 맡은 책임을 센다.

다음의 예를 확인하면서 이해해보자.

public class SuperDashboard extends JFrame implements MetaDataUser{
	public String getCustomizerLanguagePath()
    public void setSystemConfigPath(String systemConfigPath)
    public String getSystemConfigDocument()
    ...
    ...
	//대략 70개의 공개 메소드
    ...
    ...
    public ideMenuBar getIdeMenuBar()
    public void showHelper(MetaObject metaObject, String propertyName)
    //...많은 비공개 메소드가 이어진다
}

위 목록은 SuperDashboard라는 클래스 - 대략 70개 정도의 공개 메소드와 더 많은 비공개 메소드를 갖고 있다.

이 클래스를 아래와 같이 줄인다면?

 

public class SuperDashboard extends JFrame implements metaDataUser{
	public Component getLastFocusedComponent()
    public void setLastFocused(Component lastFocused)
    public int getMajorVersionNumber()
    public int getMinorVersionNumber()
    public int getBuildeNumber()
}

메소드 다섯개 정도면 괜찮은 규모이지 않은가? 라는 생각을 할 수 있다. 하지만 클래스에서는 아니다.

SuperDashboard는 메소드 수가 작음에도 불구하고 너무 많은 책임을 맡고 있다.

 

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

첫번째 중요 포인트가 바로 클래스 작명이다.

간결한 이름이 떠오르지 않는다면 이는 필경 클래스 크기가 너무 커서 그렇다

필경: 직업으로 글이나 글씨를 쓰는 일, 원지에 철필로 글씨를 쓰는 일; 작명 대상 클래스로 해석된다)

클래스 이름이 모호하다면 필경 클래스가 맡은 책임이 너무 많아서이다.

또한 클래스 설명은 만일(if), 그리고(and), -며(or), 하지만(but)을 사용하지 않고 서 25단어 내외로 가능해야 한다.

 

단일 책임 원칙

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

SRP는 '책임'이라는 개념을 정의하며 적절한 클래스 크기를 제시한다. 클래스는 책임, 즉 변경할 이유가 하나여야 한다는 의미다.

위 예제에서 SuperDashboard 클래스명을 변경해야 할 이유는 두가지다. 

1. 소프트웨어 버전 정보를 추적

2. 자바 스윙 컴포넌트를 관리 - 스윙 코드를 변경할 때마다 버전 번호가 달라진다

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

 

cf) SRP

SRP란 Single Responsibility Principle라는 단일 책임 원칙을 의미하며 말 그대로 단 하나의 책임만을 가져야 한다는 것을 의미한다. 여기서 말하는 책임의 기본 단위는 객체를 의미하며 하나의 객체가 하나의 책임을 가져야 한다는 의미이.

그렇다면 책임이란 무엇인가?
객체 지향에 있어서 책임이란 객체가 할 수 있는 것과 해야 하는 것으로 나뉘어져 있다. 즉 한 마디로 요약하자면 하나의 객체는 자신이 할 수 있는 것과 해야하는 것만 수행할 수 있도록 설계되어야 한다는 법칙이다.

SRP를 지켜야하는 이유는 무엇인가?
이를 고전적 설계개념인 응집도와 결합도 관점에서 바라보자.
응집도란 한 프로그램 요소가 얼마나 뭉쳐있는가를 나타내는 척도이며 결합도는 프로그램 구성 요소들 사이가 얼마나 의존적인지를 나타내는 척도이다.

 

위와 같은 고려사항을 반영하면 위의 예제 코드는 아래와 같이 변경된다.

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

 

SRP는 객체 지향 설계에서 더욱 중요한 개념이다. 또한 이해하고 지키기 수월한 개념이기도 하다. 하지만 클래스 설계자가 (소프트웨어 작동에 포커스를 맞추기에, 단일 책임 클래스가 많아지면 큰 그림을 볼 수 없을거란 우려 때문에) 가장 무시하는 규칙 중 하나이다. 

 

그래도! 큰 클래스 몇 개가 아니라 작은 클래스 여럿으로 이뤄진 시스템이 더 바람직하다.

 


응집도

클래스는 인스턴스 변수 수가 작아야 한다. 각 클래스 메소드는 클래스 인스턴스 변수를 하나 이상 사용해야 한다.

일반적으로 메소드가 변수를 더 많이 사용할수록 메소드와 클래스는 응집도가 더 높다. 모든 인스턴스 변수를 메소드마다 사용하는 클래스는 응집도가 가장 높다.

일반적으로 이처럼 응집도가 가장 높은 클래스는 가능하지도 바람직하지도 않다. 응집도가 높다는 말은 클래스에 속한 메소드와 변수가 서로 의존하며 논리적인 단위로 묶인다는 의미기 때문이다.

 

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

이는 새로운 클래소르 쪼개야 한다는 의미이며, 응집도가 높아지도록 변수와 메소드를 적절히 분리해 새로운 클래스 두세 개로 쪼개준다.

 

 

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

큰 함수를 작은 함수로 나누는 상황에서, 작은 함수로 나눈다하더라도 사용하는 매개변수가 큰 함수 내에 있다면 작은 함수에서도 인자로 받을 수 밖에 없다. 이러한 경우에는 클래스 인스턴스 변수로 승격한다면 새 함수는 인수가 필요없다. 그만큼 함수를 쪼개기 쉬워진다.

 

하지만 이러한 방식을 채택한다면 클래스가 응집력을 잃는다. 소수의 함수가 사용하는 변수도 인스턴스 변수로 선언되어 그 변수가 점점 늘기 때문이다.

 

이러하듯이 몇몇 함수가 몇몇 변수만 사용한다면 독자적인 클래스로 분리하면 된다. 클래스가 응집력을 잃는다면 쪼개는 방식을 택하자. 큰 함수를 작은 함수 여럿으로 쪼개다 보면 종종 작은 클래스 여럿으로 쪼갤 기회가 생긴다. 그러면서 프로그램에 점점 더 체계가 잡히고 구조가 투명해진다.

 

다음 코드를 확인해보자.

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

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

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

import java.util.ArrayList;

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

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

    private static void set2AsFirstPrime(){
        primes[0] = 2;
        multuplesOfPrimeFactors.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)){
            multuplesOfPrimeFactors.add(candidate);
            return false;
        }
        return isNotMultipleOfAnyPreviousPrimeFactor(candidate);
    }

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

    private static boolean isNotMultipleOfAnyPreviousPrimeFactor(int candidate){
        for (int n = 1; n < multuplesOfPrimeFactors.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 = multuplesOfPrimeFactors.get(n);
        while(multiple<candidate)
            multiple += 2 * primes[n];
        multuplesOfPrimeFactors.set(n, multiple);
        return multiple;
    }
}

위와 같이 코드가 변경이 된 후에 가장 눈에 띄는 점은 프로그램이 길어졌다는 점이다.

- 좀 더 길고 서술적이 변수 이름을 사용하고 

- 함수 선언과 클래스 선언으로 코드를 설명하고

- 가동성을 위해 공백을 추가하고 형식을 맞췄다

 

클래스가 분할이 되어 이에 따라 각각의 클래스 책임은 다음과 같이 분리되었다.

- PrimePrinter : main 함수 하나만 포함하며 실행 환경을 책임진다

- RowColumnPagePrinter : 숫자 목록을 주어진 행과 열에 맞춰 페이지에 출력하는 역할을 담당

- PrimeGenerator: 소수 목록을 생성하는 역할을 담당

 

장황하게 늘여놓은 코드와 3개의 클래스로 분할된 코드를 비교하면 둘 다 같은 알고리즘과 동작 원리를 사용한다.

하지만 분할된 코드가 훨씬 명확하고 책임이 분담되어 있다는 점에서 더 좋다는 판단이 가능하다.


변경하기 쉬운 클래스

대다수 시스템은 지속적인 변경이 일어난다. 그리고 코드를 변경할 때마다 프로그램에 문제가 발생할 잠정적 가능성 또한 높아지게 된다.

이에 따라 테스트 또한 전체적으로 다시 해야하는 경우도 발생한다.

 

SQL 예제에서도 알 수 있듯이 하나의 클래스에 너무 많은 책임들이 내포되어 있으면 코드를 수정하게 되었을 때 문제가 발생할 가능성이 매우 농후하다. 하지만 이를 작은 규모의 클래스로 분할하여 리팩토링하여 하나의 클래스가 하나의 책임(SRP)을 맡게 한다면 하나의 클래스나 함수의 수정으로 인하여 다른 클래스 및 함수가 망가질 가능성이 사실상 사라지게 된다.

 

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

 

 

변경으로부터 격리

요구사항이 변경될 가능성이 있기에 코드도 변하기 마련이다.

'변경으로부터 격리' 라는 전략을 위해선 구체적인 클래스와 추상 클래스의 개념을 다시 한번 짚어보아야 한다.

구체적인 클래스는 상세한 구현(코드)을 포함하며 추상 클래스는 개념만 포함한다고 볼 수 있다. 상세한 구현에 의존하는 클라이언트 클래스는 구현이 바뀌면 위험에 빠지게 되는 반면, 인터페이스와 추상 클래스를 사용하면 구현이 미치는 영향을 격리할 수 있다.

 

시스템의 결합도를 낮추면 유연성과 재사용성도 더욱 높아진다. 결합도가 낮다는 소리는 각 시스템 요소가 다른 요소로부터 그리고 변경으로부터 잘 격리되어 있다는 의미이다. 시스템 요소가 서로 잘 격리되어 있으면 각 요소를 이해하기도 더 쉬워진다.

이렇게 결합도를 최소로 줄이는 과정을 밟다보면 자연스럽게 또 다른 클래스 설계 원칙인 DIP(Dependency Inversion Principle)를 따르는 클래스가 나오게 된다. 본질적으로 DIP는 클래스가 상세한 구현이 아닌 추상화에 의존해야 한다는 원칙이다.

 

의존관계 역전 원칙 : DIP(Dependency Inversion Principle)

고차원 모듈은 저차원 모듈에 의존하면 안된다. 추상화된 것은 구체적인 것에 의존하면 안된다. 구체적인 것이 추상화된 것에 의존해야 한다. 자주 변경되는 클래스에 의존하지 말자. 로 요약될 수 있다. 즉, 자신보다 변하기 쉬운 것에 의존하지 말라는 의미이다.

프로그래머는 '추상화에 의존해야지, 구체화에 의존하면 안된다' 라는 전략으로써 의존성 주입은 이 원칙을 따르는 방법 중 하나이다.

따라서, 개방 폐쇄 원칙(OCP)의 해결법과 유사하게 구체적인 class가 아닌, 인터페이스 및 추상 클래스에 의존함으로써 해결이 가능하다.