Java

Refactoring은 극단적으로 해볼수록 더욱 성장한다 ! (feat. TDD)

eunmiee 2024. 5. 14. 21:57

시작하며


 

안녕하세요!

오랜만에 회고록이 아닌 공부기록 포스팅이네요😊

 

제가 최근에 책 하나를 읽기 시작했는데요!

바로 포비님이 저서이신 자바 웹 프로그래밍 Next Step이라는 책입니다 !

 

하나씩 벗겨가는 양파껍질 학습법 !!

 

사실 사둔지 좀 됐는데 너무 읽고 싶었지만 취업 준비로 인해 미루고 미루다 이제서야 보게 되었습니다..

 

드디어 두근 거리는 마음으로 첫장을 펴게 되었는데 주제부터 TDD와 refactoring과 관련된 내용이라니 !

그동안 TDD라는 말도 많이 듣고 우테코 프리코스 과정에서도 해봤지만 정말 어려운 주제였거든요..

 

 

이번 챕터를 공부하면서 새롭게 느끼고 배운 점에 대해서 하나씩 정리해보도록 하겠습니다!

 

그럼 가볼까요?!

 


 

 

2장에서는 문자열 계산기를 구현해보며 테스트와 리펙토링에 대한 내용을 학습할 수 있었습니다.

전반적으로 이론적인 학습 주제는 두가지로 구성되어 있었습니다.

 

1. Main() 메소드의 문제점

2. JUnit을 사용하는 이유

 

위 내용에 대해 궁금하다면 깃허브를 통해 정리를 해두었으니 아래 링크를 참고해주세요 !

https://github.com/jum0624/java-web-programming-next-step/tree/main/CalculatorEx

 

java-web-programming-next-step/CalculatorEx at main · jum0624/java-web-programming-next-step

🧅 하나씩 벗겨가는 양파껍질 학습법. Contribute to jum0624/java-web-programming-next-step development by creating an account on GitHub.

github.com

 
여기에서는 위 내용을 학습한 뒤, 진행되는 문자열 계산기 실습을 진행하며 고민했던 과정들과 느낀점들에 대해 포스팅 하도록 하겠습니다.

 

 

테스트에 집중하며 설계해보자!


처음에 이 내용을 봤을 때는 무슨 소리인지 정확히 감이 오지 않았습니다.

그렇게 두가지 고민이 시작되었습니다.

기능을 어떻게 테스트를 할지에 대해 집중해보며 설계하라는 것인가?
구현해야할 비즈니스 로직에 대한 테스트에 집중해야하는 것일까?

 

이러한 고민을 시작으로 우선적으로 설계를 진행했습니다.

우선 무슨 기능을 구현해야할까..

 

아직 감이 잘 오지 않았던 저는 무작정 구현해야할 기능 목록들을 정리해나가기 시작했습니다.

이후 구현해야할 목록들을 기준으로 역할과 책임을 분리해보며 객체를 더 작은 단위로 쪼개나가려고 했습니다.

 

하지만, 주제가 리펙토링과 테스트인만큼 객체를 분리할수록 어떻게 테스트를 진행해야할지 더 감이 오지 않았습니다.

이렇게 되면 객체단위로 테스트를 진행해야하는걸까?

 

 

그래서 객체를 분리하기 보단 하나의 클래스에서 기능을 작은 단위로 나눠보며 쪼개보자! 라는 생각으로 다시 설계를 진행해나갔습니다.

다시 구성한 설계도

우선적으로, 구현해야할 기능과 예외처리해야할 부분들을 체크해나가며 설계를 진행했습니다.

이후 설계를 바탕으로 바로 코드로써 구현을 해나가기 시작했습니다.

 

여기서부터 저의 고민들이 연속이 시작이되었습니다.

 

테스트는 어디부터 시작해야 하는가?


구현을 시작하기 무섭게 아무래도 TDD니까 테스트 코드를 먼저 구현해야겠지? 라는 생각이 먼저 들었습니다.

