본문 바로가기
책 내용 정리/[책] 내 코드가 그렇게 이상한가요?

6장 조건 분기: 미궁처럼 복잡한 분기 처리를 무너뜨리는 방법

by 문자메일 2023. 7. 23.

https://link.coupang.com/a/37DdD

 

내 코드가 그렇게 이상한가요? : 좋은 코드/나쁜 코드로 배우는 설계 입문

COUPANG

www.coupang.com

↑ 정말 재밌게 봤고 좋은 책

 

 

인터페이스는 switch 조건문의 중복(전략패턴)을 제거할 수 있을 뿐만 아니라, 다중 중첩된 복잡한 분기를 제거(정책패턴, 같은 판정 로직을 재사용하려면? (정책 패턴))하는 데 활용할 수 있다.

 

 

6.1 조건 분기가 중첩되어 낮아지는 가독성

 

if 조건문을 중첩해서 구현한 예

아래 조건들을 만족하면 마법을 발동할 수 있는 로직이다.

 

// 살아 있는가

if ( 0 < member.hitPoint){

     // 움직일 수 있는가

    if ( member.catAct()){

        // 매직 포인트가 남아 있는가

        if (megic.costMagicPoint <= member.magicPoint){

            member.consumeMagicPoint(magic.costMagicPoint);

            member.chant(magic);

        }

    }

}

 

위처럼 if문이 중첩되어 있으면 코드의 가독성이 크게 떨어지는 문제가 생긴다.

가독성이 나쁜 코드는 팀 전체의 개발 생산성을 저하시킨다.

 

 

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

중첩을 제거하는 방법 중 하나로 조기 리턴이 있다.

조기 리턴은 조건을 만족하지 않는 경우 곧바로 리턴하는 방법이다.

위 6.1에 중첩 코드를 조기 리턴으로 중첩을 제거하면 아래와 같이 보기 편해진다.

조건은 앞에, 실행 로직은 뒤에 모여졌다.

 

if(member.hitPoint <= 0) return;

if(!member.canAct()) return;

if(member.magicPoint < magic.costMagicPoint) return;

 

member.consumeMagicPoint(magic.costMagicPoint);

member.chant(magic);

 

 

6.1.2 가독성을 낮추는 else 구문도 조기 리턴으로 해결하기

 

히트포인트 비율 건강 상태
0% 사망
30%미만 위험
50%미만 주의
50%이상 양호

아래 로직은 기존 if-else문에서 외형 개선 효과와 함께, 테이블의 요구사항을 그대로 잘 표현한다는 부분에 의미가 크다.

 

float hitPointRate = member.hitPoint / member.maxHitPoint;

 

if (hitPointRate == 0) return HealthCondition.dead;

if (hitPointRate < 0.3 ) return HealthCondition.danger;

if (hitPointRate < 0.5) return HealthCondition.caution;

return HealthCondition.fine;

 

 

6.2 switch 조건문 중복

값의 종류에 따라 다르게 처리하고 싶을 때switch 조건문을 많이 사용한다.

 

마법별 기본 요구사항

항목 설명
이름 마법의 이름
mp 소비량 마법 사용시 소비량
공격력 마법 공격력

마법 목록

마법 설명
파이어 불 계열 마법, 래밸 높을수록 공격력 높아짐
라이트닝 번개 계열 마법, 민첩 높을수록 공격력 높아짐.

 

6.2.2. 같은 형태의 조건문이 여러 개 사용되기 시작

6.2.3 요구 사항 변경 시 수정 누락(case 구문 추가 누락)

 

조건 분기를 switch 문으로만 처리하려고 할 때 나오는 코드

 

 

enum MagicType{

    fire,

    lighting

}

 

class MagicManager{

    String getName(MagicType magicType){

        String name = "";

        switch(magicType){

            case fire:

                name = "파이어";

                break;

            case lightning:

                name = "라이트닝";

                break;

        }

        return name;

    }

    // MP 소모량 지정

    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 ;

    }

}

 

