들어가며

레벨 1 마지막 미션으로 '장기'를 구현했다.
시작할 때까지만 해도 내가 이 복잡한 게임을 구현할 수 있을까? 했는데,
결국에는 DB도 연결하여 프로그램이 종료되어도 게임 데이터를 유지할 수 있게 되었다.
다만 블랙잭 때와 달리, 예정된 2주를 넘어 3주에 걸쳐 완성하였다...ㅎㅋ
기록하지 않으면 확장된 사고가 흐려지기에,,,
장기를 구현하며 고민했던 지점과, 그 과정에서 배운 점들을 차근차근 돌아보려 한다.
▼ PR 링크
cycle1: 보드 초기화 + 기물 이동
cycle2: 기물 확장 + DB 적용
장기판(Board) 자료구조: 배열 vs List vs Map
장기 게임에서 기물의 상태를 관리하기 위해서는 “특정 좌표에 어떤 기물이 있는가?”를 빠르게 조회할 수 있어야 한다.
그렇다면 이 요구사항을 기준으로 배열, List, Map 중 어떤 선택이 적절할까?
1. 배열 vs List: 타입 안정성
처음에는 2차원 배열을 통해 장기판을 표현하는 방법이 떠올랐다.
하지만 [배열보다는 리스트를 사용하라 - 이펙티브 자바 Item 28]에 근거하여 배열과 List를 비교했을 때, 가장 큰 차이는 타입 안정성이었다.
배열은 공변성(함께 변한다는 뜻을 지님)을 가지기 때문에 상위 타입으로 형변환이 가능하고,
이로 인해 잘못된 타입이 저장되는 경우를 런타임에서야 발견할 수 있다.
예를 들어 Integer[]는 Object[]로 다룰 수 있고, 이 상태에서 String 등 다른 타입의 값을 넣으면
런타임에서 ArrayStoreException이 발생한다.
반면 List는 불공변이기 때문에 형변환이 불가능하여, 타입 오류를 컴파일 시점에 방지할 수 있다.
따라서 List가 배열보다 안전하기에 더 나은 선택이라고 판단했다.
2. List vs Map: 탐색 비용
장기 게임에서는 매 턴마다 “이 좌표에 어떤 기물이 있는가?”를 조회해야 한다.
이 때 List를 이용한다면 특정 좌표의 기물을 찾기 위해 모든 기물을 순회해야 한다.
즉, 조회 시마다 시간복잡도는 O(N)이 되며,
이는 조회가 빈번한 장기 게임의 특성과 맞지 않는다고 판단했다.
반면 Map을 사용하면 좌표를 key로, 기물을 value로 관리할 수 있다.
- 조회 시간복잡도: O(1)
- “좌표 → 기물”이라는 관계가 코드에 직접적으로 드러남
따라서 최종적으로 장기판은 Map<Position, Piece> 구조로 관리하는 것이 가장 적합하다고 판단하였다.
장기 기물 설계: Piece 상속 vs MoveStrategy 인터페이스
장기의 기물들은 고유한 행마법을 지닌다.
이 때 조건문을 통해 기물 행마법을 구현하면, 여러 분기가 발생하며 중복 코드가 발생하게 된다.
그럼 유지보수하기 어렵지 않을까?
또한 새로운 기물이 추가되거나, 기존 기물의 이동 규칙이 변경되는 상황에서
최소한의 수정으로 확장할 수 있는 구조(OCP)를 만들고자 두 가지 설계를 비교했다.
1. Piece 상속: 기물 중심 설계
이 방식은 Piece를 추상 클래스로 두고,
각 기물이 이를 상속받아 자신의 이동 규칙을 구현하는 구조이다.
ㄴCannon
ㄴHorse
ㄴChariot 등
public abstract class Piece {
private final CampType campType;
protected Piece(CampType campType) {
this.campType = campType;
}
public abstract void validateMove(Position source, Position destination, BoardChecker board);
public abstract PieceType pieceType();
public final CampType campType() {
return campType;
}
public final boolean isSameCampType(CampType other) {
return this.campType == other;
}
}
public class Cannon extends Piece {
public Cannon(CampType campType) {
super(campType);
}
@Override
public void validateMove(Position source, Position destination, BoardChecker board) {
// 포 이동 규칙 검증
}
@Override
public PieceType pieceType() {
return PieceType.CANNON;
}
}
장점:
- 높은 응집도
- 기물의 상태(진영, 점수)와 행동(이동 로직)이 하나의 클래스에 모여 있어
해당 기물의 역할을 한눈에 파악할 수 있다.
- 기물의 상태(진영, 점수)와 행동(이동 로직)이 하나의 클래스에 모여 있어
- 직관적인 설계
- '포(Cannon)는 기물(Piece)의 일종이다'라는 IS-A 관계가 명확히 드러난다.
기물마다 이동 규칙에서 아래 예시처럼 똑같은 부분이 있어도
중복이라 보지 않고 '다른 기물과 공유되지 않는 고유한 이동 규칙'이라 여기면 상속도 충분히 좋은 설계라고 생각한다.
- 차(Chariot)와 포(Cannon)는 모두 상하좌우 직진 이동을 함
- 궁(General)과 사(Guard)는 이동 방식이 동일함
단점:
만약 위와 같은 이동 규칙을 중복으로 간주하여
공통 로직을 제거하기 위해 SlidingPiece, SingleStepPiece와 같은 중간 계층을 도입하면
상속 구조가 점점 깊어지게 되며, 아래와 같은 문제를 만든다.
- 이해하기 어려운 구조
- 현재 클래스에서 다루지 않는 상태 또는 메서드를 파악하기 위해 부모 클래스까지 모두 추적해야 한다.
이로 인해 코드를 한눈에 파악할 수 없는 문제가 존재한다.
- 현재 클래스에서 다루지 않는 상태 또는 메서드를 파악하기 위해 부모 클래스까지 모두 추적해야 한다.
- 강한 결합도
- 부모 클래스의 변경이 자식 클래스에 영향을 주기 때문에 변경에 취약하다.
- 요구사항이 변경되어 부모 클래스인 Piece에 새로운 기능이나 필드가 추가/수정되면, 이를 상속받는 모든 기물 클래스가 예상치 못한 버그에 노출될 위험이 크다.
- 유연성 저하
- 요구사항이 변경될 때, 계층 구조 자체를 수정해야 하는 상황이 발생할 수 있다.
겉으로 보면 중복을 잘 제거한 것처럼 보인다.
하지만 실제로는 현재 클래스만 봐서는 동작을 이해하기 어려워진다.
예를 들어 Cannon을 이해하려면:
- Cannon
- SlidingPiece
- Piece
를 모두 봐야 한다.
public class Cannon extends SlidingPiece {
public Cannon(CampType campType) {
super(campType);
}
@Override
public void validateMove(Position source, Position destination, BoardChecker board) {
validateStraightMove(source, destination); // 직선 이동 검증. 근데 이 메서드 어디에 정의되어 있지?
validateJumpRule(source, destination, board); // 넘는 기물 개수 검증
validateDifferentPieceRule(source, pathBeforeDestination, board); // 포를 넘지 않는지 검증
validateDestination(source, destination, board); //목적지 좌표의 기물이 포가 아닌지 검증
}
private void validateJumpRule(Position source, Position destination, BoardChecker board) {
int count = board.countPiecesBetween(source, destination);
if (count != 1) {
throw new IllegalArgumentException("포는 정확히 하나의 기물을 넘어야 합니다.");
}
}
private void validateDifferentPieceRule(Position source, List<Position> path, BoardChecker board) {
if (hasSamePieceType(source, path, board)) {
throw new IllegalArgumentException("포를 넘을 수 없습니다.");
}
}
private boolean hasSamePieceType(Position source, List<Position> path, BoardChecker board) {
return path.stream()
.anyMatch(position -> board.isSamePieceType(source, position));
}
private void validateDestination(Position source, Position destination, BoardChecker board) {
if (board.isSamePieceType(source, destination)) {
throw new IllegalArgumentException("목적지에 포가 존재합니다.");
}
}
@Override
public PieceType pieceType() {
return PieceType.CANNON;
}
}
이 코드만 보면 validateStraightMove()가 어디 있는지 바로 알 수 없고,
결국 부모 클래스를 계속 타고 올라가야 한다.
public abstract class SlidingPiece extends Piece {
protected SlidingPiece(CampType campType) {
super(campType);
}
protected void validateStraightMove(Position source, Position destination) {
boolean isStraight = source.row() == destination.row()
|| source.col() == destination.col();
if (!isStraight) {
throw new IllegalArgumentException("직선 이동만 가능합니다.");
}
}
}
따라서 중복을 줄이기 위해 계층을 나누면 나눌수록,
현재 클래스가 어떤 책임을 가지고 어떤 방식으로 동작하는지 한눈에 파악하기 어렵게되어 코드 읽는 비용이 커진다.
2. MoveStrategy 인터페이스: 움직임 중심 설계
이 방식은 기물의 이동 규칙을 MoveStrategy 인터페이스로 분리하고,
일반 Piece 클래스 + Enum에 각 기물 타입마다 전략을 위임받아 사용하는 구조이다.
public record Piece(PieceType pieceType, CampType campType) {
public void validateMove(Position source, Position destination, BoardChecker board) {
pieceRule.validateMove(source, destination, board);
}
public boolean isSamePieceType(PieceType pieceType) {
return this.pieceType == pieceType;
}
public boolean isSameCampType(CampType campType) {
return this.campType() == campType;
}
}
public enum PieceType {
GENERAL(new GeneralStrategy()),
CHARIOT(new ChariotStrategy()),
HORSE(new HorseStrategy()),
CANNON(new CannonStrategy()),
GUARD(new GuardStrategy()),
ELEPHANT(new ElephantStrategy()),
SOLDIER(new SoldierStrategy()),
;
private final MoveStrategy moveStrategy;
PieceType(MoveStrategy moveStrategy) {
this.moveStrategy = moveStrategy;
}
public void validateMove(Position source, Position destination, BoardChecker board) {
moveStrategy.validate(source, destination, board);
}
}
장점:
- 변경과 확장에 유연함
- 이동 규칙이 독립된 객체로 분리되어 있어, 규칙이 변경되더라도 기존 기물 코드를 직접 수정할 필요가 줄어든다.
- 새로운 이동 규칙이 필요할 경우 전략 클래스를 추가하는 방식으로 확장할 수 있어 OCP를 지키기 쉽다.
- 중복되는 이동 규칙만 필요에 따라 교체하거나 조합할 수 있다.
- 관심사 분리
- 기물은 진영, 타입, 점수와 같은 상태를 담당하고, 이동 규칙은 MoveStrategy가 담당한다.
이로 인해, 기물 데이터와 이동 로직의 책임이 분리되어 각 객체의 역할이 더 명확해진다.
- 기물은 진영, 타입, 점수와 같은 상태를 담당하고, 이동 규칙은 MoveStrategy가 담당한다.
- 명확한 테스트 단위
- 이동 규칙이 전략 클래스 단위로 분리되기 때문에, 각 기물의 이동 규칙을 독립적으로 테스트할 수 있다.
따라서 기물 객체 전체를 테스트하기보다, “이 이동 규칙이 올바른가”에 집중할 수 있다.
- 이동 규칙이 전략 클래스 단위로 분리되기 때문에, 각 기물의 이동 규칙을 독립적으로 테스트할 수 있다.
예를 들어 아래 코드 처럼 여러 전략을 구현하여 행마법을 조합할 수 있다.
public enum PieceType {
GENERAL(new SingleStepStrategy(), new EmptyCondition()),
CHARIOT(new MultiStepStrategy(), new EmptyCondition()),
HORSE(new HorseStrategy(), new EmptyCondition()),
CANNON(new MultiStepStrategy(), new OnePieceExistsCondition()),
GUARD(new SingleStepStrategy(), new EmptyCondition()),
ELEPHANT(new ElephantStrategy(), new EmptyCondition()),
SOLDIER(new SoldierStrategy(), new EmptyCondition());
private final MoveStrategy moveStrategy;
private final MoveCondition moveCondition;
PieceRule(MoveStrategy moveStrategy, MoveCondition moveCondition) {
this.moveStrategy = moveStrategy;
this.moveCondition = moveCondition;
}
public List<Position> findPath(Position source, Position destination, Camp camp) {
return moveStrategy.findPath(source, destination, camp);
}
public void checkPath(List<Position> path, Camp camp, BoardChecker board) {
moveCondition.checkPath(path, camp, board, this);
}
}
단점:
- 이동 규칙 이해 비용 증가
- “마는 어떻게 움직이는가?”를 파악하려면
Piece, PieceType, HorseMoveStrategy 등 여러 클래스를 확인해야 한다.
- “마는 어떻게 움직이는가?”를 파악하려면
결론
상속은 분명 매력적인 선택이다.
각 기물이 자신의 상태와 행마법을 함께 가지고 있기 때문에, 구조도 직관적이고 응집도도 높다.
다만 각 기물마다 달라지는 것이 결국 이동 규칙이라는 점에서,
이동 규칙을 별도의 객체로 분리하는 것이 더 유연하다고 느꼈다.
또한 이동 규칙을 조합시킬 수 있다는 점에서 좀 더 확장성이 높다고 느꼈다.
그래서 이번 구현에서는
기물을 나누기보다는 행동을 나누는 쪽에 집중했고,
상속보다 조합을 선택했다.
MoveStrategy: 어디까지 분리할 것인가?
MoveStrategy를 도입하면서 또 하나 고민했던 지점은, 이동 규칙을 어디까지 분리할 것인가였다.
처음에는 기물마다 겹치는 규칙을 중복으로 보고 겹치는 부분을 전략으로 만들었다.
GENERAL(new SingleStepStrategy(), new EmptyCondition()),
CHARIOT(new MultiStepStrategy(), new EmptyCondition()),
HORSE(new HorseStrategy(), new EmptyCondition()),
CANNON(new MultiStepStrategy(), new OnePieceExistsCondition()),
GUARD(new SingleStepStrategy(), new EmptyCondition()),
ELEPHANT(new ElephantStrategy(), new EmptyCondition()),
SOLDIER(new SoldierStrategy(), new EmptyCondition());
그랬더니 아래와 같은 리뷰를 받았다.

