Skip to main content

Command Palette

Search for a command to run...

Clean Code

Updated
15 min read

우리는 평균적으로 코드를 작성하는 시간보다 코드를 읽고 해석하는데 많은 시간을 소비한다. 하물며, 자기 자신이 짠 소스 코드도 많은 분석시간이 필요하다. 그렇기 때문에 우리는 미래의 나를 믿지 말고 미래의 나까지도 쉽게 이해 시킬 수 있는 깨끗한 코드를 작성해야 한다. 그렇다면 깨끗한 코드는 무엇일까? 바로 "읽기가 쉬운 코드" 이다.

의미 있는 이름

소프트웨어에서 이름은 어디서나 쓰인다. 하지만 주의 깊게 이름을 붙이지 않는다.

우리는 변수에도 이름을 붙이고, 함수에도 이름을 붙이고, 인수와 클래스 그리고 패키지명에도 이름을 붙인다. 소스 파일을 저장하는 디렉토리에도 이름을 붙인다. 여기저기 도처에서 이름 사용한다. 이렇듯 많이 사용하므로 이름을 잘 지으면 여러모로 편하다. 그렇다면 어떻게 짓은 이름이 과연 잘 지은 이름일까?

첫 번째 규칙 : 의도를 분명히 밝혀라.

"의도가 분명한 이름을 지으라". 이름을 지으려면 많은 시간이 걸리지만 좋은 이름으로 절약하는 시간이 훨씬 더 많다. 이름에서 정확한 의도가 파악이 된다면 코드를 읽는 사람은 좀 더 행복해지리라.

그리고 의도를 더 분명히 하고 싶을 때 클래스로 정의하는 것 또한 매우 좋다. 단순히 int kong = 100이라는 정의하는 것 보다, Vegitable kong = new Vegitable(100); 클래스로 정의하는 것이 더 의미를 분명하게 전달할 수 있다.

변수나 함수 그리고 클래스 이름은 아래와 같은 질문에 모두 답해야 한다.

  1. 변수(혹은 함수나 클래스)의 존재 이유는?

  2. 수행 기능은?

  3. 사용 방법은?

// 이름으로 의도가 파악되지 않는 이름
int d; // 경과 시간(단위: 날짜)
// 이름으로 의도가 올바르게 파악되는 이름
int elapsedTimeInDay;
int daysSinceCreation;
int daysSinceModification;
int fileAgeInDays;

다음 예제 코드는 하는 일을 짐작하기 어렵다. 복잡한 문장도 아니고, 복잡한 구조(다형성)도 아니다. 문제는 코드의 단순성이 아니라 코드의 함축성에 있다. 코드 맥락이 코드 자체에 명시적으로 드러나 있지 않다. 위 코드의 저자는 독자가 다음과 같은 정보를 안다라는 가정에 작성했다.

  1. theList에 무엇이 들어 있는가?

  2. theList에서 0번째 값이 중요한 이유는 무엇인가?

  3. 4라는 값이 가지는 의미는?

  4. 함수가 반환하는 list1의 사용 용도는?

public class MeaninglessName {
    private static final int[][] theList = new int[5][5];

    @PostConstruct
    public void init(){
        log.info("After call constructor function, this function Run");
        for (int i = 0; i < theList.length; i++) {
            for (int j = 0; j < theList[i].length; j++) {
                theList[i][j] = ((int) (Math.random() * 5));
            }
        }
    }

    public List<int[]> getThem() {
        List<int[]> list1 = new ArrayList<>();

        for (int[] x : theList) {
            if (x[0] == 4)
                list1.add(x);
        }
        return list1;
    }
}

“지뢰찾기” 게임을 만드는 코드라는 기반 지식이 있다고 가정해보자. 그러면 theList는 게임판이라는 사실을 알 수 있다. theList를 gameBoard로 바꿔보자.

그리고 배열에서 0번째 값은 칸 상태를 의미하고 4는 깃발이 꽂힌 상태를 가리킨다. 그러면 의미를 담고 있는 함축적인 이름으로 바꿔보자.

list1은 깃발이 꽂혀 있는 셀만 담고 있는 배열로 보여진다. 이 또한 의도를 잘 포현하고 있는 이름으로 바꿔보자.

@Component
@Slf4j
public class MeaningfulName {

    private static final int[][] gameBoard = new int[5][5];
    private static final int STATUS_VALUE = 0;
    private static final int FLAGGED = 4;

    @PostConstruct
    public void afterConstructorCallInit(){
        log.info("After Constructor call, this function call");
        for (int i = 0; i < gameBoard.length; i++) {
            for (int j = 0; j < gameBoard[i].length; j++) {
                gameBoard[i][j] = ((int) (Math.random() * 5));
            }
        }
    }

