우아한테크코스 LV1 세 번째 미션은 블랙잭 게임을 구현하는 것이었다. step1은 플레이어의 이름을 입력받아 블랙잭 게임을 진행하고 플레이어 별 승패 결과를 출력하는 것이 목표였다. 그 다음 step2는 배팅 시스템을 도입하여 플레이어가 배팅 금액을 입력하고 승패 결과에 따라 상금을 받아갈 수 있도록 하는 것이 목표였다.
step1 페어는 민트였으며 페어프로그래밍 기간 내내 데드라인에 쫓겨 굉장히 익스트림하게(?) 진행했었던 미션이었다. 그래도 페어 기간 내내 많이 웃으면서 정말 재미있게 진행할 수 있었던 것 같다. 하지만 이후 리팩토링과 step2를 진행하면서 굉장히 코드를 많이 뒤엎었기에 전반적인 설계에 대한 중요성을 뼈저리게 체감할 수 있었던 미션이기도 했다. 개인적으로 정말 애증이 많이 남은 미션이었는데 어떤 부분들이 나를 힘들게 했었는지 회고해보려 했으나...
회고를 작성하는 시점이 미션을 진행했을 때를 한참 지난 방학때인지라 전반적으로 블랙잭 미션을 통해 알게된 것들과 피드백을 중심으로 정리해보고자 한다...!
[객체 캐싱]
다음은 블랙잭 피드백 강의자료를 방학 때 다시 복습하며 정리하다 보니 마주친 내용이다.
클래스는 객체의 팩토리(factory)이며, 객체를 만들고, 추적하고, 적절한 시점에 파괴한다.
클래스는 객체를 생성하며 일반적으로 클래스가 객체를 '인스턴스화한다(instantiate)'라고 표현한다.
아쉽게도 Java에서 제공하는 new 연산자는 충분히 강력하지 않아 유사한 객체가 존재하거나 재사용 가능한지 확인하지 않는다.
종종 클래스를 객체의 템플릿으로 보지만 '객체의 능동적인 관리자'로 생각해야 한다.
클래스는 객체를 보관하고 필요할 때 객체를 꺼낼 수 있고 더 이상 필요하지 않을 때에는 객체를 반환할 수 있는 저장소(storage unit) 또는 웨어하우스(warehouse)로 바라봐야 한다.
클래스가 객체를 보관하고 필요할 때 꺼낼 수 있다는 관점에서 객체의 저장소 또는 웨어하우스로 바라봐야 한다는 내용이 굉장히 새롭게 다가왔다. 이후 자주 사용하는 인스턴스는 캐싱한다라는 피드백 내용도 있었는데 이러한 관점에서 기인할 수 있었던 것이라 생각했다.
자바에서 new 연산자는 동일한 인스턴스가 이미 생성되어있다고 하더라도 이를 재사용하지는 않는다. 그렇기 때문에 동일한 값을 표현하는 VO등의 객체가 정해진 케이스 안에서 생성되어 사용되는 경우라면 이를 캐싱해서 사용하는 것이 성능 상 이점을 취할 수 있다고 한다.
그런데 정말 유의미하게 성능 상 이점을 챙길 수 있는 걸까라는 의문이 들었다.
만약 블랙잭 게임에서 사용되는 카드를 나타내는 객체들을 구현했다고 가정해보자. 하나의 덱은 총 52장의 카드로 이뤄져있다. 미션에서 구현했던 블랙잭 게임은 콘솔 수준에서 단일게임을 진행하기에 기껏해야 52개의 Card 객체가 생성된다. 과연 52개의 객체를 캐싱한다고 어떤 성능 상의 이점을 취할 수 있는 걸까?
객체 캐싱의 진가는 프로그램 규모가 확장되서 동시에 여러 게임이 진행되어야 할 때 드러나게 된다. 매번 게임이 생성될 때마다 Card 객체들을 새로 생성해준다면 총 생성되는 Card 객체의 수는 52 * 총 게임 수가 된다. 만약 동시에 진행되는 게임의 수가 100개, 1000개가 된다면 어떻게 될까? 1000개의 게임 객체가 생성되었을 뿐인데 Card 객체는 52000개가 생성되어야 한다. 구현 방식에 따른 차이는 존재할 수 있겠지만 블랙잭 카드들은 값만 같으면 같은 역할을 하는 VO에 가깝다. 이 경우 완전히 동일한 역할을 수행하는 인스턴스를 몇 만개씩 생성해줄 필요가 있을까? 이런 상황을 염두에 두고 인스턴스를 미리 캐싱하여 사용함으로써 메모리 낭비를 줄일 수 있다고 생각한다면 성능 상 이점을 취할 수 있다는 관점이 납득된다. 앞으로는 클래스에서 객체를 캐싱해서 관리할 수 있는 방안을 적극 검토해볼 수 있을 것 같다. 😁
[인터페이스와 추상클래스]
블랙잭 미션에서 상태 패턴을 적용하려고 시도 하면서 인터페이스와 추상클래스를 모두 사용하게 되었다. 미션을 진행할 때 당시에는 다형성에 대한 개념도 잘 모르면서 무작정 인터페이스와 추상클래스를 사용했었는데 시간이 지나면서 각각에 대해 공부해보고 언제 사용해야 하는지 개인적인 기준을 정립해보고자 했다.
(작성하다보니 내용이 너무 길어져서 따로 포스팅을 분리했습니다.)
[객체의 책임 분배]
페어 미션 과정에서 여러가지 이유로 데드라인에 엄청 쫓기면서 미션을 구현했다. 그러다보니 객체를 설계하는데 많은 시간을 들이지 못했었는데 그런 부분이 코드리뷰를 받는 과정에서 여실히 드러났던 것 같다. 가장 많이 범했던 실수는 객체의 상태값을 외부로 반환해서 처리하도록 했던 것이었다.
시간이 촉박해서 미처 신경쓰지 못했다지만 다시 한 번 객체를 설계함에 있어서 책임 분배를 어떻게 가져가야 하는가에 대해서 생각하게 되었다. 여태까지는 객체가 어떤 상태 값을 가져야 하는지를 중점적으로 생각해서 설계를 해왔다는 생각이 들었다. 그러다 보니 객체 간 협력을 고려해 적절한 책임 분배를 하지 못하고 어떻게 상태 값을 처리할 것인지를 주로 고민했었다. 그리고 이는 객체 내부의 상태 값을 필요로 하는 기능을 다른 객체에게 부여하고 이를 어떻게 구현해야 하는지에 대한 고민을 낳았다. 결과적으로 객체를 객체답게 설계하지 못한 것이다.
이번 블랙잭 미션 리팩토링을 거치면서 객체를 설계할 때 반드시 이 객체는 어떤 책임을 수행해야 하는지를 스스로 정해두는 습관을 들여야 겠다고 결심했다. 그렇게 해야 객체 내부 상태값을 반환하는 행위가 객체에게 부여되어야 할 책임이 다른 객체에게 잘못 부여되어 기인한 것인지, 아니면 객체 간 협력관계 및 책임 분리를 위해 꼭 필요한 행위인지 판단할 수 있게된다고 결론지었다.
[인터페이스에 대고 프로그래밍하기]
리뷰어 코즈에게 리뷰받은 사항들 중 하나는 인터페이스에 대고 프로그래밍해야된다는 것이었다.
public HashMap<Name, List<Card>> makeSetUpResult() {
HashMap<Name, List<Card>> setUpResult = new LinkedHashMap<>();
}
위의 코드처럼 map 자료구조의 반환타입으로 인터페이스가 아닌 HashMap 구현체를 타입으로 사용하고 있다. 만약 이렇게 인터페이스가 아닌 구현체를 타입으로 사용하게 되면 어떤 문제점이 있을까?
Map 인터페이스를 구현한 구현체는 다양한 것들이 존재한다. HashMap, TreeMap, LinkedHashMap, EnumMap, SortedMap 등이 Map 인터페이스 구현체의 예시이다. 그런데 위 코드처럼 특정 구현체를 변수 타입으로 사용하게 되면 요구사항 변화에 굉장히 유연하지 못한 코드를 작성하게 된다는 문제점이 생긴다.
지금은 요구사항에 맞춰서 LinkedHashMap을 사용하고 있지만 이후 요구사항이 변경되어 다른 Map 구현체를 사용해야 하는 상황이 생겼다고 가정해보자. 그러면 HashMap으로 선언해줬던 모든 변수 타입을 변경해줘야 하는 불상사가 발생하게 된다. 즉 객체지향에서 추구하는 다형성의 장점을 전혀 누리지 못하는 것이다. 이렇듯 추상화된 인터페이스 타입이 아닌 실제 구현을 제공하는 타입을 이용해서 프로그래밍하는 경우 구현에 변화가 생겼을 떄 변화의 영향력이 이곳 저곳으로 퍼져 나가게 된다.
public Map<Name, List<Card>> makeSetUpResult() {
Map<Name, List<Card>> setUpResult = new LinkedHashMap<>();
}
특별한 이유가 존재하지 않는 한 인터페이스에 대고 프로그래밍해서 변화에 유연한 코드를 작성할 수 있도록 하자!
추가사항
리뷰어 코즈가 첨부해준 레퍼런스를 학습하다가 인터페이스 도출에 대한 좋은 관점을 접하게 되어 정리하고자 한다.
이미 구현된 클래스를 추상화하기 위해 인터페이스화 하는 첫 시작점은 구현체 클래스의 public 메서드를 인터페이스화 해보는 것이다. public 메서드는 외부에서 객체와 협력하기 위한 용도이기 때문에 최우선적인 추상화 대상이다.
뿐만 아니라 구현체의 private 메서드도 인터페이스화를 고려해볼 수 있다. 보통 private 메서드는 클래스 내부에서 사용하는 기능을 구현하기 위해 설계되었을 가능성이 높다. 이런 경우에도 별도의 인터페이스로 분리해서 내부 기능 구현에도 유연성을 부여할 수 있다.
항상 public 메서드에 중점을 두고 인터페이스화를 생각해왔는데 private 메서드에 대해서도 추상화를 고려해볼 수 있다는 것이 신선했다. 아직 경험이 부족해서 절실하게 와닿지는 않지만 추상화를 하면서 충분히 고려해봄직한 관점이라고 생각이 된다.
[상태 패턴]
블랙잭 미션을 진행하면서 가장 신선한 충격을 받았던 점을 꼽아보라고 하면 게임 진행 상태를 객체화하는 상태 패턴을 적용하는 부분이었다고 얘기할 수 있을 것 같다. 상태 패턴에 대해 학습한 내용과 이에 대한 개인적인 생각들을 정리해 보고자 한다.
사실 상태 패턴을 이번에 사용하게 된 계기는 스스로 어떤 난관에 부딪혀 고민하다가 필요성을 느낀 경우가 아니라 피드백 강의를 통해 알게된 새로운 디자인 패턴을 적용시켜보고 싶은 마음이었다. 그래서 어떤 경우에 상태 패턴을 적용하면 좋은지, 어떤 장점을 취할 수 있고 어떤 단점이 있는지 알지 못한 상태로 사용하게 되었는데 회고를 하면서 이 부분에 대해서 학습하게 되었다.
우리는 소프트웨어를 설계할 떄 객체들의 책임과 역할로부터 만들어지는 상태 값들을 자연스럽게 다루게 된다. 이 상태 값들은 그냥 정수나 문자열 처럼 단순하게 데이터를 담는 경우에는 정확하게 어떤 값이 담기게 될 지 예측할 수 없다. 그런데 간혹 상태 값이 미리 정해진 몇 가지의 값 안에서만 결정되는 경우도 있다. 예를 들어 신호등이 어떤 상태인지 나타내는 color라는 상태 값은 일반적으로 초록색, 노란색, 빨간색 이렇게 세 개의 상태 값으로만 이뤄진다고 볼 수 있다.
우리는 이런식으로 상태 값이 반드시 몇 가지 경우 안에서만 결정된다는 사실을 활용해 효과적으로 관리할 수 있다. 이때 우리는 보통 몇 가지 정해진 상태 값들을 상수로 정의한다. 그리고 공통적인 특징을 가지는 상수들을 한 곳에서 관리할 수 있도록 하는 Enum을 사용한다.
그런데 이렇게 Enum으로 상태 값들을 관리하다보면 이 상태 값에 따라 분기를 해서 다르게 작업을 처리해줘야 하는 경우가 생긴다.
public enum BlackjackState {
BLACKJACK,
BUST,
STAY,
HIT;
public void draw(Card card) {
if (this == BLACKJACK) {
// 블랙잭 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
if (this == BUST) {
// 버스트 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
if (this == STAY) {
// 스테이 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
if (this == HIT) {
// 히트 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
}
}
이렇게 객체가 수행하는 기능 안에서 관리하는 각 상태 값들마다 처리해줘야 하는 작업이 모두 다르다면 상태 값의 개수만큼 if 문 분기가 늘어날 수 밖에 없다. 거기에 요구사항이 변경되어 hit 상태를 딜러와 플레이어가 서로 다르게 처리되어야 한다고 한다면 어떻게 될까?
public enum BlackjackState {
BLACKJACK,
BUST,
STAY,
DEALER_HIT,
PLAYER_HIT;
public void draw(Card card) {
if (this == BLACKJACK) {
// 블랙잭 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
if (this == BUST) {
// 버스트 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
if (this == STAY) {
// 스테이 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
if (this == DEALER_HIT) {
// 딜러 히트 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
if (this == PLAYER_HIT) {
// 플레이어 히트 상태일 때 카드를 뽑으면 수행되어야 하는 코드...
}
}
}
이렇듯 기존에 구현되어 있던 draw 메서드를 수정해주면서 if 문 분기를 추가해줘야만 한다. Enum에서 모든 상태 값을 관리하는 방식을 사용할 때 그 상태 값에 따른 분기가 계속 생겨나게 된다면 다음과 같은 어려움이 생기게 된다.
- 관리해야 하는 상태 값이 늘어날 때마다 if 문의 분기가 늘어나게 된다.
- 그리고 수많은 if 문의 분기는 코드를 변경에 굉장히 취약하게 만든다.
우리는 이럴 때 상태 패턴의 도입을 고려할 수 있다. 강의 피드백 자료에 있던 내용 중에는 이렇게 많은 if 문 분기가 발생할 때 이를 객체지향의 다형성을 활용해서 해결할 수 없을지 검토해보자는 것이 있었다. 이로부터 우리는 위에서 정의한 blackjack 게임의 여러 상태들을 각각 객체화해서 해당 객체들에게 책임을 부여하자는 아이디어를 도출해낼 수 있다.
public abstract class State {
private final Hand hand;
protected State(Hand hand) {
this.hand = hand;
}
public abstract State draw(Card card);
protected final Hand getHand() {
return hand;
}
}
// 플레이어 준비 상태
public final class UserReady extends Ready {
public UserReady() {
super(new Hand());
}
public UserReady(Hand hand) {
super(hand);
}
@Override
public State draw(Card card) {
final Hand newHand = getHand().add(card);
if (getHand().isEmpty())
return new UserReady(newHand);
if (newHand.isBlackjack())
return new Blackjack(newHand);
return new UserHit(newHand);
}
}
// 플레이어 히트 상태
public final class UserHit extends Running {
UserHit(Hand hand) {
super(hand);
}
@Override
public State draw(Card card) {
final Hand newHand = getHand().add(card);
if (newHand.isBust())
return new Bust(newHand);
return new UserHit(getHand().add(card));
}
}
// 게임 종료 상태
public abstract class Finished extends State {
Finished(Hand hand) {
super(hand);
}
@Override
public final State draw(Card card) {
throw new IllegalToDrawInFinishedException(); // 게임 종료 상태에서는 draw 기능을 제공하지 않음
}
}
상태 패턴을 사용하여 blackjack 게임에 사용되는 상태 값들을 객체화 하고 각 객체별로 draw 기능을 수행하는 동작을 따로 구현해줄 수 있게 되었다. 이렇게 구현했을 때 얻을 수 있는 장점은 아래와 같다.
- 이후 요구사항 변경이 발생해서 상태 값이 추가되어야 하는 경우 새로운 클래스를 추가 구현해주기만 하면 된다.
- 기존에 구현되어 있던 다른 상태 객체들에게는 영향을 미치지 않는다.
- 만약 특정 상태에 대한 기능이 수정되어야 하는 경우 어떤 객체의 메서드를 수정해야하는지 책임소재가 명확해진다.
- 구현 코드가 상태 객체 별로 구분되기 때문에 상태별로 동작을 수정하기가 쉽다.
물론 개발에는 정답이 없기 때문에 상태 패턴을 적용했을 때 생기는 단점도 있다. 상태 패턴을 적용하게 되면 관리해야 할 클래스 자체가 많아지게 된다. 오히려 그냥 상태 값을 사용했을 때보다 코드가 복잡해질 위험도 존재한다. 항상 무지성적인 디자인 패턴 적용을 경계하고 이 도구를 왜 써야하는지에 대한 근거를 스스로 명확하게 세우고 사용할 수 있도록 하자.
Reference
상태 패턴을 사용해보자 - https://tecoble.techcourse.co.kr/post/2021-04-26-state-pattern/
'우아한테크코스 > 회고' 카테고리의 다른 글
[우아한테크코스] LV2 - 웹 장바구니 미션 Step1 회고 (0) | 2023.04.27 |
---|---|
[우아한테크코스] LV1 - 체스 미션 회고 (0) | 2023.04.10 |
[우아한테크코스] LV1 - 4기와의 수다타임 회고 (1) (0) | 2023.04.02 |
[우아한테크코스] LV1 - 솔라와의 면담 및 커피챗 회고 (2) | 2023.03.26 |
[우아한테크코스] 테코톡 발표 회고 (6) | 2023.03.05 |