본문 바로가기
프로그래머스 데브코스/회고

[데브코스] 계산기 과제를 마치며

by 7533ymh 2022. 4. 3.

1. GitHub 저장소


2. 구현 기능 목록

  • 입력
    • 메뉴
      • 1,2,3만 가능
    • 계산식
      • 공백을 기준으로 구분하기
      • 정규표현식
  • 출력
    • 계산 결과 출력
      • DecimalFormat
    • 계산 이력 출력
      • 인메모리 DB 기능
      • "계산식 = 결과값" 형식
  • 계산기 로직
    • 더하기
    • 빼기
    • 곱하기
    • 나누기
      • 0으로 나눌 시 에러
    • 우선순위
      • 후위표기식

3. 구현하며 배우고 느낀 점

이번 과제의 목적이 OOP, TDD로 만들기다 보니 이 둘을 항상 생각하고 집중하며 진행했던 것 같다. 또한, 이전 스터디에서 배운 것들도 모두 녹여내면서 구현하도록 노력하였다.

OOP

사실 이번 과제는 OOP적으로 완벽하게 수행했다고는 어려울 것 같다. 아직 OOP를 많이 경험해보지 못하기도 했고 익숙하지 않기 때문에 최대한 신경을 썼지만 잘 된거인지는 모르겠다 😅

그래도 최대한 작은 단위로 분리하기 위해 노력하였더니 테스트 하기에도 한결 편해진 것 같다. 또한, 입력이나 출력, 인메모리 DB의 경우는 DIP를 생각하여 추상체와 구상체로 나누어서 구현하였으며 나머지 것들도 최대한 의존성을 주입하여 사용하도록 구현하였다. 그러다 보니 좀 더 유연성있는 구조를 가질 수 있었다고 생각한다.

아직까지 OOP를 확실히 이해했다고 생각하지 않지만 계속 생각해고 적용해보면서 확실하게 잡아보고 싶다!!

TDD

TDD가 무엇이고 왜 중요한지에 대해서는 알고 있었지만 어려워 보여 한번도 적용해보지 않았던 것 같다.

하지만 이번 과제의 목적 중 하나가 TDD인 만큼 최대한 TDD를 통해 구현하려고 노력하였다. 밑의 두가지 영상이 큰 도움이 되었다.

실패하는 테스트 작성 -> 테스트가 성공할 수 있는 최소한의 프로덕션 코드 구현 -> 프로덕션 코드 리팩토링순으로 진행하였으며 처음에는 TDD를 왜 해야하는 지를 느끼지 못했다.

하지만 리팩토링을 진행하면서 TDD의 참맛(?)을 느낄 수 있었던 것 같다. 이미 나는 테스트코드라는 안전장치를 만들어놨으니 리팩토링을 하더라도 테스트코드를 믿고 자신감있게 할 수 있었다. 또한, 리팩토링을 진행하면서 이 기능은 분리할 수 있겠는데?"라고 생각하며 더 쉽게 작은 단위로 분리를 할 수 있었던 것 같다.

처음 시도해보는 TDD라 두렵고 어려웠지만 TDD의 참맛을 느끼고 나니 자신감을 가지게 되었고 TDD를 안 할 이유가 없어졌다. (TDD 최고!)

Enum 활용

예전에 Enum을 사용하면서 많이 도움을 주었던 글이였는 데 이번 과제를 하면서 Enum을 적용할 수 있을 것 같아 적극적으로 활용해보았다.

Enum 사용 전

public interface Operator {

    String ERROR_DIVIDE = "[ERROR] 0으로 나눌 수 없습니다. 다시 입력해주세요.";

    default double add(double numberOne, double numberTwo) {
        return numberOne + numberTwo;
    }

    default double subtract(double numberOne, double numberTwo) {
        return numberOne - numberTwo;
    }

    default double multiply(double numberOne, double numberTwo) {
        return numberOne * numberTwo;
    }

    default double divide(double numberOne, double numberTwo) {
        if (numberTwo == 0) {
            throw new IllegalArgumentException(ERROR_DIVIDE);
        }
        return numberOne / numberTwo;
    }
}

public class Calculator implements Operator {
    private static final String PLUS = "+";
    private static final String MINUS = "-";
    private static final String MULTIPLY = "*";
    private static final String DIVIDE = "/";
    private static final String ERROR_NOT_OPERAND = "[ERROR] 연산자가 아닙니다. 다시 입력해주세요.";

    public double calculate(String operator, double numberOne, double numberTwo) {
        if (operator.equals(PLUS)) {
            return add(numberOne, numberTwo);
        }

        if (operator.equals(MINUS)) {
            return subtract(numberOne, numberTwo);
        }

        if (operator.equals(MULTIPLY)) {
            return multiply(numberOne, numberTwo);
        }

        if (operator.equals(DIVIDE)) {
            return divide(numberOne, numberTwo);
        }

        throw new IllegalArgumentException(ERROR_NOT_OPERAND);
    }
}

