김코딩

계산기 Lv.3 본문

TIL

계산기 Lv.3

김코딩딩 2025. 4. 22. 16:45

이제 마지막 과제인 Lv. 3 계산기를 구현해보았습니다.

Lv.3 계산기의 요구사항은 다음과 같습니다.


Lv 3. Enum, 제네릭, 람다 & 스트림을 이해한 계산기 만들기

  • 현재 사칙연산 계산기는 (➕,➖,✖️,➗) 이렇게 총 4가지 연산 타입으로 구성되어 있습니다.
    • Enum 타입을 활용하여 연산자 타입에 대한 정보를 관리하고 이를 사칙연산 계산기 ArithmeticCalculator 클래스에 활용 해봅니다.
  • 실수, 즉 double 타입의 값을 전달 받아도 연산이 수행하도록 만들기
    • 키워드 : 제네릭
      • 단순히, 기존의 Int 타입을 double 타입으로 바꾸는 게 아닌 점에 주의하세요!
    • 지금까지는 ArithmeticCalculator, 즉 사칙연산 계산기는 양의 정수(0 포함)를 매개변수로 전달받아 연산을 수행
    • 피연산자를 여러 타입으로 받을 수 있도록 기능을 확장
      • ArithmeticCalculator 클래스의 연산 메서드(calculate)
    • 위 요구사항을 만족할 수 있도록 ArithmeticCalculator 클래스를 수정합니다. (제네릭)
      • 추가적으로 수정이 필요한 다른 클래스나 메서드가 있다면 같이 수정 해주세요.
  • 저장된 연산 결과들 중 Scanner로 입력받은 값보다 큰 결과값 들을 출력
    • ArithmeticCalculator 클래스에 위 요구사항을 만족하는 조회 메서드를 구현합니다.
    • 단, 해당 메서드를 구현할 때 Lambda & Stream을 활용하여 구현합니다.
      • Java 강의에서 람다 & 스트림을 학습 및 복습 하시고 적용 해보세요!
    • 추가) 람다 & 스트림 학습을 위해 여러 가지 조회 조건들을 추가하여 구현 해보시면 학습에 많은 도움이 되실 수 있습니다.

요구사항 세부 정리

사칙연산 처리 ➕➖✖️➗ 4가지 연산 지원 Enum, ArithmeticCalculator OperatorType enum을 통해 연산자 관리
연산 타입 관리 연산자(+,-,*,/)를 문자열이나 char로 비교하지 않고 enum으로 관리 Enum 코드의 안정성과 가독성 향상
다양한 숫자 타입 지원 연산 시 int, double 등 다양한 숫자 타입을 허용 제네릭(Generics) T extends Number 활용
계산 기능 두 숫자와 연산자(enum)를 받아서 계산 결과를 반환하는 메서드 구현 제네릭, 다형성 calculate(T num1, T num2, OperatorType)
결과 저장 계산된 결과값을 저장 컬렉션(List, Queue 등) ResultRepository 활용
결과 조회 기능 저장된 결과를 전부 조회하거나 특정 조건에 따라 필터링 람다, 스트림(Stream) findAll(), findBigger(double) 등
예외 처리 잘못된 연산자, 0으로 나누기 등의 상황에 대한 예외 처리 예외 처리, IllegalArgumentException 사용자 경험 개선
사용자 입력 정수 or 실수 구분하여 입력, 연산자 입력, 명령 입력 등 처리 Scanner, Input 클래스 String.contains(".")로 실수 판별 등
명령어 기능 분리 결과 출력(show), 삭제(del), 특정값보다 큰 결과 출력(big) 등 명령 분리 Options, Command 클래스 분리 SRP (단일 책임 원칙) 적용 가능

 


App.java 클래스

package lv3;

import java.util.Scanner;

public class App {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);

        // 계산 기능 객체 생성
        ArithmeticCalculator<Number> calculator = new ArithmeticCalculator<>();
        // 계산 결과 저장 객체 생성
        ResultRepository resultRepository = new ResultRepository();
        // 부가 기능 객체 생성
