독서/Java

자바 웹 프로그래밍 Next Step 2장

윤도ri 2022. 8. 17. 21:13

| 시작하기 전..

 국비학원에서 자바로 개발을 시작한 사람으로써 어떤 로드맵을 가지고 학습해야 하는지, 학습해야할 지식이 무엇인지, 지금 공부하는것이 잘하고 있는지 감이 안왔다. 회사에서 교육의 한 과정으로 이 책을 학습하라고 하셔서 시작하게 되었지만 주먹구구식으로 개발하는 것이 아닌 생각하고 고민하는 개발자가 되는데 큰 도움이 될 것이라 생각한다. 

 


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가지 원칙)을 생각하면서 한다면 소스코드를 개선하는 데 도움이 될 것이다. 또한 중요한 점은 모든 요구사항을 다 구현하고 리팩토링 하는 것이 아니라 한개의 요구사항을 다 구현할때마다 리팩토링을 해야 소스코드의 복잡도를 줄일 수 있다는 점을 명심해야 한다. 

 

 

전체 코드 링크 ↓↓↓

https://github.com/yoondori2/java_book-web_pro_nextstep

'독서 > Java' 카테고리의 다른 글

자바 웹 프로그래밍 Next Step 2장 TDD 맛보기  (0) 2022.08.18