TDD, Clean Code with Java 를 수료하면서 작성했던 회고 글
이번주에 TDD, Clean Code with Java 14기 과정을 완료했다. 기존의 목표는 6주 안에 미션을 모두 끝마치고 이전 미션들에 대해 부족한점을 되돌아보려고 했지만 예상했던 것과는 달리 볼링 미션이라는 최종 보스를 만나 오랜 시간을 투자했다. (다시 생각해도 볼링 미션은 진짜 어려웠다...) 다행히 8주 기간 내에 미션을 완료했다. 매일 저녁먹고 자리에 앉아 있던 보람이 있었다. 마지막에 체력적으로 지쳐 가끔씩은 그냥 널브러져 있기도 했는데 그래도 기간 내에 수료하고 싶었던 욕심 덕분에 간신히 완주할 수 있었다.
- 자동차 경주 미션
- 로또 미션
- 사다리 미션
- 볼링 미션
1. TDD, Clean Code with Java 장점: 코드리뷰
미션을 진행하면서 클린코드와 객체지향적인 설계에 관해 많은 고민을 해볼 수 있는 좋은 기회였다. 단순히 구현에 초점을 맞추지 않고 객체 체의 책임과 협력에 대해 고민할 수 있었다.TDD, Clean Code with Java 과정의 장점은 코드 리뷰라고 생각한다. 코드 리뷰가 정말 좋았던 이유는 내가 스스로 공부하고 생각해볼 수 있도록 리뷰어와 고민하고 있는 내용과 개선사항에 대해 서로 논의해볼 수 있는 점이다. 아마 코드를 작성하면서 내가 작성한 방법이 좋은 방법인가? 더 좋은 방법은 없나? 에 관한 생각이 들곤 했다. 이런 고민들을 리뷰어와 의견을 주고 받으면서 더 나은 설계에 대해 함께 고민해보고 특정 디자인 패턴의 사용과 이유를 직접 코드에 적용해보면서 체감해볼 수 있었다. 그리고 나쁜 코드 습관을 고칠 수 있다. 안좋은 습관은 내가 찾기는 어렵다. 하지만 꼼꼼한 피드백 덕분에 나의 부족한 점을 다시 한번 상기시킬 수 있었고 무안한 웃음과 함께 코드를 고치는 자신의 모습을 볼 수 있다. (실수를 하지 않기 위해서 오답노트나 개선 방안에 대해서는 필수다.)
2. 추천 여부와 앞으로의 계획
내 동료, 지인들에 추천할 의사가 있다면 언제든 YES 이다. 그동안 나의 코드 습관을 점검할 뿐만 아니라 더 좋은 코드에 대해 함께 고민하고 노력하는 문화를 만들어 나갈 수 있는 긍정적인 선순환이 될 수 있을 것 같다. 비록 가격이 조금 쎄다는 점이 있지만 현업 개발자에게 코드리뷰를 받을 수 있고 또 수료한 사람들 중에 박재성님이 직접 미션 코드를 확인하고 리뷰어 제안을 하신다고 하니 그만한 퀄리티를 어느정도 보장하는 것 같다.
3. 오답노트
아래는 설계를 하면서 고민했던 부분과 피드백과 실수를 방지하기 위해 했던 노력에 대해 정리한 내용이다. 내가 했던 고민들을 공유하면 좋을 것 같아 아래에 기록한다.
1. 커스텀 예외 클래스 추가 및 원인 예외를 반환
비즈니스 로직상의 예외에 대해 커스텀 클래스를 정의하면 더욱 의미있는 비즈니스 실패 케이스를 검증할 수 있다. 또한 실패 원인을 예외 메세지에 추가하면 더욱 명확하게 예외 원인에 대해 파악할 수 있다. (이펙티브 자바 Item 75. 예외의 상세 메시지에 실패 관련 정보를 담아라 )
2. final class 선언
class의 final을 상속을 방지하겠다는 의미이다. 비록 default constructor에 private access modifier을 선언하면 암묵적으로 상속을 하지 않은 효과가 있지만 자바 문법에서 상속을 금지시키는 명확한 이유는 final이므로 final 예약어를 사용하도록 하자.
3. 자바에선 static 사용을 지양하는 이유
static은 객체 지향적이지 않다. 캡슐화 원칙에 위배된다. 캡슐화는 객체는 역할과 책임을 가지고 데이터를 직접 관리하지만 static 전역 변수를 사용하게 될 경우에 변수의 범위가 전역으로 사용되어 외부에서 데이터를 참조할 수 있기 때문이다. 또한 GC에 의해 메모리를 회수 대상이 아니므로 Memory Leak의 원인이 될 수 있다. static을 불가피하게 사용을해야 한다면 access modifier를 private로 설정하여 외부에 노출되지 않도록 한다. (Ref.왜 자바에서 static의 사용을 지양해야 하는가? )
4. 상속 대신 조합을 사용하는 방법을 생각해보자.
재사용의 목적으로 상속을 선언하는 것을 상당히 위험한 방법이다. 부모, 자식 클래스 간의 강한 결합으로 연결하여 유지보수가 어려운 코드가 된다. (부모 로직이 바뀌면 자식 로직이 바뀔 수 있고 이는 리스코프 치환 원칙을 위배할 수 있음) 또한 자식 클래스가 부모의 메서드 사용하기 때문에 캡슐화를 위반한다.
상속을 대체할 수 있는 방법 중 하나는 조합을 사용하는 것이다. 기존의 클래스에 새로운 클래스 구성요소를 추가해 캡슐화를 유지하면서 기능을 확장할 수 있는 장점이 있다. (Ref. [tecoble] 상속보다는 컴포지션(조합)을 사용하자)
public class WinningTicket {
private final LottoTicket lottoTicket;
private final LottoNumber bonusNumber;
public WinningTicket(Set<LottoNumber> numbers, LottoNumber bonusNumber) {
this(new LottoTicket(numbers), bonusNumber);
}
public WinningTicket(LottoTicket lottoTicket, LottoNumber bonusNumber) {
validateBonusNumber(lottoTicket, bonusNumber);
this.lottoTicket = lottoTicket;
this.bonusNumber = bonusNumber;
}
public Rank drawLotto(LottoTicket lottoTicket) {
int count = this.lottoTicket.countMatches(lottoTicket);
boolean matchBonus = lottoTicket.includeBonusNumber(bonusNumber);
return Rank.valueOf(count, matchBonus);
}
private void validateBonusNumber(LottoTicket lottoTicket, LottoNumber bonusNumber) {
if(lottoTicket.includeBonusNumber(bonusNumber)) {
throw new InvalidBonusNumberException(bonusNumber);
}
}
}
5. 전략 패턴(Strategy Pattern) 사용
전략 패턴은 행위를 클래스로 캡슐화하여 행위에 대해 변경을 유연하게 해줄 수 있는 패턴이다.
public interface NumberGenerationStrategy {
Set<LottoNumber> generateLottoNumbers();
}
public class ManualGenerationStrategy implements NumberGenerationStrategy {
@Override
public Set<LottoNumber> generateLottoNumbers() {
return InputUtil.readNumbers();
}
}
public class RandomGenerationStrategy implements NumberGenerationStrategy {
private static final int MAX_LOTTO_NUMBER_BOUND = 45;
private static final int LOTTO_NUMBERS_SIZE = 6;
private static final int LOTTO_MIN_NUMBER = 1;
private static final Random RANDOM = new Random();
@Override
public Set<LottoNumber> generateLottoNumbers() {
Set<Integer> numbers = generateRandomNumber();
return numbers.stream()
.map(LottoNumber::create)
.collect(Collectors.toSet());
}
private Set<Integer> generateRandomNumber() {
Set<Integer> numbers = new HashSet<>();
while(numbers.size() < LOTTO_NUMBERS_SIZE) {
int number = generateNumber();
if (numbers.contains(number) || isInvalidLottoNumber(number)) {
continue;
}
numbers.add(number);
}
return numbers;
}
private boolean isInvalidLottoNumber(int number) {
return ((number > MAX_LOTTO_NUMBER_BOUND) || (number < LOTTO_MIN_NUMBER));
}
private int generateNumber() {
return RANDOM.nextInt(MAX_LOTTO_NUMBER_BOUND);
}
}
6. 플라이웨이트 패턴 (FlyWeight Pattern)
로또 번호를 생성할 때, 매번 new 연산자를 통해 새로운 객체를 생성하면 같은 인스턴스를 반복해서 생성하기 때문에 Memory Leak 발생 지점이 될 수도 있다. (사용자 100명이 로또를 사면 600개의 새로운 로또 넘버...) 로또 번호는 고정되어 있으므로 미리 캐싱해서 사용할 수 있는 flyweight pattern을 적용한다. flyweight pattern은 값을 새로 생성하지 않고 재사용하기 위해서 종종 사용되는 패턴이다.
(Ref. 플라이웨이트(Flyweight) 패턴이란?)
public class LottoNumber {
private static final Map<Integer, LottoNumber> lottoNumbers = new HashMap<>();
private static final int MIN_LOTTO_NUMBER = 1;
private static final int MAX_LOTTO_NUMBER = 45;
private final int value;
private LottoNumber(int value) {
validate(value);
this.value = value;
}
public static LottoNumber create (int value) {
lottoNumbers.putIfAbsent(value, new LottoNumber(value));
return lottoNumbers.get(value);
}
...
}
7. 생성자 하나를 주 생성자로 사용하기
메서드가 많아지면 클래스의 초점이 흐려지고 SRP(Single Responsiblility Principle)을 위반한다. 생성자의 주된 작업은 제공된 인자를 사용해 캡슐화하고 프로퍼티를 초기화하는 일이다. 이런 초기화 로직을 단 하나의 '주 생성자'에만 위치시키고 다른 '부 생성자'들이 이 주 생성자를 호출하도록 한다. (Ref.[엘레강트 오브젝트] 1. 출생 - (2) 생성자 하나를 주 생성자로 만드세요)
8. 추상 클래스보다 인터페이스를 우선시 하라. + 마커 인터페이스
상속은 클래스의 캡슐화를 위반시키며 오버라이드 메서드를 상속한 클래스의 위치를 파악하기 어려워 가독성을 떨어뜨리는 단점이 있다. 하지만 인터페이스는 계층 구조가 없는 형태로 클래스를 구현할 수 있어 캡슐화를 지킬 수 있다. (Ref.Effective Java Item 20. 추상 클래스보다는 인터페이스를 우선하라) 또한 단순히 타입 체크를 위한 마커인터페이스(ex. serializable)의 사용은 클래스의 인스턴스를 구분하고 컴파일 타입에서 확인이 가능하다. (Ref. Effective Java Item 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용해라)
4. 마지막으로
힘들 때 한번씩 봐야겠다. 칭찬 감사합니다 🙇♂️