부제: 합성·집합·연관·의존을 가르는 진짜 축은 ‘누가 만들고, 누가 죽을 때 함께 죽는가’다 —
그리고 일반화(상속)는 왜 그 축 위에 놓을 수 없나
UML 클래스 다이어그램을 처음 제대로 읽으려 했을 때, 저는 화살표를 전부
‘모양’으로 외우려 했습니다. 속이 빈 마름모, 속이 찬 마름모, 속이 빈 삼각형,
실선 화살표, 점선 화살표 — 이걸 그림으로만 기억하니 방향도 헷갈리고, 어느
상황에 무엇을 써야 하는지도 매번 다시 찾아봐야 했습니다.
특히 집합(aggregation)과 합성(composition)을
저는 이렇게 이해하고 있었습니다.
- 집합은 인간과 팔처럼, 원래 한 몸이라 잘 떨어지지 않는 것
- 합성은 로봇과 로봇팔처럼, 나사만 풀면 떼었다 붙였다 할 수 있는 것
결론부터 말하면 정확히 반대였습니다. 그리고 이 둘을 가르는 축은
‘떼어낼 수 있는가’가 아니라, 객체의 수명(lifetime)이었습니다.
이 사실 하나를 잡고 나니 나머지 두 관계(연관·의존)까지 한 줄에 꿰였습니다.
그리고 이 축을 잡고 나서야, 늘 같은 표에 끼워 넣으려다 실패했던 관계가 왜
따로 놀았는지도 풀렸습니다. 일반화(상속)입니다. 이건 애초에
수명을 물을 수 있는 관계가 아니었습니다.
이 글은 그 정리입니다. 판단 기준이 되는 표를 먼저 두고, 포켓몬 게임
(트레이너·포켓몬·가방·Game·Screen)의 코드로 수명 축의 네 관계를 하나씩
확인한 뒤, 타입 축의 두 관계(일반화·실체화)와 실선·점선의 뜻을
이어서 봅니다.
한눈에 요약
- 네 관계를 가르는 축은 화살표 모양이 아니라 수명·소유·필드 보유 여부입니다.
- 합성(*–,
◆────): 생성자 안에서new합니다 → 주인이 죽으면 함께 죽습니다.- 집합(o–,
◇────): 밖에서 만들어 주입받습니다 → 주인이 죽어도 살아남을 수 있습니다.- 연관(–>,
────→): 필드로 붙들고 있습니다 → ‘실제 객체의 속성’.- 의존(..>,
┄┄┄┄→): 필드가 아니라 지역변수·파라미터로 쓰고 버립니다.- 그리고 선의 종류가 나머지 절반입니다 — 실선 ────은 구조로 남는 관계,
점선 ┄┄┄┄는 쓰고 지나가거나 약속만 하는 관계(§8).- 수명 축 바깥에 일반화(<|–,
◁────)와 실체화(<|..,◁┄┄┄)가
따로 있습니다. 인스턴스가 아니라 타입 사이의 관계입니다(§7).- 그 점선 삼각형이 무엇인지까지 내려가면 인터페이스는 ‘구현이 없는 타입’입니다 —
타입이라서 갈아 끼울 수 있고, 면이라서 양쪽이 서로를 모른 채 맞물립니다(§9).
1. 기준이 되는 표
먼저 판단표부터 고정하겠습니다. 이 글의 나머지는 전부 이 표를 코드로 확인하는
과정입니다.
| 축 | 그려지는 모양 | 텍스트 기호 | 뜻 | 한 줄 정의 | 이 게임에서 |
|---|---|---|---|---|---|
| 수명 | 주인 ◆────── 부분 |
*-- |
합성(composition) | 품은 쪽이 만들고, 함께 사라진다 | 트레이너가 파트너 포켓몬을 생성자에서 만든다 |
| 수명 | 주인 ◇────── 부분 |
o-- |
집합(aggregation) | 담고는 있지만 밖에서 온 것 | 가방 속 도구는 상점이 만든다 |
| 수명 | 아는쪽 ──────→ 알려지는쪽 |
--> |
연관(association) | 필드로 붙들고 있다 | Game이 트레이너와 ‘지금 있는 장면’을 붙든다 |
| 수명 | 쓰는쪽 ┄┄┄┄┄→ 쓰이는쪽 |
..> |
의존(dependency) | 쓰기만 하고 붙들지 않는다 | Game이 장면마다 Screen을 만들고 버린다 |
| 타입 | 부모 ◁────── 자식 |
<|-- |
일반화(generalization) | "…의 한 종류다" — 구현까지 물려받는다 | 파이리는 포켓몬의 한 종류다(extends) |
| 타입 | 인터페이스 ◁┄┄┄┄ 구현체 |
<|.. |
실체화(realization) | "…의 한 종류다" — 약속만 받는다 | 파이리가 Battleable을 구현한다(implements) |
기호 칸의 그림은 실제 다이어그램의 선 모양을 글자로 흉내 낸 것입니다. 속이 찬
마름모 ◆, 속이 빈 마름모 ◇, 속이 빈 삼각형 ◁(▷), 열린 화살표 →, 실선 ────,
점선 ┄┄┄┄ 여섯 가지가 이 글에 나오는 부품의 전부입니다.
두 축을 먼저 갈라 두는 게 중요합니다. 수명 축의 네 관계는 인스턴스와
인스턴스 사이라 "누가 만들고 누가 죽을 때 함께 죽는가"를 물을 수 있고,
타입 축의 두 관계는 클래스와 클래스 사이라 그 질문 자체가 성립하지
않습니다(§7). 위 표에서 축 칸이 갈린 이유가 그것입니다.
읽는 방향도 여기서 정리해 두면 좋습니다. 마름모(◆·◇)는 항상 품는 쪽,
즉 전체(whole)에 붙습니다. Trainer *-- Pokemon이라면 마름모가
트레이너 쪽에 있고, "트레이너가 포켓몬을 품는다"로 읽습니다. 부분이 전체를
품는다고 거꾸로 읽지 않도록, 마름모=주인이라고 외워 두면 방향은 두 번 다시
헷갈리지 않습니다.

