2주차 미션이 마무리되었다! 1주차와는 달라진 점, 고민했던 점, 학습한 것들을 위주로 작성해보려고 한다 😄
절차 지향적인 설계가 아닌 객체 지향적인 설계
1주차 미션에서 나는 절차지향적인 설계를 진행했었다! 이는 1주차 회고에서도 언급한 내용인데, 기능들을 그냥 생각나는대로 나열하고 이를 객체지향적으로 구현하려고 하니, 상당한 애를 먹었다 🥲
따라서 이번 미션에서는 설계 자체에 대해 고민하는 시간을 더 많이 가졌다. 실제 구현을 시작한 건 금요일이니, 수 - 금 3일동안은 설계에 대한 고민만 했던 것 같고, 클래스 다이어그램을 도출하고 각 클래스에 무슨 메소드가 들어갈지 고안했다. 문제를 '함수' 단위로 분석하기 보단 '클래스' 단위로 분석하려고 노력했다.
이 과정에서 또 한번 느낀 건 설계가 중요하다는 것.
하지만 설계에 많은 시간을 투자했음에도 내가 초기에 설계한 디자인과 최종적으로 만들어진 프로그램 디자인은 다소 다른 부분이 있었다.
첫번째로 달라진 점은, 추상적인 부분이다. 내 시스템의 로직 형태는 쉽게 표현하면 다음과 같다.
GameManager는 쉽게 말해 게임 관리자이다. 게임을 시작하고, BaseballManager에게 유저 입력 값을 넣어주고 이를 계산하라는 명령을 보낸다. 이에 따라 BaseballManager는 ScoreManager를 통해 유저 점수를 초기화한다. 이때 초기화 되는 값은 ScoreCalculator에 의해 계산된다.
로직은 나쁘지 않은 것 같다. 다만 문제점이 하나 생긴다. 클래스 간 메세지는 무엇을 주고받을 것인가? GameManager는 BaseballManager에게 무엇을 받을 것인가?
나는 초기 설계 과정에서 이 부분에 대해 간과했다. 객체 - 객체 간의 통신에 대해 크게 신경쓰지 않았다. 그래서 구현된 결과를 보면 단순히 GameManager는 BaseballManager에게 유저의 점수가 들어간 리스트(ex. [1, 1])을 받는다. 다만 이렇게 되면 클래스 간 주고받아야 할 메세지가 달라지는 경우 문제가 생길 것이다. 볼, 스트라이크만 존재하지 않고 파울 등이 생겨난다면? GameManager도 바꿔줘야 한다.
그래서 enum으로 메세지를 설정해줘야 하나? Message 클래스를 만들어볼까? 등 고민을 지속했는데 명확한 답을 찾을 수 없었다. 애초에 내가 구상한 클래스들이 이러한 메세지를 주고받기에 특화된 설계가 아닐 수도 있다. 그래서 이 부분에 대해서는 조금 더 고민해 볼 예정이다.
위와 같은 추상적인 부분이 아니더라도, 초기 디자인과 구현에 있어 달라진 부분은 다음과 같다.
- 한 책임당 한 클래스. 입출력 부분과 유틸리티 부분에서 한 책임에 대해 여러 클래스가 작동하는 것을 확인했다. 이에 따라 몇몇 클래스는 하나의 클래스로 병합함.
- InputVerifier가 책임을 여러 개 가지고 있어 수정에 열려있기 때문에 SRP, OCP를 위반, Verifer 인터페이스를 두고 기능에 따라 상속받아 사용함.
- 한 클래스는 한 클래스하고만 메세지를 주고받을 수 있도록 함. 즉, 클래스 3개(A, B, C)가 있고 C는 B에 의미적으로 포함된 클래스라고 해보자. 그러면 A는 B하고만 통신하고, A - C 통신은 이루어지지 않도록 했다.
테스트
나는 테스트라는 걸 이번에 처음 접했다. 이전까지는 단순히 프로그램을 실행시키고, 값을 넣는게 테스트라고 생각했다.
그러나 Java에서는 이미 잘 짜여진 테스트 모듈을 제공해주고 있었고(JUnit, Assertj) 이를 처음 사용해보게 되었다.
우선 테스트란 무엇인가? 쉽게 설명하자면,
1. 단위 테스트, 2. 통합 테스트로 분류되는데,
단위 테스트(Unit Test)는 하나의 모듈을 기준으로 독립적으로 진행되는 가장 작은 단위의 테스트이고, 여기서 말하는 모듈은 하나의 메소드 등으로 생각하면 된다. 즉, 단위 테스트는 그냥 ‘어떤 입력이 주어지면 어떤 결과가 나오는지’ 체크하는 역할이다.
통합 테스트는 앞서 말한 모듈을 통합하는 과정에서 모듈 간의 호환성을 확인하기 위해 수행하는 테스트이다. 쉽게 말해 모듈들끼리 메세지를 잘 주고받는가? 를 테스트하는 과정이다.
일반적으로 주목받는 건 유닛 테스트(단위 테스트)이다. 통합 테스트 역시 중요하지만, 테스트의 가장 중요한 장점 중 하나는 피드백을 빠르게 받을 수 있다는 점이다. 유닛 테스트를 통해 만든 메소드를 바로 평가하는 것이 가능하다. TDD에서 주로 언급하는 테스트도 유닛 테스트이다.
객체지향에서 SOLID 5원칙이 존재하듯이, 테스트에서도 FIRST 5원칙이 존재한다! 테스트도 어쨌거나 코드이므로.
- Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야 한다.
- Independent: 각각의 테스트는 독립적이며 서로 의존해서는 안된다.
- Repeatable: 어느 환경에서도 반복 가능해야 한다.
- Self-Validating: 테스트는 성공 또는 실패로 bool 값으로 결과를 내어 자체적으로 검증되어야 한다.
- Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 한다.
자바에서의 테스트 방법을 공부하고 이를 적용해보려고 하는데, 몇몇 어려움이 있었다.
우선 내가 만든 메소드 중 몇몇은 로직 처리를 하지 않고 단순히 다른 클래스의 메소드만 호출하거나, 값을 저장하기만 하는 경우가 있었다. 예를 들어서, 다음과 같은 코드이다.
public class ScoreManager {
private Score userScore;
public void makeUserScore(BaseballNumber baseballNum1, BaseballNumber baseballNum2) {
userScore = ScoreCalculator.calculateUserFinalScore(baseballNum1, baseballNum2);
}
...
}
makeUserScore에 대한 테스트를 수행할 필요가 있을지 의문이 들었다.
내가 내린 결론은, 단순히 다른 기능을 호출하는 '명령에 관한 책임'만 갖고 있다면 이를 테스트 할 필요는 없다는 것이다. 왜냐하면 위 예시에서는 ScoreCalculator.calculateUserFinalScore 메소드가 문제가 생기지 않는다면 makeUserScore 메소드 역시 문제가 생기지 않을 것이기 때문이다. 다만 명령을 보내고, 이 결과를 엮어줄 필요가 있는 경우에는 테스트를 진행했다. 즉, 모든 메소드에 대해 테스트 코드를 짜기보단, 불필요한 테스트는 지양하도록 했다.
그리고 비슷한 논리로 Setter에 대한 테스트를 수행할 필요가 있을까? 의문이 들어 찾아봤는데, 이미 이러한 고민을 한 개발자들이 있었다.
https://stackoverflow.com/questions/6694715/junit-testing-private-variables
Private variables are part of the implementation, tests should focus on behavior rather of implementation details.
private variable에 테스트에서 접근할 수 있냐는 문제였다. reflection을 쓰면 가능하다는 것 같다. 하지만 그래서는 안된다고 주장하고 있다. 테스트는 구현에 관련된 것이 아니라 행위에 관련된 것이기 때문에!
또한 나는 테스트를 해보면서 깨달은 것이 하나 더 있는데, 테스트의 진가는 '검증'에만 있는 것이 아니라는 것이다. BaseballNumber 클래스에서 splitDigitsIntoList 메소드를 테스트 하려던 중, 이 메소드가 non-static이었기 때문에 객체를 만든 뒤, 메소드에 접근해야 했음을 알았다. 하지만, 나는 BaseballNumber 클래스의 디폴트 생성자는 정의하지 않았다. 그렇다 해서 임의의 값으로 객체를 초기화시켜주면서 생성한 뒤, splitDigitsIntoList 메소드를 사용하기에는 무언가 찝찝함이 남았다.
그래서 왜 이런 문제가 발생할까 생각해보니, splitDigitsIntoList 메소드는 해당 클래스와 종속성이 없다는 것을 알았다! 그래서 이 메소드는 ListUtlity 클래스로 옮겼다. 이처럼 테스트를 통해 단순히 입출력 검증이 아닌 내가 설계한 클래스가 타당한 논리로 구성되었는지 알아낼 수 있다.
제공된 코드를 사용하면서 만났던 어려움들
Console 라이브러리
1주차와 마찬가지로, 2주차 역시 기본적인 테스트 코드가 제공되었는데 다른 점이 있다면 추가적으로 특정 라이브러리를 사용하도록 요구사항에 명시해준 것이다. 크게 두 가지 요구사항이 있었는데, 1. 입력을 받을 때 Console 이라는 라이브러리를 사용할 것, 2. 랜덤 수를 만들 때 Randoms 라는 라이브러리를 사용할 것.
하지만 라이브러리 사용이 순탄치 않았고(거의 오프로드였음) 이 과정에서 학습한 것들을 스토리텔링 형식으로 이야기하고자 한다.
나는 입력을 담당하는 InputHandler 클래스를 작성하면서, 한 가지 의문에 빠졌다. 분명 제공받은 Console 클래스의 메소드를 사용해 입력을 받았는데, WARNING이 뜨는 것이다.
그렇지만 입력은 가능했다. 처음에는 입력이 가능한지 모르고 WARNING이 막 뜨길래 컴파일 에러가 난 줄 알고 한참 고민했다 😟
입력은 가능했기에 사실 크게 문제삼을 건 없었다. 어쨌거나 정상 작동되니 괜찮을 거라 생각했지만 이 문제를 해결하고 싶었다.
그래서 라이브러리로 제공된 Console 클래스를 직접 읽어봤다. 문제는 다음의 코드에서 발생했다.
private static boolean isClosed() {
try {
final Field sourceClosedField = Scanner.class.getDeclaredField("sourceClosed");
sourceClosedField.setAccessible(true);
// 위 친구가 문제였음 !
return sourceClosedField.getBoolean(scanner);
} catch (final Exception e) {
System.out.println("unable to determine if the scanner is closed.");
}
return true;
}
scanner가 isClosed 되어 있는지 체크하는 함수이다. scanner가 왜 close 되는지도 궁금해서 찾아봤는데, I/O 와 관련된 자원을 운영체제에게 반납하기 위함이라고 한다.
아무튼 위 코드를 끌고 와서 직접 하나하나 실행해보며 내린 결론은 setAccesible(true); 가 실행되면 경고문을 발생시킨다는 것. 이미 캡슐화 된 Scanner 라는 클래스에 reflection으로 접근하려 해서 그렇다.
여기서 reflection도 처음으로 알게 되었는데, 쉽게 말해 '구체적인 클래스 타입을 알지 못해도 그 클래스의 메소드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API' 라고 생각하면 된다. 해결 방법을 찾다 보니 다음과 같은 문서가 나왔다.
https://learn.microsoft.com/ko-kr/java/openjdk/transition-from-java-8-to-java-11
제목에서 유추할 수 있다시피, java 버전을 8로 바꾸면 문제가 사라진다. 아마 java 버전이 높아짐에 따라 추상화된 클래스에 대해 격리 장치를 더 제공하는 것이 아닐까 예상하고 있다.
이외에도 컴파일 옵션을 주는 등, 경고문을 사라지게 하는 방법은 많았지만, 근본적인 해결책은 자바 버전을 내리는 것이기에 이후 이 메세지를 없애려 노력하진 않았다!
그러면 왜 reflection으로 추상화 된 클래스에 접근하는 것을 경고문만 주고 허용하는지 궁금증이 생긴다. 그 이유는 레거시 코드를 위함일 것이다. 새로운 버전이 업그레이드 되었다고 해서 기존 메소드들의 동작을 제한할 순 없다. 아마 reflection을 사용하는 라이브러리가 이미 많이 존재할 것이고, 동작 자체를 막아버린다면 라이브러리 자체에 문제가 생기기 때문에 경고문만 보여주는 것일 듯 하다.
Randoms 라이브러리
이후 랜덤 값을 생성하는 메소드를 작성하면서, 나는 한 가지 문제점을 발견했다. Randoms 라이브러리의 특정 메소드가 작동하지 않는 것. 엄밀히 말하면 작동은 한다. 그러나 반환값이 빈 리스트였다..!
public static List<Integer> pickUniqueNumbersInRange(
final int startInclusive,
final int endInclusive,
final int count
) {
validateRange(startInclusive, endInclusive);
validateCount(startInclusive, endInclusive, count);
final List<Integer> numbers = new ArrayList<>();
for (int i = startInclusive; i <= endInclusive; i++) {
numbers.add(i);
}
return shuffle(numbers).subList(0, count);
}
위 메소드가 문제였다. 로직 자체는 문제가 없는데, 사용하기만 하면 빈 배열을 반환한다. 디버깅을 하면서 분명 return 문에 도착했을 때는 값이 제대로 있었는데, 반환하기만 하면 빈 배열을 줬다. 이게 무슨 일이람 ..
Randoms 라이브러리에는 문제가 없음을 직접 코드를 보면서 확인했다. 그래서 import 문에 문제가 있나 싶어 import 문의 동작 방식을 살펴봤는데, 그건 아닌 것 같았다. 정말 미스테리였다. 논리적으로 납득이 안됐다. 이건 개발자의 얼굴에 침을 뱉는 것이나 마찬가지이다. 꼭 해결해야만 했다.
그렇게 모든 라이브러리 코드를 뜯어보고.. 결국에는 해답을 찾았다.
public static void assertRandomNumberInRangeTest(
final Executable executable,
final Integer value,
final Integer... values
) {
assertRandomTest(
() -> Randoms.pickNumberInRange(anyInt(), anyInt()),
executable,
value,
values
);
}
위 코드는 ApplicationTest가 기본적으로 제공하는 테스트 케이스에서 사용되는 메소드이다.
assertRandomTest 메소드를 다시 호출하고 있는 모습인데, 파라미터로 넘겨주는 것들을 잘 살펴보면 이상한게 하나 보인다. 이는 아래 코드를 보면 이해가 된다.
private static <T> void assertRandomTest(
final Verification verification,
final Executable executable,
final T value,
final T... values
) {
assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> {
try (final MockedStatic<Randoms> mock = mockStatic(Randoms.class)) {
mock.when(verification).thenReturn(value, Arrays.stream(values).toArray());
executable.execute();
}
});
}
호출되는 assertRandomTest에서는 mock 이라는 표현이 등장한다. when, thenReturn 등 이상한 문법을 쓰고 있는데, 찾아보니 이것이 Mockito 라이브러리다.
Mockito가 뭘까? 하고 다시 찾아봤는데, 임시 객체를 생성할 수 있는 라이브러리로 메소드가 구현되어 있지 않아도 메소드에 대한 반환값을 지정할 수 있다 😱
그럼 위 코드를 다시 한번 보자. 쉽게 해석하면 verification 이라는 '행위'가 발생하면 value, values를 리턴 값으로 지정하겠다는 의미이다. assertRandomNumberInRangeTest 에서는 verification으로 Randoms.pickNumberInRange를 넘겨주고 있다. 이 말인 즉슨, 테스트를 수행하면 당연히 pickNumberInRange 메소드에 대한 Mockito를 통해 리턴 값만 지정해주고 있으므로 다른 메소드를 사용하면 빈 배열을 리턴할 수 밖에 없다는 것. 이를 알고 난 뒤에는 (기존 테스트코드 자체를 바꿀 수 없으니까) Randoms의 다른 메소드를 사용해 해결했다.
이 결론을 도출하는 과정에서 테스트 코드 관련 라이브러리도 뜯어보았기 때문에, 자연스레 NsTest 라이브러리의 동작 방식에 대해서도 알게 되었고, 이를 나중에 상속받아 입출력 테스트 때 써먹을 수 있었다.
또한 랜덤한 수가 발생하는 경우 테스트 코드를 어떻게 작성해야 하는지도 알게 되었다. 생각해보면 랜덤한 수가 발생하면 테스트할 수 없게 되는데(입력이 랜덤으로 들어오므로 결과값을 미리 예측할 수 없음), 랜덤 값을 미리 지정해주면 되는 것이었다!
앞으로의 방향성
이번 미션부터는 자바 컨벤션을 따르도록 권장하고 있다. 확실히 자바 컨벤션을 따르고, 깃 컨벤션을 따르다 보니 코드가 정갈해졌고, 나 역시도 읽기 쉽다는 느낌을 받는다. 클린 코드의 가장 좋은 점은 구조가 한눈에 보인다는 점이 아닐까. 구조가 한눈에 보이기 시작하면 분리해야 할 기준점이 보이기 시작하고, 남들이 읽기 쉬운 건 덤이다. 앞으로 누가 봐도 깔끔한 코드를 작성할 수 있을 때까지 갈길이 멀지만, 조금씩 클린 코드에 가까워지는 것 같아 기분이 좋다. 앞으로도 꾸준히 컨벤션을 지켜 나갈 생각이다.
또한 테스트를 처음 사용해봤는데, 테스트의 진가를 알게 된 한 주 였다고 생각한다. 피드백을 빠르게 받을 수 있는 것. 이것이 가장 중요한 대목이다. 테스트를 사용하면서 개발을 진행하다 보니, 자연스럽게 bottom-up 방식의 개발이 되었는데 top-down 방식의 테스트 주도 개발은 어떤 식으로 진행되는지 궁금해 이에 관해서도 찾아볼 예정이다. 이점이 많은 방법이라면 3주차, 4주차에 적용해보는 건 어떨까 생각하고 있다.
객체지향 자체에 대해서도 많이 학습했던 계기가 되었다. 특히 초반 클래스 다이어그램을 정의하는 부분에서 많은 고민들을 했었는데, 다음부터는 앞서 말했던 아쉬움들을 바탕으로 더욱 신경써서 설계를 진행해볼까 한다. 그리고 '객체지향의 사실과 오해'라는 책을 방금 막 읽기 시작했는데, 객체는 실세계와 무조건적으로 대응되면 좋지 않은 설계라고 한다. 이에 대해 3주차 미션이 시작하기 전까지 읽어보고 적용해보려고 노력해야겠다.
또한, 2주차 미션부터 커뮤니티가 추가되었는데, 코드 리뷰를 하거나 받으면서 내가 모르던 관점에 대해 학습하게 되어 참 좋은 것 같다. 이렇게 다양한 사람들의 의견을 들어보고, 서로 자유롭게 토론할 수 있는 기반이 있기에 더 능동적이고 효율적인 학습이 진행되는 듯 하다. 그만큼 나도 Peer Review 스터디 및 커뮤니티 활동을 진행하면서 내 능력으로 다른 사람들에게 좋은 영향력을 끼치고 싶다.
이번 미션을 진행하면서, 아직 나는 부족한 점이 많고, 가야 할 길이 멀다고 느껴진다. 그렇지만 나는 일주일 전의 나와 비교해 확실히 성장했다. 우테코 커뮤니티를 보다가 기억에 남는 글이 하나 있었는데,
타인과 비교하지 말고, 과거의 자신과 비교해라
라는 말이었다. 어쨌거나 나는 과거보다 성장했고, 앞으로도 성장할 것이다. 이러한 깨달음을 주신 분께도 감사하다.
앞으로 남은 2주, 다른 프리코스 지원자분들과 함께 자라보자. 3주차 회고록을 쓸 때 즈음에는, 지금보다 성장해 있기를 🔥
끝!
'우테코 5기' 카테고리의 다른 글
[우테코] 우아한 테크코스 5기 1차 합격 + 최종 합격 (1) | 2022.12.15 |
---|---|
[우테코 프리코스] 4주차 회고 (1) | 2022.11.24 |
[우테코 프리코스] 3주차 회고 (0) | 2022.11.16 |
[우테코 프리코스] 1주차 회고 (2) | 2022.11.01 |
우테코 프리코스를 앞두고 (1) | 2022.10.24 |