우아한 테크 코스 4주차가 끝나가는 시점에 3주차를 정리하고 있다..이번주는 휴일도 껴있고, 뭔가 스스로 마음도 들떠서 열심히 한 주를 보내지 못한 것 같다. 우테코를 시작하고 앞의 2주와 뒤의 2주를 비교해본다면 처음 2주는 정말 느끼는 점도 많고 의욕이 앞섰다면 3주차부터는 뭔가 마음이 붕떠있는 느낌을 버릴 수 없다. 다시 마음을 다잡고! 열심히 해보자!
문자열과 List & Generic 은 학습 테스트 작성으로 대체하였습니다.
우리가 Gradle, Maven 등과 같은 프로젝트 빌드 도구 를 통해서 프로젝트를 생성하면 다음과 같이 src 밑에 main 폴더와 test 폴더를 만들어준다.
여기서 main 디렉토리 내에서 작성하는 일반적인 자바 코드는 Production Code
에 해당하며, test 디렉토리에서 작성하는 코드가 바로 Test Code
에 해당한다.
TDD란 Test Driven Development
의 약자로 말 그대로 테스트가 주가 되는 개발, 즉 테스트 주도 개발이다.
내가 이해한 TDD의 사이클은 우선 가장 작은 실패하는 테스트 코드를 먼저 작성한다.
이후 해당 테스트 코드를 통과하도록 하는 가장 작은 프로덕션 코드를 작성하고 가장 중요한 점은 중복 코드 제거 등의 리팩토링을 수행
하는 것이다.
이 때 테스트 하나는 하나의 단위만을 테스트하는 것이 좋다. 즉, 테스트 하는 목적이 하나의 기능(function)을 테스트하기 위한 테스트여야한다고 생각한다. 이와 관련해서 하나의 단위 테스트에는 하나의 assert
문 만이 포함되어야한다. 물론 여러개의 assert
문이 포함될 수도 있다. 예를 들면 다음과 같다.
@DisplayName("동일 인스턴스 검증")
@Test
public void checkSameInstance() {
// given
LottoNumber sameInstance1 = LottoNumber.from(1);
LottoNumber sameInstance2 = LottoNumber.from(1);
LottoNumber otherInstance3 = LottoNumber.from(2);
// when & then
assertThat(sameInstance1).isSameAs(sameInstance2);
assertThat(sameInstance1).isNotSameAs(otherInstance3);
assertThat(sameInstance2).isNotSameAs(otherInstance3);
}
위 코드는 동일 인스턴스 검증이라고 하는 하나의 기능만을 테스트하는 것이지만 3개의 assert 문을 사용하고 있다. 본인은 이러한 경우에는 여러개의 assert문이 사용가능하다고 생각한다.
TDD를 하면서 아마 가장 어려워하는 부분이면서도 중요한 부분은 실패하도록 작성된 테스트를 통과하는 최소한의 프로덕션 코드를 작성하는 것이다.
관련해서 나만의 팁이 있으면 좋겠지만 아직은 찾지 못했다. 현재까지 좋다고 생각되는 방법은 가장 먼저 기능 목록을 작성하는 것이다. 그리고 각 기능을 가장 최소한으로 작성한다. 예를 들어 구입 금액이 1000원 미만인 경우 예외를 던진다.
와 같이 최소한의 기능별로 작성하는 것이다.
그리고 이와 관련된 테스트 코드를 하나씩 구현해 나가는 것이 좋은 방법이라고 생각한다.
이렇게 어렵고 불편하다고만 느껴지는 TDD를 왜 해야할까??
1. 좋은 객체 지향 설계
TDD를 하다보면 최소 기능 단위로 코드를 작성하게 되고, 결국 이는 알맞은 책임을 가지는 객체를 도출하도록 유도한다. 따라서 좋은 객체 지향 설계를 유도하는 좋은 방법 혹은 연습이다.
2. 살아있는 문서
TDD를 하면서 계속해서 README와 같은 문서를 업데이트하게 된다. 이는 변화하는 요구사항을 반영하고 이를 코드와 함께 문서화하도록 유도한다.
3. 변화에 대한 두려움을 줄여준다.
두려움을 지겨움으로 라는 말이 있다. 코드를 사소하게만 변경해도 큰 영향을 끼칠 수 있게 된다. TDD는 이러한 두려움을 극복할 수 있게 해준다. (테스트 코드가 통과하는지 확인하고 제대로 통과하지 않는 부분을 확인하면 되니까..) 또한 버그 발견 시점과 수정 시점이 가까울 수록 (시간이 많이 지나지 않을 수록) 수정이 쉽다고 생각하는데 TDD는 이를 지킬 수 있게 해준다.
4. 점진적인 설계
TDD는 최소 기능 단위로 개발을 진행하기 때문에 점진적으로 설계가 가능하다. 따라서 과도한 설계에 따른 추가적인 비용을 지불하지 않도록 해준다.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class StringTest {
@Test
public void 문자열_길이_구하기() {
String name = "박재성";
assertThat(name.length()).isEqualTo(3);
}
@Test
public void 문자열_더하기() {
String name = "박재성";
String welcome = "안녕!";
assertThat(welcome.concat(name)).isEqualTo("안녕!박재성");
}
@Test
public void 문자열을_문자_단위로_출력() {
String name = "박재성";
// String의 각 문자를 배열로 가져올 수 있는 API 활용해 구현 가능
char[] nameChars = name.toCharArray();
for (char nameChar : nameChars) {
System.out.println("nameChar = " + nameChar);
}
}
@Test
public void 문자열_뒤집기() {
String name = "박재성";
String reverseName = "";
// String의 각 문자를 배열로 가져올 수 있는 API 활용해 구현 가능
char[] nameChars = name.toCharArray();
for (int i = nameChars.length-1; i >= 0; i--) {
reverseName = reverseName +nameChars[i];
}
assertThat(reverseName).isEqualTo("성재박");
}
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class StringBuilderTest {
@Test
public void append () {
assertThat(createMessage("박재성")).isEqualTo("안녕하세요, 저는 박재성입니다.");
}
private String createMessage(String name) {
StringBuilder sb = new StringBuilder();
// TODO append() 메소드 활용해 요구사항 구현
sb = sb.append("안녕하세요, 저는 ");
sb = sb.append(name);
sb = sb.append("입니다.");
return sb.toString();
}
}
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import org.junit.jupiter.api.Test;
public class ListTest {
@Test
public void arrayList() {
ArrayList<String> values = new ArrayList<>();
values.add("first");
values.add("second");
assertThat(values.add("third")).isTrue(); // 세 번째 값을 추가하라.
assertThat(values.size()).isEqualTo(3); // list의 크기를 구하라.
assertThat(values.get(0)).isEqualTo("first"); // 첫 번째 값을 찾아라.
assertThat(values.contains("first")).isTrue(); // "first" 값이 포함되어 있는지를 확인해라.
assertThat(values.remove(0)).isEqualTo("first"); // 첫 번째 값을 삭제해라.
assertThat(values.size()).isEqualTo(2); // 값이 삭제 됐는지 확인한다.
// TODO values에 담긴 모든 값을 출력한다.
System.out.println("values = " + values);
}
}
import static org.assertj.core.api.Assertions.assertThat;
import java.util.LinkedList;
import org.junit.jupiter.api.Test;
public class ListTest {
@Test
public void linkedList() {
LinkedList<String> values = new LinkedList<>();
values.add("first");
values.add("second");
assertThat(values.add("third")).isTrue(); // 세 번째 값을 추가하라.
assertThat(values.size()).isEqualTo(3); // list의 크기를 구하라.
assertThat(values.get(0)).isEqualTo("first"); // 첫 번째 값을 찾아라.
assertThat(values.contains("first")).isTrue(); // "first" 값이 포함되어 있는지를 확인해라.
assertThat(values.remove(0)).isEqualTo("first"); // 첫 번째 값을 삭제해라.
assertThat(values.size()).isEqualTo(2); // 값이 삭제 됐는지 확인한다.
// TODO values에 담긴 모든 값을 출력한다.
System.out.println("values = " + values);
}
}
위의 학습 테스트 코드에서도 보이다시피 LinkedList와 ArrayList 모두 API 사용법이 동일하다. 또한 둘다 List 인터페이스를 구현하고 있는 Concrete Class이다.
그럼 이 둘은 뭐가 다른걸까?
이 둘의 차이점은 내부 구현
에 있다.
이름에서 알 수 있다시피 ArrayList는 배열 기반 자료구조
로 배열을 이용하여 인스턴스를 저장한다. 반면 LinkedList는 리스트 기반 자료구조
로, 리스트를 구성하여 인스턴스를 저장한다.
이 둘은 앞서 언급한 것과 같이 동일한 인터페이스를 구현한 Concrete Class이기 때문에 동일한 기능을 제공한다.
하지만 인스턴스 저장방식에서 차이가 있고, 각각의 장단점이 존재한다.
그에 앞서 이 둘의 공통점, 즉 List인터페이스를 구현하는 컬렉션 클래스들이 갖는 공통적인 특성 두가지는 다음과 같다.
그럼 이제 진짜 이 둘의 차이점은 무엇일까?
ArrayList의 단점으로는 저장 공간을 늘리는 과정에서 시간이 비교적 많이 소요된다
는 것이다. 이는 내부적으로 더 큰 배열로의 교체가 이루어지기 때문에 당연하다. 다음으로는 인스턴스의 삭제 과정에서 많은 연산이 필요하게 된다.(느리다.) 왜냐하면 index 10 까지 원소가 있는 ArrayList가 있다라고 하고, 만약 index 7번읜 원소를 삭제하면 그 뒤에 있는 원소를 한 칸씩 앞으로 땡겨와야하기 때문이다.(ArrayList는 배열 중간에 위치한 인스턴스를 삭제할 경우, 삭제된 위치를 비워 두지 않는다.)
하지만 ArrayList는 배열 기반이기 때문에 저장된 인스턴스의 참조가 빠르다는 장점이 있다. 배열에 저장된 요소에 접근할 때 순차적으로 접근하는 것이 아니라 인덱스 값
을 통해서 접근이 가능하기 때문에 어느 위치에 있는 인스턴스든지 접근에 소요되는 시간이 동일하다.
다음으로 LinkedList의 단점을 살펴보자. 앞서 ArrayList와는 반대로 저장된 인스턴스의 참조 과정이 배열에 비해서 느리다. 연결 리스트라는 자료구조를 기반으로 하므로 중간에 위치한 원소까지 순차적으로 접근해야하므로 ArrayList보다 느리게된다.
하지만 다음과 같은 장점을 가진다.
우선 저장공간을 늘리는 과정이 간단하다. 또한 삭제 과정도 단순하다. 연결 리스트의 노드를 추가하고 pointing하는 과정을 통해서 저장공간을 쉽게 늘릴 수 있으며, 중간 원소 삭제시에도 중간의 노드를 제거하고 pointing만 재조정하면 되기 때문에 ArrayList보다 빠르다.
위 내용을 정리하며 본인이 내린 결론은 다음과 같다.
최초에 한 번 값을 할당하고 나서는 참조만이루어진다. 혹은 값의 삭제와 추가보다는 조회가 빈번하다면 ArrayList를 사용한다. 하지만 조회보다는 값의 추가와 삭제가 빈번하게 일어난다면 LinkedList를 사용한다. 만약 둘 중 어느 것을 쓰는게 좋을지 모르겠다면 그냥 ArrayList를 사용하자!
제네릭은 우리가 사용하는 컬렉션(ex. List)와 같이 다양한 종류의 데이터를 관리하는 경우 데이터의 타입을 특정 타입으로 고정할 수 있게 해주는 것이다.
제네릭이 있기 이전에는 List안에 Object를 상속하는 클래스의 객체, 즉 모든 인스턴스를 담을 수 있었고, 다음과 같은 코드가 가능했다.
List list = new ArrayList();
list.add(new Apple());
list.add("abc");
list.add(new Banana());
그런데 이러한 경우 해당 리스트에는 여러 타입의 인스턴스가 담기게 되고, 해당 내용물을 꺼낼 때 어떤 타입의 인스턴스가 반환되는지 미리 알아야하고 형변환을 해야한다는 단점이 존재한다.
참고로 명시적인 형 변환은 코드의 안정성을 낮추는 원인이다.
List list = new ArrayList();
list.add("Apple");
list.add("Orange");
Apple apple = (Apple)list.get(0);
Orange orange = (Orange)list.get(1);
만약 개발자가 실수로 위와 같은 코드를 작성했다고 생각해보자. 그러면 이는 예외(Exception)
이 발생하게 된다.
여기서 문제는 이 에러가 컴파일 시간에 발견되는 것이 아니라 실생시간에 사용자가 해당 코드를 실행할 때 발생한다는 것이다.
우리는 테스트 코드를 작성하면서 최대한 이러한 문제를 배포나 서비스 이전에 발견하려고 노력한다. 하지만 결국 개발자도 사람이기 때문에 이러한 문제를 발견하지 못하고 놓칠 수 있다. 그리고 위의 코드가 실제 서비스 되는 코드에 포함되어 배포되었다고 생각해보자. 심지어 돈과도 관련있는 로직이었다. 그러면 사용자가 해당 코드를 호출할 때가 되어서야 문제가 발생하게 된다. 즉, 컴파일 시간이 아닌 실행시간에 예외로써 발견된다는 것은 생각보다 심각한 문제가 될 수 있는 것이다.
우리는 이러한 문제를 바로 Generic
을 통해서 해결할 수 있다.
제네릭은 우리에게 위의 문제의 해결뿐아니라 다음과 같은 이점을 제공한다.
타입 안정성
을 제공한다.위의 예시 코드를 제네릭을 사용하여 적절하게 수정해보면 다음과 같다.
List<String> list = new ArrayList<>();
list.add("Apple");
list.add("Orange");
String apple = list.get(0);
String orange = list.get(1);
그리고 만약 타입이 일치하지 않는다면 컴파일 시간에 개발자가 알 수 있게 된다. 이는 앞서 언급한 문제점과 관련해 생각해보았을 때 매우 큰 장점이 된다.
자바 문자열이나 List & Generic의 학습 테스트 작성은 생략할까 하다가 그래도 쉽다고 학습 테스트를 안하고 넘어가는 것보다 작성 해보면서 새롭게 알게 되거나 배울 점이 있을 것이라고 생각하여 작성하게 되었습니다.