의식적인 연습으로 TDD, 리팩토링 연습하기

이석환·2023년 11월 4일
0

우아한 테크 코스

목록 보기
1/2
post-thumbnail

본 게시물은 박재성님의 '[우아한테크세미나] 190425 TDD 리팩토링 by 자바지기 박재성'를 보고 작성한 글입니다.
조금 더 자세한 내용이 궁금하신 분은 [우아한테크세미나] 190425 TDD 리팩토링 by 자바지기 박재성 에서 확인하시면 될 것 같습니다.

Move fast and break things
Done is better than perfect
Fail fast

무언가를 성공하기 위해서는 빠르게 실패를 반복해 봐야 한다.
개발 또한 마찬가지일까 ?
테스트를 여러 번 시도하면서 실패를 반복하면 더 좋은 코드를 쓸 수 있는지 궁금해졌다.

🤔 내가 TDD와 리팩토링를 공부하는 이유

2주차 미션을 끝낸 후, 나는 3주차 미션에서 새롭게 학습하고 싶었던 것이 TDD였다.
MVC 패턴도 제대로 모르면서 TDD를 ?
맞는 말이다.
하지만 저번 주 미션을 진행하고 클린 코드 책을 보면서 궁금증이 생겼다.
도대체 왜 좋은 코드를 도출하기 위해서 테스트 코드 작성 -> 프로덕션 코드 작성 -> 리팩토링인가 ?

난 이런 경우에 몸으로 직접 부딪혀 보는 스타일이라 3주차 로또 미션에서는 TDD를 적용하고자 한다.
그래서 관련된 내용을 찾아보기 시작했다.
그리고 마침 유튜브에 포비님의 TDD,리팩토링 강의가 있어서 들어보게 되었다.
이번 글은 듣고난 후 간단한 요약과 느낀점을 기술하고자 한다.

의식적인 연습

끊임없는 연습이 필요하다.
무조건 연습을 많이 한다고 잘 할수 있나 ?
매일 이를 닦는다고 이를 닦는 실력이 늘지 않는다.
즉, 무조건 많은 연습을 한다고 역랑이 늘지 않는다.

좀 더 효과적으로 연습하는 방법

아마추어와 프로의 결정적 차이

목적 의식 있는 연습에 얼마나 많은 시간을 투자했는가

의식적인 연습을 위해

  • 컴포트 존을 벗어난 지점에서 진행, 자신의 현재 능력을 살짝 넘어가는 작업을 지속적으로 시도
  • 명확하고 구체적인 목표
  • 피드백
  • 기존에 습득한 기술의 특정 부분을 집중적으로 갯너함으로써 발전시키고, 수정하는 과정을 수반

TDD 리팩토링 적용 - 개인

TDD와 리팩토링은 운동과 같다.
평생동안 연습하겠다는 마음가짐으로 시작해야 한다.

1단계 - 단위 테스트 연습

  • TDD를 바로 시작하기 어렵다
  • 단위 테스트를 먼저 익히기
  • 내가 익숙하게 사용하는 API 사용법을 익히자 → 테스트를 통해

연습 효과

  • 단위 테스트 방법 학습
  • 단위테스트 도구(xUnit)의 사용법을 익힐 수 있다.
  • 사용하는 API에 대한 학습 효과가 있다.

Input과 Output이 명확한 클래스 메소드 (보통 Util 성격의 메소드)에 대한 단위 테스트를 먼저 연습

2단계 - TDD 연습

난이도가 낮거나 자신에게 익숙한 문제로 시작하는 것이 좋다
TDD 연습이 목적이기 때문에

TDD Circle of life

Test Fails → Test Passes → Refactor
가장 중요한건 Refactor인데 사람들이 안 하는 경우가 많다.
리팩토링에 집중해서 많은 시간을 할애하자

요구사항

문자열 덧셈 계산기 요구사항
쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환

입력(Input)출력(Output)
null 또는 ""0
"1"1
"1,2"3
"1,2:3"6

Test Code 작성

public class StringCalculatorTest {
    @Test
	  public void null_또는_반값() {
		    assertThat(StringCalculator.splitAndSum(null)).isEqualTo(0);
				assertThat(StringCalculator.splitAndSum("")).isEqualTo(0);
    }

