본문 바로가기
Java/계산기 구현

트러블 슈팅 - 기능 별로 클래스를 나누어보자

by JuNo_12 2025. 4. 23.

3단계 계산기를 처음부터 다시 만들어보았습니다.

어떤 순서대로 구현을 했는지에 초점을 맞추어 기록을 진행해보겠습니다.

 

우선, 핵심 기능들이 무엇이 있는지부터 파악을 해보았어요.

  • 실수와 사칙연산자를 입력받는 기능
  • 입력받은 데이터들을 활용한 연산 기능
  • 연산 결과들을 저장해주는 리스트 생성
  • 저장된 리스트들의 값을 조회해주는 기능
  • 가장 오래된 결과값을 삭제해주는 기능
  • 종료, 조회, 삭제 등 커맨드를 입력받고 실행해주는 기능
  • 예외처리 해주는 기능

구현 순서를 생각해보면,

 

1. 실수와 사칙연산자들을 입력을 받는 클래스를 생성해준다.

2. 입력받은 사칙연산자들을 enum 타입으로 관리를 해준다. (여기서 0으로 나누려고 했을 때와 올바른 사칙연산기호를 입력하지 않았을 때, 총 2가지의 예외를 발생시켜준다. 예외처리는 추후 단계에서 진행)

3. 연산 기능을 지닌 클래스를 생성해준다. (여기에 리스트를 선언해주고 데이터들을 저장해준다.)

4. 조회를 해주는 기능은 getter()를 사용하여 호출해준다.

5. 가장 오래된 결과값을 삭제해주는 기능을 지닌 클래스를 생성해준다.

6. 종료, 조회, 삭제, 다음 연산을 진행해주는 기능을 지닌 클래스를 생성해준다.

 

사실, 3번까지만해도 충분히 할 수 있는 내용입니다. (enum 타입에 대한 공부를 잘 했다면)

하지만, 내가 코드 구현을 하면서 어려웠던 점이 2가지 정도 있는데,

  • 결과값들이 들어가있는 리스트를 여러 클래스(삭제, 조회, 연산, 메인)에서 사용하기에 값이 덮여쓰여지거나 엉뚱한 곳에서 초기화가 되어 저장이 안되는 문제점
  • 클래스가 여러 개다보니 한 클래스 내에 여러가지 객체를 생성하고 호출해줘야하는 어려움

이 2가지가 가장 어려웠습니다. 뒷 내용은 이 2가지를 어떻게 해결해 나가고 내가 어떠한 지식이 부족했는지에 대해 적어보겠습니다. 그리고 코드블럭에서 설명이 들어가 있지 않은 부분은 없앴습니다.


입력받은 연산자들을 가져와서 연산 기능에 활용

public class InputData {
    Scanner sc = new Scanner(System.in);

    public static Operators.Operator operator;
    public double n1, n2;

    public void inputNumber() {

            System.out.print("첫 번째 실수를 입력하세요: ");
            this.n1 = sc.nextDouble();

            System.out.print("두 번째 실수를 입력하세요: ");
            this.n2 = sc.nextDouble();

            System.out.print("사칙 연산자를 입력하세요: ");
            char inputOperator = sc.next().charAt(0);
        }
   }

 

위에서 두 실수와 사칙연산자를 입력받았고

 

 

public class Operators {
	public enum Operator {
        ADD('+'){
				...
            }
        },
        SUBTRACT('-'){
				...
            }
        },
        MULTIPLE('*'){
				...
            }
        },
        DIVIDE('/'){
				...
	    	}
        };
	}
}

 

enum 타입으로 입력받은 사칙연산자에 대한 정보를 관리해주었습니다.

 

 

아래 코드는 연산 기능을 지닌 클래스입니다.

public class Calculation {
    InputData inputData;
    private final List<Double> results = new ArrayList<>();

    public Calculation(InputData inputData) {
        this.inputData = inputData;
    }

    public List<Double> getResults() {
        return results;
    }

    public void calculate() {
            double result = InputData.operator.apply(inputData.n1, inputData.n2);
            results.add(result);
            System.out.println("연산 결과 : " + result);
    }
}

 

 

 

여기서 볼 부분은, 아래 부분인데

    public Calculation(InputData inputData) {
        this.inputData = inputData;
    }

 

'생성자' 는 InputData 클래스에서 객체를 받아와서 이 클래스 내부에서 사용할 수 있게 하기 위해 필요합니다.

 

