개요
이론 정리 - < 물려받기와 갈아끼우기 > 마지막에 다음은 SOLID라고 적어뒀다. 그 다음 편이다.
거기서는 “계약을 안 지킨 클래스로는 객체를 못 만든다”까지 왔었다. 순수 가상 함수를 안 채우면 추상 클래스로 남는다는 이야기였다. 그래서 다음은 계약을 지킨 척하는 구현을 볼 차례라고 적어뒀는데, 그게 이번 편의 L 자리다.
솔직히 SOLID는 약자 외우기가 제일 걸렸다. S, O, L, I, D 다섯 글자에 원칙 이름을 붙이는 것까지는 되는데, 그게 코드 앞에서 무슨 쓸모인지가 안 잡혔다. 그래서 이번에는 정의를 외우는 대신 각 글자를 질문 하나로 바꿔서 갔다.
변경이 생겼을 때 그 충격이 시스템 전체로 퍼지는 걸 어떻게 줄일 것인가?
다섯 개 전부 이 질문의 변주였다. 그렇게 놓고 보니 오히려 이전 편들에서 이미 손대고 있던 것들이었다.
그리고 이번 편에서 가장 크게 바뀐 건 원칙 자체보다 원칙을 안 쓰는 판단이었다. 앞의 네 편은 대체로 “이걸 이렇게 해야 한다”를 쌓는 쪽이었는데, 이번엔 “이건 안 해도 된다” 가 절반이었다.
-
switch는 지워야 한다 — 아니다 - 인터페이스는 잘게 쪼갤수록 좋다 — 아니다
- 인터페이스가 있으면 DIP다 — 아니다
- SRP를 고치면 OCP는 따라온다 — 아니다
( 이번에도 눈으로 읽고 결과를 추론하는 방식으로 진행했고, 직접 컴파일해서 돌려보지는 않았다. 아래 코드는 전부 개념 예시다 )
1. 다섯 글자를 다섯 질문으로
먼저 이렇게 바꿔놓고 시작했다.
| 원칙 | 물어볼 것 | |
|---|---|---|
| S | Single Responsibility | 이 코드는 왜 바뀌는가 |
| O | Open/Closed | 새 기능 추가하려고 기존 코드를 계속 뜯는가 |
| L | Liskov Substitution | 부모 대신 자식을 넣어도 기대가 유지되는가 |
| I | Interface Segregation | 안 쓰는 기능까지 강요받는가 |
| D | Dependency Inversion | 고수준 코드가 저수준 이름을 직접 아는가 |
L만 사람 이름이라 외우기가 애매한데, Barbara Liskov라는 이름보다 Substitution(대체) 쪽을 기억하는 게 실제로 쓸 때 낫다.
이렇게 놓으니 코드를 보고 “이건 무슨 원칙 위반?”을 맞추는 게 아니라, 다섯 질문을 순서대로 던져보는 것이 됐다. 나중에 8번에서 보겠지만 실제 코드는 여러 개가 동시에 걸리기 때문에 이쪽이 더 맞는 사용법이었다.
2. S — 작업 개수를 세는 원칙이 아니다
SRP는 보통 “한 클래스는 하나의 일만 해야 한다”로 설명된다. 나도 처음엔 여러 작업을 하면 책임이 많은 것이라고 읽었다.
void Attack()
{
CalculateDamage();
PlayAnimation();
ApplyDamage();
}
이 함수는 세 가지를 한다. 그럼 SRP 위반인가.
아니다. 이 셋은 전부 Attack이라는 하나의 변경 축에 속한다. 전투 동작이 바뀌면 같이 바뀌고, 안 바뀌면 같이 안 바뀐다.
void Attack()
{
CalculateDamage();
PlayAnimation();
soundSystem.Play();
database.Save();
network.Send();
analytics.Track();
}
이쪽은 다르다. 작업 개수가 세 개에서 여섯 개로 늘어난 게 문제가 아니라, 바뀌는 이유가 여섯 갈래라는 게 문제다.
전투 밸런스 변경
Animation 변경
Audio system 변경
Database schema 변경
Network protocol 변경
Analytics policy 변경
이 여섯 개 중 아무거나 하나만 바뀌어도 이 함수를 열어야 한다. 서로 아무 상관없는 여섯 명이 같은 파일에 수정 요청을 보내는 상태다.
함수 개수도, 줄 수도, 메서드 개수도 아니다. 어떤 이유로 이게 바뀌는가가 전부다.
응집도와는 어떻게 다른가
앞 편 8번에서 응집도와 결합도를 정리했었다. SRP는 응집도와 비슷하게 들리는데 같은 개념은 아니다.
| 묻는 것 | 비유하면 | |
|---|---|---|
| 응집도(Cohesion) | 한 모듈 안의 요소들이 얼마나 같은 목적을 향하는가 | 이 팀원들이 같은 종류의 일을 하는가 |
| SRP | 한 모듈이 몇 개의 독립적인 변경 축에 영향을 받는가 | 이 팀이 몇 명의 상사에게 요청을 받는가 |
SRP를 잘 지키면 응집도가 높아질 가능성이 크긴 하다. 그래도 정의상 같은 질문은 아니다.
3. O — switch를 지우라는 원칙이 아니다
Open/Closed는 확장에는 열려 있고, 수정에는 닫혀 있다는 뜻이다. 예제는 익숙한 모양으로 나왔다.
switch (monsterType)
{
case Goblin:
...
break;
case Dragon:
...
break;
case Slime:
...
break;
}
몬스터를 하나 추가할 때마다 이 switch를 열어야 한다. 앞 편에서 정리한 다형성으로 바꾸면 이렇게 된다.
class Monster
{
public:
virtual void Attack() = 0;
virtual ~Monster() = default;
};
void Battle(Monster& monster)
{
monster.Attack();
}
Battle은 그대로 두고 새 자식만 추가하면 된다. 여기까지는 예상대로였다.
그런데 여기서 한 번 걸렀다
이 예제를 보면서 머릿속에 남는 결론이 “switch는 나쁜 것” 이 되기 쉽다. 실제로 그렇게 굳어질 뻔했다.
OCP가 말하는 건 그게 아니라 이쪽이다.
자주 확장되는 축에서 안정된 기존 코드를 반복 수정하지 않도록 확장 지점을 설계한다.
핵심은 switch가 아니라 “자주 확장되는가” 다.
enum class Direction
{
North,
South,
East,
West
};
동서남북이 다섯 번째로 늘어날 일이 없다면, 여기에 전략 클래스를 세우는 건 확장성을 얻는 게 아니라 복잡도만 늘리는 것이다. 닫혀 있는 도메인에서는 단순한 switch가 더 읽기 좋고 더 싸다.
SRP를 고치면 OCP는 따라오나
이건 내가 실제로 그렇게 말했다가 교정된 부분이다. SRP가 해결되면 OCP도 자연스럽게 해결된다고 봤다.
둘은 서로 돕지만 자동은 아니다. 묻는 게 다르기 때문이다.
SRP "왜 변경되는가?"
OCP "확장할 때 어떻게 변경되는가?"
책임을 잘 나눠서 사운드 관련 코드를 별도 클래스로 뺐다고 하자. SRP는 좋아졌다. 그런데 그 클래스 안에 여전히 switch (soundType)이 있고 사운드 종류가 계속 늘어난다면, 확장할 때 뜯어야 하는 건 그대로다. 변경 이유를 분리한 것과 확장 지점을 만든 것은 별개의 작업이었다.
4. L — is-a가 맞아도 대체가 안 될 수 있다
앞 편 마지막에 남겨둔 자리다. 문법적으로 상속이 되는 것과 올바른 서브타입인 것은 다르다.
class Monster
{
public:
virtual void Attack() = 0;
};
class PeacefulNPC : public Monster
{
public:
void Attack() override
{
throw std::runtime_error("I cannot attack");
}
};
컴파일된다. 앞 편 4번 기준으로도 문제없다. PeacefulNPC는 순수 가상 함수를 채웠으니 추상 클래스가 아니고, 인스턴스도 만들어진다.
그런데 이걸 넣으면,
void Battle(Monster& monster)
{
monster.Attack();
}
Battle은 터진다. Monster를 받는다고 적어놨으니 Attack()이 될 거라고 믿고 쓴 코드다.
can-be-used-as
여기서 용어 하나가 갈렸다. 앞 편 8번에서 상속과 합성을 is-a / has-a로 가르는 건 어렵지 않다고 적었는데, 그 is-a가 두 가지를 뭉치고 있었다.
자연어의 PeacefulNPC is-a Monster
타입 시스템의 PeacefulNPC can-be-used-as Monster
이 둘은 항상 같지 않다. LSP가 보는 건 아래쪽이다. 문장으로 말이 되는가가 아니라 부모 자리에 끼워 넣어도 부르는 쪽의 기대가 유지되는가.
앞 편에서 “계약을 안 지킨 클래스로는 객체를 못 만든다”까지 갔었는데, 여기서는 계약을 형식만 채운 구현이 나온다. 컴파일러는 시그니처가 맞는지까지만 본다. = 0을 채웠는지는 검사해도, 그 안에서 던지는지는 검사하지 않는다.
행동 계약 — 사전 조건과 사후 조건
그럼 무엇을 지켜야 대체 가능한가. 세 가지로 봤다.
precondition (사전 조건) 호출 전에 성립해야 하는 것
postcondition (사후 조건) 호출 후에 보장되는 것
invariant (불변 조건) 계속 유지되어야 하는 것
일반적인 규칙은 이렇다.
- 자식은 사전 조건을 더 강하게 만들면 안 된다 — 부모 때는 되던 호출이 자식에서 거부되면 안 되니까
- 자식은 사후 조건을 더 약하게 만들면 안 된다 — 부모 때 보장하던 걸 자식이 안 지키면 안 되니까
throw는 사후 조건을 극단적으로 약화시킨 경우다. 좀 더 미묘한 예도 있었다.
// 부모 계약: Attack()은 항상 0 이상의 damage를 반환한다
class Boss : public Monster
{
public:
int Attack() override
{
if (rage_ < 100)
return -1;
return 500;
}
};
이건 안 터진다. 예외도 없다. 그래서 더 나쁘다. 부르는 쪽이 이렇게 되기 시작한다.
int damage = monster.Attack();
if (damage == -1)
return;
이 if 한 줄이 뜻하는 게 뭔가. 부르는 쪽이 Boss라는 자식의 사정을 알아야 한다는 것이다. 앞 편 1번에서 다형성을 “부모 타입 하나로 자식들을 안전하게 다루는 것”이라고 적었는데, 여기서 그 전제가 무너진다. 타입은 여전히 Monster& 하나인데, 실제로는 자식 종류를 신경 쓰고 있다.
대체 가능성이 깨지면 다형성이 준 것부터 반납하게 된다.
5. I — 쪼개라가 아니라 필요한 만큼만
class IMonster
{
public:
virtual void Attack() = 0;
virtual void Fly() = 0;
virtual void Swim() = 0;
virtual void CastSpell() = 0;
};
Slime은 공격만 한다. 그런데 이 인터페이스를 구현하려면,
class Slime : public IMonster
{
public:
void Attack() override { }
void Fly() override { }
void Swim() override { }
void CastSpell() override { }
};
빈 함수 세 개가 생긴다. 이 빈 몸통들이 ISP가 잡는 지점이다.
능력별로 나누면,
class IAttackable { public: virtual void Attack() = 0; };
class IFlyable { public: virtual void Fly() = 0; };
class ISwimmable { public: virtual void Swim() = 0; };
class ISpellCaster { public: virtual void CastSpell() = 0; };
이렇게 되고, 얻는 게 하나 더 붙는다.
void MakeFly(IFlyable& entity);
Slime은 컴파일 시점부터 여기 못 들어간다. 앞 편 2번에서 override가 컴파일 시점 검사라고 정리했는데, 인터페이스를 나누는 것도 결국 타입 시스템에게 검사할 거리를 주는 일이었다.
무기 예제
class Weapon
{
public:
virtual int Damage() const = 0;
virtual void Reload() = 0;
};
Sword::Reload()가 빈 구현이어야 한다. 나눠보면,
class IDamageSource { public: virtual int Damage() const = 0; };
class IReloadable { public: virtual void Reload() = 0; };
Rifle -> IDamageSource + IReloadable
Sword -> IDamageSource
능력을 갖고 있는 대로 적은 것뿐인데 빈 함수가 사라진다.
ISP와 LSP는 이어져 있다
여기서 4번과 연결됐다. 빈 구현이 왜 위험한가.
너무 큰 인터페이스
↓ ISP
필요 없는 메서드 강제
↓
빈 구현 / throw 구현
↓
부르는 쪽이 그 메서드 계약을 믿는 순간
↓ LSP
Sword::Reload()가 빈 함수인 채로 남아 있으면, 언젠가 Reload()를 부르고 장전됐다고 믿는 코드가 나온다. 그러면 ISP 문제였던 게 LSP 사고로 바뀐다.
그래서 두 원칙이 동시에 보인다고 아무거나 대는 게 아니라, 직접 원인과 파생 결과를 구분하는 게 맞다. Sword::Reload()는 인터페이스가 능력을 잘못 묶은 게 원인이므로 직접적으로는 ISP다.
그런데 여기도 과하면 안 된다
IFlyable
ITakeOffable
ILandable
IHoverable
ITurnWhileFlying
IAccelerateWhileFlying
이렇게까지 가면 얻는 것보다 잃는 게 많다. 추상화 개수가 늘고, 뭘 조합해야 하는지 전제가 늘고, 읽을 때 따라가야 할 파일이 늘어난다.
ISP는 작게 쪼개라가 아니라 부르는 쪽이 실제로 필요한 능력 경계에 맞춰라였다. 기준이 인터페이스 크기가 아니라 client다.
6. D — 인터페이스가 있다고 DIP가 아니다
앞 편 9번에서 이미 한 번 정리했던 자리다. 그때 화살표를 반대로 봤다가 고쳤었는데, 이번에는 다른 각도로 또 걸렸다.
핵심은 인터페이스가 있느냐가 아니라 소스 코드 의존이 어느 쪽을 향하느냐다.
고수준 정책 → 추상화 ← 저수준 구현
앞 편에서 확인한 대로 RealSoundSystem은 구체 클래스 그대로여도 된다. 실제로 소리를 내야 하니 오히려 구체적이어야 한다. 바뀐 건 화살표뿐이다.
DI와 DIP를 다시 갈랐다
Dragon(RealSoundSystem& sound); // DI: O DIP: X
Dragon(ISoundPlayer& sound); // DI: O DIP: O
DI dependency를 누가 만들어서 넘기는가
DIP dependency의 소스 코드 방향이 어디를 향하는가
앞 편에서 정리한 것과 같다. 다만 이번에는 아닌 것들 쪽이 새로 붙었다.
"인터페이스가 있다" ≠ DIP
"구체 클래스를 쓴다" ≠ DIP 위반
"생성자 주입을 한다" ≠ DIP
세 번째가 특히 헷갈리기 쉬운 자리다. 생성자로 받는 건 DI일 뿐이고, 받는 타입이 구체 클래스면 DIP는 여전히 아니다.
그리고 두 번째. Vector3, Position, Damage 같은 안정된 값 타입까지 인터페이스로 감쌀 이유는 없다. 이건 9번에서 다시 나온다.
7. 인터페이스가 어느 계층의 언어를 쓰는가
이번 편에서 제일 새로웠던 부분이다. 인터페이스를 만들었는데도 추상화 레벨이 안 올라간 경우가 있다.
class IDatabase
{
public:
virtual void ExecuteSQL(std::string sql) = 0;
};
class GameSaveService
{
public:
void Save(const Player& player)
{
db_.ExecuteSQL("INSERT INTO player ...");
}
private:
IDatabase& db_;
};
그림으로는 완벽하다.
GameSaveService -> IDatabase
구체 클래스 이름이 없고, 인터페이스에 의존하고, 주입도 받는다. 6번의 체크리스트를 다 통과한다.
그런데 GameSaveService가 여전히 아는 게 있다.
SQL
query
table
database operation
저장 정책을 담당하는 고수준 클래스가 SQL 문자열을 직접 조립하고 있다. 인터페이스를 하나 세웠지만 그 인터페이스가 여전히 저수준의 언어로 말하고 있어서, 저수준 개념이 그대로 위로 새어 올라온다.
class ISaveRepository
{
public:
virtual void Save(const Player&) = 0;
virtual ~ISaveRepository() = default;
};
이렇게 두면 부르는 쪽은,
repository_.Save(player);
GameSaveService가 아는 건 “게임 상태를 저장한다” 하나로 줄어든다. 직렬화, 커넥션, SQL, 트랜잭션은 전부 구현 뒤로 들어간다.
class MySQLSaveRepository : public ISaveRepository
{
public:
void Save(const Player&) override
{
// serialize
// connection
// SQL
// transaction
}
};
기준
두 인터페이스의 차이를 이렇게 잡았다.
IDatabase::ExecuteSQL() 제공하는 쪽(provider)의 언어
ISaveRepository::Save() 쓰는 쪽(consumer)의 언어
좋은 추상화는 제공자가 할 수 있는 기능 목록이 아니라 소비자가 필요로 하는 능력을 기준으로 정의된다. 5번에서 ISP의 기준이 인터페이스 크기가 아니라 client였던 것과 같은 방향이다. 크기든 언어든, 기준을 쓰는 쪽에 두는 것이 반복해서 나왔다.
추상화 레벨은 인터페이스 이름이 아니라 그 인터페이스가 어느 계층의 단어로 말하는가로 판단한다.
8. 실제 코드는 한 원칙만 깨지지 않는다
class Goblin
{
public:
Goblin()
: sound_(new RealSoundSystem())
, logger_(new FileLogger())
{}
void Attack()
{
int damage = CalculateDamage();
sound_->Play();
logger_->Log(damage);
}
private:
RealSoundSystem* sound_;
FileLogger* logger_;
};
여기 몇 개가 걸리나 세어봤다.
DIP
Goblin이 구체 sound/logger 구현에 직접 의존
↓
OCP
구현을 바꾸려면 Goblin의 기존 코드를 수정해야 함
↓
SRP
Goblin이 전투뿐 아니라 sound/logging 정책 변경에도 영향을 받음
세 개가 동시에 걸린다. 그런데 이걸 “세 개 위반”이라고 나열하는 건 정확하지 않았다. 순서가 있다.
가장 직접적인 원인은 DIP다. 구체 타입 이름을 직접 알고 직접 new 하는 것. 그 결과로 구현을 갈아끼울 때 Goblin을 뜯어야 하니까 OCP 비용이 생기고, Goblin이 사운드·로깅 정책까지 책임지게 되니 SRP 관점에서도 문제가 된다.
면접에서 “이건 D입니다”라고 하나만 찍는 것보다,
가장 직접적으로는 DIP 문제이고, 그 결과 구현 교체 시 기존
Goblin코드까지 수정해야 하므로 OCP 측면의 변경 비용도 생기며,Goblin이 사운드·로깅 정책까지 책임진다면 SRP 관점에서도 문제가 됩니다.
처럼 주 원인과 파생 문제를 나눠서 말하는 쪽이 정확하다. 1번에서 다섯 글자를 다섯 질문으로 바꿔놓은 게 여기서 쓰인다. 매칭 게임이 아니라 순서대로 던져보는 질문이었다.
여담으로 이 코드는 앞 편 기준으로도 이미 위험하다. 생성자에서 new 두 번을 하는데 소멸자가 없고, Goblin이 복사되면 같은 포인터를 두 객체가 들고 있게 된다. 소유권 이야기는 < 소유권과 수명 >과 < 가리키기와 옮기기 >에서 정리한 대로다.
9. 추상화는 공짜가 아니다
이번 편에서 가장 크게 남은 게 이것이다. SOLID를 배우면 자연스럽게 인터페이스를 더 만들고 싶어진다. 그런데 하나 추가할 때마다 붙는 비용이 있다.
이 인터페이스를 왜 만들었는지 이해하는 비용
구현체를 찾아 들어가는 비용
객체가 어디서 생성되는지 추적
소유권 / 수명 확인
런타임 다형성이 실제로 필요한지 검토
파일 사이를 오가는 비용
간접성 때문에 디버깅이 어려워짐
설계를 유지하는 비용
그래서 이런 건 오히려 나쁜 설계가 된다.
IVector3
IPosition
IColor
IDamage
값 타입은 안정적이고 바뀔 압력이 없다. 여기에 인터페이스를 씌우면 유연성은 안 생기고 위 목록만 늘어난다. 6번에서 “구체 클래스를 쓴다 ≠ DIP 위반”이라고 적은 게 이 자리다.
그럼 언제 만드나
“현재 구현체가 몇 개인가”만 보지 않는다. 이쪽을 같이 본다.
- 실제로 아키텍처 경계가 있는가
- 변경 가능성이 높은가
- 기술을 교체할 가능성이 있는가
- 외부 시스템과의 경계인가
- 테스트에서 격리가 필요한가
- 저수준 detail이 고수준 코드로 새어 나오는가
- 컴파일 의존성과 재빌드 비용이 커지는가
- 리소스 수명이나 실패 처리가 복잡한가
- 이 추상화가 도메인 어휘로 자연스러운가
- 추상화 비용보다 얻는 유연성이 큰가
그래서 이런 경계는 구현체가 하나뿐이어도 인터페이스의 값어치가 크다.
DB
Network
File system
Audio engine
Payment API
OS API
반대로 변경 압력이 없고 구체 클래스가 이미 detail을 잘 감싸고 있다면, 필요해질 때 인터페이스를 뽑아내는 것도 충분히 합리적인 선택이다.
이 계산을 한 번 해본 적이 있다
유니티 < 3 >에서 무기와 피격 대상을 붙일 때 IDamageable을 만들었다. 그때 이렇게 적어놨었다.
지금 이걸 구현한 건
Health하나뿐이라, 원래대로면 아직 만들 이유가 없다.그런데 이번엔 만들었다. “나중을 위해”가 아니라 기획서에 무기 3종이 이미 확정되어 있어서다.
오늘 목록으로 다시 보면 이게 “변경 가능성이 높은가” 한 줄을 실제로 확인하고 넘어간 것이었다. 구현체 개수를 세는 게 아니라 확장될 축이 문서에 적혀 있는지를 본 것이고, 9번 목록이 하라는 게 정확히 그 판단이다.
거기에 하나 더 있었다. IDamageable이라는 이름은 Health가 제공하는 기능이 아니라 무기가 필요로 하는 능력을 적은 것이다. 7번의 소비자 언어와 5번의 능력 경계가 같이 걸려 있는 이름인데, 그때는 그냥 “무기 쪽에서 부를 이름”으로 지었다. 이름을 그렇게 지을 이유가 있었다는 걸 오늘에서야 확인했다..
앞 편 8번에서 결합도를 낮추는 것도 공짜가 아니라고 적었었다. 그때는 이벤트 간접성과 디버깅 난이도가 값이었는데, 여기서는 추상화 개수와 탐색 비용이 값이다. 같은 계산의 다른 항목이었다.
SOLID는 추상화를 최대화하는 규칙이 아니라, 변경 비용과 지금의 복잡도 사이에서 균형을 잡는 도구다.
10. 하나로 묶어본 케이스
마지막으로 지금까지의 질문을 코드 하나에 전부 던져봤다.
class GameSaveService
{
public:
void Save(const Player& player)
{
std::string json = SerializeToJson(player);
MySQLDatabase db;
db.Connect("127.0.0.1");
db.Execute("INSERT INTO save_data ...");
std::cout << "Save completed\n";
}
};
S — 왜 바뀌는가. 네 갈래다.
직렬화 포맷 변경
DB 연결 방식 변경
DB 스키마 변경
로그 출력 방식 변경
D — 고수준이 저수준 이름을 아는가. 안다. MySQLDatabase가 이 안에 박혀 있다.
여기서 하나 걸렀다. 나는 처음에 GameSaveService가 Player를 구체 타입으로 받는 것도 DIP 문제일 수 있다고 봤다. 아니다. 도메인 객체에 구체 의존이 있는 건 자연스럽다. 저장 서비스가 저장할 대상을 아는 건 당연한 일이다. 문제는 MySQLDatabase라는 인프라 detail을 아는 쪽이다.
9번의 목록으로 봐도 갈린다. Player는 도메인 어휘고, MySQLDatabase는 외부 시스템 경계다.
O — 확장할 때 뜯는가. 파일 저장이나 클라우드 저장을 추가하려면 이 함수를 열어야 한다. 다만 3번에서 정리한 대로, SRP를 고친다고 이게 자동으로 풀리지는 않는다.
정리하면,
class ISaveRepository
{
public:
virtual void Save(const Player&) = 0;
virtual ~ISaveRepository() = default;
};
class MySQLSaveRepository : public ISaveRepository
{
public:
void Save(const Player&) override;
};
class GameSaveService
{
public:
GameSaveService(ISaveRepository& repository)
: repository_(repository)
{}
private:
ISaveRepository& repository_;
};
GameSaveService
↓
ISaveRepository
↑
MySQLSaveRepository
-
DI —
GameSaveService가 의존을 직접 만들지 않고 밖에서 받는다 - DIP — 고수준과 저수준이 둘 다 추상을 향한다
-
7번 — 인터페이스가
Save(Player)라는 소비자 언어로 말한다.ExecuteSQL이 아니다 -
앞 편 10번 —
ISaveRepository&를 멤버로 들었으니, 대상의 수명은 바깥에서 보장해야 한다
마지막 줄이 앞 편과 그대로 이어지는 자리다. 인터페이스로 받았다고 수명 문제가 사라지는 게 아니다. 레퍼런스로 의존을 받으면 누가 MySQLSaveRepository보다 GameSaveService를 먼저 죽이지 않도록 보장하는가가 여전히 남는다. 설계 원칙을 지킨 코드에도 앞 편의 멤버 선언 순서 이야기가 그대로 붙는다.
정리
바로 잡혔던 것
-
변경 전파, 결합도, 책임 분리 관점 자체는 먼저 잡혀 있었다. 예제를 보자마자 “사운드 구현이 바뀌면
Goblin도 고쳐야 한다”는 건 용어 없이도 나왔다 - 구현이 하나로 고정되고 변경 압력이 없으면 인터페이스가 불필요한 복잡도일 수 있다는 감각도 있었다. 9번은 그걸 기준 목록으로 바꾼 것에 가깝다
- DI와 DIP의 구분은 앞 편에서 한 번 고친 뒤라 이번에는 안 흔들렸다
틀렸던 것
- SRP는 작업 개수 세는 원칙이 아니다. 세 가지를 해도 변경 축이 하나면 위반이 아니다
- SRP를 고쳐도 OCP는 자동으로 안 풀린다. “왜 바뀌는가”와 “확장할 때 어떻게 바뀌는가”는 다른 질문이다
-
Player를 구체 타입으로 받는 건 DIP 위반이 아니다. 도메인 객체와 인프라 detail을 같은 급으로 봤다 -
ISaveRepository라는 클래스가 DIP인 게 아니다. DIP는 클래스 하나가 아니라 의존 관계의 방향에 대한 원칙이다
아니라고 확인한 것
-
OCP는 모든
switch를 없애라가 아니다. 닫힌 도메인이면switch가 더 낫다 - ISP는 인터페이스를 잘게 쪼개라가 아니다. 기준은 크기가 아니라 client가 실제로 필요한 능력 경계다
- 인터페이스가 있다고 DIP가 아니고, 생성자 주입을 한다고 DIP가 아니다
- 추상화는 공짜가 아니다. 값 타입까지 습관적으로 인터페이스화하면 얻는 것 없이 비용만 늘어난다
새로 얹은 것
-
is-a와can-be-used-as는 다르다. LSP가 보는 건 뒤쪽이다 -
행동 계약 — 사전 조건은 더 강하게, 사후 조건은 더 약하게 만들지 않는다.
throw뿐 아니라-1반환도 여기 걸린다 - 부르는 쪽이 자식별 예외 처리를 시작하면 다형성이 준 것부터 잃는다
- ISP 위반이 LSP 사고로 번진다. 빈 구현을 누군가 믿는 순간이 그 지점이다
-
추상화 레벨은 인터페이스 이름이 아니라 그게 어느 계층의 언어로 말하는가로 판단한다.
ExecuteSQL대Save(Player) - 실제 코드는 여러 원칙이 동시에 걸린다. 나열하지 말고 주 원인과 파생 문제를 나눠야 한다
앞 편에서 “다음은 계약을 지킨 척하는 구현을 보는 자리”라고 적어뒀는데, 그게 4번의 PeacefulNPC였다. 순수 가상 함수를 채우기만 하면 컴파일러는 통과시킨다는 게 앞 편 결론이었고, 그 통과한 코드가 왜 여전히 문제인지가 이번 편이었다. 컴파일러가 검사해주는 선이 어디까지인지를 두 편에 걸쳐 본 셈이다.
그리고 이번 편은 절반이 안 해도 되는 이유였다. switch를 지우지 않아도 되는 경우, 인터페이스를 안 만들어도 되는 경우, 구체 클래스를 그대로 둬도 되는 경우. 원칙을 배웠으니 다 적용해야 할 것 같은데, 정작 배운 건 어디에만 적용할지 고르는 기준이었다.
다음은 자료구조와 알고리즘 쪽으로 넘어간다. 첫 주제는 std::vector와 std::list이고, 첫 질문은 둘 다 순회가 O(N)인데 왜 실제로는 vector가 훨씬 빠른 경우가 많은가다. 지금까지는 “무엇이 바뀔 때 어디가 아픈가”를 봤는데, 이제는 “같은 복잡도인데 왜 실제 속도가 다른가” 쪽인 것 같다.
참고 자료
- C++ Core Guidelines - I.25 인터페이스로는 추상 클래스를
- C++ Core Guidelines - C.120 클래스 계층은 언제 쓰는가
- C++ Core Guidelines - C.121 인터페이스로 쓸 기반 클래스는 순수 추상으로
- C++ Core Guidelines - C.35 기반 클래스 소멸자
- cppreference - abstract class
- Barbara Liskov, Jeannette Wing - A Behavioral Notion of Subtyping (1994)
- 이론 정리 - < 물려받기와 갈아끼우기 > — 이 글의 앞 편
- 이론 정리 - < 가리키기와 옮기기 > — 포인터, 레퍼런스, 이동
- 이론 정리 - < 소유권과 수명 > — 소유권, 순환 참조
-
유니티 학습 프로젝트 - < 3 > —
IDamageable을 만든 근거 - C# - <3> — 인터페이스와 느슨한 결합을 처음 정리했던 글
소감(이전 한줄 평)
- 원칙 다섯 개를 배웠는데 정작 손에 남은 건 안 해도 되는 이유 쪽이 더 많았다. 인터페이스를 하나 만들 때마다 그만큼 비용을 내는 거라는 걸 이제야 문장으로 갖게 됐다.. 예전 같으면 SOLID 배웠으니 다 뜯어고쳤을 텐데, 그러기 전에 “이건 실제로 바뀔 축인가”를 먼저 물어보게 된 게 오늘 제일 큰 변화인 것 같다.
IDamageable때 기획서를 근거로 삼았던 게 운이 아니라 기준이었다는 것도 뒤늦게 확인해서 나름 뿌듯했다.