카테고리 없음

[우아한테크코스] 프리코스 1주차 - 문자열 덧셈 계산기 미션 회고

mint* 2024. 10. 21. 20:48
728x90

프리코스 1주차 - 문자열 덧셈 계산기 미션 회고

고민한 점

추상화는 어디까지 해야할까?

추상화 시기

이전 프로젝트에서는 여러 종류의 클래스가 필요할 때 인터페이스를 도입했다.
그런데 이번 미션은 요구사항에 따라 구현할 클래스가 이미 정해진 상황이다.
하지만 확장 가능한 설계를 먼저 고려해서 좋은 소프트웨어를 만들기 위해 노력해 보려고 했다.

 

추상화 결정 기준

추상화를 도입할 부분을 살펴볼 때, 각 기능을 어느 정도까지 추상화를 하고, 하지 말지 고민이 되었다.
내가 내린 결론은, 해당 기능이 확장 가능하다고 충분히 설득되는 대체성이 있을 때만 추상화를 진행하는 것이다.

 

예를 들어 덧셈 계산기를 구현할 때 Integer 타입과 Long 타입을 각각 지원하도록 추상화한다면 충분히 설득력이 있다.

 

하지만 덧셈 계산기 구현 시 덧셈, 뺄셈, 곱셈, 나눗셈을 모두 포함하는 계산기 인터페이스를 생성하는 것은 설득력이 없다. 문제 범위를 크게 넘어섰기 때문이다.
그리고 ISP(인터페이스 분리 원칙)에 따라 인터페이스는 최대한 기능을 쪼개야 하기 때문에, 덧셈/뺄셈/나눗셈/곱셈 기능을 각 인터페이스로 쪼개는 것이 좋다.

과도한 추상화는 지양하자!

 

관련 과제 코드

  • Integer 타입과 Long 타입을 각각 지원하는 덧셈 계산기
public interface Addable<T extends Number> {  
  
    T addNumbers(final List<T> numbers);  
  
}

 

public class IntegerAdder implements Addable<Integer> {  
  
    private static final Integer MAX_VALUE = Integer.MAX_VALUE;  
    private static final Integer MIN_VALUE = Integer.MIN_VALUE;  
  
    @Override  
    public Integer addNumbers(final List<Integer> numbers) {  
        int sum = 0;  
        for (Integer number : numbers) {  
            sum = addSafely(sum, number);  
        }  
  
        return sum;  
    }  
  
    private int addSafely(final int number1, final int number2) {  
        if (number2 > 0 && number1 > MAX_VALUE - number2) {  
            throw new IllegalStateException("오버플로우가 발생했습니다.");  
        }  
  
        if (number2 < 0 && number1 < MIN_VALUE - number2) {  
            throw new IllegalStateException("오버플로우가 발생했습니다.");  
        }  
  
        return number1 + number2;  
    }  
  
}

 

public class LongAdder implements Addable<Long> {  
  
    private static final Long MAX_VALUE = Long.MAX_VALUE;  
    private static final Long MIN_VALUE = Long.MIN_VALUE;  
  
    @Override  
    public Long addNumbers(final List<Long> numbers) {  
        long sum = 0;  
        for (Long number : numbers) {  
            sum = addSafely(sum, number);  
        }  
  
        return sum;  
    }  
  
    private long addSafely(final long number1, final long number2) {  
        if (number2 > 0 && number1 > MAX_VALUE - number2) {  
            throw new IllegalStateException("오버플로우가 발생했습니다.");  
        }  
  
        if (number2 < 0 && number1 < MIN_VALUE - number2) {  
            throw new IllegalStateException("오버플로우가 발생했습니다.");  
        }  
  
        return number1 + number2;  
    }  
  
}

 

느슨한 결합으로 유연성을 제공하자

미션에서 기본 구분자는 쉼표, 콜론으로 주어지고, 추가적으로 커스텀 구분자를 지정할 수 있다.

 

계산기와 기본 구분자의 강결합

public class Delimiters {

    private static final String CONTAINING_ALL_START_REGEX = "^.*(";
    private static final String DEFAULT_DELIMITER_REGEX = ",|:";
    private static final String CONTAINING_ALL_END_REGEX = ").*";

    private void checkIfDefaultDelimiterIncluded(final Delimiter delimiter) {
        String totalRegex = CONTAINING_ALL_START_REGEX + DEFAULT_DELIMITER_REGEX + CONTAINING_ALL_END_REGEX;
        if (delimiter.matches(totalRegex)) {
            throw new IllegalArgumentException("구분자는 기본 구분자를 포함할 수 없습니다.");
        }
    }
}

