2025. 4. 5. 18:02ㆍ우아한테크코스
미션 시작 당일 아침에 체스가 아닌 장기로 변경됐다!
객체
객체지향이란 무엇일까? 단순히는 객체를 중심으로 문제를 해결하는 방식이라고 말할 수 있다. 그렇다면 여기서 말하는 '객체'란 무엇을 의미할까? 흔히 객체는 상태와 행동을 가진 존재라고 설명되지만, 나는 조금 더 넓은 시각에서 접근하고자 한다.
객체란 책임을 다하는 존재라고 생각한다. 문제를 해결하기 위해 객체들은 서로 협력하며, 이 협력 속에서 각 객체는 자신에게 주어진 역할을 수행한다. 이 역할을 수행하려면 객체는 반드시 책임을 다해야 한다. 이런 관점에서 객체는 단지 상태(데이터)와 행동(메서드)을 묶어둔 것 이상의 의미를 가진다.
한 걸음 더 나아가 '상태가 없는 객체'라는 개념을 생각해 볼 수 있다. 상태가 없다는 것은 내부적으로 데이터를 유지하지 않는다는 의미다. 전략 객체, 정책 객체 등이 대표적인 예인데, 이런 객체들은 입력된 데이터를 처리하고 결과를 반환하는 명확한 행위만 수행하며 내부에 저장된 데이터는 없다. 그럼에도 이러한 객체들을 객체로 표현하는 것이 부적절하다고 말할 수는 없다. 이는 객체지향 설계가 데이터 자체보다는 그 데이터를 다루는 행위에 더 초점을 맞추고 있기 때문이다.
이러한 맥락에서 상태가 없는 객체가 흥미로운 이유는, 상태를 갖지 않음에도 불구하고 캡슐화가 가능하기 때문이다. 일반적으로 캡슐화는 객체 내부의 상태를 외부로부터 숨기고, 객체 스스로가 자신의 행동을 책임지는 구조를 만드는 데 목적이 있다. 즉, 캡슐화를 철저히 지키기 위해서는 객체의 상태가 외부(호출자) 시선에서 사라지게 된다. 다시 말해, 외부에서 객체의 내부 데이터를 직접 꺼내 가공하기보다는, 메시지를 통해 객체에게 원하는 행위를 요청하고, 객체가 그 메시지에 자율적으로 응답하는 방식이 캡슐화의 핵심이다.
상태가 없는 객체는 숨길 상태 자체는 없지만, 외부로부터 메시지를 받아 스스로 책임 있는 행동을 수행한다는 점에서 오히려 캡슐화의 목적을 더 순수하게 실현하고 있다고 볼 수 있다. 외부 입장에서 이 객체를 사용할 때, 객체의 내부 구현이나 상태를 전혀 신경 쓰지 않고도 원하는 행위를 메시지를 통해 위임할 수 있다면, 그것은 명백히 캡슐화된 객체라 할 수 있다고 생각한다. 결국 상태의 유무는 캡슐화의 필요조건이 아니며, 오히려 중요한 것은 객체가 자율적으로 행동하고, 외부와의 상호작용을 메시지를 통해 명확하게 제한하는 구조를 갖추는 것이다. 상태가 없는 객체는 이러한 구조를 충실히 따르며, 명확한 책임을 가지고 행동을 수행하면서 외부와의 의존성을 최소화한다. 따라서 자연스럽게 높은 응집도와 낮은 결합도를 갖는다.
응집도가 높다는 것은 객체가 수행할 책임이 명확하고 일관적이라는 것을 의미한다. 예를 들어, StraightMoveStrategy라는 전략 객체가 장기말의 직선 이동 검증이라는 단일 책임만 가지고 있다면, 이는 매우 명확한 책임이자 높은 응집도를 의미한다.
결합도는 객체 간의 의존성을 나타내는데, 상태가 없는 객체는 내부 상태를 유지하지 않고 외부에서 주어진 입력만 활용하기 때문에 일반적으로 다른 객체에 대한 의존이 줄어들며 결합도 또한 낮아지는 경향이 있다.
하지만 상태가 없고 메서드만 있는 클래스라면 굳이 객체로 만들지 않고 static 메서드로 구성된 클래스로 바꿔도 되지 않을까 하는 의문이 생길 수 있다. 기능적으로 보면 유사하지만 객체지향적 관점에서 둘은 분명히 다르다. 객체지향에서 인스턴스 메서드는 '객체에게 메시지를 보내고 객체가 책임을 지고 응답한다'는 의미를 갖는다. 이 방식은 객체 간의 협력뿐만 아니라, 다형성을 활용하여 런타임에 객체를 동적으로 교체할 수 있는 유연한 설계를 가능하게 한다. 반면 static 메서드는 클래스 수준에서 미리 정해진 방식으로 호출하는 절차지향적 접근 방식에 가깝기 때문에 객체지향적 유연성이 떨어진다.
결론적으로 상태가 없는 객체와 상태를 가진 객체는 어느 쪽이 더 우수하거나 열등한 것이 아니라 모두 객체로서 유효하다. 객체를 정의하는 본질은 상태나 행동이 아니라 책임과 역할, 그리고 객체 간의 협력에 있다는 점이라고 생각한다.
순환 참조
객체 간의 의존 관계를 설계할 때, 종종 의미적으로는 맞지만 구조적으로 위태로운 상황에 놓이곤 한다. 특히 상호 간에 서로를 참조해야 하는 구조, 순환 참조(circular dependency)가 대표적인 예시다.
내가 만든 장기 게임에서는 Board가 모든 Piece들을 소유하고 관리하는 구조였다. 그런데 각 Piece는 자신이 움직일 수 있는지 판단하기 위해, 보드 위의 다른 말들의 위치에 대한 정보가 필요했다. 자연스럽게 Piece 객체가 Board를 참조하게 되었다.
public class Board {
// 조회 기능들
public boolean isExists(...) {
...
}
public boolean isAllyAt(...) {
...
}
public boolean isPalace(...) {
...
}
public boolean isCenterOfPalace(...) {
...
}
...
// 명령 기능
public void movePiece(...) {
...
final Piece movedPiece = selectedPiece.move(this, destination);
...
}
...
}
public abstract class Piece {
...
public Piece move(final Board board, ...) {
validateMove(board, destination);
...
}
...
}
위 코드를 살펴보자. "말이 움직이려면 보드의 상황이 필요하다"는 점은 의미상 어색하지 않다. 문제는 Piece가 Board를 참조함으로써 양방향 의존, 즉 순환 참조가 생겼다는 점이다. Board는 Piece를 알고, Piece도 Board를 안다. 이로 인해 의존성이 복잡하게 얽히기 시작할 수 있다. 뿐만 아니라, Piece가 Board의 모든 메서드에 접근할 수 있었기 때문에, 의도하지 않은 방식으로 보드를 조작할 가능성도 있었다.
예를 들어 Piece 내부에서 board.movePiece(...)를 호출해 다른 말을 강제로 이동시킬 수도 있는 구조였던 것이다.
문제를 해결하기 위해 의존 역전 원칙을 적용했다. 핵심은 Piece가 Board가 아닌, 추상화된 BoardContext에 의존하게 만드는 것이다.
public interface BoardContext {
// Piece가 필요로 하는 최소한의 기능만 정의
// 즉, 조회 기능들
}
public class Board implements BoardContext {
// BoardContext의 기능 + 명령 기능 + 기타 기능
}
public abstract class Piece {
...
// Board가 아닌 BoardContext를 전달받음
public Piece move(BoardContext boardContext, ...) {
validateMove(boardContext, destination);
...
}
...
}
순환 참조 (개선 전)
Board <-------> Piece
- Board는 모든 Piece들을 관리
- Piece는 움직임 검증을 위해 Board 참조
--------------------------------------------------------------------
의존 역전 원칙 적용 (개선 후)
Board
│
│ implements
↓
BoardContext (인터페이스)
↑
│ uses
│
Piece
- Piece는 BoardContext 인터페이스를 통해 최소한의 조회 기능만 사용
- Board는 BoardContext 인터페이스를 구현하며, 명령 기능과 조회 기능을 모두 포함
- Piece는 더 이상 Board의 모든 기능을 알지 못하므로, 실수로 보드를 조작하는 일은 구조적으로 불가능해졌다.
- Piece가 필요한 정보만 추상화된 형태로 의존하게 되었기 때문에, 테스트 시 BoardContext의 mock 또는 fake 객체를 사용하여 독립적인 테스트가 가능해졌다.
- 무엇보다 순환 참조가 제거되어, Board와 Piece가 서로 물고 늘어지는 구조에서 벗어났다.
이 구조는 인터페이스 분리 원칙도 충실히 따른다. Piece(클라이언트)가 Board의 모든 메서드를 알 필요는 없고, 오직 자신이 사용하는 기능만 인터페이스로 분리해서 의존하게 만든 것이다.
Value Object
좌표와 관련된 기능들을 설계할 때, 단순히 코드 구조만 나눈 것이 아니라, 장기 도메인 개념이 VO에 스며들지 않도록 의도적으로 분리했다.
coordinate 패키지에는 Row, Column, Vector, Distance, Position, Route 같은 클래스들이 있다. 이들은 모두 물리적인 좌표계 상의 위치, 거리, 방향 등의 개념만을 다룬다. 예를 들어 vector.down()은 단순히 y축 방향으로 -1을 이동하는 벡터일 뿐이다. 이것이 장기에서의 움직임과 연결되는지는 전적으로 도메인 객체가 해석할 책임이다. (즉, 의존의 방향이 항상 도메인 객체 -> VO 이다.)
이러한 구조 덕분에 좌표 관련 VO들은 독립적으로 테스트할 수 있고, 체스나 다른 보드 게임 같은 다른 도메인에도 거의 수정 없이 재사용할 수 있는 구조가 되었다. 또, 도메인 용어를 VO에 끌어들이지 않음으로써 책임과 의미의 중복을 방지할 수 있다.
추후 복잡도가 증가하면, 별도의 도메인 객체가 VO를 감싸는 방식으로 대응하면 된다고 생각한다.
마무리
설계의 충돌 속에서, 객체 하나하나를 어떻게 바라보고 책임을 나눌지에 대해 끝없이 되묻는 시간이었다.
모든 고민은 결국 하나의 질문으로 모였다.
"내 기준은 무엇인가?"
고민이 이어질 것 같다.
기타
훅 메서드 VS 추상 메서드, IDE 추적성을 포함한 가독성


예외 메시지의 위치, 추상체 VS 구현체


정적 팩토리 메서드



'우아한테크코스' 카테고리의 다른 글
레벨 1 회고 (1) | 2025.04.08 |
---|---|
[블랙잭 미션] 회고 : Ace, Hit, SRP (1) | 2025.03.17 |
[출석 미션] 회고 : TDD를 미워합니다 (5) | 2025.03.05 |
[로또 미션] 회고 : 수동적인 객체 (0) | 2025.02.19 |
[우테코 7기 프리코스 최종] 회고, 합격 (4) | 2024.12.17 |