리뷰를 받은 후에는 추상화 수준을 맞추고자 공통 로직을 더 분리하는 시도를 했다.
여러 전략을 재사용 및 조합시키는 게 전략패턴을 사용하는 장점이라 생각했기 때문이다.
(보다 자세한 코드는 pr 참고)
GENERAL(new SingleStepRule(false)),
CHARIOT(new EmptySlidingRule()),
HORSE(new DiagonalStepRule(1)), // 대각선 방향으로 몇 칸(1) 이동하는지
CANNON(new JumpingSlidingRule()),
GUARD(new SingleStepRule(false)),
ELEPHANT(new DiagonalStepRule(2)),
SOLDIER(new SingleStepRule(true)); // 후진 이동 불가(true)
그 결과, 몇 가지 문제가 더 생겼다...ㅋㅋ쿠
- 이동 규칙이 여러 클래스에 분산되어 있다.
- 포처럼 여러 조건이 있는 경우, 각 조건을 전략으로 나눠(점프 전략, 같은 타입 이동 불가 전략 등) 조합하면
코드를 한눈에 파악하기 어렵기 때문에 오히려 이해하기 어려운 구조가 된다.
- 포처럼 여러 조건이 있는 경우, 각 조건을 전략으로 나눠(점프 전략, 같은 타입 이동 불가 전략 등) 조합하면
- 현재 공통이라고 생각한 부분이 나중에도 공통일지 확신하기 어렵다.
- 예시로, 궁, 사, 졸 모두 1칸 이동이라는 공통점을 가지지만,
요구사항이 변경되면 특정 기물만 다른 규칙을 가지게 될 수도 있다. - 이 때, 공통 전략을 수정해야 하는 상황이 생기면 다른 기물에도 영향이 갈 수 있다.
결국 특정 기물 때문에 공통 전략 내부에 분기가 생기거나, 새로운 전략을 다시 만들어야 하는 상황이 생길 수 있다.
또한 새로운 전략을 만들면서 추상화 수준도 일정하지 않을 것이다.
- 예시로, 궁, 사, 졸 모두 1칸 이동이라는 공통점을 가지지만,
최종적으로는, 각 기물마다 하나의 이동 전략을 가지는 방식으로 결정했다.
GENERAL(new GeneralStrategy()),
CHARIOT(new ChariotStrategy()),
HORSE(new HorseStrategy()),
CANNON(new CannonStrategy()),
GUARD(new GuardStrategy()),
ELEPHANT(new ElephantStrategy()),
SOLDIER(new SoldierStrategy());
그 이유는 장기 규칙 변경이 대부분 기물 단위로 발생한다고 판단했기 때문이다.
예를 들어 요구사항은 보통 다음과 같이 들어온다.
- 포의 행마법 변경
- 마의 행마법 변경
+ 리뷰어 분께도 의견을 여쭤봤는데 다음과 같이 답변을 받았다.

