우아한테크코스 레벨 1에서 학습한 내용을 정리한 글입니다.
💭 들어가며
코드 리뷰에서 조합을 사용해 보는 것을 권유받았다. 조합..? 조합에 대해서 전혀 몰랐던 나는 관련 아티클을 무작정 찾아보며 도입을 시도하고, 장단점을 직접 분석해보고자 했다. 찾아보니 객체지향 설계 원칙 중에 "상속보다 조합을 우선하라"라는 말을 발견했다. "대놓고 상속보다 조합을 사용하라고 하네?"라는 생각이 들었지만, 왜 조합을 써야 하는지, 어떤 상황에서 조합이 좋은지 정확히 몰랐기 때문에 이를 정리하고, 실제 미션에 적용해 보기로 했다.
✅ 상속 (Inheritance)
상속은 부모 클래스의 속성과 행동을 자식 클래스가 물려받는 방식이다.
🔽 장점
- 부모 클래스의 정의된 로직을 자식 클래스가 그대로 사용할 수 있어 코드 재사용성이 높다.
- 여러 자식 클래스가 동일한 부모 클래스를 기반으로 동작하므로 구조 파악이 쉽다.
- 다형성을 활용해 부모 타입으로 자식 객체를 다룰 수 있다.
🔽 단점
- 부모 클래스에 강하게 의존하게 되므로, 부모의 변경이 자식에게 직접적인 영향을 미칠 수 있다. (강결합)
- 필요한 기능만 선택적으로 가져올 수 없고, 전체를 상속받아야 하는 경우가 생길 수 있다.
- 인터페이스가 아닌 경우, 단일 상속만 가능하기 때문에 유연성이 떨어진다.
🔽 적절한 사용 시점
- is-a 관계일 경우 (예: Bird is an Animal)
- 공통 로직이 강하게 공유될 필요가 있을 경우
- 클래스 간 계층 구조가 자연스러운 경우
🔽 예시 코드
class Animal {
void move() {
System.out.println("움직인다");
}
}
class Bird extends Animal {
void fly() {
System.out.println("날아간다");
}
}
추상 클래스, 인터페이스, 일반 상속에 대한 자세한 내용은 여기에 정리해 두었다.
✅ 조합 (Composition)
조합은 한 객체가 다른 객체를 멤버 변수로 포함하고, 해당 객체에게 기능을 위임하는 방식이다.
🔽 장점
- 포함된 객체의 내부 구현이 변경되더라도 외부에 미치는 영향이 적어, 유지보수가 용이한 느슨한 결합 구조를 만든다.
- 필요한 기능만 선택적으로 사용할 수 있다.
🔽 단점
- 위임해야 할 메서드가 많아질 경우, 구조 파악이 어려워질 수 있다.
- 상속처럼 자동으로 기능이 주어지지 않기 때문에, 직접 위임하는 작업이 필요하다.
🔽 적절한 사용 시점
- has-a 관계일 경우 (예: Car has an Engine)
- 기능을 선택적으로 조합하고 싶을 경우
- 유지보수가 중요한 상황일 경우
🔽 예시 코드
class Engine {
void run() {
System.out.println("엔진이 작동한다");
}
}
class Car {
private Engine engine = new Engine();
void drive() {
engine.run();
System.out.println("자동차가 달린다");
}
}
✅ 왜 상속보다 조합을 사용해야 할까?
내가 생각하는 좋은 설계란, 유지보수가 쉽고 변경이 용이한 구조이다. 이는 객체지향 설계의 핵심 원칙인 SOLID 원칙에서도 강조되는 부분이다. 상속을 사용하면 부모 클래스와 자식 클래스가 강하게 결합되어 있어, 부모 클래스에 변경이 생길 경우 자식 클래스 전체에 영향을 줄 수 있다. 이처럼 상속은 구조적으로 변화에 취약한 설계가 되기 쉽다.
실제로 실무에서는 상속을 거의 사용하지 않는다고 들었는데, 그 이유는, 절대 바뀌지 않을 것 같았던 요소들조차 바뀌는 경우가 많기 때문이다. 따라서 이런 변화에 유연하게 대응할 수 있는 조합이 더욱 선호되는 것이다.
✅ 장기 미션에서의 조합
- PR 링크: 장기 미션 Step2 PR 링크
장기에는 차, 포, 궁, 사, 병, 졸, 마, 상과 같은 기물들이 존재한다. Step1에서는 Piece라는 추상 클래스를 만들고, 이를 상속받아 각 기물을 구현했었다. 하지만 미션을 진행하면서 Piece 클래스를 수정할 때마다 자식 클래스가 직접적인 영향을 받는 문제를 겪었다. (물론 내 코드 설계의 미숙함도 있었겠지만) 이 과정을 통해 상속 구조의 한계를 체감했고, Step2에서는 이를 조합 기반 구조로 리팩터링하게 되었다.
🔽 Step1 상속 코드
public abstract class Piece {
protected final Team team;
public Piece(Team team) {
this.team = team;
}
public final boolean canMove(Board board, Coordinate departure, Coordinate arrival) {
if (!findMovableCandidates(departure).contains(arrival)) {
return false;
}
if (!canMoveConsideringObstacles(board, departure, arrival)) {
return false;
}
return true;
}
protected abstract Set<Coordinate> findMovableCandidates(Coordinate departure);
protected abstract boolean canMoveConsideringObstacles(Board board, Coordinate departure, Coordinate arrival);
protected abstract Set<Coordinate> findPaths(Coordinate departure, Coordinate arrival);
// ...
}
public class Cha extends Piece {
public Cha(Team team) {
super(team);
}
@Override
protected Set<Coordinate> findMovableCandidates(Coordinate departure) {
return departure.moveByCross();
}
@Override
protected boolean canMoveConsideringObstacles(Board board, Coordinate departure, Coordinate arrival) {
return findPaths(departure, arrival)
.stream()
.noneMatch(board::hasPiece);
}
@Override
protected Set<Coordinate> findPaths(Coordinate departure, Coordinate arrival) {
// ...
}
}
🔽 Step2 조합 코드
public class Piece {
private final Team team;
private final PieceType pieceType;
public Piece(Team team, PieceType pieceType) {
this.team = team;
this.pieceType = pieceType;
}
public boolean canMove(Board board, Coordinate departure, Coordinate arrival) {
if (!validateObstacle(board, departure, arrival)) {
return false;
}
if (!validateMovable(departure, arrival)) {
return false;
}
return true;
}
private boolean validateObstacle(Board board, Coordinate departure, Coordinate arrival) {
return pieceType.getObstacleValidators().stream()
.allMatch(pathValidator -> pathValidator.validate(board, departure, arrival));
}
private boolean validateMovable(Coordinate departure, Coordinate arrival) {
return pieceType.getMovableValidators().stream()
.flatMap(pathGenerator -> pathGenerator.generate(departure).stream())
.collect(Collectors.toSet())
.contains(arrival);
}
// ...
}
public enum PieceType {
GOONG(
List.of(new CrossOneInCastlePathGenerator(),
new DiagonalOneInCastlePathGenerator()),
List.of()
),
SA(
List.of(new CrossOneInCastlePathGenerator(),
new DiagonalOneInCastlePathGenerator()),
List.of()
),
// ...
CHA(
List.of(new CrossPathGenerator(),
new DiagonalInCastlePathGenerator()),
List.of(new CrossPathValidator())
),
;
private final List<PathGenerator> pathGenerators;
private final List<PathValidator> pathValidators;
PieceType(List<PathGenerator> pathGenerators, List<PathValidator> pathValidators) {
this.pathGenerators = pathGenerators;
this.pathValidators = pathValidators;
}
// ...
}
직접 조합 구조로 바꿔보니, 공통된 동작을 분리해 재사용할 수 있었고, (장기는 기물이 고정된 시스템이긴 하지만) 새로운 기물이 추가된다면 쉽게 추가할 수 있어 OCP를 만족시키기에도 더 적합한 방식이라고 느꼈다. 또한, 이후에 PathGenerator나 PathValidator의 내부 로직이 바뀌더라도 Piece 클래스는 상속 구조보다 변경의 영향을 적게 받는 설계가 된다는 점도 장점이었다.
📍 참고 자료
'Programming > Java' 카테고리의 다른 글
[Java] 불변 객체, 값 객체 (0) | 2025.04.13 |
---|---|
[Java] Optional (0) | 2025.04.07 |
[Java] 인터페이스, 추상 클래스, 일반 상속 (0) | 2025.03.22 |
[Java] 제네릭(Generic) (0) | 2025.03.17 |
[Java] 컬렉션 프레임워크(Collection Framework) (0) | 2025.03.11 |