구현 요구사항
Lv.3 의 구현사항은 아래와 같다.
- 계산기 클래스의 메서드 `calculate`의 피연산자로 모든 타입의 숫자를 받아올 수 있어야 한다. (int, double, 양수, 음수)
- enum 으로 연산자 타입을 관리하고 활용한다.
- 계산기에 저장된 연산 결과를 Lambda & Stream을 활용하여 조건에 맞도록 조회하는 메서드를 구현한다.
오늘은 1과 2를 중점적으로 고민하여 구현하였다.
Generics : 피연산자 타입 확장하기
먼저 기존 Lv.2의 계산기 클래스이다.
기존의 피연산자 입력으로 0을 포함한 양의 정수만을 받아왔기 때문에 `calculate` 메서드의 피연산자 타입이 int로 지정되어있다.
결과 값은 double로 지정되어있는데, 피연산자가 모두 정수여도 나누기 연산 시 실수가 될 수 있기 때문이다.
이제 Generics를 활용하여 피연산자 타입을 Integer & Double 모두 가능하도록 만들어줘야 한다.
import java.util.LinkedList;
public class Calculator {
private LinkedList<Double> result = new LinkedList<>();
public double calculate(int firstNum, int secondNum, String operator) {
// 양의 정수(0포함) 2개와 연산 기호를 매개변수로 받아 사칙연산 기능을 수행한 후 결과 값을 반환
if (firstNum < 0 || secondNum < 0) {
throw new IllegalArgumentException("0 이상의 정수를 입력해주세요.");
}
switch (operator) {
case "+" -> {
return firstNum+secondNum;
}
case "-" -> {
return firstNum-secondNum;
}
case "*" -> {
return firstNum*secondNum;
}
case "/" -> {
if(secondNum == 0) {
throw new ArithmeticException("나눗셈 연산 시 분모(두 번째 숫자)가 0이 될 수 없습니다.");
}
return (double) firstNum/secondNum;
}
default -> {
throw new IllegalArgumentException("올바른 연산자를 입력해주세요. (+,-,*,/ 중 하나)");
}
}
}
public double getResult() {
return this.result.get(this.result.size()-1);
}
public int getSizeOfResult() {
return this.result.size();
}
public void setResult(double result) {
this.result.add(result);
}
public void removeResult() {
// 가장 먼저 저장된 데이터를 삭제
if(!result.isEmpty()) {
this.result.removeFirst();
}
}
}
💥 타입변수간 사칙연산 연산자 사용 불가
우선 단순하게 <T extends Number>로 계산기 클래스를 지네릭 클래스로 만들고 피연산자 타입에 타입변수 T를 대입해줬다.
- String, Chracter 등 다른 타입이 피연산자로 들어오는 것을 막는다.
- Integer, Double의 조상 클래스인 Number를 선언하여 Integer와 Double 타입이 모두 피연산자로 들어올 수 있도록 한다.
그랬더니 아래와 같은 에러가 발생하였다.
타입변수끼리는 사칙연산자를 사용할 수 없다는 에러였다.
extends Number로 선언해서 연산이 될 줄 알았는데??
Integer, Double과 같은 Wrapper 클래스 객체에는 사칙연산자를 사용할 수 있다. 이는 컴파일러가 오토 언박싱을 수행해주기 때문이다.
> Auto Unboxing
- Wrapper 클래스가 사칙연산의 피연산자가 되었을 때, 컴파일러가 자동 언박싱(Auto Unboxing)을 수행한다.
- Auto Unboxing은 어떻게 수행되는가? 아래와 같다.
- 즉, 참조변수의 타입을 확인하고 해당 타입이 Integer이면 `intValue()` 메서드를, Double이면 `doubleValue()` 메서드를 호출하여 Wrapper 클래스가 멤버변수로 갖고 있는 원시 값(int,double,char,....)을 가져오는 작업을 컴파일러가 수행한다.
- 그리고 그 값을 가지고 사칙연산을 수행하는 것
타입변수에 대해서는 extends Number를 해주어도 이 오토언박싱이 적용되지 않는다.
- 참고로 Integer, Double을 extends 해주더라도 (Integer), (Double)과 같이 형변환을 시켜주어야 한다.
따라서, 우리가 컴파일러 대신 직접 `intValue()`, `doubleValue()`를 사용하여 원시값을 가져와야 한다.
(+) 오토언박싱이 되지 않는 정확한 이유 & 로직이 궁금해서 여러 아티클을 살펴보았으나 아직 시원하게 해결하지를 못 했다 .... Generics의 타입 소거 & 런타임 동작에 관련이 있는 것 같은데 아직 확실히 이해가 가진 않는다 오늘 참고한 아티클은 아래에 있다
✅ doubleValue()로 모두 명시적 double 형변환 처리
public Double calculate(T firstNum, T secondNum, String operator) {
switch (operator) {
case "+" -> {
return firstNum.doubleValue()+secondNum.doubleValue();
}
case "-" -> {
return firstNum.doubleValue()-secondNum.doubleValue();
}
case "*" -> {
return firstNum.doubleValue()*secondNum.doubleValue();
}
case "/" -> {
if(secondNum.doubleValue() == 0) {
throw new ArithmeticException("나눗셈 연산 시 분모(두 번째 숫자)가 0이 될 수 없습니다.");
}
return firstNum.doubleValue()/secondNum.doubleValue();
}
default -> {
throw new IllegalArgumentException("올바른 연산자를 입력해주세요. (+,-,*,/ 중 하나)");
}
}
}
- 모든 피연산자로부터 `doubleValue()`를 통해 double 타입으로 값을 가져왔다.
- int -> double 로의 형변환은 언제나 안전하게 가능하므로 double 값으로 연산을 진행하도록 하였다.
코드를 보니 어딘가 모르게 불편한 이 마음은 enum 도입을 통해 해소된다. 계속 보자.
💥 사용자 입력을 어떻게 Integer / Double 로 형변환할 것인가?
기존엔 0을 포함한 양의 정수를 입력받아 int형 변수에 저장하고, 다른 입력값일 경우 `calculate` 메서드 내에서 예외처리를 해주었다.
System.out.print("첫 번째 숫자(0 이상의 정수)를 입력해주세요 : ");
int firstNum = Integer.parseInt(sc.nextLine());
System.out.print("두 번째 숫자(0 이상의 정수)를 입력해주세요 : ");
int secondNum = Integer.parseInt(sc.nextLine());
System.out.print("연산자를 입력해주세요 (+,-,*,/ 중 하나) : ");
String operator = sc.nextLine();
// 연산 & 연산 결과 저장
calculator.setResult(calculator.calculate(firstNum, secondNum, operator));
// 연산 결과 출력
System.out.println("결과 : " + calculator.getResult());
이제는 사용자가 실수를 입력할 경우 Double형으로, 정수를 입력할 경우 Integer형으로 `calculate` 메서드의 매개변수에 넣어주어야 한다.
이 역할을 수행할 모듈로 `NumberParser`를 구현하였다.
✅ NumberParser 구현
public class NumberParser {
/**
* 소수점 여부에 따라 Double 또는 Integer를 반환합니다.
*/
public static Number parse(String number) throws NumberFormatException {
if(number.contains(".")) {
return Double.parseDouble(number);
} else {
return Integer.parseInt(number);
}
}
}
- static 메서드 `parse`를 통해 문자열을 가져와서 소수점(.)을 포함한다면 실수로 간주하여 Double로 형변환을 해주고, 포함하지 않는다면 정수로 간주하여 Integer로 형변환을 하도록 하였다.
- parseDouble() 과 parseInt() 를 수행할 때 숫자 형식이 맞지 않는다면 NumberFormatException이 발생한다. 이 에러 핸들링을 parse 메서드에서 바로 해줄까, 아니면 main 메서드에서 할까 고민을 하다가 일단 연산 실패와 관련된 다른 예외들을 main 메서드에서 하고 있어서 main 메서드에 catch 블럭을 추가해주었다. 그런데 어디가 좋을 지 더 고민을 해봐야 할 것 같다 . . .
이제 다음과 같이 사용자 입력을 받아오고, NumberParser를 이용하여 Integer 혹은 Double로 파싱한 뒤 calculate 함수의 매개변수로 넘어간다.
// 1. 입력 받기 : int와 double 모두 가능
System.out.print("첫 번째 숫자를 입력해주세요 : ");
String firstNum = sc.nextLine();
System.out.print("두 번째 숫자를 입력해주세요 : ");
String secondNum = sc.nextLine();
System.out.print("연산자를 입력해주세요 (+,-,*,/ 중 하나) : ");
String operator = sc.nextLine();
// 2. 연산
// String -> Integer / Double로의 변환을 위해 NumberParser를 사용합니다.
calculator.calculate(
NumberParser.parse(firstNum),
NumberParser.parse(secondNum),
operator
);
// 3. 연산 결과 출력
System.out.println("결과 : " + calculator.getResult());
실행 화면
Enum : OperatorType 사용하기
기존 `ArithmeticCalculator` 클래스의 `calculate` 메서드를 보고 문제점을 뽑아보자.
public Double calculate(T firstNum, T secondNum, String operator) {
switch (operator) {
case "+" -> {
return firstNum.doubleValue()+secondNum.doubleValue();
}
case "-" -> {
return firstNum.doubleValue()-secondNum.doubleValue();
}
case "*" -> {
return firstNum.doubleValue()*secondNum.doubleValue();
}
case "/" -> {
if(secondNum.doubleValue() == 0) {
throw new ArithmeticException("나눗셈 연산 시 분모(두 번째 숫자)가 0이 될 수 없습니다.");
}
return firstNum.doubleValue()/secondNum.doubleValue();
}
default -> {
throw new IllegalArgumentException("올바른 연산자를 입력해주세요. (+,-,*,/ 중 하나)");
}
}
}
💥 기존 코드의 문제점
- 한 메서드 내에서 너무 많은 작업을 모두 처리한다.
- 현재 calculate 메서드는 연산자 분기 처리, 피연산자 데이터 변환, 연산 예외 처리를 모두 담당하여 여러 책임을 갖고 있다.
- 만약 사칙연산을 제외한 다른 새로운 연산들이 더 많이 늘어나게 된다면? 예를 들어 사칙연산이 아닌 제곱근, 로그, 지수 연산이 추가가 된다면? 그에 맞는 case 문이 늘어나고, 해당 연산에 발생할 수 있는 예외 처리문까지 다 덧붙여지게 된다면 결국 엄청나게 뚱뚱한 메서드가 되어버릴 수 있다.
- 따라서 각 책임을 별도의 메서드 또는 클래스에 위임하여 코드를 명확하게 분리해주는게 좋을 것 같다.
- 재사용성 & 확장성 빵점이다.
- 가능한 연산 종류가 switch-case문 내에 하드코딩 되어 있다는 점
- 찜찜한 기분
학부생 시절부터 저렇게 짜지말라고 세뇌 당함여러 모듈로 나눠주지 않으면 어딘가 찜찜함
문제점들을 다음과 같이 해결하고자 하였다.
- 연산자 확인 로직, 연산을 수행하는 로직을 각기 다른 메서드에 작성하여 책임을 분리시킨다.
- 다른 클래스에서도 사칙연산 기능을 사용할 수 있도록 모듈화하여 재사용성을 높인다.
- 가능한 연산 종류를 상수로 관리한다.
✅ OperatorType 구현
public enum OperatorType {
ADD("+") {
@Override
public double operate(Number a, Number b) {
return a.doubleValue() + b.doubleValue();
}
}, SUBTRACT("-") {
@Override
public double operate(Number a, Number b) {
return a.doubleValue() - b.doubleValue();
}
}, MULTIPLY("*") {
@Override
public double operate(Number a, Number b) {
return a.doubleValue() * b.doubleValue();
}
}, DIVIDE("/") {
@Override
public double operate(Number a, Number b) {
if(b.doubleValue() == 0) {
throw new ArithmeticException("나눗셈 연산 시 분모(두 번째 숫자)가 0이 될 수 없습니다.");
}
return a.doubleValue() / b.doubleValue();
}
};
private final String symbol;
OperatorType(String symbol) {
this.symbol = symbol;
}
public String getSymbol() {
return this.symbol;
}
public static OperatorType fromSymbol(String symbol) {
for(OperatorType type : values()) {
if(type.getSymbol().equals(symbol)) {
return type;
}
}
throw new IllegalArgumentException("지원하지 않는 연산입니다.");
}
public abstract double operate(Number a, Number b);
}
구현 내용
- 각 연산자 상수를 두고, String `symbol` 필드를 추가하여 각 상수가 해당하는 연산자 기호 값을 가질 수 있게 하였다.
- getter 함수도 추가해주었다.
- 추상 메서드를 선언하여 각 상수가 해당하는 연산을 구현하도록 하였다.
- 이전 코드에서와 같이 모든 숫자 타입을 피연산자로 허용하되, double 값으로 연산을 진행하도록 하였다.
- 이제 각 연산 상수마다 분리된 메서드를 하나씩 가질 수 있게 되었다.
- 연산자 기호에 해당하는 `OperatorType`을 가져올 수 있도록 `fromSymbol()` 메서드를 static으로 구현하였다.
- 이제 switch-case 문을 따로 작성할 필요 없이 OperatorType.fromSymbol()에 연산자 기호를 넣는다면 해당하는 OperatorType을 가져올 수 있다.
이제 계산기 클래스의 calculate는 어떻게 바뀌었을까?
public Double calculate(T firstNum, T secondNum, String operator) {
double result = OperatorType.fromSymbol(operator).operate(firstNum,secondNum);
setResult(result);
return result;
}
이렇게나 간단하게 바뀌었다.
fromSymbol 메서드를 통해 연산자 기호에 해당하는 OperatorType을 가져오고, 바로 operate 메서드를 사용하여 결과를 얻을 수 있다.
이제 메서드 하나가 여러 기능을 수행하지 않고 본인이 맡은 하나의 로직만 수행하게 되었다.
더불어 찜찜함도 함께 해소되었다 야호
계획했던 로직대로 구현은 완료하였지만, 타입변수에는 Integer와 같은 컴파일러로 의해 오토언박싱이 수행되는 클래스를 extends 하더라도 오토언박싱이 불가능한 이유를 명확하게 이해하지 못 해서 찝찝함이 남는다 ... 그리고 Exception 처리 위치도 계속해서 고민이 된다 흠
Lv.3의 3번째 구현 요구사항은 일단 만들어 놓기는 했는데, 내일 더 다듬어보아야겠다. 일단 오늘은 끝
'TIL' 카테고리의 다른 글
🧐 계산기 과제를 마무리하는데 생겨난 궁금증 (0) | 2025.01.09 |
---|---|
계산기 과제 : 계산 결과 Lambda&Stream 필터링 조회 구현하기 (0) | 2025.01.07 |
날아갈랑말랑했던 SQL 문법 복기 (2) (1) | 2024.12.27 |
날아갈랑말랑했던 SQL 기본 문법 복기 (1) (0) | 2024.12.26 |