문자열 계산기 풀이

이프·2025년 10월 20일

woowacourse

목록 보기
4/9

먼저 기능 구현부터하자!

최종 테스트를 대비해 빠르게 구현하는 습관을 먼저 들이기로 했습니다.

그래서 TDD와 같은 개발방법론보다는 우선 구현하는 것에 초점을 뒀습니다.

레이어 분리

레이어는 MVC 패턴을 차용해 각 관심사를 분리했습니다.

  • View - Presentation Layer(UI)
    • 입/출력을 담당하는 레이어
  • Controller - Business Layer(Application)
    • 앱 동작(비즈니스 흐름)을 담당하는 레이어
  • Model - Domain Layer(Domain)
    • 도메인의 비즈니스 로직을 담당하는 레이어

이를 패키지 구조로 아래와 같이 작성했습니다.

calculator/
├── View.class
├── Controller.class
├── StringCalculator.class
└── Application.class

요구사항 분석

1. 계산기는 기본 구분자(`:`, `,`)를 가진다.
2. 계산기는 커스텀 구분자를 지정할 수 있다.
    * 커스텀 구분자는 문자열 앞부분의 `//`와 `\n` 사이에 위치하는 `문자`를 커스텀 구분자로 사용한다.
    * `//.\n` -> 커스텀 구분자 = `.`
3. 분리한 각 숫자의 합을 반환한다.
4. 빈 문자열일 경우 0을 반환한다.
5. 잘못된 입력 시 `IllegalArgumentException`이 발생한다.
    * 양수가 아니거나 구분자 외 문자가 있는 경우
    * 프로그램은 즉시 종료된다.

요구사항을 토대로 기능 구현 목록을 작성하니 세가지 구현 스텝이 나왔습니다.

  • 1 → 3 → 5
  • 4
  • 2

해당 순서로 기본 구분자용 계산기와 커스텀 구분자용 계산기를 만들면 되겠다는 생각이 들었습니다.

기능 구현하기

명확한 구현 순서가 정해졌으므로 해당 순서대로 기능을 구현합니다.

Controller와 View 구현하기

  • View와 Controller는 레이어 계층에 따라 구현을 마쳤고, 도메인 구현은 아래와 같습니다.

도메인 로직 구현: 계산기는 기본 구분자(:, ,)를 가진다.

  • 파싱하는 과정에서 정규식으로 문제를 해결했습니다.
  • 처음에는 직접 파싱했지만, 뒤에 나오는 커스텀 구분자로 인해 정규식을 선택했습니다.

분리한 각 숫자의 합을 반환한다.

  • 파싱해서 생성한 numbers로 합을 구한 뒤 반환하는 방식으로 간단히 구현했습니다.

잘못된 입력 시 IllegalArgumentException이 발생한다.

  • 파싱 로직에서 양수가 아닌 경우, 숫자로 파싱이 실패한 경우 예외가 발생하도록 처리했습니다.

커스텀 구분자 처리

  • 2번에서 살짝 언급 됐지만, 처음에 직접 파싱했더니 와일드 카드 기호로 인해 코드가 복잡해지는 과정이 발생했습니다. 그래서 와일드 카드 기호 파싱을 지원하는 정규식을 활용했습니다.

엣지 케이스 E2E 테스트

구현한 내용을 토대로 발생 할 수 있는 엣지케이스에 대해 정리해보며, 해결하는 과정을 거쳤습니다.

기본 구분자 처리 계산기 엣지 케이스 정리

  • 빈 문자열("")의 경우 0을 반환한다.
  • 양의 정수가 아니라면 입력 받을 수 없다.
    • "0" 입력은 받을 수 없다.
    • 음수 또한 입력받을 수 없다.
  • 공백(" ")인 경우는 잘못된 입력이다.
    • 라는 구분자가 생긴 꼴이다.
  • 구분자로 끝나는 경우는 잘못된 입력이다.
  • "1, 2, 3"과 같은 입력은 잘못된 입력이다.
    • 공백은 하나의 구분자가 될 수 있고, 이는 기본 구분자가 아니다.
  • 각 숫자의 20자리 이상(Long 타입 맥스 사이즈 + 1) 입력에도 정상 동작해야한다.
    • 문자열 계산기이므로 각 숫자는 매우 큰 수가 들어올 수 있다.

커스텀 구분자 처리 계산기 엣지 케이스 정리

  • "//.-\\n1""//.\\n//-\\n1", "a//.-\\n1 같은 입력은 받을 수 없다.
    • //\\n 사이에 문자들혹은 문자열이 있는 꼴이다.
    • //\\n 사이에 문자열이 있는 꼴이다.
    • 커스텀 구분자 입력은 //로 시작해야 된다.

E2E 테스트와 해결

각 엣지 케이스에 대해 E2E 테스트를 진행하고 실패하는 테스트들은 해결하며 동작하는 코드를 구현했습니다.

  • 큰수: long → BigInteger
  • 구분자 사이 공백 포함 안되도록