위의 코드를 Enum을 통해 if문도 제거하여 더욱 깔끔하고 상태와 행위를 한 곳에서 관리할 수 있도록 바꿔주었다.

Enum 사용 후

public enum OperatorType {

    PLUS("+", Double::sum),
    MINUS("-", (firstNumber, secondNumber) -> firstNumber - secondNumber),
    MULTIPLY("*", (firstNumber, secondNumber) -> firstNumber * secondNumber),
    DIVIDE("/", (firstNumber, secondNumber) -> {
        if (secondNumber == 0) {
            throw new IllegalArgumentException("[ERROR] 0으로 나눌 수 없습니다. 다시 입력해주세요.");
        }
        return firstNumber / secondNumber;
    });

    private final String operator;
    private final BinaryOperator<Double> expression;

    OperatorType(String operator, BinaryOperator<Double> expression) {
        this.operator = operator;
        this.expression = expression;
    }

    public static OperatorType from(String operator) {
        return Arrays.stream(OperatorType.values())
            .filter(type -> type.operator.equals(operator))
            .findFirst()
            .orElseThrow(() -> new IllegalArgumentException("[ERROR] 연산자가 아닙니다. 다시 입력해주세요."));
    }

    public double calculate(double firstNumber, double secondNumber) {
        return expression.apply(firstNumber, secondNumber);
    }
}

public class Calculator {

    public double calculate(String operator, double firstNumber, double secondNumber) {
        return OperatorType.from(operator)
            .calculate(firstNumber, secondNumber);
    }
}

BinaryOperator와 Lambda를 활용하여 좀 더 간결하고 명시적으로 확인할 수 있게 되었다. 그저 외부에선 from을 통해 OperatorType을 찾아내고 계산만 해주면 되는 것이다.

사실 Enum을 바로 떠올리고 활용한 것이 아니였지만 계속해서 리팩토링을 하며 생각하여 위와 같이 구현했다는 점이 재미있고 기뻤던 것 같다. 또한, 위에서 소개한 글을 보고 그냥 이해한 것이 아닌 직접 적용해봤다는 점에서 한층 더 성장했음을 느낄 수 있었다.

4. 질문 & 피드백

데브코스에서 2주동안 진행되는 팀 중 나는 아만 멘토님 계신 팀에 속해있어 아만드님에게 피드백을 받았다.

TDD 관련 질문

Q. 작은 단위 부터 TDD를 통해 구현하다보니 리팩토링을 통해 해당 기능을 다른 클래스에게 부여하거나 클래스를 분리해야할 때가 있습니다. 이런 경우 그저 작성한 테스트를 해당 클래스의 테스트 코드에 옮겼는 데 맞는 방식인가요?

A. 해당 기능을 다른 클래스가 갖게 된다면 그에 해당하는 테스트코드도 그 클래스의 테스트 코드가 가져가야 하는게 당연하다고 생각합니다.

Q. 작은 단위의 기능부터 테스트코드를 짜다보니 후에 가서는 해당 기능이 public이 아닌 private로 바꿔야 할 경우 작성한 테스트 코드를 삭제해도 되나요?

A. 테스트코드 역시 관리의 대상이기 때문에 많으면 좋긴 하지만 그만큼 관리가 힘들어질 수 있습니다. 그러하기에 Private 메소드 테스트는 직접적으로 하는게 아닌 그 private 메소드를 사용하는 메소드의 테스트 케이스에서 커버를 해줘야 한다고 생각합니다. 그렇기에 private 메소드를 커버하는 테스트코드를 다양한 값을 통해 테스트해서 검증 할 필요가 있다고 생각합니다.


위의 질문들은 이번 과제에서 TDD를 적용해보면서 알맞은 방향인지 궁금하여 여쭤본 질문들이다. 리팩토링을 진행하다보니 기능이 분리됨에 따라 클래스를 분리하게 되어서 작성한 클래스를 그냥 옮기기만 하였는 데 이런 과정이 맞는건지 의문이 있었는 데 사실 생각해보면 당연하는 것을 알 수 있다 ㅎㅎ

또한, private 메서드의 경우 나는 public 메서드의 테스트를 통해 어차피 검증된다고 생각하고 삭제하게 되었는 데 멘토님의 말씀처럼 public 메서드의 테스트에서 검증하되 private 메서드를 커버하는 다양한 값을 통해 검증해야 겠다고 깨닫게 되었다.


OOP 관련 질문