    public List<Cell> getFlaggedCell(){
        List<Cell> flaggedCells = new ArrayList<>();
        for(Cell cell : gameBoard){
            if(cell[STATUS_VALUE] == FLAGGED)
                flaggedCells.add(cell);
                        if(cell.isFlagged())
                flaggedCells.add(cell);
        }
        return flaggedCells;
    }
}

코드의 단순함은 여전한데, 각 개념에 이름만 붙였는데도 불구하고 코드가 상당히 나아졌다.

두 번째 규칙: 그릇된 정보를 피하라.

프로그래머는 코드에 그릇된 단서를 남겨서는 안된다. 그릇된 단서는 코드 의미를 흐린다.

  1. 나름대로 널리 쓰이는 의미가 있는 단어를 다른 의미로 사용해도 안된다. 예를 들어 "여러 계정을 묶을 때", 실제 List형이 아니라면, accountList라고 명명하면 안된다. 프로그래머에게 List라는 단어는 특수한 의미이기 때문이다.

     String accountList = "송혜교, 전지현, 김태희, 고소영";
     for(String account : accountList){
         // 데이터 처리
         ...
     }
    

    그래서 올바른 명명은 "accountGroup", "bunchOfAccounts", "Accounts"정도가 아주 휼륭한 이름이지 않을가 싶다.

     String accountGroup = "송혜교, 전지현, 김태희, 고소영";
     // or String accounts = "송혜교, 전지현, 김태희, 고소영";
     // or String bunchOfAccounts = "송혜교, 전지현, 김태희, 고소영";
     ArrayList<String> accountList = (ArrayList<String>) Arrays.stream(accountGroup.split(",")).toList();
     for(String account : accountList){
         // 데이터 처리
         ...
     }
    
  2. 서로 흡사한 이름을 사용하지 않도록 주의한다. 한 모듈에서 XYZControllerForEfficientHandlingOfString 라는 이름을 사용하고, 조금 떨어진 모듈에서 XYZControllerForEfficientStorageOfString라는 이름을 사용한다면 차이를 알아 챘는가?

     class XYZControllerForEfficientHandlingOfString {
     }
     ...
     class XYZControllerForEfficientStorageOfString {
     }
    
  3. 유사한 개념은 유사한 표기법으로 사용한다. 이것도 정보다. 일관성이 떨어지는 표기법은 그릇된 정보이다. 에디터를 사용하여 코드를 작성한다면, 우리는 자동 완성 기능을 사용한다. 이름을 몇 자만 입력한 후 핫키 조합을 누르면 가능한 후보 목록이 뜬다. 후보 목록에 유사한 개념이 알파벳 순으로 나온다면 그리고 각 개념 차이가 명백히 드러난다면 코드 자동 완성 기능은 굉장히 유용해진다.

  4. 이름으로 그릇된 정보를 제공하지 말자. 끔찍한 예가 소문자 L이나 대문자O 변수다. 소문자 l은 1과 유사하고 대문자 O와 0(zero)는 매우 유사하다.

세 번째 규칙: 의미 있게 구분하라.

동일한 범위 안에서는 다른 두 개념에 같은 이름을 사용하지 못한다. 컴파일러를 통과할지라도 연속된 숫자를 덧붙이거나 불용어를 추가하는 방식은 적절하지 못하다. class라는 변수를 이미 사용하고 있어서 klass를 사용한다. 이러한 이름은 아무런 정보도 제공하지 못하기 때문에 코드를 읽는 사람에게 도움이 되지 못한다. 이름이 달라야 한다면 의미도 달라져야 한다.

예를 들어, Customer라는 클래스와 CustomerObject라는 클래스를 발견했다면 차이를 알 수 있는가? getActiveAccount( ); getActiveAccounts( ); getActiveAccountInfo( ); 이 세 개의 함수 차이를 알 수 있는가?

new Customer("Jinho");
new CustomerObject("Jinho");

getActiveAccount();
getActiveAccounts();
getActiveAccountInfo();

읽는 사람이 차이를 알도록 이름을 지어라.

네 번째 규칙: 발음하기 쉬운 이름을 사용하라.

소스를 읽을 때, 읽기가 쉽지 않고 줄임말로 인해서 우스깡스러운 표현을 하게 된다. 우리들은 단어에 능숙하다. 우리 두뇌에서 상당 부분은 단어라는 개념만 전적으로 처리한다. 그리고 정의상으로 단어는 발음이 가능하다. 말을 처리하려고 발달한 두뇌를 활용하지 않는다면 안타까운 손해 때문이다. 그러므로 발음하기 쉬운 이름을 선택한다.

// 발음하기 어려운 명명 규칙을 사용한 소스
// 또한 약어로 인해 "그릇된 정보"를 줄 수 있다.
class DtaRcrd102 {
    private Date genymdhms;
    private Date modymdhms;
    private final String pszqint = "102";
}
// 발음하기 쉬운 명명 규칙을 사용한 소스
class Customer {
    private Date generationTimestamp;
    private Date modificationTimestamp;
    private final String recordId = "02"
}

