[블랙잭 미션] 회고 : Ace, Hit, SRP

2025. 3. 17. 15:47우아한테크코스

ACE

 

블랙잭, 카드

 

블랙잭에서 ACE 카드는 특별한 존재다. 상황에 따라 1점 혹은 11점으로 점수가 달라질 수 있기 때문이다.

ACE 카드의 점수 결정 책임을 어느 객체가 관리해야할까?


ACE의 점수 결정 책임은 게임이 관리

  • 카드는 자신이 ACE인지 아닌지만을 알고, ACE가 1점이나 11점 중 어느 값을 가질지는 Hand(Cards), Participants, Game과 같은 상위 객체가 관리
  • 게임의 현재 상황이나 점수에 따라 ACE의 값이 결정되어야 하기 때문이다.

 

장점

  • 카드의 책임이 명확하고 단순해진다.
  • ACE의 점수 결정 로직이 게임 흐름과 밀접하게 위치하므로, 게임 전체 흐름 파악이 용이할 수 있다.

단점

  • ACE 카드의 특별한 속성이 ACE 카드 클래스 코드에 명확히 드러나지 않는다.
  • 게임 로직 내부에서 ACE 점수 처리를 위한 복잡한 조건문이 생길 가능성이 있다.
  • 카드 객체가 지나치게 수동적으로 설계되어 객체의 자율성 및 캡슐화가 부족할 수 있다.

ACE 카드가 스스로 점수를 결정

  • ACE 카드를 별도의 클래스로 분리하고, 스스로가 현재 점수 상황에 따라 1점 또는 11점 중에서 자신의 값을 결정하도록 책임을 부여하는 방법이다.

 

장점

  • ACE 카드의 특별한 개념이 ACE 카드 클래스 코드에서 명확하게 드러나게 된다.
  • 카드 스스로가 자신의 점수를 결정하므로 ACE 점수 결정 로직이 명확하고 유지보수가 쉬워진다.
  • Hand, Game 등 상위 객체가 ACE 카드의 세부 사항을 직접 관리할 필요가 없어 코드 구조가 간결해진다.

단점

  • ACE 카드 객체가 외부 정보(누적 점수)를 알아야 하므로, 책임이 과도해질 수 있다. (협력이 복잡)

결론 (ACE)

블랙잭 도메인에서 ACE 카드는 명확히 특별한 카드이며, 점수 결정이라는 특수한 책임을 스스로 관리하는 것이 좋다고 판단했다.

 

객체가 자율적이고 책임이 명확할수록 캡슐화된 구조가 형성되며, 이는 유지보수성과 가독성을 크게 높여준다. 비록 ACE 카드가 외부 상황(현재 카드 합 등)을 인지하게 되면서 조금 더 많은 정보를 알게 되지만, 블랙잭이라는 도메인에서 ACE 카드가 특별하고 중요한 역할을 수행한다는 점을 고려했을 때 이는 충분히 허용 가능한 수준이라고 생각했다.

 

결론적으로, 나는 ACE 카드를 별도의 클래스로 구분하여 ACE 카드 스스로가 점수를 결정하는 책임을 가지도록 하는 방식을 선택했다.

 


 

함수형 인터페이스

 

블랙잭 게임을 구현하면서 플레이어가 추가로 카드를 받을지 결정(Hit)하는 로직을 설계할 때 고민이 생겼다. 플레이어는 처음 2장의 카드를 받은 이후 카드의 합이 21점(버스트)을 초과하지 않는 한 계속해서 카드를 받을 수 있다. 이 결정은 입력 뷰(InputView)를 통해 이루어진다.

 

여기서 고려할 중요한 원칙은 뷰(View)는 자주 변경될 가능성이 높기 때문에 도메인 로직이 뷰를 직접 의존해서는 안 된다는 점이었다. 따라서 도메인이 뷰와 강하게 결합되지 않으면서도 플레이어의 선택(의사 결정)을 효과적으로 표현할 방법을 찾고자 했다.

 

대표적으로 2가지 접근법이 있었다.

  1. 최상위 클래스에서 직접 처리:
    • 컨트롤러없이 게임의 최상위 클래스(BlackjackGame)에서 뷰를 의존하고, 플레이어를 직접 관리
  2. 컨트롤러가 도메인과 뷰를 연결:
    • 컨트롤러가 게임의 흐름을 파악하고, 뷰와 도메인 모델 간의 소통을 직접 처리

 

