본문 바로가기
Back-End/Book

[Clean Code] 5장. 형식 맞추기

by 7533ymh 2022. 9. 25.

프로그래머라면 형식을 깔끔하게 맞춰 코드를 짜야 한다. 팀으로 일한다면 팀이 합의해 규칙을 정하고 모두가 그 규칙을 따라야 한다.

개발자란 결국 함께 하는 직업이다. 만약 형식을 정해놓지 않고 각자 개발을 한다면?? 코드의 통일성이 있지 않아 해석하기 어렵고 각기 다른 형식으로 개발되다보니 유지보수도 어려울 것이다.
그렇기 때문에 형식은 매우 중요한 것 같다. 결국 코드도 의사소통이니 말이다.
그래서 프로젝트를 진행하기 전 코드 컨벤션을 미리 정하고 진행해보았는 데 코드해석도 한결 편해지고 커뮤니케이션 비용이 많이 줄게 되었다. 코드 컨벤션 대부분을 네이버 핵데이 컨벤션을 사용하게 되었는 데 해당 장의 내용의 거의 대부분이 녹아져 있는 것 같아서 신기했다.
좀 더 자세히 알아보자!

형식을 맞추는 목적

코드 형식은 너무너무 중요하다!!!! 결국 코드 형식은 의사소통의 일환이며 개발자의 일차적인 의무다.
돌아가는 코드를 일차적인 의무로 두지 말자. 구현한 기능은 다음 버전에 바뀔 확률은 매우 높기 때문이다.
하지만 코드의 가독성은?? 앞으로 바꾸리 코드의 품질에 대해 지대한 영향을 미치며 오랜 시간이 지나도 처음 잡아놓은 구현 스타일과 가독성 수준은 유지보수성과 확장성에 계속 영향을 미친다.
즉, 원래 코드는 사라지더라도 개발자의 스타일과 규율은 사라지지 않는다는 것이다.

해당 단락을 읽으며 박재성님의 영상이 떠올랐다.
돌아가는 코드에만 몰두하고 핑계를 대며 나쁜 코드를 짜기 시작하면 결국 팀 생산성이 떨어지고 최악의 경우 기존의 똥덩어리(레거시)에 새로운 똥덩어리(재설계) 하나가 더 추가되면서 GG를 치게 된다는 것이다.
그러니 돌아가는 코드에 집중하지 말고 처음부터 언제나 코드를 깨끗하게 유지하는 습관을 들여 깨끗한 코드를 만들 수 있도록 노력해야 한다.

적절한 행 길이를 유지하라

소스 코드는 얼마나 길어야 적당할까? 자바에서 파일 크기는 클래스 크기와 밀접하다.
우리가 자주 사용하는 JUnit의 경우 파일 크기가 작으며 500줄이 넘어가는 파일이 없다. 대다수가 200줄 미만인 것이다. 이 말은 대부분 200줄 정도인 파일로도 커다란 시스템을 구축할 수 있다는 사실이다.
일반적으로 큰 파일보다 작은 파일이 이해하기 쉬우니 말이다.
그렇게 하기 위해선?
함수파트에서 배운 것처럼 클래스도 작게 쪼개는 것이 중요하다!!!

신문 기사처럼 작성하라

좋은 신문 기사는 위에서 아래로 읽을 수록 점점 세세한 사실이 드러난다. 상단에는 세세한 사실은 숨기고 커다란 그림을 보여주며 쭉 읽으며 내려가면 세세한 사실이 점점 드러나게 된다.

우리 소스코드도 비슷하게 작성되야 한다. 계속 강조하듯이 이름을 간단하면서도 설명이 가능하게 지어야 한다. 그렇게 되면 이름만으로 세부사항은 몰라도 올바른 모듈을 살펴보고 있는지 아닌지를 판단할 수 있게 된다. 소스 파일 첫 부분은 고차원 개념과 알고리즘을 설명하고 아래로 내려갈 수 록 의도를 세세하게 묘사한다. 마지막에는 가장 저차원 함수와 세부 내역이 나오게 된다. 즉, 고수준 -> 저수준으로 함수를 위치를 조정하는 것도 중요하다!

개념은 빈 행으로 분리하라

거의 모든 코드는 왼쪽에서 오른쪽으로 그리고 위에서 아래로 읽힌다.

  • 각 행은 수식이나 절을 나타냄
  • 일련의 행 묶음은 완결된 생각 하나를 표현
    생각 사이에는 빈 행을 넣어 분리해야 한다.