Q. operatorType에서 연산자를 입력받아 해당하는 타입을 반환하도록 하였습니다. 계산하는 메서드같은 경우 OperatorType안에서 연산자를 입력받아 타입을 찾고 계산하는 방식이 맞는 건지 아니면 외부에서 OperatorType을 연산자를 통해 반환받고 OperatorType을 통해 계산을 하는 방식이 맞는 건지 궁금합니다.

// OperatorType 안에서 찾고 계산 실시 
public static double calculate(String operator, double firstNumber, double secondNumber) {
    return from(operator).expression.apply(firstNumber, secondNumber);
}

// 외부에서 OperatorType을 찾아 해당 Type으로 계산 실시
public static double calculate (double firstNumber, double secondNumber) {
    return expression.apply(firstNumber, secondNumber);
}

A. 저는 개인적으로 후자가 맞다고 생각합니다. Operator을 생성하는거와 계산하는걸 분리시킬 필요가 있다고 생각하고 외부에서 생성한 객체로 단순히 계산만 진행하는게 더 좋을 것 같네요

Q. 처음 구현할 때 입력받은 식을 후위표기식으로 바꿔주는 기능을 Paser라는 클래스를 통해 해주었습니다. 하지만 바꿔주는 기능과 계산하는 기능이 따로 있는 것보단 같이 있는 게 좋을 것 같아 PostfixExpression라는 클래스로 다시 만들었습니다. 해당 방식이 객체지향적으로 생각하는 것이 맞는 건지 궁금합니다.

A. 후위 표기식을 계산하는 클래스와 바꾸는 클래스로 나누는 것이 좋다고 생각하네요 그런데 이걸 각각의 클래스로 만들어도 좋고, 아니면 inner class로 변환/ 계산 이렇게 생성해도 될 것 같네요.


첫번째 질문의 경우 operator를 입력받아서 해당하는 operator의 로직을 실행시켜주는 것이 좋지 않을까? 라고 생각하고 구현하게 되었는 데 멘토님의 말씀처럼 생성과 계산을 같이 하는 것보단 분리해주는 것이 맞다고 생각하게 되었다.

두번째 질문의 경우 후위표기식으로 변환하는 기능과 계산하는 기능이 함께 있는 것이 더 효율적이라고 생각해 구현하였지만 분리하는 것이 좋다는 멘토님의 의견이 있었다. 생각해보면 클래스를 작은 단위로 분리하는 것도 중요하니 분리해주는 것이 맞는 것 같다.

그래서 후위표기식으로 변환하는 기능을 유틸성 클래스로 분리해주게 되었는 데 유틸성 클래스로 분리하는 것도 분리로 치는 건지, 의존관계에선 어떻게 되는지에 의문을 다시 품게 되었고 멘토님에게 질문하게 되었다. 그 결과 유틸성 클래스로 분리하였어도 분리가 일어났다고 볼 수 있고 사실 유틸성 클래스는 어디서든 갖다 쓸 수 있도록 한거기 때문에 의존관계에서 좀 자유롭게 봐도 좋다는 의견을 주셨다.


피드백

Enum

image-20220403202909669

Operator 뿐만 아니라 메뉴를 입력받은 커맨드 또한, Enum으로 처리하는 것을 추천해주셨다. 사실 이러한 커맨드도 상수로 처리하는 것 보단 Enum으로 처리해주면 유지보수성면에서 뛰어날 것이다.

Optional

image-20220403203145539

해당 메서드는 저장한 계산 결과를 List형으로 반환해주는 데 만약 저장된 것이 없을 수 있으니 Optional로 한번 감싸주어 사용하였다. 하지만 멘토님은 굳이 Optional로 감싸줄 필요없이 그냥 비어있는 상태 그대로 반환하는 것을 추천해주셨다. 생각해보니 비어있는 상태로 반환해도 그 값 자체가 비어있는 지 확인하면 되니 굳이 Optional을 사용할 필요가 없었다.

Optional은 null방지를 위해서만 사용하자!!


마무리하며

이번 과제를 진행하고 피드백을 받으면서 많이 성장하는 느낌을 받으면서도 아직 많이 부족하다는 것을 느끼게 되는 것 같다.

부족하다며 조급해하며 여러가지를 보는 것보단 하나라도 정확히 아는 것이 중요하다는 것을 요즘 크게 느끼게 된다.

피드백을 진행하면서 또 궁금한 점이 생겨 리팩토링 이후 재리뷰를 보내게 되었다. 예전이라면 그냥 구현하고 끝! 이였을 텐데 계속해서 바꿔야 할 점이나 잘못된 점이 없는 지 확인하고 생각하는 내가 신기하다.

이러한 마음가짐을 잊지말고 계속해서 성장해나가고 싶다!!!

'프로그래머스 데브코스 > 회고' 카테고리의 다른 글

조금 이른 2022 회고  (3) 2022.12.18
[데브코스] Spring 3주차를 마치며  (0) 2022.05.02

댓글