이전 시간에 실습한 코드 기능 명세, 테스트 우선 개발을 중심으로 코드를 정리하는 시간이다.
강의에선 자바스크립트로 진행되지만 자바로 해석하며 진행했다.
📖 원본이 궁금하면 이규원 님의 TDD 수강하는 걸 추천드립니다. 👍
작업 환경 정리
1) 생산성 : 정리된 환경과 어지럽혀진 환경에서의 작업 생산성 차이
2) 지속성 : 작업 환경의 생산성이 일정 수준 미만으로 떨어지면 더 이상 그 환경에서 작업 진행은 불가능
3) 코드는 작업 환경이자 작업 결과물
이러한 작업 환경 정리를 리팩토링이라고 한다.
흔히들 리팩토링이라 하면 코드를 전혀 다른 코드로 바꾼다 생각하는데, 이는 오해이다.
코드를 리팩토링한다는 것은 코드로 만들어진 객체를 더 작거나 단순한 객체들로 나눠서 표현하는 것이다.
즉, 원래의 의미를 훼손하지 않으면서 구조를 바꾸는 것이 리팩토링이다.
여기서 문제가 하나 있다.
많은 프로그래머가 리팩토링 후 의미가 변했는지 확인도 안한다. 위험한 행위다.
리팩토링 할 때 코드의 의미를 어떻게 확인해야 할까?
그건 바로 테스트다. 수동 테스트 / 자동화 테스트 / 인수 테스트 등 테스트로 확인하면 된다.
리팩터링 실습
코드 기능 명세에서 했던 실습을 기억하는가? 그 코드로 리팩토링 해보자
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("데이터가 입력되지 않았습니다.");
return;
}
double[] s = new double[args.length];
for (int i = 0; i < s.length; i++) {
s[i] = Double.parseDouble(args[i]);
}
double sum = 0;
for (double value : s) {
sum += value;
}
double variance = sum / (s.length);
System.out.println("variance = " + variance);
}
코드를 보면
1. 입력된 값을 double로 변환하는 작업을 한다.
2. 변환된 값을 모두 합한다.
3. 평균을 구한다.
복잡한 로직이 한 곳에 구현되어 실행되고 있다. 이제 로직 별로 메서드로 추출해보자.
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("데이터가 입력되지 않았습니다.");
return;
}
double[] s = parseArguments(args);
double sum = 0;
for (double value : s) {
sum += value;
}
double variance = sum / (s.length);
System.out.println("variance = " + variance);
}
private static double[] parseArguments(String[] args) {
double[] s = new double[args.length];
for (int i = 0; i < s.length; i++) {
s[i] = Double.parseDouble(args[i]);
}
return s;
}
우선 입력된 값을 변환하는 걸 parseArguments()로 추출했다.
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("데이터가 입력되지 않았습니다.");
return;
}
double[] s = parseArguments(args);
double sum = calculateSum(s);
double variance = sum / (s.length);
System.out.println("variance = " + variance);
}
private static double calculateSum(double[] s) {
double sum = 0;
for (double value : s) {
sum += value;
}
return sum;
}
private static double[] parseArguments(String[] args) {
double[] s = new double[args.length];
for (int i = 0; i < s.length; i++) {
s[i] = Double.parseDouble(args[i]);
}
return s;
}
합계를 구하는 걸 calculateSum()로 추출했다.
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("데이터가 입력되지 않았습니다.");
return;
}
double[] s = parseArguments(args);
double sum = calculateSum(s);
double variance = sum / (s.length);
System.out.println("variance = " + variance);
}
메인 함수는 단순해졌고, 의미가 그대로인지 이전처럼 1,2,3,4,5로 테스트해보자.
제대로 동작했고, 데이터가 없는 경우엔
정상적으로 동작이 되었다.
충분히 의미별로 분리되었고, 테스트 결과로 의미엔 변함이 없다. 여기서 한번 더 리팩토링을 해보자.
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("데이터가 입력되지 않았습니다.");
} else {
System.out.println(getVariance(args));
}
}
private static String getVariance(String[] args) {
double[] s = parseArguments(args);
double sum = calculateSum(s);
double variance = sum / (s.length);
return "variance = " + variance;
}
... (생략)
좀 더 단순하게 변경되었다.
테스트를 진행하면 이전과 똑같은 결과를 볼 수 있다.
이제 추출된 메서드의 반복문을 스트림으로 변경해보자.
// 변경 전
private static double[] parseArguments(String[] args) {
double[] s = new double[args.length];
for (int i = 0; i < s.length; i++) {
s[i] = Double.parseDouble(args[i]);
}
return s;
}
// 변경 후
private static double[] parseArguments(String[] args) {
return Arrays.stream(args)
.mapToDouble(Double::parseDouble)
.toArray();
}
// 변경 전
private static double calculateSum(double[] s) {
double sum = 0;
for (double value : s) {
sum += value;
}
return sum;
}
// 변경 후
private static double calculateSum(double[] s) {
return Arrays.stream(s)
.sum();
}
이전과 똑같이 테스트를 진행해보자, 동일한 결과를 받을 수 있다.
리팩토링 전 코드를 비교해보자.
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("데이터가 입력되지 않았습니다.");
return;
}
double[] s = new double[args.length];
for (int i = 0; i < s.length; i++) {
s[i] = Double.parseDouble(args[i]);
}
double sum = 0;
for (double value : s) {
sum += value;
}
double variance = sum / (s.length);
System.out.println("variance = " + variance);
}
리팩토링 전 코드는 무엇을 의미하는지 곧바로 파악하기 힘들었다. 하지만 리팩토링 후엔
public static void main(String[] args) {
if (args.length == 0) {
System.out.println("데이터가 입력되지 않았습니다.");
} else {
System.out.println(getVariance(args));
}
}
private static String getVariance(String[] args) {
double[] s = parseArguments(args);
double sum = calculateSum(s);
double variance = sum / (s.length);
return "variance = " + variance;
}
private static double calculateSum(double[] s) {
return Arrays.stream(s)
.sum();
}
private static double[] parseArguments(String[] args) {
return Arrays.stream(args)
.mapToDouble(Double::parseDouble)
.toArray();
}
로직마다 메서드 이름으로 의미를 표현하고 구분했고, 가독성이 개선되었다.
이제 테스트 우선 개발 시간에 했던 refind을 리팩터링 해보자.
작성된 테스트 코드는 건들지 않을 것이며, 기능이 수정될 때마다 테스트를 실행하면서 의미가 변했는지 확인한다.
public String refineText(String s, List<String> bannedWords) {
s = s.replace(" ", " ")
.replace("\t", " ")
.replace(" ", " ")
.replace(" ", " ")
.replace(" ", " ")
.replace("mockist", "*******")
.replace("purist", "******");
for (String bannedWord : bannedWords) {
s = s.replace(bannedWord, repeat(bannedWord.length()));
}
return s;
}
public String repeat(int length) {
StringBuilder builder = new StringBuilder();
IntStream.range(0, length)
.forEach(i -> builder.append("*"));
return builder.toString();
}
우선 인자로 받은 변수명 s를 source로 변경하고, 반복되는. replace(" ", " ")를 지워보자
public String refineText(String source, List<String> bannedWords) {
source = source.replace("\t", " ")
.replace(" ", " ")
.replace(" ", " ")
.replace(" ", " ")
.replace(" ", " ")
.replace("mockist", "*******")
.replace("purist", "******");
for (String bannedWord : bannedWords) {
source = source.replace(bannedWord, repeat(bannedWord.length()));
}
return source;
}
테스트를 해보자.
실패했다. 테스트 자동화가 있으니 리팩토링해도 피드백으로 결과를 알 수 있다. 해결법을 찾기 전까지 수정했던 부분을 되돌리자.
마지막에 금지어를 변환하는 코드를 지워보자
source = source.replace("\t", " ")
.replace(" ", " ")
.replace(" ", " ")
.replace(" ", " ")
.replace(" ", " ");
잘 작동한다.
다시 한번 중복되는 공백 로직을 줄여보자.
public String refineText(String source, List<String> bannedWords) {
source = source.replace("\t", " ");
source = compactWhiteSpaces(source);
for (String bannedWord : bannedWords) {
source = source.replace(bannedWord, repeat(bannedWord.length()));
}
return source;
}
public String compactWhiteSpaces(String source) {
return source.indexOf(" ") < 0
? source
: compactWhiteSpaces(source.replace(" ", " "));
}
연속된 공백이 몇 개가 되든 1개가 될 때까지 재귀 함수로 처리한다.
불필요한 replace 로직이 사라지고 깔끔해졌다.
이제 다른 로직도 메서드로 추출해보자.
public String refineText(String source) {
return refineText(source, new ArrayList<>());
}
public String refineText(String source, List<String> bannedWords) {
source = normalizeWhiteSpaces(source);
source = compactWhiteSpaces(source);
source = maskBannedWords(source, bannedWords);
return source;
}
private String maskBannedWords(String source, List<String> bannedWords) {
for (String bannedWord : bannedWords) {
source = source.replace(bannedWord, repeat(bannedWord.length()));
}
return source;
}
private String normalizeWhiteSpaces(String source) {
return source.replace("\t", " ");
}
public String compactWhiteSpaces(String source) {
return source.indexOf(" ") < 0
? source
: compactWhiteSpaces(source.replace(" ", " "));
}
public String repeat(int length) {
StringBuilder builder = new StringBuilder();
IntStream.range(0, length)
.forEach(i -> builder.append("*"));
return builder.toString();
}
이전과 비교하면 중복된 코드가 없어지고 가독성도 많이 좋아졌다.
첫 시간에 했던 코드는 리팩토링하면서 의미가 변경되었는지 매번 수동 테스트로 확인하는 불편함을 맛보았다.
반대로 이전 시간에 했던 코드는 이전에 만들어놓은 자동화 테스트만 실행해서 받은 피드백으로 간단하게 운영코드만 수정했다.
이제 다음 시간은 테스트 주도 개발에 대해 알아보겠다.
'교육 및 인강 > 이규원의 현실 세상의 TDD' 카테고리의 다른 글
이규원님의 현실 세상의 TDD 기초, 8편 : 프로그래머 피드백 (0) | 2021.04.16 |
---|---|
이규원님의 현실 세상의 TDD 기초, 7편 : 테스트 주도 개발 (0) | 2021.04.16 |
이규원님의 현실 세상의 TDD 기초, 5편 : 테스트 우선 개발 (0) | 2021.04.13 |
이규원님의 현실 세상의 TDD 기초, 4편 : 단위테스트 (2) | 2021.04.13 |
이규원님의 현실 세상의 TDD 기초, 3편 : 코드 분해 (0) | 2021.03.10 |