왜냐하면, 이 클래스는 입력된 두 실수 (n1, n2)와 연산자 (inputOperator)를 기반으로 연산을 수행하는데 이 값들은 InputData 클래스에서 입력받기 때문에 이 정보들을 가져와야하죠.

 

'this.inputData = inputData;' 이 부분이 있어야 inputData.n1, inputData2 같은 걸 연산 메서드에서 '참조' 할 수 있습니다.

 

 

 

 

그리고 이 부분을 진행할 때 InputData 클래스에 아래와 같이 작성해주었는데

operator = Operators.Operator.fromSymbol(inputOperator);

 

이는 "입력받은 inputOperator를 Operators 클래스에서 걸러주고, Operators 클래스에서 걸러준 사칙연산자들을 InputData 클래스에서 operator 라는 변수에 넣어줬다." 라고 생각하시면 됩니다.

 

이렇게 한 이유는 이 변수를 Calculation 클래스에서 사용하여 입력받은 실수들과 연산을 진행하고 싶기 때문이였습니다.

( 연산을 진행하기 이전에 미리 걸러줬다고 생각하면 됩니다. )

 

 

 

 

자 여기서 첫번째 어려움이 발생합니다.

public class Command {

    Scanner sc = new Scanner(System.in);
    CheckResults checkResults;
    Calculation calculation;
    RemoveResult removeResult;

    public Command(Calculation calculation, CheckResults checkResults, RemoveResult removeResult){
        this.checkResults = checkResults;
        this.calculation = calculation;
        this.removeResult = removeResult;
    }


    public void command(){
        while (true) {
            System.out.println("추가적인 계산을 원하시면 '계산'을, 특정 값 이상의 데이터 조회를 원하시면 '조회'를, 가장 오래된 값 삭제를 원하시면 '삭제'를, 종료를 원하시면 '종료'를 입력해주세요.");
            String input = sc.nextLine();

            switch (input){
                case "계산" :
                return;

                case "조회" :
                    checkResults.filter(calculation.getResults());
                    break;

                case "종료" :
                    System.out.println("계산기를 종료합니다.");
                    System.exit(0);

                case "삭제" :
                    removeResult.removeResult();
                    break;

                default:
                    System.out.println("올바른 명령어를 입력해주세요.");
            }

         }
    }
}

 

 

여기서 집중해서 볼 곳은 아래와 같습니다.

                case "계산" :
                return;

 

이게 참 짧고 쉬워보이지만, 제가 애초에 매서드를 void로 선언해주었죠?

왜? 이 메서드에서 반환할게 없다고 판단했기때문이였습니다. (단순히 입력받고 출력만 해주는 기능으로서 사용하려는 기능)

 

근데 여기서 '계산'을 입력하면 이 메서드를 빠져나와서 처음에 적어줬던 연산자들을 입력하는 곳으로 이동을 하고싶었는데,

저는 break;와 continue;만 알아서 이를 해결할 방안이 없었습니다.

 

처음에 한 생각은 "아 그냥 이 계산 case에서 입력받는메서드와 연산 메서드를 가져와서 실행을 해주어야겠다." 였는데,

생각해보니 메인 클래스에 적어둔 것과 똑같은 것이였습니다.

그래서 뭔가 너무 불필요한 코드라고 생각했습니다.

 

한편으로 "메인클래스에 내가 적을 내용이 '이미' 존재하는데 이 메인클래스의 첫 부분으로 이동을 할 수 있는 방법이 없을까? " 라고 생각해서 튜터님께 여쭤본 결과 void 메서드에서 return;을 사용하게 되면 이는 '메서드 실행을 조기 종료' 하는 기능을 수행한다고 하셨습니다. 그래서 이 부분은 우선 해결을 했습니다.


다음으로 넘어가보죠!

 

값을 입력 받아서 입력 받은 값보다 큰 결과값들을 출력해주는 기능을 지닌 클래스입니다.

public class CheckResults {
    
    Scanner sc = new Scanner(System.in);

    public void filter(List<Double> results){
        System.out.print("기준 값을 입력하세요: ");
        double filterNumber = sc.nextDouble();

        List<Double> filtered = results.stream()
                .filter(num -> num > filterNumber)
                .toList();

        if(filtered.isEmpty()){
            System.out.println("입력한 값보다 큰 연산 결과가 없습니다.");
        } else {
            System.out.println("결과 값들 : ");
            filtered.forEach(num -> System.out.print(num + "  /  "));
            System.out.println();
        }
    }

}

 