//        Options options = new Options(resultRepository);

        while (true) {
            // 숫자 2개 입력(정수 or 실수)
            Number num1 = Input.inputNumber("첫 번째 숫자를 입력하세요: ", sc);
            Number num2 = Input.inputNumber("두 번째 숫자를 입력하세요: ", sc);

            // 연산자 입력
            System.out.print("사칙연산 기호를 입력하세요(+,-,*,/) : ");
            char op = sc.next().charAt(0);
            sc.nextLine();

            try {
                // 입력한 연산자를 기반으로 OperatorType enum으로 변환
                OperatorType operatorType = OperatorType.of(op);
                // 계산 수행
                double result = calculator.calculate(num1, num2, operatorType);
                // 결과 저장
                resultRepository.save(result);
                System.out.println("계산 결과: " + result);
            } catch (NumberFormatException e) {
                // 던져진 예외 메시지 출력
                System.out.println(e.getMessage());
            } catch (IllegalArgumentException e) {
                System.out.println(e.getMessage());
                continue;
            }

            // 종료 및 부가기능 수행
            if (!Command.process(resultRepository, sc)) break;
        }
    }


}

 

ArithmeticCalculator<Number> 제네릭을 활용한 계산 기능 객체. 실수/정수 둘 다 처리 가능
ResultRepository 결과를 저장하는 저장소 객체. 나중에 조회 및 삭제에 사용
Input.inputNumber(...) 사용자로부터 숫자를 입력받고, 실수인지 정수인지 판단
OperatorType.of(op) 입력된 연산자 char 값을 Enum으로 변환
calculator.calculate(...) 실제 계산 수행
resultRepository.save(...) 계산 결과 저장
Command.process(...) 명령어 입력 (exit, show, del, big)을 처리. 결과 조회, 삭제 등 담당

OperatorType.java 클래스

package lv3;

import java.util.function.DoubleBinaryOperator;

public enum OperatorType {
    ADD('+', (a, b) -> a + b),
    MINUS('-', (a, b) -> a - b),
    MULTIPLY('*', (a, b) -> a * b),
    DIVIDE('/', (a, b) -> {
        if (b == 0) {
            throw new ArithmeticException("나눗셈 연산에서 분모(두번째 정수)에 0이 입력될 수 없습니다.");
        } else {
            return a / b;
        }
    });

    private final char operator;
    private final DoubleBinaryOperator operation;

    /**
     * @param operator 연산자 기호
     * @param operation 연산 수행 람다식
     */
    OperatorType(char operator, DoubleBinaryOperator operation) {
        this.operator = operator;
        this.operation = operation;
    }


    public char getOperator() {
        return operator;
    }


    public static OperatorType of(char op) {
        if (op == '+') {
            return ADD;
        } else if (op == '-') {
            return MINUS;
        } else if (op == '*') {
            return MULTIPLY;
        } else if (op == '/') {
            return DIVIDE;
        } else {
            throw new IllegalArgumentException("올바르지 않은 연산자입니다: " + op);
        }
    }


    //람다식을 사용하기 위한 함수형 인터페이스
    public double calculate(double a, double b) {
        return operation.applyAsDouble(a, b);
    }

}

클래스 개요

OperatorType은 사칙연산 기호(+, -, *, /)를 Enum 상수로 표현한 클래스입니다.
각 연산자에 **고유의 연산 행동(람다식)**을 부여하여, 외부에서 분기처리 없이 계산을 직접 수행할 수 있도록 설계했습니다.

주요 필드

private final char operator;
private final DoubleBinaryOperator operation;
  • char operator
    → 실제 사용자가 입력할 연산자 문자(+, - 등)를 저장합니다.
  • DoubleBinaryOperator operation
    → 두 개의 double 타입을 받아 연산을 수행하고 결과를 반환하는 함수형 인터페이스입니다.
    이를 통해 람다식으로 연산 동작을 정의할 수 있습니다.

생성자

OperatorType(char operator, DoubleBinaryOperator operation)

각 Enum 상수(ADD, MINUS 등)에 대해 연산자 기호와 연산 로직을 함께 전달받습니다.

 

예시:

ADD('+', (a, b) -> a + b)

 

주요 메서드

public static OperatorType of(char op)


사용자가 입력한 연산자로부터 알맞은 Enum 상수를 반환합니다.
잘못된 연산자가 들어오면 예외 발생시킵니다.