코드를 개선해보는 과정

클린 코드 책으로 스터디를 진행하며 1장의 내용을 작성해봤습니다. - 깨끗한 코드 노션 링크

1장은 구루들의 경험이 담긴 내용이 정말 많이 담겨있었는데요.

구루들의 깨끗한 코드에 대한 설명을 들으니, 지금까지 학습했던 내용들이 퍼즐마냥 끼워맞춰지기 시작했습니다.

특히 마이클 페더스이 설명한 “깨끗한 코드는 언제나 누군가 주의 깊게 짰다 는 느낌을 준다”는 내용이 정말 인상깊었습니다. 그래서 동작하는 코드를 다시 되돌아보면서 “나는 얼마나 주의 깊게 작성했을까?”라는 생각이 떠오르더라구요. 1장에서 깨달은 내용으로 리팩토링을 한 번 진행해봤습니다.

시나리오를 통한 개선 포인트 찾기

스스로에게 물어봅니다. “내 코드를 얼마나 주의 깊게 짰는가?”

위 질문에 답변을 하기 힘들었습니다. 내가 이 프로그램을 만드는 목적이 무엇인지부터 정의할 필요를 느꼈습니다.

“나는 왜 이 코드를 작성하는가?” 단순히 우아한테크코스 합격을 위햬?

그래서 저는 이 프로그램을 사용하는 페르소나를 설정해 개발을 진행했습니다.


문자열계산기 페르소나 설정 및 시나리오 기획

페르소나

  • A. 기획팀
  • B. 플랫폼 개발자 (나)
  • C. 각 개발팀

시나리오

기획팀

  • 이프님 각 개발팀에서 사용할 수 있는 프로그램을 하나 만들어주세요.
  • 프로그램은 “문자열계산기”이고, 요구사항은 정리해서 보내드릴게요.

플랫폼 개발자

  • (요구사항을 보며 메신저로) 네 알겠습니다. 요구사항 잘받았고, 기능 구현되면 배포하겠습니다.
  • 나는 기능 개발을 마치고 https://jitpack.io/ 에 배포했다.
  • (각 개발팀에게) 모듈 사용 방법 안내를 공지합니다!
    • 각 개발팀은 jitpack에서 string-calculator를 gradle dependency에 추가한다.
    • Controller 객체의 run() 메서드를 통해 string-calculator를 실행한다.

각 개발팀

  • 개발팀 A: 입력 내용이 친절하지 않네..? 나는 입력 예시도 포함하고 싶은데?
    • 덧셈할 문자열을 입력해 주세요.덧셈할 문자열을 입력해 주세요. 예) 1,2:3 =>
  • 개발팀 B: 커스텀 구분 기호가 애매한데..? 숫자는 구분 기호로 포함하면 안될 것 같은데;;
    • 커스텀 구분 기호 규칙 : //.\n기존 구조는 따르나 숫자는 포함되면 안돼.
  • 개발팀 C: 문자열 계산기의 BigInteger 타입은 메모리 누수가 발생할 수 있어서 위험한데…
    • 숫자 처리: BigIntegerLong or Interger

리팩토링

각 페르소나를 설정하고 개발을 시작하니 단순히 미션 완수가 목적이 아니게 됐습니다.

각 사용자에게 서비스를 제공해주는 개발자의 입장에서, 각 페르소나의 요구사항을 반영하는 나만의 목적을 찾을 수 있었습니다. 요구사항을 반영하기 위해 최대한 확장 가능한 구조로 개선해보는 과정을 가졌습니다.

개발팀 A 요구사항 반영

개발팀 A의 요구사항: 입력 내용이 친절하지 않네..? 나는 입력 예시도 포함하고 싶은데?

  • (의도치 않게) 이미 View에 대해 DIP를 적용하고 있었습니다.
    • 이전에는 목적이 없었다면 이제는 명확한 목적이 생겼습니다.

  • 팀 A는 View를 상속받아 A팀만의 커스텀된 View를 사용합니다.
    • 의존 역전 원칙(DIP)과 리스코브 치환 원칙(LSP)덕분에 기존 코드는 변경되지 않았습니다.
    • 하지만 확장이 되면서 계방-폐쇠 원칙(OCP)를 지켰습니다.

  • 정상적으로 반영된 것을 확인하며, 확장 가능한 구조를 확보했습니다.

개발팀 B 요구사항 반영

개발팀 B 요구사항: 커스텀 구분 기호가 애매한데..? 숫자는 구분 기호로 포함하면 안될 것 같은데;;

  • 개발팀 A의 요구사항을 반영하면서, 특정 부분을 변경하고 싶다면 DIP는 꼭 필요하다는 것을 알았습니다.
    • DelimiterTokenizer를 생성자로 전달합니다.
    • 필드, 타 메서드는 작성되어 있다고 가정합니다.

