본문 바로가기
미니 프로젝트/이노베이션 2주차

이노베이션 2주차 1 - 숫자로 만드는 야구게임의 객체지향적 설계

by 구너드 2023. 6. 17.

기존 캠프에서 팀원 2인 1조로 하여 숫자 야구 게임을 자바로 구현해보았다. 모든 메서드를 한 클래스 안에 작성함으로써 기본적으로 숫자 야구 게임을 돌아가게 만드는 원리에 대해서 익힐 수 있었다. 과제 제출 기한이 끝나고 코드를 다시 보면서 보다 간결하게, 그리고 객체지향적으로 재구성하면 어떨까라는 생각이 들었다. 어제 밤새 해본 결과물이지만 피곤해서 오늘에야 올린다는 점.


먼저 이전에는 메인 클래스 안에 숫자를 얻고, 스트라이크, 볼 판정을 내리고, 게임을 시작 및 종료하는 모든 로직을 담아두었다. 객체지향적 설계를 하기 앞서 지난번 공부한 SOLID를 떠올리며 역할과 책임을 구분짓고자 하였다. 
 
1. 게임플레이
2. 난수 생성
3. 게임 룰
 
이 세 가지를 중점적으로 기능들을 나누어 설계를 시작했다.


먼저 제일 쉬운 난수 생성부터 시작하였다. 기존에 작성했던 코드들도 보다 간결하게 보이기 위해서 가다듬는 작업을 병행했기에 생각보다 오래 걸렸다.

public interface GameNumber {
    public String makeGameNumber();

    public int getLengthNumber();
}

일단 실제 게임 숫자와 게임숫자의 길이에 해당하는 값을 얻을 수 있는 추상메서드를 가진 GameNumber 인터페이스를 생성하였다. 이제 이들을 직접 구현해주면 되는데
 

import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class BaseballGameNumber implements GameNumber {

    // 랜덤한 넘버 길이가 바뀔 수 있는 상황과, 다른 외부적인 개입에서 
    // 해당 멤버를 보호하는 것을 고려하여  private 사용
    private static final int RandomNumberLength = 3;

    @Override
    public int getLengthNumber() {
        return RandomNumberLength;
    }

    @Override
    public String makeGameNumber() {
        return Stream.generate(() -> new Random().nextInt(10))
                .distinct()
                .limit(RandomNumberLength)
                .map(String::valueOf)
                .collect(Collectors.joining());
    }
}

주석을 작성한 내용을 보면 확인할 수 있듯이 전체숫자의 길이는 private static final로 설정하고 따로 빼내어 다른 외부적인 요소들이 건드릴 수 없게끔 설정하였다. 이후 기존의 for문을 사용하여 얻었던 랜덤한 게임숫자를 스트림과 람다식을 활용하여 가독성을 높였다.


public interface GameType {
    void play();
}

두 번째는 게임타입 인터페이스를 구현했다. 사실 직접 만드는 것은 BaseballGame이지만 상황에 따라서 숫자를 맞추고 이러한 로직을 통해 실행할 수 있는 다른 종목의 게임을 만들 수도 있다는 가정하에 GameType 인터페이스의 필요성을 느꼈다. 그게 무슨 게임이 됐던간에 GameType의 play만 구현한다면 실행할 수 있도록 말이다. 이러한 과정에서 최대한 역할과 책임을 나누어 설계를 하려고 노력했다.
 

import java.util.Scanner;

public class BaseballGame implements GameType {

    GameNumber gameNumber;
    GameLogic gameLogic;

    public BaseballGame(GameNumber gameNumber, GameLogic gameLogic) {
        this.gameNumber = gameNumber;
        this.gameLogic = gameLogic;
    }

    @Override
    public void play() {

        Scanner input = new Scanner(System.in);

        String randomNumber = gameNumber.makeGameNumber();
        System.out.println("컴퓨터가 숫자를 생성하였습니다. 답을 맞춰보세요!");

        int count = 1;

        while (true) {

            System.out.print(count + "번째 시도 : ");
            String inputNumber = input.nextLine();

            int[] result = gameLogic.getRule(inputNumber, randomNumber);
            String resultStr = gameLogic.getResult(result);

            if (result[0] == 3) {
                System.out.println(resultStr);
                System.out.println(count +" 번만에 맞히셨습니다.");
                System.out.println("게임을 종료합니다");
                break;
            }
            System.out.println(resultStr);
            count++;
        }
    }

}

play 메서드의 내용은 크게 어렵지 않다. 세 번 맞추면 게임을 종료하고 이 횟수를 반환하는 단순한 논리이다. 중요한 것은 확장에는 열려있지만, 변경에는 닫혀있는 OCP와 모듈이 구현체가 아닌 추상체에 의존해야하는 DIP를 지키는 것이다. 이러한 부분은 후술하겠지만 GameConfig라는 설정자 클래스를 따로하나 만들어두었다. 그 전에 작성 전 부터 고민이 많았던 GameLogic으로 넘어가보자.


public interface GameLogic {
    public int[] getRule(String str1, String str2);
    public String getResult(int[] intArr);
}