예를 들어 패키지 선언부, import문, 각 함수 사이에도 빈 행이 들어가게 된다. 빈 행은 새로운 개념을 시작한다는 시각적 단서다.

package calculator.utils;  

import java.util.ArrayList;  
import java.util.Arrays;  
import java.util.List;  
import java.util.Stack;  
import java.util.regex.Pattern;  
import java.util.stream.Collectors;  

import calculator.domain.OperatorType;  

public class PostfixParser {  
    private static final String ERROR_INPUT_FORM = "[ERROR] 정상적인 입력이 아닙니다. 다시 입력해주세요.";  
    private static final Pattern INPUT_FORM = Pattern.compile("-?\\d+(.\\d+)?( [+-/*] -?\\d+(.\\d+)?)*");  
    private static final String DELIMITER = " ";  

    private PostfixParser() {  
    }  
    public static List<String> parse(String expression) {  
        validateExpression(expression);  

        List<String> splitExpression = split(expression);  
        return parsePostfix(splitExpression);  
    }  

    private static List<String> parsePostfix(List<String> splitExpression) {  
        Stack<String> stack = new Stack<>();  
        List<String> result = new ArrayList<>();  

        for (String value : splitExpression) {  
            if (ValidatorString.isNumber(value)) {  
                result.add(value);  
                continue;  
            }  

            if (ValidatorString.isOperator(value)) {  
                while (hasValueInStack(stack) && isPopOperator(stack.peek(), value)) {  
                    result.add(stack.pop());  
                }  
                stack.add(value);  
            }  
        }  

        while (hasValueInStack(stack)) {  
            result.add(stack.pop());  
        }  
        return result;  
    }

위 코드를 빈 행을 빼버린다면 아래와 같이 가독성이 현저하게 떨어지는 코드가 되어버린다.

package calculator.utils;  
import java.util.ArrayList;  
import java.util.Arrays;  
import java.util.List;  
import java.util.Stack;  
import java.util.regex.Pattern;  
import java.util.stream.Collectors;  
import calculator.domain.OperatorType;  
public class PostfixParser {  
    private static final String ERROR_INPUT_FORM = "[ERROR] 정상적인 입력이 아닙니다. 다시 입력해주세요.";  
    private static final Pattern INPUT_FORM = Pattern.compile("-?\\d+(.\\d+)?( [+-/*] -?\\d+(.\\d+)?)*");  
    private static final String DELIMITER = " ";  
    private PostfixParser() {  
    }  
    public static List<String> parse(String expression) {  
        validateExpression(expression);  
        List<String> splitExpression = split(expression);  
        return parsePostfix(splitExpression);  
    }  
    private static List<String> parsePostfix(List<String> splitExpression) {  
        Stack<String> stack = new Stack<>();  
        List<String> result = new ArrayList<>();  
        for (String value : splitExpression) {  
            if (ValidatorString.isNumber(value)) {  
                result.add(value);  
                continue;  
            }  
            if (ValidatorString.isOperator(value)) {  
                while (hasValueInStack(stack) && isPopOperator(stack.peek(), value)) {  
                    result.add(stack.pop());  
                }  
                stack.add(value);  
            }  
        }  
        while (hasValueInStack(stack)) {  
            result.add(stack.pop());  
        }  
        return result;  
    }

세로 밀집도

줄바꿈이 개념을 분리한다면 세로 밀집도는 연관성을 의미한다. 즉, 세로 밀집한 코드 행은 세로로 가까이 놓여야 한다는 뜻이다.
의미 없는 주석 등으로 연관있는 코드를 띄어놓지 말고 한눈에 볼 수 있도록 하자.

수직 거리

서로 밀접한 개념은 세로로 가까이 둬야 한다. 더 나아가 서로 밀접한 개념은 한 파일에 속해야 마땅하다.
앞써 말했듯이 밀접한 두 개념은 세로 거리로 연관성을 표현한다. 여기서 연관성이란 한 개념을 이해하는 데 다른 개념이 중요한 정도다.
연관성이 깊은 두 개념을 멀리 떨어뜨리게 되면 여기저기를 뒤지면서 코드를 해석해야 한다.

변수 선언
변수는 사용하는 위치에 최대한 가까이 선언한다. 앞써 배웠으니 우리의 함수는 매우 짧을 것이다. 그러니 지역 변수는 각 함수 맨 처음에 선언해주자.

private static void readPreferences() {
    InputStream is = new FileInputStream(getPreferenceFile());
    try (is) {
        ...
    } catch (IOExpcetion e)
    ...
}

책의 예제를 try-with-resources로 리팩토링해보았다.
try에서 선언된 객체들에 대해서 try가 종료될 때 자동으로 자원을 해제해주는 기능으로써 객체가 AutoCloseable을 구현하고 있어야 하며 close 메서드를 통해 해제가 된다. > 자바7부터 지원했으며 자바9부터는 자원 할당을 밖에서 하고 try문에서 사용할 수 있다.
대신 final이거나 effectively final 이여야 한다. 만약 불변이 아니라면 열린 자원으로 변경되어 close시에 문제가 발생할 수 있기 때문이 아닐까 예상해본다. (좀 더 알아봐야 겠다.)

루프를 제어하는 변수는 흔히 루프 문 내부에 선언하자.

public int countTestCases() {
    int count = 0;
    **for(Test each : tests)** {
        count += each.countTestCases();
    }
    return count;
}

드물지만 다소 긴 함수에서 블록 상단이나 루프 직전에 변수를 선언하는 경우도 있다.

인스턴스 변수
클래스 맨 처음에 선언하며 변수 간에 세로로 거리를 두지 않는다. 잘 설계한 클래스는 클래스의 많은 메서드가 인스턴스 변수를 사용하기 때문이다.
인스턴스 변수를 선언하는 위치는 논쟁이 분분하지만 중요한 것은 잘 알려진 위치에 모아놔야 한다는 점이다. 변수 선언을 어디서 찾을 지 모두가 알고 있어야 한다.

종속 함수
한 함수가 다른 함수를 호출한다면 두 함수는 세로로 가까이 배치한다. 또한 가능하다면 호출하는 함수를 호출되는 함수보다 먼저 배치한다. 그러면 프로그램이 자연스럽게 읽힌다.
호출되는 함수를 찾기가 쉬워지며, 그만큼 모듈 전체의 가독성도 높아진다.