기본 구분자와 커스텀 구분자는 계산기 로직에서 사용되는Delimiters라는 일급 컬렉션에 함께 저장된다.
그리고 커스텀 구분자가 내부에 다른 구분자를 포함하면 예외가 발생하도록 작성했다. (기본 구분자 : 커스텀 구분자 :a (X))
구현은 Delimiters 클래스에 쉼표, 콜론을 포함하는 정규식(,|:) 상수로 지정하고, 새롭게 지정된 커스텀 구분자를 |로 연결시켜 정규식을 완성했다.

 

그런데 만약 외부로부터 기본 구분자의 요구사항이 변경된다면? 기본 구분자가 더 추가된다면 어떻게 수정해야할까?
기본 구분자를 변경하기 위해 Delimiters의 하드코딩된 문자열을 수정하는 것이 과연 OCP(개방-폐쇄 원칙)을 지키는 것이라 할 수 있을까?

 

느슨한 결합하기

기본 구분자를 외부로부터 주입받아 정규식을 생성한다면 기본 구분자가 수정되어도 Delimiters 클래스를 수정하지 않고 확장할 수 있다.
즉, 계산기와 구분자가 느슨한 결합을 한다.

public class StringCalculator {  
    // ...  
    public Delimiters initialize() {  

        return new Delimiters(List.of(new Delimiter(","), new Delimiter(":")));  
    }  

    public void run(final Delimiters defaultDelimiters) { // 외부로부터 주입받기
        // 0. 입력한다.  
        String input = consoleInputHandler.getUserInput();  

        // 1.커스텀 구분자를 추출한다.  
        Delimiters delimiters = delimiterExtractor.extractDelimitersFrom(input, defaultDelimiters);
    // ...

 

객체지향 원칙을 적용하자

단일 책임 원칙 (SRP)

클래스와 메서드가 한 가지의 책임만 가지도록 분리했다.
잘 분리되었는지 확인하는 가장 쉬운 방법은 클래스가 적당한 인스턴스 변수를 가지고, 메서드가 적절한 크기로 선언되었는지 확인하는 것이다.

ex) 클래스당 4개 미만의 인스턴스 변수 유지 , 메서드의 인자를 3개 이하 유지

 

개방-폐쇄 원칙 (OCP), 의존성 역전 원칙 (DIP)

Addable, NumberConvertible 인터페이스를 도입하여 계산 타입(Integer, Long)을 확장하고 추상화에 의존했다.
InputHandlerOutputHandler 인터페이스를 통해 입출력 방식을 확장하고 추상화에 의존했다.

 

인터페이스 분리 법칙(ISP)

덧셈 기능을 정의하는 Addable 인터페이스는 숫자를 더하는 기능만을 가진 인터페이스이다. (인터페이스를 잘게 쪼갰다.)

 

정규식을 익히고 최적화하자

이번 프리코스에서 나의 목표는 gpt를 사용하지 않고 요구사항을 모두 만족시키는 것이었다.
정규식의 경우 gpt에 의존했던 부분 중 하나였다.

 

정규식을 익히면서 눈으로만 훑고 지나갔던 정규식을 이해하고 작성할 수 있게 되었다.
지금까지 자동화에 의존하여 조건에만 만족하는 비효율적인 정규식을 만들지는 않았었나라는 반성을 하게 되었다.

 

*? 의 차이

*은 앞 문자가 0번이상 반복한다는 뜻이며 greedy하고, ?은 앞 문자가 존재할 수도, 존재하지 않을 수 있으며 lazy하다.

x.*y 은 x로 시작하고 가장 많은 문자를 포함하도록 가장 먼 y로 범위를 설정한다.
x.?y 는 x로 시작하고 가장 가까운 y까지만 범위를 설정한다.

 

그룹화 ()

()는 그룹화하여 한 단위로 매치할 수 있다.
Matcher에서 group()은 매칭된 부분을 반환하고, group(int num)은 ()로 그룹화한 것 중 num번째 그룹을 반환한다.
start()end() 는 그룹이 시작하는 위치와 끝 다음 문자 위치를 반환한다.

 

[ab]a|b

같은 의미이다.
하지만 []는 한문자씩 매치되고, |은 더 긴 문자열(aaab|abba)로 매치할 수 있다.

 

빈 부정 전방탐색 (?!)

항상 실패하는 패턴이다. "아무것도 없음"을 부정하고 있기 때문이다.

OR을 이용해 정규식을 생성할 때 맨 처음 문자를 빈 부정 전방탐색으로 하면 빈 문자열을 포함하지 않는 정규식을 생성할 수 있다.

public class Delimiters {  

