[우아한테크코스] 프리코스 1주차 후기
1주차 프리코스 과제는 문자열 덧셈 계산기를 구현하는 문제였다. 정상적인 입력에 대한 구현은 크게 어렵지는 않다고 느꼈지만, 비정상적인 입력에 대한 처리는 제법 까다로운 과제라는 생각이 들었다. 어떤 입력까지는 정상적인 입력으로 간주하고 어떤 입력부터는 비정상적인 입력으로 간주할 지 개발자의 판단에 맡긴다는 느낌이 들었고 스스로 기준을 정해서 구현했다.
1. 애플리케이션 아키텍처 설계
애플리케이션 아키텍처를 어떻게 구성하는게 좋을지가 큰 고민점 중 하나였다. 간단하게는 Application 클래스 하나로만 해결할 수도 있고, 기타 다양한 아키텍처와 디자인 패턴을 적용한 복잡한 설계를 선택할 수도 있다고 봤다. 개인적으로는 해당 과제의 목적은 애플리케이션의 정확한 동작과 꼼꼼한 예외처리가 메인이지 않을까 생각해서 간단하게 구성하기로 마음 먹었다.
기본으로 주어지는 Application 클래스는 입출력을 담당하는 클라이언트로 활용했고 Application 클래스가 문자열 덧셈 계산을 위한 StringSumCalculator 클래스를 활용해 요청에 대한 응답을 하는 방향으로 잡았다. StringSumCalculator 클래스는 문자열 덧셈 기능 하나만 제공하는 클래스로 문자열을 파싱해서 의미 있는 데이터로 변환하는 과정은 DelimiterParser 클래스가 책임지도록 했다. 이를 통해 입출력과 문자열 덧셈 계산, 문자열 파싱을 각각 책임지도록 분리할 수 있었다.
StringSumCalculator와 DelimiterParser 두 클래스는 무상태라는 특징이 있다고 판단하여 유틸리티 클래스와 싱글톤 클래스 중 하나로 구현하는 것을 고민했는데 DI를 통한 유연함까지는 고려하지 않아도 될 것으로 판단하여 간단한 유틸리티 클래스로 구현했다.
2. Application 클래스
문자열 덧셈 계산기를 활용한 입출력을 담당하는 Application 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Application {
public static void main(String[] args) {
try {
System.out.println("덧셈할 문자열을 입력해 주세요.");
String line = Console.readLine();
BigInteger result = StringSumCalculator.calculate(line);
System.out.println("결과 : " + result);
} finally {
Console.close();
}
}
}
main 메서드의 종료로 자원 반납이 이루어져 try ~ finally를 활용한 자원 반납이 큰 의미가 있지는 않다고 봤지만 좋은 코딩 습관에 의미를 두며 명시해줬다. try with resources를 활용한 자원 반납이 최근 권장되는 트렌드로 알고 있는데 Console이 정적 Scanner를 가지고 있는 구조라 해당 방식의 적용이 안됐다.
사용자의 입력은 String 그대로 받아서 문자열 덧셈 계산기에 넘겨주어 입력값의 유효성에 대한 책임은 Application이 갖지 않도록 했다. Application이 유효성 검증을 할 경우 문자열에 대한 유효성 검증과 파싱 두 단계에서 문자열을 분석하는 로직이 반복되어 비효율적일 것으로 판단했다.
문자열 덧셈 계산기의 경우 결과를 BigInteger 타입으로 반환하게 했는데 입력 값의 범위 제한이 없어서 int 타입을 활용하여 범위에 대한 고려는 생략하거나 아예 BigInteger로 큰 수 처리도 가능하게 하거나 두 가지 선택지가 있다고 보였다. 오버플로우를 고려해 long 타입을 활용하는건 별 의미가 없다고 여겨 BigInteger를 선택했다.
3. StringSumCalculator 클래스
문자열 덧셈 계산기 역할을 수행하는 StringSumCalculator 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 문자열 덧셈 계산기 클래스
*/
public class StringSumCalculator {
// 유틸리티 클래스 객체 생성 및 상속 방지
private StringSumCalculator() {
throw new AssertionError();
}
/**
* DelimiterParser의 파싱 결과인 숫자 리스트의 합을 계산하는 메서드
*
* @param line
* @return 문자열의 숫자의 합
*/
public static BigInteger calculate(String line) {
return DelimiterParser.parse(line).stream()
.reduce(BigInteger.ZERO, BigInteger::add);
}
}
유틸리티 클래스로 설계했으니 가변 필드는 사용하지 않았고, 클래스 내외부 모두 인스턴스 생성 및 상속을 방지할 수 있게 생성자를 구현했다.
메서드는 파라미터의 문자열에 대해 DelimiterParser을 통해 파싱한 결과의 합을 반환하는 calculate 메서드 하나만 두었다. 파싱한 결과는 List<BigInteger>로 반환되는데 스트림 API를 활용해 간단하게 합을 반환할 수 있었다.
4. DelimiterParser 클래스
문자열 파싱을 수행하는 DelimiterParser 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
* 문자열 파싱 전용 유틸리티 클래스
*/
class DelimiterParser {
// 기본 구분자
private static final String DEFAULT_DELIMITERS = ",:";
// 커스텀 구분자 패턴
private static final Pattern CUSTOM_DELIMITERS_PATTERN = Pattern.compile("^//(.*?)\\\\n");
// 유틸리티 클래스 객체 생성 및 상속 방지
private DelimiterParser() {
throw new AssertionError();
}
/**
* line에 커스텀 구분자가 있으면 커스텀 구분자를 추출하는 메서드
*
* @param line
* @return Optional로 감싼 커스텀 구분자
*/
public static Optional<String> getCustomDelimiters(String line) {
return Optional.of(CUSTOM_DELIMITERS_PATTERN.matcher(line))
.filter(Matcher::find)
.map(m -> m.group(1));
}
/**
* line에 커스텀 구분자가 있으면 해당 패턴을 제거한 문자열을 추출하는 클래스
*
* @param line
* @return 패턴 부분을 제거한 line
*/
public static String removeCustomDelimiterPattern(String line) {
return line.replaceFirst(CUSTOM_DELIMITERS_PATTERN.pattern(), "");
}
/**
* line에 구분자를 적용하여 분리된 숫자를 추출하는 메서드
* 커스텀 구분자가 있으면 커스텀 구분자 패턴 뒷부분부터 구분자를 적용하여 파싱
* 커스텀 구분자가 없으면 기본 구분자를 적용하여 파싱
*
* @param line
* @return 추출한 숫자로 이루어진 List
*/
public static List<BigInteger> parse(String line) {
return getCustomDelimiters(line)
.map(d -> {
if (d.isEmpty()) throw new IllegalArgumentException("커스텀 구분자가 비어있습니다.");
return parseWithDelimiter(removeCustomDelimiterPattern(line), d);
})
.orElseGet(() -> parseWithDelimiter(line, DEFAULT_DELIMITERS));
}
/**
* line에 구분자를 적용하여 분리된 숫자를 추출하는 메서드
*
* @param line
* @param delimiter
* @return 추출한 숫자로 이루어진 List
*/
private static List<BigInteger> parseWithDelimiter(String line, String delimiter) {
return Arrays.stream(line.split("[" + delimiter + "]", -1))
.filter(s -> !s.isEmpty())
.map(DelimiterParser::convertToBigInteger)
.toList();
}
/**
* 주어진 문자열을 BigInteger로 변환하는 메서드
*
* @param number
* @return 변환된 숫자
*/
private static BigInteger convertToBigInteger(String number) {
BigInteger bigInteger = validate(number);
if (bigInteger.signum() <= 0) {
throw new IllegalArgumentException("음수는 입력할 수 없습니다.");
}
return bigInteger;
}
/**
* String -> BigInteger 변환이 가능한지 체크하고 변환하는 메서드
*
* @param number
* @return 변환된 숫자
*/
private static BigInteger validate(String number) {
try {
return new BigInteger(number);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("잘못된 입력값입니다.");
}
}
}
DelimiterParser 역시 유틸리티 클래스로 설계하여 클래스 내외부 인스턴스 생성 및 상속을 방지했고, 가변 필드를 사용하지 않았으며, public class로 열지 않아서 애플리케이션 전반에 활용 가능한 유틸리티 클래스가 아니라는 의도를 보였다. 해당 클래스는 오직 StringSumCalculator에서만 사용하는 것이 목적이었으므로 굳이 공개 API로 둘 필요가 없다고 생각했다.
핵심 메서드는 parse 메서드로 커스텀 구분자가 없으면 기본 구분자로 문자열을 파싱하고 커스텀 구분자 있으면 커스텀 구분자로 파싱하도록 했다. 이때 커스텀 구분자를 명시한 문자열 앞부분은 잘라낸 뒷부분에 대해 판단해야 하는 문제를 찾는게 시간이 좀 걸렸다.
파싱을 split과 패턴 매칭을 활용하여 숫자로 예상되는 부분을 잘라내고 숫자로 변환이 가능하면 변환하면 된다. 이 과정에서 음수, 잘못된 입력값 등 예외를 던지도록 했고 IllegalArgumentException이 Unchecked Exception이라서 예외 누적 없이 깔끔해보이게 구성할 수 있었다. 생각보다 예외 검증 부분이 많아서 깔끔한 구조에 대한 어려움이 있었다.
5. 정상적인 입력 vs 비정상적인 입력
과제 요구사항에 명시되지 않은 내용은 개발자의 판단으로 구현해야 했는데 어느정도까지 정상입력으로 볼 지에 대한 기준이 필요했다.
나 같은 경우 아래 입력들은 정상적인 입력으로 판단했다.
int,long타입을 넘어가는 양수 값- 기본 구분자로만 구성되거나 문자열 양 끝이 숫자로 끝나지 않거나 기본 구분자가 연달아 있는 경우
",:"->0",1,2,3"->6
- 커스텀 구분자 문법을 사용했지만 커스텀 구분자를 명시하지 않은 경우
"//\n"->0
- 커스텀 구분자로 길이 2 이상의 문자열을 입력한 경우 각 문자를 커스텀 구분자 토큰으로 판단
"//;$\n1;2$3"->6
6. 과제 후기
해당 과제를 수행하며 다양한 입력에 대한 결과 값을 잘 정의하는 것이 무엇보다 중요하다는 것을 느꼈다. 로직을 조금만 수정해도 예상한 결과랑 다르게 나올 수 있으니 미리 입력별 예상 결과를 정의하고 이를 테스트 코드로 작성해서 로직을 구현한 후 테스트 코드를 통해 예상대로 잘 동작하는지 검증하는게 무엇보다 중요하다고 느껴졌다.
애플리케이션 아키텍처적으로는 구현 후 돌아보면 유틸리티 클래스보다는 싱글톤 기반의 설계가 더 적합했을 것으로 생각되었다. 유틸리티 클래스를 활용하니 확장이나 변경에 제약이 있다고 느껴져서 객체 지향 프로그래밍을 거슬러 간다는 느낌이 들었고 테스트 코드 작성도 불편하게 느껴졌다.
공식적인 1주차 피드백에서는 배열 대신 컬렉션을 활용하는게 좋다는 피드백이 가장 인상적이었는데 최근 배열의 공변으로 인한 문제점과 제네릭의 불공변 및 타입 안전성을 학습하며 숫자들을 리스트로 반환했던 것이 잘 맞아 떨어져서 앞으로도 컬렉션을 적극 활용할 것 같다.