6.2.4 폭발적으로 늘어나는 switch 조건문 중복

위 MagicManager 클래스 메서드들에서 switch 조건문은 모두 MagicType을 기준으로 분기하고 있다.

switch 조건문이 중복코드가 된 것이다.

switch 조건문의 중복이 많아지면 실수가 발생할 가능성이 높다.

 

6.2.5 조건 분기 모으기

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

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

간단하게 조건식이 같은 조건 분기를 여러 번에 작성하지 말고 한 번에 작성하자는 말이다.

단일 책임의 원칙에 따라서 MagicType의 switch 조건문을 하나로 묶는다.

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);

                costTechnicalPoint = 0;

                break;

            case lightning:

                name = "파이어";

                costMagicPoint = 5 + (int) (member.level * 0.2);

                attackPower = 50 + (int) (member.level * 1.5);

                costTechnicalPoint = 5;

                break;

            case hellFire:

                name = "헬파이어";

                costMagicPoint = 16;

                attackPower = 200 + (int) (member.magicArrack* 0.5 + member.vitality * 2);

                costTechnicalPoint = 20 + (int)(member.level*0.4);

                break;

            default:

                throw new IllegalArgumentException();

        }

    }

}

 

6.2.6 인터페이스로 switch 조건문 중복 해소하기

6.2.5 처럼 단일 책임의 원칙으로 switch 조건문 하나만 사용해도, 기능이 추가되어 클래스가 거대해지면 데이터와 로직의 관계를 알기 어려워진다.

따라서 클래스가 거대해지면 관심사에 따라 작은 클래스로 분리하는 것이 중요하다.

이런 문제를 해결할 때는 '인터페이스'를 사용한다.

인터페이스 사용하면 장점 2가지

인터페이스는 서로 다른 자료형을 같은 자료형처럼(인터페이스 변수에 저장할 수 있게) 사용할 수 있게 해준다.

인터페이스를 활용하면 메서드를 호출할 때 자료형 판정 분기를 따로 작성하지 않아도 된다. (서로 다른 자료형에 인터페이스를 상속받아 구현한 기능 구현체를 인터페이스 자료형 변수에 저장하고, 변수.method로 instanceof같은 코드 필요 없이 다른 자료형의 구현체에 구현된 기능을 호출할 수 있다.)

 

 

6.2.7 인터페이스를 switch 조건문 중복에 응용하기(전략패턴)

종류별로 다르게 처리해야 하는 기능을 인터페이스의 메서드로 정의하기

 

 

인터페이스의 이름을 결정하는 방법: '어떤 부류에 속하는가?'

가장 기본적인 방법은 '인터페이스를 구현하는 클래스들이 어떤 부류인가?' 를 생각해 보는 것이다.

 

// 마법 자료형

interface Magic{

    String name();

    int costMagicPoint();

    int attackPower();

    int costTechnicalPoint();

}

 

종류별로 클래스 만들기

마법 클래스
파이어 Fire
라이트닝 Lightning
헬파이어 HellFire

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);

    }

 

    public int costTechnicalPoint(){

        return 0;

    }

}

 

class Lightning, HellFire도 동일

 

뒷 내용 로직은 너무 길어서 타이핑 안 함..

 

 

 

6.3 조건 분기 중복과 중첩

인터페이스는 switch 조건문의 중복을 제거할 수 있을 뿐만 아니라, 다중 중첩된 복잡한 분기를 제거하는 데 활용할 수 있다.

아래는 온라인 쇼핑몰에서 우수 고객인지 판정하는 로직이고, 아래 조건 3개를 만족할 경우 골드 회원으로 판정한다.

  • 지금까지 구매한 금액이 100만원 이상
  • 한 달에 구매하는 횟수가 10회 이상
  • 반품률이 0.1% 이하

 boolean isGoldCustomer(PurchaseHistory history){

    if(1000000 <= history.totalAmount){

        if(10 <= history.purchaseFrequencyPerMonth){

            if(history.returnRate <= 0.001) {

                return true;

            }

        }

    }

    return false;

}

 

