ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 3. 클래스 설계 (객체 지향 설계의 기본)
    Study/내 코드가 그렇게 이상한가요? 2023. 12. 5. 06:41
    반응형

    객체 지향 설계의 기본이라고 할 수 있는 클래스 설계의 기본을 알아본다.

    3.1 클래스 단위로 잘 동작하도록 설계하기

    가장 중요한 것은 '클래스 단위로도 잘 동작하게 설계해야 한다'는 것이다. 클래스는 클래스 하나로도 잘 동작할 수 있어야 하며, 이는 복잡한 초기 설정 없이 바로 사용할 수 있으며, 클래스를 마음대로 조작해서 클래스 전체가 고장나는 일이 없도록 최소한의 조작 방법(메서드)만 외부에 제공해야 한다는 의미이다.

    클래스는 기본적으로 '인스턴스 변수', '메서드' 두 가지로 구성된다. 여기서 잘 만들어진 클래스는 이를 다음과 같이 쓸 수 있다.

    • 인스턴스 변수
    • 인스턴스 변수에 잘못된 값이 할당되지 않게 막고, 정상적으로 조작하는 메서드

     

    이러한 구성을 지켜야 하는 이유는, 이러한 구성을 지키지 않았을 때 발생하는 문제들을 살펴보면 알 수 있다. 인스턴스 변수를 조작하는 로직이 다른 클래스에 구현되어 있을 경우 생길 수 있는 문제점은 다음과 같다.

    1. 코드의 연관성을 알아채기 어려워서 코드가 중복될 수 있고, 가독성을 낮출 수 있다.
    2. 인스턴스 변수들이 아직 유효하지 않은 상태이므로 초기화를 따로 해줘야 한다.
    3. 인스턴스 변수에 어떠한 값이든 들어갈 수 있으므로, 잘못된 값이 쉽게 들어간다.

    이러한 구성은 클래스가 자기 자신을 보호할 수 있는 로직이 없으므로, 다른 클래스가 여러 가지 준비를 해줘야만 잘 작동하고, 혼자서는 아무것도 할 수 없는 미성숙한 클래스를 유발한다. 이는 처음에 말했던, '클래스 단위로도 잘 동작하게 설계해야 한다.'는 원칙을 위배한다.

    그렇다면, 어떻게 하면 좋은 클래스를 설계할 수 있는 것일까? 굉장히 간단하다. 클래스에 자기 방어 임무를 부여해서, 다른 클래스에 맡기던 일을 스스로 할 수 있게 설계하면 된다. 이에 대해 자세한 내용을 이어서 알아보자.

    3.2 성숙한 클래스로 성장시키는 설계 기법

    성숙한 클래스의 설계 기법을 설명하기 위해 금액을 나타내는 Money라는 클래스를 예로 들어서, 이 클래스를 성숙한 클래스로 성장시키는 과정을 살펴보자. Money는 인스턴스 변수만 가지고 있는 전형적인 데이터 클래스이다.

    import java.util.Currency;
    
    class Money {
        int amount;		// 금액
        Currency currency;	// 통화 단위
    }

    3.2.1 생성자로 확실하게 정상적인 값 설정하기

    위 Money 클래스는 '초기화되지 않은 상태'를 유발하는 클래스 구조이다. 이러한 상태를 방지하기 위해서, 클래스 인스턴스를 생성하는 시점에 확실하게 인스턴스 변수가 정상적인 값을 갖게 만들어야 한다.

    // 생성자에서 초기화하기
    class Money {
        int amount;
        Currency currency;
        
        Money(int amount, Currency currenc) {
            this.amount = amount;
            this.currency = currency;
        }
    }

    이렇게 하면 클래스를 생성할 때, 생성자에서 인스턴스 변수가 무조건 초기화 된다. 다만, 위 상태는 생성자의 매개변수로 잘못된 값(-100, null, ..)이 전달될 경우 인스턴스 변수가 잘못된 값으로 초기화 될 수 있다.

    // 생성자에서 유효성 검사하기
    class Money {
        // 생략
        Money(int amount, Currency currency) {
            if (amount < 0) {
                throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
            }
            if (currency == null) {
                throw new NullPointerException("통화 단위를 지정해 주세요.");
            }
            
            this.amount = amount;
            this.currency = currency;
        }
    }

    이렇게 생성자에서 유효성 검사를 하면 올바른 값만 인스턴스 변수에 저장되도록 할 수 있다. 위의 생성자와 같이 처리 범위를 벗어나는 조건을 메서드 가장 앞부분에서 확인하는 코드를 가드(guard)라고 부른다. 가드를 활용하면 잘못된 값을 가진 인스턴스가 존재할 수 없게 되어서, 항상 정상적인 인스턴스만 존재하게 된다.

    3.2.2 계산 로직도 데이터를 가진 쪽에 구현하기

    '데이터''데이터를 조작하는 로직'이 분리되어 있는 구조를 '응집도가 낮은 구조'라고 한다. 응집도가 낮은 구조에서는 여러 가지 문제가 발생할 수 있으므로, 다른 클래스에게 맡겼던 일을 스스로 할 수 있게 만들어서 클래스를 성숙하게 만들어야 한다.

    // Money 클래스에 금액을 추가하는 메서드 만들기
    class Money {
        // 생략
        void add(int other) {
            amount += other;
        }
    }

    3.2.3 불변 변수로 만들어서 예상하지 못한 동작 막기

    인스턴스 변수의 값이 계속해서 바뀌면, 값이 언제 변경되었는지, 지금 값은 무엇인지 계속 신경을 써야 한다. 또한, 비즈니스 요구 사항이 바뀌어서 코드를 수정하다가 의도하지 않은 값을 할당하는 '예상치 못한 부수 효과'가 쉽게 발생할 수 있다. 이를 방지하기 위해, 인스턴스 변수를 불변(immutable)으로 만들어서 값을 한 번 할당하면 다시는 바꿀 수 없는 변수로 만든다.

    // final을 붙여 불변 변수로 만들기
    class Money {
        final int amount;
        final Currency currency;
        
        Money(int amount, Currency currency) {
            // 생략
            this.amount = amount;
            this.currency = currency;
        }
    }

    이렇게 하면 Money 클래스는 생성자를 통해 생성된 이후에 인스턴스 변수에 잘못된 값을 직접 할당할 수 없다.

    3.2.4 변경하고 싶다면 새로운 인스턴스 만들기

    인스턴스 변수의 변경이 필요할 때는, 직접 인스턴스 변수의 내용을 변경하는 것이 아니라, 변경된 값을 가진 새로운 인스턴스를 만들어서 사용한다.

    // 변경된 값을 가진 인스턴스 생성하기
    class Money {
        // 생략
        Money add(int other) {
            int added = amount + other;
            return new Money(added, currency);
        }
    }

    3.2.5 메서드 매개변수와 지역 변수도 불변으로 만들기

    메서드 매개변수와 지역 변수도 인스턴스 변수와 마찬가지로 값이 중간에 바뀌면 값의 변화를 추적하기 힘들기 때문에 버그를 발생시키기도 한다. 따라서, 메서드 내부에서 매개변수를 변경하지 못하도록 매개변수도 불변으로 지정한다.

    // add 메서드의 매개 변수도 final로 만들기
    class Money {
        // ...
        Money add(final int other) {
            int added = amount + other;
            return new Money(added, currency);
        }
    }

    지역 변수도 마찬가지로, 재할당으로 인하여 값의 의미가 중간에 변경되지 않도록 불변으로 만들어준다.

    // 지역 변수도 불변으로 만들기
    class Money {
        // ...
        Money add(final int other) {
            final int added = amount + other;
            return new Money(added, currency);
        }
    }

    3.2.6 엉뚱한 값을 전달하지 않도록 하기

    현재 add 메서드는 매개 변수를 불변으로 지정했음에도 불구하고 매개 변수로 엉뚱한 값이 전달될 수 있다.

    // 금액을 의미하지 않는 값을 전달하는 경우
    final int ticketCount = 3;
    money.add(ticketCount);

    위의 코드는 add 메서드를 이용하여 가격이 아니라 티켓의 수를 더해버렸다. 명백한 버그이지만, 둘 다 int 자료형이므로 코드는 문제없이 실행되고 버그를 찾기도 쉽지 않다. int와 String과 같은 기본 자료형(primitive type)은 의미가 다른 값이 여러 개 있어도 모두 int, String으로 정의하기가 쉽다. 따라서, 실수로 의미가 다른 값을 전달하지 않기 위해 Money와 같은 독자적인 자료형을 사용하면, 의미가 다른 값을 전달할 경우 컴파일 오류가 발생하여 이를 방지해준다.

    // Money 자료형만 받도록 메서드 수정하고 개선하기
    class Money {
        // ...
        Money add(final Money other) {
            if (!currency.equals(other.currency)) {
                throw new IllegalArgumentException("통화 단위가 다릅니다.");
            }
            
            final int added = amount + other.amount;
            return new Money(added, currency);
        }
    }

    3.2.7 의미 없는 메서드 추가하지 않기

    금액을 나타내는 Money 클래스에서 합계 금액을 구하려면 가산(덧셈), 할인을 하려면 감산(뺄셈), 비율을 구하려면 나눗셈을 사용할 수 있다. 하지만 금액을 곱하는 일은 일반적인 회계 서비스에서 있을 수 없다. 이러한 경우, 의미 없는 메서드(곱셈)을 추가하지 않도록 하자. '덧셈, 뺄셈, 나눗셈 만드는 김에 곱셈도 구현해 두자.' 라는 생각은, 이후에 누군가 이를 무심코 사용했을 때 버그가 될 수 있다. 시스템 사양에 필요한 메서드만 정의하자.

    3.3 결과

    import java.util.Currency;
    
    class Money {
        final int amount;
        final Currency currency;
    
        Money(final int amount, final Currency currency) {
            if (amount < 0) {
                throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
            }
            if (currency == null) {
                throw new NullPointerException("통화 단위를 지정해 주세요.");
            }
            
            this.amount = amount;
            this.currency = currency;
        }
        
        Money add(final Money other) {
            if (!currency.equals(other.currency) {
                throw new IllegalArgumentException("통화 단위가 다릅니다.");
            }
            
            final int added = amount + other.amount;
            return new Money(added, currency);
        }
    }

    클래스 설계란 인스턴스 변수가 잘못된 상태에 빠지지 않게 하기 위한 구조를 만드는 것이라고 해도 과언이 아니다. 위의 Money 클래스처럼 관련된 로직이 한 곳에 모여 있는 구조를 응집도가 높은 구조라고 한다. 또한, '데이터'와 그 '데이터를 조작하는 로직'을 하나의 클래스로 묶고, 필요한 절차(즉 메서드)만 외부에 공개하는 것을 캡슐화라고 한다.

    3.4 프로그램 구조의 문제 해결에 도움을 주는 디자인 패턴

    응집도가 높은 구조로 만들거나, 잘못된 상태로부터 프로그램을 방어하는 등 프로그램의 구졸르 개선하는 설계 방법을 디자인 패턴(설계 패턴, design pattern)이라고 부른다. 이 장에서 사용된 Money 클래스는 완전 생성자와 값 객체라는 두 가지 디자인 패턴을 적용한 것이다.

    3.4.1 완전 생성자

    완전 생성자(complete constructor)는 잘못된 상태로부터 클래스를 보호하기 위한 디자인 패턴이다. 인스턴스 변수를 모두 초기화해야만 객체를 생성할 수 있게 매개변수를 가진 생성자를 만들고, 생성자 내부에 가드를 사용해서 잘못된 값이 들어오지 못하게 만들면, 값이 모두 정상인 완벽한 객체만 만들어질 것이다. 위 Money 클래스의 생성자가 바로 완전 생성자 구조이다. 또한, 인스턴스 변수에 final 수식자를 붙여서 불변으로 만들면, 생성 후에도 잘못된 상태로부터 방어할 수 있다.

    3.4.2 값 객체

    값 객체(value object)란 값을 클래스(자료형)로 나타내는 디자인 패턴이다. 예를 들어, 금액을 단순한 int 자료형의 지역 변수 또는 매개 변수로 사용하면, 금액 계산 로직이 이곳저곳에 분산되어 응집도가 낮은 구조가 되며, 실수로 의미가 다른 값들이 섞일 수도 있게 된다.

    값 객체를 사용하면 이러한 상황을 막을 수 있다. Money 클래스는 생성자에서 금액에 제약 조건(0원 이상)을 걸고 있으며, 금액을 계산하는 로직도 Money.add 메서드로 갖고 있어서 응집도가 높은 구조라고 할 수 있다. 또한, Money.add 메서드는 매개 변수로 Money 자료형만 받을 수 있으므로 의도하지 않게 다른 값이 섞이는 상황을 원천적으로 차단할 수 있다.

    값 객체와 완전 생성자는 얻을 수 있는 효과가 거의 비슷하므로 일반적으로 함께 사용되며, '값 객체 + 완전 생성자'는 객체 지향 설계에서 폭넓게 사용되는 기법이라고 할 수 있다.

     

    참고 - '내 코드가 그렇게 이상한가요?' 3장 클래스 설계: 모든 것과 연결되는 설계 기반

    반응형
Designed by Tistory.