[REAL Java] – “야구 게임으로 알아보는 프로그래밍 패러다임의 역사”
[REAL Java] – “야구 게임으로 알아보는 프로그래밍 패러다임의 역사”
문제 정의: 숫자 야구 게임 (bulls and cows)
이번 포스트에서, 우리는 과거의 초창기 컴퓨터부터 객체 지향 프로그래밍까지 어떻게 프로그래밍 패러다임이 나타났고 발전했는지에 대해서 알아볼 것입니다. 글로만 주절주절하기 보다는, 실제로 현실 세계의 문제를 정의하고 그것을 각 패러다임으로 풀어나가는 방식으로 해결해 봅시다.
여러분은 숫자 야구라는 것을 아시나요? 영어로는 bulls and cows 라고 알려져 있는데, 간단한 숫자 맞추기 게임입니다.
- “숫자를 맞추는 사람”, “문제를 내는 사람” 이 정해져 있습니다.
- “문제를 내는 사람” 은 먼저 1부터 9까지의 중복되지 않는 세 자리 수의 숫자를 생각합니다.
- “문제를 맞추는 사람” 은 “문제를 내는 사람” 이 생각한 숫자를 예상한 뒤 맞춥니다.
- “문제를 내는 사람” 은 숫자를 확인한 뒤, “문제를 내는 사람” 에게 아래의 규칙에 따라 힌트를 줍니다.
- 같은 수가 같은 자리에 있으면, 스트라이크!
- 같은 수지만, 다른 자리에 있으면, 볼!
- 같은 수가 전혀 없다면, 아웃!
이 간단한 프로그램을 구현해 봅시다. 문제를 내는 사람은 컴퓨터가, 문제를 맞추는 사람은 프로그램 사용자가 그 역할을 할 것입니다. 이를 구현하기 위해서, JAVA 라는 프로그래밍 언어를 사용할 것입니다. 결국 우리의 목적은, JAVA 라는 프로그래밍 언어를 활용해서 위와 같은 조건을 만족하는 게임 소프트웨어를 만드는 것이네요.
소프트웨어를 개발하는 사람들, 프로그래머
개발자, 프로그래머라고 불리는 사람들의 역할은 소프트웨어를 개발하는 것입니다. 아무리 많은 수의 연산을 처리할 수 있는 CPU도, 수백 테라바이트의 자료를 저장할 수 있는 하드 디스크도, 수많은 픽셀을 그려낼 수 있는 훌륭한 성능의 그래픽 카드도 소프트웨어가 없다면 빈 껍데기일 뿐입니다.
소프트웨어는 이런 문제를 해결합니다. 현실 세계의 문제를 하드웨어가 알아먹을 수 있는 명령어로 바꿔 주고, 하드웨어가 그 역할을 제대로 수행할 수 있게끔 해 주죠.
그리고 세상에 공짜는 없습니다. 이러한 역할을 하는 소프트웨어는 하늘에서 번쩍 하고 떨어진 게 아니죠. 소프트웨어는 사람이 만든 것입니다. 그리고, 우리는 그러한 역할을 하는 사람을 프로그래머, 개발자라 부릅니다.
초창기의 컴퓨터와 폰 노이만 아키텍처
그렇다면, 소프트웨어라는 것은 어떻게 기계와 소통할 수 있는 걸까요? 그것을 알기 위해서는, 우리가 생각하는 “컴퓨터” 라는 것이 어떻게 이루어져 있는지에 대해 간략히 알아야 합니다.
ENIAC 과 Colossus 와 같은 초기의 컴퓨터들은 프로그래밍을 하기 위해서는 하드웨어를 물리적으로 직접 조작해야 했습니다. 그렇기 때문에, 컴퓨터를 이용해 다른 목적을 달성하기 위해서는 수천 개의 케이블, 플러그들을 수동으로 배치해야 했고, 이는 너무나 지루한 과정이었죠.
이를 안타깝게 여기던 폰 노이만이라는 경제학자, 컴퓨터과학자, 수학자와 에니악 개발팀은 새로운 구조의 컴퓨팅 기계를 고안해냅니다. 이를 “폰 노이만 아키텍처” 라고 부릅니다. 중앙처리장치, 메모리, 프로그램의 요소로 이루어진 폰 노이만 아키텍쳐는 현재 거의 모든 컴퓨터들이 따르는 구조가 되었습니다.
폰 노이만 아키텍처 이후, 이를 따르는 컴퓨터들은 “프로그램을 저장” 하고, 프로그램을 실행하면 “명령어들을 메모리에 저장” 하고, CPU 는 “해당 명령어들을 처리” 할 수 있게 되었습니다.
이 그림, 기억하시죠? 조금 더 정확히 표현해 볼까요.
아, 그렇다면 우리가 이 소제목의 맨 처음에 가졌던 의문인 “소프트웨어는 어떻게 기계와 소통하는가?” 가 조금씩 해결되는 것 같습니다. 결국 “중앙처리장치는 어떤 명령을 알아듣는가?” 를 알면 되겠어요.
가장 낮은 수준의 프로그래밍 패러다임, 기계어
CPU 는 메모리에 적재된 명령어들을 읽어옵니다. 그리고 메모리는 1비트 단위를 저장할 수 있는 메모리 셀이라는 것으로 이뤄져 있죠. 그렇다면 알 것 같지 않나요? CPU 는 결국 비트 단위의 명령어만 읽을 수 있다는 거네요.
폰 노이만 구조 이후의 초창기 컴퓨터가 알아들을 수 있는 언어는 비트 단위(흔히 “컴퓨터는 0과 1만 알아들을 수 있어요!” 로 설명됩니다.) 의 명령어 뿐이었습니다. 그렇기에 사람이 컴퓨터에게 5+1 을 계산하라고 하기 위해서는, 그에 맞는 기계어 코드를 직접 컴퓨터에게 던져줘야 했죠.
또, CPU 제조사들마다, CPU 아키텍처마다 다른 명령어 집합을 가지고 있기 때문에 “이 CPU 에서는 되는데 저 CPU 에서는 안 되는” 프로그램이 만들어질 수 있었습니다. 게다가 2진수로 표현되었기 때문에 사람 입장에서는 가독성이 최악이었습니다. 기계어를 직접 입력해서 하는 프로그래밍, 여러분은 상상이 가시나요?
이에, 조금 더 쉽게 읽을 수 있는 기계어인 어셈블리어가 만들어집니다. 어셈블리어는 기계어와 일대일 대응되는데, 사람이 읽기 어렵고 외우기도 어려운 기계어에 라벨을 붙여 읽기 쉽게 만든 것이죠. 프로그래머가 어셈블리 언어로 프로그램을 작성하면, 어셈블러라는 도구가 그것을 기계어로 변환해 주었습니다.
..하지만 여전히 프로그래머가 쓰기에는 많이 복잡했습니다. 같은 목적을 달성하기 위한 프로그램이더라도, CPU 의 명령어 집합이 다르면 그에 맞는 어셈블리 코드를 따로 작성해야 했기 때문입니다.
명령을 순차적으로, 절차적 프로그래밍 언어의 등장
“프로그래밍을 하는 사람이, 하드웨어와 밀접하게 연결되어 있다” 는 여전히 어셈블리를 이용해 프로그래밍하는 사람들의 고민거리였습니다. “숫자 야구 게임” 을 만들기 위해서 “이 제조사에서 만든 CPU 전용 게임”, “저 제조사에서 만든 CPU 전용 게임” 을 따로 만들어야 한다니요. 참 골칫거리였죠.
COBOL, FORTRAN, C
와 같은 3세대 프로그래밍 언어들은 이러한 문제를 해결했습니다. 더 높은 수준의 추상화를 도입함으로서, 프로그래머가 CPU 아키텍쳐와 같은 하드웨어에 대한 세부사항을 더더욱 신경쓰지 않고 개발할 수 있도록 만들어준 겁니다.
프로그래밍 언어가 할 수 있는 것들이 많아지고, 기능이 강력해짐에 따라 프로그래머들은 어셈블리 시절의 코딩 스타일이 가지는 문제점을 깨닫기 시작했습니다.
그건 별로야, 구조적 프로그래밍 패러다임의 등장
구조적 프로그래밍(structured programming)은 구조화 프로그래밍으로도 불리며 프로그래밍 패러다임의 일종인 절차적 프로그래밍의 하위 개념으로 볼 수 있다. GOTO문을 없애거나 GOTO문에 대한 의존성을 줄여주는 것으로 가장 유명하다.
…프로그램의 논리 구조는 제한된 몇 가지 방법만을 이용하여 비슷한 서브 프로그램들로 구성된다. 프로그램에 있는 각각의 구조와 그 사이의 관계를 이해하면 프로그램 전체를 이해해야 하는 수고를 덜 수 있어, SoC에 유리하다.
https://ko.wikipedia.org/wiki/%EA%B5%AC%EC%A1%B0%EC%A0%81_%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D
그럼.. 위에서 말한 어셈블리 시절의 코딩 스타일이 대체 무엇을 말하는 걸까요?
section .data
hello db 'Hello, World!',0
section .text
global _start
exit:
; Exit the program
mov eax, 1 ; syscall number for sys_exit
mov ebx, 0 ; Return code (0 for success)
int 0x80 ; Call kernel
_start:
; Print "Hello, World!" to stdout (File Descriptor 1)
mov eax, 4 ; syscall number for sys_write
mov ebx, 1 ; File Descriptor 1 (stdout)
mov ecx, hello ; Pointer to the string to be printed
mov edx, 13 ; Length of the string
int 0x80 ; Call kernel
; Unconditional jump to exit
jmp exit
위는 “Hello, World!” 를 출력하는 x86 어셈블리 프로그램입니다. 맨 아래 라인의 JMP
명령이 보이시나요? JMP
명령은 프로그램의 제어 흐름을 무조건 점프하는 명령어입니다. 이처럼 어셈블리로 이루어졌던 프로그램들에는 위와 같은 명령들이 매우 많이 존재했죠. C 와 같은 3세대 프로그래밍 언어에서도 위와 같은 요소는 여전히 사용되었습니다.
#include <stdio.h>
int main() {
int i = 1;
start:
printf("%d ", i);
i++;
goto start;
printf("\n");
return 0;
}
위와 같은 goto
의 사용은 흔히 안티패턴으로 간주됩니다. 간단한 예제이기에 실행 결과를 파악하는 것이 어렵지 않을 수 있지만, goto
의 사용이 점점 늘어남에 따라 프로그램을 파악하기 굉장히 어려워집니다.
Edgar Dijkstra: Go To Statement Considered Harmful
https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf
에츠허르 다익스트라라는 컴퓨터 과학자는 Communications of ACM 이라는 월간 저널의 편집자에게 보낸 “Go To Statement Considered Harmful” 라는 편지로 goto
의 과도한 사용에 대한 문제를 제기하고, 구조적 프로그래밍 방식을 제안했습니다. 그의 편지 내용을 간단히 설명해보자면 아래와 같습니다.
프로그램을 작성하는 프로그래머, 그것을 프로세스로 만드는 기계
위에서 언급한 내용이지만, 당연하게도 프로그래머는 프로그램을 작성합니다. 프로그래머로서 가지는 역할은 그것이죠. 프로그램을 작성한다는 것은 그 재료가 기계어가 되었든, 어셈블리가 되었든, Python
이 되었든 컴퓨터가 알아들을 수 있는 방식으로 현실 세계의 문제를 해결하라고 명령한다는 것을 의미합니다.
그렇기에, 작성된 프로그램을 프로세스로 만드는 것은 기계의 책임입니다. 문법적으로 완전한 Python
프로그램을 제대로 프로세스로 만들고, 실행시키는 것은 기계의 책임으로 위임한다는 것이죠.
정적인 프로그램과 동적인 프로세스
우리가 텍스트로 작성하는 프로그램은 정적입니다. 하지만 실제 실행되는 프로세스는 시간의 흐름 안에서 수행되죠. 사람의 지적 능력이 동적 관계(시간에 따라 만들어지는)보다 정적 관계를 파악하는 데에 더 적합함에 따라, 유지보수에 용이한 프로그램을 만들기 위해서 프로그래머는 프로그램-프로세스의 개념적 격차를 최대한 줄여야 합니다. 그리고 이 부분에서 goto
가 어째서 해로운지에 대한 이유가 등장합니다.
goto
의 해로움
아래의 코드를 읽어 보세요. c 로 되어 있는 프로그램이지만, 쉬울 것이라 장담합니다.
#include <stdio.h>
int main() {
int i = 0;
int j = 1; // 💡
return 0;
}
이 코드에서, 프로세스가 💡 가 있는 줄까지 도달했다고 가정해 봅시다. 여러분은 현재 💡가 있는 줄이 완벽히 현재의 코드 진행을 나타낸다고 확신하실 수 있을 것입니다. 코드 안에서, 💡 의 위치와 시간상의 흐름이 완벽히 일치하기 때문입니다. 정적인 프로그램, 그리고 실제 프로세스의 흐름이 동일하다는 것입니다.
#include <stdio.h>
int main() {
int i = 1;
start:
printf("%d ", i); // 💡... 몇 번째 맞닥뜨리는 if 일까? 현재 시점 i 의 값은 무엇일까?
i++;
goto start;
printf("\n");
return 0;
}
그럼, 안티패턴이라고 소개되었던 위의 코드를 볼까요? 마찬가지로 현재 프로세스는 💡 가 있는 곳에 도달해 있습니다. 여러분은 현재 💡 의 위치에서 프로세스가 어떤 상황으로 돌아가고 있는지 설명할 수 있으신가요?
goto
의 사용으로 인해 무조건 점프를 수행하므로, 위의 💡 표시만으로는 이 프로그램이 if
문을 거친 상태인지, 아니면 가장 첫 번째로 맞닥뜨린 printf()
의 호출인지 매우 불명확합니다. 위의 벌브가 조금 더 많은 정보를 가질 필요가 있습니다.
#include <stdio.h>
int main() {
int i = 1;
start:
printf("%d ", i); // 💡 지금까지 수행한 줄: n줄
i++;
goto start;
printf("\n");
return 0;
}
맞아요, 위처럼 “현재까지 진행한 줄 수가 몇 줄인가”(카운터) 를 나타내면 이제서야 벌브로 현재 프로그램의 진행 상황을 도장 찍듯이 나타낼 수 있습니다. 벌브가 “현재 몇 개의 명령을 수행했는지, 현재 프로세스는 어디에 도달했는지” 에 대한 정보를 가지고 있기 때문입니다.
하지만, 위의 정보가 있더라도 프로그래머의 입장에서 현재 프로그램의 상태를 이해하는 것은 매우 어렵습니다. “현재 n줄을 실행했다” 와 같은 구체적이지 못한 정보로 현재 i
의 값을 알아내기 위해서는 프로그래머가 직접 프로그램을 한 줄 한 줄 해석해나가고, 그에 맞추어 변수의 값을 계산해야 하기 때문입니다. 프로그램을 해석하거나 만들고, 수정하는 데 있어 변수를 이해하는 것이 중요하다는 것이 참인 이상, 위의 코드는 해석하기도, 수정하기도 어려운 코드가 되어버린다는 겁니다.
구조적 프로그래밍 패러다임의 이점
구조적 프로그래밍 패러다임을 사용하면, 코드는 아래와 같이 변합니다.
#include <stdio.h>
int main() {
int i = 1;
while (i <= 10) {
printf("%d ", i);
i++;
}
printf("\n");
return 0;
}
이제 우리는 위의 코드를 보고서 프로그램의 구조를 대략 파악하기 훨씬 쉬워졌습니다. while
만 보고도 “아, 최종적으로 변수 i
의 값은 무엇이 되겠구나~” 를 알 수 있죠. while
이라는 친구 덕분에, 함수가 끝나는 return
시점에 있어서 i
의 값을 프로그래머가 한 줄 한 줄 소스코드를 머릿속으로 실행시켜 보지 않아도 알 수 있다는 것입니다.
구조적 프로그래밍 방식으로 구현된 숫자 야구 게임
새로 배운 구조적 프로그래밍 접근 방식을 이용해 숫자 야구 게임을 구현해 봅시다. 아무래도 위의 프로그램은 너무 간단하죠?
구현할 프로그램의 구조 파악하기
우리가 위에서 정의한 문제에 따르면, 프로그램의 흐름은 아래와 같으면 되겠네요.
이 흐름을 구현하기 위해서, JAVA
라는 프로그래밍 언어에는 while, for
과 같은 기능들이 이미 준비되어 있습니다. 이들을 적절하게 활용하면 되겠습니다.
package baseball;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.Scanner;
public class BaseballGame {
public static void main(String[] args) {
System.out.println("숫자 야구 게임을 시작합니다.");
int isPlaying = 1;
while (isPlaying == 1) {
List<Integer> randomNumbers = new ArrayList<>();
while (randomNumbers.size() < 3) {
int randomNumber = new Random().nextInt(9) + 1;
if (!randomNumbers.contains(randomNumber)) {
randomNumbers.add(randomNumber);
}
}
System.out.println("정답: " + randomNumbers);
while (true) {
System.out.print("숫자를 입력해주세요 : ");
Integer userNumber = new Scanner(System.in).nextInt();
if (
userNumber < 100
|| userNumber > 999
|| userNumber.toString().length() != 3
|| userNumber.toString().contains("0")
|| userNumber.toString().chars().distinct().count() != 3
) {
throw new IllegalArgumentException();
}
int strike = 0;
int ball = 0;
for (int i = 0; i < 3; i++) {
int userDigit = Integer.parseInt(userNumber.toString().substring(i, i + 1));
if (randomNumbers.get(i) == userDigit) {
strike++;
} else if (randomNumbers.contains(userDigit)) {
ball++;
}
}
if (strike == 3) {
System.out.println("3 스트라이크");
break;
} else if (strike > 0 || ball > 0) {
System.out.println(strike + " 스트라이크, " + ball + " 볼");
} else {
System.out.println("아웃");
}
}
System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
isPlaying = new Scanner(System.in).nextInt();
if (isPlaying != 1 && isPlaying != 2) {
throw new IllegalArgumentException();
}
}
}
}
대략적인 흐름은 아래와 같습니다.
맞아요, 뭔가 “이건 아닌데..” 라는 느낌이 오실 거라고 생각합니다. 아무리 코드가 여러 좋은 기능들에 의해 구조화되었다고 하더라도, 위의 코드는 읽기에 상당한 시간이 소요될 것만 같아 보입니다.
괜찮습니다. 우리는 재사용 가능한 코드 블럭인 함수라는 걸 알고 있거든요.
위의 숫자 야구 구현에서는 각각의 코드 조각들을 메서드로 분리하여 작성했습니다. main
메서드는 프로그램의 흐름만 신경쓰고, 나머지 부분들인 게임을 다시 시작할 것인지 묻는다던지, 게임을 하기 위한 세 자리 숫자를 생성한다든지 등의 역할들을 다른 메서드들에게 위임했죠.
코드는 꽤 좋아 보입니다. 몇 가지 시나리오를 생각해보기 전까지는요..
객체지향 프로그래밍 패러다임
주의: 필자는 객체지향 프로그램에 대한 전문가가 아닙니다. 따라서, 예시로 존재하는 코드는 정확히 객체지향 프로그래밍 패러다임을 따랐다고 보기 어려울 수 있습니다. 왜 기존 코드에 문제가 있었는지, 기존 코드를 객체지향을 사용하여 어떻게, 그리고 왜 리팩토링했는지에 대한 제 의견 을 서술하게 되는 파트입니다.
Blog-Driven-Development
를 하지 마세요.
위의 숫자 야구 게임을 판매하여 인디 게임 개발자로서 쏠쏠한 수익도 얻고, 유명세를 타게 된 저는 어느 순간부터 사용자들이 내뱉는 불평불만이 많아짐을 깨닫게 됩니다. 게임이 너무 쉽다는 것이었죠. 컨텐츠의 고갈이었습니다.
그래서 저는 패치를 하기로 결심합니다. 맞춰야 하는 숫자 수도 늘리고, 게임 시작 후 숫자를 입력해야 하는 제한시간도 추가하고, 숫자를 맞출 때마다 점수가 늘어나게 하는 시스템도 개발하게 됩니다. 그리고, 어느 순간 저는 맛깔나게 꼬인 스파게티 코드를 맞닥뜨리게 되죠.
실제 코드를 살펴봅시다. 새로 제가 하고 싶었던 것은 “3자리이던 숫자 게임을, 5자리짜리로 바꿔라!” 였습니다. 그렇다면 위의 코드에서는 세 메서드의 수정이 이뤄져야 합니다. 난수를 생성하는 방식, 사용자 입력의 유효성을 검증하는 방식, 스트라이크/볼 등 결과를 출력하는 방식을 모두 바꿔야 원하던 대로 동작이 이뤄지죠.
지금에야 코드베이스가 비교적 간단하고 논리도 간단하지만 – Application.java
코드가 5000줄짜리라고 생각해 보세요. 그 상황에서 “어.. 다시 숫자를 5자리로 바꿔야 할 것 같은데? 사용자들이 이건 야구의 근본이 아니래..ㅠ” 라는 요구사항이 들어온다면 개발하는 입장에서 끔찍한 시나리오일 것입니다.
이처럼, 객체지향 프로그래밍 패러다임은 기존 프로그래밍 패러다임이 가지던 한계를 보완하기 위해 나타나게 되었습니다. 규모가 있는 어플리케이션에서 복잡성을 낮추고, 만들어 뒀던 코드를 재사용하고, 말로도 표현하기 복잡한 현실 세계의 문제를 모델링하는 것을 쉽게 하는 등 기존의 문제를 해결하고 복잡성 높은 프로젝트의 개발과 유지&보수를 쉽게 다룰 수 있도록 한 것이죠.
숫자 야구 게임에서 객체지향을 설계해 보자
저는 객체지향 프로그래밍 패러다임에 대한 큰 경험이 없습니다. 큰 프로젝트를 객체지향 방식으로 리팩토링한다거나, 프로그램 설계부터 객체지향을 위해 노력하고, 피드백을 받는 등의 경험이 부족합니다. 하지만 객체지향에서 이루고자 하는 바가 “역할, 책임, 협력” 이라는 것은 알고 있습니다. (feat. 객체지향의 사실과 오해)
그러한 관점에서, 우리는 위에서 정의했던 문제를 더더욱 확실하게 정의해야 합니다. 문제를 확실하게 정해 두고, 그 문제를 해결하기 위한 – 서로 협력하는 객체들을 설계해 내야 하기 때문입니다. “늦잠을 잔 아침, 나는 회사에 출근해야 한다” 라는 문제를 해결하기 위해 “택시 기사 객체”, “택시 객체” 등이 필요한 것처럼요.
문제의 확실한 정의
기존에는 문제를 내는 것이 “사람” 이었죠. 우리는 이제 컴퓨터가 그것을 수행한다는 것을 압니다.
- “컴퓨터 게임” 은 먼저 1부터 9까지의 중복되지 않는 세 자리 수의 숫자를 생각합니다.
- “컴퓨터 게임” 은 유효한 숫자를 생각해내야 할 책임이 있습니다.
- “문제를 맞추는 사람” 은 “컴퓨터 게임” 이 생각한 숫자를 예상한 뒤 맞춥니다.
- “컴퓨터 게임” 은 사용자로부터 입력받은 숫자가 유효한 숫자인지 검증할 책임이 있습니다.
- “컴퓨터 게임” 은 사용자가 숫자를 맞추면, 게임을 끝낼지&재시작할지 묻고 흐름을 통제할 책임이 있습니다.
- “컴퓨터 게임” 은 숫자를 확인한 뒤, “문제를 내는 사람” 에게 숫자 야구의 규칙에 따라 힌트를 줍니다.
- “컴퓨터 게임” 은 사용자로부터 입력받은 숫자를 비교하고, 제대로 된 결과를 보여줄 책임이 있습니다.
그림으로 나타내본다면.. 아래와 같겠죠.
이와 같은 설계로 프로그램을 구현할 수 있겠지만, 저는 제 프로그램이 조금 더 객채애(愛) 넘치는 세계이길 바랍니다. 컴퓨터 객체 하나가 위의 모든 책임을 짊어지게 된다면 결국 하나의 클래스에서 모든 작업이 이루어지던 전의 상황과 크게 다를 것이 없어지기 때문입니다.
컴퓨터 게임 객체여, 책임을 내려놓으라.
저는 “좋은 객체지향 설계란 무엇인가?” 에 대한 정의를 쉽사리 하지 못합니다. 흔히
SOLID
라고 불리는 원칙들을 소개하고 싶지만, 글의 저자 객체로서 신뢰성 있고 유용한 예시를 들 책임을 수행하는 것이 매우 어렵고, 경험이 부족한 사람으로서 힘들다는 것을 인정하기에 “단일 책임 원칙은 무엇이고, 이것을 적용하면 이렇게 됩니다” 와 같은 예시를 들기보다는 “왜 이 코드가 이렇게 변하는지” 에 대한 제 생각을 서술하는 방식으로 글을 이어나가겠습니다.
이전에 저는 “택시 기사” 객체를 예시로 들었습니다! 택시 기사는 손님을 택시에 태워 목적지까지 안전하게 운행할 책임이 있습니다. 하지만 택시의 연료가 떨어졌을 때, 택시에 연료를 주유하는 것은 주유소 사장님의 책임이지 택시기사의 책임이 아닙니다. 컴퓨터 게임의 책임을 덜어주기 위해서, 몇 가지 다른 객체들을 설계해 봅시다.
택시기사를 도와줄 수 있는 신호등, 주유소 직원, 손님, 포스기, 신용카드 객체처럼 컴퓨터 게임을 도와줄 수 있는 여러가지 객체들을 위와 같이 설계할 수 있습니다. 각각의 객체들이 어떤 것을 해야 하는지 알고 있으므로, 우리는 그것을 구현할 수 있습니다.
객체들이여, 존재하라
JAVA
에서 이러한 객체를 만드는 좋은 방법은 클래스를 작성하는 것입니다. 저만의 방식대로 각각의 클래스를 작성하겠습니다.
package baseball;
import java.util.List;
public class Game {
public void startGame() {
Rule rule = new Rule(100, 999, 3, 1, 2);
System.out.println("숫자 야구 게임을 시작합니다.");
boolean isPlaying = true;
while (isPlaying) {
playGame(rule);
isPlaying = askToPlayAgain(rule);
}
System.out.println("게임 종료");
}
/**
* 사용자에게 게임을 다시 시작할 것인지 물어봅니다.
*
* @return 사용자가 게임을 다시 시작하려면 true, 종료하려면 false
*/
private boolean askToPlayAgain(Rule rule) {
System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
int userChoice = InputHandler.getUserGameChoiceInput(rule);
return userChoice == 1;
}
/**
* 숫자 야구 게임을 진행합니다. Rule 객체를 통해 NumberGenerator 객체를 생성하고, NumberGenerator 객체가 랜덤한 숫자를 생성합니다. 사용자로부터 입력을 받아 Referee
* 객체를 통해 결과를 계산하고, DisplayManager 객체를 통해 결과를 출력합니다. 사용자가 3 스트라이크를 달성할 때까지 반복합니다.
*
* @param rule 숫자 야구 게임의 규칙을 담고 있는 Rule 객체
*/
private void playGame(Rule rule) {
NumberGenerator numberGenerator = new NumberGenerator(rule);
List<Integer> randomNumbers = numberGenerator.generateRandomNumbers();
Referee referee = new Referee(rule);
while (true) {
int userNumber = InputHandler.getUserGameNumberInput(rule);
int[] result = referee.compareNumbers(randomNumbers, userNumber);
DisplayManager.printResult(result);
if (result[0] == rule.getNumberOfDigits()) {
break;
}
}
}
}
간단하게, 규칙을 주입받은 후 규칙에 따라서 게임을 시작하고 끝내는 역할을 하게 됩니다. 확실히 예전에 가지던 책임감들이 많이 줄어든 것을 볼 수 있죠? 규칙은 Rule 이라는 친구가 가지고 있습니다. 게임 클래스는 규칙을 받아 “이 규칙으로 게임 진행시켜!” 를 명령하면 됩니다.
package baseball;
/**
* 심판 클래스로부터 결과를 받아서 출력하는 클래스입니다.
*/
public class DisplayManager {
public static void printResult(int[] result) {
int ball = result[1];
int strike = result[0];
if (strike == 3) {
System.out.println("3스트라이크");
} else if (ball > 0 || strike > 0) {
System.out.println(ball + "볼 " + strike + "스트라이크");
} else {
System.out.println("낫싱");
}
}
}
심판이 알려준 결과를 전광판에 어떻게 내보낼지를 고민하는 역할을 하는 클래스입니다. 맞춘 숫자가 아무것도 없을 때, “아웃” 을 외치는지, “낫싱” 을 외치는지는 이 친구가 고민할 문젭니다.
package baseball;
import camp.nextstep.edu.missionutils.Console;
public class InputHandler {
private final Rule rule;
public InputHandler(Rule rule) {
this.rule = rule;
}
public static int getUserGameNumberInput(Rule rule) {
int userNumber;
System.out.print("숫자를 입력해주세요: ");
userNumber = Integer.parseInt(Console.readLine());
if (!rule.isValidNumber(userNumber)) {
throw new IllegalArgumentException();
}
return userNumber;
}
public static int getUserGameChoiceInput(Rule rule) {
int userChoice;
System.out.print("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요: ");
userChoice = Integer.parseInt(Console.readLine());
if (!rule.isValidChoice(userChoice)) {
throw new IllegalArgumentException();
}
return userChoice;
}
}
이 친구는 게임장에서 손님을 응대합니다. 규칙에 따라서 손님이 부정한 행위를 하거나, 게임을 다시 시작하고 싶다는 의사를 밝히거나, 게임을 종료하고 싶다는 의사를 밝힌다거나 하는 경우를 판단해 적절하게 처리합니다.
package baseball;
import camp.nextstep.edu.missionutils.Randoms;
import java.util.ArrayList;
import java.util.List;
public class NumberGenerator {
private final Rule rule;
public NumberGenerator(Rule rule) {
this.rule = rule;
}
public List<Integer> generateRandomNumbers() {
List<Integer> randomNumbers = new ArrayList<>();
while (randomNumbers.size() < rule.getNumberOfDigits()) {
int randomNumber = Randoms.pickNumberInRange(rule.getMinNumber(), rule.getMaxNumber());
if (!randomNumbers.contains(randomNumber)) {
randomNumbers.add(randomNumber);
}
}
return randomNumbers;
}
}
이 친구는 쪽지로 전달받은 규칙에 의해 게임을 위한 숫자를 생성해냅니다. 그리고 필요한 게임 객체에게 그 숫자를 던져줍니다.
package baseball;
import java.util.List;
/**
* 규칙에 따라 스트라이크, 볼을 판단하는 클래스입니다.
*/
public class Referee {
private final Rule rule;
public Referee(Rule rule) {
this.rule = rule;
}
public int[] compareNumbers(List<Integer> randomNumbers, int userNumber) {
int strike = 0;
int ball = 0;
String userNumberStr = String.valueOf(userNumber);
for (int i = 0; i < rule.getNumberOfDigits(); i++) {
int userDigit = Character.getNumericValue(userNumberStr.charAt(i));
if (randomNumbers.get(i) == userDigit) {
strike++;
} else if (randomNumbers.contains(userDigit)) {
ball++;
}
}
return new int[]{strike, ball};
}
}
카운터가 들고 온 유효한 숫자를 가지고 스트라이크와 볼이 몇 개인지를 판단하는 심판 클래스입니다. 심판이므로 “1 Strike!”라고 표현할지, “스트라이크 하나!” 라고 표현할지에 대해서는 관심이 없습니다. 단지 전광판을 관리하는 업자에게 이 정보만 전달해주면 됩니다.
package baseball;
public class Rule {
private final int minNumber;
private final int maxNumber;
private final int numberOfDigits;
private int endChoice = 1;
private int continueChoice = 2;
public Rule(int minNumber, int maxNumber, int numberOfDigits, int endChoice, int continueChoice) {
this.minNumber = minNumber;
this.maxNumber = maxNumber;
this.numberOfDigits = numberOfDigits;
this.endChoice = endChoice;
this.continueChoice = continueChoice;
}
public int getMinNumber() {
return minNumber;
}
public int getMaxNumber() {
return maxNumber;
}
public int getNumberOfDigits() {
return numberOfDigits;
}
public boolean isValidNumber(int number) {
String numberStr = String.valueOf(number);
return number >= minNumber
&& number <= maxNumber
&& numberStr.length() == numberOfDigits
&& !numberStr.contains("0")
&& numberStr.chars().distinct().count() == numberOfDigits;
}
public boolean isValidChoice(int choice) {
return choice == endChoice || choice == continueChoice;
}
}
규칙을 가지고 있는 클래스입니다. 규칙에 따라서 입력에 대한 유효성을 검증합니다. 이 친구는 게임의 규칙을 정하기 때문에, 만약 “아, 이거 사용자가 4를 눌러야 게임을 재시작해야겠다” 라고 마음먹는다면 게임 전체가 그렇게 동작하게 됩니다. 새로운 기능을 위해서 레벨이 어떻게 올라가는지, 숫자는 최대 몇 글자까지 가능한지 등에 대해서 수정하려면 이 친구에게 문의하면 되겠죠.
이렇게, 각 패러다임이 어떻게 생겨났고 발전해왔는지를 숫자 야구와 같은 간단한 게임을 구현해 보며 알아보았습니다.
하지만 – 위의 설계가 객체지향을 얼마나 잘 지키고 있는가에 대한 고민도 고민이지만, 방법론에 대해 많은 고민을 하는 경우 그것을 제껴두고 생각나는 것은 YANGI 입니다. 그게 무슨 뜻이냐구요? –
항상 드는 고민: YANGI – You aren’t gonna need it
“진짜 필요한 거 맞아?” 입니다. 객체지향, 심지어 JAVA 에 대한 경험이 전무하다고 볼 수 있는 저로서는 위의 구현을 하고 테스트를 돌리고, 디버깅하는 과정이 쉽지는 않았던 것 같습니다. 특히 생성자, 타입 관련 에러가 날 때는 제 절친인 Python
과 소주 한 잔 하고 싶었어요.
하지만 그래도 어떻습니까. 쉽지 않아도 살아가야 하는 게 인생인데요. /돈받기 1000000 같은 치트가 있다면 인생이 얼마나 재미 없겠어요. 저번의 Flask
관련 글도 그렇지만, 이렇게 저만의 무언가를 만들어 보며 리팩토링해 가는 과정이 저는 재미있는 것 같습니다.
짧다면 짧고, 깊이가 얕다면 매우 얕게 보일 수 있는 주니어 개발자의 글을 읽어 주셔서 감사합니다.