이 두 방법의 장단점을 고려하여, 나는 최종적으로 BlackjackGame 클래스에 선언한 메서드를 함수형 인터페이스 형태로 플레이어에게 전달하는 방식을 선택했다. 이를 통해 도메인 모델이 컴파일 타임에 뷰에 의존하지 않도록 하고, 런타임에만 뷰와 연결되도록 만들었다. 이 방식에서 함수형 인터페이스는 플레이어의 생성자를 호출할 때 인자로 전달되며, 한 번 설정되면 변경되지 않는 필드로 저장된다.

 

이 코드를 본 한 크루(링X)가 "함수형 인터페이스를 굳이 플레이어 객체의 필드로 저장할 필요가 있을까? 메서드 호출 시점에만 넘기면 되지 않나?"라는 질문을 제기했다.

 

이 질문을 계기로 함수형 인터페이스를 객체의 필드에 저장하는 방식과 메서드 호출 시점에 매번 전달받는 방식 사이에서 적절한 접근법이 무엇인지 고민하게 되었다.

 


객체의 필드로 함수형 인터페이스를 저장

// 생성자에서 final 필드에 설정한 함수형 인터페이스
private final Function<Player, Boolean> hitDecision;

public boolean shouldHit() {
    return !this.isBust() && hitDecision.apply(this);
}
객체 생성 시점에 플레이어의 행동 방식을 명확하게 결정하고, 이후 이를 변경할 수 없도록 final 필드로 저장

 

장점

  • 객체의 자율성이 보장됨 
    • 플레이어가 자신의 행동을 스스로 결정하며, 외부에서 이를 변경할 수 없다. (캡슐화 유지)
  • 도메인 로직과의 결합도가 높음
    • 객체의 행동이 명확하고 직관적이며, “이 플레이어는 항상 이렇게 행동한다”는 의도가 코드에서 드러난다.

단점

  • 설정 시점과 사용 시점의 간극
    • 함수형 인터페이스가 객체 생성 시점에 설정되기 때문에, 실제로 사용될 때 어떤 행동을 하는지 확인하려면 생성 지점을 찾아야 한다.
  • 유연성이 낮음
    • 객체의 행동이 고정되어 변경할 수 없으므로, 동적으로 플레이어의 전략을 변경하는 상황에서는 적합하지 않을 수 있다.

메서드 호출 시점에 함수형 인터페이스를 전달

// 인자로 전달된 함수형 인터페이스를 사용
public boolean shouldHit(Function<Player, Boolean> hitDecision) {
    return !this.isBust() && hitDecision.apply(this);
}
호출할 때마다 외부에서 원하는 행동을 결정하는 함수를 전달

 

장점

  • 높은 유연성
    • 행동 결정 로직이 설정과 호출 시점이 명확히 분리되어, 필요할 때마다 다른 행동을 지정할 수 있다.
  • 동적 전략 변경 가능
    • 실행 시점에서 행동을 결정하므로, 다양한 상황에 맞춰 플레이어의 전략을 변경할 수 있다.

단점

  • 캡슐화 약화
    • 객체가 스스로 행동을 결정하는 것이 아니라 외부에서 결정해주기 때문에, 객체의 자율성이 줄어든다.
  • 책임 분산으로 인한 일관성 부족
    • 동일한 객체라도 메서드를 호출할 때마다 다른 전략을 전달하면 일관되지 않은 행동이 나올 수 있다.

결론 (함수형 인터페이스)

객체는 자신의 행동을 스스로 결정하고 관리할 때 더 견고하고 유지보수하기 쉬운 구조를 가질 수 있다.

 

따라서 나는 함수형 인터페이스를 객체의 final 필드로 보관하는 방식을 선택했다. 이를 통해 객체의 행동을 명확하게 정의하고, 캡슐화를 유지할 수 있다.

 

물론, 이 방식은 유연성이 다소 떨어질 수 있다. 하지만 객체의 책임과 역할이 명확해지고, 코드의 일관성이 유지되므로 장기적으로 유지보수와 코드 이해도를 향상시키는 데 유리하다고 판단했다.

 


SRP

 