		@Test
	  public void 값_하나() {
		    assertThat(StringCalculator.splitAndSum("1")).isEqualTo(1);
		}
		

	  @Test
	  public void 쉼표_구분자() {
		    assertThat(StringCalculator.splitAndSum("1,2")).isEqualTo(3);
		}	

		@Test
	  public void 쉼표_콜론_구분자() {
		    assertThat(StringCalculator.splitAndSum("1,2:3")).isEqualTo(6);
		}
}

프로덕션 코드 작성

public class StringCalculator {
    public static int splitAndSum(String text) {
        int result = 0;
		    if (text == null || text.isEmpty()) {
				    result = 0;
				} else {
					  String[] values = text.split(",|:");
					  for (String value : values) {
						    result += Integber.parseInt(value);
						}
				}
		}
}

테스트 코드를 토대로 프로덕션 코드 작성을 끝냈다면 리팩토링을 시도하자

✏️ 리팩토링 연습 - 메소드 분리

테스트 코드는 변경하지 말고 테스트 대상 코드(프로덕션 코드)를 개선하는 연습을 집중한다.
정상적인 기준보다는 정량적이고 측정가능한 방법으로 연습

한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.

인덴트를 줄이려면 메서드를 분리하자

public class StringCalculator {
    public static int splitAndSum(String text) {
        int result = 0;
		    if (text == null || text.isEmpty()) {
				    result = 0;
				} else {
					  String[] values = text.split(",|:");
					  result = sum(values);   // Sum() 메서드로 분리
				}
		}

		private static int sum(String[] values){
		    int result = 0;
				for (String value : values) {
				    result += Integer.parseInt(value);
				}
				return result;
		}
}

Else 예약어를 쓰지 않는다.

public class StringCalculator {
    public static int splitAndSum(String text) {
		    if (text == null || text.isEmpty()) {
				    result 0;
				} 
				String[] values = text.split(",|:");
				return sum(values);
		}

		private static int sum(String[] values){
		    int result = 0;
				for (String value : values) {
				    result += Integer.parseInt(value);
				}
				return result;
		}
}

메소드가 한 가지 일만 하도록 구현하기

public class StringCalculator {
    public static int splitAndSum(String text) {
		    [...]
		}
		
		private static int[] toInts(String[] values) {
		    int[] numbers = new int[values.lenght];
				for(int i = 0; i < values.length; i++) {
				    numbers[i] = Integer.parseInt(values[i]);
				}
				return numbers;
		}

		private static int sum(String[] values){
		    int result = 0;
				for (int number : numbers) {
				    result += number;
				}
				return result;
		}
}

이렇게 메서드를 분리시켜두면 메서드를 재활용할 수 있다.

로컬 변수 제거

