3주차의 목표는 클래스 분리, 단위 테스트를 시작해 보는 것이다. 이번 주 아쉬웠던 부분을 피드백하고 모르는 내용을 정리하는 시간을 가지려 한다.
프로그래밍 규칙 및 구조
함수(메서드) 라인 기준
함수의 길이는 15라인으로 제한되며, 공백 라인도 포함된다. 이 규칙은 main() 함수에도 적용된다. 만약 함수가 15라인을 초과하면, 역할을 명확히 하고 코드의 가독성과 유지보수성을 높이기 위해 함수 또는 클래스를 분리해야 한다.
기존 getStart() 메서드는 로또 프로세스를 관리하는 기능을 담당하지만 15라인을 초과한다. 이는 한 가지 이상의 일을 처리 중일 가능성이 높다.
// 입력부터 출력에 관한 프로세스를 관리
public void getStart() {
while (true) {
try {
lottoPurchase.inputAmount();
issueLotto(lottoPurchase.getPurchaseCount());
printIssuedLottos();
break;
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
while (true) {
try {
lottoPurchase.inputLottoNumber();
updateWinningList(lottoPurchase.getWinningLotto());
printWinningReport();
break;
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
// 수익률 관련 메서드
calculateWinningRate();
printWinningRate();
}
getStart()를 아래와 같이 수정하면 반복되는 코드를 줄이고 메서드의 명확성과 가독성, 유지보수성을 높일 수 있다.
// 입력부터 출력에 관한 프로세스를 관리
public void getStart() {
handleLottoPurchase();
handleWinningReport();
calculateWinningRate();
printWinningRate();
}
private void handleLottoPurchase() {
executeWithRetry(() -> {
lottoPurchase.inputAmount();
issueLotto(lottoPurchase.getPurchaseCount());
printIssuedLottos();
});
}
private void handleWinningReport() {
executeWithRetry(() -> {
lottoPurchase.inputLottoNumber();
updateWinningList(lottoPurchase.getWinningLotto());
printWinningReport();
});
}
private void executeWithRetry(Runnable action) {
while (true) {
try {
action.run();
break;
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
예외 상황 고려
정상적인 상황보다 예외 상황을 고려하는 것이 더 어렵지만, 매우 중요하다. 코드를 작성할 때 예상되는 예외를 미리 생각해 프로그램이 비정상 종료되거나 잘못된 결과를 내지 않도록 해야 한다.
예외 처리에 대한 고민은 1주차부터 있었다.
[[우테코 7기 프리코스] 1주차 피드백](https://velog.io/@grayson1999/%EC%9A%B0%ED%85%8C%EC%BD%94-7%EA%B8%B0-%ED%94%84%EB%A6%AC%EC%BD%94%EC%8A%A4-1%EC%A3%BC%EC%B0%A8-%ED%94%BC%EB%93%9C%EB%B0%B1)
- 기록은 생명이다
요구 사항이 우선임을 기억하고, 중요한 내용은 메모나 주석, 기능 명세 등에 기록해야 한다. 현재 개발 중인 기능에 집중하여 계획대로 진행할 필요가 있다. 예외 처리를 위해 현재 작업에 집중하지 못하면 개발 시간이 늘어날 수 있다.
- 예외 처리는 미리 계획해야 한다
예외 처리를 하지 말라는 것이 아니다. 개발 전 단계에서 예외 처리 계획을 반드시 세워야 한다. 그 후 찾지 못한 예외는 기능 개발 시 반례를 생각하며 작성하면 좋다.
비즈니스 로직과 UI 로직 분리
비즈니스 로직과 UI 로직을 한 클래스에서 처리하는 것은 단일 책임 원칙(SRP)에 위배된다. 비즈니스 로직은 데이터 처리 및 도메인 규칙을 담당하고, UI 로직은 데이터 표시 및 입력을 처리해야 하므로 분리해야 한다. UI 관련 코드는 별도의 View 클래스로 분리하고, 객체 상태를 표현할 때는
toString()메서드를 사용하며, UI에서 필요한 데이터는 getter 메서드를 통해 전달하는 것이 바람직하다.
- 비즈니스 로직
- 애플리케이션의 핵심 기능과 비즈니스 규칙을 정의한다.
- 데이터 처리, 규칙 검증, 계산 등을 포함한다.
package calculator;
public class CalculatorService {
public double calculate(double a, double b, String operator) {
return switch (operator) {
case "+" -> a + b;
case "-" -> a - b;
case "*" -> a * b;
case "/" -> {
if (b == 0) throw new IllegalArgumentException("0으로 나눌 수 없습니다.");
yield a / b;
}
default -> throw new IllegalArgumentException("지원하지 않는 연산자입니다.");
};
}
}
- UI 로직
- 사용자와의 상호작용을 관리하는 부분이다.
- 사용자 입력을 수집하고, 비즈니스 로직을 호출하여 결과를 출력하는 역할을 한다.
package calculator;
import java.util.Scanner;
public class CalculatorApp {
public static void main(String[] args) {
CalculatorService service = new CalculatorService();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("계산할 연산 (예: 2 + 3) 또는 'exit' 입력: ");
String input = scanner.nextLine();
if (input.equalsIgnoreCase("exit")) break;
try {
String[] parts = input.split(" ");
double a = Double.parseDouble(parts[0]);
double b = Double.parseDouble(parts[2]);
double result = service.calculate(a, b, parts[1]);
System.out.println("결과: " + result);
} catch (Exception e) {
System.out.println("오류: " + e.getMessage());
}
}
scanner.close();
}
}
연관성이 있는 상수는 static final 대신 enum 활용
연관성이 있는 상수는
static final대신enum을 사용하는 것이 효과적이다.enum을 통해 관련 상수를 그룹화하고 각 상수에 속성과 행동을 부여함으로써 코드의 가독성과 유지보수성을 향상시킬 수 있다. 예를 들어, 로또의 당첨 등수를 나타내는Rank열거형은 각 등수마다 일치하는 숫자 개수와 상금을 정의할 수 있다.
public enum Rank {
FIRST(6, 2_000_000_000),
SECOND(5, 30_000_000),
THIRD(5, 1_500_000),
FOURTH(4, 50_000),
FIFTH(3, 5_000),
MISS(0, 0);
private int countOfMatch;
private int winningMoney;
private Rank(int countOfMatch, int winningMoney) {
this.countOfMatch = countOfMatch;
this.winningMoney = winningMoney;
}
}
final 키워드를 사용해 값의 변경을 막는다
final키워드를 사용하여 값의 변경을 방지하는 것은 중요한 습관이다. 이를 통해 불변 객체(Immutable Object)를 생성하여 값이 한 번 설정된 후 변경되지 않도록 보장할 수 있으며, 이는 예기치 않은 오류를 방지하고 코드의 안정성을 높인다. 많은 프로그래밍 언어들이 불변성을 지향하며, Java에서도final키워드를 통해 이를 구현할 수 있다.
public class Money {
private final int amount;
public Money(final int amount) {
...
}
}
객체의 상태 접근 제한
캡슐화(Encapsulation)는 객체의 상태 접근을 제한하는 중요한 원칙이다. 인스턴스 변수에 private 접근 제어자를 설정하면 외부에서 직접 접근하거나 수정할 수 없게 되어, 객체의 상태가 외부에서 통제되지 않고 객체 내부에서만 관리된다.
public class WinningLotto {
private Lotto lotto;
private Integer bonusNumber;
// 생성자에서만 상태를 설정
public WinningLotto(Lotto lotto, Integer bonusNumber) {
this.lotto = lotto;
this.bonusNumber = bonusNumber;
}
}
위 3개의 피드백을 토대로 static, final, static final, enum에 대해 한 번 더 공부하여 정리하였다. 아래 블로그 글을 참고하면 좋다.
[[JAVA] 상수와 열거형: static, final, static final, enum의 이해](https://velog.io/@grayson1999/JAVA-%EC%83%81%EC%88%98%EC%99%80-%EC%97%B4%EA%B1%B0%ED%98%95-static-final-static-final-enum%EC%9D%98-%EC%9D%B4%ED%95%B4)
객체는 객체답게 사용한다
Lotto 클래스는 numbers라는 상태 값을 가지며, 초기에는 getter 메서드만 제공한다. 그러나 객체 지향 원칙에 따라 데이터를 외부에서 꺼내는 대신, Lotto 객체가 스스로 처리하도록
contains와matchCount메서드를 추가하여 수정한다. 이렇게 하면 Lotto 객체가 자신의 데이터를 스스로 처리하게 된다.
public class Lotto {
private final List<Integer> numbers;
public Lotto(List<Integer> numbers) {
this.numbers = numbers;
}
public int getNumbers() {
return numbers;
}
}
public class Lotto {
private final List<Integer> numbers;
public boolean contains(int number) {
// 숫자가 포함되어 있는지 확인한다.
...
}
public int matchCount(Lotto other) {
// 당첨 번호와 몇 개가 일치하는지 확인한다.
...
}
}
자바 빈 설계 규약에 따라 멤버변수는 private으로 설정하고, getter와 setter를 제공해야 하지만, 모든 멤버변수에 getter를 무분별하게 생성하는 것은 객체의 행동을 외부로 노출하게 해 객체의 캡슐화를 저해한다.
객체는 자신의 상태를 스스로 관리해야 하며, getter를 남용하지 않고 메시지를 통해 객체가 스스로 작업을 수행하도록 설계해야 한다.
- “다른 객체가 어떠한 자료를 갖고 있는지 속사정을 몰라야 한다는 것을 의미”
필드(인스턴스 변수) 수 줄이기
필드가 많아지면 객체의 복잡도가 증가하고 관리가 어려워지며 버그 발생 가능성이 높아진다. 중복되거나 불필요한 필드를 최소화해야 한다.
public class LottoResult {
private Map<Rank, Integer> result = new HashMap<>();
private double profitRate;
private int totalPrize;
}
LottoResult 클래스의 profitRate와 totalPrize는 result 필드만으로도 계산할 수 있으므로, 이를 제거하고 계산 메서드를 추가하는 방식으로 구현할 수 있다.
public class LottoResult {
private Map<Rank, Integer> result = new HashMap<>();
public double calculateProfitRate() { ... }
public int calculateTotalPrize() { ... }
}
예를 들어, 아래와 같이 Order 클래스가 있다면 totalPrice의 경우 메서드를 구현하여 인스턴스 변수를 줄일 수 있다.
public class Order {
private double itemPrice;
private int quantity;
private double discount;
private double totalPrice;
}
public class Order {
private double itemPrice;
private int quantity;
private double discount;
public double calculateTotalPrice() {
return (itemPrice * quantity) - discount; // 계산 메서드로 대체
}
}
테스트 원칙
성공하는 케이스와 예외 케이스 모두 테스트
테스트 작성 시 성공하는 케이스에만 집중하기 쉽지만, 예외 상황에 대한 테스트도 매우 중요하다. 특히 결함이 자주 발생하는 경계값이나 잘못된 입력에 대한 테스트를 철저히 작성해야 예기치 않은 오류를 방지할 수 있다.
@DisplayName("보너스 번호가 당첨 번호와 중복되는 경우에 대한 예외 처리")
@Test
void duplicateBonus() {
assertThatThrownBy(() -> new WinningLotto(new Lotto(List.of(1, 2, 3, 4, 5, 6), 6)))
.isInstanceOf(IllegalArgumentException.class);
}
테스트 코드도 코드다
테스트 코드도 코드의 일환이므로, 리팩터링을 통해 지속적으로 개선하는 것이 중요하다. 반복적인 부분은 중복을 제거하여 유지보수성과 가독성을 높여야 한다. 특히, 파라미터 값만 바뀌는 경우에는 파라미터화된 테스트를 활용해 중복을 줄일 수 있다.
@DisplayName("천원 미만의 금액에 대한 예외 처리")
@ValueSource(strings = {"999", "0", "-123"})
@ParameterizedTest
void underLottoPrice(Integer input) {
assertThatThrownBy(() -> new Money(input))
.isInstanceOf(IllegalArgumentException.class);
}
파라미터화된 테스트 사용법은 아래의 내용을 참고하면 된다.
[[codingPractices] Parameterized Tests 사용법](https://velog.io/@grayson1999/codingPractices-Parameterized-Tests-%EC%82%AC%EC%9A%A9%EB%B2%95)
테스트를 위한 코드는 구현 코드에서 분리
테스트를 위해 구현 코드를 변경하는 것은 좋지 않은 습관이다. 테스트 코드를 작성하면서 접근 제어자를 변경하거나, 테스트 전용 메서드를 추가하는 경우가 있다. 이는 구현 코드가 테스트에 종속되고 캡슐화가 깨지며 코드의 일관성이 저해된다.
특히 다음 두 가지에 유의해야 한다:
- 테스트를 위해 접근 제어자를 바꾸는 경우
- 테스트 코드에서만 사용되는 메서드
private 함수를 테스트 하고 싶다면 클래스 분리 고려
private 메서드는 외부에서 직접적으로 테스트할 수 없다. 일반적으로 public 메서드를 통해 간접적으로 테스트된다. 그러나 private 메서드가 단순한 가독성을 위한 분리 이상으로 중요한 역할을 수행하는 경우, 클래스 분리를 고려해야 한다. 이는 단일 책임 원칙(SRP)을 따르며, 테스트 가능성을 높이고 코드의 응집도를 향상시킨다.
단위 테스트하기 어려운 코드를 단위 테스트하기
단위 테스트를 용이하게 하기 위해 Lotto 클래스를 리팩터링하는 과정을 설명한다. 이 과정은 테스트하기 어려운 의존성을 외부에서 주입하여 코드의 유연성과 테스트 용이성을 확보하는 방법을 보여준다.
기존 코드 (A)
초기 구조에서는 Lotto 클래스가 Randoms 클래스를 직접 호출하여 랜덤 번호를 생성한다. 이로 인해 로또 번호 생성이 테스트하기 어려워진다. 전체 구조는 다음과 같다:
import camp.nextstep.edu.missionutils.Randoms;
public class Lotto {
private List<Integer> numbers;
public Lotto() {
this.numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6);
}
}
public class LottoMachine {
public void execute() {
Lotto lotto = new Lotto();
}
}
Application(테스트하기 어려움)
⬇️
LottoMachine(테스트하기 어려움)
⬇️
Lotto(테스트하기 어려움) ➡️ Randoms(테스트하기 어려움)
리팩터링 후 코드 (B)
리팩터링을 통해 Lotto 클래스의 생성자를 수정하여 외부에서 번호 리스트를 주입받도록 변경한다. 이렇게 하면 랜덤 번호 대신 특정한 번호를 사용하여 Lotto를 테스트할 수 있다. 수정된 코드는 다음과 같다:
import camp.nextstep.edu.missionutils.Randoms;
public class Lotto {
private List<Integer> numbers;
public Lotto(List<Integer> numbers) {
this.numbers = numbers;
}
}
public class LottoMachine {
public void execute() {
List<Integer> numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6);
Lotto lotto = new Lotto(numbers);
}
}
Application(테스트하기 어려움)
⬇️
LottoMachine(테스트하기 쉬움) ➡️ Randoms(테스트하기 어려움)
⬇️
Lotto(테스트하기 쉬움)
위와 같이 Lotto 클래스는 외부에서 주입받은 번호를 기반으로 동작하도록 변경되었다. 이로 인해 Lotto 클래스는 특정한 번호를 사용하여 단위 테스트를 쉽게 수행할 수 있게 되었고, LottoMachine은 여전히 랜덤 번호를 생성하나 Lotto의 생성자는 테스트하기 쉬운 구조가 되었다.
테스트 코드 작성 시 주의할 점과 테스트하기 어려운 의존성을 외부에서 주입하여 코드의 유연성과 테스트 용이성을 확보하는 방법은 아래 블로그에서 확인할 수 있다.
[[codingPractices] 효율적인 단위 테스트: private 메서드와 랜덤 함수](https://velog.io/@grayson1999/codingPractices-%ED%9A%A8%EC%9C%A8%EC%A0%81%EC%9D%B8-%EB%8B%A8%EC%9C%84-%ED%85%8C%EC%8A%A4%ED%8A%B8-private-%EB%A9%94%EC%84%9C%EB%93%9C%EC%99%80-%EB%9E%9C%EB%8D%A4-%ED%95%A8%EC%88%98)