나의 독학은

핵심 기능부터 구현하기 위한 고찰 본문

카테고리 없음

핵심 기능부터 구현하기 위한 고찰

안종혁 2023. 11. 27. 15:01

우테코 프리코스가 끝나고, 핵심 기능 부터 구현하기 위해 '숫자 야구 게임' 미션을 다시 풀어보았다.

 

하지만, 핵심 기능부터 구현 하기 위해, 고민하고 답을 찾는 과정이 2주가 걸렸다.

이 과정에서 생겼던 궁금증들을 Q&A 형식으로 먼저 얘기해보겠다!

 

*예시로 나오는 코드의 전문입니다.

✅ 요구사항을 만족하기 위해 검증(validate)을 어디서 할 것인가?

요구사항을 만족하는 조건들을 어디서 검증해야 할까?

 

나의 생각은 각 객체의 생성자에서 검증을 하는 것이다.

이 때, 객체가 생성이 된다면 그 객체는 요구사항의 조건을 충족하는 보장된 객체임을 뜻하기 때문이다.

 

예를 들어, 숫자 야구 게임의 "서로 다른 세 자리수" 라는 요구사항은 다음과 같은 조건들을 내포하고 있다.

①서로 다른 세 자리수의 숫자는 중복되지 않아야 한다

② 1~9사이의 숫자여야 한다

③세 자리 숫자여야 한다.

 

이러한 조건들을 만족하기 위해 세 자리 숫자를 생성하는 객체인 GameNumber 를 생성할 때, 생성자에서 조건들을 검증(validate)을 하도록 했다.

public class GameNumber{
    private final List<Integer> numbers;
    
    public GameNumber(List<Integer> numbers){
        validate(numbers);
        this.numbers = numbers;
    }
    ... 다른 로직들 ...
    
    private void validate(List<Integer> numbers){
        1. 중복되는지 여부
        2. 숫자가 1~9사이인지 여부
        3. 세 자리수인지 여부
    }
}

입력값인 String List<Integer>로 타입을 변환한 후, GameNumber 에게 넘겨 주었을 때!!

GameNumber 가 성공적으로 생성 된다면 사용자가 입력한 값은 요구사항에 맞는 입력이란 것이 보장된다는 의미이다.

 

즉, GameNumber 에 의해 생성된 숫자는 중복되지 않고, 숫자가 1~9사이 이고, 세 자리수가 보장된다는 의미이다.

이것이 일급컬렉션의 글의 1번에 해당되는 내용이다.

 

그러나, 조영호님의 객체지향 세미나의 1시간 23분 15초경을 보면 각 객체에 있는 validate 들을 한꺼번에 관리하는 Validator 를 만드신다.

그리고, validate 를 한꺼번에 관리하는 이 설계(Validator)는 흩어져 있는 validate 들을 묶을 수 있기에 좋다고 말씀하신다.

validate 가 흩어져 있다면 전체적인 로직의 플로우을 놓칠 수 있고, 어떤 상황에서 오류가 생기는지 어렵다고 한다.

 

그렇다면 생성자에서 검증하는 것과 검증 로직들을 모아 놓은 Validator 중 어떤 것이 더 좋은 설계일까?

✅ Wrapping은 어디까지 하는가?

위 예시에 있는 List<Integer> numbers 에 해당하는 IntegerWrapping을 할 수 있다.

List<Integer> numbersList<Number> numbers 로 바꿀 수 있다는 얘기이다.

 

그렇다면, Wrapping을 해야 할까? 말아야 할까?

 

결론부터 말하자면 나는 List 안에 있는 숫자들을 Number Wrapping 을 했다.

왜냐하면 Number 객체에 의해 생성된 숫자들은 1~9사이의 숫자임을 보장하고 싶었기 때문이다.

public class GameNumber{
    private final List<Number> numbers;
    
    public GameNumber(List<Number> numbers){
        validate(numbers);
        this.numbers = numbers;
    }
    ... 다른 로직들 ...
    
    private void validate(List<Number> numbers){
        1. 중복되는지 여부
        2. 세 자리수인지 여부
    }
}

public class Number{
    private final int value;
    