    private static final String CONTAINING_ALL_START_REGEX = "^.*(";  
    private static final String CONTAINING_ALL_END_REGEX = ").*";  
    private static final String NON_MATCH = "(?!)";  

    // ...

    public Regex makeDelimitersRegex() {  
        Regex regex = new Regex(CUSTOM_DELIMITER.getRegex());  
        for (Delimiter delimiter : delimiters) {  
            regex.addContinuously(delimiter.delimiter());  
        }  

        return regex;  
    }  

|a|b vs (?!)|a|b

 

정규식의 성능 이슈 & 보안

정규식은 작동 방식에 따라 성능이 크게 달라진다.
특히 비효율적인 정규식은 처리 시간을 크게 증가시킨다.

 

2019년 7월 Cloudflare에서 백트래킹이 많은 정규식으로 인해 전체 서비스가 27분간 다운되는 사고가 발생했다.
https://ryanking13.github.io/2019/07/18/details-of-the-cloudflare-outage-on-july-2-2019.html

 

(번역) 2019년 7월 2일 Cloudflare 장애 보고서

Details of the Cloudflare outage on July 2, 2019

ryanking13.github.io

 

또한 정규 표현식 서비스 거부 공격(ReDoS)는 정규 표현식 처리 시간을 일부로 비정상적으로 길게 만들어 서비스 거부 공격을 발생시킨다.

따라서 작성된 정규식을 이해할 뿐 아니라 직접 효율적으로 작성할 줄 알아야 한다.

 

정규식 최적화

  • 처음에 작성한 정규식

//과 \n 사이의 모든 문자를 추출하기 위해 처음에 작성한 정규식이다.

private static final String CUSTOM_DELIMITER_EXTRACT_REGEX = "(?<=\\/\\/)([\\w\\*\\@\\$\\!\\%\\*\\#\\?\\&\\;\\~\\^\\{\\}\\(\\)\\<\\>\\-\\+\\[\\]\\'\\\"\\,\\.\\\\]*)(?=\\\\n)";

메타 문자를 포함하려다보니 굉장히 길어지고 복잡해졌다.

 

  • 그룹화? 사용한 정규식
private static final String CUSTOM_DELIMITER_EXTRACT_REGEX = "//(.+?)\\\\n";

정규식은 요구사항을 만족하도록 매치하면서 이해하기 쉽고 성능이 좋도록 만드는 것이 중요한것 같다.

 

느낀점

TDD 적용해보며 느낀 점

미션을 진행하면서 다들 TDD에 대한 이야기를 많이 했다.
TDD(Test-Driven Development)테스트 주도 개발이라는 뜻이며, 테스트 코드를 먼저 작성하고 실제 코드를 작성하는 것이다.
test -> feat -> refactor 순으로 진행되며, red(실패하는 테스트) -> green(테스트를 통과하기 위한 최소한의 코드) -> refactor(리팩토링) 사이클을 반복한다.

 

TDD를 적용할 때 나에게 있어 가장 큰 걱정은 테스트를 먼저 작성하고 구현함으로써 걸리는 시간이었다.
그런데 결과적으로 이번 미션에 TDD를 적용하면서 느꼈던 점은 TDD로 인해 개발 시간이 단축되었다는 것이다.

 

느낀 TDD의 장점

  • 1. 요구사항을 깊게 분석하고 설계할 수 있다.
    • 테스트를 먼저 작성하면서 요구사항에 대해 충분히 생각하고 설계할 수 있었다.
    • 테스트 작성 과정에서 여러 시나리오와 엣지 케이스를 고려해서 결과적으로 더 견고하게 로직을 작성할 수 있었다.
  • 2. public API 설계가 명확해졌다.
    • 테스트에서 검증되는 부분은 입력에 대한 결과이기 때문에, private 메서드나 void 테스트를 어떻게 테스트해야하나 라는 고민이 사라지고 코드가 보기 쉽게 작성되었다.
  • 3. 개발 진행상황 파악이 쉽다.
    • 구현하면서 테스트를 빠르게 돌리고 테스트 커버리지를 확인하면서 작업 진행도를 파악하기 수월했다.
  • 3. 안전하게 리팩토링할 수 있다.
    • 리팩토링하면서 발생된 부작용이나 버그를 바로 찾을 수 있어 코드 품질이 일정 이상 보장되면서 향상된다.

 

느낀 TDD 개발시 주의사항