위 코드는 뭐 별거 없어서 여긴 넘어가도록하고 이번엔 삭제를 해주는 클래스를 보겠습니다.


오래된 결과값들부터 삭제를 해주는 기능을 지닌 클래스

public class RemoveResult {

    Calculation calculation;

    public RemoveResult(Calculation calculation){
        this.calculation = calculation;
    }

    public void removeResult(){
        if(!calculation.getResults().isEmpty()){
            System.out.println("삭제된 값: " + calculation.getResults().removeFirst());
            for (int i = 0; i < calculation.getResults().size(); i++) {
                System.out.println((i + 1) + "번째 결과 값 : "+ calculation.getResults().get(i));
            }
        } else {
            System.out.println("연산 데이터가 존재하지 않습니다.");
        }
    }
}

 

여기가 우리가 배웠던 내용들을 복습하기 딱 좋은 클래스입니다.

 

우선, 이전에 Calculation 클래스에 연산을 마친 결과값들을 저장해준 '리스트' 를 만들어주었죠?

 

저는 이 클래스에서 삭제 기능을 만들기 위해선 이 '리스트'를 가져올 필요가 있었습니다.

그래서 calculation 이라는 인스턴스 변수를 생성해주고 이를 통해 '리스트'에 접근하려고 했습니다.

 

 

 

 

위에서 했던 생성자에 대해 복습해봅시다.

    Calculation calculation;
    
    public RemoveResult(Calculation calculation){
        this.calculation = calculation;
    }

 

외부에서 전달된 Calculation 객체를 이 클래스 내부의 calculation 변수에 저장했습니다.

이렇게 하면 RemoveResult 클래스는 계산 결과값에 접근할 수 있게 된 것이죠?

 

사실 너무 길게 설명 했는데, 단순하게 생각하면

"Calculation 클래스에 있는 리스트를 가져오기 위해 객체를 생성해서 이 객체에 대한 정보를 calculation에 넣어주고 이를 RemoveResult 클래스에서 사용할 수 있도록 선언했다." 라고 생각하면 된다.

(사실 틀린 말이긴 합니다만 뒤에서 다뤄보겠습니다.)

 

근데 여기서  "어? 객체를 생성할 때는 new로 해주는거 아닌가?" 라고 생각할 수도 있는데 반은 맞고 반은 틀립니다.

 

 

 

 

이걸 이해하려면 제가 작성한 메인 클래스를 봐야하는데

package newCalculator;

public class Main {
    public static void main(String[] args) {

        InputData inputData = new InputData();
        Calculation calculation = new Calculation(inputData);
        CheckResults checkResults = new CheckResults();
        RemoveResult removeResult = new RemoveResult(calculation);
        Command command = new Command(calculation, checkResults, removeResult);

        while (true) {
            inputData.inputNumber();

            calculation.calculate();

            command.command();
        }
    }
}

 

메인 클래스에서 new 키워드로 객체를 생성해주었습니다.

 

결론부터 말하면, 이렇게 하면 하나의 calculation 객체 (ex. 리스트) 를 Command, RemoveResult 등 여러 클래스들이 '함께' 공유할 수 있습니다. => 연산 결과 리스트를 한 군데에서 관리하고 유지할 수 있죠.

 

 

 

 

예를 들어서

public class RemoveResult {
    Calculation calculation = new Calculation(new InputData()); // 이렇게 내부에서 직접 생성

    public void removeResult() {
        ...
    }
}

 

이런 식으로 객체를 만들어줬다고 하면, 이는 '새로운' Calculation 객체가 '따로' 만들어지기 때문에 Main 클래스에서 쓰는 calculation 이랑 완전히 다른 객체가 되어버립니다. 그렇게 되면 결과 리스트 공유가 불가능하고, 값 누적이 불가능해지는 아주 큰 문제가 발생하죠.

 

 

 

 

정리하자면,

    Calculation calculation;
    
	public RemoveResult(Calculation calculation){
        this.calculation = calculation;
    }

 

이것의 정확한 의미는 " '이미' 메인 클래스에서 객체를 생성 해주었는데, 이 생성된 객체를 '불러와서' calculation 이라는 인스턴스 변수 내에 저장을 해주어서 RemoveResult 클래스 내부에서 리스트에 접근할 수 있게 해주었다." 입니다.

 

 

 

    Calculation calculation;

 

이건 단지 RemoveResult 클래스 안에 있는 '인스턴스 변수(필드)' 일 뿐이죠.