public class StringCalculator {
    public static int splitAndSum(String text) {
		    if (text == null || text.isEmpty()) {
				    return 0;
				} 

				return sum(toInts(text.split(",|:)));
		}
		
		private static int[] toInts(String[] values) {
		    [...]
		}

		private static int sum(String[] values){
		    [...]
		}
}

의문점

저번에 숫자야구 미션을 하면서 while문 안에 메서드 반환형을 bool 형식으로 해서 statement가 없는 무한루프를 만들었었는데, 종료 조건을 정확히 명시하지 않았다는 생각에 조건문에 true를 주고 지역 변수를 할당해서 종료조건을 빠져나가게 만들었다.

기존 코드

private void startOneGame() {
	gamePreparation();
    while (!gameProgress()) {

	}
}

리팩토링

private void startOneGame() {
	gamePreparation();
	while (true) {
	if (gameProgress()) {
    	break;
    }
}

로컬 변수를 제거하는 상황은 아니지만 while문 안에 statement가 없으면 타인이 코드를 읽었을 때 오히려 명확성을 떨어뜨린다고 생각해서 똑같은 결과가 나오지만 나는 while문 조건에 true를 주고 내부에 if문을 통해 statement를 작성했다.

굳이 작성해도 되지 않은 코드를 작성한 건데 타인이 봤을 때는 무한 루프를 빠져나갈 수 있나? 라고 생각할 것 같아 작성했는데 이러한 경우에는 어떻게 해야 하는 것일까
일단 다음부터는 코드를 최소한으로 하기 위해 제거하는 방향으로 해야겠다.

Compose method 패턴 적용

메소드(함수)의 의도가 잘 드러나도록 동등한 수준의 작업을 하는 여러 단계로 나눈다.

public class StringCalculator {
    public static int splitAndSum(String text) {
		    if(isBlank(text)) {
				    return 0;
				}

				return sum(toInts(split(text)));
		}

		private static boolean isBlank(String text) {
		    return text == null || text.isEmpty();
		}
		
		private static String[] split(String text) {
		    return text.split(",|");
		}
		
		private static int[] toInts(String[] values) {
		    [...]
		}

		private static int sum(String[] values){
		    [...]
		}
}

연습 방법

한 번에 모든 원칙을 지키면서 리팩토링하려고 연습하지 마라
한 번에 한 가지 명확하고 구체적인 목표를 가지고 연습하라

연습은 극단적인 방법으로 연습하는 것도 좋다
→ 오히려 작은 코드가 좋을 수도 있다
예를 들어 한 메소드의 라인 수 제한을 15라인 → 10라인으로 줄여가면서 연습하는 것도 좋은 방법이다.

Step by Step이 중요하다.

✏️ 리팩토링 연습 - 클래스 분리

위의 문자열 덧셈 계산기 요구사항이 다음이 추가되었다.
숫자 이외의 값 또는 음수를 전달하는 경우 RuntimeException 예외를 throw한다.
“-1,2:3” → RuntimeException

테스트 코드

@Test(expected = RuntimeException.class)
public void 음수값() {
    String Calculator.splitAndSum("-1,2:3");
}

프로덕션 코드

private static int toInt(String value) {
    int number = Integer.parseInt(value);
		if (number < 0) {
				throw new RuntimeException();
		}
		return number;
}

모든 원시값과 문자열을 포장

메서드를 클래스로 분리할 수 있는지 검토해보자

public class Positive {
    private int number;

		public Positive(String value) {
				this(Integer.parseInt(value));
		}

		public Positive(int number) {
				if (number < 0) {
						throw new RuntimeException();
				}
				this.number = number;
		}
}

0보다 작으면 RuntimeException을 던지는 조건문을 Positive라는 클래스로 분리

값이 Positive라는 객체로 만들어지면 0보다 큰 것이 보장된다.

생성자를 두 가지로 둬서 역할을 분리 (숫자로 바꾸는 것과 0보다 작으면 exception 발생)

private static Positive[] toPositives(String[] values) {
		Positive[] numbers = new Positive[values.length];
		for (int i = 0; i < values.length; i++) {
				numbers[i] = new Positive(values[i]);
		}
		return numbers;
}

private static int sum(Positive[] numbers) {
		Positive result = new Positive(0);
		for (Positive number : numbers) {
				result = result.add(number);
		}
		return result.getNUmber();
}
public class Positive {
    private int number;

		public Positive(String value) {
				this(Integer.parseInt(value));
		}

		public Positive(int number) {
				if (number < 0) {
						throw new RuntimeException();
				}
				this.number = number;
		}

		public Positive add(Positive other) {
				retunr new Positive(this.number + other.number);
		}
		
		public int getNumber() {
				return number;
		}
}

클래스 분리 연습을 위해 활용할 수 있는 원칙

  • 일급 콜렉션을 쓴다.
  • 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.

EX) number라는 인스턴스 변수를 하나만 가지는 객체를 만든다.

일급 콜렉션

콜렉션을 포장하는 클래스를 만드는 것

public class Lotto {
		private static final int LOTTO_SIZE = 6;

		private final Set<LottoNumber> lotto;

		private Lotto(Set<LottoNumber> lotto) {
				if (lotto.size() != LOTTO_SIZE) {
						throw new IllegalArgumentException();
				}
		}
}

3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.

public class WinnigLotto {
		private final Lotto lotto;
		private final LottoNumber no;

		public WinningLotto(Lotto lotto, LottoNumber no) {
				if (lotto.contains(no)) {
						throw new IllegalArgumentException();
				}
				this.lotto = lotto;
				this.no = no;
		}

		public Rank match(Lotto userLotto) {
				int matchCount = lotto.match(userLotto);
				boolean matchBonus = userLotto.contains(no);
				return valueOf(matchCount, matchBonus);
		}
}

연습 방법

정량적인 기준을 점차 줄이면서 연습해보자
스스로 피드백하는 효과가 있음
피드백이 있으면 재밌다.

✏️ 리팩토링 연습 - 장난감 프로젝트 난이도 높이기

점진적으로 요구사항이 복잡한 프로그램을 구현한다.
앞에서 지켰던 기준을 지키면서 프로그래밍 연습을 한다.

게임과 같이 요구사항이 명확한 프로그램으로 연습

의존관계(모바일 UI, 웹 UI, 데이터 베이스, 외부 api와 같은 의존관계)가 없이 연습

약간은 복잡한 로직이 있는 프로그램 EX 로또 사타리 타기 볼링 게임 점수판

✏️ 리팩토링 연습 - 의존관계 추가를 통한 난이도 높이기

웹, 모바일, DB 등 추가

테스트하기 쉬운 코드와 테스트하기 어려운 코드를 보는 눈
테스트하기 어려운 코드를 테스,트 하기 쉬운 코드로 설계하는 감 (sense)

앞에 있는 연습을 잘 했으면 쉬운 코드와 어려운 코드를 분리하는 역량이 있을 것이다.

한 단계 더 나아가 연습을 하고 싶다면

컴파일 에러를 최소화하면서 리팩토링하기
ATDD 기반으로 응용 애플리케이션 개발하기
레거시 애플리케이션에 테스트 코드 추가해 리팩토링하기

TDD, 리팩토링 적용에 실패하는 이유
연습이 충분하지 않은 상태에서 “레커시 애플리케이션에 테스트 코드 추가해 리팩토링하기”와 같은 높은 난이도에 바로 도전하기 때문

✏️ 리팩토링 연습 - 구체적인 연습 목표 찾기

객체지향 생활체조 원칙

규칙 1: 한 메서드에 오직 한 단계의 들여쓰기(indent)만 한다.
규칙 2: else 예약어를 쓰지 않는다.
규칙 3: 모든 원시값과 문자열을 포장한다.
규칙 4: 한 줄에 점을 하나만 찍는다.
규칙 5: 줄여쓰지 않는다(축약 금지).
규칙 6: 모든 엔티티를 작게 유지한다.
규칙 7: 3개 이상의 인스턴스 변수를 가진 클래스를 쓰지 않는다.
규칙 8: 일급 콜렉션을 쓴다.
규칙 9: 게터/세터/프로퍼티를 쓰지 않는다.

클린 코드

클린 코드 책에서는 메서드 인자 개수를 통제하는 것을 강조한다.

메서드(함수)에서 이상적인 인자 개수는 0개(무항)이다. 다음은 1개이고 다음은 2개이다.
3개는 가능한 피하는 편이 좋다.
4개 이상은 특별한 이유가 있어도 사용하면 안 된다.

내가 가장 조심해야 할 것

게터/세터/프로퍼티를 쓰지 말자 (특히 세터)
3개 이상의 인스턴스 변수를 사용하는 클래스를 만들지 말자

필요한 것

조급함 대심 마음의 여유
나만의 장난감 프로젝트
같은 과제를 반복적으로 구현할 수 있는 인내력, 꾸준함, 성실함

💡 생각

'가장 필요한 것은 가보지 않은 길에 꾸준히 도전할 수 있는 용기'

마지막쯤에 강의에 나오는 내용이다.
영국 시인 Christopher Logue의 'Come to the Edge' 라는 시가 떠올랐다.
지금까지 난 나에게 익숙했던 기술을 사용해서 개발을 했고, 새로운 기술을 학습해도 그 범주 안에서 크게 벗어나지 않았던 것 같다.

이번 주차에서는 한 번도 도전해보지 않았던 TDD를 통해 리팩토링하는 방법을 적용해 볼 예정이다.

Reference
https://www.youtube.com/watch?v=bIeqAlmNRrA에서

profile
반갑습니다.

3개의 댓글

comment-user-thumbnail
2023년 11월 8일

좋은 글이네요~~^^ 근데 의식적인 연습이 뭐죠?

1개의 답글