006. (Clean Code) 6. 객체와 자료구조 - Objects and Data Structures

6. 객체와 자료구조 - Objects and Data Structures

객체지향 프로그래밍 언어에서 클래스가 가진 변수를 private으로 정의하는 이유는 무엇일까?

클래스 자기 자신이 아닌 다른 클래스가 해당 변수를 의존하지 않게 만들기 위해서이다.

그런데 왜 변수를 private으로 선언해놓고, getter/setter를 통해 간접적으로 외부에 노출시키는 걸까?

6.1. 자료 추상화

아래 두 개의 클래스를 보자.

1
2
3
4
public class Point {
public double x;
public double y;
}
1
2
3
4
5
6
7
8
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가 어떤 자료구조를 가지고 있는지 메서드를 통해 표현하고 있다.

그럼 단순히 변수를 private으로 선언하고 함수 계층을 넣으면 구현이 감추어질까?

엄밀하게 말해 구현을 감춘다는 것은 추상화를 통해 구현을 모른채 해당 자료를 수정하고 조회할 수 있어야 한다.

이번엔 다른 예제를 살펴보자.

1
2
3
4
5
// Vehicle-A
public interface Vehicle {
double getFuelTankCapacityInGallons();
double getGallonsOfGasoline();
}
1
2
3
4
// Vehicle-B
public interface Vehicle {
double getPercentFuelRemaining();
}

Vehicle-A는 자동차의 연료 상태를 구체적인 숫자로 알려주고 있고, Vehicle-B는 백분율로 알려주고 있다.

무슨 차이가 있을까?

Vehicle-A는 클래스가 가진 어떤 변수의 값을 그대로 반환하고 있음을 추측할 수 있고,

Vehicle-B는 내부의 변수를 활용해 계산하는건지 아예 백분율을 변수로 가지고 있는 건지 알수가 없다.

결과적으로 구현을 감추는 것이 중요한 객체지향에서는 Vehicle-B가 제일 우아하게 자료를 표현한 것임을 알 수 있다.

6.2. 자료/객체 비대칭

객체는 추상화를 방패로 데이터를 숨긴채, 해당 데이터를 활용하는 함수만을 공개한다.

반면, 자료구조는 자료를 그대로 공개하고 별다른 함수를 제공하지 않는다.

즉 객체와 자료구는 본질적으로 상반된 정의를 가지고 있는 것이다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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();
}
}

상위 3개의 클래스는 각각 정사각형, 직사각형, 원을 표현하고 있고, Geometry 클래스는 파라미터로 넘어온 도형의 면적을 반환해주는 area() 함수를 가지고 있다.

만약 Geometry에 도형의 둘레를 구하는 perfimeter() 함수를 추가한다면 어떻게 될까?

Square, Rectangle, Circle 클래스에 전혀 영향을 주지않고 추가할 수 있을 것이다.

하지만 반대로 새로운 도형 클래스를 추가하는 경우엔 Geometry의 모든 함수를 고쳐야 한다.

이를 해소하기 위해 좀 더 객체지향적으로 도형을 표현해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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;
}
}

모든 도형이 Shape 인터페이스를 구현하여 각각 면적에 해당하는 함수를 구현하였다.

절차지향적인 코드와 객체지향적인 코드 둘 중 어느 것이 더 좋은 코드일까?

정답은 “절대 우위는 없다” 이다.

절차지향적인 코드와 객체지향적인 코드 모두 상호보완적이기 때문이다.

그래서 결국 객체와 자료구조는 근본적으로 둘로 나뉜다.

술어로 정리하면 아래와 같다.

자료구조를 사용하는 절차지향적인 코드는 기존 자료구조를 변경하지 않으면서 새로운 함수를 추가하기 쉽다.
반면, 객체지향적인 코드는 기존 함수를 변경하지않으면서 새로운 클래스를 추가하기 쉽다.

복잡한 시스템을 구현하다보면 새로운 함수가 아닌 새로운 타입의 자료구조가 필요한 경우가 생긴다.

이때는 객체지향 기법이 좀 더 유리하고, 새로운 함수가 필요한 경우엔 절차지향 기법이 좀 더 유리하다.

상술했듯 절대 우위는 없으므로 각 상황에 맞는 최적을 잘 적용해야 한다.

6.3. 디미터 법칙

디미터 법칙은 모듈은 자신이 조작하는 객체의 속사정을 몰라야한다는 법칙이다.

좀 더 명세해보자면, 객체는 조회 함수를 통해 내부 구조를 공개해선 안된다는 뜻이다.