아래 2개 조건을 만족하는 경우 실버 회원으로 간주한다.

  • 한 달에 구매하는 횟수가 10회 이상
  • 반품률이 0.1% 이하

 boolean isSilverCustomer(PurchaseHistory history){

    if(10 <= history.purchaseFrequencyPerMonth){

        if(history.returnRate <= 0.001) {

            return true;

        }

    }

    return false;

}

판정 조건이 골드 회원과 거의 비슷하다.

비슷한 판정 로직이 또 사용된다면, 같은 판정 로직이 여러곳에 작성될 것이다. (중복 야기)

같은 판정 로직을 재사용하려면? (정책 패턴)

 

 

6.3.1 정책 패턴으로 조건 집약하기

위 상황 처럼 특정 조건이 반복 작성되어 사용되는 경우 정책 패턴을 유용하게 활용할 수 있다.조건을 부품처럼 만들고, 부품으로 만든 조건을 조합해서 사용하는 패턴이다.

 

(1) 규칙을 나타내는 인터페이스 정의interface ExcellentCustomerRule{    boolean ok(final PurchaseHistory history);}

 

(2) 인터페이스를 상속해서 골드회원 조건 나타내는 클래스 정의class GoldCustomerPurchaseAmountRule implements ExcellentCustomerRule{    public boolean ok(final PurchaseHistory history){

        return 1000000 <= history.totalAmount;

    }}

 

class PurchaseFrequencyRule implements ExcellentCustomerRule{

    public boolean ok(final PurchaseHistory history){

        return 10<= history.purchaseFrequencyPerMonth;

    }

}

 

class ReturnRateRule implements ExcellentCustomerRule{

    public boolean ok(final PurchaseHistory history){

        return history.returnRate <= 0.001;

    }

}

 

(3) 조건을 가지는 정책 클래스 생성

class ExcellentCustomerPolicy{

    private final Set<ExcellentCustomerRule> rules;

    

    ExcellentCustomerPolicy(){

        rules = new HashSet();

    }

 

    void add(final ExcellentCustomerRule rule){

        rules.add(rule);

    }

 

    boolean complyWithAll(final PurchaseHistory history){

        for(ExcellentCustomerRule each : rules){

            if(!each.ok(history)) return false;

        }

        return true;

    }

}

 

(4) 특정 조건별 정책 클래스 생성

 - 골드 회원 조건이 집약된 클래스

class GoldCustomerPolicy{

    private final ExcellentCustomerPolicy policy;

 

    GoldCustomerPolicy(){

        policy = new ExcellentCustomerPolicy();

        policy.add(new GoldCustomerPurchaseAmountRule());

        policy.add(new PurchaseFrequencyRule ());

        policy.add(new ReturnRateRule());

    }

 

    boolean complyWithAll(final PurchaseHistory history){

        return policy.complyWithAll(history);

    }

}

 

 - 실버 회원 조건이 집약된 클래스

class SilverCustomerPolicy{

    private final ExcellentCustomerPolicy policy;

 

    SilverCustomerPolicy(){

        policy = new ExcellentCustomerPolicy();

        policy.add(new PurchaseFrequencyRule ());

        policy.add(new ReturnRateRule());

    }

 

    boolean complyWithAll(final PurchaseHistory history){

        return policy.complyWithAll(history);

    }

}

 

 

6.4 자료형 확인에 조건 분기 사용하지 않기

인터페이스는 조건 분기를 제거할 때 활용할 수 있다.

 

interface HotelRates{

    Money fee(); // 요금

}

 

class RegularRates implements HotelRates{

    public Money fee(){

        return new Money(70000);

    }

}

 

class PremiumRates implements HotelRates{

    public Money fee(){

        return new Money(120000);

    }

}

 

위 로직으로 구현되어 있을 때, 성수기 때 일반 숙박과 프리미엄 숙박의 로직을 아래처럼 급하게 구현한다면?