결론
확장 가능성을 지나치게 고려하면서 깨달은 점이 있다. 바로 YAGNI(You Aren’t Gonna Need It) 법칙이다.
확장을 대비한 설계는 분명 중요하지만, 그만큼 코드의 복잡도도 함께 증가한다.
문제는 그렇게 미리 고려한 확장이 실제로 필요할지 확신할 수 없다는 점이다.
오히려 불필요하게 복잡해진 구조가, 정작 변화가 필요할 때 발목을 잡기도 한다.
따라서 확장 가능성보다 중요한 것은 현재 요구사항에 맞는 단순하고 직관적인 구조라고 느꼈다.
모든 가능성을 미리 대비하기보다, 변화에 따라 구조를 유연하게 발전시킬 수 있어야 한다.
완벽한 설계를 처음부터 만드는 것보다, 요구사항에 맞춰 점진적으로 진화하는 코드가 더 좋은 방향임을 이번 미션을 통해 배웠다.
+ 확장성을 고려한 설계에 대한 리뷰어 분의 조언 ⬇️


테스트 코드도 코드다.
ElephantSetting을 enum으로 정의하여 사용했다.
다만 이 구조에서는 HAN과 CHO의 매개변수 순서가 잘못 들어가더라도,
타입이 ElephantSetting로 동일하기 때문에 컴파일 단계에서 이를 검증할 수 없다는 점이 고민이었다.
public static Map<Position, Piece> init(ElephantSetting hanChoice, ElephantSetting choChoice) {
Map<Position, Piece> board = new HashMap<>();
for (InitialPiecePlacement piece : values()) {
board.put(new Position(piece.row, piece.column), new Piece(piece.pieceRule, piece.camp));
}
board.putAll(hanChoice.createElephantOrder(Camp.HAN));
board.putAll(choChoice.createElephantOrder(Camp.CHO));
return board;
}
이와 같은 상황에서 개발자에게 매개변수 순서를 지켜야 한다는 인지적 부담을 주기보다는
타입을 더 세분화하여 컴파일 타임에 검증할 수 있도록 구조를 강화하는 방향을 먼저 떠올렸다.
하지만 타입을 추가하는 만큼, 코드의 복잡도가 증가한다는 점이 고민이었다.
따라서 이 고민을 리뷰어분께 공유했고, 구조를 단순하게 유지하면서도 테스트 코드작성을 통해 매개변수 순서를 보장할 수 있다는 조언을 얻었다.