다섯 번째 규칙: 검색하기 쉬운 이름을 사용하라.

문자 하나를 사용하는 이름과 상수는 텍스트 코드에서 쉽게 눈에 띄지 않는다는 문제점이 있다. MAX_CLASSES_PER_STUDENT는 grep으로 찾기가 쉽지만, 숫자 7은 은근히 까다롭고 검색 결과가 많이 나오기 때문에 찾기가 힘들다.

이름 길이는 범위(Scope) 크기에 비례해야한다. 변수나 상수를 코드 여러 곳에서 사용한다면 검색하기 쉬운 이름이 바람직하다.

여섯 번째 규칙: 인코딩을 피하라

명명 규칙에 데이터 타입을 인코딩할 필요는 없다. 이름 안에 데이터타입이 인코딩되면 의미가 혼탁해 질 수 있다.

String phoneNumberString = "010-1234-1234";
// 미래에 String -> PhoneNumber로 타입이 변경되도, 변수명은 바뀌지 않는다!!!
PhoneNumber phoneNumberString;

때로는 인코딩이 필요할 때 가 있다. 인터페이스 클래스와 구현 클래스의 경우, 인터페이스 클래스는 접두어를 붙이지 않고, 구체 클래스에 접두어를 붙이는 것이 좀 더 보기 좋다. ShapeFactory 인터페이스 클래스와 구현체인 ShapeFactoryImp가 보기 좋다.

클래스 이름

클래스 이름과 객체 이름은 명사나 명사구가 적합하다.

메서드 이름

동사나 동사구가 적합하다. 접근자, 변경자, 조건자는 javabean 표준에 따라 값 앞에 set, get, is 접두사를 붙인다.

일곱 번째 규칙: 한 개념에 한 단어를 사용하라.

추상적인 개념 하나에 단어 하나를 선택해 이를 고수한다. 예를 들어, 유사한 행동을 하는 메서드를 클래스마다 fetch, retrieve, get으로 제각각 부르면 혼란스럽다.

일관성 있는 어휘를 선택해서 이름을 붙이자.

// 일관성 없는 어휘 선택은 혼란을 줄 수 있음.
userService.getUserNameAndPasswrod();
boardService.fetchHotBoardList();
boardService.retrieveBookmakr();

// 일관성 있는 어휘를 선택하자.
userService.getUserNameAndPasswrod();
boardService.getHotBoardList();
boardService.getBookmakr();

과거에는 서비스 계층 별 어휘를 나눠서 결정한 케이스도 있다. Controller 계층에서는 fetch로 어휘를 통일했고 Service 계층에서는 get으로 어휘를 통일해서 계층 별로도 접두사로 사용되는 어휘를 구분하는 케이스도 있었다. Logging 정보를 좀 더 효과적으로 구분하고자 하여 효율적인 디버깅을 하고자 했던 것으로 기억이 든다.

여덟 번째 규칙: 의미 있는 맥락을 추가하라.

스스로 의미가 분명한 이름이 없지 않다. 하지만 대다수 이름은 그렇지 못하다. 그래서 클래스, 함수, 이름 공간에 의미를 넣어 맥락을 부여한다. 의미 전달이 어렵다고 판단될 경우, 접두어를 붙어서 의미를 전달한다.

// 변수를 모두 훑어봐야 주소와 관련된 데이터임을 알 수 있다.
String firstName;
String lastName;
String zipCode;
String city;
String state;

// 맥락을 좀 더 분명하게 하기 위해서 addr라는 접두어 붙이면 
// 단번에 알 수 있다.
String addrFirstName;
String addrLastName;
String zipCode;
String addrCity;
String addrState;

아홉 번째 규칙: 불필요한 맥락을 없애라.

일반적으로 짧은 이름이 긴 이름보다 좋다. 단, 의미가 분명한 경우에 한해서다. 이름에 불필요한 맥락을 추가하지 않도록 주의한다.

NHN KCP의Data를 처리하는 Application을 만든다고 가정해보자. 그래서 우리는 모든 클래스의 명칭에 KCPData라는 접두어를 붙이는 것은 매우 바람직하지 못하다. 접두어가 의미가 있다면 붙이는 것이 바람직하겠지만, 그렇지 않기 때문에 접두어를 붙이는 건 매우 협오적인 행동이다.

변수명은 짧으면 짧을 수록 좋다. 단, 의미가 잘 전달된다는 조건이다. 만약, 의미가 제대로 전달되지 않는 짧은 변수명이라면 과감하게 의미를 명확하게 전달할 수 있는 변수명으로 바꿔라. 변수명이 길어지더라도 말이다.

boolean isExisted;
boolean isExistTempDirectory

