왜 프로그래머들은 변수를 private으로 설정하고 조회(get)와 설정(set)함수를 당연하게 public으로 외부에 노출할까요? 그리고 우리는 당연하게 getter와 setter로 접근하게 버릇을 만듭니다.
그런데 결국엔 "이게 더 지저분한거 아니야?"라는 생각을 하기도 하고요 그냥 "변수에 대한 접근 제어를 함수로 처리하니 더 좋지~"라고 넘어가기도 해요. 이번 장에서는 이러한 객체나 자료 구조로의 추상화를 하는 이유나 생각을 만들어드리려고 해요.
// Concrete Pointpublic class Point { public double x; public double y;}// Abstract Pointpublic interface Point { double getX(); double getY(); void setCartesian(double x, double y); double getR(); double getTheta(); void setPolar(double r, double theta);}위 두 클래스에 대해서 어떻게 생각하시나요? 1번째 Point는 직교좌표계를 쓴다는 것이 보여요. 2번째 Point는 직교인지 극좌표계인지 혹은 둘다 아닌지 잘 모르겠어요
우선 1번째 클래스는 각 값을 직접 설정하고 조회해서 구현을 외부에 노출하고 있어요. 그러면 변수를 private으로 선언하고 getter와 setter를 추가해서 계층을 만들면 달라질까요? 이것도 결국 형식 논리에 빠져있을때만 추상화된 것이지 마찬가지로 외부에 구현을 노출하는 거에요. 결국 핵심은 추상 인터페이스를 사용하는 사용자가 내부 구현은 모르지만 조작할 수 있어야하는 거에요.
// Concrete Vehiclepublic interface Vehicle { double getFuelTankCapacityInGallons(); double getGallonsOfGasoline();}// Abstract Vehiclepublic interface Vehicle { double getPercentFuelRemaining();}위 두 클래스에 대해서는 어떻게 생각하시나요? 1번째 Vehicle은 [연료통 용량을 갤런으로 알려주는 함수], [연료 상태를 갤런으로 알려주는 함수]를 세세하게 알려주고 있어요. 하지만 2번째 Vehicle은 어디서 정보가 오는지 모르겠지만 연료 상태를 백분율이라는 개념으로 알려줘요.
1번째 클래스는 사실 내부 구현을 그대로 알려주는 모양이고, 2번째 클래스는 좀 더 추상적인 개념으로 나타내요.
저자는 위 두 코드에서 2번째 Point와 2번째 Vehicle 클래스가 더 좋다고 판단했어요. 자료를 세세하게 공개하기보다는 추상적인 개념으로 표현하는 것이 더 좋기 때문이에요. 조회/설정 함수만으로는 추상화가 되지 않음을 명심하세요. 아무 생각 없이 조회/설정 함수를 추가한다면 함께 반성해볼까요?
앞서 소개한 두 예제는 객체와 자료 구조 사이에 벌어진 차이를 보여줘요. 객체는 추상화 뒤로 자료를 숨기고 자료를 다루는 함수만 공개해요. 자료구조는 자료를 그대로 공개하며 별다른 함수를 제공하지 않아요. 자료 구조와 객체는 본질적으로 상반되는 거에요.
다음 예시에서 절차적인 도형 클래스와 Geometry 클래스를 봐보세요. 각 도형 클래스는 간단한 자료 구조이고 도형의 동작 방식은 Geometry 클래스에서 구현되요.
public class Square { public Point topLeft; public double side;}public class Rectangle { public Point topLeft; public double height; public double width;}public class Circle { public Point center; public double radius;}public class Geometry { public final double PI = 3.141592653589793; public double area(Object shape) throws NoSuchShapeException { if (shape instanceof Square) { Square s = (Square)shape; return s.side * s.side; } else if (shape instanceof Rectangle) { Rectangle r = (Rectangle)shape; return r.height * r.width; } else if (shape instanceof Circle) { Circle c = (Circle)shape; return PI * c.radius * c.radius; } throw new NoSuchShapeException(); }}객체 지향 프로그래머가 위 코드를 보면 의존성 역전이든 뭐든 사용해야 한다면 절차적이라 비판할 수 있어요. 그리고 맞는 말이긴해요.
하지만 100% 비판할 수는 없어요. 만약 둘레를 구하는 perimeter()라는 함수를 추가하고 싶을때 class에 추가하는것과 Gemetry에 추가하는 것 중에 뭐가 더 좋을까요? 절차 지향적 코드에 Geometry에 둘레를 구하는 함수를 만든다면 나머지 3개의 도형 클래스는 전혀 영향받지 않아요.
그러면 반대로 새 도형을 추가하고 싶다면 어떨까요? Geometry 클래스에 속한 모든 함수를 고칠 확률이 높아요. 두 조건은 완전히 반대인 셈이에요.
이제는 객체 지향적 도형 클래스를 구현한 다음 예시로 이야기해봐요. area()는 다형 메서드이고 Geometry 클래스가 필요 없어져요. 그래서 새 도형을 추가해도 기존 함수에 영향이 없어요. 하지만 새 함수를 추가하려면 모든 도형 클래스를 고쳐야해요. 진짜 반대인 조건 같죠?
public class Square implements Shape { private Point topLeft; private double side; public double area() { return side*side; }}public class Rectangle implements Shape { private Point topLeft; private double height; private double width; public double area() { return height * width; }}public class Circle implements Shape { private Point center; private double radius; public final double PI = 3.141592653589793; public double area() { return PI * radius * radius; }}그래서 위 객체 지향적 도형과 절차 지향적 도형은 상호 보완적인 특질이 있어요. 사실 서로 반대라고 볼 수 있어요. 그래서 객체와 자료구조는 근본적으로 양분되요.
(자료 구조 사용하는) 절차적인 코드는 기존 자료구조를 변경하지 않으면서 새 함수 추가가 쉬워요. 반대로 객체 지향 코드는 기존 함수를 변경하지 않으면서 새 클래스 추가가 쉽고요.
반대로 말해도 똑같아요.
절차적인 코드는 새로운 자료 구조 추가가 하려면 모든 함수를 고쳐야 해서 자료 구조 추가가 어렵고요. 객체 지향 코드는 새로운 함수 추가를 하려면 모든 클래스를 고쳐야해서 새로운 함수 추가가 어려워요.
정리하면 객체 지향 코드에서 어려운 변경은 절차 지향 코드에서 쉽고, 절차 지향 코드에서 어려운 변경은 객체 지향 코드에서 쉬워요.
그래서 복잡한 시스템을 만들때, 새로운 자료 타입이 필요한 경우에는 클래스와 객체 지향 기법이 적합해요.
반면, 새로운 함수가 필요한 경우에는 절차적인 코드와 자료구조를 사용하는 것이 적합하고요.
그래서 상황에 적합한 코드를 짜는 분별 있는 프로그래머가 되야해요.
디미터 법칙은 유명한 짐작법이에요. 자신이 조작하는 객체의 내부 사정을 자세히 몰라야 한다는 법칙이에요. 다시 말하면 객체는 내부 구조를 공개하지 않는 거에요.
좀더 정확히 표현하면 디미터 법칙은 "클래스 C의 메서드 f는 다음과 같은 객체의 메서드만 호출해야 한다"에요.
클래스 C
f 스코프에서 생성한 객체
f 인수로 넘어온 객체
C 인스턴스 변수에 저장된 객체
하지만 위 객체에서 허용한 메서드가 반환하는 객체의 메서드는 호출하면 안되요. (1다리 건너뛰어 만나기 금지!)
이제 다음 코드르 보면 디미터 법칙을 어기는 듯이 보이는 코드에요. 어떤 객체의 A메서드가 반환한 객체의 B 메서드가 반환한 객체의 C를 호출하네요.
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();위와 같은 코드를 기차 충돌이라 불러요. 일반적으로 조잡하다 여겨지는 방식이라 피하는 편이 좋다고 저자가 말해요. 그래서 차라리 다음과 같이 바꾸라해요.
Options opts = ctxt.getOptions();File scratchDir = opts.getScratchDir();final String outputDir = scratchDir.getAbsolutePath();그런데 방금 두 코드 예제가 디미터 법칙을 위반할까여? 디미터 법칙 위반 여부는 ctxt, Options, ScratchDir이 객체인지 자료구조인지에 달려있어요. 객체라면 내부 구조를 숨겨야하기 때문에 디미터 법칙을 위반하는 거고요. 반대로 자료구조라면 당연히 내부 구조를 노출해야하기 때문에 디미터 법칙이 적용되지 않아요.
위 예제는 조회 함수를 사용하기 때문에 혼란을 주는거에요. 코드를 다음과 같이 바꾸면 디미터 법칙을 거론할 필요가 없어져요.
final String outputDir = ctxt.options.scratchDir.absolutePath;[자료구조 => 함수 없이 공개 변수만 포함], [객체 => 비공개 변수와 공개 함수 포함]이라고 정의한다면 문제가 훨씬 간단해져요. 물론 단순한 자료 구조에도 조회 함수와 설정 함수의 정의를 요구하는 프레임워크와 표준(빈, "bean")이 있어요.
잡종 구조(hybrids)는 절반은 객체이고 절반은 자료구조를 말해요. 잡종 구조는 중요한 기능 수행하는 함수도 있고, 공개 변수나 공개 조회/설정 함수도 있어요. 그러면 절차적인 프로그래밍의 자료구조 접근 방식처럼 비공개 변수를 사용하고싶은 유횩에 빠지기도 해요. 그리고 이런 구조는 새로운 함수는 물론이고 자료 구조 추가까지 어렵게 만들어요. 단점만 모아버린 구조가 되는거에요. 프로그래머로서 무지함을 드러내는 것과 같아요.
그러면 만약 앞선 예제에서 ctxt, options, scratchDir이 객체라면 어떻게 outputDir까지 접근할 수 있게 만들까요? 분명한건 아까의 기차놀이를 하면 내부 구조를 다 노출시켜서 안되요. 다음 두 코드를 우선 봐요.
ctxt.getAbsolutePathOfScratchDirectoryOption();ctxt.getScratchDirectoryOption().getAbsolutePath()첫 번째 방법은 ctxt에 공개해야 하는 메서드가 너무 많아지고요.(이름에) 두 번째 방법은 getScratchDirectoryOption()이 자료구조를 반환한다고 가정하는 거에요. 그런데 이것도 마음에는 안들거에요.
ctxt가 객체라면 내부를 보여주지 않고 객체에게 지시를 내려야해요. 그러면 지시를 하기 위해서 절대 경로를 어디에 왜쓰는지 부터 한번 알아 볼까요?
String outFile = outputDir + "/" + className.replace('.', '/') + ".class"; // 추상화 수준이 뒤섞여 있지만 감안해주세요.FileOutputStream fout = new FileOutputStream(outFile);BufferedOutputStream bos = new BufferedOutputStream(fout);위 코드는 같은 모듈에 있는 코드에요. 위 코드를 보면 절대 경로를 얻는 이유가 임시 파일 생성이라는 것이 드러나요. 그러면 이제 절대 경로가 필요한 이유를 알았으니 ctxt 객체에게 임시 파일을 생성하라고 시키면 되겠네요! 시켜볼까요?
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);이러면 ctxt는 내부 구조를 드러내지 않으며 모듈에서는 해당 함수는 자신이 몰라야 하는 여러 객체를 탐색할 필요가 없어져요. 그래서 디미터 법칙을 위반하지 않아요.
자료 구조체는 일반적으로 공개 변수만 있고 함수가 없는 클래스에요. 자료 전달 객체(DTO)라고도 해요. DTO는 유요한 구조체에요. DB와 통신하거나 소켓에서 받은 메시지의 구문을 분석할 때 유용해요. 흔히 DTO는 DB에 저장된 가공되지 않은 정보를 코드단에서 사용할 객체로 변환하는 일련의 단계에서 가장 처음 사용되는 구조체에요.
좀더 일반적인 형태는 빈("bean")구조에요. 다음 예제를 보면 "bean"은 private 변수를 조회/설정 함수로 조작해요.
이 책의 저자는 "bean"구조에 대해서 아주 엄청난 악담을 하는데 이 내용은 저가 담지 않을게요.
public class Address { private String street; private String streetExtra; private String city; private String state; private String zip; public Address(String street, String streetExtra, String city, String state, String zip) { this.street = street; this.streetExtra = streetExtra; this.city = city; this.state = state; this.zip = zip; } public String getStreet() { return street; } public String getStreetExtra() { return streetExtra; } public String getCity() { return city; } public String getState() { return state; } public String getZip() { return zip; }}활성 레코드는 DTO의 특수한 형태에요. 공개 변수가 있거나 비공개 변수에 조회/설정 함수가 있는 자료구조지만, save나 find와 같은 탐색 함수도 제공해요. 활성 레코드는 DB 테이블이나 다른 소스에서 자료를 직접 변환한 결과에요.
그런데 활성 레코드에 비즈니스 규칙 메서드를 추가해 자료구조를 객체로 취급하는 개발자가 흔해요. 이렇게 만들면 자료구조도 아니고 객체도 아닌 잡종 구조가 나오기 때문에 바람직하지 않아요.
해결책은 활성 레코드는 자료 구조로 취급하세요. 비즈니스 규칙을 담으면서 활성 레코드를 숨기는 객체를 따로 생성하세요.
객체는 동작을 공개하고 자료를 숨겨요. 그래서 새 객체 타입 추가는 쉽지만 기존 객체에 새 동작 추가는 어려워요.
자료구조는 동작 없이 자료를 노출해요. 그래서 기존 자료 구조를 위한 새 동작 추가는 쉽지만 새 자료구조를 추가하기는 어려워요.
시스템을 만들 때 자료 타입에 대한 유연성이 필요하면 객체가 적합하고, 새로운 동작에 대한 유연성이 필요한 절차적인 코드가 적합해요.
아마 저가 리팩터링 책 공부한 것을 공유했던 글을 보면 한번 더 봤던 것 보는 느낌일 거에요. 그런데 이렇게 읽어도 어느 순간 많은 부분을 까먹거나 지킬 생각을 안하기도 하는거 같아요. 정말 이렇게 다시 읽으면 많은 도움이 된다는 걸 느껴요. 다시 책을 볼때마다 새롭게 눈을 뜨는 기분이에요. 이상입니다.~