  • public과 private는 어떤 식으로 구분해야할까? 메서드 추출을 통해 private 메서드가 나올 경우 public 바로 아래 두어야 할까? 아니면 public 끼리 뭉쳐놓고 private 끼리 뭉쳐놓는 게 좋을까?
  • 주로 나는 public끼리, private 끼리 뭉쳐서 사용하는 데 어떤 것이 좋을 지 생각해봐야겠다.
public Response makeResponse(...) {
    String pageName = getPageNameOrDefault(request, "FrontPage");
    ...
}

private String getPageNameOrDefault(Request request, String defaultPageName) {
    ....
}

위의 코드는 상수를 적절한 수준에 두는 좋은 예라고 한다. 만약 orDefault 함수 안에 "FrontPage" 상수를 사용한다면 잘 알려진 상수가 적절하지 않은 저차원 함수에 묻히게 된다.
상수를 알아야 마땅한 함수에서 실제로 사용하는 함수로 상수를 넘겨주는 방법이 더 좋다.
즉, makeResponse 로직을 위해선 "FrontPage"의 내용을 알아야 하지만 상수로 사용해버리면 함수에 묻혀 바로 알아차리지 못하게 된다.

개념적 유사성

친화도가 높을수록 코드를 가까이 배치한다.
친화도가 높은 요인으로는 다음과 같다.

  • 한 함수가 다른 함수를 호출해 생기는 직접적인 종속성
  • 변수와 그 변수를 사용하는 함수
  • 비슷한 동작을 수행하는 일군의 함수

마지막 비슷한 동작을 수행하는 일군의 함수는 무엇일까? 아래 코드를 보자

public class Assert {
    static public void assertTrue(String message, boolean condition) {
    if(!condition)
        fail(message);
    }

    static public void assertTrue(boolean condition) {
        assertTrue(null, condition);
    }

    static public void assertFalse(String message, boolean condition) {
    assertTrue(message, !condition);
    }