이후, 테스트 코드를 짜기위해 class를 생성했지만 여기서부터 또 다른 고민이 들기 시작했습니다.

 

그럼 이제 어디서부터 테스트 해야하지?

입력 먼저 구현해야하니까 입력에 대한 테스트를 만들어야하나?

그렇다면 로직 전체가 맞는지 확인하기 위해 비즈니스 로직을 먼저 테스트 코드로 만든 뒤, 메인 코드로 옮겨야하는 걸까?

아니면 단순 기능 테스트라고 생각하고, 덧셈이라는 기능만 테스트 하는 것이 맞는걸까?

 

당시 고민의 흔적들..

 

아무래도 모든 비즈니스 로직에 대한 메소드까지 테스트를 진행하다보면 불필요한 public 선언이 많아질 것 같아 테스트를 여기까지 이렇게 진행하는게 맞을지 많은 고민을 했습니다.

 

이러한 고민이 시작되면서 간단한 문자열 계산기 구현이 오랜 시간이 걸리게 되었습니다..

하지만 이 과정 또한 저에게 새로운 배움이 될 것이기에 일단 비즈니스 로직부터 테스트를 해보며 기능 테스트까지 모두 테스트 코드를 만들며 첫번째 문자열 계산기를 완성했습니다 !

 

 

리펙토링을 해보자 !


완성 후 바로 해설 코드로 끝나는 것이 아닌 "리펙토링" 단계가 시작 되었습니다.

 

책에서는 다음 요구사항에 맞춰 리펙토링을 진행하도록 했습니다.

1. 메소드가 한 가지 책임만 가지도록 구현한다.
2. 인덴트(indent, 들여쓰기) 깊이를 1단계로 유지한다.
3. else를 사용하지 마라.

 

그렇게 다시 리펙토링 과정을 진행했고, 어느정도 기능을 메소드 단위로 쪼개두었기 때문에 단순히 코드를 더 깔끔하게 정리하여 마무리를 진행했었습니다.

 

지금 코드를 다시 보니 아쉬운 부분들도 많이 보이고 부끄럽지만 당시 코드를 공유하도록 하겠습니다.

public class StringCalculator {
    private String text;
    private String delimeter;

    public StringCalculator() {
        this.text = null;
        this.delimeter = ":|,";
    }

    public String getText() {
        return text;
    }

    public void setText(String text) {
        this.text = text;
    }

    public String getDelimeter() {
        return delimeter;
    }

    public void setDelimeter(String delimeter) {
        this.delimeter = delimeter;
    }
}

 

우선적으로, 구분자의 경우 default로는 ":" 또는 "," 로 진행했기 때문에 초기화 과정에서 진행을 해주었습니다.

조금 아쉬웠던 점은 text까지 멤버 변수로 빼놓게 되어 add 메소드에서 시작시 setText()라는 작업을 해주어야하는 점에서 깔끔한 코드는 아니라는 생각이 들었습니다.

 

    private void customSplit(String text) {
        Matcher matcher = Pattern.compile("//(.)\n(.*)").matcher(text);
        if (matcher.find()) {
            setDelimeter(matcher.group(1));
            setText(matcher.group(2));
        }
    }

    public int add(String text) {
        setText(text);
        if (isEmptyText(text)) return 0;  // 유효성 검증으로 입력값 변환 x
        customSplit(text); // text, Delimeter 변동 가능(파라미터 text를 사용하게 되면 값 변환 가능성 있음)
        String[] tokens = getText().split(getDelimeter());
        return validationNumber(tokens);
    }

 

또한, 값을 this를 통해 호출하지 않고, getter와 setter 메소드를 추가하여 호출해주었습니다.

