우아한테크코스는 의견이 갈릴 수 있는 주제에 대해서 많은 사람들과 이야기할 수 있는 장을 제공한다는 점에서 참 좋은 교육 프로그램인 것 같다. 이번에도 객체의 생성 경로가 다중화되는 것과 객체의 자율성에 대해서 크루들, 그리고 리뷰어들과 여러 이야기를 나눌 수 있었다. 해당 주제를 가지고 정말 많은 사람들과 이야기를 나누다 보니 재미있는 일화도 생겨났는데, 다른 사람들을 설득하기 위해 내가 스스로 청소기론 이라는 이론을 만들어 설파하고 다닌 것이다. 정답없이 선택만 존재하는 프로그래밍이지만 여러 사람들과 이야기해볼 수 있었던 경험은 나의 코드 철학을 형성하고 다양한 의견을 유용하게 교류하는 역량 향상에 큰 도움이 되었던 것 같다. 이번 포스팅에서는 여러분들에게 나의 청소기론과 객체의 자율성에 대한 나의 고민일지를 공유해보고자 한다.
블랙잭 미션을 같이 진행했던 페어가 리뷰어에게 생성자의 접근제어자 관련해서 코멘트를 받은 것이 이번 토론의 시작이었다. 먼저 코드를 보자. 다음의 코드는 블랙잭 미션 프로젝트에서 사용되는 CardDeck 객체이다.
package blackjack.domain.card;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.stream.Collectors;
public class CardDeck {
private final Queue<Card> cards;
CardDeck(List<Card> cards) {
this.cards = new LinkedList<>(cards);
}
public static CardDeck createShuffledFullCardDeck() {
List<Card> cards = createFullCardDeck();
Collections.shuffle(cards);
return new CardDeck(cards);
}
private static List<Card> createFullCardDeck() {
List<CardRank> cardRanks = CardRank.getAllCardRanks();
List<CardSuit> cardSuits = CardSuit.getAllCardSuits();
return cardRanks.stream()
.flatMap(rank -> cardSuits.stream()
.map(suit -> new Card(rank, suit)))
.collect(Collectors.toList());
}
public Card popCard() {
if (cards.isEmpty()) {
throw new IllegalArgumentException("[ERROR] 남아있는 카드가 부족하여 카드를 뽑을 수 없습니다.");
}
return cards.poll();
}
}
블랙잭 게임에서는 카드 한벌을 사용한다. 만약 카드덱이 다음처럼 완전하지 않은 상태로 블랙잭 게임에서 사용되고 있다면 이는 블랙잭 게임 규칙을 어긴 것이다.
CardDeck illegalCardDeck1 = new CardDeck(List.of(Card.HEART_1)); // 하트 1만 가지고 있는 카드덱
CardDeck illegalCardDeck2 = new CardDeck(List.of()); // 비어 있는 카드덱
CardDeck의 생성자가 public으로 열려있다면 위와 같은 CardDeck 객체가 생성될 수 있다. 이는 프로젝트내에 비정상적인 CardDeck 객체가 협력할 수 있음을 암시한다. 페어는 이러한 상황을 피하고 도메인 규칙 준수를 강제하고 싶어 생성자의 접근제어 권한을 강제했다고 한다. 정적 팩토리 메서드를 통해 추상화한 생성로직으로만 객체가 생성되도록 생성자의 접근 제어 수준을 강화한 것이다.
package blackjack.domain.card;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.stream.Collectors;
public class CardDeck {
private final Queue<Card> cards;
/**
* 정적 팩토리 메서드를 통해서만 객체가 생성되도록 객체의 생성자 접근 제어 수준을 강화
* 다만 테스트에서 객체 생성의 용이성을 위해 private이 아닌 default 생성자를 사용
**/
CardDeck(List<Card> cards) {
this.cards = new LinkedList<>(cards);
}
public static CardDeck createShuffledFullCardDeck() {
List<Card> cards = createFullCardDeck();
Collections.shuffle(cards);
return new CardDeck(cards);
}
...
}
그리고 페어는 다음의 코멘트를 리뷰어에게 받았다.