그동안 테스트 코드를 이러한 관점에서 활용해볼 생각을 하지 못했기 때문에, 새로운 접근 방식이라는 점에서 인상 깊었다.
앞으로 비슷한 상황이 생긴다면, 구조를 복잡하게 만들기 전에 테스트 코드로 해결할 수 있는지 먼저 고민해봐야겠다!


또한 테스트 코드를 작성하면서, 동작을 검증하는 것을 넘어
추상화 수준에 맞는 메서드 이름을 짓고, 중복 코드를 줄이는 것 신경 써야 한다는 것을 리뷰를 통해 배웠다.
테스트 코드는 살아있는 문서로서의 역할을 하기 때문에, 프로덕션 코드만큼 유지보수가 중요하며
그렇기에 더욱 신경 써야 한다는 점을 깨달을 수 있었다!
궁성 설계: PalaceStrategy 상속 vs Board내 Palace 주입
궁성 규칙을 기물에 적용하는 과정에서,
각 기물의 이동 전략 클래스마다 궁성 범위를 검사하는 if문이 반복적으로 등장했다.
이 과정에서 "대부분의 기물이 궁성 범위를 확인하고 있는데, 이 흐름을 한 곳에서 제어할 수는 없을까?"라는 의문이 들었고,
이를 해결하기 위해 템플릿 메서드 패턴을 적용하게 되었다.