직접적으로 this를 통해 변경하기 보단, getter와 setter를 사용한 방식이 가독성 측면이나 값을 보호하는 측면에서 더 좋을 것이라고 판단했지만 오히려 코드가 더 복잡해 보이는 것 같은 느낌도 있었기 때문에 이 부분보단 다른 부분에 더 집중했으면 좋았겠다는 생각이 들기도 했습니다.

 

    private int validationNumber(String[] tokens) {
        try {
            return Arrays.stream(tokens)
                    .mapToInt(this::stringToInt)
                    .sum();
        } catch (NumberFormatException e) {
            throw new InputException("다시 입력해주세요.");
        }
    }

    private int stringToInt(String token) {
        int number = Integer.parseInt(token);
        if (number < 0) {
            throw new InputException("Negative number detected: " + number);
        }
        return number;
    }

 

마지막으로 숫자에 대한 유효성 검사 메소드 부분인데요.

해당 부분은 다시 생각해봐도 좋지 않은 코드네요..

유효성 검사 메소드라고 했지만, 지금 여기에서는 유효성 검사, 정수로의 변환, 합구하기까지..

하나의 메소드에 너무 많은 책임 있었거든요.

단순히 가독성을 위해 stream을 적용했지만, 오히려 이부분 때문에 너무 많은 책임을 갖게 해버린 느낌이 많았죠..

 

문자열 계산기를 힌트 없이 완성을 하긴 했지만, 아직은 제가보기엔 부족함이 너무 보였고 그렇다고 어떻게 수정을 해야할지 감이 오지 않았기에 답답함이 커져만 갔습니다.

 

이렇게 저의 답답함은 포비님의 유튜브 강의를 통해 부족했던 부분과 TDD와 리펙토링은 어떤식으로 개발하는 건지 감을 잡을 수 있었습니다. 이 과정에서 저의 TDD에 대한 오해와 개발 방향에 대해 알아갈 수 있었습니다.

 

 

TDD.. 이렇게 하는거였네..


우선 강의를 모두 들은 저는 그동안 알고있던 TDD에 대한 오해와 방향성에 대한 감을 잡을 수 있었습니다.

 

깨달은 바는 다음과 같았습니다.

[TDD 로 개발하는 방식에 대한 오해]
- 테스트 코드의 경우, 기능 단위로 테스트를 진행한다.
   - 굳이 너무 작은단위로 테스트 하지 않아도 된다! (불필요한 테스트는 하지 않아도 된다)
   - 만약 자바 기능에 대해서 잘 모르는 부분이 있거나 해당 로직에 대해 확실성이 부족한 경우 새로운 클래스를 생성하여 테스트를 진행한다.
- TDD로 개발 시, 하나의 기능을 구현한 뒤 테스트가 성공했더라도 바로 다음 요구사항을 개발하지 말라!
   - 해당 기능을 구현후 현재 코드에서 리펙토링할 부분은 없는지, 코드의 복잡도나 가독성 측면에서 고민하며 필요한 경우 리펙토링을 진행 후 다음 요구사항에 맞춰 개발을 진행해야 한다.
- 만약 자바 기능에 대해서 잘 모르는 부분이 있거나 해당 로직에 대해 확실성이 부족한 경우 새로운 클래스를 생성하여 테스를  진행한다.

 

정리를 해보자면, "테스트를 해야한다!"는 강박이 아닌 기본적으로 기능상 요구조건에 맞는 테스트는 단위테스트로 구현 후, 리펙토링 과정이나 구현과정에서 내가 궁금하거나 확실하지 않은 부분만 테스트 하자!

 

그동안 테스트라는 단어에 너무 부담감을 갖고 무겁게 테스트 코드를 작성해왔던 제 자신을 돌아볼 수 있었고, 추가적으로 테스트도 기능단위나 테스트 단위로 클래스를 분리해줘야한다는 것을 깨달을 수 있었습니다.

 

리펙토링 전(왼쪽)과 후(오른쪽)

 

리펙토링 전 테스트 코드에 해당하는 패키지 구조입니다..

