ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 6. 조건 분기 : interface 활용하여 복잡한 조건 분기 제거하기
    Study/내 코드가 그렇게 이상한가요? 2024. 1. 6. 20:59
    반응형

    조건 분기는 조건에 따라 처리 내용을 전환하는 꼭 필요한 구조이지만, 조건이 복잡해지면 코드의 동작을 이해하기 힘들고 유지보수 시 여러 버그를 만들 수 있다. 이 장에서는 조건 분기를 잘 다루어 버그가 없는 설계를 만드는 방법을 설명한다.

    1. 중첩되는 조건 분기 제거하기

    코드를 보다보면 if 조건문이 여러 개 중첩되어 사용된 경우가 있다. 조건 분기의 중첩은 코드의 가독성을 크게 떨어뜨린다.

    if (조건) {
        // ...
        if (조건) {
            // ...
            if (조건) {
                // ...
                if (조건) {
                    // ...
                }
                else {
                    // ...
                }
            }
        }
    }

    중첩 구조 사이사이에 수많은 코드가 섞여 있으면, 각 조건문의 범위를 찾기도 매우 힘들고, 각 조건에 따라 어떤 로직이 처리되는 지 알기 힘들다. 이런 상황에서 코드를 충분히 이해하지 못하고 로직을 변경하며 버그가 생기기 쉽다.

    조기 리턴으로 중첩 제거하기

    조건 분기의 중첩을 제거하는 방법 중 하나로 조기 리턴(early return)이 있다. 아래 예제를 보자.

    // 살아 있는가
    if (0 < member.hitPoint) {
        // 움직일 수 있는가
        if (member.canAct()) {
        	// 매직 포인트가 남아 있는가
            if (magic.costMagicPoint <= member.magicPoint) {
                member.consumeMagicPoint(magic.costMagicPoint); // 매직 포인트 소비
                member.chant(magic); // 마법 발동
            }
        }
    }

    특정 멤버가 스킬 사용 시 여러 조건을 체크한 후 매직 포인트를 소비하고 마법을 발동하는 코드이다. 중첩된 조건 분기들을 조기 리턴으로 제거해보자.

    // 살아 있지 않은 경우 처리를 종료
    if (member.hitPoint <= 0) return;
    // 움직일 수 없다면 처리를 종료
    if (!member.canAct()) return;
    // 매직 포인트가 부족하다면 처리를 종료
    if (member.magicPoint < magic.costMagicPoint) return;
    
    member.consumeMagicPoint(magic.costMagicPoint);
    member.chant(magic);

    조기 리턴으로 중첩이 제거되어 가독성이 좋아졌다. 위에 예제에서는 조기 리턴하는 형태로 변경하기 위해 원래 조건을 반전하였다.('살아 있는가?' -> '죽은 상태인가?')

    조기 리턴에는 또 다른 장점이 있다. 바로 조건 로직과 실행 로직을 분리할 수 있다는 것이다. 조건 로직과 실행 로직이 복잡하게 섞여있던 이전 코드에 비해서, 조기 리턴을 사용한 코드는 마법을 쓸 수 없는 조건은 앞부분에 조기 리턴으로 모았고, 마법 발동 때 실행할 로직은 뒤로 모았다. 이러한 구조는 새로운 조건과 실행 로직을 추가할 때 더 빛을 발한다.

    if (member.hitPoint <= 0) return;
    if (!member.canAct()) return;
    if (member.magicPoint < magic.costMagicPoint) return;
    if (!(발동 조건1)) return;
    if (!(발동 조건2)) return;
    
    member.consumeMagicPoint(magic.costMagicPoint);
    member.chant(magic);
    // 실행 로직 추가

    분리되어 있는 조건 로직, 실행 로직에 맞추어서 각각 로직을 간단하게 추가할 수 있다.

    else를 사용하는 복잡한 조건 분기도 조기 리턴을 사용하여 개선이 가능하다.

    if (hitPointRate == 0) {
        return HealthCondition.dead;
    }
    else if (hitPointRate < 0.3) {
        return HealthCondition.danger;
    }
    else if (hitPointRate < 0.5) {
        return HealthCondition.caution;
    }
    else {
        return HealthCondition.fine;
    }

    위 코드는 아래와 같이 개선이 가능하다.

    if (hitPointRate == 0) return HealthCondition.dead;
    if (hitPointRate < 0.3) return HealthCondition.danger;
    if (hitPointRate < 0.5) return HealthCondition.caution;
    
    return HealthCondition.fine;

     

    2. switch 조건문 중복 제거하기

    값의 종류에 따라 다르게 처리하고 싶을 때 많이 사용되는 switch 조건문은 버그를 만들기 굉장히 쉬운 제어 구문이다. RPG 게임에서 효과가 다른 여러 마법을 구현한다고 생각해보자.

    enum MagicType {
        fire,	// 불 계열 마법
        lightning	// 번개 계열 마법
    }

    각 마법에 이름, 매직 포인트 소비량, 공격력의 요구 사항이 필요할 때 각 메서드를 구현하면 아래와 같이 된다.

    // 마법의 이름
    String getName(MagicType magicType) {
        String name = "";
        
        switch (magicType) {
        	case fire:
            	name = "파이어";
                break;
            case lightning:
            	name = "라이트닝";
                break;
        }
        return name;
    }
    
    // 매직포인트 소비량
    int costMagicPoint(MagicTYpe magicType, Member member) {
        int magicPoint = 0;
        
        switch (magicType) {
        	case fire:
                magicPoint = 2;
                break;
            case lightning:
                magicPoint = 5 + (int)(member.level * 0.2);
                break;
        }
        return magicPoint;
    }
    
    // 공격력
    int attackPower(MagicType magicType, Member member) {
        int attackPower = 0;
        
        switch (magicType) {
        	case fire:
                attackPower = 20 + (int)(member.level * 0.5);
                break;
            case lightning:
                attackPower = 50 + (int)(member.agility * 1.5);
                break;
        }
        return attackPower;
    }

    위와 같이 MagicType에 따른 처리를 switch 조건문으로 구현하는 것은 매우 좋지 않다. 먼저 요구 사항의 변경 시 수정이 누락될 수 있다. 현재 구조는 fire, lightning 외에 새로운 마법을 추가해야 될 때, getName, costMagicPoint, attackPower 모든 메서드의 switch 조건문에 case 구문이 추가가 되어야 한다. 이러한 요구 사항의 추가, 수정, 삭제가 계속 반복되다 보면 어느 한 군데에서 수정이 누락이 발생하기 쉽고, 이는 곧 버그로 이어진다.

    현재는 2가지 마법(fire, lightning)에 3가지 요구사항(이름, 매직포인트 소비량, 공격력)이지만, 실제 RPG 게임에서는 수십~수백 가지의 마법 종류가 있고, 마찬가지로 수십개가 넘는 요구사항이 있다. 이런 상황에서 폭발적으로 늘어나는 switch 조건문에서는 아무리 주의 깊게 대응한다고 해도 누락을 피하기 쉽지 않다.

    조건 분기 모으기

    이러한 switch 조건문 중복을 해소하려면, 단일 책임 선택의 원칙을 생각해 봐야 한다.

    • 단일 책임 선택의 원칙 : 소프트웨어 시스템이 선택지를 제공해야 한다면, 그 시스템 내부의 어떤 한 모듈만으로 모든 선택지를 파악할 수 있어야 한다.

    조건식이 같은 switch 조건문을 여러 번 작성한 우리의 코드는 이 원칙을 위반하므로, MagicType의 switch 조건문을 하나로 묶어보자.

    class Magic {
        final String name;
        final int costMagicPoint;
        final int attackPower;
        final int costTechnicalPoint;
        
        Magic(final MagicType magicType, final Member member) {
            switch (magicType) {
                case fire:
                    name = "파이어";
                    costMagicPoint = 2;
                    attackPower = 20 + (int)(member.level * 0.5);
                    break;
                case lightning:
                    name = "라이트닝";
                    costMagicPoint = 5 + (int)(member.level * 0.2);
                    attackPower = 50 + (int)(member.agility * 1.5);
                    break;
                case hellFire:
                    ...
                default:
                    throw new IllegalArgumentException();
            }
        }
    }​

    switch 조건문 하나로 모든 요구 사항을 다루고 있으므로 사양을 변경할 때 누락 실수를 줄일 수 있다. 하지만 여전히 마법의 종류가 많아지면 클래스가 거대해지고 이에 따라 가독성이 떨어지게 된다. 따라서, 클래스가 거대해지면 관심사에 따라 작은 클래스로 분할하는 것이 중요하다. 이러한 문제를 해결할 때 사용하는 것이 '인터페이스'이다.

     

    3. 인터페이스 활용하기

    인터페이스를 잘 사용하면 조건 분기를 크게 줄일 수 있다. 즉 코드를 단순하게 만들 수 있고, 인터페이스를 잘 사용하는 지가 곧 설계 능력의 전환점이라고 할 수 있다.

    인터페이스는 자바와 같은 객체 지향 프로그래밍 언어 특유의 문법으로, 기능 변경을 편리하게 할 수 있는 개념이다. 인터페이스를 사용하면, 분기 로직을 작성하지 않고도 분기와 같은 기능을 구현할 수 있다. 여러 도형의 면적을 구하는 예를 들어보자.

    // 사각형
    class Rectangle {
        private final double width;
        private final double height;
        
        double area() {
            return width * height;
        }
    }
    
    // 원
    class Circle {
        private final double radius;
        
        double area() {
            return radius * radius * Math.PI;
        }
    }

    면적을 구하는 메서드가 area로 같은 이름이고 호출 역시 똑같은 방식으로 할 수 있을 것 같지만, Rectangle과 Circle은 클래스가 서로 다르다. 즉, 우리는 도형의 넓이를 구하기 위해서 각 도형이 어떤 도형인지(어느 클래스에 속하는지)를 먼저 알아야 한다. 이러한 상황을 구현하면 아래와 같다.

    double getArea(Object shape) {
        if (shape instanceof Rectangle) {
            return ((Rectangle) shape).area();
        }
        if (shape instanceof Circle) {
            return ((Circle) shape).area();
        }
    }

    이렇게 도형의 자료형을 판정하고, 각 자료형으로 변환하는 등의 번거로운 작업을 해결해 주는 것이 인터페이스이다. 인터페이스는 서로 다른 자료형을 같은 자료형처럼 사용할 수 있게 해준다. 인터페이스를 사용해서 위 코드를 개선해보자.

    interface Shape {
        double area();
    }

    위와 같이 도형을 나타내는 Shape라는 이름의 인터페이스를 만든 후, 면적을 구하는 area 메서드를 정의했다. 그리고, 각 도형 Rectangle과 Circle은 Shape 인터페이스를 구현하도록 변경한다.

    class Rectangle implements Shape {
        private final double width;
        private final double height;
        
        public double area() {
            return width * height;
        }
    }
    
    class Circle implements Shape {
        private final double radius;
        
        public double area() {
            return radius * radius * Math.PI;
        }
    }

    이렇게 하면, Rectangle과 Circle을 Shape라는 자료형 하나로 다룰 수 있게 되고 우리는 공통 메서드인 area를 사용할 때 각 도형이 어떤 타입인 지 알 필요가 없게 된다.

    double getArea(Shape shape) {
        return shape.area();
    }

    위 메서드는 Shape 인터페이스를 구현하는 모든 클래스를 매개변수로 받을 수 있고, instanceof로 자료형을 판정하는 조건 분기를 작성하지 않아도 각 도형에 맞는 area 메서드가 실행된다. 또한, 새로운 도형(Triangle 등)을 추가할 때도, Shape를 구현하는 Triangle 클래스를 추가하기만 하면 이외의 사용 로직은 수정할 필요가 없다.

     

    switch 조건문 -> 인터페이스 (전략 패턴)

    위에서 나왔던 switch 조건문 중복 문제를 인터페이스를 사용하여 해결해보자.

    인터페이스의 큰 장점 중 하나는 다른 로직을 같은 방식으로 처리할 수 있다는 것이다. 앞서 살펴본 switch 조건문은 마법의 이름, 매직포인트 소비량, 공격력을 각 마법별로 다른 로직으로 처리했다. 이처럼 공통으로 필요하지만 종류별로 다르게 처리해야 하는 기능을 인터페이스의 메서드로 정의한다.

    interface Magic {
        String name();
        int costMagicPoint();
        int attackPower();
    }

    Tip. 인터페이스의 이름을 결정하는 방법 : 인터페이스를 구현하는 클래스들이 어떤 부류인가?(사각형, 원 -> 도형. 파이어, 라이트닝, 헬파이어 -> 마법)

    이제 각 마법 클래스를 만들어서 Magic 인터페이스를 구현한다.

    class Fire implements Magic {
        private final Member member;
        
        Fire(final Member member) {
            this.member = member;
        }
        
        public String name() {
            return "파이어";
        }
        
        public int costMagicPoint() {
            return 2;
        }
        
        public int attackPower() {
            return 20 + (int)(member.level * 0.5);
        }
    }
    
    class Lightning implements Magic {...}
    
    class HellFire implements Magic {...}

    이와 같이 구현하면, Fire, Lightning HellFire를 모두 Magic 자료형으로 활용할 수 있다.

    각 마법을 Magic 자료형으로 사용할 수 있게 되었지만, 아직 MagicType이 어떠한 Magic 자료형을 사용하여야 하는 지에 대한 처리가 필요하다. 이는 Map을 사용하여 처리할 수 있다. Map이 switch 조건문처럼 경우에 따라 처리를 구분하는 것이다.

    final Map<MagicType, Magic> magics = new HashMap<>();
    
    final Fire fire = new Fire(member);
    final Lightning lightning = new Lightning(member);
    final HellFire hellFire = new HellFire(member);
    
    magics.put(MagicType.fire, fire);
    magics.put(MagicType.lightning, lightning);
    magics.put(MagicType.hellFire, hellFire);

    위와 같은 형식으로 MagicType과 Magic이 매핑되는 Map을 만들어두고, magicType을 매개변수로 전달받았을 때 magicType에 해당하는 Magic 인스턴스를 추출하여 로직을 처리하면 된다. 완성된 코드는 아래와 같다.

    final Map<MagicType, Magic> magics = new HashMap<>();
    
    ...
    
    // 마법 공격 실행하기
    void magicAttack(final MagicType magicType) {
        final Magic usingMagic = magics.get(magicType)	// magicType에 해당하는 Magic 인스턴스 추출
        
        showMagicName(usingMagic);
        consumeMagicPoint(usingMagic);
        magicDamage(usingMagic);
    }
    
    void showMagicName(final Magic magic) {
        System.out.println(magic.name());
    }
    
    void consumeMagicPoint(final Magic magic) {
        final int costMagicPoint = magic.costMagicPoint();
        // ...
    }
    
    void magicDamage(final Magic magic) {
        final int attackPower = magic.attackPower();
        // ...
    }

    이처럼 인터페이스를 사용해서 처리를 한꺼번에 전환하는 설계를 '전략 패턴(strategy pattern)'이라고 한다. 인터페이스를 활용한 전략 패턴은 버그의 발생을 미연에 방지할 수 있다. 왜냐하면, 인터페이스를 구현한 클래스는 인터페이스의 메서드를 모두 구현하여야 컴파일할 수 있기 때문이다. 만약에 MagicType에 HellFire라는 새로운 타입을 추가할 때, Magic 인터페이스를 구현하도록 HellFire 클래스를 만들었다면, Magic 인터페이스에 있는 name, costMaigcPoint, attackPower 메서드 중 하나라도 구현을 빠뜨린다면 컴파일 오류가 발생하고, 구현을 누락하는 실수를 방지할 수 있다.

    플래그 매개변수

    아래의 damageFlag와 같이 메서드의 기능을 전환하는 boolean 자료형의 매개변수를 플래그 매개변수라고 한다. 이러한 플래그 매개변수 또한, 전략 패턴을 사용하여 제거가 가능하다. (하나의 메서드는 하나의 기능만 하는 것이 좋기 때문에 플래그 매개 변수를 사용하는 로직은 좋지 않다.)

    void damage(boolean damageFlag, int damageAmount) {
        if (damageFlag == true) {
            // 물리 대미지 처리
            ...
        }
        else {
            // 마법 대미지 처리
            ...
        }
    }

    이를 전략패턴을 사용하여 변경해보자.

    // 대미지를 나타내는 인터페이스
    interface Damage {
        void execute(final int damageAmount);
    }
    // 물리 대미지
    class HitPointDamage implements Damage {
        public void execute(final int damageAmount) {
            ...
        }
    }
    
    // 마법 대미지
    class MagicPointDamage implements Damage {
        public void execute(final int damageAmount) {
            ...
        }
    }

    위와 같이 Damage 인터페이스를 각각 구현하는 형태로 구성을 하고, 사용할 때는 Map을 사용하여 사용한 인터페이스를 선택하도록 구현한다.

    enum DamageType {
        hitPoint,
        magicPoint
    }
    
    private fianl Map<DamageType, Damage> damages;
    
    void applyDamage(final DamageType damageType, final int damageAmount) {
        final Damage damage = damages.get(damageType);
        damage.execute(damageAmount);
    }

     

    다중 중첩 -> 인터페이스 (정책 패턴)

    인터페이스는 switch 조건문 뿐 아니라, 다중 중첩된 복잡한 분기를 제거하는 데도 활용할 수 있다. 다음 코드는 온라인 쇼핑몰에서 우수 고객인지 판정하는 다중 중첩이 사용된 로직이다. 

    // 골드 회원 판정
    boolean isGoldCustomer(PurchaseHistory history) {
        // 구매 금액이 100만원 이상 
        if (1000000 <= history.totalAmount) {
            // 한 달 구매 횟수 10회 이상
            if (10 <= history.purchaseFrequencyPerMonth) {
                // 반품률이 0.1% 이하
                if (history.returnRate <= 0.001) {
                    return true;
                }
            }
        }
        return false;
    }
    
    // 실버 회원 판정
    boolean isSilverCustomer(PurchaseHistory history) {
        // 구매 금액이 50만원 이상 
        if (500000 <= history.totalAmount) {
            // 한 달 구매 횟수 5회 이상
            if (5 <= history.purchaseFrequencyPerMonth) {
                // 반품률이 0.1% 이하
                if (history.returnRate <= 0.001) {
                    return true;
                }
            }
        }
        return false;
    }

    골드 회원과 실버 회원은 판정 조건이 거의 같지만, 같은 로직이 계속 작성되고 있다. 이러한 상황에서 유용하게 활용할 수 있는 패턴으로 정책 패턴(policy pattern)이 있다. 조건을 부품처럼 만들고, 부품으로 만든 조건을 조합해서 사용하는 패턴이다.

    우선 하나하나의 규칙(판정 조건 : 골드 회원 조건, 실버 회원 조건, 추후 추가될 수 있는 회원 등급 조건 등)을 나타내는 인터페이스를 만든다.

    interface ExcellentCustomerRule {
        boolean ok(final PurchaseHistory history);
    }

    이제 각 회원 등급을 달성하기 위해 만족해야 각각의 조건을 ExcellentCustomerRule을 구현해서 만든다.

    // 골드 회원 구매 금액 조건
    class GoldCustomerPurchaseAmountRule implements ExcellentCustomerRule {
        public boolean ok(final PurchaseHistory history) {
            return 1000000 <= history.totalAmount;
        }
    }
    
    // 골드 회원 구매 빈도 조건
    class GoldCustomerPurchaseFrequencyRule implements ExcellentCustomerRule {
        public boolean ok(final PurchaseHistory history) {
            return 10 <= history.purchaseFrequencyPerMonty;
        }
    }
    
    // 실버 회원 구매 금액 조건
    class SilverCustomerPurchaseAmountRule implements ExcellentCustomerRule {
        public boolean ok(final PurchaseHistory history) {
            return 500000 <= history.totalAmount;
        }
    }
    
    // 실버 회원 구매 빈도 조건
    class SilverCustomerPurchaseFrequencyRule implements ExcellentCustomerRule {
        public boolean ok(final PurchaseHistory history) {
            return 5 <= history.purchaseFrequencyPerMonty;
        }
    }
    
    // 공통 반품률 조건
    class ReturnRateRule implements ExcellentCustomerRule {
        public boolean ok(final PurchaseHistory history) {
            return history.returnRate <= 0.001;
        }
    }

    위와 같이 각 조건들을 하나의 부품처럼 만들고, 필요한 조건들을 조합하여 각 등급을 판정하면 된다. 규칙을 집약하고, 판정하는 정책 클래스를 만들어보자.

    class ExcellentCustomerPolicy() {} {
        private final Set<ExcellentCustomerRule> rules;
        
        ExcellentCustomerPolicy() {
            rules = new HashSet();
        }
        
        /**
        * 정책에 사용할 규칙 추가
        *
        * @param rule 규칙
        */
        void add(final ExcellentCustomerRule rule) {
            rules.add(rule);
        }
        
        /**
        * @param history 구매 이력
        * @return 규칙을 모두 만족하는 경우 true
        */
        boolean complyWithAll(final PurchaseHistory history) {
            for (ExcellentCustomerRule each : rules) {
                if (!each.ok(history)) return false;
            }
            return true;
        }
    }

    이제 각 회원 등급의 정책을 만들 때, 원하는 규칙을 추가하여 자유자재로 만들고 간편하게 수정할 수 있다. 이를 사용하여 골드 회원 정책을 만들어보자.

    class GoldCustomerPolicy {
        private final ExcellentCustomerPolicy policy;
        
        GoldCustomerPolicy() {
            policy = new ExcellentCustomerPolicy();
            policy.add(new GoldCustomerPurchaseAmountRule());
            policy.add(new GoldPurchaseFrequencyRule());
            policy.add(new ReturnRateRule());
            // 원하는 조건 자유롭게 추가
        }
        
        boolean complyWithAll(final PurchaseHistory history) {
            return policy.complyWithAll(history);
        }
    }

     

    지금까지 살펴본 것처럼 인터페이스를 잘 사용하면 조건 분기를 크게 줄일 수 있다. 인터페이스를 적재 적소에 잘 사용하기 위하여 아래 문구를 머릿속에 새겨 두도록 하자.

    조건 분기를 써야 하는 상황에는 일단 인터페이스 설계를 떠올리자

    반응형
Designed by Tistory.