GitHub - soeun2537/java-lotto-7: 우아한테크코스 프리코스 3주차 과제
우아한테크코스 프리코스 3주차 과제. Contribute to soeun2537/java-lotto-7 development by creating an account on GitHub.
github.com
🎯 목표 설정
2주차 과제에서 TDD의 효과를 느끼고 이를 실천하기 위해 많은 공을 들였다. 당시 내가 진행했던 TDD 방식은 통합 테스트 작성 -> 기능 구현 -> 단위 테스트 작성 -> 클래스 분리의 순서로 진행되었다. 하지만 회고와 프리코스 커뮤니티의 글들을 통해, 내가 해온 방식이 본래의 TDD 방식과 다르다는 것을 깨달았다. TDD는 처음부터 단위 테스트를 작성하고, 그 후 기능을 구현하는 것이 원칙이었다. 2주차 공통 피드백 중 “처음부터 큰 단위의 테스트를 만들지 않는다”는 내용을 보며, 지금까지의 방식이 잘못된 TDD임을 더 명확히 인식하게 되었다. 이를 바탕으로 3주차 과제에서는 올바른 TDD 방식에 초점을 맞추어, 단위 테스트 작성 후 기능을 구현하는 방식을 목표로 삼아 과제를 진행할 계획이다.
또한, 2주차 과제의 코드 리뷰와 공통 피드백을 통해 유틸리티 클래스 활용, 예외 메시지 작성, 상수 관리 방식, 정적 팩토리 메서드 도입, 변수명에 자료형 포함 지양, 메서드의 단일 책임 원칙(프리코스 2주차 기록 고찰에 작성) 등에 대한 피드백을 받았다. 이번 주차에는 이러한 피드백을 바탕으로 새로운 기술을 도입해 보고, 피드백을 받은 부분에 더욱 신경 써서 코드를 작성하고자 노력했다.
🔽 진행 계획
- 기능 구현 목록 정리
- 단위 테스트 코드 작성
- 기능 구현
- 통합 테스트 코드 작성
- 클린 코드 원칙 점검 및 코드 개선
- 배운 점 정리
💻 진행 과정
▶ 기능 구현 목록 정리
TDD의 정석적인 방식을 적용하기 위해 구현 기능 목록을 잘 정리할 필요성을 느꼈다. 다만, 2주차 공통 피드백에서 구현 기능 목록이 업데이트될 수 있으며, 시작부터 모든 기능을 완벽하게 정리하려는 부담을 가질 필요가 없다고 언급했다. 이를 참고해 필요한 내용을 최대한 세부적으로 정리하면서도, 부담을 느끼지 않고 진행할 수 있었다.
▶ 단위 테스트 코드 작성 및 기능 구현
3주차 과제에서 드디어 TDD의 정석적인 방식을 적용해 보았다. 각 단위별로 테스트 코드를 작성한 후 기능을 구현하면서 정말 많은 것을 배울 수 있었다. 이 부분은 따로 고찰에서 더 깊이 작성해 보려고 한다.
class ValidatorTest {
...
@Test
@DisplayName("로또 구입 금액 유효성 검사: 배수 아님 - 예외 테스트")
void validateLottoPurchaseAmount_notMultiple() {
// given
Integer lottoPurchaseAmount = 2500;
// when & then
assertThatThrownBy(() -> Validator.validateLottoPurchaseAmount(lottoPurchaseAmount))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("[ERROR]");
}
@Test
@DisplayName("당첨 번호 유효성 검사 - 성공 테스트")
void validateWinningTicket_success() {
// given
List<Integer> winningTicket = Arrays.asList(1, 2, 3, 4, 5, 6);
// when & then
assertThatCode(() -> Validator.validateWinningTicket(winningTicket))
.doesNotThrowAnyException();
}
...
}
▶ 통합 테스트 코드 작성
기능 구현을 완료한 후, 통합 테스트 코드를 작성하여 전체적인 흐름을 점검했다. 이 과정에서 중간에 놓친 부분들을 발견할 수 있었다. 이 부분에 대해서도 고찰에서 더 깊이 다뤄보려고 한다.
class ApplicationTest extends NsTest {
private static final String ERROR_MESSAGE = "[ERROR]";
...
@Test
void 기능_테스트_오름차순_정렬_확인() {
assertRandomUniqueNumbersInRangeTest(
() -> {
run("4000", "1,2,3,4,5,6", "7");
assertThat(output()).contains(
"4개를 구매했습니다.",
"[13, 14, 16, 38, 42, 45]",
"[7, 11, 30, 40, 42, 43]",
"[2, 13, 22, 32, 38, 45]",
"[1, 3, 5, 14, 22, 45]",
"3개 일치 (5,000원) - 1개",
"4개 일치 (50,000원) - 0개",
"5개 일치 (1,500,000원) - 0개",
"5개 일치, 보너스 볼 일치 (30,000,000원) - 0개",
"6개 일치 (2,000,000,000원) - 0개",
"총 수익률은 125.0%입니다."
);
},
List.of(45, 42, 38, 16, 14, 13),
List.of(43, 42, 40, 30, 11, 7),
List.of(45, 38, 32, 22, 13, 2),
List.of(45, 22, 14, 5, 3, 1)
);
}
@Test
void 예외_테스트_로또_구입_금액_문자() {
assertSimpleTest(() -> {
runException("a", "1,2,3,4,5,6", "7");
assertThat(output()).contains(ERROR_MESSAGE);
});
}
...
}
🔨 기술 고도화 및 리팩토링
▶ 유틸리티 클래스 활용
공통적으로 사용되는 기능을 유틸리티 클래스로 분리했다.
🔽 변경 전
변경 전에는 각 클래스에서 검증 로직을 실행하기 위해 Validator 객체를 생성하고 의존성 주입을 통해 사용했다. 이로 인해 코드의 의존성이 증가하고, 불필요한 객체 생성 비용이 발생했다.
public class LottoController {
private final Validator validator;
public LottoController(Validator validator) {
this.validator = validator;
}
public Integer getLottoPurchaseAmount() {
String lottoPurchaseAmountInput = InputView.getLottoPurchaseAmountInput();
validator.validateLottoPurchaseAmount(lottoPurchaseAmount);
...
}
}
🔽 변경 후
검증 로직을 Validator 유틸리티 클래스의 정적 메서드로 전환하여, 의존성 주입 없이 공통 기능을 직접 호출할 수 있도록 개선했다.
public class Validator {
public static void validateLottoPurchaseAmount(Integer lottoAmount) {
validateNonNegative(lottoAmount);
validateMultiple(lottoAmount);
}
public static void validateWinningTicket(List<Integer> winningTicket) {
List<Integer> validatedWinningTicket = new ArrayList<>();
for (Integer winningNumber : winningTicket) {
validateNoDuplicate(validatedWinningTicket, winningNumber);
validateRange(winningNumber);
validatedWinningTicket.add(winningNumber);
}
validateSize(validatedWinningTicket);
}
...
}
이제 Validator의 메서드는 인스턴스를 생성할 필요 없이 호출이 가능해졌다.
public class LottoController {
public Integer getLottoPurchaseAmount() {
String lottoPurchaseAmountInput = InputView.getLottoPurchaseAmountInput();
Validator.validateLottoPurchaseAmount(lottoPurchaseAmount);
...
}
}
🔽 장점
- 의존성 주입을 사용하지 않음으로써 클래스의 인스턴스를 전달할 필요가 없어지고, 코드가 더욱 간결해졌다.
- 불필요한 객체 생성이 줄어들어 메모리 사용이 효율적이다.
- 공통 기능을 필요로 하는 여러 클래스에서 Validator를 일관되게 사용할 수 있어 유지보수성이 높아졌다.
▶ Enum 활용을 통한 상수 관리
상수 값을 Enum으로 관리하여 코드의 일관성과 유지보수성을 강화했다.
🔽 변경 전
프로그래밍 요구사항에 따르면 else 및 switch문을 사용하지 말라는 지침이 있었다. 해당 지침이 생겨난 이유를 생각해 보면, 이런 식의 코드 구성 방식은 값(상태)과 메서드(행위)의 관계를 이해하는 데 시간이 걸린다는 문제가 있다. 또한, 새로운 조건이 추가될 때마다 불필요하게 분기문이 늘어나 코드의 복잡성이 증가할 수 있다는 단점도 있다.
public class LottoService {
public Rank evaluateRank(int matchCount, boolean isBonusNumberMatched) {
if (matchCount == 6) {
return Rank.FIRST_PLACE;
} else if (matchCount == 5 && isBonusNumberMatched) {
return Rank.SECOND_PLACE;
} else if (matchCount == 5) {
return Rank.THIRD_PLACE;
} else if (matchCount == 4) {
return Rank.FOURTH_PLACE;
} else if (matchCount == 3) {
return Rank.FIFTH_PLACE;
} else {
return Rank.LAST_PLACE;
}
}
}
🔽 변경 후
Enum을 사용하여 상태(매칭 횟수, 보너스 번호 필요 여부, 상금)와 행위(등수 판별 로직)를 한 곳에 결합하여 구현했다.
public enum Rank {
FIRST_PLACE(6, false, 2000000000),
SECOND_PLACE(5, true, 30000000),
THIRD_PLACE(5, false, 1500000),
FOURTH_PLACE(4, false, 50000),
FIFTH_PLACE(3, false, 5000),
LAST_PLACE(0, false, 0)
;
...
public static Rank getRankByMatch(Integer matchCount, boolean isBonusNumberMatched) {
for (Rank rank : Rank.values()) {
if (isRankMatched(rank, matchCount, isBonusNumberMatched)) {
return rank;
}
}
return LAST_PLACE;
}
private static boolean isRankMatched(Rank rank, Integer matchCount, boolean isBonusNumberMatched) {
if (rank.isRequireBonusNumberMatch()) {
return rank.getMatchCount().equals(matchCount) && isBonusNumberMatched;
}
return rank.getMatchCount().equals(matchCount);
}
}
🔽 장점
- Rank enum 내부에 상태(필드)와 행위(메서드)를 함께 관리하므로 코드의 가독성이 크게 향상된다.
- if-else나 switch문을 대체하여, enum 상수마다 필요한 상태와 메서드를 작성함으로써 중복되는 분기문을 제거할 수 있다.
- Rank enum에 추가적인 로직을 넣어야 할 때, 다른 클래스의 코드를 건드리지 않고 enum에만 추가하면 되어 유지보수가 쉬워진다.
관련 글은 여기에 자세히 정리했으니, 참고하면 좋을 것 같다.
▶ 예외 메시지 작성
예외가 발생했을 때 사용자에게 명확한 메시지를 제공하기 위해 예외 메시지를 구체적으로 작성했다.
🔽 적용 코드
public enum ErrorMessage {
INVALID_NULL("입력 값은 null일 수 없습니다."),
INVALID_NEGATIVE_NUMBER("음수는 입력할 수 없습니다."),
INVALID_INPUT_TYPE("숫자가 아닌 값을 입력할 수 없습니다."),
INVALID_MULTIPLE_AMOUNT("로또 구입 금액은 %d의 배수여야 합니다."),
INVALID_DUPLICATE_NUMBER("로또 번호는 중복될 수 없습니다."),
OUT_OF_RANGE("로또 번호는 %d에서 %d 사이여야 합니다."),
INVALID_WINNING_NUMBER_COUNT("로또 번호는 %d개여야 합니다.")
;
private static final String PREFIX = "[ERROR] ";
private final String message;
ErrorMessage(String message) {
this.message = message;
}
public String getMessage() {
return PREFIX + String.format(message);
}
...
}
public class Validator {
private static void validateNonNegative(Integer number) {
if (number < 0) {
throw new IllegalArgumentException(INVALID_NEGATIVE_NUMBER.getMessage());
}
}
...
}
🔽 출력 결과
▶ 정적 팩토리 메서드 도입
객체 생성 방식을 유연하게 관리하기 위해 정적 팩토리 메서드를 활용했다.
🔽 적용 코드
정적 팩토리 메서드는 new 키워드를 사용하는 생성자 호출과 달리, 객체 생성 로직을 메서드로 캡슐화하여 표현할 수 있는 방식이다. 또한, new Lotto() 형태보다 더 직관적이고 유연하게 객체를 생성할 수 있다.
public class Lotto {
private final List<Integer> numbers;
// private 생성자를 통해 직접 객체 생성 방지
private Lotto(List<Integer> numbers) {
this.numbers = numbers;
}
// 정적 팩토리 메서드
public static Lotto of(List<Integer> numbers) {
return new Lotto(numbers);
}
...
}
List<Integer> numbers = Arrays.asList(3, 5, 12, 23, 32, 41);
Lotto lottoTicket = Lotto.of(numbers);
System.out.println(lottoTicket.getNumbers()); // [3, 5, 12, 23, 32, 41]
🔽 장점
- 직접 생성자 호출을 제한하고, of 메서드를 통해서만 객체를 생성하도록 강제할 수 있다. 이는 객체 생성 과정을 제어할 수 있게 한다.
- Lotto.of()처럼 정적 팩토리 메서드에 명확한 이름을 부여할 수 있어, 코드의 가독성을 높이고 객체 생성 방식의 의도를 더 분명하게 전달한다.
- 동일한 파라미터로 객체가 자주 생성되는 경우, 객체를 재사용하거나 캐싱하는 방식으로 성능을 향상시킬 수 있다.
▶ 랜덤 메서드 테스트를 위한 전략 패턴 도입
과제에서 로또 번호 생성 기능을 구현할 때, LottoRandomUtil 클래스를 사용해 지정된 범위 내에서 고유한 랜덤 숫자를 뽑아내는 메서드를 구현했다. 하지만, 이와 같은 랜덤 요소는 테스트 시 동일한 값을 얻기 어렵기 때문에 모킹 관련 라이브러리가 없으면 테스트 작성이 어렵다. 이를 해결하기 위해 전략 패턴을 도입하여 유연한 랜덤 값 생성 로직을 설정하고, 테스트에서 고정된 값을 반환하는 TestRandomUtil을 사용할 수 있도록 했다.
🔽 전략 패턴을 위한 인터페이스 RandomUtil
먼저, 랜덤 숫자 생성 로직을 인터페이스로 추상화했다. RandomUtil 인터페이스는 랜덤 숫자 생성 메서드를 정의하며, 이를 통해 여러 구현체에서 일관된 메서드를 사용할 수 있다.
public interface RandomUtil {
List<Integer> issueLottoTicket(Integer minNumber, Integer maxNumber, Integer lottoCount);
}
🔽 RandomUtil 인터페이스의 구현체 LottoRandomUtil
RandomUtil의 기본 구현체로, LottoRandomUtil 클래스는 실제로 랜덤 숫자를 생성한다. 싱글톤 패턴을 통해 단 하나의 인스턴스만을 유지하며, 랜덤한 로또 번호를 발행하는 메서드 issueLottoTicket을 제공한다.
public class LottoRandomUtil implements RandomUtil {
private static LottoRandomUtil lottoRandomUtil;
private LottoRandomUtil() {
}
public static LottoRandomUtil getLottoRandomUtil() {
if (lottoRandomUtil == null) {
lottoRandomUtil = new LottoRandomUtil();
}
return lottoRandomUtil;
}
@Override
public List<Integer> issueLottoTicket(Integer minNumber, Integer maxNumber, Integer lottoCount) {
return Randoms.pickUniqueNumbersInRange(minNumber, maxNumber, lottoCount);
}
}
🔽 RandomUtil 인터페이스의 테스트용 구현체 TestRandomUtil
RandomUtil의 테스트용 구현체로, TestRandomUtil 클래스는 고정된 결과를 반환한다. TestRandomUtil은 테스트에서 사용될 고정된 숫자 리스트를 생성자 파라미터로 받아, 항상 동일한 로또 번호를 반환하도록 한다.
public class TestRandomUtil implements RandomUtil {
private final List<Integer> fixedResults;
public TestRandomUtil(List<Integer> fixedResults) {
this.fixedResults = fixedResults;
}
@Override
public List<Integer> issueLottoTicket(Integer minNumber, Integer maxNumber, Integer lottoCount) {
return fixedResults;
}
}
🔽 테스트 코드
TestRandomUtil을 활용해 랜덤 번호가 아닌, 고정된 결과값을 반환받아 특정 입력에 대해 일관된 검증이 가능해졌다.
class LottoServiceTest {
private TestRandomUtil testRandomUtil;
private LottoService lottoService;
@BeforeEach
void beforeEach() {
List<Integer> fixedResults = Arrays.asList(7, 8, 9, 10, 11, 12);
testRandomUtil = new TestRandomUtil(fixedResults);
lottoService = new LottoService(testRandomUtil);
}
@Test
@DisplayName("여러 장의 로또 티켓 생성 확인: 오름차순")
void createLottoTickets_ascendingOrder() {
// given
List<Integer> fixedResults = Arrays.asList(12, 11, 10, 9, 8, 7);
testRandomUtil = new TestRandomUtil(fixedResults);
lottoService = new LottoService(testRandomUtil);
Integer lottoCount = 10;
// when
List<Lotto> lottoTickets = lottoService.createLottoTickets(lottoCount);
// then
assertThat(lottoTickets).hasSize(10);
assertThat(lottoTickets.get(0).getNumbers()).containsExactly(7, 8, 9, 10, 11, 12);
assertThat(lottoTickets.get(9).getNumbers()).containsExactly(7, 8, 9, 10, 11, 12);
}
...
}
🔽 장점
- 전략 패턴을 통해 인터페이스 기반으로 랜덤 생성 로직을 추상화하고, TestRandomUtil로 고정된 결과를 반환하게 함으로써 랜덤에 영향을 받지 않는 테스트를 작성할 수 있다.
- RandomUtil 인터페이스와 다양한 구현체를 통해 필요한 상황에 맞는 랜덤 생성 방식을 선택할 수 있어 코드의 재사용성을 높였다.
- 싱글톤으로 설계되어 불필요한 객체 생성과 메모리 낭비를 방지했다.
해당 부분은 프리코스 7기 BE 지원자인 송선권 님의 글을 참고하여 진행했다.글 내용이 너무 좋다...
🔍 클린 코드 원칙 준수 점검 및 코드 개선
클린 코드 원칙 정리는 여기를 참고하면 좋을 것 같다.
▶ else 예약어를 쓰지 않았는가?
Enum을 활용하여 기존의 else를 사용하던 코드를 개선할 수 있었다.
▶ 3개 이상의 인스턴스 변수를 가진 클래스를 구현하지 않았는가?
유틸리티 클래스를 활용하여 LottoController에서 불필요한 인스턴스 변수 선언과 의존성 주입을 제거했다. 이를 통해 클래스가 3개 이상의 인스턴스 변수를 가지는 것을 방지할 수 있었다.
▶ 메서드가 한 가지 일만 담당하도록 구현했는가?
랜덤 요소를 전략 패턴으로 구현하여, 로또 생성 로직을 별도로 분리했다. 이를 통해 메서드가 한 가지 일만 수행하도록 개선할 수 있었다.
✏️ 배운 점 정리
▶ Enum
🔽 기본 문법: 선언
- enum 이름은 클래스와 동일하게 첫 글자를 대문자로 시작한다.
- 관례적으로 열거형 상수는 모두 대문자로 작성한다.
- 열거형 상수가 여러 단어로 구성될 경우, 단어 사이를 밑줄(_)로 연결한다.
public enum Rank {
FIRST_PLACE,
SECOND_PLACE,
THIRD_PLACE,
FOURTH_PLACE,
FIFTH_PLACE,
LAST_PLACE
}
🔽 메서드 종류
메서드 | 설명 | 반환 타입 |
name() | 열거 객체의 문자열을 반환 | String |
ordinal() | 열거 객체의 순번(0부터 시작)을 반환 | int |
valueOf(String name) | 문자열을 입력받아서 일치하는 열거 객체를 반환 | enum |
values() | 모든 열거 객체들을 배열로 반환 | enum[] |
compareTo() | 열거 객체 간 순번 차이를 비교하여 반환 | int |
equals() | 열거 객체와 다른 객체를 비교하여 일치 여부를 반환 | boolean |
- name(): 열거 객체가 가지고 있는 문자열을 반환한다.
Rank rank = Rank.SECOND_PLACE;
String rankName = rank.name();
System.out.println(rankName); // SECOND_PLACE
- ordinal(): 열거 객체의 순번(0부터 시작)을 반환한다.
Rank rank = Rank.SECOND_PLACE;
int rankNum = rank.ordinal();
System.out.println(rankNum); // 2
- valueOf(): 문자열을 입력받아 일치하는 열거 객체를 반환한다.
Rank rank = Rank.valueOf("SECOND_PLACE");
System.out.println(rank); // SECOND_PLCAE
- values(): 모든 열거 객체들을 배열로 반환한다.
Rank[] ranks = Rank.values();
System.out.println(Arrays.toString(ranks)); // [FIRST_PLACE, SECOND_PLACE, THIRD_PLACE, FOURTH_PLACE, FIFTH_PLACE, LAST_PLACE]
- compareTo(): 주어진 열거 객체를 비교해서 순번 차이를 반환한다.
- 열거 객체가 매개 변수의 열거 객체보다 순번이 빠르면 음수를 반환한다.
- 열거 객체가 매개 변수의 열거 객체보다 순번이 늦으면 양수를 반환한다.
Rank firstRank = Rank.FIRST_PLACE;
Rank secondRank = Rank.SECOND_PLACE;
int firstCompare = firstRank.compareTo(secondRank);
System.out.println(firstCompare); // -1
int secondCompare = secondRank.compareTo(firstRank);
System.out.println(secondCompare); // 1
- equals(): 해당 enum 상수와 다른 객체를 비교하여 boolean 형태로 반환한다.
Rank firstRank = Rank.FIRST_PLACE;
Rank secondRank = Rank.SECOND_PLACE;
boolean isEqual = firstRank.equals(secondRank);
System.out.println(isEqual); // false
🔽 고급 문법: 매핑
enum을 활용하면 특정 값과 매핑된 열거 상수를 출력할 수 있다. enum의 생성자에 매개변수를 전달해 각 열거 객체에 고유한 값을 부여할 수 있으며, 이를 필드에 저장한다. 이렇게 저장된 필드는 getter 메서드를 통해 접근할 수 있어, 열거 객체와 연관된 정보를 가져올 수 있다.
public enum Rank {
FIRST_PLACE(6, false, 2000000000),
SECOND_PLACE(5, true, 30000000),
THIRD_PLACE(5, false, 1500000),
FOURTH_PLACE(4, false, 50000),
FIFTH_PLACE(3, false, 5000),
LAST_PLACE(0, false, 0)
;
// 필드
private final Integer matchCount;
private final boolean requireBonusNumberMatch;
private final Integer prizeAmount;
// 생성자
Rank(Integer matchCount, boolean requireBonusNumberMatch, Integer prizeAmount) {
this.matchCount = matchCount;
this.requireBonusNumberMatch = requireBonusNumberMatch;
this.prizeAmount = prizeAmount;
}
// Getter (일치 개수)
public Integer getMatchCount() {
return matchCount;
}
// Getter (보너스 번호 일치 여부)
private boolean isRequireBonusNumberMatch() {
return requireBonusNumberMatch;
}
// Getter (상금)
public Integer getPrizeAmount() {
return prizeAmount;
}
...
}
관련 글은 여기에 자세히 정리했으니, 참고하면 좋을 것 같다.
▶ 유틸리티 클래스
유틸리티 클래스는 객체 상태에 의존하지 않는 공통 기능을 제공할 때 사용된다. 보통 static 메서드로만 구성되며, 객체를 생성하지 않고 호출할 수 있도록 설계한다. 이를 통해 불필요한 메모리 사용과 복잡도를 줄일 수 있다.
🔽 구조
- 인스턴스 필드를 가지지 않는다. 즉, 상태를 가지지 않는다.
- 모든 메서드들은 static으로 선언된다. (private 메서드도 포함)
- 인스턴스화를 방지하기 위해 기본 생성자를 private로 선언한다.
🔽 장점
- 인스턴스 상태에 의존하지 않기 때문에 의존성 주입이 필요하지 않다.
- 메모리 효율성을 높이며, 코드의 간결성을 높여준다.
🔽 예시
- Spring 프레임워크에서 제공하는 StringUtils, CollectionUitls 등
▶ 정적 팩토리 메서드
정적 팩토리 메서드는 객체 생성 방식을 관리하는 방법 중 하나로, new 키워드를 사용하지 않고 개발자가 구성한 static 메서드를 통해 간접적으로 생성자를 호출하여 객체를 생성하는 디자인 패턴이다.
🔽 특징
- 메서드 이름으로 반환될 객체의 의미를 명확하게 전달할 수 있다.
- 인스턴스 생성을 통제하고 관리할 수 있어, 필요에 따라 매번 새로운 객체를 반환하거나, 하나의 객체를 재사용하도록 설정할 수 있다.
- 하위 자료형 객체를 반환할 수 있다.
- 입력 인자에 따라 다양한 객체를 반환할 수 있도록 분기할 수 있다.
- 객체 생성 로직을 캡슐화할 수 있다.
🔽 네이밍 규칙
네이밍 단어 | 설명 |
from | 하나의 매개변수로 객체 생성 |
of | 여러 매개변수로 객체 생성 |
getInstance / instance | 인스턴스를 생성. 기존 객체를 반환할 수도 있음 |
newInstance / create | 항상 새로운 인스턴스를 생성 |
get[OrderType] | 타입별 인스턴스를 생성. 기존 객체를 반환할 수도 있음 |
new[OrderType] | 타입별 새로운 인스턴스를 항상 생성 |
🔽 문제점
- private 생성자 사용 시 상속이 불가능하다.
- API 문서에서 생성 방법이 명확히 드러나지 않아 혼란을 줄 수 있다.
추후 관련 글을 정리할 예정이다.
▶ 전략 패턴
전략 패턴은 특정 행동을 클래스 외부에서 정의하여, 필요에 따라 객체의 행동을 동적으로 변경할 수 있도록 하는 디자인 패턴이다. 실행(런타임) 중에 다양한 알고리즘 전략을 선택하여 객체의 동작을 실시간으로 바꿀 수 있게 해 준다.
🔽 3주차 과제 적용
로또 프로그램에서는 랜덤 숫자 생성 방식을 전략 패턴으로 구현하여, 실제 환경에서는 LottoRandomUtil을, 테스트 환경에서는 TestRandomUtil을 활용하여 랜덤 값을 주입했다. 이를 통해 테스트 시 랜덤 값에 의존하지 않고 고정된 값을 사용할 수 있었다.
🔽 사용 시기
- 전략 알고리즘의 여러 버전 또는 변형이 필요하여 이를 클래스화해 관리해야 할 때
- 알고리즘 코드가 노출되지 않아야 하는 데이터에 접근하거나 해당 데이터를 활용해야 할 때 (캡슐화 필요)
- 알고리즘의 동작이 런타임 중에 실시간으로 교체되어야 할 때
🔽 주의점
- 알고리즘이 많아질수록 관리해야 할 객체의 수가 늘어난다.
- 알고리즘이 많지 않거나 자주 변경되지 않는 경우, 굳이 새로운 클래스와 인터페이스를 만들어 프로그램을 복잡하게 만들 필요가 없다.
- 적절한 전략을 선택하려면 각 전략 간의 차이점을 개발자가 잘 이해하고 있어야 한다
▶ 싱글톤 패턴
싱글톤 패턴은 애플리케이션 전역에서 단 하나의 인스턴스만을 사용하도록 보장하는 디자인 패턴이다. 여러 클래스에서 공통의 인스턴스를 사용할 때 유용하며, 인스턴스를 반복해서 생성할 필요가 없어 메모리 사용이 효율적이다.
🔽 사용 방법
- Eager Initialization (즉시 초기화)
- Lazy Initialization (지연 초기화)
- Thread-safe Lazy Initialization (지연 초기화 - 멀티스레드 안전)
- Double-Checked Locking (이중 검사 락)
- Bill Pugh Singleton Design (내부 정적 클래스 사용)
- Enum Singleton (Enum 사용)
🔽 3주차 과제 적용
현재 코드는 Lazy Initialization (지연 초기화) 방식으로 싱글톤을 구현했다.
public class LottoRandomUtil implements RandomUtil {
private static LottoRandomUtil lottoRandomUtil;
private LottoRandomUtil() {
}
public static LottoRandomUtil getLottoRandomUtil() {
if (lottoRandomUtil == null) {
lottoRandomUtil = new LottoRandomUtil();
}
return lottoRandomUtil;
}
...
}
LottoRandomUtil 클래스의 인스턴스 lottoRandomUtil은 처음부터 생성되는 것이 아니라, getLottoRandomUtil() 메서드를 통해 처음 요청될 때 생성된다. 이를 통해 필요할 때까지 인스턴스를 생성하지 않으므로 메모리 효율성이 높아진다. 다만, 이 구현 방식은 멀티스레드 환경에서 안전하지 않을 수 있다고 한다. 동시에 여러 스레드가 getLottoRandomUtil() 메서드를 호출하면, 여러 개의 인스턴스가 생성될 수 있어서 개선이 필요하다.
🔽 특징
- 애플리케이션 전역에서 고유한 상태를 유지할 수 있다.
- 객체 생성 비용을 줄이며 메모리 효율성을 높인다.
- enum을 활용하면 싱글톤을 간편하고 안전하게 구현할 수 있다.
🔽 문제점
- 모듈 간 의존성이 높아질 수 있다.
- SOLID 원칙에 위배될 수 있다. (특히 SRP, OCP, DIP)
- TDD에서 싱글톤 클래스를 사용하는 모듈의 테스트가 어려울 수 있다.
추후 관련 글을 정리할 예정이다.
💡 고찰
▶ 테스트 코드를 작성하는 이유
이전까지는 통합 테스트를 작성한 후 기능 구현을 하고, 단위 테스트를 모두 작성한 뒤 코드 리팩토링을 진행하는 방식으로 작업을 했다. 하지만 이번 과제부터는 처음부터 단위 테스트를 작성하고, 작은 단위부터 TDD를 적용해 보았다. 그 결과 정말 놀라운 효과를 경험할 수 있었다. "이렇게 빨리 끝나?" 하는 생각이 들 정도로 작업을 효율적으로 완료할 수 있었다.
또한, 3주차 과제를 진행하던 중 요구 사항을 잘못 이해해 등수 조건을 수정해야 하는 일이 있었다. 코드 변경 후 테스트 코드를 실행해 보니, 어디서 오류가 발생하는지 즉각적으로 파악할 수 있었고 연관된 부분들을 빠르게 수정할 수 있었다. 이를 통해 유지보수 시간이 정말 말도 안 되게 줄은 걸 보면서, TDD가 왜 많은 개발자에게 안정적인 개발 방식으로 채택되는지 실감할 수 있었다.
테스트 코드는 그 자체로 강력한 안전장치 역할을 하는 것 같다. 예상치 못한 문제가 발생했을 때 테스트 코드를 통해 빠르게 문제의 원인과 수정할 부분을 파악할 수 있어, 코드의 안정성을 높이는 데 큰 도움을 주는 것 같다.
▶ PR 리뷰의 가치
처음에는 "코드 리뷰가 정말 그렇게 중요한가?" 하는 생각이 들기도 했다. 그러나 실제로 코드 리뷰를 진행해 보니, 그 생각이 얼마나 주제넘은 것이었는지 깨닫게 되었다.
2주차 과제까지 다양한 코드 리뷰를 통해 많은 피드백을 받을 수 있었다. 매번 과제를 구현할 때는 최선을 다했다고 생각했지만, 리뷰를 받으면 항상 개선할 점을 발견하게 된다. 새로운 시각과 피드백을 통해 부족한 부분을 보완하고, 또 새로운 기술에 대해 배울 수 있는 좋은 기회가 된다. 또한, 해당 리뷰를 다음 과제에 적용해 코드를 개선하고 나면 뿌듯함은 이루 말할 수 없다.
또한, 다른 사람들의 코드를 리뷰하면서 내가 고민했던 부분을 어떻게 해결했는지 볼 수 있고, 반대로 내가 해결한 부분을 설명하며 서로 성장하는 재미를 느끼고 있다. 다양한 코드를 보다 보니 리뷰하는 안목도 생기고, 다른 사람들의 리뷰를 보며 소프트 스킬에 대한 배움도 얻게 되었다.
이전에는 프로젝트를 빨리 끝내기 위해 간단한 피드백을 주고받는 정도였다면, 프리코스에서는 서로 피드백을 주고받으며 함께 성장하는 경험을 하고 있다. 상호 간의 리뷰와 피드백을 통해 함께 공부하고 성장하는 것이 정말 참된 리뷰임을 깨닫고 있다.
▶ 프리코스 커뮤니티
프리코스 커뮤니티의 "함께-나누기" 채널이 생각보다 많은 도움이 되고 있다. 프로젝트를 진행하다 보면 언제나 명확한 정답이 있는 것은 아니기 때문에, 애매한 부분에서 스스로 판단을 내려야 하는 경우가 많다. 이러한 고민은 개발자로서 판단력을 키우는 중요한 과정이라고 생각한다. 그리고 이런 고민들이 커뮤니티에 자주 올라오는 것을 보며, 다들 비슷한 의문을 가지며 개발하고 있구나 하고 동질감을 느낄 수 있다.
가끔 내가 구현하지 못한 부분을 멋진 기술로 해결한 사람들의 글을 보며 존경심이 들기도 한다. 어떤 개발 경력을 쌓아왔길래 저렇게 기술을 접목시킬 수 있을까 생각도 들고, "이 기술을 왜 사용해야 하지?"라는 본질적인 고민을 계속하는 모습에서 많은 것을 배우고 있다.
▶ PR 리뷰와 공통 피드백에서 얻은 추가 고찰 (11.12 추가)
- 클래스마다 Enum을 통해 상수를 관리하는 것은 static final을 사용하는 것과 크게 다르지 않다. 재사용성을 높이기 위해 상수들을 한곳에 통합하여 관리하는 방식으로 변경할 예정이다.
- InputView에서 Console.readLine() 호출이 반복되는 부분이 많다. 이를 별도의 메서드로 추출하면 중복을 줄일 수 있을 것 같다.
- View와 Model의 역할을 더 명확히 분리하기 위해 DTO 사용을 고려해 볼 필요가 있을 것 같다.
- Integer 같은 참조형 변수보다 int 같은 기본형을 사용하는 것은 메모리와 성능 측면에서 유리하다고 한다. 특별한 이유가 없는 경우 참조형 변수 대신 기본형을 사용하는 것이 좋다.
- 줄바꿈을 표시할 때 \n을 지양하고, System.lineSeparator() 혹은 String.format()의 %n 서식 문자 활용하는 것이 좋다.
- 함수형 인터페이스를 활용하면 반복적인 작업을 더 간결하게 작성할 수 있다는 피드백을 받았다. 이후 적극 활용할 예정이다.
- 열거형을 키로 사용하는 매핑이 있었는데, 이때 EnumMap을 사용하면 메모리 효율성과 접근 속도가 개선된다고 한다.
- 메서드 명을 get으로 시작하는 대신, 메서드의 역할과 의도를 명확히 나타내는 이름을 사용하는 것이 좋다.
- static은 객체지향적인 접근 방식이 아니므로, 꼭 필요한 경우에만 신중히 사용해야 한다.
- 큰 숫자는 가독성을 위해 언더스코어(_)를 사용해서 표기할 수 있다.
- 반복문 대신 Stream API를 사용하면 가독성이 높아지고, 코드가 간결해진다.
- 메서드에 여러 인자를 전달해야 할 때 가변 인자(...)를 사용하면 유연성이 높아진다.
- 객체 내부에서 스스로 검증을 수행하는 방식이 객체를 객체답게 사용하는 방식이다.
- 싱글톤 패턴의 지연 초기화 방식은 멀티 스레드에서 안전하지 않을 수 있다.
📍 참고 자료
'우아한테크코스' 카테고리의 다른 글
[우아한테크코스] 프리코스 4주차 기록 (3) | 2024.11.12 |
---|---|
[우아한테크코스] Java Enum 활용기 (4) | 2024.11.05 |
[우아한테크코스] 프리코스 2주차 기록 (2) | 2024.10.29 |
[우아한테크코스] 프리코스 1주차 기록 (4) | 2024.10.22 |
[우아한테크코스] 우테코 코드 스타일 포매터 적용 (0) | 2024.10.15 |