-> 결과적으로 리스코프 치환 원칙 위반 (기반 자료형을 하위 자료형으로 변경해도, 코드는 문제없이 동작해야 한다.)

왜냐하면 다른 hotelRates 자료형이 추가된다면 아래와 같은 기존 동작하는 로직 코드에 변경이 필요하기 때문이다.

 

AS-IS : 

Money busySeasonFee;

if (hotelRates instanceof RegularRates){

    busySeasonFee = hotelRates.fee().add(new Money(30000));

}

else if(hotelRates instanceof PremiumRates){

    busySeasonFee = hotelRates.fee().add(new Money(50000));

}

 

TO-BE : 

interface HotelRates{

    Money fee();

    Money busySeasonFee();

}

 

class RegularRates implements HotelRates{

    public Money fee(){

        return new Money(70000);

    }

 

    public Money busySeasonFee(){

        return fee().add(new Money(30000));

    }

}

 

class PremiumRates implements HotelRates{

    public Money fee(){

        return new Money(120000);

    }

 

    public Money busySeasonFee(){

        return fee().add(new Money(50000));

    }

}

 

Money busySeasonFee = hotelRates.busySeasonFee();

 

위에 처럼 새로운 인터페이스를 추가해서 사용하면, instanceof으로 자료형 판정을 안 해도 되게 된다.

 

 

6.5 인터페이스 사용 능력이 중급으로 올라가는 첫걸음

인터페이스를 잘 사용하면 조건 분기를 크게 줄일 수 있고, 따라서 코드를 단순하게 만들 수 있다.

인터페이스를 잘 사용하는지가 설계 능력의 전환점이라고 할 수 있다.

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

  초보자 중급자 이상
분기 if, switch만 사용 인터페이스 설계 활용
분기마다의 처리 로직 그냥 작성 클래스 사용

 

 

6.6 플래그 매개변수

메서드의 기능을 전환하는 boolean이나 int 자료형 같은 매개변수를 플래그 매개변수라고 한다.

플래그 매개변수를 받는 메서드는 어떤 일을 하는지 예측하기 어려운 문제가 존재한다. ( 확인을 하기 위해서는 반드시 메서드 내부 로직을 확인해야 하기 때문이다.)

 

플래그 매개변수 사용하는 로직 예시

void damage(boolean damageFlag, int damageAmount){

    // 물리 데미지 로직

    if(damageFlag == true){

    ...

    }

    // 마법 데미지 로직

    else{

    ...

    }

}

 

void execute(int processNumber){

    // 계정 등록 처리

    if(processNumber == 0){

    }

    // 배송 완료 메일 발송 처리

    else if(processNumber == 1){

    }

    ...

}

 

6.6.1 메서드 분리하기

플러그 매개변수를 받는 메서드는 내부적으로 여러 기능을 수행하고 있을 가능성이 높다. ( 어찌보면 당연하지만, 조건에 따라 '서로다른'기능 수행 하기 위해서 플래그 매개변수를 받는거니..)

그런데, 메서드는 하나의 기능만 하도록 설계하는 것이 좋다. 그래서 플래그 매개변수를 받는 메서드는 기능별로 분리하는 것이 좋다.

 

 

6.6.2 전환은 전략 패턴으로 구현하기

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){

        ...

    }

}

 

 

enum DamageType{

    hitPoint,

    magicPoint

}

 

private final Map<DamageType, Damage> damages;

 

void applyDamage(final DamageType damageType, final int damageAmount){

    final Damage damage = damages.get(damageType);

    damage.execute(damageAmount);

}

 

applyDamage(DamageType.magicPoint, damageAmount);

 

이렇게 설계하면 조건 분기를 사용하지 않아 가독성이 높아진걸 볼 수 있음.

그리고 이렇게 전략 패턴으로 설계하면, 이후에 새로운 종류의 구현 클래스(대미지)가 추가되었을 때도 쉽게 대응할 수 있다.

댓글