ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 4. 불변 활용하기 (안정적으로 동작하게 만들기)
    Study/내 코드가 그렇게 이상한가요? 2023. 12. 20. 11:07
    반응형

    가변과 불변을 적절하게 설계하지 못하면 동작을 예측하기 어렵고 혼란스러워진다. 이러한 상황을 피하기 위해 가능한 한 상태가 변경되지 않도록 불변을 활용하여 설계하는 방법을 알아본다.

    4.1 재할당

    재할당(파괴적 할당) : 변수에 값을 다시 할당하는 것
    재할당은 변수의 의미를 바꿔 추측하기 어렵게 만들고, 언제 어떻게 변경되었는지 추적하기도 힘들게 만든다.

    int damage() {
        // 기본 공격력
        int tmp = member.power() + member.weaponAttack();
        // 속도로 공격력 보정
        tmp = (int)(tmp  (1f + member.speed() / 100f));
        // 적 방어력 적용
        tmp = tmp - (int)(enemy.defence / 2);
        // 음수 조정
        tmp = Math.max(0, tmp);
        
        return tmp;
    }

    위 코드는 변수 tmp 값에 기본 공격력, 보정 값, 데미지 등이 계속 재할당되면서 값의 의미가 바뀐다. 이는 읽는 사람이 헷갈릴 수 밖에 없게 만들고, 버그의 원인이 되므로 이러한 재할당은 피하는 것이 좋다. 좋은 대안은 의미를 갖는 새로운 값이 있을 때마다 새로운 변수를 만들어 사용하는 것이다.

    4.1.1 불변 변수로 만들어서 재할당 막기

    의도치 않게 사용하는 재할당을 기계적으로 막는 방법은 변수를 불변으로 만드는 것이다. java 언어 기준으로 변수에 final 수식자를 붙이면 된다. 불변으로 만든 변수는 재할당 시 컴파일 오류가 발생한다.

    int damage() {
        final int basicAttackPower = member.power() + member.weaponAttck();
        final int finalAttackPower = (int)(basicAttackPower * (1f + member.speed() / 100f));
        final int reduction = (int)(enemy.defence / 2);
        final int damage = Math.max(0, finalAttackPower - reduction);
        
        return damage;
    }

    tmp 변수를 의미를 가진 각각의 변수로 나누고 불변으로 만들어서 재할당을 제거하였다.

    4.1.2 매개변수도 불변으로 만들기

    매개변수도 마찬가지로 그 값이 변경되면 읽는 사람을 헷갈리게 만들고, 버그의 원인이 될 수 있다. 이러한 재할당을 막기 위해서 매개변수도 역시 불변으로 만들면 된다. 만약 매개변수에 어떠한 연산을 적용하고 싶다면, 불변 지역 변수를 만들고 이를 활용해서 연산을 한다.

    void addPrice(final int productPrice) {
        final int increasedTotalPrice = totalPrice + productPrice;
        if (MAX_TOTAL_PRICE < increasedTotalPrice) {
        	throw new IllegalArgumentException("구매 상한 금액을 넘었습니다.");
        }
    }

    4.2 가변으로 인해 발생하는 의도하지 않은 영향

    그렇다면 가변일 경우 의도치 않은 영향을 끼치는 경우를 살펴보자.

    4.2.1 사례 1 : 가변 인스턴스 재사용하기

    게임을 예로 들어서 설명한다. 만약 아래와 같이 공격력을 나타내는 AttackPower 클래스가 있다고 하자.

    class AttackPower {
        static final int MIN = 0;
        int value;	// 가변 변수
        
        AttackPower(int value) {
        	if (value < MIN) {
                throw new IllegalArgumentException();
            }
            this.value = value;
        }
    }

    해당 클래스를 사용하여 A, B 두 개의 무기에 같은 공격력(20)을 할당했다고 하자.

    AttackPower attackPower = new AttackPower(20);
    
    Weapon weaponA = new Weapon(attackPower);
    Weapon weaponB = new Weapon(attackPower);

    이 경우 A 무기의 공격력을 강화하면 B 무기의 공격력도 같이 강화되는 치명적인 버그가 발생한다. 이러한 코드를 짜는 것 자체가 말이 안 된다고 생각할 수 있지만, 만약 첫 기획이 모든 무기는 같은 공격력을 가진다라는 가정으로 시작했다면 충분히 있을 법 하다. (서비스의 기능이 수시로 변하고 업데이트 되는 것은 흔한 일이다.)
    이러한 상황을 예방하려면, 인스턴스를 재사용하지 못하게 만들면 된다. 각 무기의 AttackPower 인스턴스를 개별적으로 생성하고, 재사용하지 않는 로직으로 변경해보자.

    AttackPower attackPowerA = new AttackPower(20);
    AttackPower attackPowerB= new AttackPower(20);
    
    Weapon weaponA = new Weapon(attackPowerA);
    Weapon weaponB = new Weapon(attackPowerB);

    다만 위 코드도 AttackPower 클래스의 value 값 자체가 아직 가변이기 때문에 위와 같은 상황을 인지하고 설계하지 않는다면 언제든 버그가 재발할 수 있다.

    4.2.2 사례2 : 함수로 가변 인스턴스 조작하기

    예상하지 못한 동작은 함수(메서드) 때문에 발생하기도 한다. AttackPower 클래스에 공격력을 변화시키는 reinforce(강화), disable(무력화) 메서드를 추가해보자.

    class AttackPower {
        static final int MIN = 0;
        int value;
        
        AttackPower(int value) {
        	if (value < MIN) {
                throw new IllegalArgumentException();
            }
            this.value = value;
        }
        
        /**
        * 공격력 강화하기
        * @param increment 공격력 증가량
        */
        void reinforce(int increment) {
        	value += increment;
        }
        
        /** 무력화하기 */
        void disable() {
        	value = MIN;
        }
    }

    위의 코드에서 처음에는 정상적으로 동작했지만 어느날 갑자기 공격력이 0이 되는 일이 종종 발생하는 버그가 생겼다. 원인을 조사해보니, AttackPower 인스턴스가 다른 스레드에서 사용된 것이 원인이었다. 다른 스레드에서 disable() 메서드의 실행이 어떤 이슈로 지연되었고, 실행되지 않아야 할 순서에 실행된 것이다.

    AttackPower attackPower = new AttackPower(20);
    // ...
    attackPower.reinforce(15);
    
    // 다른 스레드의 처리
    attackPower.disable();

    AttackPower의 disable 메서드와 reinforce 메서드는 구조적인 문제를 갖고 있다. 바로 부수효과 이다.

    4.2.3 부수 효과의 단점

    함수(메서드)에는 주요 작용과 부수 효과가 있다.

    • 주요 작용 : 함수(메서드)가 메개 변수를 전달 받고, 값을 리턴하는 것
    • 부수 효과 : 주요 작용 이외의 상태 변경을 일으키는 것

    여기서 상태 변경이란 아래와 같은 함수 밖에 있는 상태를 변경하는 것을 의미한다.

    • 인스턴스 변수 변경
    • 전역 변수 변경
    • 매개변수 변경
    • I/O 조작(파일 읽고 쓰기 등)

    4.2.2 코드의 reinforce와 disable에서 value라는 상태 변경이 포함된 코드는, 버그가 없는 동일한 결과를 얻기 위해서 동일한 순서로 실행이 되어야 한다. 이는 결과가 작업 실행 순서에 의존하게 되며, 예측할 수 없는 결과를 만들어낸다. 인스턴스 변수 뿐아니라 위에 나열한 전역 변수, 매개변수, 파일 I/O 조작도 모두 마찬가지이다. 다만, 함수 내부에 선언한 지역 변수는 함수 외부에 영향을 주지 않기 때문에 부수 효과라고 할 수 없다.

    4.2.4 함수의 영향 범위 한정하기

    함수의 예상치 못한 동작을 막기 위해서는 함수가 영향을 주거나 받을 수 있는 범위를 한정하는 것이 좋다. 다음과 같은 항목을 만족하도록 함수를 설계하자.

    • 데이터(상태)는 매개 변수로 받는다.
    • 상태를 변경하지 않는다.
    • 값은 함수의 리턴 값으로 돌려준다.

    4.2.5 불변으로 만들어서 예기치 못한 동작 막기

    그럼 모든 문제의 근원이었던 가변 인스턴스 변수 value를 불변으로 변경해보자.

    class attackPower {
        static final int MIN = 0;
        final int value;	// 불변
        
        AttackPower(final int value) {
        	if(value < MIN) {
                throw new IllegalArgumentException();
            }
            this.value = value;
        }
        
        /** 공격력 강화하기
        * @param increment 공격력 증가량
        * @return 증가된 공격력
        */
        AttackPower reinforce(final AttackPower increment) {
        	return new AttackPower(this.value + increment.value);
        }
        
        /**
        * 무력화하기
        * @return 무력화한 공격력
        */
        AttackPower disable() {
        	return new AttackPower(MIN);
        }
    }

    value를 불변으로 만들고 reinforce와 disable 메서드에서 새로운 인스턴스를 만들어 반환하도록 변경하였다. 위 코드를 사용하여 reinforce, disable을 호출하는 코드도 변경해보자.

    final AttackPower attackPower = new AttackPoweR(20);
    // ...
    final AttackPower reinforced = attackPower.reinforce(new AttackPower(15));
    
    // 다른 스레드에서 처리
    final AttackPower disabled = attackPower.disable();

    불변으로 만들어 재할당을 막음으로서, reinforce, disable을 통해 변경된 값은 새로운 인스턴스에 저장되어야 한다. 이는 변경 전과 변경 후의 공격력이 서로 영향을 주지 않게 한다.

    4.3 불변과 가변은 어떻게 다루어야 할까

    실제로 개발을 할 때, 불변과 가변을 어떻게 다루어야 할 지 알아본다.

    4.3.1 기본적으로 불변으로

    변수를 불변으로 만들면 다음과 같은 장점이 있다.

    • 변수의 의미가 변하지 않으므로, 혼란을 줄일 수 있다.
    • 동작이 안정적이게 되므로, 결과를 예측하기 쉽다.
    • 코드의 영향 범위가 한정적이므로, 유지 보수가 편리해진다.

    따라서 기본적으로 불변으로 설계하는 것이 좋다. 비교적 최근에 등장한 러스트(Rust) 언어에서는 불변이 기본 값이고, 가변으로 만들려면, mut 키워드를 붙여야 한다. 이를 통해 불변이라는 성질을 중요하게 여기는 최근 프로그래밍 언어의 추세를 알 수 있다.

    4.3.2 가변으로 설계해야 하는 경우

    가변이 필요한 경우도 당연히 존재한다. 바로 성능(performance)이 중요한 경우이다. 불변의 값을 변경할 때는 인스턴스를 매번 새로 생성해야 하므로, 크기가 큰 인스턴스를 매번 새로 생성하면서 성능에 문제가 생긴다면, 불변보다는 가변을 사용하는 것이 좋다. (ex. 대량의 데이터를 빠르게 처리해야 하는 경우, 이미지 처리, 리소스에 제약이 큰 임베디드 소프트웨어를 다루는 경우 등)
    또한 범위(scope)가 국소적인 경우에도 가변을 사용해도 좋다. 예를 들어 반복문 카운터 등 반복 처리 스코프에서만 사용되는 지역 변수는 가변이라도 괜찮다.

    4.3.3 상태를 변경하는 메서드 설계하기

    인스턴스 변수를 가변으로 만들었다면, 메서드를 만들 때 항상 주의해야 한다. 메서드에서 상태를 변경할 때 항상 조건에 맞는 올바른 상태가 되도록 변경하여야 한다.

    /** 대미지 받는 처리
    * @param damageAmount 대미지 크기
    */
    void damage(final int damageAmount) {
        final int nextAmount = amount - damageAmount;
        amount = Math.max(MIN, nextAMount);
    }

    4.3.4 코드 외부와 데이터 교환은 국소화 하기

    파일을 읽고 쓰는 I/O 조작이나 데이터베이스 접근 등은 코드를 아무리 주의 깊게 작성하여도 외부 상태이므로 제어할 수 없는 상황이 생긴다. (파일이나 데이터베이스가 다른 시스템에 의해 덮어 쓰이는 경우 등)
    이러한 영향을 그나마 줄일 수 있도록, 코드 외부와 데이터 교환을 국소화 하는 테크닉을 사용한다. 국소화하는 방법으로는 리포지토리 패턴(repository pattern)이 있다. 리포지토리 패턴은 데이터베이스의 영속화(persistence)를 캡슐화하는 디자인 패턴이다. 

    반응형
Designed by Tistory.