ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [우아한 테크코스 7기] 프리코스 2주차 다섯째, 여섯째날 회고 - 유연하게 유연하게.. 🏄🏼🌊
    회고 2024. 10. 28. 01:06

    오늘은 병적으로 유연하게.. 유연한 설계를 찾아 헤맸던 날이었다.

    나의 모토는 '남들이 한다고 해서 다 하지 말자', '이유가 명확하지 않으면 하지 말자' 이기 때문에 물론 이유 없이 적용한 것들은 없었다.

    유연하게 유연하게를 되뇌이며 결합도를 낮추다 보면 어느새 새롭게 보이는 것들이 있어 너무 신기했다.

    문제의식을 갖게 된 계기

    public class Game{
        public Game(String[] nameOfCars, int totalRounds) {
                this.cars = new Cars(nameOfCars);
                this.totalRounds = new TotalRounds(totalRounds);
                this.totalRounds = totalRounds;
                this.currentRound = 1;
        }
    }
    
    
    public class Cars{
        public void move(int index) {
            cars.get(index).move();
        }
    }

    기존에는 위와 같이 Game 의 생성자에서 Cars 를 생성하고 있었다. 또한, 자동차의 움직임을 Game 의 외부로 드러내지 않고 캡슐화한 상태였다.

     

    Game 에서 우승자를 잘 판단하는지 검증하기 위해서는 자동차들을 이동시켜야 했다. 그러나, 자동차의 움직임을 숨긴 동시에 자동차 자체를 Game 내부에서 생성하다 보니 테스트할 때 해결할 수 있는 방법이 없었다.

     

    생성자의 파라미터로 자동차들의 이름을 넘기는 것이 아니라, Cars 객체를 외부에서 생성해서 주입해준다면 테스트가 편해질텐데.. 하는 생각을 했다. 그러다가 문득 테스트와 관련해 읽었던 책에서의 문구가 떠올랐다.


    내부 행위를 테스트하고자 한다면 이는 설계에 문제가 있는 것이다.
    단일 책임 원칙을 어긴 것인지 살펴봐야 한다.

    그래서 혹시 Game 객체의 결합도가 높은 건 아닐까? 변경이 전파될 우려가 높은 객체인 건 아닐까? 하는 생각이 들어 Game 객체를 낱낱이 살펴보기 시작했다.

    1. 테스트가 편해지네?

    이번 미션에서는 테스트하기 가장 어려운 조건이 있었다. 바로 '랜덤'. 현재 읽고 있는 테스트 책에서도 테스트를 하기 위해서는 예측 불가능한 부분들을 없애라고 한다. 그렇지만, 나는 미션의 특성 상 랜덤이라는 요소를 없앨 수는 없겠구나~ 싶어 그냥 넘어갔었다.

     

    그러나, 게임의 전체적인 흐름을 관리하는 Game 의 역할이 너무 많은 것 같다는 생각이 들었다. 또한, Random 이라는 제공된 클래스를 직접적으로 의존하고 있기에 결합도가 높다는 생각이 들었다.

     

    내가 정의한 Game 클래스는 '랜덤한 숫자에 의해 운영되는 자동차 게임' 이 아니라 그냥 '숫자에 의해 운영되는 자동차 게임' 이다. 그렇기에 자동차가 움직이는 로직을 Game 이 담당해서는 안된다는 생각을 했다. 추후에 숫자를 랜덤이 아니게 뽑게 된다면 Game 에도 변경이 전파된다.

     

    이에 인터페이스로 감싸고 구현체를 만들었다.

    public interface NumberPicker {
        int pickNumberInRange(int startInclusive, int endInclusive);
    }
    
    public class RandomNumberPicker implements NumberPicker {
        @Override
        public int pickNumberInRange(int startInclusive, int endInclusive) {
            return Randoms.pickNumberInRange(startInclusive, endInclusive);
        }
    }

     

    이렇게 했더니 당연히 테스트 부분에서도 변경이 생겼다.

    문득, 생각이 들었던 것이 인터페이스를 직접 테스트 용으로만 생성해서 주입하면 되는 게 아닐까 하는 생각을 했다.

     

    @Test
    @DisplayName("뽑힌 숫자가 이동 기준 이상일 때 자동차가 움직인다.")
    void shouldMoveCarsWhenNumberExceedsMoveCriteria() {
    
        // given
        NumberPicker fixedNumberPicker = new NumberPicker() {
            @Override
            public int pickNumberInRange(int startInclusive, int endInclusive) {
                return MOVEMENT_CRITERIA;
            }
        };
    
        Game game = new Game(cars, totalRounds, fixedNumberPicker, new PositionBasedReferee());
        List<CarSnapshot> expectedResult = List.of(
                new CarSnapshot(names[0], 1, 1),
                new CarSnapshot(names[1], 1, 1),
                new CarSnapshot(names[2], 1, 1)
        );
    
        // when
        game.playNextRound();
        List<CarSnapshot> carSnapshots = game.getCarSnapshots();
    
        // then
        assertThat(carSnapshots).isEqualTo(expectedResult);
    }

    그래서 이렇게! 고정적으로 자동차가 움직일 수 있도록 하는 숫자를 반환하도록 인터페이스를 구현해 자동차의 움직임 여부를 테스트할 수 있었다.

    기존에는 자동차가 움직이는 것은 랜덤이기에 어떤 때는 실패하고, 어떤 때는 성공하는 테스트였다. 그렇기에 테스트를 작성할 수가 없었는데, 결합도를 낮췄더니 테스트의 용이성까지 따라오는 것이 신기했다!

    2. 마음대로 조립할 수가 있네?

    위에서 언급한 바와 같이, 결합도를 낮췄더니 정말 조립만 하면 되는 기계가 만들어진 기분이었다.

    Game 이라는 객체에 언제든지 RandomNumberPicker 가 아닌 이진수만 반환하는 BinaryNumberPicker 를 주입해도 아무것도 변화되지 않는다. Game 이라는 객체에게는 숫자를 뽑는 것에 대한 책임이 없고, 어떤 객체가 주입되는지도 모르기 때문이다.

    NumberPicker 라는 인터페이스를 통해 역할을 명시하고, RandomNumberPicker 나 BinaryNumberPicker 는 자신의 규칙에 따라 숫자를 뽑고 Game 은 게임을 진행하기만 하는 각자의 역할에 충실할 뿐이다.

    3. 나중에 변경되어도 괜찮네?

    둘째, 셋째날 회고에 보면 알 수 있듯 어디까지 변경, 변화를 고려해야 하는 것인지 모르겠다는 이야기를 했었다. 내가 어떻게 미래를 내다보고 설계하는데! 싶어졌다. 미래를 고려하라면서 또 지금 당장 이 상황에서 불필요한 것들은 만들지 말라고 한다. 이 두 말이 상충한다는 생각뭐야 싶기도 했다. 

     

    그런데 유연하게 .. 유연하게의 마법을 되뇌이다 보니 이 말이 이해되기 시작했다.

     

    앞선 2번에서 언급한 것과 같이 추후에 숫자를 뽑는 규칙이 달라져도 숫자를 뽑는 것과는 전혀 상관없는 나머지 객체들은 전혀 상관이 없다.

    public class GameFactory {
        public static Game create(String[] carNames, int totalRounds) {
            return new Game(new Cars(carNames), new TotalRounds(totalRounds), new RandomNumberPicker(),
                    new PositionBasedReferee());
        }
    }

    나는 팩토리 메서드 패턴을 사용해 Game 객체를 생성해주고 있다. 즉, 외부에서 주입하고 있다. 그렇기에 이 메서드에서만 슬쩍 다음과 같이 바꿔주면 된다.

    public class GameFactory {
        public static Game create(String[] carNames, int totalRounds) {
            return new Game(new Cars(carNames), new TotalRounds(totalRounds), new BinaryNumberPicker(),
                    new PositionBasedReferee());
        }
    }

    이렇게 되면, Game 의 코드는 하나도 수정하지 않은 채로 작동하는 코드를 만들 수 있다.

     

    이 숫자를 뽑는 로직뿐만 아니라, 입력값을 검증하는 객체도 인터페이스를 두어 유연하게 설계하고자 했다.

    public interface GameInputValidator {
        void validateNameOfCars(String input);
    
        void validateTotalRounds(String input);
    }
    
    public class GameFactory {
        public static Game create(String[] carNames, int totalRounds) {
            return new Game(new Cars(carNames), new TotalRounds(totalRounds), new RandomNumberPicker(),
                    new PositionBasedReferee());
        }
    }

    이렇게 설계하면, 추후에 입력값을 검증하는 로직이 통째로 바뀌어 나가도 아무런 영향이 없다. 더 복잡해지거나, 더 단순해져도 그저 구현체를 주입만 해주면 끝이다.

     

    이것이.. 변화를 고려한 설계인가 실감했다! 미래를 내다보고 전부 예측하라는 것이 아니라 유연하게, 나중에 변경이 되어도 그 범위가 정말 최소한이게끔 하면 되는구나 하는 생각을 했다.

     

    이렇게 정책적인 로직이 변경되는 것뿐만 아니라, 객체를 생성할 때 필요로 하는 파라미터마저도 변경에 용이하게 된다.

    public class Game{
        public Game(String[] nameOfCars, int totalRounds) {
                this.cars = new Cars(nameOfCars);
                this.totalRounds = new TotalRounds(totalRounds);
                this.totalRounds = totalRounds;
                this.currentRound = 1;
        }
    }

    다시 Game 객체를 생성하는 생성자로 돌아가보자. 만약 Cars 가 name 이 아니라 Engine 이라는 객체를 주입받는 것으로 변경됐다고 해보자. 그렇다면 Cars 를 생성하는 것과는 전혀 상관 없는 Game 까지도 변경이 전파된다. 

    public class Game{
        public Game(Cars cars, TotalRounds totalRounds) {
                this.cars = cars;
                this.totalRounds = totalRounds
                this.totalRounds = totalRounds;
                this.currentRound = 1;
        }
    }

    그러나 이렇게, cars 를 주입받는다면 Game 에게는 전혀 상관이 없어진다.

    진짜로 그랬다!

    public class TotalRounds {
    
        private static final int MAX_TOTAL_ROUNDS = 10;
        private static final int MIN_TOTAL_ROUNDS = 1;
        private final int totalRounds;
    
        public TotalRounds(int totalRounds) {
            validateRoundsInRange(totalRounds);
    
            this.totalRounds = totalRounds;
        }
    
        // 생략..
    }

    이전까지 이야기했던 예시들은 모두 가정에 의한 이야기들이다. 그렇다면 내가 정말로 겪은 사례는 없을까?나는 기존의 TotalRounds 라는 클래스 내부에 필드 역시도 totalRounds 로 해뒀었다. 그러나 이렇게 되니, 필드명과 클래스명이 헷갈려 가독성이 떨어진다는 생각을 했다. 그래서 roundCount 라는 필드명으로 수정했다. 캡슐화를 지키면서 변경이 전파되지 않도록 하고, 유연하게 설계를 하려다 보니 getter 를 지양하게 되어 roundCount 를 최대한 노출하지 않았다. 그렇게 되니, totalRounds 에서 roundCount 로 필드명을 바꾼 뒤에도 git 에서 변경이 감지된 클래스가 TotalRounds 하나였다. 이것이 '변경에 용이한', '유연한', '변경의 범위를 최소화하는' 설계구나 하고 실감할 수 있는 기회였다.

    4. 책임이 명확해지네?

    또 또 유연~한 설계를 해야지! 싶어 GameController 를 유심히 보는데 Game 을 생성하는 로직이 마음에 걸렸다.

    public class GameController {
        private Game createGame() {
            // 생략..
    
            Cars cars = new Cars(carNames);
            TotalRounds totalRounds = new TotalRounds(Integer.parseInt(totalRoundsInput));
            NumberPicker numberPicker = new RandomNumberPicker();
            return new Game(cars, totalRounds, numberPicker);
        }
    }

    GameController 에서 구체 클래스를 생성하고, 이를 Game 에 주입해주고 있었다. 그러나, 사실상 핵심 로직에 해당하는 controller 에서 구체 클래스를 직접 생성하고 이를 주입해준다는 것이 결국에는 유연성을 떨어뜨린다는 생각을 했다.

     

    그렇다면 GameController 를 생성할 때처럼 팩토리 메서드 패턴을 쓰자~ 하고 도입했다.

    public class GameController {
        private Game createGame() {
            // 생략..
            return GameFactory.create(names, totalRounds);
        }
    }
    
    public class GameFactory {
        public static Game create(String[] carNames, int totalRounds) {
            return new Game(new Cars(carNames), new TotalRounds(totalRounds), new RandomNumberPicker(),
                    new PositionBasedReferee());
        }
    }

    팩토리 메서드 패턴을 이용해 결합도를 낮추고 나니, 문득 역할도 적절히 분배됐다는 생각을 했다. 기존에는 GameController 에서 모델을 수정하고, 데이터를 정제하는 것 뿐만 아니라 핵심 모델인 Game 을 생성하는 역할까지 했다. 그러나 이제는 GameFactory 에게 이를 넘겨줌으로서 적절히 책임이 분배되고, GameController 가 작아졌음을 알 수 있었다.

     

    또한, 기존에는 Game 에서 우승자를 판단했었다.

    public class Game{
        public void judgeWinners() {
                List<Car> maxPositionCars = cars.getMaxPositionCars();
                this.winners = new Winners(maxPositionCars);
                this.winners = referee.judgeWinners(cars);
        }
    }

    Game 에서 숫자를 추출하는 로직을 분리하다 보니, 자연스레 우승자를 판단하는 로직에 대해서도 분리해야 하지 않을까 하는 생각을 했다.

    나중에 우승자를 판단하는 로직이 바뀌게 된다면 대응할 수 없기 때문이다. 그래서 이 역시도 Referee 라는 인터페이스를 만들어 구체 클래스를 주입 하도록 했다. 이렇게 하니, 또 다시 책임이 분리되었다는 것을 확인할 수 있었다!

     

    아직 해결 못한 부분도 있다..

    public void playNextRound() {
        for (int index = 0; index < cars.getSize(); index++) {
            int number = numberPicker.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE);
    
            if (number >= MOVEMENT_CRITERIA) {
                cars.move(index);
            }
        }
        currentRound++;
    }

    내 가장 큰 고민.. 이 부분은 여전히 결합도를 어떻게 낮출지 아무리 머리를 굴려봐도 보이지가 않는다.

    내가 원하는 것은 cars.getSize 를 없애는 것..! 최대한 getter 를 열어두고 싶지 않기도 하고, 나중에 cars 의 내부 구현이 바뀌게 되면 변경이 전파되기 때문이다.

    그렇게 되기 위해서는 반복문을 Cars 내부로 넘겨야 한다. 그러나, 그렇게 되면 숫자를 반복적으로 뽑는 로직이 Cars 로 넘어가게 되는데 내가 지금 판단할 때 이는 절대 안된다고 생각했다. 숫자를 뽑는 것은 게임의 맥락이지, 자동차들의 맥락에는 관련이 없기 때문이다.

    이 부분은 조금 더 고민하고 판단해야겠다..!


     

    사실 이 모든 사실들은 내가 몰랐던 것들이 아니다. 결합도를 낮추면 이런 장점이 있어요~ 하고 기계적으로 암기하고 있던 것들이었다.

    그렇기에 객체지향을 생각하면, 유연한 설계를 하면, 변화를 고려하면 이렇게 좋아요 하고 기계적으로 구현하고 고려했던 부분들이었다.

    그러나 더 깊이 고민하고, 더 개선점들을 찾아보다 보니 이렇게 기계적으로 암기했던 장점들을 실감하고 체감하게 되는 것이 정말 좋은 것 같다. 지난 주차의 객체지향 vs 절차지향에서도 같은 기분이었다.

    기계적으로 객체지향을 따라가려다, 다시 고민해보고 왜 객체지향을 써야 하는지 다시 고려해보며 절차지향을 사용했었다.

    이렇게 한번 명확한 절차지향을 사용해보니 어떤 점이 문제겠구나가 명확히 보이고 다른 사람들과의 코드를 비교하며 왜 우리가 객체지향을 추구해야 하는지를 체감할 수 있었다.

    어쩌면 틀린 답은 없지만 좋은 설계는 있기에 이를 따라가는 것은 좋은 것 같다. 그렇지만, 직접 자신의 판단 기준에 따라 그 당시에는 최선을 다해봤다가 점차 맞는 방향을 찾아가고 왜 이게 맞는 방향인지를 깨닫는 것이 중요하다고 생각한다. 

     

    더 많은 깨달음과 배움을 얻는 시간들이 되길 바라며! 내일은 힘내서 마무리를 잘 해야겠다.

     

Designed by Tistory.