명확한 네이밍을 위해 CalculatorTextCalculator 로 개선했습니다.

  • 구분자를 파싱하는 로직은 무조건 동일한 패턴이 하나있습니다.
    • 빈 문자열이거나 null인 경우, 무조건 빈 배열을 반환해야합니다.
    • 그래서 Template-Method Pattern 을 활용해 추상화했습니다.

  • DelimiterTokenizer를 구현한 SimpleDelimiterTokenizer를, 기본으로 제공하는 문자열 계산기의 표현식 분리 클래스로 사용합니다.
  • 이제 개발팀 B는 SimpleDelimiterTokenizer와 같이 DelimiterTokenizer를 상속받아, 구현해 개발팀 B만의 표현식 분리 처리를 할 수 있습니다.

하지만, 근본적인 문제가 있으므로 해결하고 넘어가야합니다. 앞서 작성한 Controller와 View 구현하기 에서 Controller 내부에서 Calculator를 직접 생성하고 있습니다.

  • TextCalculator도 View와 마찬가지로 DIP를 적용해줍니다.
    • DIP로 인해 생성시점에 표현식을 전달 할 수 없습니다.
    • expression은 자연스레 calculate 로직으로 이동합니다.

이 과정에서 View와 Controller 또한 명확한 네이밍으로 수정했습니다.

  • 이제 개발팀 B는 DelimiterTokenizer를 상속받아 자유롭게 커스터마이징 할 수 있습니다.

  • StackTrace를 확인 결과, 자유롭게 커스터마이징하며 개발팀 B의 요구사항을 해결했습니다.

개발팀 C 요구사항 반영

개발팀 B 요구사항: 문자열 계산기의 BigInteger 타입은 메모리 누수가 발생할 수 있어서 위험한데…

기존 코드는 “문자열 계산기”이기 때문에, 충분히 큰 수를 대체할 수 있는 것을 목적으로 구현했습니다.

이번에는 요구사항에 맞게 BigInteger가 아닌 Int나 Long 또한 허용해줘야 할 수 있다는 것을 생각해야 됩니다.

  • 문자 숫자를 숫자 타입으로 파싱하는 NumberParser도 DIP를 적용해 개선했습니다.
    • NumberParser는 타입에 따라 파싱해야되므로 제너릭을 활용합니다.
    • 기본 BigInteger Parser를 제공합니다.
  • 제너릭 활용으로 인해 TextCalculator는 자동으로 다형성을 유지해야됩니다.
    • LSP로 Template-Method Pattern을 만들고, 기본 BigInteger 계산기를 제공합니다.
  • 다양한 타입에 대한 양수 입력을 factory method로 오버로딩해서 확장성있게 가져갑니다.

TextCalculator는 제너릭을 활용하며 타입 캐스팅의 문제가 발생 할 수 있습니다.

  • 와일드 카드보다는 좀 더 명확하게 interface를 통해 Number Type을 반환하도록 했습니다.

이렇게 남은 전체 구조를 타입 확장할 수 있는 형태로 리팩토링했습니다.

  • 이제 개발팀 C는 NumberParser와 TextCalculator를 확장해 OCP를 지키며 커스터마이징이 됩니다.

  • StackTrace 결과 개발팀 C의 요구사항을 성공적으로 반영했습니다.

마치며

문제 난이도가 쉬운 덕분에 빠르게 기능을 구현하고, 더 깊이 생각해 볼 수 있었던 과정인 것 같습니다. 다음 주 부터는 난이도가 조금 올라갈 것 같아서, 이정도로 세분화를 해 볼 수 있을지 또 새로운 도전이 될 것 같네요 😊

그리고 클린코드를 아주 짧게 읽었음에도 불구하고 깨닫게 된 내용이 정말 많았습니다.

(클린코드를 완독할 이유가 생김)

실수한 부분도 있습니다… TextCalculatecalculate 메서드를 List<Positive<T>>이 아닌 Numbers라는 일급컬렉션을 만들어 반환했는데요 ㅠㅠ.. 글을 쓰면서 정리하다보니, 상태 저장이 전혀 필요없는데 오버엔지니어링을 했더라구요…

개발하는 과정에서 계속 바뀌고 하다보니 놓친 것 같습니다.. 이거 글 쓸 시간에 코드 한번 더 볼걸..

어쨌든 이렇게 1주차 프리코스 문자열 계산기를 잘 마무리했습니다. 다들 고생하셨습니다.

profile
if (이런 시나리오는 어떨까?) then(테스트로 검증하고 해결) else(다음 시나리오 고민)

1개의 댓글

comment-user-thumbnail
2025년 10월 21일

안녕하세요 글 잘 읽었습니다 :) 개발팀 B 요구사항 반영 부분에서 textCalculator 클래스를 DIP 원칙에 맞게 수정하셨다고 하셨는데, 아직 DIP 에 대해 개념만 알고 적용해본 적이 없어서 어떤 식으로 리팩터링하셨는지 여쭤보고 싶습니다 ..! 코드를 봐도 아직 잘 이해가 안가네요 ..ㅎ

답글 달기