    ...
}

해당 코드는 개념적인 친화도가 높다고 볼 수 있다. 명명법이 똑같고 기본 기능이 유사하며 간다하기 때문이다. (서로가 서로를 호출하는 관계는 부차적인 요인이다.)
그렇기에 종속적인 관계가 없더라도 가까이 배치할 함수들이다.

세로 순서

일반적으로 함수 호출 종속성은 아래 방향으로 유지한다. 즉, 호출되는 함수를 호출하는 함수보다 나중에 배치한다. 그렇게 위치함으로써 소스 코드 모듈이 고차원에서 저차원으로 자연스럽게 내려가게 된다.
세세한 사항까지 파고들 필요없이 첫 함수 몇 개만 읽어도 개념을 파악하기 쉬워진다.

가로 형식 맞추기

한 행은 가로로 얼마나 길어야 적당할까? 예전에는 모니터가 작으니 오른쪽으로 스크롤할 필요가 없도록 코드를 짰다고 한다.
하지만 요즘엔 모니터가 크다보니 100~120자 정도로 제한하는 것을 권장하고 있다.
구글 자바 컨벤션이나 네이버 핵데이 컨벤션도 120자로 제한하고 있다.

가로 공백과 밀집도

가로로는 공백을 사용해 밀접한 개념과 느슨한 개념을 표현한다.

private void measureLine(String line) {
    lineCount++;
    int lineSize = line.length();
    totalChars += lineSize;
    lineWidthHistogram.addLine(lineSize, lineCount);
    recordWidestLine(lineSize);
}

할당 연산자를 강조하려고 앞뒤에 공백을 줌으로써 두 가지 주요 요소가 확실히 나뉘게 된다.
반면, 함수 이름과 이어지는 괄호 사이에는 공백을 넣지 않았는 데 이는 함수와 인수는 서로 밀접하기 때문이다. 공백을 넣게 되면 한 개념이 아닌 별개로 보이게 된다.
함수를 호출하는 콛에서 괄호 안 인수는 공백으로 분리함으로써 쉼표를 강조해 인수가 별개라는 사실을 보여주게 된다.
b*b - 4*a*c 와 같이 연산자 우선순위를 강조하기 위해서도 공백을 사용한다. 하지만 IDE와 같은 도구는 대다수가 우선순위를 고려하지 못해 수식에 똑같은 간격을 적용하게 되어 공백이 없어지는 경우가 흔하다.

가로 정렬

선어문과 할당문을 정렬할 경우 코드가 엉뚱한 부분을 강조해 진짜 의도가 가려지게 된다.
그러니 정렬을 하지 말아야 하며 정렬하지 않으면 중대한 결함을 찾기 쉽다.
정렬이 필요할 정도로 목록이 길다는 것은 클래스를 쪼개야 한다는 신호이다.
이는 스프링에서 생성자 주입을 권장하는 이유와 동일 선상에 있다고 생각한다.

들여쓰기

범위로 이뤄진 계층을 표현하기 위해 우리는 코드를 들여쓴다. 들여쓰기 정도는 계층에서 코드가 자리잡은 수준에 비례한다.
우리들은 이런 들여쓰기 체계에 크게 의존한다. 왼쪽으로 코드를 맞춰 코드가 속하는 범위를 시각적으로 표현함으로써 범위 간에 재빨리 이동하기 쉬워진다. 예를 들어 현재 상황과 무관한 if문이나 while문 코드를 일일이 볼 필요가 없어진다.
들여쓰기가 없다면 코드를 읽기란 불가능할 것이다.
그래서 객체 지향 생활 체조 원칙에서는 한 메서드에 오직 한 단계의 들여쓰기만 한다.라는 원칙이 있다.

들여쓰기 무시하기

// AS-IS
public String render() throws Exception {return "";}

// TO-BE
public String render() throws Exception {
    return "";
}

팀 규칙

프로그래머라면 각자 선호하는 규칙이 있지만 팀에 속하다면 선호해야 할 규칙은 바로 팀 규칙이다.
팀은 한 가지 규칙을 합의해야 하며 모든 팀원은 그 규칙을 따라야 한다. 그래야 소프트웨어가 일관적인 스타일을 보인다. 마치 따로국밥처럼 맘대로 짜는 코드는 피해야 한다.

좋은 소프트웨어 시스템은 읽기 쉬운 문서로 이뤄진다는 사실을 기억하자. 스타일은 일관적이고 매끄러워야 한다. 온갖 스타일을 뒤섞어 소스 코드를 필요 이상으로 복잡하게 만드는 실수는 반드시 피하자.
코드 자체가 최고의 구현 표준 문서가 되게 하자.

결론

요즘엔 IDE를 사용하면서 손쉽게 형식을 맞출 수 있게 되었다.
그렇기에 많이들 쓰이깐 나도 따라 써야지 라고만 생각하고 왜 그렇게 설정되어 있는지는 몰랐었다.
이번 장을 읽으며 컨벤션들이 왜 그렇게 설정되어 있는지에 대해 알 수 있었다.
특히, 공백에 대해서 조금 더 신경써야 한다는 점을 느끼게 되었다. 메서드 내에서도 공백을 통해 밀집도, 응집도를 표현하게 되면 그대로 추출도 해줄 수 있을 것 같다.
앞으로는 공백도 의미있게 쓰도록 노력해야겠다!!

Reference

댓글