2장_ 문자열 계산기 추가 요구사항 : 중복 제거, 읽기 좋은 코드를 구현하기 위한 리팩토링

2026. 1. 27. 20:45·Book/자바 웹 프로그래밍 Next Step

 

문자열 계산기를 구현하면서 제일 크게 느낀 건 이거였다.

요구사항을 하나씩 추가할 때마다 코드가 생각보다 훨씬 빨리 더러워진다.

처음엔 “그냥 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
'Book/자바 웹 프로그래밍 Next Step' 카테고리의 다른 글
  • 3장_ 원격 서버 배포 전 준비 : UTF-8 세팅하기
  • 3장_ 실습 환경 구축
  • 2장_ 문자열 계산기 리팩토링: JUnit으로 요구사항 검증하면서 구현해보기
  • 2장_ 문자열 계산기 : main()으로 찍어보는 테스트, 왜 결국 막히는가?
sqaxe1
sqaxe1
woojoo-devlog 님의 블로그 입니다.
  • sqaxe1
    Woojoo's Devlog
    sqaxe1
  • 전체
    오늘
    어제
    • 분류 전체보기 (148)
      • Backend (9)
        • Servlet (7)
        • Spring (2)
      • Frontend (1)
      • CS (0)
      • Book (33)
        • 자바 웹 프로그래밍 Next Step (30)
        • 테스트 주도 개발: 고품질 쾌속개발을 위한 TDD.. (1)
        • 성공과 실패를 결정하는 1%의 네트워크 원리 (2)
      • Engineering (0)
        • Testing (0)
      • Infra (6)
        • AWS (6)
      • Java (4)
      • Network (1)
      • 김영한 (28)
        • 자바 입문 (8)
        • 실전 자바 - 기본편 (6)
        • 실전 자바 - 중급편 (10)
        • 실전 자바 - 고급편 (4)
      • Web (39)
        • Web Basics (39)
      • Project (24)
        • NeoSquare (0)
        • Memo Evolution (24)
      • 정보처리기사 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    개발서적
    aws
    java
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
sqaxe1
2장_ 문자열 계산기 추가 요구사항 : 중복 제거, 읽기 좋은 코드를 구현하기 위한 리팩토링
상단으로

티스토리툴바