객체와 자료구조
- 자료 추상화
- 자료/객체 비대칭
- 디미터 법칙
- 자료 전달 객체
이 글을 읽기 전에 추상화의 개념을 간단하게 알아보자. 나무 위키에서는 추상화를 아래와 같이 정의했다.
컴퓨터과학에서 추상화는 어떤 종류의 대상들에 대해 그것이 가져야 할 핵심적인 특징들을 가지는 모델을 만드는 것이다.
예를 들어보자면 쇼핑몰에서는 회원에게 더 좋은 서비스를 제공하기 위해서는 고객의 정보가 필요하다. 회원의 정보는 이름, 주소, 휴대폰 번호, 성별 등... 이 있을 수 있다. 이때에 정보라는 범위가 모호하므로 위의 정보 외에도 직장, 취미, 특기, 음식, 이상형까지 포함할 수 있지만 회원 정보로는 필요하지 않다. 그렇기에 이러한 불필요한 정보들을 제거함으로써 중요한 공통점들만 남기는 것도 추상화라고 할 수 있다. (reference: 추상화란?)
즉, 추상화는 어떠한 대상이 가져야 할 핵심적인 특징 중 불필요한 세부 사항은 버리고 공통적인 특징만을 가지는 것이다. 추상화의 이점은 모델링, 코드의 재사용성, 가독성, 일관된 방향성, 생산성, 에러 감소 여러 가지가 있다.
자료 추상화
// 첫 번째
public class Point{
public double x;
public double y;
}
// 두 번째
public interface Point{
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}
위의 두 클래스중 첫 번째는 구현을 외부로 노출하고 두 번째 클래스는 구현을 숨긴다. 두 번째는 점이 직교 좌표계를 사용하는지 극좌표계를 사용하는지 알 수 없다. 반면 첫 번째는 직교 좌표계를 사용하는 것이 분명하게 드러난다. 첫 번째 클래스의 변수들을 private로 바꾸고 조회 함수와 설정 함수를 제공한다고 해도 구현을 외부로 노출하는 것은 마찬가지다.
변수 사이에 함수라는 계층을 넣는다고 구현이 감춰지지는 않는다. 구현을 감추려면 추상화가 필요하다. 형식적인 조회 함수(getter())와 설정 함수(setter())로 변수를 다룬다고 클래스가 되지는 않는다. 진정한 클래스는 추상 인터페이스를 제공해 사용자가 구현을 모른 채 자료의 핵심을 조작할 수 있어야 한다.
자료 추상화 정리
나는 자료 추상화에서 구현이라는 단어에 집중했다. 구현을 외부로 노출하는 것과 노출하지 않는 것은 무엇일까?, 구현을 모른 채 자료의 핵심을 조작한다는 것은 무엇일까? 내 생각을 조금 정리해봤다.
구현을 외부로 노출한다는 것은 추상화 인터페이스를 통해 노출된 객체의 행동을 사용하는것이 아닌 사용자가 직접적으로 객체의 상태를 조회하고 조작할 수 있는 상태이다. 코드를 통해 살펴보자
public class ExampleList {
public Object[] elementData;
}
private ExampleList ExampleList(){
ExampleList list = new ExampleList();
list.elementData[0] = "test";
return list;
}
평소 우리는 ArrayList를 사용할때에 add(), get() 등.. 의 메서드들을 이용하여 데이터 조회 및 조작을 했을 것이다. 그러나 위의 코드는 사용자가 직접적으로 객체의 상태를 조작하는 모습을 볼 수 있다. 여기에 public을 private로 바꿔주고 get(), set() 함수를 만들어줘도 같은 결과다. 그럼 어떻게 해야 할까? 구현을 외부로 노출하지 않는 것이 무엇인지 살펴보며 이해해보자.
구현을 외부로 노출하지 않는다는 것은 추상화 인터페이스를 통해 노출된 행동을 사용하여 객체의 상태를 조회하고 조작할 수 있는 상태이다. 코드를 통해 살펴보자
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
transient Object[] elementData;
........
}
private ArrayList ExampleList(){
ArrayList list = new ArrayList();
list.add("test");
return list;
}
구현을 외부로 노출했을때 즉, ExampleList 클래스를 사용할 때는 elementData를 사용자가 직접 조작했었다. 반면 ArrayList는 add()라는 행동을 통해서 데이터를 조작하는 모습을 볼 수 있다.
ArrayList는 List 인터페이스의 구현 클래스이다. List 인터페이스에는 add(), get(), remove(), indexOf() 등... 객체의 상태를 조회, 조작할 수 있는 다양한 행동들이 정의되어있다. ArrayList 클래스를 통해 이 행위들을 구현하고, 구현된 행동들을 통해 데이터를 조회하고 조작하는 것이 바로 구현을 외부로 노출하지 않는다는 것이다.
"진정한 클래스는 추상 인터페이스를 제공해 사용자가 구현을 모른 채 자료의 핵심을 조작할 수 있어야 한다."라는 말에도 부합하다. 행동들로 데이터를 조작하기에 사용자는 구현을 모른 채 자료의 핵심을 조작할 수 있다.
추가적으로 추상화를 할때에는 자료를 세세하게 공개하기보다는 추상적인 개념으로 표현하는 것이 좋다. Vehicle이라는 인터페이스가 있고 연료상태를 나타내는 함수를 getGasollonsOfGasoline() 또는 getFuelTankCapacityInGallons()처럼 연료의 상세한 단위를 나타내기보다는 getPercentFuelRemaining()처럼 백분율이라는 추상적인 개념으로 알려주라는 것이다. 간단히 요약하면 상세한 단위로 알려주기보다는 상세한 것들을 통틀어 묶어줄 수 있는 더 상위의 개념을 사용하라는 이야기다.
자료/객체 비대칭
자료 추상화에서 보인 List 예제는 객체와 자료 구조 사이에 벌어진 차이를 보여준다. 객체는 추상화 뒤로 자료를 숨긴 채 자료를 다루는 행동 즉, 함수만 공개한다. 반면 자료 구조는 자료를 그대로 공개하며 별다른 함수는 제공하지 않는다. 두 정의에 차이는 사소한 차이로 보일 수도 있지만, 그 차이가 미치는 영향은 크다. 코드를 통한 예시로 살펴보자.
절차 지향적인 도형
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 double area(Object shape) throws .....{
if(shape instanceof Sqeare){
return ....
}else if(shape instanceof Rectangle){
return ....
}else if(shape instanceof Circle){
return ....
}
}
}
Geometry는 세 가지의 도형 클래스를 다루고 있다. 각 도형 클래스들은 자료구조만을 제공할 뿐 어떠한 행동 즉, 메서드도 제공하지 않는다. 도형이 동작하는 방식은 Geometry클래스에서 구현된다. 절차적인 코드는 기존 자료구조를 변경하지 않으면서 새 함수를 추가하기가 쉽다. 그러나 새로운 자료구조를 추가하려면 함수들을 고쳐야 하기 때문에 자료구조를 추가하기 어렵다.
객체지향적인 도형
public interface Shape{
double area();
}
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 heigth * width;
}
}
public class Circle implements Shape {
private Point center;
private double radius;
private final double PI = 3.141592653589793;
public double area() {
return PI * radius * radius;
}
}
객체 지향적 코드는 자료구조를 감추고 자료구조를 조작할 수 있는 행동 즉, 메서드를 제공한다. 객체 지향적인 코드는 기존 함수를 변경하지 않으면서 새 클래스를 추가하기가 쉽다. 그러나 새로운 함수를 추가하려면 추상 클래스의 구현 클래스들을 모두 고쳐야 한다.
정리하자면 객체 지향 코드에서 어려운 변경은 절차 지향에서는 쉽고, 절차 지향에서 변경이 어려운것은 객체 지향에서 쉽다. 복잡한 시스템을 짜다 보면 새로운 함수가 아니라 새로운 자료 타입이 필요한 경우도 생길 수 있다. 이때는 클래스와 객체 지향 기법이 가장 적당하다. 반면, 새로운 자료 타입이 아닌 메서드가 필요한 경우도 생긴다. 이때는 절차적인 코드와 자료구조가 더 적합하다.
모든 것이 객체임은 미신이다. 때로는 단순한 자료 구조와 절차적인 코드가 가장 적합한 상황도 있다.
디미터 법칙
디미터의 법칙은 디미터라는 이름의 프로젝트를 진행하던 도중 객체가 다른 객체에 대해 지나치게 많이 알다 보니, 결합도가 높아져 좋지 못한 설계를 야기한다는 것을 발견하였다. 그래서 객체에게 자료를 숨기는 대신 함수 즉, 행동을 공개하기로 하였는데 이것이 디미터의 법칙이다. 즉, 디미터의 법칙은 다른 객체가 어떠한 자료를 갖고 있는지 속사정을 몰라야 한다는 법칙이다.
이러한 이유로 디미터의 법칙은 Don't Talk to Strangers(낯선 이에게 말하지 마라) 또는 Principle of least Knowledge(최소 지식 원칙)으로도 알려져 있다. 또는 직관적인 이해를 위해 여러개의 .(도트)를 사용하지 말라는 법칙으로도 많이 알려져 있다. 디미터의 법칙을 준수한다면 결합도를 낮추고 캡슐화를 높여 자율성과 응집도를 높일 수 있다.
낯선 이에게 말하지 마라, 최소 지식 원칙
디미터의 법칙은 노출 범위를 제한하기 위해 객체의 모든 메서드는 다음과 같은 객체의 메서드만 호출해야 한다고 주장한다. "클래스 C의 메서드 f는 다음과 같은 객체의 메서드만 호출해야 한다"
- 클래스 C 객체의 메서드: 객체 자신의 메서드들
- f가 생성한 객체: 메서드 내부에서 생성, 초기화된 객체의 메서드를 의미한다.
- f 인수로 넘어온 객체: 메서드의 파라미터로 넘어온 객체들의 메서드들
- C 인스턴스 변수에 저장된 객체: 인스턴스 변수로 가지고 있는 객체가 소유한 메서드
코드를 통해서 한번 살펴보자.(예제 참조: 디미터의 법칙)
class Demeter {
private Member member;
public void demeterMethod() {
....
}
public void testDemeter(Paramemter param) {
demeterMethod(); // 1.객체 자신의 메서드
Local local = new Local();
local.localMethod(); // 2. 메서드 내부에서 생성, 초기화된 객체의 메서드
param.paramMethod(); // 3. 메서드의 파라미터로 넘어온 객체들의 메서드
member.memberMethod(); // 4. 인스턴스 변수로 가지고 있는 객체가 소유한 메서드
}
}
위 객체에서 허용된 메서드들이 있다. 그러나 해당 메서드가 반환하는 객체의 메서드를 다시 호출해서는 안된다. 간단히 예를 들자면 local.locaMethod(); 까지만 놀고 local.localMethod().unknownMethod(); 이런 식으로 호출해서는 안된다. 낯선 사람은 경계하고 주변 친구랑만 놀라는 의미다.
.(도트)를 사용하지 말라, 기차 충돌
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
흔히 위와 같은 코드를 기차 충돌이라고 부른다고 한다. 여러 객차가 한 줄로 이어진 기차처럼 보이기 때문이다. 위와 같은 코드는 일반적으로 조잡하다 여겨지므로 피하는 것이 좋다. 사실 조잡하다는 이유보다 더 큰 이유가 있다. 위에서 말한 낯선 이에게 말하지 말라, 최소 지식 원칙을 어기고 있기 때문이다. getOption() 함수가 반환하는 객체의 getScratchDir() 함수를 호출하고, 그 반환 객체인 getAsolutePath() 함수를 호출하기 때문이다. 위의 코드는 아래와 같이 작성하는 게 더 좋다.
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDit();
final String outputDir = scratchDir.getAbsolutePath();
위에 나온 두 코드들은 확실히 함수 하나가 아는 지식이 많다. 즉, 함수 하나가 많은 객체를 탐색할 줄 안다는 말이다. 과연 이러한 함수들은 디미터의 법칙을 어긴 것일까...? 위반에 대한 여부는 해당 함수가 객체인지 아니면 자료구조 인지에 달렸다.
객체라면 내부 구조를 숨겨야 하는 게 맞기 때문에 확실히 디미터의 법칙을 위반한다. 그러나 자료구조라면 당연히 내부 구조를 노출하므로 디미터의 법칙이 적용되지 않는다. 우리가 사용하는 Dto는 자료구조이다. 또한.(도트)를 사용하지 말라는 도트를 무조건 한 개만 찍으라는 말은 아니다 아래의 코드를 살펴보자.
public List<DetailMonsterDto> getAllDetailMonster() {
return monsterRepository.findAll()
.stream().map(DetailMonsterDto::fromEntity)
.collect(Collectors.toList());
}
위의 코드가 디미터의 법칙을 어긴 것이라고 생각될 수 있다. 그러나 디미터의 법칙은 내부 결합도와 관련된 것이며, 문제가 되는 상황은 내부 구조가 외부에 유출되는 경우이다. 위의 코드는 Stream의 내부 구조가 노출되지 않았다. Stream을 다른 Stream으로 반환해줬을 뿐 캡슐화는 잘 유지되고 있다.
잡종 구조
잡종 구조란 절반은 객체, 절반은 자료 구조인 구조이다. 잡종 구조는 중요한 기능을 수행하는 함수도 있고, 공개 변수나 공개 조회/설정 함수도 있다. 공개/설정 함수는 비공개 변수를 그대로 노출한다. 이러한 구조는 새로운 함수를 추가하기도 힘들고 새로운 자료 구조를 추가하기도 어렵다. 잡종 구조는 객체와 자료구조의 단점만 모아놓은 구조이기 때문에 피하는 것이 좋다.
객체를 다룰 때에 내부 구조를 감추고 행동 즉, 메서드만을 공개해 조회/조작할 수 있어야 한다는 것을 생각하고 있다면 잡종 구조는 나오지 않으리라 생각된다.
구조체 감추기
위의.(도트)에서 나왔던 ctxt, options, scratchDir들이 자료구조라면 문제가 되지 않는다. 다만 진짜 객체라면 어떨까? 앞서 나온 코드처럼 기차 충돌 형태 즉, 줄줄이 꽤어진 코드 형태를 취해서는 안된다. 객체라면 내부 구조를 감춰야 한다. 행동을 통해 뭔가를 명령해야 하지 속을 드러내라 해서는 안된다. 그렇다면 어떻게 감춰야 할까...?
먼저 집중해야 할 것은 목적이다. 저렇게 경로들을 얻어내는 이유는 임시 디렉터리의 경로를 만들어 임시 파일을 생성하기 위해서라고 가정해보자. 그렇다면 직접 객체를 조회하여 임시 경로를 만들어 임시 파일을 생성하기보다는 객체의 행동 즉, 메서드로 임시 파일을 생성하는 메서드를 만들어 두면 더 좋은 방법이 될 것이다.
경로를 만들기 위해 조회했던 객체들의 내부 구조를 드러내지 않으며 모듈에서 해당 함수는 자신이 몰라야 하는 여러 객체를 탐색할 필요가 없어진다. 디미터 법칙을 위반하지 않는다.
코드로 요약!
// 임시 파일 생성 경로를 얻기 위해 객체를 조회
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDit();
final String outputDir = scratchDir.getAbsolutePath();
// 파일 생성
String outFile = outputDir + "/" + className.replace('.', '/') + ".class";
FileOutputStream fout = new FileOUtputStream(outFile);
BufferedOutputStream bos = new BufferedOutputStream(fout);
// 위와 같은 방법은 객체의 속을 드러내야하며, 여러 추상화 수준이 뒤섞여있다.
// 목적은 임시 디렉토리의 경로를 얻어 임시 파일을 생성하기 위함이다.
// 그렇다면 아래처럼 임시파일을 생성해주는 행동 즉, 메서드를 만드는건 어떨까?
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);
자료 전달 객체
자료 구조체의 전형적인 형태는 공개 변수만 있고 함수가 없는 클래스다. 이런 구조체를 때로는 자료 전달 객체 즉, Data Transfer Object, DTO라고 한다. 흔히 DTO는 데이터베이스에 저장된 가공되지 않은 정보를 애플리케이션 코드에서 사용할 객체로 변환하는 일련의 단계에서 가장 처음으로 사용하는 구조체이다.
좀 더 일반적인 형태는 '빈 Bean' 구조라고 한다. 빈은 비공개 private 변수를 조회/설정 함수로 조작한다. 우리가 흔히 사용하는 DTO가 빈 구조인 것이다
public Class Member{
private name;
private age;
public Member(String name, Integer age){
this.name = name;
this.age = age;
}
public String getName(){
return this.name;
}
public void setName(String name){
this.name = name;
}
.......
}
활성 레코드(ActiveRecord)
활성 레코드는 DTO의 특수한 형태이다. 공개 변수가 있거나 비공개 변수에 조회/설정 함수가 있는 자료구조지만, 대개 save나 find와 같은 탐색 함수도 제공한다. 활성 레코드는 데이터베이스 테이블이나 다른 소스에서 자료를 직접 변환한 결과이다.
활성화 레코드에는 비즈니스 로직 메서드를 추가해서는 안된다. 활성화 레코드는 자료 구조체일 뿐 객체가 아니기 때문이다. 비즈니스 로직 메서드를 추가하는 순간 잡종 구조가 탄생한다. 그렇다면 어떻게 해결할까? 단순하다 활성화 레코드는 그저 자료구조로 취급하면 된다. 비즈니스 로직이 필요하다면 해당 비즈니스 로직을 담은 객체를 만들면 된다. 자료 구조를 사용하고 싶다면 비즈니스 로직 객체의 행동을 통해 사용하거나, 자료구조의 인스턴스를 생성해 사용하면 된다.
마무리
객체는 동작을 공개하고 자료를 숨긴다. 그렇기에 기존 동작을 변경하지 않으면서 새 객체 타입을 추가하기는 쉬운 반면, 기존 객체에 새 동작을 추가하기는 어렵다. 추상화 클래스의 구현 클래스들에 추가된 동작을 모두 구현해줘야 하기 때문이다.
반면 자료구조는 별다른 동작 없이 자료를 노출한다. 그래서 기존 자료구조에 새 동작을 추가하기는 쉬우나, 기존 함수에 새 자료 구조를 추가하기는 어렵다. 자료구조는 쉽게 생각하면 노출된 자료를 통해 메서드를 외부 클래스에서 구현한다고 생각하면 된다. 그렇기에 새 자료구조를 추가하면 이미 구현된 함수를 수정해야 한다.
우리가 어떤 시스템을 구현할 때에 새로운 자료 타입을 추가하는 유연성이 필요하다면 객체가 더 적합하다. 동물이라는 추상 클래스를 통해 토끼, 호랑이, 곰 등... 여러 동물 타입을 쉽게 구현할 수 있다. 다른 경우로는 새로운 동작을 추가하는 유연성이 필요하다면 자료 구조와 절차적인 코드가 더 적합하다. 왜냐하면 이미 구현된 토끼, 호랑이, 곰에 새로운 동작을 추가하려면 동물 추상클래스를 구현한 모든 클래스에 동작을 구현해줘야 하기 때문이다.
우수한 소프트웨어 개발자는 편견 없이 이 사실을 이해하고 직면한 문제에 대해서 최선의 해결책을 선택해야 한다.
'Read Book > CleanCode' 카테고리의 다른 글
CleanCode 8장 경계 (0) | 2022.11.09 |
---|---|
CleanCode 7장 오류 처리 (0) | 2022.11.06 |
CleanCode 5장 형식 맞추기 (0) | 2022.09.24 |
CleanCode 4장 주석 (3) | 2022.09.19 |
CleanCode 3장 함수 (0) | 2022.09.15 |