궁성 내 좌표 위치 로직(isPalaceRange, isPalaceDiagonalPath 등)도 부모에 캡슐화하고,
하위 클래스는 validatePalaceMove와 validateNormalMove만 구현하도록 책임을 분리했다. (관련 PR 링크)
궁성 범위 인지 체크하는 if문 분기를 없애고, 중복 코드도 줄였기에 좋은 설계라고 느꼈다.
하지만, 돌아온 피드백에서는 상속 대신 조합을 사용하는 것에 대해 고민해보면 좋을 것 같다고 하셨다.

'왜 조합을 추천하셨을까?' 에 대해 고민해본 나의 결론은 다음과 같다.
- 현재 구조는 GeneralStrategy가 PalaceStrategy를 상속받는 형태가 되었는데,
이 관계를 IS-A로 보기에 다소 어색하다. - 궁성은 특정 이동 방식을 정의하기보다는 '장기판 위 지형 정보'에 가깝다.
- 현재 클래스에서 다루지 않는 메서드를 파악하기 위해 부모 클래스까지 모두 추적해야 하는 복잡함이 존재한다.
이에 따라 피드백 주신 대로 조합으로 분리하는 것이 더 자연스럽다고 느꼈다.
PalaceStrategy 상속 여부에 따라 나뉘던 전략 계층을 제거하니, 전체 구조도 보다 단순해지고 이해하기 쉽다고 느꼈다!
public class Board implements BoardChecker {
private final Map<Position, Piece> board;
private final Palace palace;
@Override
public boolean isPalaceRange(Position source, Position destination) {
return palace.isPalaceRange(source, destination);
}
@Override
public boolean isAllowedDiagonalPath(Position source, Position destination) {
return palace.isAllowedDiagonalPath(source, destination);
}
}
public class SoldierStrategy implements MoveStrategy {
private static final int DISTANCE = 1;
@Override
public void validate(Position source, Position destination, BoardChecker board) {
Movement movement = new Movement(source, destination);
if (board.isPalaceRange(source, destination) && board.isAllowedDiagonalPath(source, destination)) {
validateDiagonalMove(source, movement, board);
return;
}
validateStraightMove(source, movement, board);
}
}
public class GeneralStrategy implements MoveStrategy {
private static final int DISTANCE = 1;
@Override
public void validate(Position source, Position destination, BoardChecker board) {
Movement movement = new Movement(source, destination);
if (!board.isPalaceRange(source, destination)) {
throw new IllegalArgumentException(ExceptionMessage.INVALID_PALACE_MOVE.getMessage(DISTANCE));
}
if (movement.exceedsDistance(DISTANCE)) {
throw new IllegalArgumentException(ExceptionMessage.INVALID_PALACE_MOVE.getMessage(DISTANCE));
}
if (movement.isDiagonal() && !board.isAllowedDiagonalPath(source, destination)) {
throw new IllegalArgumentException(ExceptionMessage.INVALID_PALACE_MOVE.getMessage(DISTANCE));
}
}
}
Repository란 무엇일까?
에릭 에반스의 DDD(Domain-Driven Design)에 따르면, Repository는
마치 메모리 안에 객체 컬렉션이 있는 것처럼 도메인 객체를 다룰 수 있게 해주는 추상화
즉, 호출하는 쪽이랑 도메인 객체만 주고 받으면서,
호출하는 쪽에서는 DB가 있는지, SQL이 어떻게 생겼는지 전혀 몰라도 되도록 숨겨주는 역할을 한다.
코드로 표현하면 다음과 같다.
User user = userRepository.get(1L);
userRepository.save(user);
Repository를 사용하는 입장에서는 "DB에서 가져온다"가 아니라, "객체를 꺼내고 넣는다"는 느낌을 받지 않을까?
왜 Repository에 비즈니스 로직을 넣지 말라고 할까?
public long save() {
return insert("INSERT INTO games(current_turn, game_status) VALUES(?, ?)", CampType.CHO.name(), GameStatus.PLAYING.name());
}
public void updateTurn(long gameId, CampType currentTurn) {
update("UPDATE games SET current_turn = ?, last_updated_at = CURRENT_TIMESTAMP WHERE game_id = ?",
currentTurn.name(), gameId
);
}
public void updateStatus(long gameId, GameStatus gameStatus) {
update("UPDATE games SET game_status = ?, end_at = CURRENT_TIMESTAMP , last_updated_at = CURRENT_TIMESTAMP WHERE game_id = ?",
gameStatus.name(), gameId);
}


