5.1 SOLID - SRP
1. SOLID 개요
객체 지향 프로그래밍에는 4가지 객체지향의 특징과, 5가지 객체지향의 원칙이 존재합니다.
A. SOLID 원칙이란?
: 객체 지향 프로그래밍 설계시 준수해야 할 5가지의 원칙을 의미합니다.
SOLID | Eng. | Kor. |
SRP | Single Responsibility Principle | 단일 책임 원칙 |
OCP | Open-Closed Principle | 개방-폐쇄 원칙 |
LSP | Liskov Substitution Principle | 리스코프 치환 원칙 |
ISP | Interface Segregation Principle | 인터페이스 분리 원칙 |
DIP | Dependency Inversion Principle | 의존성 역전 원칙 |
B. SOLID는 처음 한번에 나온 것이 아니라, 여러 학자들의 연구와 글에서 유래하여 정리된 원칙입니다.
- SRP (단일 책임 원칙)
- 《Agile Software Development, Principles, Patterns, and Practices》(2002)
- 로버트 C. 마틴 (Robert C. Martin)
- OCP (개방-폐쇄 원칙)
- 《Object-Oriented Software Construction》(1988)
- 버트란드 마이어(Bertrand Meyer)
- LSP (리스코프 치환 원칙)
- "Data Abstraction and Hierarchy" (1987년 논문)
- 바바라 리스코프(Barbara Liskov)
- ISP (인터페이스 분리 원칙)
- 로버트 C. 마틴이 1996년경 자신의 저술에서 정리.
- DIP (의존 역전 원칙)
- 역시 로버트 C. 마틴이 제시
C. SOLID 학습 방향
객체지향 개념은 독립적으로 존재하지 않고,
추상화 → 캡슐화 → 다형성 → 상속/인터페이스 → SOLID 원칙 → 디자인 패턴 등이 서로 연결되어 있습니다.
WPF 의 MVVM과 같은 "아키텍처 패턴"이나 "디자인 패턴"을 학습하기에 앞서
우선 객체 지향 프로그래밍 설계의 5대 원칙(SOILD)를 이해 합니다.
D. 고려사항
모든 프로그래밍 기술들은 (실무를 경험하지 못한) 학습자 관점에서 이해하기 어렵습니다.
모든 프로그래밍 기술들은 학습자를 위한 것이 아니기 때문입니다.
현장의 프로그램 개발 효율을 높이기 위해 발전한 기술이기 때문에,
여러명과 협업하고, 수많은 기술들을 포함시켜, 최소 몇개월 단위로 작업을 목적으로 하기에,
단편적인 예제들로 학습자들에게 필요성을 납득시키기가 쉽지 않습니다.
SOLID 원칙도 마찬가지 입니다. "굳이 적용해야 하는가?" 하는 의문이 들 수도 있지만,
지금 학습 할 것들은 현장에서 사용하는 살아있는 지식이며, 기본적인 이론입니다.
납득하기 어렵더라도, 우선 알고 계셔야합니다.
2. 단일 책임 원칙 (SRP, Single responsibility principle) 개요
A. 정의
"There should never be more than one reason for a class to change."
≒ "클래스가 변경되어야 할 이유는 단 하나여야 한다."
클래스가 여러개의 책임(기능)을 갖게 되면, 각 책임에 맞춰 연결된 모든 구현부를 변경해야하는 문제가 발생한다.
클래스 내부에 여러 함수가 구현될 수 있다. 하지만 이 함수들이 대표하는 기능은 하나여야 한다.
B. SRP 위반 예제 01
: 출력 형식 바꾸려면 Score 클래스를 수정해야 함 → SRP 위반
// ❌ SRP 위반: 한 클래스가 '[책임1]점수 계산'과 '[책임2]출력' 두 책임을 동시에 가짐
using System;
class Score
{
public int Value { get; private set; }
public void Add(int x) // [책임1] 점수(도메인 상태) 변경
{
Value += x;
}
public void Print() // [책임2] 출력(프레젠테이션)
{
Console.WriteLine($"현재 점수: {Value}");
}
}
class Program
{
static void Main()
{
var s = new Score();
s.Add(10);
s.Print(); // 도메인 로직과 출력이 같은 클래스에 있어 변경 이유가 2개
}
}
C. SRP 준수 예제 01
using System;
#region Domain: 점수 관리
// [1] 점수 데이터와 계산만 담당
class Score
{
public int Value { get; private set; }
public void Add(int x)
{
Value += x;
}
}
#endregion
#region Presentation: 출력 전담
// [2] 출력 전용 클래스
class ScorePrinter
{
public void Print(Score s)
{
Console.WriteLine($"현재 점수: {s.Value}");
}
}
#endregion
class Program
{
static void Main()
{
var score = new Score();
var printer = new ScorePrinter();
score.Add(10); // 점수 로직
printer.Print(score); // 출력 로직
}
}
- 위반 버전
- Score 클래스가 Add와 Print 두 가지 책임을 동시에 가짐 → 점수 계산/출력 모두 수정해야 할 수 있음.
- 준수 버전
- Score는 점수 로직만, ScorePrinter는 출력만 담당 → 출력 방식을 바꾸려면 ScorePrinter만 수정하면 됨.
3. 예제 02
A. SRP 위반
: 파일 저장 로직을 바꾸려면 Score 클래스를 수정해야 함 → SRP 위반
// ❌ SRP 위반: [책임1] 점수 관리 + [책임2] 출력 + [책임3] 파일 저장 책임이 모두 한 클래스에 섞여 있음
using System;
using System.IO;
class Score
{
public int Value { get; private set; }
public void Add(int x) // [책임1] 점수 관리
{
Value += x;
}
public void Print() // [책임2] 출력(UI/프레젠테이션)
{
Console.WriteLine($"현재 점수: {Value}");
}
public void SaveToFile(string path) // [책임3] 파일 저장
{
File.AppendAllText(path, $"{DateTime.Now:HH:mm:ss} - Score={Value}{Environment.NewLine}");
}
}
class Program
{
static void Main()
{
var s = new Score();
s.Add(10);
s.Print();
s.SaveToFile("score.log"); // 파일 저장 로직이 한 클래스에 묶여 있음 → SRP 위반
}
}
C. SRP 준수
책임 분리
class Score
{
public int Value { get; private set; }
public void Add(int delta) { Value += delta; }
}
interface IScorePrint
{
void Print(Score s);
}
class ScorePrint : IScorePrint
{
public void Print(Score s) { Console.WriteLine($"현재 점수: {s.Value}"); }
}
interface IScoreSave
{
void Save(Score s);
}
class ScoreSave : IScoreSave
{
private readonly string _path;
public ScoreSave(string path)
{
_path = path;
}
public void Save(Score s)
{
File.AppendAllText(_path, $"{DateTime.Now:HH:mm:ss} - Score={s.Value}{Environment.NewLine}");
}
}
필요한 책임 모듈 조합 가능 (재사용성)
- 케이스 A: 출력만
- 케이스 B: 저장만
- 케이스 C: 출력 + 저장(조합)
- 케이스 D: 저장 + 출력(순서 변경)
- + 그 외에 모듈별 조합 가능(변경되어야 할 이유는 단 하나, SRP)
interface IScoreOrchestrator
{
void Run(Score s);
}
// ① 출력만 수행
class PrintOnlyOrchestrator : IScoreOrchestrator
{
private readonly IScorePrint _printer;
public PrintOnlyOrchestrator(IScorePrint printer)
{
_printer = printer;
}
public void Run(Score s)
{
_printer.Print(s);
}
}
// ② 저장만 수행
class SaveOnlyOrchestrator : IScoreOrchestrator
{
private readonly IScoreSave _repo;
public SaveOnlyOrchestrator(IScoreSave repo)
{
_repo = repo;
}
public void Run(Score s)
{
_repo.Save(s);
}
}
// ③ 출력 + 저장 수행
class PrintAndSaveOrchestrator : IScoreOrchestrator
{
private readonly IScorePrint _printer;
private readonly IScoreSave _repo;
public PrintAndSaveOrchestrator(IScorePrint printer, IScoreSave repo)
{
_printer = printer;
_repo = repo;
}
public void Run(Score s)
{
_printer.Print(s);
_repo.Save(s);
}
}
#endregion
4. 예제 03 - 전사 게임 케릭터 만들기
A. SRP 위반
: 하나의 클래스에 여러 책임(기능)이 섞여있는 코드
using System;
using System.IO;
namespace SRPViolation_Warrior
{
// ❌ SRP 위반: 전투 로직 + UI 출력 + 파일 저장이 한 클래스에 섞여 있음
class Warrior
{
public string Name { get; }
public int Level { get; private set; }
public int Hp { get; private set; }
public int AttackPower { get; private set; }
public Warrior(string name, int level, int hp, int ap)
{
Name = name;
Level = level;
Hp = hp;
AttackPower = ap;
}
// [책임1] 전투
public int Attack(string target)
{
int damage = AttackPower + (Level * 2);
Console.WriteLine($"{Name}이(가) {target}에게 {damage} 피해를 주었다!");
return damage;
}
// [책임2] UI
public void RenderHud()
{
Console.WriteLine($"[HUD] {Name,-8} | Lv {Level,2} | HP {Hp,4} | AP {AttackPower,3}");
}
// [책임3] 저장
public void SaveProfile(string path)
{
File.WriteAllText(path, $"{Name},{Level},{Hp},{AttackPower}");
Console.WriteLine($"[SAVE] 전사 프로필 저장 완료 -> {path}");
}
}
class Program
{
static void Main()
{
var w = new Warrior("강한전사", level: 5, hp: 120, ap: 18);
// 전투 로직 + UI + 저장이 모두 Warrior에 들어있음 → SRP 위반
w.RenderHud(); // UI 책임
w.Attack("고블린"); // 도메인 책임
w.SaveProfile("warrior.csv"); // 저장 책임
}
}
}
이렇게 작성하면
- [1] 전투
- [2] UI
- [3] 저장
등 서로 다른 이유로 바뀔 수 있는 기능들이 전부 한 클래스 안에 모여 있습니다.
"한 클래스는 변경 사유가 하나만 있어야 한다"라는 SRP를 어긴 예시입니다.
Warrior클래스가 입력, 이동, 사운드, 애니메이션, 저장, 네트워크, UI까지 전부 담당한다면
예를 들어, "입력 방식이 바뀐다" >> 하나의 책임(기능)이 수정될 때 Player 클래스를 수정 필요
"애니메이션 추가" >> Player 클래스 수정 필요
"저장 방식 바뀐다" >> Player 클래스 수정 필요
하나만 고쳐도 Player 전체를 열어봐야 하므로 유지보수가 어렵습니다.
실제 일어날만한 문제 가정
- “입력만 바꿨는데 사운드가 깨져요” 같은 예상 밖 회귀 버그.
- “이 부분 누구 건지 몰라서 수정 무서움” >> 버그 방치/코드 동결.
- “테스트 쓰기 힘들어요” >> 자동화 테스트 부재, 수동 QA 비용 증가.
- 여러 사람이 같은 거대 클래스에 손대야 하므로 충돌·리뷰 비용 급증.
- 책임이 많아 복잡도 및 클래스 길이 증가 >> 버그 잠재성 증가
- 작은 요구(예: “애니메이션만 추가”)도 클래스 전반을 건드리게 됨.
B. SRP 준수
using System;
using System.IO;
namespace SRPCompliant_Warrior
{
// [도메인] 전사 상태만 보관
class Warrior
{
public string Name { get; private set; }
public int Level { get; private set; }
public int Hp { get; private set; }
public int AttackPower { get; private set; }
public Warrior(string name, int level, int hp, int ap)
{
Name = name;
Level = level;
Hp = hp;
AttackPower = ap;
}
}
// [전투 책임] 공격 전략 인터페이스
interface IAttackStrategy
{
int CalcDamage(Warrior w);
}
// 기본 공격 규칙: AP + (Level * 2)
class BasicAttack : IAttackStrategy
{
public int CalcDamage(Warrior w)
{
return w.AttackPower + (w.Level * 2);
}
}
// [UI 책임] HUD 출력
interface IHudRenderer
{
void Render(Warrior w);
}
class Renderer : IHudRenderer
{
public void Render(Warrior w)
{
Console.WriteLine("[HUD] {0} | Lv {1} | HP {2} | AP {3}",
w.Name, w.Level, w.Hp, w.AttackPower);
}
}
// [저장 책임] 프로필 저장
interface IProfileSave
{
void Save(Warrior w, string path);
}
class SaveFile : IProfileSave
{
public void Save(Warrior w, string path)
{
string data = w.Name + "," + w.Level + "," + w.Hp + "," + w.AttackPower;
File.WriteAllText(path, data);
}
}
// [오케스트레이션] 전투 흐름 관리
class BattleService
{
private readonly IAttackStrategy _attack;
public BattleService(IAttackStrategy attack)
{
_attack = attack;
}
public void Attack(Warrior w, string target)
{
int damage = _attack.CalcDamage(w);
Console.WriteLine("{0}이(가) {1}에게 {2} 피해를 주었다!", w.Name, target, damage);
}
}
// 데모 실행
class Program
{
static void Main()
{
Warrior w = new Warrior("강한전사", 5, 120, 18);
IHudRenderer hud = new Renderer();
IProfileSave saver = new SaveFile();
IAttackStrategy attack = new BasicAttack();
BattleService svc = new BattleService(attack);
hud.Render(w); // HUD 출력
svc.Attack(w, "고블린"); // 전투 수행 (공격 책임은 BasicAttack이 가짐)
saver.Save(w, "warrior.csv"); // 저장 책임
Console.WriteLine("[SAVE] 전사 프로필 저장 완료");
}
}
}
장점
- 가독성 향상
- 코드가 역할별로 분리 >> 코드가 짧고 명확하여 읽기 쉽고 유지보수 편리합니다.
- 코드가 역할별로 분리 >> 코드가 짧고 명확하여 읽기 쉽고 유지보수 편리합니다.
- 확장성 향상
- 단일 기능으로 이루어져 있기 때문에 이 클래스를 상속받아 확장에 용이합니다.
- 재사용성 향상
- 단일 기능으로 모듈식으로 여러 부분에서 응용 가능합니다.
- 변경 이유가 분리됨
- 입력만 바꾸고 싶으면 InputModule만 수정.
- 테스트 용이
- 각 모듈 단위 테스트 가능.
5. 예제 04 - 전사 → 기사, 도적 추가
확장(1) - 직업 클래스 재사용 Warrior → Knight, Rogue 추가
// [도메인] 전사 상태만 보관
class Warrior
{
public string Name { get; private set; }
public int Level { get; private set; }
public int Hp { get; private set; }
public int AttackPower { get; private set; }
public Warrior(string name, int level, int hp, int ap)
{
Name = name;
Level = level;
Hp = hp;
AttackPower = ap;
}
}
// ===== 도메인: 확장 타입들(상속) =====
class Knight : Warrior
{
public int Defense { get; private set; } // 기사 전용 속성(예: 방어력)
public Knight(string name, int level, int hp, int ap, int defense)
: base(name, level, hp, ap)
{
Defense = defense;
}
}
// 도적 클래스 추가 (기존 코드 수정 없음)
class Rogue : Warrior
{
public int Agility { get; private set; } // 도적 전용 속성(예: 민첩)
public Rogue(string name, int level, int hp, int ap, int agility)
: base(name, level, hp, ap)
{
Agility = agility;
}
}
// 기존 Warrior도, 새로 추가된 Knight/Rogue도 같은 흐름 재사용
Warrior baseWarrior = new Warrior("평범한전사", 4, 100, 15);
Knight knight = new Knight("튼튼한기사", 6, 140, 18, defense: 10);
Rogue rogue = new Rogue("민첩한도적", 7, 110, 16, agility: 12);
hud.Render(baseWarrior); // HUD 출력
svc.Attack(baseWarrior, "고블린"); // 전투 수행 (공격 책임은 BasicAttack이 가짐)
saver.Save(baseWarrior, "warrior.csv"); // 저장 책임
Console.WriteLine("[SAVE] 전사 프로필 저장 완료");
hud.Render(knight); // HUD 출력
svc.Attack(knight, "슬라임"); // 전투 수행 (공격 책임은 BasicAttack이 가짐)
saver.Save(knight, "knight.csv"); // 저장 책임
Console.WriteLine("[SAVE] 전사 프로필 저장 완료");
hud.Render(rogue); // HUD 출력
svc.Attack(rogue, "오우거"); // 전투 수행 (공격 책임은 BasicAttack이 가짐)
saver.Save(rogue, "rogue.csv"); // 저장 책임
Console.WriteLine("[SAVE] 전사 프로필 저장 완료");
확장(2) - 공격 클래스 확장
// [전투 책임] 공격 전략 인터페이스
interface IAttackStrategy
{
int CalcDamage(Warrior w);
}
// 기본 공격 규칙: AP + (Level * 2)
class BasicAttack : IAttackStrategy
{
public int CalcDamage(Warrior w)
{
return w.AttackPower + (w.Level * 2);
}
}
// Knight에 특화된 전략(방어 태세 보너스 등)
class KnightShieldAttack : IAttackStrategy
{
public int CalcDamage(Warrior w)
{
// Knight만의 추가 보너스 예시: (Defense / 2)
// 상위 타입으로 받아도, 런타임 타입이 Knight면 보너스를 얹음
int baseAp = (int)w.GetType().GetProperty("AttackPower").GetValue(w, null);
int lv = (int)w.GetType().GetProperty("Level").GetValue(w, null);
int dmg = baseAp + (lv * 2);
if (w is Knight k)
{
dmg += k.Defense / 2;
}
return dmg;
}
}
// Rogue에 특화된 전략(백스탭 등 민첩 보너스)
class RogueBackstab : IAttackStrategy
{
public int CalcDamage(Warrior w)
{
int baseAp = (int)w.GetType().GetProperty("AttackPower").GetValue(w, null);
int lv = (int)w.GetType().GetProperty("Level").GetValue(w, null);
int dmg = baseAp + (lv * 2);
if (w is Rogue r)
{
dmg += r.Agility; // 민첩만큼 보너스
}
return dmg;
}
}
// 기존 Warrior도, 새로 추가된 Knight/Rogue도 같은 흐름 재사용
Warrior baseWarrior = new Warrior("평범한전사", 4, 100, 15);
Knight knight = new Knight("튼튼한기사", 6, 140, 18, defense: 10);
Rogue rogue = new Rogue("민첩한도적", 7, 110, 16, agility: 12);
// 1) 기존 전략(BasicAttack)을 그대로 재사용 (수정 없음)
BattleService svcBasic = new BattleService(new BasicAttack());
svcBasic.Attack(baseWarrior, "슬라임");
// 2) Knight 전용 전략 추가(기존 클래스 수정 없이 새로운 클래스만 추가/주입)
BattleService svcKnight = new BattleService(new KnightShieldAttack());
svcKnight.Attack(knight, "오우거");
// 3) Rogue 전용 전략 추가(기존 클래스 수정 없이 새로운 클래스만 추가/주입)
BattleService svcRogue = new BattleService(new RogueBackstab());
svcRogue.Attack(rogue, "오우거");