함수; The Small is Best!!

  1. 작게 만들어라!

    함수를 만드는 첫째 규칙은 "작게!"다. 함수를 만드는 둘째 규칙은 "더 작게"다.

    1.1 블록과 들여쓰기

    if 문/else 문/ whie 문 등에 들어가는 블록은 한 줄이어야 한다.

    • 한 가지만 해라!

      함수는 한 가지를 해야한다. 그 한 가지를 잘 해야 한다. 그렇다면 그 '한 가지가' 무엇인지 알기가 어렵다. 지정된 함수 이름 아래에서 추상화 수준이 하나인 단계만 수행한다면 그 함수는 한 가지 작업만 한다.

1.2 함수 내 섹션

한 함수에서 섹션을 나눌 때, 자연스럽게 나눠진다면 한 함수에서 여러 작업을 한다는 증거다. 한 가지 작업만 하는 함수는 자연스럽게 섹션으로 나누기 어렵다.

  • 함수 당 추상화 수준은 하나로!

    추상화 수준을 섞으면 코드를 읽는 사람이 헷갈린다. 특정 표현이 근본 개념인지 아니면 세부사항인지 구분하기 어려운 탓이다.

1.3 위에서 아래로 코드 읽기: 내려가기 규칙

코드는 위에서 아래로 이야기처럼 읽혀야 좋다. 한 함수 다음에는 추상화 수준이 한 단계씩 낮은 함수가 온다. 즉, 위에서 아래로 프로그램을 읽으면 함수 추상화 수준이 한번에 한 단계씩 낮아진다.

