매주차 난이도가 조금씩 올라가는 느낌인데 그래서 더 재미있게 했다. 3주차에는 클래스 분리와 단위 테스트에 집중하면서 진행했다. 그 둘에 집중하면서 구현하다보니 이 왜 중요한 지 확실히 느꼈다.
이전 플젝은 Spring 프레임워크로 복잡한 클래스 구조없이 개발했었어서, 결합도는 낮고 응집도는 높은 설계의 중요성을 크게 느끼지 못했었다. 엄청 큰 프로젝트를 접해야만 그 장점을 알 수 있는 건가 싶었다. 근데 이번에 자바로만 구현하면서, 결합도는 낮고 응집도는 높은 설계의 중요성을 정말 체득한 것 같다.
이전 과제인 숫자 야구, 로또 게임은 어떻게 보면 웹서비스에 비해 엄청 간단한 프로그램인데도 기능 수정하거나 테스트 작성할 때, 적절한 클래스 분리의 필요성을 많이 느꼈다. 리팩토링..이라고 부르기도 어려울 정도로 아예 다 엎고 새로 클래스 설계하고 이 과정을 많이 반복했는데, 뭐 하나 추가하거나 테스트 할때마다 클래스가 잘 분리 되어 있어야 함을 느꼈다. 이번 회고록에 클래스 분리/단위 테스트와 그 외 배우고 느꼈던 점을 정리해보았다.
🧱 설계
우선 전체 설계는 MVC 패턴에 맞게 각각 분리했고 이건 저번주에 해봤어서 빠르게 했다. 도메인은 저번보다 살짝 복잡해져서 클래스 설계를 어떻게 할 지 고민이었다. 그래서 DDD 설계 방식을 참고했는데, 그 과정을 정리해보았다.
규칙 자체를 단순히 따라하는 것에 목표를 두기보다는 지금처럼 아예 설계를 어떻게 해야할 지 감을 못 잡았을 때 참고했다. 이 설계방법들이 어떤 점에서 편리하고 왜 이런 규칙이 생겼는지를 실제로 해보면서 알아보자는 느낌으로 접근했다.
MVC 패턴
먼저 큰 틀 설계를 잘해야 다른 것들도 쉬워지는 것 같다. 즉 클래스 분리/클래스 간 데이터 주고받는 설계가 잘 짜여있어야, 메소드도 잘 나눠지고 메소드명도 잘 만들수 있는 것 같다. 클래스 분리가 제대로 안되면 내가 코드 시작이 잘 안되는 것 같다.
설계하면서 고민했던 과정을 정리해보면,
- MVC 패턴에 맞게 View와 Domain 로직을 분리한다. (사실상 비즈니스 로직에서 뷰 로직을 분리하는 느낌이다)
- Domain 단에서 필요한 클래스들을 고민한다.
- 우선 필요한 클래스들의 이름 먼저 고민하고, 그 클래스는 그 이름에 맞는 기능/ 데이터만 담는다! ⇒ 아래 DDD 방식 참고한 내용에서 설명!
- Controller 단에서 클래스들 사이에서 데이터를 어떻게 주고 받을 지 고민한다.
- View에서 입력 받은 데이터를 어떻게 Domain 클래스에게 전달해줄지
- Domain에서 비즈니스 로직 처리한 데이터를 어떻게 View에게 전달해줄지
DDD(도메인 주도 설계) 참고
사실 DDD를 완벽하게 따라하려 했다기보다는, 이전보다 복잡해진 도메인단을 어떻게 클래스 분리할 지 고민하다가 DDD 방식을 참고하기로 했다. 과제 규칙상 Lotto는 변경이 안되서 DDD 규칙을 모두 완벽히 지키는 데 집중하지는 않았다. 관련 도메인 객체들을 묶고, 각 집합이 한 객체만으로 데이터를 주고 받는 설계 규칙에서 힌트를 얻어 도메인단 설계를 했다.
(DDD 규칙에서는 다른 애그리거트는 한 객체(애그리거트 루트)으로만 데이터를 주고 받아야 하는데, 지금은 Referee가 Lotto, BonusLotto 둘을 참고하고 있다. 과제 규칙 상 Lotto를 수정하면 안되서 변경하지 않았는데, 만약 제대로 DDD 규칙을 따르려면 Lotto 안에 보너스 넘버까지 넣어야 하는 걸까..!)
- DDD 주도 설계 참고 : https://medium.com/myrealtrip-product/what-is-domain-driven-design-f6fd54051590
이렇게 MVC 패턴 + DDD 설계 방식을 참고해서 구현했다. 그리고 이 과정에서 기능마다 테스트 코드를 작성하고 계속 리팩토링했다.
🌱 배운 점
1. 클래스 분리와 단위 테스트
이번 과제하면서 클래스 분리랑 단위 테스트가 상호 보완적인 관계라고 느꼈다.
- 클래스가 잘 분리되어있어야 단위 테스트를 작성하기 쉬움
- 다른 클래스들을 참조하지 않고 해당 클래스만으로 단위 테스트를 작성할 수 있어 편리함
- 다른 사람이 보았을 때도 테스트 코드를 이해하기 쉬움
- 단위 테스트 작성하면서 클래스 설계를 스스로 피드백할 수 있음
- 클래스 간 결합도가 높으면 테스트 작성 시 다른 클래스들을 다 가져와야 해서 복잡/불편해짐
- 테스트 작성하면서 해당 클래스/메소드의 기능과 역할이 크거나 복잡한 지 스스로 피드백할 수 있음
만약 클래스 분리가 잘 안되어 있으면, 한 클래스/메소드가 다른 클래스/메소드에 의존도가 높다. 그러면 해당 메소드를 일부 변경할 때 관련 메소드들도 함께 바꿔야해서 유지보수가 어렵다. 근데 클래스 설계를 잘했는 지는 단위 테스트로 스스로 피드백을 할 수 있었다.
이렇게 이 둘을 잘 지키다보면, 기능 변경/확장에 유연해질 수 있다고 느꼈다. 또한 기능 별로 클래스가 잘 분리되고, 각 메소드별 단위 테스트가 작성되어 있으면, 다른 사람들이 더 읽기 쉬운 코드가 되는 것 같다.
2. 단위 테스트 - 피드백 도구이자, 내 코드 사용 설명서
단위 테스트로 스스로 피드백을 받을 수 있을 뿐 아니라, 내가 짠 코드의 사용 설명서로도 활용할 수 있었다. 메소드가 어떻게 쓰이는 지를 상세히? 혹은 명확하게 보여줄 수 있는 것 같다. @DisplayName으로도 설명하고, 테스트 코드에서 해당 메소드를 실사용하는 코드를 보여줄 수 있다.
물론 구현 코드 내에서 함수가 어떻게 사용되었는 지 확인할 수 있다. 그렇지만 단위 테스트 코드에서 성공/실패 케이스를 확인하는 게, 이 메소드가 어떤 기능을 수행하는 지, 어떻게 사용하는지 다른 사람들이 더 확실하게 보여줄 수 있는 것 같다. 실제로 다른 분들의 코드를 리뷰할 때 단위 테스트가 잘 작성되어 있으면 이해하기 쉬웠다.
참고로 단위 테스트 작성 시 여러 파라미터를 사용할 수 있는 @ParameterizedTest을 쓰고 싶었는데, gradle 의존성을 추가해주어야 해서 그냥 여러 테스트 메소드로 따로 작성했다. (과제 규칙 상 gradle은 변경하면 안됬기에) 만약 가능했다면 아래처럼 사용했을 것 같다. 깔끔하고 validateInputMoney()가 뭐를 validate하는 함수인지 한 눈에 잘 보이는 것 같다.
@DisplayName("입력금액이 1000원 단위가 아닌 경우 예외를 발생한다")
@ParameterizedTest
@ValueSource(strings = { "1000j", "-1000", "1234", "0", "10 00", "1,000" })
@Test
void inputMoneyByNonNumber (String arg) {
assertThatThrownBy( () -> validateInputMoney(arg))
.isInstanceOf(IllegalArgumentException.class);
}
- ParameterizedTest 공부 자료 : https://www.baeldung.com/parameterized-tests-junit-5
3. if-else 대신 Enum
클린 코드 원칙에선 가독성을 위해 if-else를 지양한다. 대신 Early return을 사용하거나 if문만(else X)으로 대신한다. 근데 return하지 않으면서, 분기문이 필요한 상황이 있지 않나? 생각했다. 그리고 if문만 사용하는 경우, if-else 보다는 개선됬지만 뭔가 더 좋은 방식이 있지 않을 까 싶었다.
그래서 이번에는 Enum 클래스를 적극 사용해봤는데, 활용도가 굉장히 높았다. 아래 코드처럼 로또 점수 별 상수값/메소드를 Enum에 몰아두았다.
public enum Prize {
THREE_STRIKE(3.0, "3개 일치 (5,000원)", resultCount -> resultCount * 5000),
FOUR_STRIKE(4.0, "4개 일치 (50,000원)", resultCount -> resultCount * 50000),
FIVE_STRIKE(5.0, "5개 일치 (1,500,000원)", resultCount -> resultCount * 1500000),
FIVE_STRIKE_WITH_BONUS(5.5, "5개 일치, 보너스 볼 일치 (30,000,000원)", resultCount -> resultCount * 30000000),
SIX_STRIKE(6.0, "6개 일치 (2,000,000,000원)", resultCount -> resultCount * 2000000000);
private Double prize;
private String message;
private Function<Integer, Integer> expression;
Prize(Double prize, String message, Function<Integer, Integer> expression) {
this.prize = prize;
this.message = message;
this.expression = expression;
}
// ... 코드 생략
}
각 점수별로 메세지와 계산 메소드를 처리할 수 있다. if-else문없이도, 아니 그것보다 훨씬 더 명확하고 헷갈리지 않게 분기 처리가 된 것 같다. 특히나 Enum을 사용하면 변경/확장이 정말 편했다. if-else문 하나하나 수정하지 않고, Enum 클래스 안에서 수정해주면 되니까 너무 편리했다.
근데 저 코드도 다시 보니까 메시지 전체를 상수화하지 않는 게 더 유연할 것 같다. Enum 클래스에는 점수랑 당첨 금액만 상수화하는 게 더 유연할 것 같다. 메시지는 출력 담당 OutputView에서 담당하는 게 좋을 것 같고, 점수 계산은 간단한 곱셈만 있어서 금액만 상수화(메소드 상수화X)해도 계산할 수 있을 것 같다. 생각은 했었는데, 플젝 일정이 겹쳐 바빠서 이쪽은 건드리지 못했다 😢
public enum Prize {
THREE_STRIKE(3, false,5_000),
FOUR_STRIKE(4,false, 50_000),
FIVE_STRIKE(5, false, 1_500_000),
FIVE_STRIKE_WITH_BONUS(5,true, 30_000_000),
SIX_STRIKE(6, false,2_000_000_000);
private Integer matchCount;
private Boolean isBonus;
private Integer money;
Prize(Integer matchCount, Boolean isBonus, Integer money) {
this.matchCount = matchCount;
this.isBonus = isBonus;
this.money = money;
}
public Integer calculate(int count) {
return money * count;
}
}
4. 클린 코드를 위한 고민
indent 줄이기, 한 메소드 15줄 이하로 구현하기 : 메소드를 최소한의 기능으로 분리했다.
for문 내부에 또 for이나 if문 등이 필요하거나, 메소드가 길어지면 아예 새 메소드 분리했다. 그리고 메소드명대로, 딱 그 기능만 구현하고자 했다. 메소드가 다 10줄 이하이긴 했으나, 뭔가 더 분리할 수 있으면 더 보는 게 좋았으려나? 고민된다. 이전에는 무조건 한 메소드 작게 만들자! 해서 한 메소드 당 5줄 이내로 구현했었다. 근데 오히려 불필요하게 메소드가 많아진 것 같아서 이번에는 무조건 작게!보다는 한가지 기능만 구현하자에 초점을 맞췄다. 한 메소드의 양?이 어느 정도가 적당할 지 감이 올듯..말듯..하다.
부적절한 건 이제 어느 정도 보인다.(과하게 길거나, indent가 많거나, 여러 기능이 섞여있거나, 메소드명 외의 기능도 있거나, 다른 메소드와 의존성이 높거나..) 근데 내가 짠 게 적절한 가?는 확실하게 감이 안온다.
기능 목록
너무 구체적으로 적지 않았다. 이전에는 클래스별로 메소드마다 작성했는데, 이번에는 구현할 기능들을 적어두는 식으로 덜어냈다. 공통 피드백 이후에 이전 주차 기능 목록을 다시보니까 오히려 너무 많고 구체적이라 이해하기 어려운 것 같았다. 길고 구체적인 글이 항상 꼭 좋은 글은 아니니, 꼭 필요한 내용만 넣으려 했다..! 그래도 예외 처리는 꼼꼼한 게 좋을 것 같아서 자세하게 썼다.
메소드명
생각보다.. 메소드명/변수명을 명확하고 헷갈리지 않게 짓는 게 어려웠다. 정답 로또 번호, 사용자의 로또 번호, 당첨, 점수 등등 다 용어가 비슷비슷해서 내 코드를 누군가 처음보면 혼동될 만한 단어들이 많았다. 그래서 따로 용어 정리한 내용을 리드미에 추가했다. 메소드명을 개선하려면 계속 내 코드를 리뷰하고 좋은 코드를 참고하는 방법 밖에 없는 것 같다.
생각보다도 이름이 많이 중요한 듯 싶다. 클래스/메소드 내부에는 그 이름에 해당하는 기능만 넣어야되니까, 이름이 명확해야 헷갈림이 없다. 클래스 분리를 잘 하려면, 이름도 잘 지어야 하는 것 같다.
깃모지
가독성을 위해 깃모지를 사용했다. 특히 커밋 메시지 중 feat / test 가 구분이 잘 안되서 깃모지를 활용했다. 깃모지 + 커밋 메시지 규칙도 리드미에 올렸다.
- ✨ feat : 기능 추가
- ♻ refactor : 코드 리팩토링
- ✅ test : 테스트 코드 작성
- 📝 docs : 문서 수정
- 🎨 style : 코드 스타일 변경 (기능 상 변경X, 공백 등 코드 스타일만 변경)
- 🚚 rename : 리소스 이동, 이름 변경
- 🐛 fix: 버그 수정
📚 공부할 것들!
4주차 과제 목표는 클래스 분리 + 리팩토링이다. 원래는 커밋 전에 리팩토링을 어느정도 마치고 커밋을 올렸었는데, 이번에는 리팩토링 과정까지 커밋 올려볼 예정이다.
추가 공부할 내용들이 뭐가 있을까 고민하다가, 이번에 배웠듯이 리팩토링하려면 단위 테스트도 잘 설계해야 겠다는 생각을 했다. 테스트를 짜다보면 리팩토링해야되는 지점들을 발견하기도 하니까❕ 3주차에는 여러 테스트 문법들을 적용해보는 데에 그쳤다면, 이번에는 단순 junit & assertj 문법 뿐만 아니라 좋은 테스트 코드에 대한 고민을 좀 더 깊게 해야겠다.
- 좋은 테스트 코드에 대한 고민, 공부
- 리팩토링 ↔ 테스트 반복 (좋은 자료 찾으면 링크걸어둘 예정)
📑 회고
매주차 많이 배우고 있어서, 이전 코드를 보는 게 부끄럽다 ㅎㅎ😅 예전에는 어떤 코드가 좋은 지에 대한 기준이 아예 없었는데, 지금 보니 확실히 안 좋은 코드는 눈에 거슬리기 시작했다.. 배웠다는 의미니까 다행이면서도..싹 다 리팩토링 하고 싶어진다.ㅎㅎ 3주차도 다시 보니 아쉬운 코드들이 좀 보인다! 😢
그리고 생각보다 더 프리코스 과정이 재밌는 것 같다. 미션 하나하나 난이도가 조금씩 올라가는 느낌이라 성취감도 있고 안보이던 것들이 조금씩 보이니까 순수하게 배움의 재미?를 느끼게 되는 것 같다. 원래 개발 공부하면 뭔가 해치운다는 느낌이 강했는데, 프리코스 과제하면 한 단계씩 배워가는 재미가 있다.ㅎㅎ
클린코드, 좋은 설계, 한 메소드의 적절한 양 등 좋은 코드에 대한 기준은 아직 고민하는 부분이 많지만, 안 좋은 코드에 대한 기준은 이제 하나둘씩 생기는 것 같다. 예를 들면 의존도가 높은 클래스/메소드 설계, 모호한 변수명, 가독성 떨어지는 코드, 한 메소드에 너무 많은 기능, 불필요한 if-else문 사용 등등.. 4주차에는 단순하게 시도해본다! 정도가 아니라 정말 좋은 클래스 설계, 좋은 테스트 코드에 대한 본질적인 고민과 공부를 더 해야겠다는 생각이 든다.