글을 작성하게 된 계기
회사에서 코드를 작성하던 중, 객체지향에 대해 생각을 정리해보고 싶어 글을 작성하게 되었습니다.
1 왜 이런 생각을 하게 됐을까?
한 1년 반 정도 객체지향을 나름 열심히 공부했습니다. 책/강연을 보면서 생각을 정리하면서 나름 객체지향에 대해 잘 알고 있다고 착각 했는데요, 문득 일주일 전에 작성한 코드를 보다가 너무 마음에 안 들었습니다. 사실 좀 화가 났습니다. 😡
대략 다음과 같은 코드 인데, 매니저(Manager)는 자신이 관리하는 가맹점(Merchant)의 재고를 업데이트 합니다. 언뜻 보기에는 기능도 잘 동작하고, 검증도 잘 돼 있고, 일급 컬렉션으로 응집성도 갖추고 있는 것 같은데 왜 불만이 생겼을까요?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Manager {
private static final String SIZE_S = "S";
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
public void updateAllMerchantCount(final int count) {
for (final Merchant merchant : merchants) {
if (isUpdateable(merchant)) {
merchant.updateCount(count);
}
}
}
private static boolean isUpdateable(final Merchant merchant) {
return merchant.getSize() != null
&& SIZE_S.equals(merchant.getSize());
}
......
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Merchant {
private final String size;
private final String name;
private int count;
public Merchant(
final String size,
final String name,
final int count
) {
this.size = size;
this.name = name;
this.count = count;
}
public void updateCount(final int count) {
validateCount(count);
this.count = count;
}
......
}
참고로 재직중인 회사는 결제 도메인으로, 위 코드는 업무와는 전혀 상관 없습니다.
2. 협력, 역할, 책임
왜 일주일 전에 작성한 코드에 불만을 가졌는지 해답을 찾기 위해 위해 협력과 역할, 책임, 계약의 정의에 대해 다시 생각해봤는데, 그 과정을 따라가보겠습니다.
- 협력(Collaboration)
- 역할(Role)
- 책임(Responsibility)
- 계약(Contract)
2-1. 협력
협력은 객체가 다른 객체에 무엇인가를 요청하는 것 입니다. 매니저는 가맹점들에게 재고를 업데이트 하라고 메시지를 보냅니다. 이를 통해 Manager가 모든 역할을 담당하는 것이 아닌 Merchant와의 협업을 통해 문제를 해결합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Manager {
private static final String SIZE_S = "S";
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
public void updateAllMerchantCount(final int count) {
for (final Merchant merchant : merchants) {
if (isUpdateable(merchant)) {
merchant.updateCount(count);
}
}
}
......
참고로 메시지와 메서드의 차이는 다음과 같습니다.
- 메시지(Message):
- 메서드(Method):
만약 메시지를 보내지 않고 Manager가 모든 것을 처리한다면 다음과 같은 코드가 되겠죠. 협력을 하기 위해서는 문맥(Context)이 필요한데, Manager는 현재 모든 문맥을 다 알고 있는 상태 입니다. 즉, 협력이 아닌 정보 과잉(?) 상태인 것이죠.
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
class Manager {
private static final int MIN_SIZE = 0;
private static final int MAX_SIZE = 1_000;
private static final String SIZE_S = "S";
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
public void updateAllMerchantCount(final int count) {
for (final Merchant merchant : merchants) {
if (isUpdateable(merchant)) {
if(count < MIN_SIZE) {
throw new IllegalArgumentException("올바른 수량을 입력해주세요.");
}
if(count > MAX_SIZE) {
throw new IllegalArgumentException("한 번에 너무 많은 수량을 입력할 수 없습니다.");
}
}
merchant.setCount(count);
}
}
}
......
2-2. 책임
책임은 객체에 의해 정의되는 응집도 있는 행위의 집합 으로, 객체가 유지해야 하는 정보와 수행할 수 있는 행동에 대해 개략적으로 서술한 문장 입니다. 아래 코드에서 Manager는 자신이 담당하고 있는 Merchant의 재고를 갱신할 책임이 있으며, updateAllMerchantCount 메서드를 통해 이를 수행합니다. 여기서 정보는 Manager가 관리하는 가맹점들, 행위는 수량을 업데이트 하라는 지시죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Manager {
private static final String SIZE_S = "S";
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
// 관리하는 가맹점들의 재고를 업데이트하는 행위
public void updateAllMerchantCount(final int count) {
for (final Merchant merchant : merchants) {
if (isUpdateable(merchant)) {
merchant.updateCount(count);
}
}
}
......
행위를 하기 위해서는 어떤 정보를 알고 있는지도 고려 해야 합니다. 책임을 할당하기 위해서는 책임을 수행하는 데 필요한 정보를 잘 알고 있는 정보 전문가(Information Export) 에게 맡기라는 말이 있는데, 즉, 연관된 문맥을 잘 알고 있고, 이를 수행하기에 적절한 개념을 알고 있는 사람에게 맡겨야 한다는 말입니다.
Information expert (also expert or the expert principle) is a principle used to determine where to delegate responsibilities such as methods, computed fields, and so on.
오브젝트에는 책임을 설명하는 챕터에 객체가 협력에 적합한지를 결정하는 것은 그 객체의 상태가 아니라 행동이다 라는 문장이 나오는데요, 개인적으로 이 파트는 이상 에 가까우며, 현실에서 이런 설계를 하는 것은 꽤 어려운 것 같습니다. 어떤 맥락에서 작성한지는 이해하지만, 현실에서는 데이터를 떼놓고 객체를 생각할 수 없으며, 데이터 구조가 항상 객체지향을 고려해 설계된 것은 아니기 때문 입니다.
만약 수 십년 간 잘 운영되고 있는 코드들을 행동 중심의 설계로 코드를 바꾸는 것은 현실적으로 어렵죠.
물론 영속 엔티티(Persistence Entity)와 도메인 엔티티(Domain Entity)를 분리 해서 데이터 중심 설계의 문제 를 해결할 수도 있지만, 이런 코드를 작성하기 전에는 항상 팀원 간의 협의 가 있어야 합니다.
좋은 설계도 중요하지만, 개인적으로 팀원 간의 협의나 일관된 코드가 더 중요하다고 생각해서요.
2-3. 역할
역할은 객체가 어떤 특정한 협력 안에서 수행하는 책임의 집합 입니다. 우리는 Manager를 통해 Merchant의 재고를 업데이트 하는데요, 즉, 재고를 변경하는 문맥에서 Manager는 재고를 변경하라는 지시를 하고, 각 Merchant는 해당 지시를 듣고 자신이 가지고 있는 재고를 변경합니다.
1
2
3
4
5
6
7
8
9
10
11
// 전체 가맹점에 재고를 변경하라고 지시하는 역할
class Manager {
......
public void updateAllMerchantCount(final int count) {
......
}
......
1
2
3
4
5
6
7
8
9
10
11
// 재고를 변경하는 역할
class Merchant {
......
public void updateCount(final int count) {
......
}
......
}
2-4. 계약
계약(Contract)은 객체가 자신의 책임을 수행할 때 반드시 지켜야 할 조건 을 의미합니다. 객체와 객체 사이에 맺는 일종의 약속 이라고 생각할 수 있죠. 계약은 크게 세 가지로 나눌 수 있습니다.
- 사전조건(Precondition)
- 사후조건(Postcondition)
- 불변식(Invariant)
2-4-1. 사전 조건
사전조건은 메서드를 호출하기 전, 반드시 만족해야 하는 조건 입니다. 즉, 클라이언트가 지켜야 할 의무입니다. 예를 들어, updateCount(count)를 호출할 때, count가 0 이상 1,000 이하의 값이어야 합니다. 또한, Merchant의 size가 S일 경우 정상적으로 값을 업데이트할 수 있습니다. 이 사전조건이 만족되지 않으면 메서드는 정상적으로 동작하지 않고, IllegalArgumentException을 던져 호출을 거부합니다.
1
2
3
4
5
public void updateCount(final int count) {
validateSize(); // 사전조건: size는 "S"여야 한다.
validateCount(count); // 사전조건: count는 0 이상 1,000 이하이어야 한다.
this.count = count;
}
2-4-2. 사후 조건
사후조건은 메서드 실행이 끝난 후 반드시 만족해야 하는 조건 입니다. 즉, 메서드 제공자가 지켜야 할 의무 입니다. 아래 코드에서는 updateCount(count)를 정상적으로 실행하고 나면 Merchant의 count 필드는 전달받은 값으로 정확히 변경 되어 있어야 합니다. 사후조건은 updateCount가 성공적으로 수행되었을 때 count가 정확히 기대한 값으로 세팅되었음을 보장합니다.
1
2
3
4
5
public void updateCount(final int count) {
validateSize();
validateCount(count);
this.count = count; // 사후조건: count 필드는 항상 입력받은 값과 같다.
}
2-4-3. 불변식
불변식은 객체의 생명주기 동안 항상 만족해야 하는 조건 입니다. 즉, 객체의 존재 자체에 대한 규칙 입니다. 우리 Merchant 객체에서 생각할 수 있는 불변식은 다음과 같습니다.
- size는 S이거나 null이어야 한다.
- count는 항상 0 이상, 1,000 이하여야 한다.
이 불변식을 코드로 강화하려면 다음과 같이 추가 검증을 할 수 있습니다. 또는, 메서드 마지막에 checkInvariant( )를 호출해 객체 상태가 항상 유효한지 검증할 수도 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Merchant(
final String size,
final String name,
final int count
) {
this.size = size;
this.name = name;
this.count = count;
checkInvariant();
}
private void checkInvariant() {
if (this.count < 0) {
throw new IllegalStateException("count는 항상 0 이상이어야 합니다.");
}
// size가 비어 있어도 되는 경우는 통과, 아니라면 추가 체크 가능
}
계약을 정의하는 이유는 객체의 책임을 명확하게 하고, 시스템의 신뢰성을 높이기 위해서 입니다. 객체지향에서 객체는 서로 협력하여 문제를 해결하는데, 이때 객체 간에는 반드시 서로 지켜야 할 약속 이 필요합니다. 이 약속이 없다면, 어떤 객체가 어떤 조건에서 동작하는지 알 수 없고, 시스템 전체가 불안정해질 위험이 생깁니다. 사전조건, 사후조건, 불변식과 같은 계약을 명확히 세우면 다음과 같은 이점을 얻을 수 있습니다.
객체 책임과 기대 행동 명확화: 객체를 사용할 때 “무엇을 지켜야 하고, 무엇을 기대할 수 있는지”를 쉽게 알 수 있습니다.버그 발생 가능성 감소: 사전조건이 어겨지거나, 사후조건이 깨지거나, 불변식이 무너지면 즉시 오류를 감지할 수 있습니다.코드 사용법의 명확성 제공: 코드 내부를 일일이 읽지 않아도, 계약만 보면 객체를 어떻게 사용해야 하는지 이해할 수 있습니다.유지보수성과 확장성: 계약이 명확하면 내부 구현이 변경되더라도, 외부 사용자는 계약만 준수하면 문제없이 기존 코드를 계속 사용할 수 있습니다.
3. 묻지 말고 시켜라
Tell, Don’t Ask는 객체지향 설계에서 데이터와 행동을 객체 안에 함께 묶으라는 원칙 입니다. 객체에게 데이터를 꺼내어 외부에서 처리하지 말고, 객체에게 해야 할 일을 직접 지시 하라는 것입니다. 이를 통해 코드의 응집도가 높아지고, 객체가 자신의 상태를 스스로 관리하게 되어 유지보수가 쉬워집니다.
Tell-Don’t-Ask is a principle that helps people remember that object-orientation is about bundling data with the functions that operate on that data. It reminds us that rather than asking an object for data and acting on that data, we should instead tell an object what to do. This encourages to move behavior into an object to go with the data.
하지만 마틴 파울러는 이 원칙을 절대적으로 따르지는 않습니다. 경우에 따라서는 객체가 데이터를 제공(get) 하도록 해서 다른 객체가 이를 활용하는 것이 더 깔끔하고 단순할 수 있기 때문입니다. 즉, 이는 데이터와 행동을 모으는 방향을 생각하게 해주는 좋은 출발점이지만, 상황에 따라 융통성 있게 적용해야 한다고 말합니다.
설계란 결국 다양한 요소들 사이에서 트레이드오프를 고려해야 한다는 것입니다.
이를 코드로 보면 다음과 같습니다. Manager는 Merchant의 세부 데이터와 로직을 알지 못하며, 단지 재고를 업데이트하라고 지시만 하는 것이죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Manager {
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
public void updateAllMerchantCount(final int count) {
validateCount(count);
for (final Merchant merchant : merchants) {
merchant.updateCount(count);
}
}
......
}
4. 어떻게 코드를 수정하면 될까?
이제 처음에 본 코드를 리팩토링해보겠습니다. 초기에 작성했던 코드는 Manager가 Merchant의 내부 상태(size)를 직접 조회하고 판단하여 조건에 따라 행동을 수행하는 구조였습니다. 즉, 이는 객체 간 협력이 아닌, Manager가 Merchant를 통제하는 형태에 가까웠죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Manager {
private static final String SIZE_S = "S";
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
public void updateAllMerchantCount(final int count) {
for (final Merchant merchant : merchants) {
if (isUpdateable(merchant)) {
merchant.updateCount(count);
}
}
}
private boolean isUpdateable(final Merchant merchant) {
return merchant.getSize() != null
&& SIZE_S.equals(merchant.getSize());
}
}
두 번째 문제는 책임의 분리가 명확하지 않았다는 것입니다. Manager가 Merchant의 내부 상태를 아는 것은 캡슐화(Encapsulation)를 깨뜨리는 행위이며, 객체의 독립성을 해칩니다. Manager는 어떤 Merchant가 수량을 업데이트할 수 있는지 를 알아서는 안 되고, 오직 수량을 업데이트해라는 메시지만 전달 해야 합니다.
1
2
3
4
5
// 세부 로직을 아는 코드
private static boolean isUpdateable(final Merchant merchant) {
return merchant.getSize() != null
&& SIZE_S.equals(merchant.getSize());
}
이를 반영하면 최종적으로 다음과 같이 코드를 수정할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Manager {
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
public void updateAllMerchantCount(final int count) {
for (final Merchant merchant : merchants) {
merchant.updateCount(count);
}
}
......
}
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
37
38
39
40
41
42
class Merchant {
private static final String SIZE_S = "S";
private static final int MIN_SIZE = 0;
private static final int MAX_SIZE = 1_000;
private final String size;
private final String name;
private int count;
public Merchant(
final String size,
final String name,
final int count
) {
this.size = size;
this.name = name;
this.count = count;
}
public void updateCount(final int count) {
validateSize();
validateCount(count);
this.count = count;
}
private void validateSize() {
if (size != null && SIZE_S.eqauls(size)) {
return;
}
throw new IllegalArgumentException("가맹점 매출구간 정보가 존재하지 않습니다.");
}
private void validateCount(final int count) {
if (count < MIN_SIZE) {
throw new IllegalArgumentException("올바른 수량을 입력해주세요.");
}
if (count > MAX_SIZE) {
throw new IllegalArgumentException("한 번에 너무 많은 수량을 입력할 수 없습니다.");
}
}
}
사전 조건을 어디에 둘지는 사람마다 생각이 다를 수 있는데요, 이런 부분은 명확한 정답이 아닌, 어느 정도의 주관이 개입된다고 생각합니다. 아래와 같이 작성해도 될 것 같은데, 이건 상황에 맞게 잘 판단하시죠.
Manager에서 validateCount(count)를 직접 검증할지, 아니면 Merchant 내부에서 검증할지는 설계 선택의 문제입니다. 만약 count라는 값이 모든 Merchant에게 공통적으로 적용되는 유효성 검사라면 Manager가 사전에 검증하는 것이 자연스럽습니다. 예를 들어, 어떤 수량도 0 이상 1,000 이하라는 규칙이 모든 가맹점에 똑같이 적용된다면, Manager 쪽에서 한 번만 검증하고 모든 Merchant에게 적용하는 것이 낫습니다.
count의 유효성이 Merchant 각각의 속성(예: size가 S인 경우만 허용) 같은 객체 내부의 상태에 따라 다르게 적용된다면, 그 검증은 Merchant 내부에 있어야 합니다. 즉, Merchant 스스로 자신의 상태를 보고 판단하고 검증하는 것이 더 객체지향적이고 자연스러운 설계입니다.
지금 작성한 구조에서는 Merchant가 size와 count를 함께 고려해 수량 업데이트 가능 여부를 판단하고 있기 때문에, validateCount도 Merchant 안에 두는 편이 더 적합합니다. 이는 객체가 스스로 자신의 상태를 책임지고 관리하게 하는 Tell, Don’t Ask 원칙에도 부합하기 때문입니다.
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
class Manager {
private final List<Merchant> merchants;
public Manager(final List<Merchant> merchants) {
this.merchants = merchants;
}
public void updateAllMerchantCount(final int count) {
validateCount(count);
for (final Merchant merchant : merchants) {
merchant.updateCount(count);
}
}
private void validateCount(final int count) {
if (count < MIN_SIZE) {
throw new IllegalArgumentException("올바른 수량을 입력해주세요.");
}
if (count > MAX_SIZE) {
throw new IllegalArgumentException("한 번에 너무 많은 수량을 입력할 수 없습니다.");
}
}
......
}
5. 이렇게 코드를 작성하면 어떤 점이 좋을까?
객체가 자신의 책임을 명확히 가지게 되면서 코드에 여러 가지 장점이 생깁니다.
- 테스트 용이성
- 명확한 의도 전달
- 캡슐화
5-1. 테스트 용이성
객체가 자신의 책임을 명확히 가지고 있기 때문에, 단위 테스트가 훨씬 단순하고 직관적으로 작성할 수 있습니다. Manager는 Merchant에게 “업데이트하라”고만 명령하고, 내부 동작은 신경쓰지 않습니다.
- Merchant는 자신의 상태에 대해 스스로 판단하고 동작합니다.
- 이로 인해 각각의 객체를 독립적으로 테스트할 수 있습니다.
즉, 다음과 같이 코드로 결과를 검증할 수 있죠.
1
2
3
4
5
6
7
8
9
10
11
@Test
void 모든_가맹점에_수량을_업데이트한다() {
Merchant merchant1 = new Merchant("S", "가맹점1", 10);
Merchant merchant2 = new Merchant("S", "가맹점2", 20);
Manager manager = new Manager(List.of(merchant1, merchant2));
manager.updateAllMerchantCount(100);
assertEquals(100, merchant1.getCount());
assertEquals(100, merchant2.getCount());
}
5-2. 명확성 의도
객체가 수행해야 할 행동이 명확히 드러납니다. Manager는 수량을 업데이트하라고 요청합니다. Merchant는 “요청을 받고 스스로 검증 후 업데이트 합니다. 덕분에 코드를 읽으면 **누가 무엇을 해야 하는지 의도가 바로 보입니다.
1
2
3
4
5
public void updateAllMerchantCount(final int count) {
for (final Merchant merchant : merchants) {
merchant.updateCount(count);
}
}
1
2
3
4
5
public void updateCount(final int count) {
validateSize();
validateCount(count);
this.count = count;
}
5-3. 캡슐화
캡슐화로 객체 내부의 변경이 객체 외부에 영향을 주지 않게 되어, 코드의 유지보수성과 확장성이 크게 향상됩니다. 초기에는 Manager가 Merchant의 size를 직접 확인하며 업데이트 가능 여부를 판단했습니다. 즉, Manager가 Merchant의 내부 정보를 알아야만 동작할 수 있었고, 만약 업데이트 가능 조건이 바뀌면 Manager 코드도 함께 수정해야 했습니다.
1
2
3
4
private static boolean isUpdateable(final Merchant merchant) {
return merchant.getSize() != null
&& SIZE_S.equals(merchant.getSize());
}
하지만 리팩토링 후에는, 업데이트할 수 있는지 판단하는 책임을 Merchant 내부로 옮겼습니다. 이제 만약 사이즈 허용 규칙이 변경되더라도, Manager는 수정할 필요가 없습니다. 오직 Merchant의 validateSize( ) 메서드만 수정하면 됩니다.
1
2
3
4
5
6
private void validateSize() {
if (size != null && SIZE_S.equals(size)) {
return;
}
throw new IllegalArgumentException("가맹점 매출구간 정보가 존재하지 않습니다.");
}
6. 정리
왜 이전에 작성한 코드가 마음에 안 드는지 이해하는 과정에서 객체지향에 대한 개념, 코드를 수정했던 사고의 흐름을 돌아봤습니다. 올해는 컴퓨터 공학 에 집중하고 싶었고, 시스템 아키텍처 나 인프라 구조 가 코드 설계보다 더 중요하다고 생각했는데, 문득 이전 코드를 보며 코드를 위한 설계 도 중요하다는 것을 다시 한 번 떠올렸습니다. 그리고 설계와 코드는 계속해서 변화하고 성장하며, 여기에 정답은 없다 라는 것도 다시 한 번 느낄 수 있었습니다.