문자열 계산기를 구현하면서 제일 크게 느낀 건 이거였다.
요구사항을 하나씩 추가할 때마다 코드가 생각보다 훨씬 빨리 더러워진다.
처음엔 “그냥 add() 안에 if 하나 추가하면 되겠지”라고 들어갔는데,
조금만 진행하면 분기랑 예외 처리랑 파싱이 한 메서드에 엉겨 붙는다.
그리고 한 번 엉키기 시작하면 다음 요구사항이 들어올 때마다 코드를 “끼워 넣는” 방식으로만 진화한다.
이게 쌓이면 어느 순간부터는 기능 하나 추가하는데도 머리가 터진다.
그래서 책에서 말하는 리팩토링 규칙이 왜 중요한지 이제야 감이 왔다.
리팩토링 규칙 3개
- 메서드가 한 가지 책임만 가지도록 구현
- 인덴트(들여쓰기) 깊이를 1단계로 유지
- if나 while/for가 들어가면 깊이가 1 늘어남
- else 사용하지 말기
처음엔 “이게 너무 빡센 거 아닌가?” 싶었는데, 실제로 문자열 계산기 같은 과제에서 이 규칙이 왜 나오냐면 한 가지 때문이었다.
코드 복잡도가 쉽게 증가하는 이유는
하나의 요구사항을 완료한 후 리팩토링을 하지 않은 상태에서 다음 단계로 넘어가기 때문이다.
즉, **기능 구현 → 출력 확인(또는 테스트 확인)**하고 바로 다음 요구사항으로 넘어가면,
이전 단계에서 만든 찌꺼기가 그대로 다음 단계의 발판이 된다.
그러면 다음 단계는 더 지저분해지고, 그 다음은 더더 지저분해진다.
복잡도가 선형이 아니라 “눈덩이”처럼 커진다.
“단계의 끝” 기준을 바꿔야 한다
내가 원래 하던 방식은 사실 이거였음.
- 구현 ⇒ 실행해봄 ⇒ 결과 맞네? ⇒ 다음 기능 ㄱㄱ
근데 책이 말하는 기준은 다르다.
각 단계에서 다음 단계로 넘어가기 위한 작업의 끝은
내가 기대하는 결과를 확인했을 때가 아니라
결과를 확인한 후 리팩토링까지 완료했을 때이다.
그래서 흐름이 이렇게 바뀐다.
구현 ⇒ 테스트로 결과 확인 ⇒ 리팩토링
이 구조가 중요한 이유는 명확하다.
- 테스트가 없으면 리팩토링이 무섭다
- → “바꿨는데 깨졌는지”를 내가 눈으로 다 확인해야 함
- 테스트가 있으면 리팩토링이 가능해진다
- → 실패하면 바로 알 수 있으니까 마음 놓고 구조를 정리할 수 있음
결국 테스트는 “검증”만 하는 게 아니라, 리팩토링을 가능하게 해주는 안전망이다.
근데 왜 “극단적으로” 리팩토링하라고 하냐
문자열 계산기 같은 과제는 요구사항이 누적되면서 분기가 늘어난다.
- 빈 문자열이면 0
- 기본 구분자 , :
- 커스텀 구분자 //;\\n
- 빈 토큰 스킵
- 음수면 예외
이걸 add() 메서드 하나에 몰아 넣으면 거의 무조건 이런 코드가 된다.
- if 안에 if
- else 안에 for
- for 안에 if
- 중간에 예외 처리 섞임
즉, 인덴트 깊이 3~4는 금방 가고,
나중엔 “내가 지금 어떤 조건에서 어떤 분기 타고 있는지” 추적 자체가 어렵다.
그래서 “극단 리팩토링”은 그냥 깔끔함을 위한 게 아니라:
코드가 복잡해지기 전에
복잡해질 수 있는 구조를 아예 못 만들게 막는 장치
이 느낌이다.
문자열 계산기 예제로 보는 “극단 리팩토링” 흐름
내가 처음 만든 프로덕션 코드는 대충 이런 형태였다.
package jwp_2nd_onion.src;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class StringCalculator {
public int add(String text) {
if(text == null || text.isEmpty()) {
return 0;
}
String[] tokens;
Pattern p = Pattern.compile("//(.)\\n(.*)");
Matcher m = p.matcher(text);
if(m.find()) {
String customDelimeter = m.group(1);
tokens = m.group(2).split(customDelimeter);
} else {
tokens = text.split(",|:");
}
int sum = 0;
for (String token : tokens) {
if(token.isEmpty()) continue;
int n = Integer.parseInt(token);
if (n < 0) {
throw new IllegalArgumentException("음수는 허용되지 않습니다.: " + n);
}
sum += n;
}
return sum;
}
}
문제는 이게 지금은 괜찮아 보이는데,
테스트 케이스가 늘어나고 요구사항이 추가될수록 add()가 “모든 걸 하는 메서드”가 되는 순간부터 망하기 시작한다.
그래서 리팩토링은 “동작 확인 후” 바로 들어가야 한다.
0단계: 시작점(한 방에 다 처리하는 add)
처음 구현은 이런 식이다.
- 빈 문자열이면 0 리턴
- 커스텀 구분자면 정규식으로 분리 후 split
- 아니면 ,|:로 split
- 토큰 돌면서 parseInt + 음수 체크 + 합
이 상태에서 테스트는 대충 아래부터 시작한다.
테스트(최소 시작)
- "1,2,3" → 6
- "1,-2,3" → 예외
이제 중요한 건 코드를 “동작하게 만든 뒤” 바로 구조를 정리하는 것.
1단계: add()에서 “빈 입력 처리”를 먼저 밖으로 뺀다
최종 코드 보면 add()는 처음에 isBlank(text)만 검사하고 끝이다.
그래서 첫 리팩토링은 딱 이거 하나.
변경 전(add에 직접 체크)
if (text ==null || text.isEmpty()) return0;
변경 후(의도를 이름으로 고정)
public int add(String text) {
if (isBlank(text)) {
return 0;
}
// 나머지 로직...
}
private boolean isBlank(String text) {
return text == null || text.isEmpty();
}
여기서 얻는 게 뭐냐면, add()를 읽을 때 “빈 입력이면 0”이 문장처럼 보이기 시작한다는 거다.
이게 쌓이면 최종적으로 add()가 “한눈에 읽히는 흐름”이 된다.
2단계: add()에서 “토큰 분리(split)”를 통째로 빼낸다
다음으로 add() 안에서 제일 복잡해지는 부분이 “구분자 처리”다.
- 커스텀인지?
- 기본인지?
- split 패턴은 뭐지?
이건 무조건 분기가 생겨서 add()가 깊어지기 시작한다.
그래서 통째로 split(text)로 빼는 게 다음 단계다.
public int add(String text) {
if (isBlank(text)) {
return 0;
}
String[] values = split(text);
// 다음 단계에서 숫자 변환, 합산...
return 0;
}
여기까지 오면 add()는 “입력 검사 → 분리”까지만 책임진다.
(아직 합산/예외 로직은 남아있지만 방향이 잡힘)
3단계: split() 안에서 “커스텀 구분자 파싱”을 책임지게 만든다
Matcher m = Pattern.compile("//(.)\\n(.*)").matcher(text);
if (m.find()) {
String customDelimiter = m.group(1);
return m.group(2).split(customDelimiter);
}
return text.split(",|:");
이걸 만들기 위한 리팩토링 순서는 이렇게 가는 게 자연스럽다.
1) split()는 일단 기본 구분자만 처리하도록 시작
private String[] split(String text) {
return text.split(",|:");
}
2) 커스텀 구분자 테스트를 추가한다 (여기서부터 “한 단계씩 테스트 추가”가 의미 있음)
테스트를 바로 “전부” 쓰는 게 아니라,
새 요구사항을 넣고 싶으면 테스트를 하나 추가하고, 그걸 통과시키는 식.
@Test
void customDelimiter() {
assertEquals(6, strCal.add("//;\\n1;2;3"));
}
테스트가 실패하면 이제 split()을 확장한다.
3) 정규식으로 커스텀 선언을 감지하고 분리
private String[] split(String text) {
Matcher m = Pattern.compile("//(.)\\n(.*)").matcher(text);
if (m.find()) {
String customDelimiter = m.group(1);
return m.group(2).split(customDelimiter);
}
return text.split(",|:");
}
여기서 포인트는:
- m.group(1) = 커스텀 구분자 1글자
- m.group(2) = 실제 숫자 본문
4단계: “숫자 변환”을 toInts()로 분리한다
이제 add()의 역할을 더 줄여야 한다.
최종 형태는 이것이다.
return sum(toInts(split(text)));
즉 “split → toInts → sum” 파이프라인.
그래서 String[]을 int[]로 바꾸는 책임을 toInts()로 보낸다.
private int[] toInts(String[] values) {
int[] numbers = new int[values.length];
for (int i = 0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
이 상태에서 add()는 이렇게 바뀐다.
public int add(String text) {
if (isBlank(text)) {
return 0;
}
return sum(toInts(split(text)));
}
여기까지 오면 add()가 거의 최종 형태가 된다.
(이제 남은 건 음수 예외 같은 “정책” 분리)
5단계: 음수 예외 검증을 toPositive()로 고정한다
음수 예외는 “숫자 하나를 만들 때의 규칙”이다.
그래서 최종 코드처럼 toInts()는 변환만 담당하고, 검증은 toPositive()로 뺀다.
private int[] toInts(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;
}
이제 “음수면 예외” 정책은 toPositive() 단 하나의 위치로 고정된다.
나중에 “음수면 예외 메시지”, “여러 음수 한 번에 모아서 출력” 같은 요구사항이 와도 수정 지점이 명확하다.
이 단계 끝 기준: assertThrows 테스트가 계속 통과
6단계: 합산(sum)도 한 가지 책임만 남긴다
최종 sum()은 이런 형태다.
private int sum(int[] numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
여기까지 오면 최종 형태가 완성된다.
최종: add()는 “흐름만” 보여주는 함수가 된다
코드의 핵심은 이 한 줄이다.
return sum(toInts(split(text)));
이게 왜 좋냐면, add()를 봤을 때 “이 프로그램이 뭘 하는지”가 끝난다.
- 빈 값이면 0
- 아니면 split해서
- int로 바꾸고
- 합산
세부 구현은 아래 private 메서드들이 책임지고,
각 메서드는 한 가지 일만 하니까 다음 요구사항이 와도 흔들리지 않는다.
결론
- 기능을 하나 추가하면 코드 복잡도가 커진다(이건 피할 수 없음)
- 근데 리팩토링을 단계마다 해두면, 복잡도가 “쌓이지” 않는다
- 테스트가 있으면 리팩토링이 무섭지 않다
- 그래서 결국 “구현 → 테스트 확인 → 리팩토링”이 한 세트가 된다
출처 : 《자바 웹 프로그래밍 Next Step》, 박재성, 로드북
'Book > 자바 웹 프로그래밍 Next Step' 카테고리의 다른 글
| 3장_ 원격 서버 배포 전 준비 : UTF-8 세팅하기 (0) | 2026.01.28 |
|---|---|
| 3장_ 실습 환경 구축 (0) | 2026.01.28 |
| 2장_ 문자열 계산기 리팩토링: JUnit으로 요구사항 검증하면서 구현해보기 (0) | 2026.01.27 |
| 2장_ 문자열 계산기 : main()으로 찍어보는 테스트, 왜 결국 막히는가? (0) | 2026.01.27 |
| 왜 이 책으로 시작했나 (0) | 2026.01.27 |