GameRepository에 다음과 같은 메서드를 작성했을 때
리뷰어께서는 메서드에 비지니스 로직이 포함된 것 같다며 Game 자체를 조회/저장/수정/삭제 하는 방식을 제안해 주셨다.
왜일까?
- 단일 책임 원칙(SRP) 위배
- Repository는 본래 데이터를 저장하고 조회하는 역할을 가진다.
- 하지만 여기에 비즈니스 로직이 포함되면, [저장소 역할 + 비즈니스 규칙 판단 + 상태 변경] 이라는 세 가지 책임을 동시에 가지게 된다.
즉, SRP 원칙을 위배하는 것이다.
- 테스트 복잡도 증가
- Repository의 테스트는 데이터가 정상적으로 저장되고 조회되는지 검증하는 데 초점이 맞춰져야 한다.
- 하지만 비즈니스 로직이 함께 포함되면, DB 동작 검증 & 비즈니스 규칙 검증 이 하나의 테스트에 섞이게 된다.
그 결과 테스트의 목적이 흐려지고, 속도와 복잡도 모두 증가하게 된다.
- 재사용성 저하
- 위의 update 메서드들은 특정 의미에 묶여 있기 때문에, 조금 다른 방식의 상태 변경이 필요해지면 기존 메서드를 재사용하지 못하고 유사한 메서드를 계속 추가하게 된다.
어디까지를 비즈니스 로직으로 볼 수 있지?
리뷰어분의 답변에 따르면
비지니스 로직이 변경됐을 때 Repository도 함께 변경된다면 비지니스 로직이 Repository에 포함됐다고 볼 수 있을 것 같다고 하셨다.
하지만 데이터가 많아져 성능 개선이 필요하다면, Repository에 비지니스 로직을 포함시키는 경우도 가끔 있다고 하셨다.