위 네 줄은 결합이 약한 순서(의존 → 연관 → 집합 → 합성)이고, 아래 두 줄은 그 줄에 놓을 수 없는 타입 축입니다. 선이 실선인지 점선인지가 나머지 절반을 말합니다.
2. 합성 *-- — 생성자가 만들고, 함께 죽는다
트레이너는 게임을 시작할 때 파트너 포켓몬과 함께 만들어집니다. 이 포켓몬
개체는 트레이너와 따로 존재하는 게 아니라, 트레이너가 만들어지는 순간
생성자 안에서 함께 태어납니다.
class Trainer { // 트레이너
private final List<Pokemon> party; // 파트너 포켓몬(개체)
Trainer() {
// 생성자 안에서 직접 new 한다 → 트레이너와 파트너 포켓몬은 '한 번에' 태어난다
this.party = List.of(new Pokemon("파이리"), new Pokemon("꼬부기"));
}
}
여기서 핵심은 두 가지입니다.
- 포켓몬을 만드는 주체가 트레이너입니다. 밖에서 포켓몬을 만들어
넘겨주는 쪽이 없습니다.new Pokemon(...)이 트레이너의 생성자 안에 있습니다. - 트레이너가 사라지면 이 파티의 포켓몬도 사라집니다. 이
party
리스트를 붙들고 있는 것은 오직 트레이너뿐입니다. 트레이너 객체가 가비지
컬렉션되면, 그 포켓몬 개체를 가리키는 참조도 함께 끊깁니다. 도감 속
‘종족’은 세계에 남지만, 이 트레이너의 ‘이 개체’는 주인 없이 홀로 남지
않습니다.
이게 합성입니다. 부분의 수명이 전체의 수명에 완전히 종속됩니다.
UML에서는 실선에 속이 꽉 찬 마름모(Trainer ◆────── Pokemon)로
그립니다. 문서에서 Trainer *-- Pokemon으로 쓸 때 별표가 그 채워진 마름모이고,
-- 두 개가 실선입니다.
3. 집합 o-- — 밖에서 만들어 담기만 한다
가방은 다릅니다. 가방에 든 상처약은 가방이 만든 게 아닙니다. 상점에서 사거나
다른 트레이너에게 받은, 이미 세계에 존재하던 도구를 가방이 담아
둘 뿐입니다.
class Bag { // 가방
private final List<Item> items = new ArrayList<>();
// 도구를 '만들지' 않는다. 밖에서 만들어진 걸 받아서 담기만 한다
void put(Item item) { items.add(item); }
}
// 게임 로직 어딘가 —
Item potion = mart.buy("상처약"); // 도구는 '상점'이 만든다
bag.put(potion); // 가방은 담을 뿐이다
도구를 만드는 주체가 가방이 아니라 상점이라는 점이 결정적입니다. 그래서
수명도 갈립니다. 가방을 잃어버려도, 그 직전에 상처약을 꺼내 손에 쥐고
있었다면 상처약은 여전히 세계에 남습니다. 부분(도구)이 전체(가방)보다
오래 살 수 있습니다.
이게 집합입니다. 담고는 있지만, 만든 것도 아니고 수명을 함께하지도 않습니다.
UML에서는 실선에 속이 빈 마름모로 그립니다 —
Bag ◇────── Item, 텍스트로는 Bag o-- Item입니다. 알파벳 o가 빈 마름모의
자리를 대신하고 있는 셈입니다.
코드에서 집합의 신호는 대개 생성자 파라미터로 들어오는 참조입니다.
new로 직접 만들지 않고 밖에서 받아 필드에 꽂으면, 그 객체의 수명은
이 클래스가 책임지지 않는다는 뜻입니다. 이것이 이어지는 DAO·의존성 주입
이야기와도 곧장 연결됩니다 — 주입(injection)은 본질적으로 집합 관계를 만듭니다.
4. 저는 왜 반대로 이해했을까요 — 축을 잘못 잡은 탓
이제 처음의 오해로 돌아가 보겠습니다. 저는 탈착 가능성을 축으로
삼았습니다. "로봇팔은 나사 풀면 떼어지니까 합성, 사람 팔은 붙어 있으니까
집합"이라고요.
하지만 UML이 실제로 가르는 축은 수명입니다. 같은 ‘팔’ 비유를
수명으로 다시 보면 이렇게 뒤집힙니다.