실제 객체가 아니고, 그냥 "Calcuation 타입의 변수를 하나 선언해놓은 것" 일 뿐입니다.

 

 

 

	public RemoveResult(Calculation calculation){
        this.calculation = calculation;
    }

 

이는 메인 클래스에서 '이미' 생성해둔 Calculation 객체를 외부에서 받아와서 이 클래스 안의 calculation 필드에 저장해주는 것입니다.


자 마지막으로 제가 엄청 헤매었던 부분을 같이 봅시다.


어떠한 문제점이 발생했냐면, 첫 번째 연산을 마치고 두 번째 연산을 진행하고 '조회' 를 진행했더니 두 번째 연산결과값만 출력이 되는 것이였습니다. (저는 이 문제를 도저히 어떻게 해결해야하는지 몰랐습니다. 이거때문에 점심도 안먹고 계속 붙잡고 있었...)

 

 

 

문제가 발생한 시점에 작성된 메인 클래스를 보시면

package newCalculator;

public class Main {
    public static void main(String[] args) {
    
        while (true) {
            InputData inputData = new InputData();
            inputData.inputNumber();

            Calculation calculation = new Calculation(inputData);
            calculation.calculate();

            Command command = new Command(calculation, new CheckResults(), new RemoveResult(calculation));
            command.command();
        }
    }
}

 

 

 

여기서 주목해야할 곳은 아래 부분입니다. 물론 다른 이유도 있겠지만, 이 부분만 정확히 짚고 넘어가더라도 다음에 또 이런 문제가 발생했을 때 해결할 수 있을겁니다.

while (true) {
	Calculation calculation = new Calculation(inputData);
    }

 

제가 말한 문제점인 도대체 어떤 이유로 리스트에 첫 번째 연산 데이터가 없고 두 번째 연산 데이터만 있었는지 아실거같은 분은 박수쳐드릴게요. 대박입니다. 왜냐하면 지금까지 내용을 기억해야 알 수 있거든요.

 

 

 

이전에 Command 클래스 기억하시나요? 일정 부분만 떼어서 보겠습니다.

            switch (input){
                case "계산" :
                return;
                }

 

아까 길게 설명한 부분이죠?

여기서 '계산'을 입력하면 메인 클래스의 while문 처음으로 돌아갑니다. 거기서 연산을 할 수 있죠.

 

그런데, 위의 while문 '내부'에서 객체를 '생성' 해주었습니다.

 

이게 무슨 뜻일까요?

처음에 연산한 결과가 리스트에 쌓여있던건 분명합니다.

하지만, 객체를 '다시' 생성해주어서 이전 결과값이 들어가 있는 리스트는 사라지고, 새로운 '빈' 리스트에 두 번째 연산값이 저장되었기 때문이죠. 연산을 할 때마다 매 번 초기화 되는겁니다.

 

그래서

public class Main {
    public static void main(String[] args) {

        InputData inputData = new InputData();
        Calculation calculation = new Calculation(inputData);
        CheckResults checkResults = new CheckResults();
        RemoveResult removeResult = new RemoveResult(calculation);
        Command command = new Command(calculation, checkResults, removeResult);

        while (true) {
            inputData.inputNumber();

            calculation.calculate();

            command.command();
        }
    }
}

 

이런 식으로 루프 밖에서 객체를 생성해주면 계속 '유지'가 되면서 리스트에 결과값이 누적될 수 있는겁니다.


마무리하며...

3단계를 처음부터 다시 만들면서 느낀 가장 큰 점은

각 명령어가 어떠한 역할을 수행할 수 있는지를 아는 것보단

 

나 자신이 어떠한 방향성을 가지고 코드를 만들 것인지,

어떠한 순서로 코드가 진행이 되고 있는지 (또는 어떤 순서로 진행이 되길 원하는지),

각 클래스의 뚜렷한 역할 배정,

클래스명과 변수명의 중요성 등

이러한 점들이 굉장히 중요하구나 라고 느꼈습니다.

 

이 계산기에서 개선되어야할 부분은

1. 제네릭과 람다를 활용해보기

2. 클래스명과 변수명을 길어도되니까 누구나 이해할 수 있게 수정

입니다.

 

아직 변수명을 짓는 것이 익숙하지 않지만, 나중에 협업을 할 때 굉장히 큰 걸림돌이 될거같습니다. 그래서 다음 과제를 진행할 때는 클래스명과 변수명을 지을 때 많은 고민과 공부를 해봐야겠습니다.