    public Number(int value){
        validate(value);
        this.value = value;
    }
    
    private void validate(int value){
        1. 숫자가 1~9사이인지 여부
    }
}

이로 인해 GameNumber 에 있던 "각 숫자는 1~9사이 인지 확인" 하는 검증 조건은 Number 에게로 옮겨지게 되었다.

* [바뀐 검증 조건]

GameNumber : 각 숫자가 중복되는지 확인, 세 자리 수 인지 확인

Number : 각 숫자가 1~9인지 확인

 

이제 Number 를 사용하는 객체들은 1~9사이의 숫자를 이용한다는 보장이 되었다.

* 지금까지의 내용이 이해가 잘 가지 않는다면 포비님이 진행하신 리팩토링 영상의 45분 경에 나오는 Positive 관련 설명을 추천한다!

이 설명에서 나는 Positive가 0 이상의 숫자들을 모아놓은 객체임이 보장된다고 이해했다.

 

하지만, Wrapping 을 꼭 해야 할까? 그냥 냅두면 안되나?

그렇다면, 스트라이크와 볼 개수를 갖고 있는 Result 의 필드인 int ballCountint strikeCountWrapping 해야 할까?

 

나는 ballCountstrikeCount 를 통해 요구사항으로부터 보장하고 싶은 값은 없었기 때문에 Wrapping 하지 않고 원시 타입으로 두었다.

 

물론, 나와 다른 설계를 한 누군가는 NumberWrapping 하지 않았을 것이다.

오히려, 볼 개수와 스트라이크 개수를 Wrapping 했을 수 있다.

 

그렇다면 Wrapping을 어디까지 해야 좋은 설계라고 말할 수 있을까?

✅ 입력값의 검증은 어디서 하는가?

사용자가 입력하는 값의 타입은 String 이다. 

 

GameNumberString 타입을 사용해서 숫자를 만드는 것은 직관적이지 않을 뿐더러 숫자를 생성한다는 책임 이외에 타입을 변환하는 책임도 갖게 된다. (String 타입 → List<Integer> 타입) 

 

또한, validate 도 숫자를 검증해야 하는데 String 타입을 이용해서 검증하게 되기 때문에 직관적이지 않다.(서로 다른 숫자를 생성하기 때문에 GameNumberint 타입을 사용할 것이라 생각했는데 String 타입이다.)

public class GameNumber{
    private final List<Integer> numbers;
    
    public GameNumber(String input){
        validate(input);
        this.numbers = Arrays.stream(input.split(""))
            .map(Integer::parseInt)
            .collect(toList());
    }
    
    private void validate(String input){
        1. 중복되는지 여부
        2. 입력값의 길이가 세 자리인지 여부
    }
}

그렇기에 GameNumber 에게 사용자 입력값인 String 타입을 그대로 넘겨주지 말고, 누군가가 StringList<Integer> 로 타입을 변환해서 줘야 한다.

 

나는 이것을 GameNumberFactory 에서 진행해주었고, 동시에 입력값이 숫자로 변환 될 수 있는지도 검증해주었다.

public class GameNumberFactory(){
    public static GameNumber createNumber(String input){
        validate(input); // 입력값이 숫자로 변환되는지 검증, 변환이 안된다면 예외 발생
        return GameNumber.from(generateNumbers(input)); // String 타입을 List<Integer> 타입으로 변환
    }
    