public double calculate(double a, double b) {
    return operation.applyAsDouble(a, b);
}

각 Enum 상수에 등록된 람다식을 호출하여 실제 계산을 수행합니다.
이 메서드 덕분에 외부에서는 if, switch 없이도 깔끔하게 연산을 수행할 수 있습니다.


ArithmeticCalculator.java 클래스

package lv3;


public class ArithmeticCalculator<T extends Number> {

    public double calculate(T num1, T num2, OperatorType op) {
        double a = num1.doubleValue();
        double b = num2.doubleValue();

        return op.calculate(a, b);
    }

}

클래스 개요

ArithmeticCalculator는 두 숫자와 연산자(Enum)를 입력받아 실제 연산을 수행하는 계산기 클래스입니다.
제네릭(Generic)을 사용해 Integer, Double 등 다양한 숫자 타입을 받을 수 있게 설계되어 있습니다.

클래스 선언

public class ArithmeticCalculator<T extends Number>
  • T extends Number
    → T는 Integer, Double, Float 등 모든 Number 하위 타입을 허용합니다.
    → 이렇게 하면 다양한 숫자 타입의 유연한 계산이 가능해집니다.

핵심 메서드

public double calculate(T num1, T num2, OperatorType op)
  • 파라미터
    • T num1, T num2: 사용자로부터 입력받은 숫자 (정수든 실수든 상관 없음)
    • OperatorType op: Enum을 통해 연산 종류를 전달받음 (ADD, MINUS, 등)
  • 내부 로직→ 제네릭 타입이 어떤 숫자 타입이든 doubleValue()로 변환해서 연산의 일관성을 유지합니다.
double a = num1.doubleValue();
double b = num2.doubleValue();
  • 연산 실행→ OperatorType에 정의된 람다식 연산자 동작을 그대로 호출합니다.
return op.calculate(a, b);

ResultRepository.java 클래스

package lv3;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 연산 결과를 저장하고 관리하는 저장소 클래스
 * 제네릭 타입 T는 Number를 상속받는 타입만 사용 가능
 */
public class ResultRepository {

    //계산 결과 저장 리스트
    private final List<Double> results = new ArrayList<>();

    // 저장
    public void save(double result) {
        results.add(result);
    }

    // 전체 결과 조회
    public List<Double> findAll() {
        return new ArrayList<>(results);
    }

    // 가장 오래된 값 삭제
    public void deleteOldest() {
        if (!results.isEmpty()) {
            results.remove(0);
        }
    }

    // 특정 값보다 큰 결과 조회
    public List<Double> findBigger(double value) {
        return results.stream()
                .filter(result -> result > value)
                .collect(Collectors.toList());
    }
}

클래스 개요

ResultRepository는 계산된 결과(double)들을 저장하고,
필요할 때 조회, 삭제, 조건 필터링(ex. 큰 값만 보기) 할 수 있도록 도와주는 결과 저장소 클래스입니다.

내부 필드

private final List<Double> results = new ArrayList<>();
  • 계산된 결과를 저장할 리스트입니다.

주요 메서드

public void save(double result) {
    results.add(result);
}
  • 새로운 연산 결과를 저장합니다.
  • 연산이 끝날 때마다 App 클래스에서 호출됩니다.
public List<Double> findAll() {
    return new ArrayList<>(results);
}
  • 현재까지 저장된 모든 결과를 반환합니다.
  • new ArrayList<>(...)를 통해 원본 리스트 보호 (캡슐화 원칙 준수).
public void deleteOldest() {
    if (!results.isEmpty()) {
        results.remove(0);
    }
}
  • 리스트의 가장 첫 번째 요소(= 가장 오래된 결과)를 삭제합니다.
public List<Double> findBigger(double value) {
    return results.stream()
            .filter(result -> result > value)
            .collect(Collectors.toList());
}
  • 람다와 스트림을 활용해, 사용자가 입력한 값보다 큰 결과만 필터링하여 반환합니다.

Input.java 클래스

package lv3;

import java.util.Scanner;

