데보션 영 다독다독 조별 스터디에서 <클린 코드: 애자일 소프트웨어 장인 정신(로버트C.마틴, 인사이트)>를 읽고
공부한 내용을 바탕으로 정리한 글입니다.
자료 추상화
점을 표현하는 아래의 두 가지 클래스를 봅시다.
// 구체적인 Point 클래스
public class Point {
public double x;
public double y;
}
// 추상적인 Point 클래스
public interface Point {
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}
구체적인 Point 클래스는 확실하게 직교좌표계를 쓰고 있음을 알 수 있고, 좌표값을 개별적으로 읽고 설정하게 합니다. 반면에 추상적인 Point 클래스는 직교좌표계를 사용하는지, 극좌표계를 사용하는지 모릅니다. 또, 클래스 메서드가 접근 정책을 강제합니다.(좌표 값을 읽을 때는 각 값을 개별적으로 읽고 설정할 때는 두 값을 한꺼번에 설정해야 합니다.)
하지만, 그저 조회 함수와 설정 함수로 변수를 다룬다고 클래스가 되고, 추상화가 되는 것이 아닙니다. 이보다는 사용자가 "구현을 모르게"하는 것이 진정한 추상화입니다.
다음 두 가지 방법으로 작성된 Vehicle 클래스를 봅시다.
// 구체적인 Vehicle 클래스
public interface Vehicle {
double getFuelTankCapacityInGallons();
double getGallonsOfGasoline();
}
// 추상적인 Vehicel 클래스
public interface Vehicle {
double getPercentFuelRemaining();
}
구체적인 Vehicle 클래스는 연료 상태를 구체적 값으로 알려주는 반면 추상적인 Vehicle 클래스는 연료 상태를 추상적인 개념으로 알려주고 있습니다. 이처럼 표현하는 것이 진정한 추상화라고 볼 수 있겠습니다.
자료/객체 비대칭
두 가지 방법(절차적/객체지향적)으로 작성된 도형 클래스를 봅시다.
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) {
Rectangel r = (Rectangle)shape;
return r.height * r.width;
}
else if (shape instanceof Circle) {
Circle c = (Circle)shape;
return radius * radius * PI;
}
throw new NoSuchShapeException();
}
}
위의 코드처럼 절차적 코드로 도형 클래스를 작성했을 경우, 새로운 함수를 추가할 때는 기존 자료 구조를 전혀 변경하지 않고 추가할 수 있습니다. 예를 들어, Geometry
클래스에 도형의 둘레의 길이를 구하는 perimeter()
함수를 추가하로 싶을 대, 도형 클래스 자체는 아무런 영향을 받지 않습니다. 하지만, 새로운 자료 구조를 추가하기는 어렵습니다. 예를 들어, 위 코드에서 새로운 도형을 추가하고 싶다면, Geometry
클래스에 속한 함수의 수정이 필요합니다.
public class Square impletments Shape {
private Point topLeft;
private double side;
public double area() {
return side * side;
}
}
public class Rectangle impletments 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 radius * radius * PI;
}
}
위의 객체지향적 코드에서는 절차적 코드와 완전히 반대의 특징을 가집니다. 새로운 함수를 추가하기 위해서는 모든 클래스를 고쳐야하고, 새로운 클래스를 추가할 때는 기존 함수 변경없이 추가가 가능합니다. 예를 들어, 둘레의 길이를 구하는 함수를 추가하기 위해서는 모든 도형 클래스에 새롭게 함수를 추가해주어야 하지만, 새로운 도형 자체를 추가할 때에는 다른 도형 클래스는 전혀 건드리지 않고 새로운 도형 클래스를 추가하기만 하면 됩니다.
디미터 법칙
디미터 법칙이란, "모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다"는 법칙입니다. 즉, 객체는 조회 함수로 내부 구조를 공개해서는 않됩니다. 클래스 C의 메서드 f는 클래스 C의 메서드, f가 생성한 객체의 메서드, f인수로 넘어온 객체의 메서드, C인스턴스 변수에 저장된 객체의 메서드만 호출해야 한다고 디미터 법칙은 이야기 합니다.
The Law of Demeter
It often is forgotten or ignored 😔
levelup.gitconnected.com
final String outputDir = text.getOptions().getScratchDir().getAbsolutePath();
위의 코드를 기차 충돌(train wreck)라고 부릅니다. 여기서는getOptions()
함수가 반환하는 객체의
getScratchDir()
메서드를 호출하고, getScratchDir()
함수가 반환하는 객체의getAbsolutePath()
메서드를 호출합니다. 이는 위에서 호출할 수 있는 메서드의 범위를 벗어나므로 디미터 법칙에 위반된 코드입니다.
그렇다면 아래의 코드는 어떨까요?
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();
기차 충돌 코드보다는 좋아보이지만, 이 또한 디미터 법칙을 완전히 준수하고 있다고 보기 어렵습니다. 왜냐하면 위의 코드를 포함하고 있는 함수는 ctxt 객체가 Options을 포함하고, Options가 ScratchDir을 포함하고 ScratchDir이 AbsolutePath를 포함한다는 사실을 알고 있습니다. 함수 하나가 아는 지식이 너무 많고, 많은 객체를 탐색할 수 있는 것은 좋지 않습니다.
그러면 이런 경우에는 어떻게 절대 경로를 구하는 코드를 작성하는 것이 좋을까요?
final String outputDir = ctxt.options.scratchDir.absolutePath;
앞선 코드처럼 조회 함수를 사용하지 않고 위의 함수처럼 그저 자료 구조만 사용한다면 디미터 법칙을 거론할 필요조차 없어집니다. 자료 구조는 무조건 함수 없이 공개 변수만 포함하고 객체는 비공개 변수와 공개 함수를 포함하게 한다면 디미터 법칙을 지키기 수월합니다. 하지만, 단순한 자료구조에도 get함수와 set 함수를 정의하라는 프레임워크와 표준(빈bean)이 존재합니다.(저도 이렇게 배웠는데,,,😅)
[잡종 구조]
잠종 구조란 절반은 객체, 절반은 자료 구조인 구조입니다. 이런 구조는 객체와 자료구조의 단점만 모아놓은 구조로, 사용하지 않는 것이 훨씬 좋은 코드입니다.
[구조체 감추기]
객체에게는 "속을 드러내라!"가 아니라 "뭔가를 해라!"라고 이야기해야 합니다.
위의 예시의 경우, ctxt 객체에서 임시 디렉토리의 절대 경로가 필요한 이유가 임시 파일을 생성하기 위한 목적이라고 했을 때, ctxt 객체에게 임시 파일을 생성하라고 시키는 것은 훨씬 더 좋은 대안이자 코드가 될 수 있습니다.
BufferedOutputStream bos = ctxt.createSctrachFileStream(classFileName);
위 코드처럼 작성한다면 내부 구조를 드러내지 않고 모듈에서 해당 함수는 자신이 몰라야 하는 객체를 탐색할 필요가 없으므로 디미터 규칙을 지킬 수 있습니다.
자료 전달 객체 (Data Transfer Object, DTO)
DTO란 공개 변수만 있고 함수는 없는 클래스로, 자료를 전달하는 데에 그 목적이 있는 객체를 이야기 합니다. 흔히 데이터베이스에 저장된 raw data를 어플리케이션 코드에서 사용할 객체로 변환할 때 사용되는 객체입니다. 일반적인 형태가 빈(bean) 구조인데, 비공개 변수를 조회(get)/설정(set) 함수로 조작합니다. 자바에서 자료구조를 만들 때 일반적으로 많이 쓰는 구조입니다. 그러나 이런 형태는 책에서는 사이비 캡슐화라고 이야기하면서 별다른 이익을 제공하지 않는다고 말합니다..!(오엥...)
public class Address {
private String street;
private String streetExtra;
private String city;
private String state;
private String zip;
private 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;
}
}
[활성 레코드]
(이해 안됨,,)
활성 레코드는 자료 전달 객체의 특수한 형태로, 공개 변수와 비공개 변수의 조회/설정 함수가 있고 거기에 save와 find 같은 탐색 함수도 제공합니다. 즉 데이베이스나 다른 코드에서 자료를 직접 변환한 결과가 활성 레코드입니다. 이 활성 레코드를 자료 구조로 취급한다.
결론은..
객체는 동작을 공개하고, 자료를 숨깁니다. 따라서 새로운 자료 타입을 추가하는 유연성이 필요한 경우는 객체를 사용하고, 새로운 동작(함수)를 추가하는 유연성이 필요한 경우라면 자료구조와 절차적 코드를 사용하는 것이 적절합니다! 결국 무조건 이렇게 해라!는 없기 때문에 프로그래머는 고민하고 고민하고 또 고민해서 코드를 작성하자!입니다
❗️저는 무조건 객체지향적으로 코드를 짜는 것이 좋다고 생각했는데, 어떤 상황에서 어떤 유연성이 더 필요하냐에 따라 코드 작성 방법을 프로그래머가 심도있게 고민해야 한다는 내용이 매우 인상적이었습니다!
또, 제가 지금까지 알고 있었던 추상화의 개념이 너무너무 좋지 않은 개념이었구나를 깨달았습니다. 클래스 안의 변수를 그저 getter/setter로만 감싸면 추상화했다!라고 할 수 있을 줄 알았는데,, 절대 절대 아니었다는... 것을 깨달았습니다ㅎㅡㅎ
댓글