전혀 분리가 되어있지 않고, StringCalculateorTest라는 클래스 안에 덧셈 기능 뿐만 아니라, input, split 등 다양한 테스트 코드들도 하나의 클래스에서 관리하고 있던 것이죠..

 

어떻게 보면, 이런 부분들도 신경쓰는게 당연했는데 테스트 코드에 집중하다보니 테스트 코드의 클래스 분리는 신경쓰지 못했다는 점에서 약간 반성하게 되었던 부분도 있었습니다😅

 

그렇게 시작된 리펙토링 재도전기! (극단적으로 리펙토링 해보기)


강의를 본 뒤, 다시 기존 코드를 리펙토링 해나가기 시작했습니다.

public class StringCalculator {
    private String delimeter;

    private final String basicDelimeter = ":|,";
    private final String customRegex = "//(.)\n(.*)";

    public StringCalculator() {
        this.delimeter = basicDelimeter;
    }

    public int add(String text) {
        if (isEmptyText(text)) return 0;
        return sum(stringToInt(split(getDelimeter(text))));
    }
    
    ...
    
 }

 

첫번째로는 메소드의 순서에도 집중을 해보았습니다.

메인 메소드는 add이기 때문에 해당 메소드를 최상단으로 시작하여 부가적인 메소드들은 아래에 구현해두었습니다.

 

두번째로는, add 메소드에서 어떤 기능을 하는지 메소드명을 명확하게 표기하려고 노력했습니다.

뿐만 아니라, 해당 메소드에서 쪼갤 수 있는 단위를 생각해보며 메소드를 분리해나갈 수 있었습니다.

 

리펙토링 과정을 거친 add 메소드를 보면 다음의 과정을 한눈에 확인할  수 있습니다.

1. 해당 함수에서 입력한 text가 빈값인지 체크
2. 문자열을 구분할 구분자를 가져온다.
3. 해당 구분자로 문자열을 쪼갠다.
4. 쪼갠 문자열을 int형으로 변환한다.
5. 덧셈 연산을 진행한다.

 

확실히 전 코드보다 훨씬 가독성이 좋아진 것을 확인할 수 있었습니다.

private final String basicDelimeter = ":|,";
private final String customRegex = "//(.)\n(.*)";

...

private String getDelimeter(String text) {
        Matcher matcher = Pattern.compile(customRegex).matcher(text);
        if (matcher.find()) {
            this.delimeter = matcher.group(1);
            text = matcher.group(2);
        }
        return text;
    }
    
    ...

 

구분자를 가져오는 코드 또한 customRegex 부분을 final로 선언하여 따로 값을 선언해주었습니다.

이렇게 되면 custom과 관련된 정규식을 수정하게 되더라도 굳이 해당 코드를 찾지 않아도 바로 유지보수에 편리하고, 가독성 측면에서도 더 깔끔하게 표현할 수 있었습니다.

 

 private int sum(int[] numbers) {
        int sum = 0;
        for (int i = 0; i < numbers.length; i++) {
            sum += toPositive(numbers[i]);
        }
        return sum;
    }


    private int toPositive(int number) {
        if (number < 0) {
            throw new RuntimeException("0이상의 숫자를 입력하세요.");
        }
        return number;
    }

    private int[] stringToInt(String[] tokens) {
        int[] numbers = new int[tokens.length];
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = Integer.parseInt(tokens[i]);
        }
        return numbers;
    }

 

이전에는 validationNumber라는 메소드 하나에 있던 기능들을 모두 쪼갠 뒤, 구현을 진행했습니다.

훨씬 깔끔하고, 가독성 또한 좋아진 것을 확인할 수 있었습니다.

무엇보다도, add() 메소드에서 어떤 일을 하고 있는지 확실하게 나타났기 때문에 메소드가 늘어나 이전보다 코드의 길이가 길어지더라도 더 나아진 코드로 보였습니다.

 