  • 테스트에서 검증을 엄격하게 해야 한다.
    • 엄격한 검증을 수행하지 않으면 잘못된 결과더라도 테스트를 통과하게 되어 버그가 발생한다.
  • 테스트 생성시 충분히 생각하고 여러 예외 케이스와 경계값 케이스를 추가해야 한다.
    • 요구사항에 대해 깊이 이해하지 않고 테스트를 작성하다보면 구현하는 과정에서 내부적으로 많은 버그가 존재하는 프로그램이 만들어진다.
      • 구현하는 과정에서는 오직 테스트 통과가 목표이기 때문이다.
    • 테스트 작성 전에 충분히 생각하고 작성해야 한다.
  • 리팩토링을 꼭 하자.
    • 처음 구현 코드는 테스트 성공을 위한 코드이므로 좋은 코드를 작성하기 위해서는 리팩토링 과정이 필수적이다.

 

내가 TDD를 하면서 더 공부해야할 점

  • 테스트 작성시 통합 테스트로 시작해야할까, 단위 테스트로 시작해야할까?

처음에 테스트를 작성할 때는 ApplicationTest라는 통합 테스트에 모든 성공, 실패 케이스를 모아서 작성했다.
그런데 구현하는 과정에서 클래스가 책임에 따라 분리되면서 분리된 클래스에 대한 단위 클래스를 작성하는 과정이 필요했다.

 

처음부터 설계를 끝내고 단위 테스트부터 작성하는 것인지, 통합 테스트부터 작성하는 것인지 궁금하다.
잠깐 보니 TDD에서는 완벽한 설계보다는 테스트를 통한점진적인 설계를 수행한다고 하는데 관련해서 TDD에 대해 알아봐야겠다고 생각했다.

 

TDD 결론

TDD에 대해 개발자마다 선호도가 달라서 나에게는 어떻게 다가오는지 궁금해서 도전해봤다.
생각보다 장점이 커서 앞으로도 TDD를 이용할 예정이고, 다른 기술에 대해서 열려 있는 태도를 가져야겠다고 생각했다.

 

디버깅은 정말 유용하다

구현하거나 리팩토링하는 과정에서 테스트 실패 시 캡슐화된 객체의 내부 데이터를 보기 위해 getter를 임시로 만들어 출력하는 코드를 작성하게 되었다.
그러나 문제를 해결한 후 테스트를 위해 작성된getter 와 출력 코드가 많아져서 삭제해야할 코드가 많아졌다.

 

디버깅을 사용하면 실행 흐름을 멈춰 내부 데이터를 볼 수 있으므로 효율적이고 삭제할 코드를 작성할 필요도 없다.
앞으로 많이 사용할 것 같다.

아쉬운 점 및 개선할 점

구분자와 소수점 충돌 문제

소수점을 가진 수에 대한 문자열 덧셈 계산기를 구현하고 싶었는데, 여러 고민이 들었다.

소수에는 소수점이 포함되는데, 소수점 계산기이면 .을 소수점으로 보면 되지만 구분자를 .으로 하면 정수 계산기가 되기 때문이다.

 

커스텀 구분자가 .이면 정수 계산기로 동작하고, 커스텀 구분자가 .이 아니면 실수 계산기로 동작하게 해도 되지만, 구분자와 계산 타입이 서로 의존하는 문제가 있지 않을까?

구분자와 계산 타입이 강결합하게 되면 책임이 제대로 분리되지 않을 것 같다는 우려가 있어 제출 코드에 적용하지 않았다.

혹시 위 요구사항을 만족해서 객체지향적으로 코드를 작성하신 분이 있다면 한번 코드를 보고 싶다.

 

기타

스킬 스왑 스터디

우테코 디스코드에서 지원자끼리 모집했던skillswap 스터디에 참여하게 되었다.

 

skillswap 스터디는 서로 자신이 가진 스킬을 공유하고, 다른 사람들로부터 스킬을 얻어가는 스터디이다.
다른 사람들로부터 내가 가지지 않은 부분을 발견하고 갭 차이를 줄이는 연습을 해야겠다는 나의 목표와도 일치해서 가입했다.

 

스터디 지원할 때 객체지향과 클린코드에 대해 이야기했는데, 말은 뱉었지만 내 스킬이라고 다시 생각해보니 정말 많이 부족한 것 같다.
그래도 내 스킬이라고 했으니, 책임지고 객체지향에 대해 공부해서 다른 분들이 질문했을 때 좋은 답변을 줄 수 있도록 노력하고 싶다.
우선 유명한 객체지향의 사실과 오해 책을 읽기로 했다.

 

글 마무리는 객체지향과 클린 코드에 대한 질문이 있었는데, 나의 생각을 정리해서 답변을 달았던 글을 첨부한다.

 

공부 열심히 하자!

728x90