위의 코멘트에서 리뷰어는 생성자를 열어둘 것을 권장한다. 자유롭게 객체를 생성하도록 하고 객체는 정해진 기능을 제공하는데에만 집중하라는 것이다. 나 역시 마찬가지의 의견을 페어프로그래밍 도중 리뷰어와 비슷한 의견을 얘기한 적이 있기에 페어는 이 부분에 대해서 나와 이야기를 나누고 싶어했고, 우선 우리는 다른 사람의 의견들도 들어보기로 했다. 생성자를 열어둘 것인지, 객체의 생성 경로를 단일화 할 것인지 다른 사람들의 의견도 궁금하여 크루들과 리뷰어 8명 정도의 의견을 들어보았다. 8명 중 5명은 정적 팩토리 메서드를 사용하는 경우 생성자의 범위를 좁혀 객체의 생성 경로를 일원화하고자 했고 나머지 3명은 생성자를 열어둔다고 답했다.
객체의 생성자를 열자는 인원들은 객체의 자유로운 협력 구조가 합리적임을 언급하며 생성자를 열어둘 것을 권장하였는데, 나 역시 마찬가지의 의견이다. 객체는 자신이 사용되는 곳을 미리 정하며 설계되면 안된다고 이전 포스팅에서 얘기한 바 있는데, 해당 철학이 이어진 결론이라고 볼 수 있겠다.
이전 포스팅의 객체 생성 계약에 대한 내용
정적 팩토리 메서드를 사용하지 않는 경우 잘못된 객체가 생성될 수 있음을 선술한 바 있다. 그렇지만 생각해보면 잘못된 객체의 생성을 우려하는 것은 객체가 쓰이는 곳을 미리 판단하고 있다는 소리가 된다. 객체는 자신이 사용될 곳을 미리 정해서는 안된다. 이는 자유로운 협력 관계를 약화시키고 여러 제약 사항이 하나의 객체에게 얽히게 함으로서 종국에는 객체지향을 깨뜨리는 설계로 이어질 가능성이 있기 때문이다. Ladder의 책임이 비대해지는 것이 객체지향을 깨뜨린 예로 해석될 수도 있겠다.
나는 반드시 하나의 방식으로 객체를 생성하도록 하고 싶다는 욕심이 안좋은 설계로 이어질 수 있다고 생각하게 되었다. 더 나아가 객체는 나를 사용하려면 ~~이런 조건을 갖춰, 그럼 난 이걸 해줄수 있어 이런 계약만 잘 지키면 된다고 생각하게 되었다. 실제로 팩토리 클래스를 운용하는 방식으로 전환해보니 생성시 validation등 제약조건은 객체에 있으나 생성로직을 다른데 둔다고 해서 해당 객체의 가치가 떨어지는건 아닌 것 같다고 느끼게 되었다.
그리고 나는 객체는 자신의 상태에 맞는 기능을 제공할 뿐이라고 생각한다. 카드가 한벌, 52장으로 구성되어야 한다는 것은 CardDeck의 로직일까 블랙잭 게임의 로직일까? 나는 블랙잭 게임의 로직이라고 본다. 만약 정말로 1장도 아니고 3장도 아닌 딱 한벌의 카드덱을 바란다면 FullCardDeck이라는, CardDeck을 상속하는 클래스가 등장했어야 한다고 생각한다.
즉, 카드덱은 주어진 데이터를 기반으로 만들어지고 명세에 맞는 기능을 제공할 뿐, 특정한 로직에 국한되어서는 안된다는 것이 나의 생각이다. shuffledFullCardDeck이 생성되길 바란다는 이야기는 충분히 공감되지만 CardDeck 클래스는 정적 팩토리 메서드로 createShuffledFullCardDeck api를 제공하니 역할을 다했다고 생각한다. (Factory 클래스로 분리했으면 더 좋을 수 있겠지만 생성 책임이 너무 분산되니 정적 팩토리 메서드로 타협한 케이스이다)
여기에서 나는 개발자의 책임은 끝났다고 생각하고 이제부터는 다른 개발자 혹은 클라이언트가 객체를 자신에 상황에 맞게 자유롭게 사용하는 것이라고 생각한다.
생성 로직을 일원화시키고 싶은 페어와 다른 크루들은 위의 나의 결론에 반박했었다. 애플리케이션이 원하는 대로 동작하도록 안전장치를 최대로 활용하는 것이 더 안전한 방식임을 설득하고자 한 것이다.
하지만 나는 설령 내가 설계해서 제시한 객체가 잘못 사용되더라도 개발자의 책임은 더 이상 없다고 생각한다. 클래스에 도메인 명세를 적절히 녹여내었다면 이를 잘못 사용하여 생기는 문제는 객체를 사용하는 클라이언트의 책임이라고 생각하는 것이다. 확장해서 생각하면 CardDeck을 이상하게 만들어서 사용하는 클라이언트가 있다면 이는 클라이언트의 잘못일 것이다.
이를 설득하기 위해 나는 청소기를 예시로 들었다.
자 여러분들과 나는 청소기를 만들어 판매한다. 청소기의 명세는 "먼지를 빨아들인다"라는 기능에 집중된다. 그런데 청소기를 구매한 사람 중 한명이 청소기를 분해한 다음 재료들로 로켓을 만들었다. 로켓은 잘 발사되지 않았고 청소기를 구매한 사람은 이제 우리에게 와서 로켓이 발사가 되지 않는다고 따진다. 이 경우 로켓이 발사되지 않은 것은 청소기 판매자인 우리의 잘못일까 구매자의 잘못일까? 나는 구매자의 잘못이라고 생각한다.
CardDeck 객체도 마찬가지다 우리는 CardDeck을 만들 수 있는 방법, CardDeck에서 사용할 수 있는 기능들을 명세로서 전달하고 있다. 즉 우리가 제공하는 인터페이스와 기능에 따라 CardDeck이 동작하도록 만든 것이다. 그런데도 클라이언트가 잘못 사용했다면 문제는 명세를 꼼꼼히 파악하지 않은 클라이언트에 있을 것이다.
물론 책임이 클라이언트 쪽으로 과하게 몰리면 좋지 않을 것이다. 그렇기 때문에 협력 객체들이 생기고 각자의 명세에 맞는 기능을 제공하는 것이다. 블랙잭 게임을 예시로 이어가면 CardDeck은 카드 뭉치로서의 역할만 다하고 블랙잭 게임에서 사용될 CardDeck을 만드는 BlackJackCardDeckCreator가 있다면 협력 구조와 명세는 각 클래스에 응집될 것이다.
// 블랙잭 게임에서 사용될 CardDeck을 만드는 Factory 클래스
public class BlackJackCardDeckCreator {
private final List<CardRank> cardRanks;
private final List<CardSuit> cardSuits;
public CardDeck createFullCardDeck() {
return cardRanks.stream()
.flatMap(rank -> cardSuits.stream()
.map(suit -> new Card(rank, suit)))
.collect(Collectors.toList());
}
}
정답은 없지만 선택은 중요하다. 주변 크루들과 이야기하면서 내가 설득시킨 크루들도 있었고 그렇지 못한 크루들도 있었다. 각자의 선택은 개인의 철학과 경험에 따라 달라질 수 있다.
여러분들은 어떤 방법을 선택하겠는가? 객체의 책임을 어느 단위로 볼 것이며 자율성을 어느 수준으로 구축할지 궁금하다. 만약 반대의견을 가진다면 내가 제시한 청소기론이 설득이 되었는지도 궁금하다. 청소기론은 객체 설계에서 책임과 자유의 균형을 잡는 데에 사용한 나의 비유다. 꼭 나의 의견에 동의하지 않더라도 여러분들의 객체 설계에 깨달음을 줄 수 있기를 바란다.
저기요 청소기로 로켓을 만들었는데 안 날아요