    // 입력값이 숫자로 변환될 수 있는지 검증
    private void validate(String input){
        try {
            Integer.parseInt(input);
        catch (NumberformatException e) {
            throw new Illegal~~;
        }
    }
}

이 때, DTO가 순간적으로 떠올랐고, DTO에 대해 학습하게 되었다.

그리고, 학습한 결과로, 만약에 내가 DTO 를 코드에 적용할 줄 알았더라면 DTO 에서 최소한의 입력값 검증을 하고(숫자 야구 게임을 예로 들면, 입력값에서 숫자임을 검증하기), 타입을 변환 했을 것이다.

* DTO를 정확하게 사용하는 방법을 아직 잘 모르겠다 ㅜㅜ

 

그 이유는 DTO 에서 한다면 예외를 발생시키기 위해 내부 로직까지 안가도 되는 장점과 입력값을 받는 View 도 입력만 받을 수 있다고 생각하기 때문이다.

*DTO :  Controller 계층 에서 Service 계층으로 데이터를 전달하는 목적이다. 관련 영상 → 인비의 DTO vs VO

*Service 계층 : 내 생각은 프로그램이 동작할 때, 하나의 트랜잭션(일련의 행동) 을 처리하는 계층이라 생각한다.(!!아닐 수도 있음 주의!!)

 

'입력값의 검증을 어디에서 해야 하는가?'는 6기 디스코드 방에서도 활발히 진행됐을만큼 의견이 매우 분분하기 때문에 내가 한 생각이 정답은 아니다.

 

그렇다면 어디서 입력값의 검증을 해야 좋은 설계일까?

✅ 어디서 부터 구현을 시작할까? 

객체지향에 입문했다면 "객체지향의 사실과 오해"나 "오브젝트"를 읽어봤을 것이다.

그리고, 이 두 책의 예제 코드는 특정 객체의 메서드를 완성시키는 것부터 구현하기 시작한다.

 

"오브젝트"의 영화 예매 예제처럼 결과를 생성하는 심판 객체부터 구현하는 것처럼 숫자 야구 게임도 결과를 만드는 심판이란 객체부터 구현하고자 했다.

 

다음과 같이 Result 는 볼 개수와 스트라이크 개수를 갖고있는 객체,, Computer 는 랜덤으로 생성되는 숫자, User는 입력값에 의해 생성되는 숫자로 메서드의 시그니처를 완성했다.

public class Referee{

    public Result compare(Computer computer, User user){
        return new Result(? , ?); // 아마 볼 개수, 스트라이크 개수가 들어갈 것이다.
    }
}

 

그러자, Result, Computer, User에 대한 수많은 컴파일 에러가 나를 반겼고, 이 메서드에 대해 테스트 코드의 작성은 너무 어려웠다.

 

객체 지향에서 메시지를 보내고, 메시지를 수행할 객체를 선택하고, 다시 또 메시지를 보내고 하는 반복되는 과정을 이해 할 수 있지만, 책에 있는 예제 코드처럼 특정 객체의 메서드 부터 구현하기는 분명 어려웠다.

 

그에 반해, 메시지를 수행할 최소 단위인 도메인부터 구현하는 것은 훨씬 쉬웠고, 테스트하기에도 용이했다.

그리고, 이 방식이 Bottom-Up이란 구현 방식이란 것을 알게 되었다.

 

Bottom-Up과 대비되는 Top-Down 방식이 있었고, 이들을 GPT에게 물어보았다.