두 관계를 가르는 것은 ‘떼어낼 수 있는가’가 아니라 ‘누가 만들고, 누가 죽을 때 함께 죽는가’입니다.
| 비유 | 저의 직관 | 수명으로 다시 보면 | 실제 |
|---|---|---|---|
| 사람과 팔 | 못 떼니까 집합 | 사람이 죽으면 팔도 끝난다 · 팔을 밖에서 만들어 붙이지 않았다 | 합성 |
| 로봇과 로봇팔 | 떼지니까 합성 | 팔은 공장에서 따로 만들어져 조립됐고, 로봇을 폐기해도 팔은 부품으로 남는다 | 집합 |
제가 ‘떼었다 붙였다’라고 부른 그 성질은, 사실 "이 부품이 밖에서
따로 만들어져 조립됐는가"였습니다. 밖에서 만들어 붙였으니 떼어도
남습니다. 그건 집합입니다. 반대로 사람 팔은 몸이 자라며 함께 생겨나
몸이 끝나면 함께 끝납니다. 그게 합성입니다.
정리하면, 두 관계를 가르는 질문은 딱 두 개입니다.
- 누가 만들었나? — 품은 쪽이 생성자에서 만들면 합성, 밖에서
만들어 넘겨받으면 집합입니다. - 주인이 죽을 때 함께 죽나? — 함께 죽으면 합성, 살아남을 수
있으면 집합입니다.
이 두 질문은 사실 같은 것을 두 방향에서 물은 것입니다. 만든 주체가 곧 수명을
책임지는 주체이기 때문입니다. 그래서 저는 이 축 하나로 두 관계를 다시
외웠습니다. ‘탈착’이 아니라 ‘출생과 죽음을 함께하는가’로요.
5. 연관 --> — 필드로 붙들면, 그건 ‘속성’이다
합성과 집합은 사실 연관의 특수한 형태입니다. 셋 다 "한 객체가 다른
객체를 필드로 가지고 있다"는 공통점을 갖습니다. 다만 그 필드를 주인이
직접 만들었고 함께 죽으면 합성, 밖에서 온 것이면 집합, 그리고
그 소유의 뉘앙스를 따지지 않고 "그냥 필드로 붙들고 있다"까지만
말하면 순수한 연관입니다.
그래서 저는 연관을 이렇게 정의하는 설명이 가장 와닿았습니다. 연관은 실제
객체의 속성(필드)으로 다른 객체를 붙들고 있는 관계입니다.
class Game {
private final Trainer trainer; // 연관 — 플레이어(트레이너)를 필드로 붙든다
private Screen currentScreen; // 연관 — '지금 있는 장면'을 필드로 붙든다
Game(Trainer trainer) {
this.trainer = trainer; // 밖에서 만든 트레이너를 받아 계속 참조
}
}
Game은 트레이너를 게임이 끝날 때까지 필드에 쥐고 있습니다. 특정 순간에만
쓰고 버리는 게 아니라, 객체의 상태로서 계속 유지합니다.
currentScreen도 마찬가지입니다 — 지금 어느 장면에 있는지를 필드로 기억하고,
장면이 바뀌면 이 필드를 갈아 끼웁니다.
여기서 자연스러운 질문이 생깁니다. 그럼 Game --> Trainer는 연관일까요,
집합일까요? 둘 다 맞습니다. 트레이너를 밖에서 받아 오래 붙드니 집합이면서
곧 연관입니다. 집합·합성은 연관에 ‘수명’이라는 정보를 덧붙인
것이라, 다이어그램에서 수명까지 말하고 싶으면 마름모를, "그냥 필드로
참조한다"까지만 말하고 싶으면 실선에 열린 화살표(Game ──────→ Trainer,
텍스트로 -->)를 쓰면 됩니다. 확신이 서지
않을 때 무난한 실선 화살표로 두는 것도 실무에서 흔합니다.
6. 의존 ..> — 필드가 아니라, 쓰고 버리는 것
마지막 의존은 처음부터 오해가 없었습니다. "쓰기만 하고 붙들지 않는다"는 한
문장에서 바로 이해됐습니다. 저는 예전엔 막연히 스코프 안에서 변수처럼
쓰는 것을 의존이라고 생각했는데, 그 직관이 사실 정확했던 셈입니다.
class Game {
void loop() {
while (running) {
// 이 장면 하나를 그리려고 잠깐 만들었다가, 이 스코프를 벗어나면 버린다
Screen screen = new TownScreen(this);
screen.render();
handleInput(screen);
} // ← screen은 여기서 사라진다. Game의 필드가 아니다
}
}
Screen은 Game의 필드가 아닙니다. loop() 메서드 안의 지역변수일
뿐이라, 그 장면을 그리는 동안만 살아 있다가 스코프를 벗어나면 버려집니다.
Game은 Screen이라는 ‘타입’을 알고 그걸 쓰긴 하지만, 인스턴스를 상태로
붙들지는 않습니다. 이게 의존입니다. UML에서는 점선에 열린
화살표(Game ┄┄┄┄┄→ Screen), 텍스트로는 Game ..> Screen으로 그립니다.
점 두 개가 곧 점선입니다.
연관과 의존을 가르는 선은 명확합니다 — 필드로 남기면 연관, 지역변수·
파라미터·반환값으로 스쳐 가면 의존입니다. "이 참조가 메서드가 끝난 뒤에도
객체 안에 남아 있는가?"만 물으면 됩니다.
7. 일반화 <|-- — 수명이 아니라 ‘종류’의 축
여기까지가 제목에 적은 네 관계인데, 다이어그램을 그리다 보면 반드시 만나는
관계가 하나 더 있습니다. 일반화(generalization), 코드로는 상속입니다.
이걸 앞의 넷과 같은 표에 넣지 않은 이유가 있고, 그 이유가 곧 이 관계의 정의입니다.
abstract class Pokemon { // 부모(일반)
abstract void attack(Move move);
}
class Charmander extends Pokemon { ... } // 자식(특수)
class Squirtle extends Pokemon { ... }
Pokemon ◁────── Charmander (텍스트 기호: Pokemon <|-- Charmander)
Pokemon ◁────── Squirtle (반대 방향으로 쓰면: Charmander --|> Pokemon)
실선에 속이 빈 삼각형이고, 그 삼각형은 언제나 부모(상위 클래스)
쪽을 가리킵니다. 마름모가 ‘주인 쪽’에 붙었던 것처럼, 삼각형은 ‘더 일반적인
쪽’에 붙습니다. 읽을 때는 삼각형 반대편에서 시작해 "…는 …의 한 종류다"로
읽으면 방향이 뒤집히지 않습니다 — "파이리는 포켓몬의 한 종류다".
삼각형을 글자로 흉내 낼 때는 방향에 따라 ▷(오른쪽) · ◁(왼쪽)를 씁니다.
Charmander ──────▷ Pokemon과Pokemon ◁────── Charmander는 같은 그림입니다.
왜 앞의 네 관계와 같은 표에 넣지 않았나
앞의 넷은 전부 인스턴스와 인스턴스의 관계였습니다. 트레이너 객체가
포켓몬 객체를 붙들고, 가방 객체가 도구 객체를 담습니다. 그래서 "누가 만들고 누가
죽을 때 함께 죽는가"라는 질문이 성립했습니다.
일반화는 타입과 타입의 관계입니다. Charmander가 Pokemon을 필드로
들고 있는 게 아닙니다. 파이리 인스턴스 하나는 그 자체가 이미 포켓몬이고,
객체가 둘이 아니라 하나입니다. 그러니 "함께 죽는가"라는 질문 자체가 성립하지
않습니다. 죽을 것이 애초에 하나뿐이니까요.
| 관계를 맺는 것 | 실행 중 객체 수 | 수명 질문 | |
|---|---|---|---|
| 연관·집합·합성·의존 | 인스턴스 ↔ 인스턴스 | 2개 | 성립한다 |
| 일반화 | 타입 ↔ 타입 | 1개 | 성립하지 않는다 |
이 구분은 실무에서 "상속이냐 합성이냐"를 고를 때 그대로 쓰입니다. is-a
(파이리는 포켓몬이다)면 일반화고, has-a(트레이너는 포켓몬을 가진다)면
합성·집합입니다. "상속보다 합성을 선호하라"는 오래된 조언이 말하는 것도 결국
이 자리입니다. 부모를 바꾸면 모든 자식이 함께 흔들리는 가장 강한 결합이라,
"한 종류다"가 정말 참일 때만 쓰는 게 안전합니다.
실체화 <|.. — 같은 삼각형인데 선이 점선
인터페이스를 구현하면 삼각형은 그대로인데 선만 점선이 됩니다.
interface Battleable { void attack(Move move); }
class Charmander extends Pokemon implements Battleable { ... }
Pokemon ◁────── Charmander 실선 + 삼각형 → 일반화(상속) Pokemon <|-- Charmander
Battleable ◁┄┄┄┄┄ Charmander 점선 + 삼각형 → 실체화(구현) Battleable <|.. Charmander
같은 삼각형을 쓰는 이유는 읽는 방식이 같기 때문입니다 — "파이리는 Battleable의
한 종류다". 선이 갈리는 이유는 다음 절의 주제이기도 한데, 미리 한 줄로 적으면
물려받은 것이 구현이냐 약속이냐의 차이입니다. 부모 클래스에서는
동작하는 코드를 물려받지만, 인터페이스에서는 "이런 걸 할 수 있다"는 약속만
물려받습니다.
8. 실선과 점선 — 그림의 나머지 절반
여기까지 화살촉(마름모·삼각형·열린 화살표)만 봤는데, 사실 선 자체가 이미
절반을 말하고 있습니다. 저는 이걸 한참 뒤에야 알아채고, 그때부터
다이어그램을 훨씬 빨리 읽게 됐습니다.
규칙은 한 문장입니다.
실선(────)은 "이 관계가 객체 안에 남는다", 점선(┄┄┄┄)은 "남지 않는다"입니다.
| 선 | 뜻 | 코드에서 | 해당 관계 |
|---|---|---|---|
| 실선 ──── | 구조가 된다 — 관계가 계속 유지된다 | 필드로 남거나, 타입 자체가 그렇게 정의된다 | 연관 --> · 집합 o-- · 합성 *-- · 일반화 <|-- |
| 점선 ┄┄┄┄ | 구조가 아니다 — 쓰고 지나가거나 약속만 한다 | 지역변수·파라미터·반환형, 또는 구현 약속 | 의존 ..> · 실체화 <|.. |
이게 임의의 관례가 아니라는 게 재미있는 지점입니다. UML 명세에서 의존
(Dependency)은 "한쪽이 바뀌면 다른 쪽이 영향을 받는다"까지만 말하는
느슨한 관계이고, 실체화(Realization)는 그 의존의 한 갈래로 정의돼
있습니다. 그래서 둘 다 점선입니다. 반면 연관과 일반화는 모델 안에 영구히
남는 구조라서 실선입니다. 점선은 "지금 잠깐" 또는 "약속만", 실선은
"계속" 이라고 외우면 명세와 어긋나지 않습니다.
앞 절의 상속과 구현이 갈린 것도 이 규칙 그대로입니다.
extends는 부모의 필드와 구현을 실제로 물려받습니다. 자식 인스턴스
안에 부모 몫의 메모리가 실제로 들어 있습니다 → 구조 → 실선.implements는 메서드 시그니처라는 약속만 받습니다. 인터페이스는
(기본 메서드를 제외하면) 인스턴스에 아무것도 보태지 않습니다 → 약속 → 점선.
그래서 그림을 볼 때 저는 이제 두 번에 나눠 읽습니다.
- 선을 먼저 봅니다. 실선이면 "이건 이 객체의 구조구나", 점선이면
"잠깐 쓰거나 약속만 하는 거구나". - 그다음 화살촉을 봅니다. 삼각형이면 종류(is-a), 마름모면 소유,
열린 화살표면 방향만.
이 순서로 보면 처음 보는 다이어그램에서도 결합이 강한 선(실선)이 어디에
몰려 있는지가 먼저 눈에 들어옵니다. 리팩터링할 자리를 찾을 때 이게 제일
빠른 단서였습니다.
부품을 한 장으로
| 부품 | 글자로 | 뜻 |
|---|---|---|
| 실선 | ──── |
관계가 구조로 남는다 |
| 점선 | ┄┄┄┄ |
남지 않는다(사용·약속) |
| 열린 화살표 | → |
방향 — 아는 쪽에서 알려지는 쪽으로 |
| 속 빈 삼각형 | ▷ ◁ |
"…의 한 종류다" — 삼각형이 부모·인터페이스 쪽 |
| 속 빈 마름모 | ◇ |
담지만 수명은 따로 — 마름모가 주인 쪽 |
| 속 찬 마름모 | ◆ |
만들고 함께 죽는다 — 마름모가 주인 쪽 |
여섯 부품의 조합이 그림의 전부입니다. 그래서 관계 여섯 개를 따로 외울 필요가
없습니다. 선 두 종류 × 화살촉 네 종류를 읽을 줄 알면 조합으로 풀립니다.
트레이너 ◆────── 포켓몬 합성 Trainer *-- Pokemon
가방 ◇────── 도구 집합 Bag o-- Item
Game ──────→ 트레이너 연관 Game --> Trainer
Game ┄┄┄┄┄→ Screen 의존 Game ..> Screen
포켓몬 ◁────── 파이리 일반화 Pokemon <|-- Charmander
Battleable ◁┄┄┄┄ 파이리 실체화 Battleable <|.. Charmander
이 규칙은 클래스 다이어그램 밖에서도 같습니다
제가 이 규칙을 믿게 된 건, 다른 종류의 다이어그램에서도 선이 똑같이 갈리는 걸
보고 나서였습니다.
| 어디서 | 실선 ──── | 점선 ┄┄┄┄ |
|---|---|---|
| 클래스 다이어그램 | 연관·집합·합성·일반화 | 의존·실체화 |
| 시퀀스 다이어그램 | 호출 메시지(실제로 부른다) | 수명선과 반환 메시지 |
| 패키지 다이어그램 | 포함(중첩) | «import» · «access» |
| 어디서나 | 모델의 요소 | 노트(주석) 연결선 |
시퀀스 다이어그램의 수명선이 왜 점선인지가 저는 특히 마음에 들었습니다.
수명선은 객체가 아니라 "그 객체가 살아 있는 시간"이라, 그림에는
있지만 모델에는 없는 것입니다. 반환 메시지도 마찬가지입니다 — 호출은
실제로 일어나는 일이고, 반환은 그 호출에서 따라 나온 것입니다.
노트를 잇는 선도 점선입니다. 설명은 모델이 아니니까요.
그래서 선의 규칙을 한 층 위에서 다시 적으면 이렇게 됩니다.
실선은 ‘그 자리에 실제로 있는 것’, 점선은 ‘거기서 파생됐거나 잠깐
일어나는 것, 또는 덧붙인 말’입니다.
클래스 다이어그램의 "구조로 남는가"는 이 문장을 클래스에 적용한 특수한
경우였습니다.
코드에서 선을 고르는 3문답
실제로 그릴 때는 이 순서로 물으면 선이 먼저 정해지고, 화살촉은 그다음에
따라옵니다.
| 물음 | 예 | 그리는 선 |
|---|---|---|
① implements 인가? |
약속만 받는다 | 점선 + 삼각형 ◁┄┄┄ |
② extends 인가? |
구현까지 물려받는다 | 실선 + 삼각형 ◁──── |
| ③ 그 참조가 필드로 남는가? | 남는다 | 실선 + (마름모 또는 화살표) |
| 안 남는다(지역변수·파라미터·반환형) | 점선 + 화살표 ┄┄┄→ |
③이 이 글 전체의 판단 기준(§10의 판단표)과 같은 질문이라는 게 핵심입니다.
선을 정하는 질문과 관계를 정하는 질문이 애초에 하나였습니다.
자주 틀리는 세 가지
제가 실제로 틀렸거나, 리뷰에서 자주 본 것들입니다.
implements를 실선 삼각형으로 그리는 것. 압도적으로 흔합니다.
도구가 자동 생성해 줄 때는 맞게 나오는데, 손으로 그릴 때 상속과 구현이
한 덩어리로 기억돼 있으면 선이 뭉개집니다.- 주입받은 것을 점선으로 그리는 것. 밖에서 만들어 받았다는 사실과
필드로 남는다는 사실은 별개입니다. 생성자로 받아 필드에 꽂았으면
집합(◇────)이고 실선입니다. 밖에서 왔다는 것은 마름모의
속이 비었다는 것으로 이미 표현됩니다. - 점선을 전부 그리려는 것. 코드에서 import 하는 타입을 다 의존으로
그리면 그림이 실을 감은 것처럼 됩니다. 의존은 설계상 말할 가치가 있는
것만 그립니다. 클래스 다이어그램은 코드의 복사본이 아니라 주장이니까요.
마지막으로 하나만 덧붙이면, 점선이라고 결합이 없는 건 아닙니다.
점선도 "저쪽이 바뀌면 이쪽이 영향을 받는다"는 뜻이고, 컴파일 의존은 그대로
남아 있습니다. 다만 갈아 끼우기 쉬운 결합이라는 것이지요.
그리고 §9에서 본 인터페이스가 그림에서 하는 일이 여기서 눈에 보입니다.
구체 클래스로 향하던 선을 인터페이스로 옮기는 일입니다.
before Game ──────→ TownScreen Game이 구체 화면을 안다
after Game ──────→ Screen ◁┄┄┄┄ TownScreen 둘 다 가운데 면만 안다
Game과 TownScreen 사이의 선은 아예 사라지고, 남는 것은 각자 인터페이스로
가는 선입니다. 그리고 구현체 쪽에서 인터페이스로 가는 선은 점선이라,
화면을 몇 개를 더 만들어 붙여도 그림에 실선은 늘지 않습니다.
9. 그래서 인터페이스란 무엇인가 — 점선 삼각형이 실제로 하는 일
실체화를 정리하다가, 제가 오래 들고 있던 인터페이스의 정의를 꺼내 점검해
봤습니다. 저는 이렇게 이해하고 있었습니다.
인터페이스는 (1) 스위칭해서 전달하기 위한 목적과, (2) 어떤 논리적 개념을
추상화해서 다른 영역에서 사용 가능하도록 하는 기법이다.
결론부터 적으면 두 축 다 맞습니다. 다만 재 보니 조여야 할 곳이
세 군데 있었고, 그걸 조이고 나니 왜 이 관계가 하필 점선인지까지
같은 문장에서 설명됐습니다.
말 자체도 그 두 축을 그대로 담고 있습니다. interface는 inter(사이) +
face(면), 즉 두 영역이 맞닿는 면입니다. 면은 양쪽 어느 쪽도
아니고, 양쪽이 서로를 몰라도 맞물릴 수 있게 하는 자리입니다.
조임 ① — 스위칭이 되는 이유는 인터페이스가 ‘타입’이기 때문입니다
제 정의에서 "기법"이라는 말이 가장 느슨했습니다. 방법론처럼 들리는데,
언어와 UML 양쪽에서 인터페이스는 타입입니다. 정확히는
구현이 없는 타입이고요. §7에서 일반화를 ‘타입 축’이라고 부른 바로 그
자리입니다.
이게 왜 중요하냐면, 스위칭은 타입이라서 따라오는 결과이기 때문입니다.
타입이니까 변수·파라미터·반환형의 자리에 올 수 있고, 그 자리에 오니까
그 자리에 들어갈 구현을 갈아 끼울 수 있습니다.
interface Battleable { void attack(Move move); }
class Charmander implements Battleable { ... }
class Squirtle implements Battleable { ... }
// 타입 자리에 인터페이스가 오니까 ─ 아래 한 줄을 고치지 않고 구현만 갈아 끼운다
Battleable current = new Charmander();
current = new Squirtle();
current.attack(move); // 어느 구현으로 갈지는 실행 시점에 정해진다
current.attack(...)을 부르는 코드는 파이리도 꼬부기도 모릅니다. 이름으로
부르고, 실제로 어디로 갈지는 실행 시점에 갈립니다. 제가 ‘스위칭’이라고 부른
것의 정체가 이것입니다 — 호출하는 지점과 구현하는 지점을 떼어 놓고, 그
사이에 갈아 끼울 수 있는 자리를 하나 만드는 것.
조임 ② — 스위칭은 ‘목적’이라기보다 ‘조건이 맞을 때 따라오는 효과’입니다
제 정의는 스위칭을 목적으로 놓았는데, 재 보니 그 효과가 나오려면 조건이
둘 붙습니다.
- 호출하는 쪽이 구체 타입을 몰라야 합니다. 중간에
if (x instanceof Charmander)같은 분기가 한 줄만 들어가도 스위칭은 거기서 깨집니다.
인터페이스를 두고도 실제로는 구현을 아는 코드가 되는, 흔한 실패입니다. - 구현들이 서로 바꿔 놓을 수 있어야 합니다. 그리고 이 조건은
컴파일러가 지켜 주지 않습니다. 인터페이스가 강제하는 것은
시그니처(이름·인자·반환형)까지이고, "이 메서드가 어떤 의미여야
하는가"는 강제하지 못합니다.attack()이 한쪽에선 공격하고 다른 쪽에선
아무것도 안 해도 컴파일은 됩니다.
그래서 저는 이 문장을 이렇게 고쳤습니다. 인터페이스는 스위칭을
‘가능하게’ 하지 ‘보장’하지는 않는다. 보장하는 쪽은 사람이 지키는
약속이고, 이건 §11에서 마름모를 두고 한 이야기(다이어그램은 강제가 아니라
의도 선언이다)와 정확히 같은 성격입니다.
조임 ③ — "다른 영역에서 사용 가능"의 진짜 이득은 재사용이 아니라 방향입니다
두 번째 축이 제일 정확했는데, 표현 하나만 바꾸면 훨씬 선명해집니다.
"다른 영역에서 사용 가능하도록"은 재사용처럼 읽히기 쉽습니다. 그런데
인터페이스가 실제로 사는 값은 코드를 다시 쓰는 데 있지 않고,
의존의 방향을 뒤집는 데 있습니다.
// 인터페이스가 없을 때 — Game이 구체 화면을 안다 (Game → TownScreen)
class Game { void loop() { TownScreen s = new TownScreen(this); s.render(); } }
// 인터페이스를 두면 — 양쪽 다 Screen만 안다 (Game → Screen ← TownScreen)
interface Screen { void render(); }
class Game { Game(Screen s) { this.screen = s; } }
class TownScreen implements Screen { public void render() { ... } }
화살표 방향이 바뀝니다. 앞의 코드에서는 Game이 TownScreen을 알아야 하고,
뒤의 코드에서는 둘 다 가운데 면만 알고 서로는 모릅니다. 새 화면을
아무리 추가해도 Game은 그대로입니다. 제가 "다른 영역에서 사용 가능"이라고
부른 그 성질은, 정확히 말하면 양쪽이 서로를 모른 채 맞물리게 만드는
것이었습니다.
그래서 왜 점선인가
여기까지 오면 §8의 규칙이 그대로 적용됩니다. 인터페이스는 인스턴스에
아무것도 보태지 않습니다. 자바 인터페이스에는 인스턴스 필드를 둘 수
없습니다(상수만 가능합니다). 그러니 구현체가 인터페이스를 하나 더 걸어도
객체의 구조는 그대로이고, 늘어나는 것은 "이런 걸 할 수 있다"는 약속뿐입니다.
구조가 아니라 약속 → 점선입니다.
자바 8의 default 메서드 이후로 인터페이스도 구현을 가질 수 있게
됐지만, 상태(인스턴스 필드)는 여전히 못 가집니다. 점선이 유지되는
이유가 "코드가 없어서"가 아니라 "구조가 늘지 않아서"라는 게 여기서
분명해집니다.
그리고 비용 한 줄
마지막으로 제 원래 정의에 빠져 있던 게 하나 있었습니다. 비용입니다.
인터페이스는 공짜가 아니라 간접 계층을 하나 늘립니다. 코드를 따라갈 때
정의로 점프하면 인터페이스가 나오고, 구현을 보려면 한 번 더 뛰어야 합니다.
구현이 하나뿐이고 앞으로도 하나일 것 같으면, 그 인터페이스는 스위칭을
주지 않으면서 추적 비용만 받아 갑니다. 그래서 저는 도입 기준을
"지금 갈아 끼울 것이 둘 이상인가, 아니면 경계 반대편을 모르고 싶은가"로
정리했습니다. 둘 다 아니면 나중에 넣어도 늦지 않습니다.
다듬은 정의
점검을 마치고 제 문장은 이렇게 됐습니다.
인터페이스는 ‘구현이 없는 타입’이고, 그 타입이 놓인 자리가 두 영역이
맞닿는 면이 된다. 타입이라서 그 뒤의 구현을 갈아 끼울 수 있고(스위칭),
면이라서 양쪽이 서로를 모른 채 맞물린다(경계). 다만 지켜지는 것은
시그니처까지이고, 의미가 같은지는 사람이 책임진다.
원래 문장에서 바뀐 건 셋입니다. ‘기법’ → ‘타입’, ‘스위칭이 목적’
→ ‘타입이라서 따라오는 효과, 그것도 조건부’, ‘다른 영역에서 사용
가능’ → ‘양쪽이 서로를 모른 채 맞물림’. 방향은 처음부터 맞았고, 재 보니
말이 조금 더 좁아졌습니다.
10. 네 관계를 한 표로
지금까지를 하나의 판단표로 압축하면 이렇게 됩니다. 코드를 보고 관계를 정할
때 이 순서로 물으면 거의 틀리지 않습니다.
| 질문 | 예 → | 아니오 ↓ |
|---|---|---|
| ① 참조가 메서드가 끝난 뒤에도 필드로 남는가? | ②로 | 의존 ..> |
② 그 필드를 주인이 생성자에서 new 했는가? |
③으로 | 집합 o-- (밖에서 주입) |
| ③ 주인이 죽으면 그 객체도 함께 죽는가? | 합성 *-- |
(사실상 집합에 가깝게 재검토) |
| 관계 | 모양 | 선 | 필드로 보유 | 주인이 생성 | 함께 소멸 | 게임 예시 |
|---|---|---|---|---|---|---|
의존 ..> |
┄┄┄┄→ |
점선 | ✗ (지역변수) | 상관없음 | — | Game → Screen |
연관 --> |
────→ |
실선 | ✓ | 무관 | 무관 | Game → 트레이너 |
집합 o-- |
◇──── |
실선 | ✓ | ✗ (주입) | ✗ | 가방 → 도구 |
합성 *-- |
◆──── |
실선 | ✓ | ✓ (생성자) | ✓ | 트레이너 → 포켓몬 |
그리고 수명 축 바깥에 있는 두 관계는 이렇게 따로 세워 둡니다. 인스턴스가
하나뿐이라 위 표의 세 칸을 물을 수 없는 관계들입니다.
| 관계 | 모양 | 선 | 무엇 사이인가 | 코드 | 게임 예시 |
|---|---|---|---|---|---|
일반화 <|-- |
◁──── |
실선 | 타입 ↔ 타입 | extends |
포켓몬 ← 파이리 |
실체화 <|.. |
◁┄┄┄ |
점선 | 타입 ↔ 타입 | implements |
Battleable ← 파이리 |
세로로 읽으면 의존 → 연관 → 집합 → 합성으로 갈수록 결합이
강해진다는 것도 보입니다. 의존은 타입만 아는 가장 느슨한 관계이고, 합성은
생사를 함께하는 가장 강한 관계입니다. 설계할 때 "이걸 이렇게까지 강하게 묶을
이유가 있을까?"를 물어 가능한 한 왼쪽(느슨한 쪽)을 고르는 것이,
결합도를 낮추는 방향과도 일치합니다.
11. 몇 가지 첨언 — 실무에서 흐려지는 지점들
깔끔한 표와 달리, 실제 코드에서는 경계가 흐려질 때가 있습니다. 미리 짚어 두면
헷갈릴 일이 줄어듭니다.
-
DI 컨테이너를 쓰면 대부분 집합처럼 보입니다. Spring 같은
프레임워크에서 객체를 밖에서 만들어 주입하기 때문에, 생성자에서 직접
new하는 합성은 오히려 드물어집니다. 그래서 도메인 모델(트레이너가 파트너
포켓몬을 품는 것)에서는 합성이 자연스럽지만, 서비스 계층에서는 집합·연관이
대부분입니다. 이 결이 앞서 정리한 아키텍처 최종장의
‘레이어별 성격’과도 맞물립니다. -
합성/집합은 ‘의도’이지, 언어가 강제하는 게 아닙니다. 자바에는
"이 객체가 죽으면 저 객체도 반드시 죽어라"를 문법으로 못 박는 장치가
없습니다. 가비지 컬렉터는 참조가 남아 있으면 살려 둡니다. 그래서 합성은
"이 참조를 주인 외에는 아무도 들고 있지 않도록 설계하겠다"는 약속에
가깝습니다. 다이어그램의 마름모는 코드의 강제가 아니라 설계자의 의도
선언입니다. -
집합과 연관을 너무 오래 구분하려 애쓰지 않아도 됩니다. UML을
만든 사람들조차 이 둘의 경계가 실무에서 모호하다고 인정한 축에 듭니다.
수명을 명확히 말하고 싶을 때만 마름모를 쓰고, 애매하면 실선 화살표로
두는 편이 소통에 낫습니다. 정작 협업에서 오해를 만드는 건 연관과 의존을
섞어 쓰는 것(필드인데 의존으로, 지역변수인데 연관으로 그리는 것)이라,
그 선을 지키는 데 집중하는 편이 실용적입니다. -
일반화는 그림에서 가장 눈에 띄지만 가장 되돌리기 어렵습니다.
삼각형 하나는 "부모가 바뀌면 모든 자식이 함께 바뀐다"는 뜻이라, 마름모보다
강한 결합입니다. 그래서 다이어그램을 검토할 때 저는 삼각형부터 셉니다.
상속 사슬이 세 단계를 넘어가면 대개 "한 종류다"가 아니라 "코드를 재사용하고
싶었다"인 경우라, 합성(◆────)으로 바꿀 수 있는지 먼저 봅니다. 반대로
점선 삼각형(실체화)은 얼마든지 늘려도 괜찮습니다 — 약속만 늘어날 뿐
구조가 늘지 않기 때문입니다. -
방향은 항상 ‘아는 쪽 → 알려지는 쪽’입니다.
Game --> Trainer는
Game이 트레이너를 알지만, 트레이너는 Game을 몰라도 된다는 뜻입니다. 화살표가
양쪽이면 서로 참조(양방향 연관)라는 뜻이고, 이건 순환 참조의 신호이기도
해서 다이어그램에서 눈에 띄면 한 번 의심해 볼 지점입니다.
한 줄 기억법
주인이 만들고 주인과 함께 죽으면 합성(
◆────), 밖에서 와서 주인보다 오래 살 수
있으면 집합(◇────), 필드로 계속 붙들면 연관(────→), 쓰고 버리면 의존(┄┄┄┄→).
그리고 "…의 한 종류다"면 삼각형 — 구현을 물려받으면 실선(◁────),
약속만 받으면 점선(◁┄┄┄).
선까지 읽게 된 뒤로 하나가 더 편해졌습니다. 점선이 많은 그림은 갈아 끼우기
쉬운 설계이고, 실선이 촘촘한 그림은 한 곳을 건드리면 여러 곳이 함께 움직이는
설계입니다. 관계 이름을 정확히 부르지 못해도, 선의 밀도만 보고 그 정도는 말할 수
있게 됐습니다.
화살표 모양을 외우려던 걸 그만두고 ‘수명’이라는 축 하나로 바꿔 잡은 뒤로,
저는 다이어그램을 그릴 때 더 이상 기호표를 다시 찾지 않게 됐습니다. 관계를
정하는 건 결국 그림이 아니라 "이 객체는 누가 만들고, 언제 죽는가"라는,
코드에 이미 적혀 있는 사실이기 때문입니다.
답글 남기기