ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 엘레강트 오브젝트 - 퍼플릭 상수(Public Constant)를 사용하지 말자
    책책책 책을 읽읍시다/프로그래밍 2023. 8. 1. 23:35

     '상수(constant)'라고도 불리는 public static final 프로퍼티는 객체 사이에 데이터를 공유하기 위해 사용하는 매우 유명한 메커니즘이다. 글자 그대로 상수를 사용하는 이유는 데이터를 공유(또는 다른 객체를 공유)하기 위해서인데, 객체들은 어떤 것도 공유해서는 안된다. 대신 독립적이어야 하고 '닫혀 있어야(closed)'한다. 간단하게 말해서 상수를 이용한 공유 메커니즘은 캡슐화와 객체지향적인 사고 전체를 부정하는 일이다. 다음 예제는 구조화된 데이터를 Writer 객체에 기록하고, 각 줄을 '개행(new line) 문자'로 종료하는 메서드를 나타낸 것이다.

    public class Records {
        private static final String EOL = "\r\n";
        
        void write(Writer out) {
            for (Record rec : this.all) {
                out.write(rec.toString());
                out.write(Records.EOL);
            }
        }
    }

     이 예제에서 static final 프로퍼티인 EOL은 private이고 Records 클래스의 내부에서만 사용된다. 이는 완전히 올바른 상황이다. 필요할 때마다 반복적으로 클래스 안에 '\w\n'을 작성하고 싶지는 않기 때문이다. 이제 매우 유사한 방식을 동작하지만, 다른 객체를 사용하는 Rows 클래스가 있다고 가정해보자.

    public class Rows {
        private static final String EOL = "\r\n";
    
        void print(PrintStream pnt) {
            for (Row row : this.fetch()) {
                pnt.printf(
                        "{ %s }%s", row, Rows.EOL
                );
            }
        }
    }

     Rows의 로직은 Records의 로직과는 다르며, 협력하는 객체 집합도 완전히 다르다. Records와 Rows는 무엇으로도 연결되어있지 않다. 두 클래스는 공통점이 전혀 없다. 하지만 두 클래스 모두 'EOL'이라는 private 상수를 정의하고 있다. 이 코드는 물론 중복이다. 아래와 같이 상수 클래스를 통해 해결할 수 있다.

    public class Constants {
      public static final String EOL = "\r\n";
    }

     이 방법은 C의 #define 매크로와 크게 다르지 않다. 두 방법 모두 전역 범위에서 접근 가능하기 떄문에 어떤 클래스라도 퍼블릭 상수를 사용할 수 있다. 사실 상수에 접근할 수 있는 범위를 제한하는 C의 매크로 방식이 더 좋다고 할 수 있다. C에서는 매크로가 정의된 .h 파일을 포함하는 곳에서만 상수에 접근할 수 있기 때문이다. Java에서는 Constants 클래스의 가시성이 public이기 때문에 클래스 로더에 의해 로딩된 모든 클래스들이 상수에 접근할 수 있다.

     Constants.EOL을 사용하도록 코드를 변경함으로써 코드 중복 문제를 '해결'했다. Records와 Rows는 더 이상 지역적으로 상수를 정의할 필요도 없고 퍼블릭 상수를 어디에서나 '재사용'할 수 있기 때문이다.

     불행하게도 코드 중복이라는 하나의 문제를 해결하기 위해 두 개의 더 큰 문제를 추가하고 말았다. 결합도(coupling)이 높아졌고, 응집도(cohesion)가 낮아졌다는 것이다.

     

    결합도 증가

     아래는 퍼블릭 상수를 사용한 Records와 Rows이다.

    public class Records {
        private static final String EOL = "\r\n";
    
        void write(Writer out) {
            for (Record rec : this.all) {
                out.write(rec.toString());
                out.write(Constants.EOL);
            }
        }
    }
    
    public class Rows {
        private static final String EOL = "\r\n";
    
        void print(PrintStream pnt) {
            for (Row row : this.fetch()) {
                pnt.printf(
                        "{ %s }%s", row, Constants.EOL
                );
            }
        }
    }

     두 클래스 모두 같은 객체에 의존하고 있으며, 이 의존성은 하드 코딩되어 있다. 이 경우에 의존성을 쉽게 분리할 수 있는 방법이 없다. Records.write(), Rows.print(), Constatns.EOL 세 곳에서 코드의 일부가 서로 결합되어 의존하고 있다. Constants.EOL의 내용을 수정하면 다른 두 클래스의 행동은 예상할 수 없는 방향으로 변경될 것이다. Constants.EOL을 변경하는 입장에서는 이 값이 어떻게 사용되고 있는지 알 수 있는 방법이 없기 때문이다. 어떤 곳에서 출력 중에 한 줄을 종료하기 위해 이 값을 사용하고 있을 수도 있으며, HTTP 프로토콜에 포함된 콘텐츠 한 줄을 종료하기 위해 이 값을 사용하고 있기 때문에 이 인코딩 규칙을 변경하는 것은 절대적으로 불가능하다.

     

     Constants.EOL 객체는 사용 방법과 관련된 어떤 정보도 제공하지 않은 채 모든 곳에서 접근 가능한 전역 가시성 안에 방치되어 있다. 이 객체가 어떤 문맥 안에서 어떻게 사용되어야 하고, 이 객체의 변경으로 인해 사용자가 어떤 영향을 받는지에 관해서도 알 수 없다. 많은 객체들이 다른 객체를 사용하는 상황에서 서로를 어떻게 사용하는지 알 수 없다면, 이 객체들은 매우 강하게 결합되어 있는 것이다.

     

    응집도 저하

     퍼블릭 상수를 사용하면 객체의 응집도는 낮아지고, 이는 객체가 자신의 문제를 해결하는데 덜 집중한다는 것을 의미한다. Constants.EOL은 하나의 텍스트 덩어리에 불과하고 자신만의 의미가 없다. 의미를 추가하기 위해서는 Records와 Rows 클래스 안에 더 많은 코드를 작성해야 한다. 목적을 명확하게 만들어줄 코드를 추가해서 이 원시적인 정적 상수를 감싸야 한. 하지만 이런 코드는 Records와 Rows가 의도했던 원래의 목적과는 동떨어져 있을 수 밖에 없다.

     Records와 Rows의 목적은 한 줄의 마지막을 처리하는 것이 아니라 레코드나 로우 자체를 처리하는 것이다. 따라서 한 줄을 종료하는 작업을 다른 객체에게 위임한다면 각 객체의 응집도를 향상시킬 수 있다. 이제 "레코드는 내가 처리할 테니 한 줄의 끝을 처리하는 일은 당신이 해주세요."라고 이야기할 수 있게 된다. 이것은 공정한 요청이며, 이를 통해 클래스는 더 높은 응집도를 유지할 수 있다.

     객체 사이에 데이터를 중복해서는 안되는 대신 기능을 공유할 수 있도록 새로운 클래스를 만들어야 한다. Records와 Rows에서는 두 클래스 모두 한 줄이 종료될 때 EOL을 출력할 필요가 있다. 이제 이 기능을 공통으로 제공할 새로운 클래스를 추가하자.

    class EOLString {
        private final String origin;
        EOLString(String src) {
            this.origin = src;
        }
        
        @Override
        String toString() {
            return String.format("%s\r\n", origin);
        }
    }

    이제 Records와 Rows 같이 필요한 곳에서 EOLString을 사용할 수 있다.

    public class Records {
        private static final String EOL = "\r\n";
    
        void write(Writer out) {
            for (Record rec : this.all) {
                out.write(new EOLString(rec.toString()));
            }
        }
    }
    
    public class Rows {
        private static final String EOL = "\r\n";
    
        void print(PrintStream pnt) {
            for (Row row : this.fetch()) {
                pnt.printf(
                        new EOLString(
                                String.format("{ %s }", row)
                        )
                );
            }
        }
    }

     이제 한 줄의 마지막에 접미사(suffix)를 덧붙이는 기능을 EOLString 클래스 안으로 완벽하게 고립시켰다. 접미사를 줄 마지막에 정확하게 추가하는 방법에 관해서는 EOLString이 책임진다. Records와 Rows는 더 이상 해당 로직을 포함하지 않는다. 각 줄의 끝에 접미사가 추가되는 정확한 방법을 알 지 못하고, 그저 EOLString이 그 작업을 책임진다는 사실만을 알고 있을 뿐이다.

     Constants.EOL과의 차이는 EOLString에 대한 결합은 계약(contract)을 통해 추가된 것이라는 점이다. 계약에 의한 결합은 언제라도 분리가 가능하기 때문에 유지보수성을 저하시키지 않다. 이 결합은 Records와 EOLString이라는 동등한 수준의 영리한 두 요소로 구성된다. EOLString은 계약에 따라 행동하며 내부에 계약의 의미를 캡슐화한다.

     현재의 플랫폼에 의존하고 있는 동작을 내일 변경해야 한다고 가정해보자. 이 프로그램이 Windows에서 실행될 경우에는 '\r\n'을 추가하는 대신 예외를 던지도록 코드를 수정하고 싶다. 다음과 같이 하면 계약(인터페이스)은 동일하게 유지하면서 동작은 변경할 수 있다.

    class EOLString {
        private final String origin;
        EOLString(String src) {
            this.origin = src;
        }
    
        @Override
        String toString() {
            if (/* WIndows의 경우 */) {
                throw new IllegalStateException("현재 Windows에서 실행 중이기 떄문에 EOL을 사용할 수 없습니다. 죄송합니다.");
            }
            return String.format("%s\r\n", origin);
        }
    }

     public static 리터럴을 사용하면 이런 변경이 불가능하다.

     

    이 말은 퍼블릭 상수마다 계약의 의미를 캡슐화하는 새로운 클래스를 만들어야 한다는 것을 의미할까?

    맞다.

     

    수백 개의 단순한 상수 문자열 리터럴 대신 수백 개의 마이크로 클래스를 만들어야 한다는 것을 의미하는 것일까?

    그렇다.

     

    이렇게 하면 중복 코드를 가진 마이크로 클래스들에 의해 코드가 더 장황해지고 오염되지는 않을까?

    그렇지 않다.

     

    클래스 사이에 중복 코드가 없다면 클래스가 작아질수록 코드는 더 깔끔해진다. 논리적이지 않다고 생각할 수도 있겠지만 이 사실은 매우 중요하다. 애플리케이션을 구성하는 클래스의 수가 많을수록 설계가 더 좋아지고 유지보수하기도 쉬워진다.

     

    예를 들어보자. Java를 포함해 HTTP 클라이언트들은 다음과 같이 HTTP 요청 메서드(HTTP request method)를 변경할 수 있는 기능을 제공한다.

    String body = new HttpRequest()
      .method("POST")
      .fetch();

    그리고 이 클라이언트들은 HTTP 메서드의 이름을 표현하는 public static 리터럴 집합도 함께 제공한다. 다음은 리터럴을 사용하도록 변경한 최종 코드이다.

    String body = new HttpRequest()
      .method(HttpMethod.POST)
      .fetch();

    하지만 이 코드는 OOP 정신에 어긋난다. 리터럴을 사용하는 대신 HTTP 메서드를 표현하는 단순한 클래스를 많이 만드는 편이 좋다.

    String body = new PostRequest(new HttpRequest())
     .fetch();

    PostRequest는 HttpRequest를 설정하는 방법을 알고 있기 때문에 기본 GET 요청 대신 POST 요청을 생성한다. 새로운 클래스인 PostRequest는 'POST' 리터럴의 의미론(semantic)인 설정 로직을 내부에 캡슐화했다. 우리는 더 이상 'POST' 리터럴의 의미를 기억할 필요가 없다. 단지 PSOT 방식으로 요청을 전송하기만 하면 된다. 우리는 이 과정이 HTTP 프로토콜 수준에서 정확히 어떻게 수행되는 지 상관할 필요가 없다.

     

    댓글

Designed by Tistory.