ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 다형성을 사용하도록 조건문 다시 작성하기
    Java 2023. 3. 26. 22:10

    객체 지향 프로그래밍을 하다 보면, 필연적으로 상속 구조를 활용하게 되는데요. 이 때, 서비스 로직에서 실제 인스턴스 타입별로 어떤 코드 실행을 다르게 해주어야 하는 경우가 발생합니다.

     

    고민하지 않고 단순히 인스턴스 별 조건분기문으로 코드를 작성하게 되면, 새로운 요구사항이 추가될 때마다 코드의 변경이 많아질 수 있습니다. 또 그런 변경들이 프로그램의 로직을 복잡하게 만들어낼 수 있는데요. 오늘은 이런 상황에서 조건문을 사용하지 않고, 다형성을 활용할 수 있는 방안에 대해서 살펴보겠습니다.

     

    상황

    • 새를 나타내는 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) 을 살펴보시면 좋을 것 같습니다. 유명한 리팩터링 항목이기 때문에, 단순 구글링을 하셔도 다양한 글들을 확인하실 수 있습니다.

     

     

    Buy Me A Coffee

    반응형

    'Java' 카테고리의 다른 글

    빌더 패턴을 사용한 객체의 생성  (4) 2023.03.18

    댓글

Designed by Tistory.