게임의 룰을 얻는 메서드와 게임 과정을 나타내는 메서드를 추상메서드로 구현한 GameLogic 인터페이스이다. 
 

public class BaseballGameLogic1 implements GameLogic {
    //RandomNumberLength와 마찬가지
    private int[] result = new int[2];
    private int strike = 0;
    private int ball = 0;

    private GameNumber gameNumber;

    public BaseballGameLogic1(GameNumber gameNumber) {
        this.gameNumber = gameNumber;
    }

    @Override
    public int[] getRule (String inputNumber, String randomNumber) {

        for (int i = 0; i < gameNumber.getLengthNumber(); i++) {
            if (inputNumber.charAt(i) == randomNumber.charAt(i)) {
                strike++;
            } else {
                if (randomNumber.indexOf(inputNumber.charAt(i)) >= 0) {
                    ball++;
                }
            }
        }
        result[0] = strike;
        result[1] = ball;

        return result;
    }


    @Override
    public String getResult(int[] result){

        String resultStr = null;

        if (result[0] == 0 && result[1] > 0 ){ return resultStr = result[1] + "B"; }
        if (result[0] > 0 && result[1] == 0){ return resultStr = result[0] + "S"; }

        return resultStr = result[1] + "B"+result[0] + "S";
    }
}

꽤 까다로워보이는 게임로직이지만 자세히 들여다보면 그렇지 않다. 이번 기회는 구체적인 로직 설명보다는 객체지향에 초점을 맞추었기에 자세한 로직설명은 생략하겟다. 다만 오버라이딩된 getResult메서드를 재구성하였는데 기존의 if elseif else 문에서 단순히 if문을 작성하여 작은 차이이지만 속도를 높일 수 있게끔 수정하였다.
 
본인이 고민한 부부은 게임로직과 게임플레이를 나누어야 할까? 라는 부분이었다. 단일 책임원칙에 따르면 클래스는 한 가지 책임만을 가지고 있어야한다. 다만 이 게임로직을 생성하는 것은 꽤 복잡하고 이 부분을 게임플레이 혼자서 가져가기엔 변경의 여지가 높다고 판단했다. 또 기존의 BaseballGameLogic에서 다른 Logic을 구성하여 새롭게 게임을 확장할 수도 있으므로 이러한 상황을 염두에 두고 GameLogic을 따로 클래스로 빼두었다. 


각각의 구현클래스들을 보면 인터페이스를 마치 변수처럼 선언한 모습들을 확인할 수 있다. 원래라면 해당 인터페이스의 실제적인 객체를 클래스안에서 생성하고 그 객체의 기능을 참조변수를 이용하여 객체지향을 구현하려고 노력했을 것이다. 하지만 이는 추상체와 구현체에 모두 의존하게 되어 DIP를 깨뜨리게 되고, 새로운 Logic, 또는 새로운 Number 클래스를 생성하게 될 경우 해당 클래스의 코드를 수정할 수 밖에 없다. 그리고 이러한 점은 OCP도 깨뜨리게 된다. 이러한 문제점을 보완하고자 의존성을 주입, 즉 객체를 해당 클래스에 대신 넣어주는 설정자 클래스를 생성하였다.
 

public class GameConfig {

    // OCP와 DIP를 지키기 위해 만든 설정자.
    // 각 클래스들이 구현체가 아닌 추상체에 의존할 수 있게끔 도와주기에 DIP를 지킬 수있고,
    // BaseballGameLogic1을 2로 바꿀시, 이 부분만 수정하면 되기에 OCP를 지킬 수 있다.
    // 이러한 점은 유지보수에 용이성 제공

    public GameNumber gameNumber() {
        return new BaseballGameNumber();
    }

    public GameLogic gameLogic() {
        return new BaseballGameLogic1(gameNumber());
    }

    public GameType gameType () {
        return new BaseballGame(gameNumber(), gameLogic());
    }
}

이 클래스를 통해 추상체에 의존하는 각 클래스들은 의존하는 구현체를 외부 클래스에 의해 주입 받을 수 있게 되어 추상체에 의존할 수 있게 된다.  뿐만 아니라 게임의 로직 변경, 게임 종류변경, 게임 난수 생성 방식 변경들도 해당 클래스를 생성하고 의존관계를 이 부분에서만 바꾸면 되기에 유지보수에도 용이하다고 할 수 있다.


게임로직을 게임플레이 안으로 넣을지 밖으로 뺄지가 많이 고민되었던 미니프로젝트였다. 다만 최대한 책임들을 분산시키기 위해서 나는 밖으로 빼두는 선택을 했고, 이는 괜찮은 선택이라고 생각한다. 객체지향을 실제로 설계하면서 굉장히 재밌었고, 보다 복잡한 코드들을 어떻게 하면 객체지향으로 바꿀 수 있을까하는 걱정, 기대들을 동시에 가질 수 있는 시간이었던 것 같다.
 
해당 코드는 아래의 링크에서
https://github.com/Goonerd17/Baseballoop