그래도 Repository를 사용할 때는 최대한 데이터를 조회하고 저장하는 기본적인 역할에만 집중할 수 있게 해야함을 배웠다!
Repository와 DAO의 차이점은 무엇일까?
- DAO: 데이터 접근 중심
- DAO(Data Access Object)는 이름 그대로 데이터 소스에 접근하는 로직을 캡슐화한 객체이다.
- Repository: 도메인 중심
- Repository는 데이터를 가져오는 것을 넘어서, 비즈니스에서 사용할 수 있는 도메인 객체를 구성해서 제공하는 계층이다.
public class UserRepository {
private UserDao userDao;
private TweetDao tweetDao;
public UserProfile findProfile(Long userId) {
User user = userDao.read(userId); // 데이터 하나 가져옴
List<Tweet> tweets = tweetDao.fetchTweets(user.getEmail());
return new UserProfile(user, tweets); // 도메인 조합
}
}
DAO와 Repository 모두 DB 외부 API 등 데이터 소스에 접근하는 로직을 캡슐화하는 역할을 지닌다.
다만, DAO는 대체로 DB의 테이블과 1:1 매핑이 되는 반면,
Repository는 여러 DAO를 활용해 도메인 객체를 비즈니스에 맞게 구성하고 제공하는 계층이다.
(연관되어 있는 객체가 없다면, 도메인 객체가 DB 테이블과 1:1 매핑이 되기도 한다.)
느낀 점
미션을 진행하면서 느낀 점이 여러 개 있다.
설계
- 도메인을 제대로 이해하지 못하면 좋은 설계를 할 수 없다,,,
- 장기를 처음 접해보다 보니, 기본적인 규칙과 기물의 행마법을 이해하는 데만 며칠이 걸렸다.
그래서 이 기간 동안 머리가 정말 느리게 돌아갔다. 어디에서 중복이 발생하는지, 어떤 구조로 설계하는 것이 더 나은지에 대한 판단이 쉽지 않았다.
페어 프로그래밍을 할 때도 의견을 내긴 했지만, 대부분 페어의 의견을 수용하게 되었고 페어의 의견도 완벽히 이해하지 못한 채 넘어가는 경우도 있었다. - 도메인에 대해 깊이 이해하고 있는 것이 가장 중요한 역량 중 하나라는 점을 다시 한번 깨달았던 시간이었다.
- 장기를 처음 접해보다 보니, 기본적인 규칙과 기물의 행마법을 이해하는 데만 며칠이 걸렸다.
- 일단 부딪혀보고 경험해야 확신이 생긴다.
- 여러 설계 방향이 고민될 때, 머릿속으로만 판단하다 보면 “정확한 이유는 모르겠지만 이 설계가 더 좋아 보이는데?”라는 생각이 자주 들었다.
하지만 이런 상태에서 다른 설계와 비교했을 때 무엇이 왜 더 좋은지 설명할 수 없었고, 결국 선택에 대한 확신도 부족했다. - 오히려 안 좋다고 생각하는 설계를 먼저 적용해보고, 그 과정에서 직접 불편함을 겪어본 이후에야 왜 특정 설계가 더 나은지 명확하게 이해할 수 있었다.
- 머릿속에서 백번 시뮬레이션 돌리는 것보다, 직접 한 번 경험해보는 게 더 의미있다는 것을 배웠다.
- 여러 설계 방향이 고민될 때, 머릿속으로만 판단하다 보면 “정확한 이유는 모르겠지만 이 설계가 더 좋아 보이는데?”라는 생각이 자주 들었다.
- 좋은 코드는 '동료 개발자가 쉽게 이해하고 수정할 수 있는 코드'이다.
- 이번 미션에 추상화&다형성을 적극적으로 적용해보면서, 코드를 작성하는 입장과 읽는 입장의 차이를 체감할 수 있었다.
- 코드 중복을 줄이기 위해 과도하게 추상화를 적용하다 보면,
읽는 사람 입장에서는 오히려 코드를 이해하는 데 시간이 오래 걸리고 쉽게 수정하기 어려운 형태가 될 수 있음을 배웠다. - 결국 협업에 있어 유지보수하기 좋은 코드의 1순위는 객체지향, 중복 없는 코드 이전에 이해가 쉬운 직관적인 코드이다.
단순한 것을 일부러 단순하게 두는 선택도, 추상화만큼이나 필요하다!
학습
- 토론을 자주 하자
- 처음에는 내가 무엇을 모르는지 명확히 인지하지 못한 상태이기 때문에, 다른 크루들의 PR을 읽으며 혼자 학습을 진행한 후 토론을 해야겠다고 생각했다.
- 하지만 이 방식은 시간이 오래 걸렸고, 코드만으로는 작성자의 의도까지 파악하는 데 한계가 있었다. (
아니 코드 작성자가 걸어서 최대 3분 거리에 있는데? 이걸 직접 안 물어 본다구?) - 그래서 방향을 바꿔 크루들에게 직접 질문하고 대화를 나누기 시작했다. 이 과정에서 내가 이해하지 못한 부분을 설명하려고 시도하면서 생각이 자연스럽게 정리되었고, 어디에서 막히고 있는지도 더 명확하게 드러났다. 이후에는 그 지점을 중심으로 학습을 진행하며 보다 효율적으로 문제를 해결할 수 있었다.
- 혼자 고민할 때 생각의 꼬리를 물고 아주 깊은 곳까지 파고들 수 있었지만, 자칫 시야가 좁아져 혼자만의 편향된 결론에 도달할 위험도 컸다.
- 깊은 고민에 빠져 치우친 결론이 날 것 같을 때면, 크루들에게 내가 가진 고민을 공유하고 토론했다.
이를 통해 갇혀 있던 시야를 확장하고 새로운 돌파구를 찾을 수 있었다.
- 리뷰어와 적극적으로 소통하자
- PR 요청이나 피드백에 답변할 때, 최대한 스스로 깊이 고민하고 생각을 정리한 뒤 리뷰어의 의견을 구하려고 했다.
- 하지만 이 과정이 생각보다 많은 시간을 필요로 한다는 것을 느꼈다.
또한 스스로 고민하다 보면 답을 찾을 수 있는 경우도 있었지만, 제한된 시간 안에 해결하지 못했다면 그 역시 현재 나의 실력 범위라는 점을 브라운과의 Q&A를 통해 깨달았다. - 결국 혼자 깊이 고민하는 것도 중요하지만, 리뷰어와 적극적으로 소통해 빠르게 인사이트를 얻는 것 또한 충분히 좋은 방법이라는 것을 배웠다.
마치며

객체지향에 집중하다 보니 DB 연결과 관련된 부분은 충분히 학습하지 못한 것 같다.
다음 주 부터 시작되는 레벨2 부터는 트랜잭션, SQL, 커넥션 풀 등 스프링 및 데이터베이스 관련 주제로 깊게 학습해야지!
정들었던 장기판도 안녕~~
참고 자료
- [📚 이펙티브 자바] Item 28 | 배열보다는 리스트를 사용하라
- DAO vs Repository Patterns | Baeldung
- Repository와 DAO의 차이점은 무엇일까
'Reflection' 카테고리의 다른 글
| [Gemini 웹앱 출시] 미션 회고 (2) | 2026.02.28 |
|---|---|
| 우아한테크코스 8기 최종 코딩테스트 회고 (4) | 2026.01.19 |
| 우아한테크코스 프리코스 - 3주 차 회고 (0) | 2025.11.04 |
| 우아한테크코스 프리코스 - 2주 차 회고 (0) | 2025.10.28 |
| 우아한테크코스 프리코스 - 1주 차 피드백 정리 (0) | 2025.10.23 |