[도메인/비즈니스 로직, 클래스 분리]
이번 미션의 요구사항 중 도메인 로직에 대한 테스트 코드 작성이 있었다. 먼저 의문이 들었던 것은 도메인 로직이 무엇인가였다. 알아본 결과 도메인 로직과 비즈니스 로직은 같은 의미로 사용된다고 알게 되었다. 비즈니스 로직은 예전에 접해본 적 있는 내용인지라 어떤 프로그램의 핵심 기능을 수행하는 코드 로직 부분을 비즈니스 로직이라고 부른다는 정도로 알고 있었다.
좀 더 구체적으로 정리하면 ‘소프트웨어로 해결하고자 하는 문제’를 도메인 혹은 비즈니스라고 부르고 ‘이를 해결하기 위해 구현된 코드 로직’을 도메인/비즈니스 로직이라고 한다는 것이다. 그리고 도메인/비즈니스 외적인 로직을 어플리케이션 서비스 로직이라고 부른다. 도메인/비즈니스 로직과 어플리케이션 서비스 로직을 구분하는 척도는 해당 코드가 비즈니스에 대한 의사결정을 하고 있는가에 대해서 생각해보면 된다. 나머지 코드는 입력, 출력, 데이터의 전달 등과 같은 어플리케이션 서비스 로직에 해당된다.
이번 주차 과제에서 스스로 정형화한 프로젝트 패키지 구조를 사용하고자 했었고 결과적으로 구조는 다음과 같았다.
<패키지 구조 세분화>
- Controller
- 서비스 객체를 멤버변수로 생성 후 서비스 객체의 메서드를 이용해 기능 수행
- 사용자 입출력은 Controller에서 수행
- run메서드에 프로그램 전체 로직이 들어가도록 구현
- Service
- 도메인 / 비즈니스 로직은 이곳에서 구현한다.
- 가장 작은 기능을 수행하는 메서드부터 구현하고 이들을 결합하며 점차 큰 기능을 수행하는 메서드를 만들어 나가기(private)
- 가장 큰 단위의 기능을 수행하는 메서드가 Controller 단에서 호출되어 사용된다.(public)
- Domain
- 프로그램에서 객체라고 분리될 수 있는, 혹은 데이터를 저장해야 하는 기능이 필요한 경우 도메인 객체로 이를 유지하도록 한다.
- Domain 객체 내부에 값을 저장하기 전 대부분 validation 기능을 요구하므로 validation 기능 구현은 validation 클래스에, 사용은 domain 클래스에 하도록 한다.
- Utils(final)
- 유틸성 함수는 최대한 일반화 시켜서 기능을 구현할 것
- public static 메서드로 구현
- Validation(final)
- 입력값이 어떤 조건에 부합하는지 검증하는 기능 구현 (public static 메서드로 구현)
- 특별한 경우가 아니라면 Validation 클래스에서 구현한 메서드들은 Domain 객체 혹은 Utils 메서드 내에서 사용
- View(final)
- public static 메서드로 구현
- 입력 메시지 (InputMessage)
- 출력 메시지 (OutputMessage)
- 출력 메시지의 경우 매개변수로 값을 넘겨 받아서 이를 출력하는 메시지도 구현한다. (단 출력 기능만 수행한다.)
이렇게 전체 프로젝트 패키지 구조를 정하고 나니 어디에 도메인/비즈니스 로직을 구현해야 하고 어플리케이션 서비스 로직은 어떻게 분리해서 구현할 지가 굉장히 명확해졌다. 처음엔 전부 service 클래스 내에서 사용자 입력을 받아오고 검증하는 것으로 했는데 패키지 구조를 명확하게 정리하고 나니 validtation 기능은 domain 객체에 입력값을 저장하기 전, 혹은 utils 함수 내에서 검증하도록 코드를 분리할 수 있었다. 또 막상 개발을 진행하면서 보니 서비스 단에서 view 컴포넌트 단에서 수행하는 기능을 사용하고 있었고 이를 컨트롤러로 분리하는 작업을 진행했다. 결과적으로 패키지 구조를 정리한 것으로 또 다른 미션 요구사항이었던 클래스(객체)의 분리를 동시에 달성할 수 있었다.
[기능 목록 작성]
공통 피드백 내용 중 기능 목록을 작성할 때 너무 상세하게 작성하지 말라는 피드백이 있었다. 지난 주차에 기능 목록을 작성할 때는 모든 메서드가 수행하는 기능들을 전부 명시했었는데 그러다 보니 시간도 굉장히 오래 걸렸었고 나중에는 과연 이걸 나중에 다 읽어볼까? 라는 생각도 들었다. 그래서 이번에는 모든 메서드에 대해 기능을 명시한 것이 아니라 요구사항 분석을 통해 도출한 필요 기능들만을 정리하려 했다. 스스로 아쉬운 점은 이번에도 핵심적인 도메인/비즈니스 로직에 해당하는 기능들만 정리한 것이 아니라 입력 값의 형식 검증이나 너무 일반화된 표현으로 기능 목록을 정리한 것이다.
실제로 validation 패키지 내에 검증 기능 메서드를 구현할 때는 최대한 일반적으로 구현하여 재사용성을 높인다고 하더라도, 구현 기능 명세서에는 최대한 도메인 / 비즈니스 로직에 가깝게 표현을 사용해야 한다는 것을 느꼈다. 예를 들어 로또번호 생성 기능 중 중복 번호를 체크하기 위한 기능으로 ‘숫자 리스트 내에 중복 값이 있는지 검증하는 기능’이라고 명시를 해두었다. 하지만 나중에 다른 사람이 기능 명세서를 읽어볼 때 원하는 것은 해당 프로그램에서 로또 게임을 진행할 때 중복 되는 번호를 입력하면 안된다는 내용이지 숫자 리스트 내 중복 요소값 존재 유무를 검증하는 기능이 있는지를 알고 싶어 하는 것이 아니다. 다음 주차 과제에는 더 도메인/비즈니스 로직에 대한 표현을 사용해 기능 명세를 하도록 노력해야 겠다.
[요구사항 분석]
개인적으로 처음 문제를 받아서 요구사항을 분석할 때 어디서부터 시작해야 할 지 몰라 해매는 시간이 많은 것 같아서 요구사항 분석 과정을 구체화 해보기로 했다.
- 프로그램 실행 예시를 먼저 보고 프로그램 전체 흐름에 따른 큰 기능들을 항목화
- 기능 요구 사항에 명시된 내용들을 큰 기능들의 하위 항목 혹은 새로운 큰 기능으로 추가
- 입출력 요구사항 항목들을 각 기능의 하위 항목으로 추가
- 명확하게 어떤 내용으로 구현해야 겠다라고 감이 오지 않는 기능은 더 구체화 시켜 완성시킨다.
- 도메인/비즈니스 로직과 어플리케이션 서비스 로직을 분리하여 사용한다.
[Enum]
이번 주차 과제 요구사항 중 Enum을 사용해야 한다는 것이 있었다. Enum은 관련있는 상수들을 묶어서 하나의 클래스 처럼 사용할 수 있게 해주는 역할을 한다. 특히 하나의 상수명에 관련 있는 여러 값을 담아서 사용할 수 있다는 점이 직관성 개선에 많은 도움을 줬고 여러 상수를 선언하지 않아도 되서 편리했다.
private Rank determineLottoRank(LottoGame lottoGame, Lotto lotto) {
int correctCount = countCorrectLottoNumbers(lottoGame, lotto);
boolean correctBonusNumber = containsBonusNumber(lottoGame, lotto);
// 일치하는 숫자의 개수를 index처럼 사용하여 반복문 indent를 감축
Rank rank = Rank.values()[correctCount];
if (rank == Rank.THIRD && correctBonusNumber) {
return Rank.SECOND;
}
return rank;
}
시간을 조금 투자했던 부분은 Enum 클래스를 구현하고 내부에 선언된 Enum 객체들을 순회하는 for문에서 indent를 줄이기 위해 고민했던 것이다. 더 공부해보고 알게된 것은 Enum 클래스의 기본 static 메서드 중 values() 를 사용하면 내부에 선언된 Enum 객체들을 선언된 순서대로 객체 배열을 만들어 리턴해준다는 것이었다. 이를 이용하여 for문을 통해 전체 Enum 객체들을 순회하는 기존 로직을 Enum객체 배열에서 바로 접근하는 로직으로 리팩토링해서 indent를 줄일 수 있었다.
(최초 작성 이후 추가 내용)
위와 같은 Rank.values()[corectCount] 로직을 사용하게 되면 이후 유지보수 측면에 아주 취약한 코드가 된다. 당장 Rank Enum 내부에서 선언된 필드 변수들의 순서만 바꿔도 바로 무너지는 로직이다. 당시에는 어떻게든 indent를 줄이는 것에만 혈안이 되어서 저렇게 구현한 것인데 나중에 최종 코딩 테스트 이전까지 진행했던 리팩토링 스터디에서 아래와 같은 코드로 리팩토링했다.
//로또 등수와 관련된 상수 Enum
public enum Rank {
NO_RANK_ZERO(0, 0L, "0"),
NO_RANK_ONE(1, 0L, "0"),
NO_RANK_TWO(2, 0L, "0"),
FIFTH(3, 5_000L, "5,000"),
FOURTH(4, 50_000L, "50,000"),
THIRD(5, 1_500_000L, "1,500,000"),
SECOND(5, 30_000_000L, "30,000,000"),
FIRST(6, 2_000_000_000L, "2,000,000,000");
private static final int DETERMINED_SIZE = 1;
private static final int INDEX_ZERO = 0;
final private int count;
final private long prize;
final private String printablePrize;
Rank(int count, long prize, String printablePrize) {
this.count = count;
this.prize = prize;
this.printablePrize = printablePrize;
}
public static Rank of(int correctCount, boolean correctBonusNumber) {
List<Rank> determinedRanks = Arrays.stream(values()).filter(rank -> rank.getCount() == correctCount)
.collect(Collectors.toList());
if(determinedRanks.size()!=DETERMINED_SIZE){
return determineSecondOrThird(correctBonusNumber);
}
return determinedRanks.get(INDEX_ZERO);
}
private static Rank determineSecondOrThird(boolean correctBonusNumber){
if(correctBonusNumber){
return SECOND;
}
return THIRD;
}
}
정답이라고 얘기할 수 있을 것 까지는 아닌 코드라고 생각되지만 적어도 처음 프리코스 미션에 제출했던 코드보다는 개선된 부분이 있기에 정리해보았다.
[테스트 코드]
이번 과제에서 테스트 코드를 작성하면서 첫 번째로 알게된 점은 테스트 코드를 작성할 때 최대한 구현 검증이 아닌 최종 결과 검증을 하는 것이 좋다는 것이다. 이것이 곧 도메인/비즈니스 로직 단위로 테스트를 구현하라는 의미인데 메서드 내부에서 어떤 식으로 기능을 수행하는지 일일이 검사할 필요가 없다는 것이다. 테스트 코드를 만약 내부 기능에 대해 일일이 테스트하도록 구현하면 메서드 로직이 수정되었을 때 테스트 코드 또한 변경사항이 발생하게 되기 때문에 변화에 매우 취약한 테스트 코드가 된다. 그렇기에 해당 기능을 수행하는 메서드가 어떤 결과값을 도출하는지에 집중해서 테스트를 진행해야 변화에 강한 테스트 코드를 작성할 수 있다.
비즈니스/로직 단위로 테스트를 작성하게 되면 무엇을 테스트 해야하는지 명확해지고 테스트 코드를 작성해야 할 범위가 줄어든다. 지난 과제에서는 모든 메서드에 대해서 하나도 빠짐없이 테스트 코드를 작성했었다. 하지만 그럴 필요 없이 도메인 / 비즈니스 로직을 수행하는 메서드들에 대해서만 기능 테스트를 진행하면 하위에 포함되는 세부 기능들도 테스트가 가능한 것임을 알게 되었다.
'우아한테크코스 > 우테코 프리코스' 카테고리의 다른 글
[우아한테크코스 5기] 백엔드 지원, 합격 과정 회고 (합격 후기) (20) | 2023.02.06 |
---|---|
[우아한테크코스 5기] 프리코스 4주차 회고 (0) | 2022.12.22 |
[우아한테크코스 5기] 프리코스 2주차 회고 (0) | 2022.11.08 |
[우아한테크코스 5기] 프리코스 1주차 회고 (1) | 2022.11.02 |