포비님께서 말씀하시길, 리펙토링 과정에서 극단적으로 진행해볼수록 더욱 성장한다고 하셨기 때문에 "이정도로 쪼개도 되나?" 싶을 정도로 최대한 해보시는 것을 추천드립니다 !

작고 간단한 기능이더라도 극단적으로 리펙터링 하자!!

극단적으로 리펙터링 하는 경험이 쌓일수록 이후, 수정해야할 코드가 더 잘 보이게 되는 훈련이 가능!
어디를 리펙토링해야할지 감이 잘 오지 않는다면 뎁스를 기준으로 1이 넘어간다면 리펙토링 하는 방향으로 접근해보는 것도 좋다.
// testCode
public class StringToIntTest {
    @Test
    public void stringToIntTest() throws Exception {
        // given
        String s = ",";

        // when

        // then
        assertThrows(RuntimeException.class, () -> Integer.parseInt(s));
    }
}

...

// StringCalculator.java
private int[] stringToInt(String[] tokens) {
        int[] numbers = new int[tokens.length];
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = Integer.parseInt(tokens[i]);
        }
        return numbers;
    }

 

또한, StringToInt의 경우 처음에는 따로 try-catch문을 사용하여 Runtime Exception을 발생시켰었는데요.

테스트를 진행해본 결과, Integer.parseInt()에서 해당 예외를 발생시키는 것을 확인할 수 있었습니다.

이후, 해당 코드를 삭제하여 불필요한 코드 요소를 없앴습니다.

 

완성된 문자열 계산기


 

이렇게 해서 완성된 코드는 다음과 같습니다 !

package org.example;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class StringCalculator {
    private String delimeter;

    private final String basicDelimeter = ":|,";
    private final String customRegex = "//(.)\n(.*)";

    public StringCalculator() {
        this.delimeter = basicDelimeter;
    }

    public int add(String text) {
        if (isEmptyText(text)) return 0;
        return sum(stringToInt(split(getDelimeter(text))));
    }

    private int sum(int[] numbers) {
        int sum = 0;
        for (int i = 0; i < numbers.length; i++) {
            sum += toPositive(numbers[i]);
        }
        return sum;
    }


    private int toPositive(int number) {
        if (number < 0) {
            throw new RuntimeException("0이상의 숫자를 입력하세요.");
        }
        return number;
    }

    private int[] stringToInt(String[] tokens) {
        int[] numbers = new int[tokens.length];
        for (int i = 0; i < numbers.length; i++) {
            numbers[i] = Integer.parseInt(tokens[i]);
        }
        return numbers;
    }

    private String[] split(String text) {
        return text.split(delimeter);
    }

    private String getDelimeter(String text) {
        Matcher matcher = Pattern.compile(customRegex).matcher(text);
        if (matcher.find()) {
            this.delimeter = matcher.group(1);
            text = matcher.group(2);
        }
        return text;
    }

    private boolean isEmptyText(String text) {
        return text == null || text.isBlank();
    }
}

 

 

마무리


 

이렇게 오늘은 문자열 계산기를 구현해보며 TDD와 refactoring을 진행했던 과정들을 포스팅 해보았습니다.

 

이 과정을 하면서 저는 그동안 알고 있던 TDD와 리펙토링 방식에 대한 오해를 풀어나갈 수 있었고,

방향성 또한 찾아나갈 수 있었습니다!

간단한 기능이지만, 이렇게 많은 고민을 하면서 더 좋은 코드로써 개발해나가보니 많은 성취감과 뿌듯함이 크네요 :)

 

혹시 코드를 보시면서 개선할 수 있는 방안이나 좋은 방법이 있다면 코드리뷰를 해주셔도 감사할 것 같습니다 😊

 

이렇게 오늘의 포스팅은 여기까지 입니다 !

 

오늘도 긴 글 읽어주셔서 감사합니다.

'Java' 카테고리의 다른 글

[Java] OOP - 메세징(메세지 전송)이란?  (1) 2023.11.03
[Java] Java Code Convention  (1) 2023.10.21