  • Top-down 방식 사용 예시:
    • 새로운 소프트웨어 제품이나 서비스를 개발할 때, 사용자의 기능적 요구사항을 먼저 이해하고 고수준의 설계를 수립할 때.
    • 대규모 시스템의 아키텍처를 설계할 때, 고수준의 모듈들을 먼저 정의하고 이들 간의 상호 작용을 고려할 때.
  • Bottom-Up 방식 사용 예시:
    • 이미 정의된 시스템이나 모듈에 새로운 기능을 추가할 때, 기존 모듈의 세부 사항을 먼저 고려하고 기능을 추가할 때.
    • 초기 단계에서는 전체적인 아이디어가 뚜렷하지 않은 경우, 먼저 작은 단계로 시작하여 기능을 빠르게 구현하고자 할 때.

숫자 야구 게임을  Bottom-Up 방식으로 설명해보면 다음과 같다. 

Number(int value)  → GameNumber(List<Number>)  → GameNumberFactory(new GameNumber) 순으로 구현하는 것이다.

1~9사이의 숫자를 보장하는 Number를 먼저 만들고, 이 Number 를 세 자리 수로 가지게 되는 GameNumber를 만들고, 이 GameNumber를 생성하는 역할인 GameNumberFactory 순으로 구현하며 테스트 하는 방식이다.

 

나는 이 방식이 핵심기능을 구현하기 위해 필요한 도메인에 대한 즉각적인 피드백도 최대한 빠르게 받을 수 있다 생각했기 때문에 이 방식을 이용하고자 했다!

 

즉, 설계는 오브젝트와 객체지향의 사실과 오해에서 말하는 것처럼 메시지를 보내면서 시작해서 Top-Down 을 이용하고,

구현은 메시지를 수행할 최소 단위부터 구현하는 Bottom-Up을 사용하기 이다.

 

또한, 이 방식은 우아한 ATDD(1시간 5분 30초~ 1시간 9분)에서도 소개된 것을 발견했다.

그래서, 깨우친 방식에 더욱 더 믿음이 갔다.

 

최소 단위의 도메인이 Number 인지 어떻게 알지? 라고 생각이 든다면, 프리코스에서 요구하는 객체 분리와 함수 분리를 계속 하려고 노력하라는 말씀을 드릴 수 있다.

저 또한 열심히 객체분리 하려고 노력하고, 함수분리를 하다보니 도메인에 대한 지식이 자연스럽게 늘어났다.

(물론, 숫자 야구 게임만 2주동안 쳐다봐서 그런 거일 수도 있다😂)

 

도메인 지식이 쉽게 얻어지진 않는다고 생각한다.

객체 분리를 하고, 함수 분리를 하기 위해  포비님이 진행하신 리팩토링 영상만 정말 7번 이상은 본 것 같다.

* 7번을 보면서 느낀 것이 '아는 만큼 보인다' 라는 말처럼 아는 것이 쌓일수록 똑같은 영상이었지만 얻는 것이 매번 달라졌다.

 

이 외에도 많은 정보를 찾고, 코드를 읽고, 각종 영상을 보며 도메인 지식을 얻기 위해 노력했다.

✅ 돌고 돌아 좋은 설계에 정답은 없다를 다시 한 번 알게 되었따.

핵심 기능부터 구현하기 위해 찾는 과정에서 생겼던 좋은 설계란 무엇일까?

정답을 찾고자 노력했지만 결론은 상황에 따라 다르다. 정답은 없다.

 

오브젝트에서도, 조영호님의 세미나 영상에서도 코드에 정답은 없다고 한다.

좋은 설계를 위한 정답은 없고, 단지 코드는 trade-off의 산물이기 때문에 어떤 것을 감수하고 어떤 것을 늘릴 건지 요구사항에 맞춰 그저 '적절하게' 해야한다고 한다.

* trade-off : 두 가지 이상의 대립적인 요소나 가치 사이에서 균형을 맞추는 것을 의미한다. 어떤 한 가지를 얻기 위해서는 다른 측면에서 어떤 것을 포기해야 하는 상황

 

이 '적절하게' 란 주관적인 단어는 좋은 코드, 좋은 설계를 하고 싶어하는 객체지향 입문자인 내게 도움이 되지 않았다.

 

하지만, 지금은 "설계는 적절해야한다"는 말에 점차 공감하고 있다.

그리고, '적절하게'라는 말에 공감하기 시작할 때!! 비로소 좋은 설계를 시작할 수 있다고 생각이 든다.

 

그렇다면 적절하게 설계를 하기 위해 내가 노력 할 수 있는 것을 생각해봤다.

 

1. '코드를 왜 이렇게 짰는가?' 를 아는 것. 스스로의 코드를 설명할 수 있어야 한다. 

2. "고민 끝에 결정한 내 설계가 옳아!!" 라는 생각 버리기. 다른 사람의 의견을 물어 다양한 관점을 얻기

3. 새로운 설계 방식을 배울 때, 내 기존 설계 방식과 비교해서 새로운 방식을 도입하면 어떤 장점이 있을지 꼭!! 생각해보기

 

(부트캠프에서 실무 현업자들에게 피드백 받는게 최고겠지만.. 못받는다면 못받는대로 아쉽지만 나아가야지!)

 

좋은 설계, 좋은 코드를 위해 ✅에 대해 스스로 생각하고 기준을 내려보았다.

기준에 대한 정답이 없었기에 기준을 내리기 까지 정말 막막했지만, 삽질한 만큼 성장했다는 확신이 든다!

 

그렇게 핵심 기능부터 구현하는 것에 대한 의미를 찾는 과정에서 많은 것을 깨닫고 얻게 되었다.