객체 지향 프로그래밍을 하다 보면, 필연적으로 상속 구조를 활용하게 되는데요. 이 때, 서비스 로직에서 실제 인스턴스 타입별로 어떤 코드 실행을 다르게 해주어야 하는 경우가 발생합니다.
고민하지 않고 단순히 인스턴스 별 조건분기문으로 코드를 작성하게 되면, 새로운 요구사항이 추가될 때마다 코드의 변경이 많아질 수 있습니다. 또 그런 변경들이 프로그램의 로직을 복잡하게 만들어낼 수 있는데요. 오늘은 이런 상황에서 조건문을 사용하지 않고, 다형성을 활용할 수 있는 방안에 대해서 살펴보겠습니다.
상황
- 새를 나타내는
Bird
클래스가 있습니다. 이Bird
객체를 API 응답으로 내려주기 위해서는BirdResult
객체로 변형되어야 합니다.BirdResult 는 내부 객체와 API 응답 필드들을 분리하기 위하여 사용하고 있는 DTO 입니다.
- 이
BirdResult
를 생성하기 위해Builder
를 사용하고 있습니다.
Bird
로부터BirdResult
의 각 응답 필드를 세팅하는 메서드는 아래와 같습니다.// BirdResult.java /** * Bird 값을 주입한다. (As-is) * @param bird * @return Builder */ public Builder bird(Bird bird) { this.id = bird.getId(); this.name = bird.getName(); this.type = bird.getType(); // ... return this; }
새로운 요구사항
Bird
는 이제 새로운 하위타입들로 나누어져야 합니다. (Canary
,Duck
,MockingBird
)
- 각 하위 타입에 따라서
BirdResult
에 채워주어야 할 필드가 달라집니다.
새로운 요구사항에 맞춰서, Builder.bird(bird)
메서드의 구현은 어떻게 달라져야 할까요?
타입을 통해 분기한다.
- type 필드값이나, instanceof 를 통해 실제 객체 타입을 확인하여 분기할 수 있습니다.
// BirdResult.java
/**
* Bird 값을 주입한다. (Conditional)
* @param bird
* @return Builder
*/
public Builder bird(Bird bird) {
this.id = bird.getId();
this.name = bird.getName();
this.type = bird.getType();
switch (bird.getType()) {
case BirdType.Canary:
this.feather = (Canary)bird.getColor();
case BirdType.Duck:
this.beakSize = (Duck)bird.getBeakSize();
case BirdType.MockingBird:
this.message = (MockingBird)bird.getMessage();
break;
default:
// ...
}
return this;
}
하지만 위와 같이 구현했을 때, 몇 가지 문제점을 생각해볼 수 있습니다.
타입이 새로 만들어질 때마다, 그에 맞는 케이스를 새로 추가해주어야 합니다.
위와 같은 코드가 한 곳에서만 사용되었다면, 당장은 문제가 되지 않을 수 있습니다. 하지만 코드는 계속 변경되고 추가되는 것이 문제죠. Bird 객체를 사용하는 모든 메서드를 찾아가면서 조건 분기로 되어 있는 모든 곳을 찾아서 알맞게 수정해주어야 하는 문제점이 있습니다.
새로운 코드가 추가되거나 변경이 될 때, 해당 코드를 사용하는 다른 모듈에서 모두 변경이 이루어져야 한다면, 이는 개방-폐쇄 원칙 (Open-closed principle)을 위반한 코드가 됩니다. 따라서 변경이 되더라도, 사용하는 다른 모듈에서 변경하지 않아도 되도록 수정되어야 합니다.
다형성을 사용한 이유가 줄어들었습니다.
위 코드에서는 단지 다형성을 사용한 목적이 하나의 “타입” 값으로만 사용되고 있습니다. 그리고 그 타입을 통해 분기하여 다시 타입캐스팅을 하여 필요한 메서드를 일일이 호출하고 있습니다.
다형성의 성격 중 하나는 객체가 스스로 실행할 메서드를 실행 시점에서 결정할 수 있다는 것입니다. (이것을 동적 바인딩이라고 합니다.) 상위 클래스의 메서드를 재정의(overriding) 하면, 해당 메서드의 실행 시점에서 하위 클래스에서 재정의한 메서드를 실행하도록 결정됩니다. 그렇기 때문에 객체를 사용하는 모듈에서는 수정이 되더라도 변경을 할 필요가 없어지도록 구현할 수 있을 것입니다.
다중정의(overloading) 를 사용한다.
.bird(bird)
를 다중정의(overloading)하여 사용할 수 있습니다.
// BirdResult.java
/**
* Bird 값을 주입한다. (Overloading)
* @param bird
* @return Builder
*/
public Builder bird(Bird serverGroup) {
this.id = bird.getId();
this.name = bird.getName();
this.type = bird.getType();
return this;
}
public Builder bird(Canary bird) {
this.bird((Bird)bird)
this.feather = (Canary)bird.getColor();
return this;
}
public Builder bird(MockingBird bird) {
this.bird((Bird)bird)
this.message = (MockingBird)bird.getMessage();
return this;
}
이렇게 구현했을 땐, 이제 받는 객체 타입에 따라서 다른 필드들이 세팅될 수 있습니다. 그리고 함께 세팅되어야 하는 공통 필드들에 대해서도 잘 설정될 수 있습니다. 하지만 아쉽게도, 위와 같은 구현 방식은 개방-폐쇄 원칙에 대해서 해결하지 못하고 있습니다.
새로운 타입이 만들어질 때마다, 새로운 메서드를 정의해주어야 한다.
위 bird
메서드를 호출하는 쪽에서, 어떤 타입인지 먼저 알고 있어야 하고, 타입에 따라 항상 타입 캐스팅을 해주어야 합니다. 그렇지 않으면 잘못된 메서드가 호출될 수 있습니다. 다시 말하면, 위 코드는 Bird
타입에 대한 분기를 Builder
를 호출하는 쪽으로 옮겨놓는 수준으로 생각할 수 있습니다.
아래 코드 예시를 통해 알아보겠습니다.
// SomethingService.java
// 상위 타입인 Bird 로 받았습니다. 실제 객체가 어떤 타입인지는 알 수 없습니다.
Bird bird = birdService.getBird(birdId);
Builder builder = new Bird.Builder();
// 아래 메서드에서는 bird(Bird bird) 가 호출됩니다. 상위 타입으로 전달했기 때문입니다.
builder.bird(bird);
// SomethingService.java
// 각 타입에 맞게 호출되기 위해서는 빌더 호출 시에 아래와 같은 분기가 필요합니다.
if (bird instanceof Canary) {
builder.bird((Canary)bird));
} else if (bird instanceof MockingBird) {
builder.bird((MockingBird)bird));
} else {
builder.bird(bird);
}
그렇다면, 다형성을 이용하여 위 코드를 구현하려면 어떻게 할 수 있을까요?
Builder 에서 상위 타입의 메서드를 호출하도록 정의하고, 각 하위 타입에서 메서드를 재정의(Overriding) 한다.
타입에 따라 메서드의 구현이 달라져야 하는 경우에, 여러 하위 타입에서 메서드에 대해 재정의를 하면 일일이 조건문을 작성하지 않아도 다형적으로 호출되게 할 수 있습니다. 아래 처럼 상위 타입의 메서드를 호출하면, 각 하위 타입의 재정의된 메서드를 따라가도록 구현할 수 있습니다.
아래처럼 Builder 의 구현에서는 상위 타입의 putResult(Builder)
메서드를 호출합니다. putResult
에서는 전달한 builder
의 필드에 필요한 항목들을 주입해주는 역할을 해줍니다.
// BirdResult.java
/**
* Bird 값을 주입한다. (Polymorphism)
* @param bird
* @return Builder
*/
public Builder bird(Bird bird) {
bird.putResults(this); // bird 에서 builder (this) 에 직접 주입한다.
return this;
}
이 때 Builder 에서 구현되어 있던 필드 설정 부분들을 옮기기 위해 다음과 같이 변경합니다.
- 상위 타입에서 호출될 메서드를 작성하여, 하위 타입에서 재정의할 수 있도록 합니다.
- 각 하위 타입 별로 분기문에서 수행되었던 코드를 각 타입의 재정의한 메서드 안으로 옮깁니다.
즉, 각각의 Canary
, MockingBird
타입에서는 추상 클래스 Bird.putResult(Builder)
에 대한 구현이 이루어져야 합니다.
// Bird.java
public abstract class Bird {
// ...
/**
* 서버 그룹 필드값들을 BirdResult.Builder 에 할당한다.
* @param builder 응답 객체 빌더
*/
public void putResult(BirdResult.Builder builder) {
// 상위 타입에서 호출될 메서드를 작성하여, 하위 타입에서 재정의할 수 있도록 한다.
builder.id(this.id);
builder.name(this.name);
builder.type(this.type);
}
}
// Canary.java
public class Canary extends Bird {
// ...
/**
* 서버 그룹 필드값들을 BirdResult.Builder 에 할당한다.
* - Bird.putResult 를 오버라이딩하여 동적바인딩 되도록 합니다.
*
* @param builder 응답 객체 빌더
*/
@Override
public void putResult(BirdResult.Builder builder) {
// 공통 필드 세팅
super.putResult(builder);
// Canary 필드 세팅
builder.feather(this.feather);
}
}
// MockingBird.java
public class MockingBird extends Bird {
// ...
/**
* 서버 그룹 필드값들을 BirdResult.Builder 에 할당한다.
* - Bird.putResult 를 오버라이딩하여 동적바인딩 되도록 합니다.
*
* @param builder 응답 객체 빌더
*/
@Override
public void putResult(BirdResult.Builder builder) {
// 공통 필드 세팅
super.putResult(builder);
// MockingBird 필드 세팅
builder.message(this.message);
}
}
이렇게 구현을 하면, 다음과 같은 장점이 있습니다.
- 타입에 따라 기능이 달라지는 여러 객체가 있을 때에도 일일이 조건문을 작성하지 않고, 다형적으로 호출되게 할 수 있습니다.
- 새로운 타입이 추가되더라도,
putResult(Builder)
만 알맞게 구현해준다면,Builder.bird(Bird)
를 변경하지 않고, 그대로 사용할 수 있습니다. 따라서 개방-폐쇄 원칙도 지킬 수 있게 되었습니다.
이 방법에 대해서 좀 더 자세하게 확인해보시고 싶으신 분들은 리팩터링(마틴 파울러)에서 조건문을 재정의로 전환 (Replace Conditional with Polymorphism) 을 살펴보시면 좋을 것 같습니다. 유명한 리팩터링 항목이기 때문에, 단순 구글링을 하셔도 다양한 글들을 확인하실 수 있습니다.