예를 들어 아래 코드는 디미터 법칙을 위반한다고 볼 수 있는 코드이다.

1
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

참고

기차 충돌 - Train Wrecks

1
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

위와 같은 코드를 흔히 기차 충돌이라고 부른다.

코드의 호출 구조가 여러 객체가 한 줄로 이어진 기차처럼 보이기때문인데,

일반적으로 조잡한 코드로 취급되므로 아래와 같이 분리하는 것이 권장된다.

1
2
3
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();

위와 같이 분리하면 디미터 법칙을 준수한 것일까?

이는 ctxt, opts, scratchDir이 객체인지, 자료구조인지에 따라 다르다.

객체라면 내부 구현을 숨겨야 하므로 디비터 법칙을 위배한 것이고, 자료구조라면 내부 자료를 보여주어야하므로 위배가 아니다.

아래와 같이 조회 함수가 아닌 공개된 변수를 통해서만 접근이 가능했다면 디미터 법칙을 위반하지 않았을 것이다.

1
final String outputDir = ctxt.options.scratchDir.absolutePath;

잡종 구조 -Hybrids

객체와 자료구조가 주는 혼란으로 인해, 반은 객체 반은 자료구조인 하이브리드 형태를 작성하게 된다.

하이브리드는 중요한 기능을 수행하는 함수와 공개된 변수나 조회, 설정 함수도 제공한다.

이러한 구조를 새로운 함수를 추가할때도, 새로운 자료구조를 추가하기도 어려운 단점의 총아와 같은 형태를 가진다.

구조체 감추기

1
2
3
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();

만약 위 코드에서 ctxt, opts, scratchDir이 객체라고 가정하면 어떻게 될까?

객체는 내부 구현을 감추어야하므로 기차 충돌 형태를 띄어서는 안된다.

그럼 어떻게 변경하면 좋을까?

첫 번째 방법은 ctxt 객체에서 처음부터 outputDir을 제공하는 것이다.

1
2
// 첫 번째 방법
final String outputDir = ctxt.getAbsolutePathOfScratchDirectoryOption();

이 방법은 ctxt 객체가 공개해야하는 메서드가 너무 많아질 수 있다는 단점이 있다.

1
2
// 두 번째 방법
final String outputDir = ctxt.getScratchDirectoryOption().getAbsolutePath();

두 번째 방법은 getScratchDirectoryOption() 함수를 통해 자료구조를 반환하는 것인데 이도 우아하게 느껴지진 않는다.

근본적으로 절대 경로 outputDir이 왜 필요한지에 대해서부터 고민해야한다.

만약 아래와 같이 절대 경로를 얻는 목적이 임시 파일을 생성하고 이를 저장하기위한 경로를 확보하기 위함임을 알게되었다고 가정하자.

1
2
3
String outFile = outputDir + "/" + className.replace('.', '/') + ".class";
FileOutputStream fout = new FileOutputStream(outFile);
BufferedOutputStream bos = new BufferedOutputStream(fout);

목적을 알았으니, 이를 우아하게 해결하기 위해선 ctxt 객체에 임시 파일을 생성하도록 시키면 된다.

1
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

이제 ctxt는 내부 구조를 드러내지않고, 특정 경로에 임시파일을 생성하기 위한 목적을 달성할 수 있게 되었다.

6.4. 자료 전달 객체 - Data Transfer Objects

자료구조의 전형적인 형태는 공개된 변수만 있고 함수가 없는 클래스이다.

이러한 형태의 클래스를 자료 전달 객체(DTO : Data Transfer Object) 라고 부른다.

DTO는 굉장히 유용한 구조체로 데이터베이스와 통신하거나 소켓에서 받은 메시지의 구분을 분석할때 유용하다.

좀 더 일반적인 형태는 빈(Bean) 구조이다.

빈은 비공개 변수를 조회/설정 함수로 조작하는 일종의 가짜 캡슐화다.

아래 예제가 빈의 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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()와 같은 탐색 함수도 제공한다.

이 활성 레코드는 데이터베이스의 테이블이나 다른 소스에서 자료를 직접 반환한 결과이며,

활성 레코드에 특정 비즈니스 로직을 추가해서 객체로 취급하는 불행한 경우가 종종 존재한다.

이 경우 잡종 구조이기때문에 최대한 회피해야한다.

근본적인 해결책은 당연히 모든 활성 레코드를 자료구조로 취급하여 비즈니스 로직을 아예 추가하지않는 것이다.