// 숫자 입력 받는 클래스 (정수 or 실수 판별 후 Number 타입으로 반환)
public class Input {
    public static Number inputNumber(String message, Scanner sc) {
        while (true) {
            System.out.print(message);
            String inputNum = sc.nextLine();
            try {
                if (inputNum.contains(".")) {
                    return Double.parseDouble(inputNum);
                } else {
                    return Integer.parseInt(inputNum);
                }
            } catch (NumberFormatException e) {
                System.out.println("잘못된 입력입니다. 숫자를 입력해주세요.");
            }
        }
    }
}

클래스 개요

Input 클래스는 사용자로부터 숫자를 입력 받아서 정수인지 실수인지 판별하고,
해당 값을 자동으로 Number 타입으로 반환해주는 입력 전용 도우미 클래스입니다.

즉, 숫자 입력 로직만 깔끔하게 분리해서 App 클래스의 책임을 줄여주는 역할을 합니다.

주요 메서드

public static Number inputNumber(String message, Scanner sc)
  • 사용자에게 메시지를 출력하고 숫자를 입력받습니다.
  • 입력값에 **.(소수점)**이 포함되어 있으면 실수로, 그렇지 않으면 정수로 간주합니다.
  • 그에 따라 Double 또는 Integer로 변환하여 Number 타입으로 반환합니다.

구현 흐름

while (true) {
    System.out.print(message);
    String inputNum = sc.nextLine();
    try {
        if (inputNum.contains(".")) {
            return Double.parseDouble(inputNum);
        } else {
            return Integer.parseInt(inputNum);
        }
    } catch (NumberFormatException e) {
        System.out.println("잘못된 입력입니다. 숫자를 입력해주세요.");
    }
}

입력값이 숫자가 아닌 경우에는 NumberFormatException을 잡아서 경고 메시지를 출력하고,
반복 입력을 유도합니다.

왜 Number 타입으로 반환하나요?

  • 정수(int)든 실수(double)든 모두 Number라는 공통 부모 클래스를 가지고 있기 때문입니다.
  • 즉, 다양한 숫자 타입을 하나의 반환 타입(Number)으로 통일해서 처리할 수 있습니다.
  • 이후 계산 로직에서는 num.doubleValue()처럼 유연하게 처리 가능!

Command.java 클래스

package lv3;

import java.util.Scanner;


// 부가 기능 실행 클래스
public class Command {

    public static boolean process(ResultRepository resultRepository, Scanner sc) {
        System.out.println("더 계산할건가요?");
        System.out.println("기능 : [exit : 종료, show : 결과 확인, del : 결과 삭제, big : 특정 값 보다 큰 값 조회]");

        // order에 대문자, 소문자, 빈 값이 들어올 수 있으므로 받아올 때 toLowerCase().trim()를 통해 소문자로 변환시키고, 양쪽에 빈 값을 없앤다.
        String order = sc.nextLine().toLowerCase().trim();
        if (order.equals("exit")) {
            System.out.println("계산기를 종료합니다. 안녕히 가세요");
            return false;
        } else if (order.equals("show")) {
            System.out.println("저장된 결과들: " + resultRepository.findAll());
//            option.showResult();
        } else if (order.equals("del")) {
            resultRepository.deleteOldest();
            System.out.println("삭제 후 결과들: " + resultRepository.findAll());
//            option.deleteOldest();
        } else if (order.equals("big")) {
            System.out.print("비교하고 싶은 값을 입력해주세요: ");
            double compareNum = sc.nextDouble();
            System.out.println("특정 값 보다 큰 값: " + resultRepository.findBigger(compareNum));
//            option.showBigger(compareNum);
            sc.nextLine();
        } else {
            System.out.println("올바른 명령어가 아닙니다. 다시 입력해주세요.");
        }
        return true;
    }
}

클래스 목적

Command 클래스는 계산 결과 외에 사용자가 입력할 수 있는 부가기능 명령어(예: 결과 보기, 삭제, 특정값 이상 조회 등)를 처리하는 명령어 제어 클래스입니다.

즉, App 클래스에서 명령어별 로직을 분리하여 코드의 가독성과 책임 분리를 높인 구조입니다.

주요 메서드

public static boolean process(ResultRepository resultRepository, Scanner sc)