객체 지향 프로그래밍에서 널리 알려진 원칙 중 하나인 단일 책임 원칙(Single Responsibility Principle, SRP)은 흔히 다음과 같이 정의된다.

"하나의 클래스는 단 하나의 책임만을 가져야 한다."
"하나의 클래스는 단 하나의 변경 이유만을 가져야 한다."

 

하지만 이 원칙을 실제 개발 현장에서 적용할 때는 다소 유연한 해석이 필요하다.


 

변경의 타이밍이 동일하다면 굳이 분리할 필요가 없다.

  • 단일 책임 원칙은 클래스를 무조건 책임 하나씩으로 쪼개라는 뜻이 아니다. 중요한 건 책임의 개수가 아니라, 책임이 바뀌는 타이밍이다. 만약 두 가지 책임이 존재하더라도 그 책임들이 항상 동시에 변경된다면 굳이 두 클래스로 나눌 필요가 없다.
  • 예를 들어, 데이터 저장 로직과 데이터 검증 로직이 거의 항상 함께 바뀌는 상황이라면 이 둘을 별도의 클래스로 분리하는 것은 오히려 과도한 추상화가 되어 불필요한 복잡성을 초래할 수 있다.

분리의 이점이 크지 않다면 단일 클래스로 유지하는 것이 합리적이다.

  • 책임을 분리하는 궁극적인 목적은 유지보수성과 확장성을 높이는 데 있다. 하지만 만약 두 책임이 밀접히 연결되어 있고, 변경 주기가 크게 다르지 않다면 분리를 통해 얻는 이점이 크지 않을 수 있다.
  • 무리한 분리로 인해 지나치게 많은 클래스가 생성되면 오히려 유지보수가 어려워지고 코드의 복잡성만 증가하게 된다.

미래의 변경 가능성을 고려하라.

  • 현재는 변경의 타이밍이 동일하지만, 미래에 각 책임이 독립적으로 변경될 가능성이 있다면 분리를 미리 고려하는 것이 좋다. 한 가지 책임의 변경이 다른 책임에 영향을 미치지 않는 구조가 바람직하기 때문이다.
  • 예를 들어, 데이터 검증 로직이 변경될 때마다 데이터 저장 로직이 영향을 받지 않아야 하는 상황이라면 두 책임을 별도의 클래스로 분리해두는 것이 적합하다.

결론 (SRP)

단일 책임 원칙은 기계적으로 적용되어서는 안 된다. 책임의 개수가 아니라 책임 간의 변경 타이밍과 연관성이 핵심이다.

 

SRP를 엄격히 해석하여 무조건 책임 하나에 하나의 클래스만 만드는 방식은 오히려 코드 복잡성을 증가시킬 수 있다.

따라서 책임의 분리가 실제로 유지보수성 향상과 변경 관리에 실질적인 이점을 주는지를 고려하여 유연하게 접근하는 것이 중요하다.

 


참고 자료

로버트 C. 마틴, 클린 소프트웨어

 


마무리

 

레벨 1의 세 번째 미션이었던 블랙잭이 끝났다.

이번 미션은 이전 미션들에 비해 설계나 구현 과정에서 많은 고민을 할 수 있어서 의미가 컸다. 특히 설계 원칙과 객체의 책임, 그리고 의존성을 끊는 방법까지 다양한 개념을 직접 경험하면서 더 성장할 수 있었다.

 

내일부터는 레벨 1의 마지막 미션, 체스가 시작된다.

사실 체스라는 도메인이 조금 두렵다. 솔직히 체스를 해본 적이 없어서 어떻게 접근해야 할지 감이 잘 오지 않는다. 만약 홀덤이었다면 훨씬 자신감 있게 접근했을 텐데, 내가 모르는 도메인을 다루게 되어 불안함을 느끼는 것 같다.

 

그래서 크루들과 모여 체스를 연습하기도 했다. 룰을 전혀 모르고 시작하는 건 불안해서 기본적인 규칙 정도는 익히려 했다. 그래도 여전히 두렵고 낯설긴 하지만, 한편으로는 못하면 어떤가? 싶다.

 

오히려 내가 잘 알지 못하는 도메인을 이해하고, 고민하며 풀어나가는 과정에서 더 큰 성장을 얻을 수 있을 거라 기대한다.

 

나는 할 수 있다.