1 주차를 진행하며 얻어갔던 부분을 마인드 셋과 구현/설계 관점으로 나누어 정리하고자 합니다.
가벼운 일기 형식의 글이라 반말로 진행하는 점 양해바랍니다.
마인드셋 - 완벽이라는 강박 버리기
평소에 나는 완벽한 설계, 클린 코드를 추구 했다. 하지만, 두 가지 계기로 이 생각을 조금 완화해보기로 했다.
첫 번째는 개발자 유튜브 채널인 개발바닥의 클린코딩 하는데 구현을 못 하는 개발자 영상이다. 좋은 설계, 클린 코드를 추구하다보니 생각이 많아지고, 결국 데드 라인까지 구현을 못하는 내용이다. 양질의 소프트웨어는 분명히 고민해야할 요소이지만, 모든 것은 클라이언트의 요구 사항을 마감일에 맞춰 제공할 수 있는 게 기반이 되어야함을 느끼게 되었다.
두 번째는 우테코 입학설명회 QnA 중 좋은 결정을 해야 한다는 강박이 있어요 질문의 안드로이드 코치님의 답변 내용이다. “정답이 없는데 어떻게 결정할까요? 일단 시도해 보고, 안 좋으면 바꾸면 되지”. 정답이 없는 영역에서 완벽하게 설계될 때까지 고민하는 딜레마에 빠져있었는데, 방향을 찾은 느낌이다.
설계 - 기능 목록을 정리해야 하는 이유
기능 목록을 정리하는 객체의 행동을 결정 짓는 과정이라는 생각이 들었다. 객체의 행동을 결정 지음으로써 객체의 상태가 만들어지고, 객체 또한 탄생하게 된다. 이는 자연스레 객체의 역할과 책임으로 이어짐을 느꼈다.
설계 - Testable Code
단위 테스트를 진행하며 두 가지 어려움이 있었다. 두 어려움의 근본적인 이유는 모두 외부 API를 직접적으로 의존하여 호출하는 것이었다. 야구공을 생성하는 BaseballCreator 클래스를 예시로 들어보면 아래와 같았다.
1. Console.readLine()
public class BaseballCreator {
// 기능: 사용자의 공을 입력 받고 생성한다
public List<Integer> createPlayerBalls() {
String number = Console.readLine(); // 여기가 문제
return number
.chars()
.mapToObj(Character::getNumericValue)
.collect(Collectors.toList());
}
}
- createPlayerBalls 메서드는 테스트하기 어려운 코드이다.
- 메서드 내부에서 외부 API인 Console을 직접 참조하여 호출하고 있고, 이는 테스트 코드 상에서 제어할 수 없는 코드이기 때문이다.
- (InputStream으로 테스트할 수는 있지만, 그 보단 설계를 변경해야할 신호라 생각한다.)
2. Randoms.pickNumberInRange()
public class BaseballCreator {
// 기능: 컴퓨터는 1에서 9까지 서로 다른 임의의 수 3개를 선택
public List<Integer> createComputerBalls() {
List<Integer> computerBalls = new ArrayList<>();
while (computerBalls.size() < 3) {
int randomNumber = Randoms.pickNumberInRange(1, 9); // 여기가 문제
if (!computerBalls.contains(randomNumber)) {
computerBalls.add(randomNumber);
}
}
return computerBalls;
}
}
- createComputerBalls 메서드는 테스트하기 어려운 코드이다.
- 1번과 마찬가지의 이유이다.
테스트하기 어려운 코드를 테스트하기 좋은 코드로 변경하는 방법은 두 가지가 있다고 느꼈다.
1. 첫 번째 방법은 메서드 시그니쳐를 변경하는 것이다.
public class BaseballCreator {
// 기능: 사용자의 공을 입력 받고 생성한다
public List<Integer> createPlayerBalls(String number) {
return number
.chars()
.mapToObj(Character::getNumericValue)
.collect(Collectors.toList());
}
}
- 하지만 이 방법은 근본적인 해결책은 아닐 수도 있다라는 생각이 든다.
- createPlayerBalls 단위 테스트는 수월해지지만, 사용자에게 입력을 받아서 야구공을 생성하라 와 같이 테스트의 범위가 넓어졌을 땐, 결국 동일한 문제가 발생하기 때문이다.
2. 두 번째 방법은 외부 API를 인터페이스로 분리하는 것이다.
public interface NumberGenerator {
int generate();
}
public class RandomNumberGenerator implements NumberGenerator {
@Override
public int generate() {
return Randoms.pickNumberInRange(3, 9);
}
}
public class BaseballCreator {
// 기능: 컴퓨터는 1에서 9까지 서로 다른 임의의 수 3개를 선택
public List<Integer> createComputerBalls(NumberGenerator generator) {
List<Integer> computerBalls = new ArrayList<>();
while (computerBalls.size() < 3) {
int randomNumber = generator.generate();
if (!computerBalls.contains(randomNumber)) {
computerBalls.add(randomNumber);
}
}
return computerBalls;
}
- 인터페이스로 분리하였기에, 테스트 코드 상에서 임의의 NumberGenerator 구현체를 만들고 DI하여 테스트할 수 있게 된다.
- 테스트 상에서 제어할 수 없었던 코드를 이제 제어할 수 있게 되었다.
설계 - 인터페이스는 트레이드 오프이다
위에서 언급한대로 인터페이스를 도입하여, 기존 설계의 복잡도가 증가했다.
- GameManager가 Console 외부 API를 직접참조하던 것을 중간에 인터페이스를 두어 간접참조로 바꾸었다.
- BaseballCreator가 Randoms 외부 API를 직접참조하던 것을 중간에 인터페이스를 두어 간접참조로 바꾸었다.
- 언뜻 보면 설계의 복잡도만 증가한 것 같다. 그렇다. 추상화는 설계의 복잡도를 증가시킨다.두 번째 이유는 런타임시에 의존 관계가 결정되기 때문이다.
- 첫 번째 이유는 중간에 인터페이스라는 녀석이 끼기 때문이다.
- 추상화는 만능이 아니다. 트레이드 오프가 발생하는 것이다.
- 트레이드 오프를 만족할만한 명확한 근거, 얻을 수 있는 이점이 분명해야 한다.
- 추상화를 통해 무엇이 좋아졌는가?
- 기존의 테스트하기 어려운 코드들은 테스트하기 쉬운 코드로 바뀌었다. 명확한 이점이다.
구현 - 일급 컬렉션
처음엔 말이 어렵다고 느껴졌지만, 돌이켜보면 그냥 List<T>를 객체로 래핑해서 쓰라는 말인 것 같다. 여러가지 장점이 있는 것 같다.
1. List<T>가 무엇인지 코드로 명확히 명시할 수 있다.
List<Integer>가 야구공인지 추적하기 매우 어려울 것이다.
2. 유효성을 중복해서 검증할 필요 없다.
3. 야구공과 관련한 기능들을 API로 제공할 수 있다.
4. 상태와 행위를 한 곳에서 관리
5. …
구현 - 정적 팩터리 메서드 패턴
때에 따라 객체 생성을 명시적으로 표현하고 싶을 때가 있다. 야구공 게임이 아주 적합한 예인 것 같다.
- 사용자 야구공은 입력값 (String)을 통해 생성된다.
- 컴퓨터 야구공은 난수 생성 (NumberGenerator)를 통해 생성된다.
생성자의 파라미터만으로 구분하기엔 이 의도를 파악하기 어렵다.
하지만 정적 메서드 패턴을 사용하면 의도를 코드로 분명하게 명시할 수 있다.
public class BaseballCollection {
private final List<Integer> baseball;
private BaseballCollection(NumberGenerator numberGenerator) {
this.baseball = createComputerBalls(numberGenerator);
}
private BaseballCollection(String playerInput) {
this.baseball = createPlayerBalls(playerInput);
}
public static BaseballCollection ofComputerBaseball(NumberGenerator numberGenerator) {
return new BaseballCollection(numberGenerator);
}
public static BaseballCollection ofPlayerBaseball(String playerInput) {
return new BaseballCollection(playerInput);
}
// something
}
정리하면 1주차에서 얻어간 것은 아래와 같다.
1. 기능 목록을 정리하여 이 행동들을 수행하기 위해 어떤 객체가 필요하며, 어떤 상태가 필요한지 파악할 수있다.
2. 메서드 시그니쳐 변경, 외부 API를 인터페이스로 분리 등을 통해 Testable Code로 리팩터링할 수 있다.
3. 인터페이스는 설계 복잡도가 증가하는 트레이드 오프가 있음을 인지하고 도입하자.
4. List<T>는 객체로 래핑하여 사용하자.
5. 객체 생성에 의도를 드러내고 싶다면 정적 팩터리 메서드 패턴을 고려하자.