예를 들어, "DB Table의 데이터를 읽는다." → "DB의 Connection 맺고 객체를 반환한다." → "Connection 객체를 이용해서 SQL문을 실행시켜 데이터를 가져온다."

  1. Switch 문

    switch문은 작게 만들기 힘들다. 본질적으로 switch문은 N가지를 처리한다. 각 switch문을 저차원 클래스에 숨기고 절대로 반복하지 않는 방법이 있다. 물론 다형성을 이용한다.

    직원 유형에 따라 다른 값을 계산해 반환하는 함수다.

     public Money calculatePay(Employee e)
         throws InvalidEmployeeType {
             swtich(e.type) {
                 case COMMISSONED:
                     return calculateCommissionedPay(e);
                 case HOURLY:
                     return calculateHourlyPay(e);
                 case SALARIED:
                     return calculateSalariedPay(e);
                 default :
                     throw new InvalidEmployeeType(e.type);
         }
     }
    

    위 함수는 여러가지 문제를 가지고 있다.

    첫번째, 함수가 길다.

    두번째, '한 가지' 작업만 수행하지 않는다.

    세번째, SRP(Single Responsibility Principle)를 위반한다. 단순히 급여계산만 하는게 아니라, 1)급여의 형태를 선택2)지급해야 할 급여를 계산하고 있다.

    네번째는 OCP(Open Close Principle)를 위반한다. 새 직원 유형을 추가할 때 마다 코드를 변경해야하기 때문이다.

    이 문제를 해결할 수 있는 방법은 switch문을 추상 팩토리에 숨기는 것이다. 팩토리는 switch문을 사용해 적절한 Employee 파생 클래스의 인스턴스를 생성한다.

    calculatePay, isPayday, deliverPay 등과 같은 함수는 Employee 인터페이스를 거쳐 호출된다. 그러면 다형성으로 인해 실제 파생 클래스의 함수가 실행된다.

     public abstract class Employee {
         public abstract boolean isPayday();
         public abstract Money calculatePay();
         public abstract void deliverPay();
     }
    
     public interface EmployeeFactory {
         public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
     }
    
     @Configure
     public class EmployeeFactoryImpl implements EmployeeFactory {
         public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
             switch(r.type){
                 case COMMISSION:
                     return new ComissionedEmployee(r);
                 case HOURLY:
                     return new HourlyEmployee(r);
                 case SALARIED:
                     return new SalariedEmployee(r);
             }
         }
     }
    
     @Service
     public class EmployService{
         private EmployeeFactoryImpl employeeFactory;
    
         public Money getTotalAmount(EmployeeRecord r){
             Employee e = employeeFactory.makeEmployee(r);
             if(e.isPayday()){
                 return e.calculatePay();
             else
                 return Money.ZERO;
         }
    
  2. 서술적인 이름을 사용해라

    “서술하다”의 사전적 의미

    <aside> 💡 어떤 사실, 사건, 생각 등을 논리나 순서에 따라 말하거나 적다.

    </aside>

    코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 깨끗한 코드이다. 그렇기 때문에 이름이 길다고 너무 걱정하지 말아라. 길고 서술적인 이름이 짧고 어려운 이름 보다 낫다.

  3. 함수 인수 타입

    1. Output(or Result) 타입

      우리는 함수에서 인수를 Input 값으로 인식한다. 그래서 함수는 Input 값을 사용해서 정의한 행동을 수행한다.

       appendFooter(s)
      

      위 예제 appendFoote 함수의 인수는 s는 Input일까? 함수의 Output(결과 값)일까? 이 부분에서 우리는 코드를 보다가 주춤하게 된다. 그러면 우리는 선언부를 찾으러 갈 수 밖에 없다. 여기서는 StringBuffer s에 바닥글을 이어서 붙이는 함수이다.

      객체지향 세계에서는 출력 인수를 사용하지 않는다. 왜냐하면 우리 세계에는 this라는 키워드가 있기 때문이다. 그렇기 때문에 appendFooter(s); 보다는 report.appendFooter();가 더 보기 좋은 코드이다.

      일반적으로 출력 인수는 피해야 한다. 함수에서 상태를 변경해야 한다면 함수가 속한 객체 상태를 변경하는 방식을 택하는 것이 좋다.

  4. 부수 효과를 일으키지 마라!

  5. 명령과 조회를 분리해라

    함수는 “어떤 행동을 수행”하거나 “질문에 답”하거나 둘 중 하나만 해야지 둘 다 하면 안된다. 객체 상태를 변경하는 행동을 하거나 객체 정보를 반환하거나 둘 중 하나만 해야 한다.

    아래 예제는 내가 현재 진행 중인 프로젝트 예제 코드이다. HDFS(데이터 저장소)에 특정한 Directory의 존재 여부를 확인하고 없다면 디렉토리를 생성하는 코드이다.

     @Test
     public void getListDirectoryTest() {
         String directoryPath = "jinho/x";
           try {
                 boolean isExisted = Optional.ofNullable(webHdfsService.getListDirectory(directoryPath).execute().body())
                     .isPresent();
                     log.info("[RESULT] isExisted ::: {}", isExisted);
                     if(!isExisted){
                         Optional.ofNullable(webHdfsService.makeNewDirectory(directoryPath).execute().body())
                             .orElse(new HashMap<String, Boolean>())
                             .getOrDefault("boolean", false);
             }
                     RespFileStatuses dirList = webHdfsService.getListDirectory(directoryPath).execute().body();
                     log.info("[RESULT] GetListDirectory ::: {}", dirList);
    
         } catch (IOException e) {
                 log.error(e.getMessage());
         }
     }
    

    현재 이 함수는 조회와 명령을 같이하고 있다. 이것을 명령과 조회로 분리하면 어떤 효과가 있는지 한번 코드로 보자.

    기존 코드랑 비교 했을 때 어떤 코드가 더 좋은가? 아마도 아래 코드가 더 쉽게 읽을 수 있을 것이다. 우리의 목표는 쉽게 읽을 수 있고 쉽게 예측 할 수 있는 코드를 작성하는 것이다.

     @SpringBootTest
     @Slf4j
     public class HdfsServiceTest {
    
         @Autowired
         private WebHdfsService webHdfsService;
    
         @Autowired
         private HdfsService hdfsService;
    
         @Test
         public void getListDirectoryTest() {
             String directoryPath = "jinho";
             try {
                 if(!hdfsService.isExistedDirectory(directoryPath))
                     hdfsService.makeDirectory(directoryPath);
                 RespFileStatuses dirList = hdfsService.getListDirectory(directoryPath);
                 log.info("[RESULT] GetListDirectory ::: {}", dirList);
    
             } catch (IOException e) {
                 log.error(e.getMessage());
             }
         }
     }
    

    명령과 조회를 구분해서 모듈화 시켰다. 함수가 하나의 일만 하니, 아주 짧고! 읽기 편하고! 예측이 가능하다! 얼마나 좋은가? 너무 행복하다.

     @Service
     @RequiredArgsConstructor
     public class HdfsService {
         private final WebHdfsService webHdfsService;
    
         public boolean isExistedDirectory(String toPath) throws IOException {
             Optional<RespFileStatuses> response = Optional.ofNullable(webHdfsService.getListDirectory(toPath).execute().body());
             return response.isPresent();
         }
    
         public boolean makeDirectory(String toPath) throws IOException {
             return Optional.ofNullable(webHdfsService.makeNewDirectory(toPath).execute().body())
                 .orElse(new HashMap<String, Boolean>())
                 .getOrDefault("boolean", false);
         }
    
         public RespFileStatuses getListDirectory(String toPath) throws IOException {
             return webHdfsService.getListDirectory(toPath).execute().body();
         }
     }
    
  6. 오류 코드 보다 예외를 사용하라!

    명령 함수에서 오류 코드를 반환하는 방식은 명령/조회 분리 규칙을 미묘하게 위반한다. 그리고 만약 호출한 함수가 오류 코드를 반환 바로 처리해야하는 문제가 생긴다. 비즈니스 로직과 예외 처리하는 로직이 함께 뒤섞이게 된다.

     if(deleteMember(memberDto) == E_OK){
         if(userRespository.reRegister(memberDto) == E_OK){
             // 비즈니스 로직 실행
             if()
         }else {
             // Error 처리
         }
     }else {
      // Error 처리
     }
    

    또한, ErrorCode라는 Enum 클래스를 정의하고 이 코드를 기반으로 분기를 처리하게 되면 오류 코드가 추가되면 코드 수정이 불가피하다.

     package com.wanted.preonboarding.clean.code.functions;
    
     public enum ErrorCode {
         INVALID_CODE(400, "C001", "Invalid Code"),
         RESOURCE_NOT_FOUND(204, "C002", "Resource not found"),
         EXPIRED_CODE(400, "C003", "Expired Code");
    
         private int status;
         private String code;
         private String message;
    
         ErrorCode(int status, String code, String message) {
             this.status = status;
             this.message = message;
             this.code = code;
         }
     }
    

    그렇기 때문에 오류코드 기반보다는 Try-Catch문으로 예외처리를 하는 것이 좀 더 현명하다.

     try {
             deleteMember(memberDto);
             userRespository.reRegister(memberDto);
             String action = "delete";
             userRespository.saveHistory(action, memberDto);
     } catch(IOException | NullPointExcepiton e){
         throw new Exception(e.getMessage());
     }
    
  7. 결론

    처음부터 완성작을 작성하는 소설가는 세상 어디에도 없을 것이다. 초본을 작성하고 초본을 토대로 구조와 기틀을 세우고 고쳐 나간다. 그렇게 완성본이 탄생한다.

    코드도 마찬가지다. 처음에는 이름도 의미가 전혀 없는 이름으로 선언하고 함수는 예외 처리 없이 단순히 실행만 되고 있는 상태이다. 우리가 코딩을 하면서 항상 첫 번째로 세우는 목표는 “실행이 되는 코드를 작성하자” 이다. 첫 번째 목표를 달성하면 두 번째 목표인 단위 테스트 모두 통과하는 코드로 코드 리팩토링을 하는 것이다. 이 시점에는 코드에서 깨끗하고 재미있는 소설처럼 술술 잃히는 코드가 되어 있을 것이다.

    그러니, 너무 처음부터 완벽한 코드를 작성하려고 하지 말아라. 그 누구도 처음부터 깨끗한 코드를 작성하는 것은 어려운 일이니 말이다.

형식 맞추기

신입이든 경력직이든, 개발자라면 입사하면 무조건 하는 일들 중 가장 중요한게 하나 있다. 내가 속한 개발팀의 문화를 배우는 것 또는 흡수하는 것이다.

사람들은 모두가 다르다. 예를 들면, 문을 마실 때 마시는 컵을 드는 방법은 사람 마다 모두 다르다. 어떤 이는 손잡이를 잡는 사람이 있을 수도 있고 잡지 않는 사람이 있을 수도 있고 우리가 생각하지 못하는 방법으로 물컵을 잡는 사람들이 있을 것이다. 여기서 이야기하고 싶은 모두의 라이프를 다르고 다양하다 라는 것을 이야기하고 싶다.

코드도 마찬가지다. 모두가 다 다르게 코드를 작성한다. 누구는 카멜 케이스로 또 누구는 스네이크 케이스로 변수명과 함수명을 작성하고 있을 수 있다.

좋은 코드는 10명이 참여한 프로젝트의 코드의 master 브랜치를 봤을 때 “질서 정연하다”라고 느끼는 코드다. 이 말의 의미는 10명이 작성하였지만, 1명이 혼자 작성한 것 처럼 형식에 일관성이 있다는 의미이다.

그래서 만약 팀 내부에 정리되어 있는 코드 스타일 규칙이 없다면, 새로운 모듈을 추가하거나 기존 코드를 고쳐야 할 때 기존에 작성되어 있는 코드를 읽어보고 어떤 스타일로 코드를 작성하고 있는지 스윽 보고 습득하자.

객체와 자료 구조

객체란 - private 형식의 변수와 함수가 존재하는 클래스. 자료 구조란 - public 형식의 변수만 가지고 있고 함수가 없는 클래스.

우리는 클래스를 정의할 때, 항상 인스턴스 변수의 접근 제한자는 항상 private으로 선언한다. 당신은 왜? 어떤 이유로 인스턴스 변수의 접근 제한자를 이렇게 선언하는가?

변수를 private(비공개) 접근 제한자로 선언하는 이유는 남들이 변수에 의존하지 않게 만들고 싶어서다. 클래스 개발자가 변덕이든 충동이든, 변수 타입이나 구현을 마음대로 바꾸고 싶어서다.

그런데 여기서 웃긴 점이 있다. 이렇게 열심히 private 접근 제한자로 클래스의 내부 상태(인스턴스 변수)를 꽁꽁 숨겨놓았는데 우리는 자연스럽게 Getter와 Setter 함수를 public하게 선언하여 외부에 노출한다. 과연 이것이 맞는걸까?

OOP스러운 클래스는 추상 인터페이스를 제공해 클래스 사용자(Class client)가 구현을 모른 채 자료의 핵심을 조작할 수 있어야 진정한 의미의 클래스다.

  1. 자료(Data Type, Has Only Variables) / 객체(Instance, Members + Method)의 비대칭

    객체(Object)는 추상화 뒤로 자료를 숨긴 채 자료를 다루는 함수만 공개한다.

    자료 구조(Structure)는 자료를 그대로 공개하며 별다른 함수는 제공하지 않는다. 자료 구조를 다루는 함수는 외부에 존재한다.

     public class Square {
         public Point topLeft;
         public double side;
     }
    
     public class Rectangle {
         public Point topLeft;
         public double height;
         public double width;
     }
    
     public class Circle {
         public Point center;
         public double radius;
     }
    
     public class Geometry {
         public final double PI = 3.141592653589793;
    
         public double area(Obeject shape) throw NoSuchShapeException
         {
             if(shape instanceof Square) {
                 Square s = (Square)shape;
                 return s.side * s.side;
             }
             else if(shape instanceof Rectangle) {
                 Rectangle r = (Rectangle)shape;
                 return r.height * r.width;
             }
             else if(shape instanceof Circle) {
                 Circle c = (Circle)shape;
                 return c.radius * c.radius;
             }
             throw NoSuchShapeException()'
         }
     }
    

    첫번째 예제 코드는 절차지향 기반의 코드이다. 세 도형을 정의한 클래스가 있다. 도형 클래스는 간단한 자료구조이다. 이 도형을 다루는 책임은 Geometry라는 클래스가 가진다. 그래서 만약, 도형들의 둘레를 구하는 함수(perimeter)를 추가하고 싶다면 Geometry 클래스에 함수만 추가하면 된다. 도형 클래스(자료구조)는 아무런 영향을 받지 않는다. 도형 클래스에 의존하는 다른 클래스도 마찬가지다.

    하지만 새 도형을 추가하고 싶다면 Geometry 클래스에 속한 함수를 모두 고쳐야 한다.( 여기서 "모두 고쳐야 한다"의 의미는 아마도 area 메소드 안에 있는 If문 분기에 하나의 분기를 더 만드는 것으로 해석된다.)

    자료 구조 형태의 코드는 기능 추가는 매우 쉽지만, 종류의 추가는 변경의 전파가 발생할 수 있다는 장/단점을 가지고 있다.

    이번에는 객체지향적인 코드를 예로 들어보자.

     public class Square implements Shape {
         private Point topLeft;
         private double side;
    
         public double area(){
             return side * side;
         };
     }
    
     public class Rectangle implements Shape {
         private Point topLeft;
         private double width;
         private double height;
    
         public double area(){
             return width * height;
         };
     }
    

    여기서 area()는 shape 인터페이스에서 상속하는 개념으로 다형(Polymorphic) 메서드다. Geometry 클래스는 필요 없다. 그러므로 새 도형을 추가해도 기존 함수에 아무런 영향을 미치지 않는다. 반면 새 함수(기능)를 추가하고 싶다면 도형 클래스 전부를 고쳐야 한다.

    객체 형태의 코드는 종류의 추가는 매우 쉽지만, 기능의 추가는 변경의 전파가 발생할 수 있다는 장/단점을 가지고 있다.

    이렇게 객체와 자료 구조는 근본적으로 양분된다. 객체지향 코드에서 어려운 변경은 절차적인 코드에서 쉬우며, 절차적인 코드에서 어려운 변경은 객체 지향 코드에서 쉽다.

    그래서 결론은, 항상 객체지향이 옳다 라기 보다는 그때 그때 적합한 방식을 적용해서 개발하는 것이 올바르다.

  2. 디미터 법칙; 모듈은 자신이 조작하는 객체의 내부구현을 몰라야 한다.

    자료 구조의 케이스는 이 법칙이 적용되지 않는다. 모든 상태 값이 public으로 외부에 노출되기 때문이다.

    객체는 상태를 숨기고 함수를 공개한다. 상태를 숨겨야 하기 때문에 조회 함수로 내부 구조를 공개하면 안된다라는 의미이다.

    객체에게 적절한 책임을 할당하고 그 책임을 수행하기 위해서 취해야하는 행동을 적절한 추상화 수준으로 정의하게 되면 모듈에서 해당 함수는 자신이 몰라야 하는 여러 객체를 탐색할 필요가 없다.

    사전과제1의 Theater 예제 코드를 한번 살펴보자. enter 함수에서 Audience 객체의 내부 상태(Bag)에 직접 접근하고 있다. enter 함수는 Audience의 내부 구현까지 알 필요가 없었음에도 불구하고 내부 구현을 노출하는 문제로 알게 되었고, 이로 인해 Bag 객체에 변경이 발생하면 Theater 객체까지 변경의 전파가 일어난다.

    이렇게 변경의 전파가 커지게 된 이유는 무의미한 Getter/Setter 함수 때문이다.

     @Component
     @RequiredArgsConstructor
     public class Theater {
    
         public void enter(Audience audience, TicketSeller ticketSeller){
             if(audience.getBag().hasInvitation()){
                 Ticket ticket = ticketSeller.getTicketOffice().getTicket();
                 audience.getBag().setTicket(ticket);
             }else {
                 Ticket ticket = ticketSeller.getTicketOffice().getTicket();
                 audience.getBag().minusAmount(ticket.getFee());
                 ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
                 audience.getBag().setTicket(ticket);
             }
         }
     }
    
  3. 자료 전달 객체

    자료 구조체의 전형적인 형태는 공개 변수(public variables)만 있고 함수(비즈니스 로직의 일부를 처리하는 함수를 지칭.)가 없는 클래스다. 이런 자료 구조체를 때로는 자료 전달 객체(Data Transfer Object, DTO) 라고 한다. DTO는 굉장히 유용한 구조체다.

  4. 결론

객체는 동작(Action)을 공개하고 자료를 숨긴다. 그래서 기존 동작을 변경하지 않으면서 새 객체 타입을 추가하기는 쉬운 반면, 기존 객체에 새 동작을 추가하기는 어렵다.

자료 구조는 별다른 동작 없이 자료를 노출한다. 그래서 기존 자료 구조에 새 동작을 추가하기는 쉬우나, 기존 함수에 새 자료 구조를 추가하기는 어렵다.

위에서 본 예제 처럼, 무조건 객체지향이 정답은 아니다. 그렇기 때문에개발자는 편견 없이 이 사실을 이해해 직면한 문제에 최적인 해결책을 선택할 수 있는 능력을 키우는 것이 중요하다.

More from this blog

어려운 Next15

Next15가 출시한 뒤 프로젝트에 도입하며 이게 과연 프레임워크로써 가치가 있는지 혹은 아니면 사용 할 수 밖에 없는 계륵같은 존재인가? 라는 생각을 많이 하였습니다.보안에 좋지만 사용하기는 매우 어려웠는데 프로젝트에 도입하며 발생 했던 문제점 혹은 사용하기 어려웠던 점을 정리하려고 합니다. 1. 서버 next서버 에서 백엔드로 api를 호출 하는 방법에는 3 가지가 있습니다. (제가 알고 있는 방식에는) 첫 번째로 서버 액션 두 번째로 서버...

Mar 2, 20256 min read

Kotlin & springboot

Spring java to Kotlin 코틀린이란? 코틀린( Kotlin )은 2011년 7월 JetBrains사가 공개한 JVM에서 동작하는 프로그래밍 언어로서, 간결하고 실용적이며 자바코드와의 상호운용성( interoperability )를 중시한 언어이다. 주요 특성 코틀린의 주목적은 현재 자바가 사용되고 있는 대부분의 곳에 변환이 가능하며 간결하고, NullSafe하게 코드 작성을 가능하게 만들어 준다. 자바 에서 코틀린으로 변경...

Feb 16, 20256 min read

[글또] 알고리즘 연습 사이트

3년 차 개발자의 알고리즘 도전기 어느덧 시간이 흘러 백엔드 개발자로 3년 차를 맞이하게 되었습니다. 처음 개발 공부를 시작할 때는 '연차가 쌓이면 알고리즘 문제 정도는 쉽게 해결하겠지?'라는 막연한 기대를 했지만, 실제로 알고리즘 문제를 접하면 여전히 쉽게 손이 가지 않고 겁부터 나는 것이 사실입니다. 최근 참여 중인 개발자 글쓰기 커뮤니티 글또에서 코드트리(CodeTree)와 함께하는 알고리즘 학습 이벤트를 발견했습니다. 프로그래머스와 Le...

Feb 2, 20253 min read

프론트 찍먹해보기 (모노레포)

Monorepo - 하나의 Git 저장소 에서 여러개의 프로젝트를 관리하는 방식을 모노레포라고 한다. - 모노레포는 프론트, 백 모두 사용가능하다. 프로젝트 구성의 발전 모놀리식 애플리케이션 모놀리식 애플리케이션은 모듈화 없이 모든 구성 요소가 한 프로젝트 안에 통합된 소프트웨어 애플리케이션을 이야기합니다. DB 커넥션을 맺고, 데이터를 요청하며, 화면을 그리는 로직이 한 프로젝트 안에 구현된 초기 웹 서비스를 모놀리식 애플리션으로 볼 수 있...

Jan 5, 20255 min read

val's log

10 posts