0️⃣ 객체 분리의 필요성
객체 분리하기 귀찮은데 메인 함수에 때려 넣으면 안되나?
실제로 내가 우아한테크코스에 들어오기 전에 사람들이 하라는 객체 분리를 하면서 떠올린 생각이다. 교내 수업 중 객체지향 프로그래밍에서 OOP에 대한 기본적인 개념들을 많이 배웠지만, 그것들은 하나의 수단일 뿐 실제로 프로그램을 만들 때 어떻게 적용해야 할지 잘 몰랐었다. 그리고 메인 함수에서 다 구현하면 요구사항은 충분히 동작하는데, 왜 굳이 객체를 분리하고 클래스의 양을 늘려야 하는지 이해하지 못했다.
그러던 내가 그 필요성을 인지하게 된 건 프리코스 1주차였다. 처음으로 순수 자바를 사용하여 프레임워크의 도움 없이 백지장에서 요구사항을 만족시키기 위한 코드를 작성하였다.
처음에는 스파게티 코드를 작성했다. 요구사항을 만족시키기에는 문제가 없었다.
💬 스파게티 코드 보기
당시 최소한의 지식으로만 작성한 숫자 야구 게임 구현 코드
public static void main(String[] args) {
// TODO: 프로그램 구현
// 게임을 시작한다.
System.out.println("숫자 야구 게임을 시작합니다.");
// 컴퓨터가 3가지의 숫자를 생성한다.
List<Integer> computer = new ArrayList<>();
while (computer.size() < 3) {
int randomNumber = Randoms.pickNumberInRange(1, 9);
if (!computer.contains(randomNumber)) {
computer.add(randomNumber);
}
}
Boolean isGameOver = false;
while (!isGameOver) {
// 사용자에게 입력을 받는다.
while (true) {
List<Integer> player = new ArrayList<>();
System.out.print("숫자를 입력하세요: ");
String userInput = Console.readLine();
if (userInput.length() != 3) {
throw new IllegalArgumentException("3자리의 숫자를 입력해주세요.");
}
for (int i = 0; i < userInput.length(); i++) {
int number = userInput.charAt(i) - '0';
if (number < 1 || number > 9) {
throw new IllegalArgumentException("1에서 9 사이의 정수를 입력해주세요.");
}
if (player.contains(number)) {
throw new IllegalArgumentException("서로 다른 숫자를 입력해주세요.");
}
player.add(number);
}
// 힌트를 제공한다.
int strike = 0;
int ball = 0;
for (int i = 0; i < computer.size(); i++) {
if (computer.get(i) == player.get(i)) {
strike++;
} else if (player.contains(computer.get(i))) {
ball++;
}
}
if (strike == 3) {
System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 종료");
System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
String input = Console.readLine();
if (input.length() != 1) {
throw new IllegalArgumentException("1자리의 숫자를 입력해주세요.");
}
int number = input.charAt(0) - '0';
if (number != 1 && number != 2) {
throw new IllegalArgumentException("1 또는 2를 입력해주세요.");
}
if (number == 2) {
isGameOver = true;
}
break;
}
if (ball != 0 && strike != 0) {
System.out.println(ball + "볼 " + strike + "스트라이크");
} else if (ball != 0) {
System.out.println(ball + "볼");
} else if (strike != 0) {
System.out.println(strike + "스트라이크");
} else {
System.out.println("낫싱");
}
}
if (!isGameOver) {
computer.clear();
while (computer.size() < 3) {
int randomNumber = Randoms.pickNumberInRange(1, 9);
if (!computer.contains(randomNumber)) {
computer.add(randomNumber);
}
}
}
}
}
하지만 이 코드에서 여러 가지 개선점을 금방 찾을 수 있었다.
1. 코드 중복으로 요구사항이 변경되어 로직을 변경해야하는 경우 N번 비용이 발생함.
2. 매직 넘버의 사용으로 어떤 의도를 담은 상수인지 판단하기 어려움.
3. 들여쓰기 레벨의 증가로 코드를 이해하는 것이 매우 어려워짐.
4. 코드가 한 곳에 뭉쳐져 있어서, 1줄의 코드를 변경하기 위해서 100줄의 코드를 탐색해야 함.
그리고 다른 사람들의 코드를 리뷰하면서 객체를 잘 분리한 코드를 읽는 것과 뭉쳐져 있는 코드를 읽는 것의 차이가 크게 다가왔다. 객체가 잘 분리되어있는 코드를 읽으면 메인 함수 (혹은 컨트롤러)에서 프로그램이 어떤 동작을 하는 지 쉽게 예측할 수 있었다.
1️⃣ 객체 분리해보기
객체를 분리하면 위에서 말한 문제점들이 해결될까?
실제로 숫자 야구 게임 코드를 분리해서 위 문제들을 해결해보자.
코드 중복 해결
처음 마구잡이로 작성한 코드에서는 컴퓨터가 3개의 숫자를 생성하는 코드가 중복되었다.
컴퓨터라는 객체를 만들고 3개의 숫자를 생성하는 역할을 담당하도록 해보자.
그러면 아래와 같이 중복 코드가 메서드화되어 Computer라는 객체의 일부가 된다.
public class Computer {
public List<Integer> generate() {
List<Integer> computer = new ArrayList<>();
while (computer.size() < 3) {
int randomNumber = Randoms.pickNumberInRange(1, 9);
if (!computer.contains(randomNumber)) {
computer.add(randomNumber);
}
}
return computer;
}
}
이제 이 메서드를 호출하는 것만으로도 랜덤 숫자를 간단하게 생성할 수 있다.
너무 간단한 작업처럼 보이지만, 0에서 1로 넘어간 중요한 작업이다.
이 시점에서 객체지향 원칙 중 하나인 단일 책임 원칙(SRP, Single Reponsibility Principle)을 이야기할 수 있다.
단일 책임 원칙은 객체는 단 하나의 책임만 가져야 한다.라는 원칙이다. 책임이란 그 객체가 담당하여 구현하는 기능이다.
프로그램 전체에서 목표로 하는 기능을 작게 나누어서 객체 하나가 단 하나의 기능만 하도록 하는 것이 객체 분리의 목적이다.
매직넘버를 상수로 분리하기
매직넘버가 객체 분리와 무슨 상관이 있지? 라고 생각할 수 있다.
하지만 상수도 메서드 처럼 객체마다 담당해야 하는 역할로 취급될 수 있다.
아래 클래스를 보자. 숫자 야구 게임에서 다루는 숫자 하나를 나타내는 객체이다. 이 객체의 역할 중 하나는 입력된 숫자가 올바른 범위 안의 숫자인지 확인하는 것이다.
public class Number {
private static final int START = 1;
private static final int END = 9;
private final int value;
public Number(int value) {
if(value < START || value > END) {
throw new IllegalArgumentException("범위를 벗어난 숫자 입력입니다.");
}
this.value = value;
}
}
이 기능을 처리하기 위해서는 Number 객체가 도메인에서 중요한 값인 1, 9를 알고 있어야 한다.
🤔 앞에서 분명히 역할은 `기능`이라고 했는데, 상수를 알고 있다는 것도 `기능`이라는 것에 포함되는건가?
<객체지향의 사실과 오해> 에서는 아래와 같이 객체의 책임을 크게 '하는 것'과 '아는 것'으로 구분하고 있다.
하는 것 (doing)
객체를 생성하거나 계산하는 등의 스스로하는 것
다른 객체의 행동을 시작 시키는 것
다른 객체의 활동을 제어하고 조절하는 것
아는 것 (knowing)
개인적인 정보에 관해 아는것
관련된 객체에 관해 아는 것
자신이 유도하거나 계산할 수 있는 것에 관해 아는 것
만약 START, END라는 상수가 Number이 아닌 엉뚱한 곳에 있었다면, 나중에 다른 개발자가 Number의 범위를 알기 위해 START나 END 상수를 찾아 이리저리 돌아다녀야 할 것 이다.
즉, Number의 값의 범위를 계산하는 것이라는 책임이 값의 범위를 아는 것이라는 책임으로 전파되는 것이다.
이렇게 객체 분리를 통해 특정 상수를 적절한 객체가 책임지게 하였다 이를 통해 스파게티 코드에서 알 수 없었던 상수의 출처, 의미를 명확하게 알고 변경에 용이하도록 할 수 있게 되었다. 👍
들여쓰기 레벨의 감소
들여쓰기 레벨이 높으면 안 좋은 것은 무엇인가? 두 말할 것 없이 아래 사진으로 대체하겠다.
개발자의 코드는 한 번 만들어지면 여러 명에 의해 읽히고, 변경되기 때문에 클린 코드를 지키는 것은 굉장히 중요하다. indent depth를 줄이는 방법은 두 가지가 있는데, 메서드를 분리하거나 객체를 분리하는 것이다.
위 스파게티 코드도 마찬가지로 두 개의 while-loop를 돌면서 indent depth를 증가시키고 있다. 두 가지 방법을 모두 적용해서 해결해보았다.
public void runGame() {
GameStatus gameStatus = GameStatus.RUNNING;
while (gameStatus == GameStatus.RUNNING) {
playUntilSuccess();
outputView.printGameSucceed();
gameStatus = inputView.enterRestartOrQuit();
}
}
private void playUntilSuccess() {
boolean notSucceed = true;
while (notSucceed) {
Numbers numbers = new Numbers(inputView.inputNumbers());
HintResult hintResult = computer.generateHintResult(numbers);
outputView.printHintResult(hintResult);
notSucceed = isSucceed(hintResult);
}
}
private boolean isSucceed(HintResult hintResult) {
return hintResult.strike() == 3;
}
짜잔 ! 메서드 분리와 객체 분리를 통해 복잡했던 스파게티 코드가 indent depth가 1을 넘어가지 않으면서도 깔끔하게 변신하였다.
이중 while문은 메서드 분리를 통해 해결하고, 게임이 성공할 때까지 반복하는 로직에서 세부적으로 나누어지는 분기문들은 모두 각자의 객체에서 수행하도록 하였다. Controller 코드만 보아도 무슨 게임을 하고 있는 지 그려지지 않는가?
코드를 변경하기 쉽게
객체 분리의 좋은 점을 지금까지 많이 설명했지만, 나는 이 부분이 핵심이라고 생각한다.
객체를 아무리 잘 설계해도 요구사항은 끊임없이 변경되기 때문에 내가 아닌 다른 사람도 내가 만든 코드를 쉽게 알아보고 변경된 사안에 맞추어 바로 변경 지점을 찾을 수 있어야 한다.
그러기 위해서는 직관적으로 이해되는 책임 부여가 이루어져야 하고, 만약 객체가 가지는 기능이 그 객체의 역할에서 벗어난다 싶으면 다른 객체를 생성하여 책임을 전가하는 것을 고려해야 한다.
그리고 객체의 책임 분리가 명확하지 않아 두 가지 기능이 하나의 객체에 구현되어있다고 하자. 이 두가지 기능에 대한 요구사항 변경이 서로 다른 개발자에 의해 동시에 일어나게 되면, 같은 지점을 변경하여 conflict가 발생하는 문제 상황도 충분히 생길 수 있다.
결국 궁극적으로 객체를 분리해야 하는 이유는 유지보수라는 하나의 워딩으로 정의할 수 있다.
이러한 깨달음을 얻기까지 받은 리뷰 중 인상 깊었던 리뷰를 하나 남기겠다.
2️⃣ 객체 분리의 시행착오
미션을 진행하는 내내 객체의 책임과 역할에 대해 고민했지만 매 미션마다 객체의 역할이 올바른가?에 대한 질문을 많이 받았다. 그만큼 객체가 `아는 것` 혹은 `하는 것`에 대해 경계를 정하고 외부로부터 캡슐화를 지키는 설계가 어려움을 몸소 느꼈다.
이러한 리뷰들을 통해 내가 관념적으로 알고 있던 개념들의 궁극적인 목표가 유지 보수성이라는 중요한 깨달음을 얻었다. 예를 들어서, "왜 도메인과 뷰를 분리해야 하는가" "왜 이 객체가 이 정보를 직접 알고 있는 것이 아니라 외부로부터 주입받아야 하는 가"에 대한 의문을 해결하다보면 "유지보수를 위함이다"라는 결론이 나왔다.
하지만 이를 머리로만 아는 것이 아닌 직접 나쁜 코드를 작성해보고 문제점을 몸소 느끼는 것이 가장 습득이 빠르다고 느꼈다.
이와 관련해서는 RDD (책임 주도 설계)라는 것을 추가적으로 알아보면 좋을 것 같다. 👍
'우아한테크코스 > 레벨1' 카테고리의 다른 글
[회고] 우아한테크코스 6기 백엔드 레벨1 방학 회고 & 레벨 2 목표 (7) | 2024.04.20 |
---|---|
[디자인 패턴] 상태 패턴 (State Pattern) 이란? 적용해보며 느낀 장점과 단점 (2) | 2024.04.13 |
[OOP] 값 객체(VO, Value Object)란? 원시값 포장과 값 객체의 차이점은? (4) | 2024.04.10 |
[회고] 우아한테크코스 레벨 1 회고 : 성장에 대하여 (7) | 2024.04.04 |
[회고] 우아한테크코스 6기 백엔드 레벨1 7주차 회고 (0) | 2024.04.01 |