화요일부터 우아한테크코스
가 시작됐다. 이번 프리코스에서 가져갈 나의 목표를 3가지 정도 생각해봤다
1. 읽기 쉬운 코드 짜기
2. 다양한 검증을 할 수 있는 테스트 코드 짜기
3. 빠른 시간 내에 요구사항 만족하기
시작하기에 앞서서 작년에 프리코스를 진행하면서 한 코드를 살펴봤다.
https://github.com/dradnats1012/java-baseball-6
https://github.com/dradnats1012/java-racingcar-6
https://github.com/dradnats1012/java-lotto-6
https://github.com/dradnats1012/java-chritmas-6-dradnats1012
그 당시에는 잘 짰다고 생각했던 코드들이 지금 보니까 알아보기가 굉장히 어려웠다. 그래서 이번에 잡은 목표 중 하나가 읽기 쉬운 코드를 짜는것이다.
그리고 빠른 시간 내에 요구사항을 만족시키고 그 이후에 추가적인 리팩터링이나, 기능을 추가하는 것을 목표로 잡았다. 그 이유는 처음부터 너무 다양한 기능들을 생각하고 개발을 하다보면 구조가 이상해지거나 요구사항도 만족하지 못하는 일이 생길까봐였다. 그래서 5시간을 잡고 기초부터 빠르게 구현하고 기능을 추가하는 방식으로 진행했다.
우아한테크코스 7기 프리코스 1주차 과제는 문자열 덧셈 계산기
였다. 개발을 시작하기에 앞서서 우선 요구사항을 정리해봤다.
기능 요구 사항
- [ ] 문자열을 입력받는다
- [ ] 숫자를 추출한다
- [ ] 구분자와 양수로 구성되었는지 확인
- [ ] 기본 구분자는 `,` 와 `:` 이다
- [ ] 커스텀 구분자는 `//` 와 `\n` 사이에 위치하는 문자이다
- [ ] 사용자가 잘못된 값을 입력한 경우 `IllegalArgumentException` 을 발생시킨다
- [ ] 아무것도 안들어갔을 경우 0
기본적인 요구사항은 이정도라고 생각했다. 첫번째 미션이라서 그런지 많은 것을 요구하지 않은것 같다. 하지만 많은 요구사항이 없는게 더 큰 어려움이라고 생각이 들었다.
예외 상황
- [ ] 커스텀 구분자에 숫자가 들어가면 exception
- [ ] 커스텀 구분자에 `//` 와 `\n` 이 들어가면 exception
- [ ] 숫자가 음수일 경우 exception
- [ ] 0이 들어가면 exception
- [ ] 커스텀 구분자가 아닌 문자가 문자열에 있는 경우 exception
- [ ] 커스텀 구분자 사이에 숫자가 있는 경우 exception
- [ ] 커스텀 구분자 만드는 문법이 잘못 됐을 경우
- [ ] 커스텀 구분자에 예약어가 들어갈 경우(ex `+`, `(`, `)` 등등)
더 많은 예외 상황이 있겠지만 일반적인 예외상황들을 생각해보고 잘 대응할 수 있도록 코드를 짜도록 노력했다.
첫 번째로 MVP
개발로 잡은것이 기본 요구사항을 만족시키는 코드를 짜는것이었다.
5시간 내에 첫번째 개발을 끝내는 것을 목표로 하고 개발을 시작했고, 첫번째 개발은 약 3시간 정도 걸려서 완료했다. 세부적인 예외상황이나 코드의 완벽한 구조들은 리팩토링을 통해서 진행하기로 했고, TDD
와 MVC패턴
으로 간단한 개발을 목표로 하니 그리 오래 걸리지는 않았다.
TDD
를 위해서 개발 시작전에 내가 생각한 예외 상황이나 일반 상황에 대해서 테스트를 작성하고 시작했다.
코드를 짜면서 테스트 코드는 최대한 건들지 않고 나의 로직을 이용해서 테스트를 통과하는 코드를 짜기 위해 노력했다.
class ApplicationTest extends NsTest {
@Test
void 커스텀_구분자_사용() {
assertSimpleTest(() -> {
run("//;\\n1");
assertThat(output()).contains("결과 : 1");
});
}
@Test
void 예외_테스트() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("-1,2,3"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 기본_구분자_테스트_1(){
assertSimpleTest(() -> {
run("1,2,3");
assertThat(output()).contains("결과 : 6");
});
}
@Test
void 기본_구분자_테스트_2(){
assertSimpleTest(() -> {
run("1:2:3");
assertThat(output()).contains("결과 : 6");
});
}
@Test
void 커스텀_구분자_숫자_예외_테스트() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("//3\\n1,3,131"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 커스텀_구분자_예외_테스트_1() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("////\\n1//3"))
.isInstanceOf(IllegalArgumentException.class)
);
}
@Test
void 커스텀_구분자_예외_테스트2() {
assertSimpleTest(() ->
assertThatThrownBy(() -> runException("//\\n\\n1\\n3"))
.isInstanceOf(IllegalArgumentException.class)
);
}
}
첫 번째 개발에서 고민한 부분은 2가지였다.
첫 번째는 파싱하는 과정에서 정규식 \n
이었고, 두 번째는 검증의 책임을 어디에 줘야 할지에 대한 고민이었다.
이 부분에서 30분 이상을 잡아먹었다.
Pattern pattern = Pattern.compile("[//](.*?)[\\n]");
커스텀 구분자를 추출하기 위해 Pattern
과 Matcher
를 사용했었는데 이 과정에서 \n
을 기준으로 자르기 위해 정규식을 사용했을 때 이러한 문제가 발생했다. 처음에는 위에 보이는 것처럼 \\n
으로 가져오는줄 알고 저렇게 사용했었다.
그랬더니 계속 알 수 없는 오류가 발생했고, 정규식이 아닌 다른곳에서 나는 오류인줄 알고 디버깅을 해보고 있었는데 다른곳에서는 문제가 없었다. 결국 문제는 정규식에 있었다.
\n
을 가져오기 위해서는 이스케이프 문자 하나를 통해 가져오면 될 줄 알았는데 그게 아니었다. Java
로 정규식을 작성할때는 \\
로 문자 그대로의 역슬래시를 표현하고, \\n
을 통해서 \n
을 찾는 방식이어서 둘이 합쳐져서 \\\\n
을 정규식 안에 넣어야 했다..
그래서 변경된 코드가 아래의 코드이다.
Pattern pattern = Pattern.compile("//(.*?)\\\\n");
그래도 다행히도 이 부분 외에는 구현에 있어서 크게 고민한 부분은 없었던 것 같다.
구현에서의 고민은 위의 경우가 있었고, 나머지는 구조에 대한 고민을 많이 했다.
제일 많은 고민을 한 부분이 검증의 책임이었다. 나는 총 2가지의 검증을 진행했는데, 입력된 숫자에 대한 검증과, 커스텀 구분자에 대한 검증이었다.
1차 개발때에는 숫자에 대한 검증까지 개발을 했었는데 이 때, ValidateNum
이라는 클래스로 구분을 하긴 했는데 이걸 Controller
에서 관리를 했다. 원래는 Parser
클래스 내에서 파싱을 하면서 검증을 하려고 했는데, 이렇게 되면 Parser
라는 클래스의 역할이 너무 커질 것 같아서 분리를 했었다. 그렇게 되면서 Controller
에서 관리를 하도록 코드를 짜고 1차 개발을 마쳤다.
public class CalculatorController {
private final Parser parser = new Parser();
public void calculate() {
String input = InputView.getInput();
List<String> parsedList = parser.parseInput(input);
VerificationNum verificationNum = new VerificationNum(parsedList);
verificationNum.verifyAndParseNums();
Converter converter = new Converter(parsedList);
Calculator calculator = new Calculator(converter.convertList());
int total = calculator.addNums();
OutputView.printResult(total);
}
}
1차 개발을 마치고 2차 개발 전에 구조에 대한 생각을 좀 더 해봤다. 검증은 입력값에 대해서 하는것인데 일단 InputView
클래스에서 하고 싶지는 않았다. 해당 클래스는 딱 입력을 받는 용도로만 두고 싶었다. 이번 미션에서는 입력을 한 번밖에 받지 않아서 클래스가 작아보여 추가할까 했지만, 추가 요구사항이 들어올때를 생각했을 때 검증을 InputView
에서 하는것은 맞지 않는것 같았다.
그래서 1차개발 때Controller
에서 검증을 하도록 구현을 했는데 이 구조는 이상하다는 생각이 들었다. 그래서 Parser
클래스 내에서 파싱을 하면서 검증을 하는 방법으로 구현을 시작했다. 대신 Parser
클래스 내에 검증 메서드들을 두지는 않았고, 검증하는 클래스를 통해 Parser
클래스 내에서 검증하는 방식을 선택했다.
위의 과정에서 기존에 객체를 생성하면서 검증하던 방식을 static
하게 검증하는 방식으로 변경했다. 검증을 하는데 객체 생성까지 필요할 것 같지는 않았다. 그래서 2차 개발에서 변경하게 되었다.
2차 개발에서는 위에 말한것처럼 구조에 대한 신경을 많이 썼다. 우선 1차때 하지 않았던 커스텀 구분자에 대한 검증을 진행했다.
이 때 한가지 생각하지 못한 예외상황이 발생했다.
커스텀 구분자를 통해 들어온 구분자들에 예약어 관련된 구분자들이 있으면 위 사진과 같은 오류가 발생했다.
dangling quantifier '+'
String separator = String.format(",|:|%s", custom);
구글에 검색을 해보니 정규표현식에 사용되는 문자들을 구분자로 사용하게 될 때 발생하는 상황이었다.
해결 방법에는 []
로 감싸거나 \\
를 앞에 써주는 방식으로 입력을 하게 되면 괜찮다고 했는데 이렇게 입력을 하는것부터가 원하는 커스텀 구분자를 사용하지 못하는 상황 같아서 그다지 끌리지 않았다.
그래서 다른 방법을 찾았는데 그게 바로 Pattern.quote()
를 사용하는 방법이었다!
String separator = String.format(",|:|%s", Pattern.quote(custom));
위의 코드에서 이런식으로 custom
부분을 Pattern.quote()
을 통해서 감싸주게 되면 정규식에서 입력한 그대로 사용할 수 있다.
이렇게 간단한 코드 추가로 문제를 해결할 수 있었다.
처음에 생각한 구조는 입력값에 대한 구분을 Parser
클래스 내에서 다 하고 그에 대한 계산은 Calculator
클래스 내에서 하는것이었다.
Parser
클래스에서는
public List<String> parseInput(String input) {
if (input.isEmpty()) {
return List.of("0");
}
if (input.startsWith("//")) {
return parseCustomSeparator(input);
}
return parseBasicSeparator(input);
}
이런식으로 List<String>
값을 반환한다.
그리고 Calculator
클래스에서는 List<Integer>
를 받아 계산한다.
처음에는 Parser
클래스에서 자료형을 변환해서 반환하는 것이나 Calculator
클래스에서 변환하는 것을 생각하고 있었다.
하지만 두 클래스의 역할은 '구분하는 것'과 '계산하는 것'으로 확실하게 나뉘어져 있었다.
이 두 클래스들에게 List<String>
를 List<Integer>
형식으로 변환하는 역할을 추가로 부여하고 싶지는 않아서 새로운 클래스인 Converter
클래스를 만들어 자료형을 변환해주는 역할을 부여해줬다.
public class Converter {
private final List<String> stringList;
public Converter(List<String> stringList) {
this.stringList = stringList;
}
public List<Integer> convertList() {
return stringList.stream()
.map(Integer::parseInt)
.collect(Collectors.toList());
}
}
이렇게 기본 요구사항과 내가 생각한 상황에 대한 코드를 전부 구현했다고 생각했는데, 마지막 관문이 남아있었다.
어려운 상황은 아니었고 생각보다 쉽게 문제를 해결할 수 있었다.
바로 공백입력에 대한 테스트였다.
아무 입력도 들어오지 않았을때 0을 반환하도록 코드를 짜놓고 직접 실행시켜서 해봤을때는 제대로 0이 나와서 안심하고 있었다.
그런데 맨 처음에 테스트 코드를 추가하지 않아 마지막에 테스트 코드를 추가하고 테스트를 돌려봤는데 테스트를 통과하지 못했다는 메세지가 출력됐다..!
@Test
void 빈문자열_입력_0() {
assertSimpleTest(() -> {
run("");
assertThat(output()).contains("결과 : 0");
});
}
당황했는데 짐작이 가는 상황이 딱 하나 있었는데 그건 줄바꿈
이었다.
직접 프로그램을 실행 시켜 테스트를 할때는 공백값이 아니라 엔터
로 인한 줄바꿈이 들어간다. 하지만 테스트 코드는 ""
라는 진짜 아무값도 안들어가서 발생하는 상황이어서 테스트 코드를 run"\n"
로 변경해주니 통과할 수 있었다.
Console
클래스의 readLine()
를 보니
public static String readLine() {
return getInstance().nextLine();
}
를 통해 입력을 대기하고 있었고 엔터를 누르지 않으면 계속 대기를 해서 생기는 상황이었던 것 같다.
오랜만에 이렇게 깊은 생각을 하며 코드를 짜볼 수 있는 기회가 생겨서 재밌었던 것 같다.
1주차 미션은 요구사항이 그렇게 크지 않아서 기본 요구사항을 만족하고 내가 생각할 수 있는 다양한 상황에 대한 코드를 짤 수 있어서 좋았다.
빠른 시간내에 집중해서 개발을 하니까 생각의 흐름이 끊기지 않고 계속해서 개발을 하는 경험이 좋았다. 이 과정에서 너무 한쪽으로만 생각을 하다보니 시간이 오래 걸리는 상황도 있었지만 이번 기회를 통해 더욱 배울 수 있었다.
그리고 구현보다 코드의 구조를 생각하며 어떻게 하면 더 나은 구조를 가질 수 있을까 라는 생각을 하면서 계속해서 생각을 하고 다른 사람들의 상황도 보면서 공부를 해서 좋았다
얼른 다른 사람들과 코드 리뷰하는 시간을 가지며 내 코드를 좀 더 개선해보고 싶다!!!!