객체 지향 프로그래밍을 하다 보면, 필연적으로 상속 구조를 활용하게 되는데요. 이 때, 서비스 로직에서 실제 인스턴스 타입별로 어떤 코드 실행을 다르게 해주어야 하는 경우가 발생합니다. 고민하지 않고 단순히 인스턴스 별 조건분기문으로 코드를 작성하게 되면, 새로운 요구사항이 추가될 때마다 코드의 변경이 많아질 수 있습니다. 또 그런 변경들이 프로그램의 로직을 복잡하게 만들어낼 수 있는데요. 오늘은 이런 상황에서 조건문을 사용하지 않고, 다형성을 활용할 수 있는 방안에 대해서 살펴보겠습니다. ## 상황 - 새를 나타내는 `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)](https://en.wikipedia.org/wiki/Open%E2%80%93closed_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)` 를 변경하지 않고, 그대로 사용할 수 있습니다. 따라서 개방-폐쇄 원칙도 지킬 수 있게 되었습니다. 이 방법에 대해서 좀 더 자세하게 확인해보시고 싶으신 분들은 [**리팩터링(마틴 파울러)**](https://product.kyobobook.co.kr/detail/S000001810241)에서 **[조건문을 재정의로 전환 (Replace Conditional with Polymorphism)](https://www.refactoring.com/catalog/replaceConditionalWithPolymorphism.html)** 을 살펴보시면 좋을 것 같습니다. 유명한 리팩터링 항목이기 때문에, 단순 구글링을 하셔도 다양한 글들을 확인하실 수 있습니다.
> https://amitness.com/posts/information-retrieval-evaluation 글을 읽고 정리한 문서입니다. ## 지표의 목적 상위 N 결과가 얼마나 우수한지 어떻게 평가할 것 인가? ### Binary relevance - 문서에 대한 관련성을 `있다 / 없다` 로만 판단한다. - 현재 Ranking model 이 query 에 대해서 5개의 각각의 문서 관련도는 `[1, 0, 1, 0, 1]` 로 나타낼 수 있다. (*binary*) ## Order-unaware metrics ### Precision@k $$ Precision@k = \frac{ true\ positives@k}{(true\ positives@k) + (false\ positives@k)} $$ - 이 메트릭은 상위 K 결과의 관련 항목 수를 정량화합니다. - 추출된 k 랭크 문서 중에서 관련 있는 문서의 갯수 예시) *Precision@2* ### Recall@k $$ Recall@k = \frac{ true\ positives@k}{(true\ positives@k) + (false\ negatives@k)} $$ - 이 메트릭은 쿼리에 대한 모든 실제 관련 결과 중에서 몇 개의 실제 관련 결과가 표시되었는지 알려줍니다. - 전체 관련 있는 문서 갯수 중에서 k 랭크 내에 추출된 관련 있는 문서의 갯수 예시) *Recall@2* ### 참고: Precision 과 Recall 의 집합관계 - A = 모델에서 문서가 관련 있다고 예측한 영역 (예측) - B = 실제 관련 있는 문서가 있는 영역 (정답) - b 영역 = True Positive 로 모델이 추출한 관련 문서 중 실제 관련 있는 문서가 있었던 영역 모델이 반환한 결과 중에서 실제 관련도 있는 문서를 추출한 비율이 precision, 실제 관련 있는 문서 목록 중 model 이 올바르게 문서를 추출한 비율이 recall 이라고 할 수 있다...
댓글
댓글 쓰기