| 시작하기 전..

국비학원에서 자바로 개발을 시작한 사람으로써 어떤 로드맵을 가지고 학습해야 하는지, 학습해야할 지식이 무엇인지, 지금 공부하는것이 잘하고 있는지 감이 안왔다. 회사에서 교육의 한 과정으로 이 책을 학습하라고 하셔서 시작하게 되었지만 주먹구구식으로 개발하는 것이 아닌 생각하고 고민하는 개발자가 되는데 큰 도움이 될 것이라 생각한다.
2장. 문자열 게산기 구현을 통한 테스트와 리팩토링
목표
테스트와 리팩토링을 학습하는 것과 더불어 자바 개발 환경에 익숙하지 않은 개발자가 자바 개발 환경을 익히고, 이 책의 실습 진행 방식을 경험하는 것을 목표로 한다.
1. main()메소드를 활용한 테스트의 문제점
- 소스 코드를 구현한 후 정상적으로 동작하는지 확인하는 일반적인 방법은 main() 메소드이다.
- 예를 들어 계산기를 구현할 때 계산기 코드는 서비스를 담당하는 프로덕션 코드와 그 코드가 정상적으로 작동하는지 확인하기 위한 main()으로 나뉜다.
첫번째 구현
- 한 클래스 안에 프로덕션 코드와 테스트코드를 같이 넣는 것이다.
- 문제점: 테스트 코드의 경우 테스트 단계에서만 필요하므로 굳이 서비스하는 시점에 같이 배포할 필요가 없다.
- 해결: 프로덕션 코드와 테스트코드를 분리하면 된다.
두번째 구현
- 프로덕션코드(Calculator 클래스)와 테스트 코드 (CalculatorTest)를 분리한다.
- 문제점: 별도의 클래스를 추가했지만 Main() 메소드 하나에서 프로덕션 코드의 여러 메소드를 동시에 테스트하고 있다. 프로덕션 코드의 복잡도가 증가하면 할수록 테스트코드를 유지하는데 부담이 된다.
- 해결: 테스트코드를 각 메소드 별로 분리해본다.
세번째 구현
- 테스트코드를 각 메소드별로 분리할 수 있다.
- 문제점: 모든 메소드가 아니라 현재 내가 구현하고 있는 메소드에 집중하고 싶은데 그게 안된다.
- 또한 main()메소드는 테스트 결과를 매번 콘솔에 출력되는 값을 통해 수동으로 확인해야 한다.
- 해결 : main()메소드를 활용한 테스트의 이 같은 문제점을 해결하기 위해 틍장한 라이브러리가 JUnit이다.
네번째 구현
Junit
- 내가 관심을 가지는 메소드에 대한 테스트가 가능하다.
- 또한 로직을 실행한 후의 결과 값 확인을 프로그래밍을 통해 자동화하는 것이 가능하다.
2.JUnit을 활용해 Main()메소드 문제점 극복
- Junit 라이브러리를 설치하면 Junit안에 있는 메소드를 사용할 수 있다.
1.한 번에 결과 하나에만 집중
CalculatorTest에 테스트 메소드마다 @Test 를 붙여주면 JUnit이 테스트를 해준다.
- 각각의 테스트 메소드를 독립적으로 실행할 수 있기 때문에 현재 내가 구현하고 있는 프로덕션 코드에 집중할 수 있는 효과를 얻을 수 있다.
2.결과 값을 눈이 아닌 프로그램을 통해 자동화
main() 메소드에서 또 다른 문제점은 결과 값을 눈으로 봐야한다는 것이다. Junit은 이같은 문제점을 극복하기 위해 asssertEquals()메소드를 제공한다.
assertEquals()
assertEquals(expected, actual)
(expected: 기대하는 값, actual :프로덕션 코드의 메소드를 실행한 결과의 결과 값)
- asssertEquals는 static 메소드라 import static 으로 메소드를 import한 후 구현할 수 있다.
- 테스트가 성공한 경우 초록색 바가 뜨면서 테스트가 성공했음을 알려주고 실패해도 실패한 이유와 함께 실패했음을 빨간 바로 알려준다.
package main;
public class Calculator {
int add(int i, int j ) {
return i+j;
}
int substract (int i, int j) {
return i-j;
}
int multiply(int i, int j) {
return i*j;
}
int divide(int i, int j) {
return i/j;
}
}
package main;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.Test;
public class CalculatorTest {
//@Test를 쓰면 현재 내가 구현하는 코드에만 집중할 수 있다.
@Test
public void substract() {
Calculator cal = new Calculator();
System.out.println(cal.substract(6, 3));
}
//assertEquals를 쓰면 기대하는값과 결과값의 비교가 가능하다.
@Test
public void add() {
Calculator cal = new Calculator();
assertEquals(9,cal.add(6, 3));
}
}
결론
이와같이 JUnit 의 assertEquals()를 사용하면 수동으로 확인했던 실행 결과를 자동화하는 것이 가능하다.
3.테스트 코드 중복 제거
Calculator 인스턴스를 생성하는 부분에 중복되는 코드가 발생한다. 이때 Junit의 @Before 어노테이션을 사용하는 것을 추천한다.
@Before
1) Calculator 인스턴스를 매 테스트마다 생성하는 이유
add()테스트 메소드를 실행할 때 Calculator 메소드를 실행할 때 영향을 미칠 수 있기 때문이다. 이런 경우 테스트 실행 순서나 Calculator 상태 값에 따라 테스트가 성공하거나 실패할 수 있다.
2) 왜 @Before 어노테이션을 사용하는것을 추천하는가
JUnit에서는 @Runwith, @Rule 같은 어노테이션을 사용해 기능을 확장할 수 있는데 @Before안이여야만 @Runwith, @Rule에서 초기화된 객체에 접근할 수 있다는 제약사항이 있기 때문이다.
따라서 이 어노테이션을 이용해 테스트 메소드에 대한 초기화 작업을 하는 것이 추후 문제가 발생할 가능성을 없앨 수 있다.
@After
- 메소드 실행이 끝난 후 실행됨으로써 후 처리 작업을 담당한다.
- 초기화, 후 처리 작업을 하는 것을 볼 수 있다.
package main;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
public class CalculatorTest2 {
//Calculator: Calculator 인스턴스를 생성하는 부분에 중복이 발생한다.
//이 클래스에서는 그 중복되는 것을 제거해줄 것이다.
private Calculator cal;
@Before
public void setup() {
cal = new Calculator();
System.out.println("before");
}
@Test
public void add() {
assertEquals(9,cal.add(6, 3));
System.out.println("add");
}
@Test
public void substract() {
assertEquals(3,cal.substract(6, 3));
System.out.println("substract");
}
@After
public void teardown() {
System.out.println("teardown");
}
}
결론
이와 같이 Junit를 사용하면 매번 초기화, 후처리 작업을 통해 각 테스트 간에 영향을 미치지 않으며 독립적인 실행이 가능하도록 할 수 있다.
3. 문자열 계산기 요구사항 및 실습
1) 요구사항
1.빈 문자열 또는 null 값을 입력할 경우 0을 반환하라
2.숫자 하나를 문자열로 입력할 경우 해당 숫자를 반환한다.
package practice;
//1,2 요구사항에 대해 구현해보았다.
//이 부분은 따로 리팩토링이 필요해보이지 않는다.
public class Test1 {
int add (String text) {
if(text == null || text.isEmpty()) {
return 0;
}
return Integer.parseInt(text);
}
}
3.숫자 두개를 쉼표 구분자로 입력할 경우 두 숫자의 합을 반환한다.
package practice;
//세번째 요구조건을 구현했다.
public class Test1 {
int add (String text) {
if(text == null || text.isEmpty()) {
return 0;
}
if(text.contains(",")) {
String[] values = text.split(",");
int sum = 0;
for (String value : values) {
sum += Integer.parseInt(value);
}
return sum;
}
return Integer.parseInt(text);
}
}
else문을 쓰지않기로 정했기 때문에 if문으로 코드를 짰다. 위의 코드를 보면 점점 복잡해지는 것을 볼 수 있다. 여기서 찜찜한 점은 숫자가 하나인 경우와 쉼표 구분자를 포함하는 경우를 따로 분기해서 처리해야 하는 점이다.
string의 split() 메소드에 숫자 하나를 가지는 문자열을 전달하면 숫자 하나가 담겨있는 String[] 을 반환하게 된다. 그래서 if문을 없애줄 수 있다.
package practice;
public class Test1 {
int add(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
String[] values = text.split(",");
int sum = 0;
for (String value : values) {
sum += Integer.parseInt(value);
}
return sum;
}
}
if절을 하나 제거했더니 조금 개선된 코드를 볼 수 있다. 추가적으로 리팩토링할 부분이 있을까? 보면 add()의 복잡도가 증가하고 있다. 먼저 숫자의 합을 구하는 부분을 별도의 메소드로 구현해볼 수 있다.
package practice;
public class Test1 {
int add(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
String[] values = text.split(",");
return sum(values);
}
//합을 구하는 메소드를 따로 뺐다.
private int sum (String[] values) {
int sum = 0;
for (String value : values) {
sum += Integer.parseInt(value);
}
return sum;
}
}
더 이상 리팩토링할 부분이 없을까? 새로운 sum()메소드를 다시 보면 이 메소드는 합을 구하는 메소드인데 String을 int타입으로 바꿔주는 코드까지 들어있다.한 메소드가 두가지의 일을 할 수 있음을 알 수 있다. 메소드는 한 가지의 책임만 져야하므로 원칙에 따라 이 두가지 작업을 분리해야 한다.
package practice;
public class Test1 {
int add(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
String[] values = text.split(",");
return sum(isInt(values));
}
//문자열을 숫자로 변환하는 메소드
private int [] isInt (String[] values) {
int[] numbers = new int [values.length];
for (int i = 0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
//변환한 값을 더해주는 메소드
private int sum (int[] numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
}
이제 한개의 메소드가 하나의 기능만 담당함을 볼 수 있다. 더 리팩토링할 것이 보이는가? 이렇게 극단적으로 리팩토링하는 이유는 소스코드를 읽을 때 이 메소드가 무슨 일을 하는 메소드인지 최대한 쉽게 파악할 수 있게 하기 위함이다. add()메소드를 보면 아직 한눈에 보이지 않아서 null값인지 확인해주는 메소드, 문자열을 구분자로 구분해주는 메소드를 따로 만들어 보았다.
package practice;
public class Test1 {
int add(String text) {
if (isBlank(text)) {
return 0;
}
return sum(isInt(split(text)));
}
//null값인지 체크해주는 메소드
private boolean isBlank(String text) {
return text == null || text.isEmpty();
}
//구분자로 문자열 나눠주는 메소드
private String[] split(String text) {
return text.split(",");
}
private int[] isInt(String[] values) {
int[] numbers = new int[values.length];
for (int i = 0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
private int sum(int[] numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
}
세부 구현은 모두 private 메소드로 분리해 일단 관심사에서 제외하고 add()메소드가 무슨 일을 하는지에 대한 전체 흐름을 쉽게 볼 수 있게 되었다. add() 메소드가 무슨 일을 하는지 글로 표현해보면 "text값이 비어 있으면 0을 반환, 비어 있지 않으면 구분자로 분리, 숫자로 변환한 다음 이 숫자의 합을 구한다"로 파악할 수 있다.
이 책의 필자는 세부 구현에 집중하도록 하지 않고 논리적인 로직을 쉽게 파악할 수 있도록 구현하는 것이 읽기 좋은 코드라고 생각한다고 하셨다.
4.구분자를 쉼표(,)이외에 콜론(:)을 사용할 수 있다.
private String[] split(String text) {
return text.split(",|:");
}
이와 같이 메소드를 잘 분리해 놓으면 새로운 요구사항이 발생할 경우 해당 메소드를 찾아 해당 메소드만 수정하는 것이 가능하다.
5.//,\n 문자 사이에 커스텀 구분자를 지정할 수 있다.
private String[] split(String text) {
// Pattern: 정규 표현식이 컴파일 된 클래스이며 정규 표현식에 대상 문자열을 검증하거나, 활요하기 위해 사용하는 클래스이다.
// Pattern.compile 주어진 정규식을 갖는 패턴을 생성
// Pattern.compile.matcher() 패턴에 매칭할 문자열을 입력해 Matcher를 생성한다.
// m = [pattern=//(.)(.*) region=0,9 lastmatch=//;1;2;3]
// m.find 패턴과 일치하는게 있다면
// m.group(1) ;
// m.group(2) 1;2;3
Matcher m = Pattern.compile("//(.)\n(.*)").matcher(text);
if (m.find()) {
String customDelimeter = m.group(1);
return m.group(2).split(customDelimeter);
}
return text.split(",|:");
}
커스텀 구분자는 정규 표현식을 이용해 문자열을 분리하고 있다. 정규 표현식을 사용하면 복잡한 문자열에서 원하는 문자열을 찾거나 특정한 패턴을 찾는데 유용하다.
6.문자열 계산기에 음수를 전달하는 경우 RuntimeException으로 예외처리를 해야한다.
private int[] isInt(String[] values) {
int[] numbers = new int[values.length];
for (int i = 0; i < values.length; i++) {
numbers[i] = toPositive(values[i]);
}
return numbers;
}
//음수인지 확인하는 메소드를 따로 구현했다. 음수이면 런타임오류가 나게 된다.
private int toPositive(String value) {
int number = Integer.parseInt(value);
if (number < 0) {
throw new RuntimeException();
}
return number;
}
실제로 테스트를 해보면 다 잘되는 것을 볼 수 있다.
package practice;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.Before;
import org.junit.Test;
public class StringCalculatorTest {
private StringCalculator cal;
@Before
public void setup() {
cal = new StringCalculator();
}
@Test
public void add_null_bin() {
assertEquals(0, cal.add(null));
assertEquals(0, cal.add(""));
}
@Test
public void add_char() {
assertEquals(4, cal.add("4"));
}
@Test
public void add_rest() {
assertEquals(7, cal.add("4,3"));
assertEquals(9, cal.add("4,2:3"));
}
@Test
public void add_custom() {
assertEquals(6, cal.add("//;\n1;2;3"));
}
@Test(expected = RuntimeException.class)
public void add_error() {
cal.add("-2,3,2");
}
}
2) 추가 요구사항 ⭐
1.메소드가 하나의 책임만 가지도록 구현한다.
2.인덴트(들여쓰기) 깊이를 1단계로 유지한다. 인덴트는 while문과 for문을 사용할 경우 인덴트 깊이가 1씩 증가한다.
3.else를 사용하지 마라.
4. 테스트와 리팩토링을 통한 문자열 계산기 구현
- 문자열 계산기를 구현하면서 복잡도가 증가함을 느꼈을것이다. 왜냐하면 요구사항의 복잡도가 높기때문이다. 개발자들은 이 복잡도와 평생을 더불어 살아갈 수밖에 없다. 그러므로 이 복잡도를 낮출 수 있는 방법을 찾아야 한다. 이 복잡도를 낮출 수 있는 방법 중의 하나가 끊임없는 리팩토링을 통해 소스코드를 깔끔하게 구현하는 연습을 하는 것이다.
1) 요구사항을 작은 단위로 나누기
2) 모든 단계의 끝은 리팩토링
각 단계에서 다음 단계로 넘어가기 위한 작업의 끝은 내가 기대하는 결과를 확인했을 때가 아니라 결과를 확인한 후 리팩토링까지 완료했을 때이다.
- 구현 → 테스트를 통한 결과 확인 → 리팩토링 과정을 거치도록 하자.
결론
세부 구현에 집중하도록 하지 않고 논리적인 로직을 쉽게 파악할 수 있도록 구현하는 것이 좋은 코드이다.
가능하면 리팩토링을 할때 위의 추가 요구사항(3가지 원칙)을 생각하면서 한다면 소스코드를 개선하는 데 도움이 될 것이다. 또한 중요한 점은 모든 요구사항을 다 구현하고 리팩토링 하는 것이 아니라 한개의 요구사항을 다 구현할때마다 리팩토링을 해야 소스코드의 복잡도를 줄일 수 있다는 점을 명심해야 한다.
전체 코드 링크 ↓↓↓
'독서 > Java' 카테고리의 다른 글
자바 웹 프로그래밍 Next Step 2장 TDD 맛보기 (0) | 2022.08.18 |
---|