사용자에게 부가기능을 선택하라는 안내 메시지를 출력하고,
입력된 명령어에 따라 적절한 기능을 실행합니다.

명령어동작
exit 프로그램 종료
show 저장된 계산 결과 전체 출력
del 가장 오래된 결과 삭제
big 특정 숫자보다 큰 결과 조회

주요 구현 흐름

String order = sc.nextLine().toLowerCase().trim();
  • 대문자/소문자/공백 모두 허용 → 사용자의 실수 방지
if (order.equals("exit")) {
            System.out.println("계산기를 종료합니다. 안녕히 가세요");
            return false;
        } else if (order.equals("show")) {
            System.out.println("저장된 결과들: " + resultRepository.findAll());
//            option.showResult();
        } else if (order.equals("del")) {
            resultRepository.deleteOldest();
            System.out.println("삭제 후 결과들: " + resultRepository.findAll());
//            option.deleteOldest();
        } else if (order.equals("big")) {
            System.out.print("비교하고 싶은 값을 입력해주세요: ");
            double compareNum = sc.nextDouble();
            System.out.println("특정 값 보다 큰 값: " + resultRepository.findBigger(compareNum));
//            option.showBigger(compareNum);
            sc.nextLine();
        } else {
            System.out.println("올바른 명령어가 아닙니다. 다시 입력해주세요.");
        }
        return true;
  • exit을 입력하면 false를 반환 → App 클래스에서 while 반복문 종료
  • 그 외 명령어는 저장소(ResultRepository)의 메서드를 호출하여 결과를 처리

구현 중 변경사항 - Options.java 클래스 제거

기존 App.java 클래스에서는 다음과 같이 계산 결과를 저장하고, 부가 기능을 수행하는 객체를 생성했습니다.

// 계산 결과 저장 객체 생성
        ResultRepository resultRepository = new ResultRepository();
        // 부가 기능 객체 생성
//        Options options = new Options(resultRepository);
if (!Command.process(options, sc)) break;

 

여기서 Options 클래스는 ResultRepository의 값을 활용해 단순한 비즈니스 로직을 수행하는 클래스였습니다.

Options.java 클래스

package lv3;

public class Options {

    private final ResultRepository resultRepository;

    public Options(ResultRepository resultRepository) {
        this.resultRepository = resultRepository;
    }

    // 저장 되어있는 결과 출력
    public void showResult() {
            System.out.println("저장된 결과들: " + resultRepository.findAll());
    }

    // 결과 삭제
    public void deleteOldest() {
            resultRepository.deleteOldest();
            System.out.println("삭제 후 결과들: " + resultRepository.findAll());
    }

    // 특정값 보다 큰 값 조회
    public void showBigger(double number) {
            System.out.println("특정 값 보다 큰 값: " + resultRepository.findBigger(number));
    }


}

왜 제거하게 되었는가?

처음에는 “역할과 책임 분리(Separation of Concerns)”의 원칙을 지키기 위해 Options 클래스를 만들었습니다.
하지만 튜터님으로부터 다음과 같은 피드백을 받았습니다.

“Options 클래스와 ResultRepository 클래스의 기능이 너무 비슷하지 않나요?”

 

이 피드백을 통해 실질적으로 Options는 ResultRepository의 메서드를 거의 그대로 호출만 하고 있다는 점을 인지하게 되었고, 결국 중복 추상화로 인한 불필요한 레이어라는 판단을 하게 되었습니다.

 

리팩토링 방향

단순한 위임만 수행하는 Options 클래스를 제거하고, Command 클래스 내에서 ResultRepository를 직접 호출하도록 리팩토링했습니다.

변경 전:

option.showResult();

변경 후:

System.out.println("저장된 결과들: " + resultRepository.findAll());

 

결과 및 느낀 점

이러한 구조 변경을 통해:

  • 클래스 수가 줄어들며 구조가 간결해졌고,
  • 불필요한 추상화 레이어를 제거해 가독성과 유지보수성을 높일 수 있었습니다.

객체지향에서 "역할 분리"는 중요하지만, 역할이 지나치게 얕거나 중복될 경우 오히려 코드의 복잡도만 증가시킬 수 있다는 점을 배웠습니다.