좋은 코드 작성하는 법
11월 7일에 카테부에서 좋은 코드 작성하는 법이라는 주제로 특강을 들었다.
카카오에서 사내 플랫폼을 개발하시는 charlotte 멘토님께서 진행한 특강이였다.
사실 이전까지는 좋은 코드가 무엇일까에 대해서 깊이 생각해보지 않았는데, 깊게 고민해본 계기가 된 특강이였다.
좋은 코드란 무엇일까?
클린 코드 SOLID 이펙티브 자바 등등 좋은 코드에 대한 자료는 많지만 정작 어떻게 내 코드에 녹여낼지는 정말 어려운 문제인 것 같다.
본 특강의 내용과 들으면서 들었던 생각들을 간단하게 정리해본다.
좋은 코드의 정의
좋은 코드란 무엇일까?
멘토님께서도 끊임없이 고민하는 주제라고 하셨다. 아래와 같은 순서로 좋은 코드에 대한 생각이 점차 바뀌셨다고 말씀해 주셨다.
- 성능이 좋고 짧은 코드
- 잘 동작하는 코드
- 읽기 좋은 코드
- 테스트 가능한 코드
좋은 코드는 상황에 따라 다르다!
모든 소프트웨서의 요구사항은 다르다. 비슷할 수는 있지만 조금씩은 다르다.
결국은 요구사항을 잘 반영한 코드가 좋은 코드이다.
- POC 중이라면 빠른 구현과 테스트
- 고성능이 중요하다면 성능 최적화에 중점
- 의료 금융 같은 분야면 안정성이 최우선
하나의 요소로만 정의할 수 없고 여러 요소를 고려해야한다.
멘토님께서 강조하신 부분은 좋은 코드는 결국 상황에 따라 다르다는 것이다.
좋은 코드를 작성하기 위해서는 상황을 읽고 요구사항을 잘 분석할 수 있어야 한다고 느껴졌다.
좋은 코드는 유지보수하기 좋다!
좋은 코드를 결국 한가지로 정의할 수는 없지만 그럼에도 가장 중요한 것은 무엇일까?
멘토님께서는 지속 가능성을 꼽으셨다.
대부분의 경우 소프트웨어의 기능은 점차 커지게 된다. 이때 기존의 코드에 새로운 코드를 작성하면서 코드 품질은 점차 떨어지는 것이 필연적이다. 이러한 문제에 대응하기 위해서는 적절한 유연성을 가진 유지보수하기 좋은 코드를 작성해야 한다.
현재 내가 가진 지식으로는 조금은 이해하기 힘들었다. 적절한 유연성을 가진 유지보수하기 좋은 코드라는 것이 참 모호하게 느껴졌다.
방법론보다 지향점 보기
멘토님께서 또 강조하셨던 부분은 방법론보다 지향점 보기였다.
좋은 코드를 작성하기 위한 방법론은 인터넷만 보아도 엄청나게 많은 것을 볼 수 있다. 그렇지만 멘토님께서 강조하신 부분은 이러한 방법론을 무작정 따르기 보다는 각각의 지향점에 대해서 직접 고민해보라는 것이였다.
- 가독성
- 확장성
- 안정성
- 테스트 용이성
위와 같은 지향점들을 고민하면서 코드를 작성하는 것이 중요하다고 해주셨다. 물론 방법론을 따르는 것도 절대 나쁜 방법이 아니라고 하셨다. 그렇지만 각각의 지향점에 대해서 고민하는 것이 많은 도움이 될 것이라고 하셨다.
직접 코드를 수정해보기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class StringCalculator {
public int calculate(String expression) {
if (expression.contains("+")) {
String[] numbers = expression.split("\\+");
return Integer.parseInt(numbers[0]) + Integer.parseInt(numbers[1]);
} else if (expression.contains("-")) {
String[] numbers = expression.split("-");
return Integer.parseInt(numbers[0]) - Integer.parseInt(numbers[1]);
} else if (expression.contains("*")) {
String[] numbers = expression.split("\\*");
return Integer.parseInt(numbers[0]) * Integer.parseInt(numbers[1]);
} else if (expression.contains("/")) {
String[] numbers = expression.split("/");
return Integer.parseInt(numbers[0]) / Integer.parseInt(numbers[1]);
}
throw new IllegalArgumentException("Unsupported operation");
}
}
다음으로는 위와 같은 예제 코드를 가지고 멘토님과 함께 직접 리팩토링 해보았다. 먼저 위 코드가 가지는 문제점에 대해서 함께 고민해 보았다.
위 코드가 가지는 문제점은?
- 하는 일이 너무 많다.
- 문자열을 파싱하고 연산자를 가지고 연산을 수행해 결과값을 리턴하는 역할까지!
- 중복되는 부분이 많다.
- 예외처리가 부족하다.
- 0으로 나눌 때, Integer로 파싱할 때 문제 발생 가능!
위와 같은 여러가지 문제들이 있었다. 여기서 무작정 코드를 수정하면 어떻게 될까? 잘못된 코드를 작성해도 대응하기 어려울 것이다.
멘토님은 먼저 테스트 코드를 작성하는 것이 좋다고 하셨다.
테스트 코드 작성하기
테스트 코드에서는 어떤 사항을 테스트해야 할까?
- 정상적으로 동작하는 상황
- 예외 사항
- 경계 조건
멘토님께서는 최소한 위의 세가지 사항에 대해서 테스트 코드를 작성해야 한다고 말씀해 주셨다. 지금은 실패하는 테스트 코드를 작성하고 해당 테스트 코드를 성공하는 방식으로 코드를 작성하는 방향(TDD)도 좋다고 하셨다.
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
class StringCalculatorTest {
@ParameterizedTest
@DisplayName("사칙 연산을 수행할 수 있다.")
@CsvSource({
"1+2, 3",
"3-2, 1",
"4*2, 8",
"6/2, 3"
})
void calculate(String expression, Integer expected) {
// given 주어진 상황을 (상황)
StringCalculator calculator = new StringCalculator();
// when 실행 하면 (행동)
// then 결과가 이렇게 된다. (검증)
assertEquals(expected, calculator.calculate(expression));
}
@Test
@DisplayName("0으로 나눌 때 IllegalArgumentException을 던진다.")
void divide_with_zero() {
StringCalculator calculator = new StringCalculator();
assertThrows(IllegalArgumentException.class, () -> calculator.calculate("1/0"));
}
@ParameterizedTest
@DisplayName("숫자 대신 문자가 들어올 때 IllegalArgumentException을 던진다.")
@ValueSource(strings = {
"a-0",
"0+b",
"1*c",
"d/2"
})
void invalid_number(String expression) {
StringCalculator calculator = new StringCalculator();
assertThrows(IllegalArgumentException.class, () -> calculator.calculate(expression));
}
@Test
@DisplayName("지원하지 않는 연산자가 들어올 때 IllegalArgumentException을 던진다.")
void unsupported_operation() {
StringCalculator calculator = new StringCalculator();
assertThrows(IllegalArgumentException.class, () -> calculator.calculate("1^2"));
}
@Test
@DisplayName("정수 범위를 초과하는 숫자가 들어올 때 IllegalArgumentException을 던진다.")
void over_int_range_number() {
StringCalculator calculator = new StringCalculator();
assertThrows(IllegalArgumentException.class, () -> calculator.calculate("2147483648+1"));
}
}
여러 순서를 거쳐서 테스트 코드를 작성하였지만 궁극적인 테스트 코드는 위와 같았다. 멘토님께서 이 테스트 코드가 무조건 좋은 테스트 코드는 아니라고 하셨고 이 또한 소프트웨어의 요구사항 및 확장 방향에 따라 달라질 것이라고 하셨다.
이렇게 짧은 코드에도 이렇게 많은 테스트 코드가 필요하다니 그간 작성했던 코드를 반성하게 되었다. 그동안은 하나의 비즈니즈 로직 당 한 개 혹은 두 개의 테스트 코드만 작성했었는데 앞으로는 더욱 치밀하게 테스트 코드를 작성해야 겠다는 생각이 들었다.
리팩토링한 코드
1
2
3
public interface Operation {
int apply(int left, int right);
}
1
2
3
4
5
6
7
8
9
public class DivideOperation implements Operation {
@Override
public int apply(int left, int right) {
if (right == 0) {
throw new IllegalArgumentException("Divide by zero");
}
return left / right;
}
}
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
public enum Operator {
ADD("+", new AddOperation()),
MINUS("-", new MinusOperation()),
MULTIPLY("*", new MultipleOperation()),
DIVIDE("/", new DivideOperation());
private final String symbol;
private final Operation operation;
Operator(String symbol, Operation operation) {
this.symbol = symbol;
this.operation = operation;
}
public static Operator find(String expression) {
for (Operator operator : values()) {
if (expression.contains(operator.symbol)) {
return operator;
}
}
throw new IllegalArgumentException("Unsupported operation");
}
public String getSymbol() {
return symbol;
}
public int applyOperation(int left, int right) {
return operation.apply(left, right);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class StringCalculator {
public int calculate(String expression) {
Operator operator = Operator.find(expression);
String[] numbers = expression.split("\\" + operator.getSymbol());
int left = parseNumber(numbers[0]);
int right = parseNumber(numbers[1]);
return operator.applyOperation(left, right);
}
private Integer parseNumber(String number) {
try {
return Integer.parseInt(number);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid number");
}
}
}
리팩토링한 코드를 간단하게만 설명하면 우선은 반복되는 구조를 추상화하여서 Operation인터페이스를 만들어서 각 연산에서 재사용 가능하도록 하였다.
다음으로는 연산자는 고정적인 상수이므로 Operator Enum 클래스로 분리하였고 Enum 클래스 내부에서 연산까지 관리하도록 코드를 수정하였다.
그리고 0으로 나눌 때, Integer로 파싱할 때 등의 예외 상황에 처리 가능하도록 코드를 추가해 주었다.
그렇다면 리팩토링한 코드는 좋은 코드일까?
어떻게 보면 좋은 코드라고 할 수 있지만 기능 확장을 고려하면 부족한 부분은 존재한다. 그리고 어떻게 보면 클래스가 많아지면서 한 눈에 코드를 파악하기 어려워 졌다고 할 수도 있다.
그렇지만 코드의 유지 보수성을 고려하였을 때는 이전 코드보다는 훨씬 나아졌다는 것을 느낄 수 있었다.
마무리하며
좋은 코드를 작성하는 것이 아무도 알아주지 않을 수도 있다. 그렇지만 다른 사람은 몰라도 내가 안다!
멘토님께서는 코드를 작성할 때 항상 좋은 코드를 작성해야 겠다는 생각을 머리에 가지고 고민하면서 코드를 작성하면 어느새 한층 성장한 자신을 볼 수 있을 것이라고 하셨다.
어쩌면 궁극적으로 좋은 코드란 존재하지 않을 것이라는 생각이 들었다. 그렇지만 끊임없이 의심하고 고민하면서 코드를 작성하면 앞으로 나아갈 수는 있을 것이다. 나도 끊임없이 의심하고 고민하면서 코드를 작성해야겠다는 생각이 들었다.