객체지향 설계 원칙: SOLID
🔵 객체 지향 설계의 5가지 기본 원칙인 SOLID에 대해 python 예제를 통해 알아보았다.
🔵 Intro
정보처리기사 시험에 꼭 나오는 키워드인 SOLID 원칙을 외우면 돌아서면 까먹는 주제다. 필기때는 중요한지 어쩐지도 모르고 그냥 대충 넘겼는데…
자세히 보니까 객체지향 프로그래밍의 원칙을 잘 담아내고 있어서 이번 기회에 예제 코드와 함께 한번 정리해보았다.
⚪ 0. SOLID 원칙이란?
SOLID 원칙은 객체지향 설계를 더 깔끔하고, 유연하고, 유지보수하기 쉽게 만드는 5가지 핵심 원칙이다. 소프트웨어 공학의 ‘로버트 C. 마틴’이 정리한 내용이다.
평소 코드가 ‘왜 이렇게 구성되어있지?’ 의문이 들곤 했는데 예제를 보면서 공부를 하니까 이러한 원칙 속에서 코드를 짜는게 고수의 길이구나 생각이 들었다.
아무튼, 정리하면 총 5가지인데
- S: 단일 책임 원칙 (SRP, Single Responsibility Principle)
- O: 개방-폐쇄 원칙 (OCP, Open/Closed Principle)
- L: 리스코프 치환 원칙 (LSP, Liskov Substitution Principle)
- I: 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)
- D: 의존관계 역전 원칙 (DIP, Dependency Inversion Principle)
⚪ 1. S: 단일 책임 원칙 (SRP)
“한 클래스는 하나의 책임(기능)만 가져야 한다.” (변경해야 할 이유가 하나여야 함)
❌ 위반 예시 (Before)
User 클래스가 **사용자 정보 관리(data)**와 **데이터베이스 저장(logic)**을 둘 다 하고 있디.
1
2
3
4
5
6
7
8
9
10
11
12
# '사용자' 클래스가 너무 많은 일을 함
class User:
def __init__(self, name: str):
self.name = name
def get_name(self) -> str:
return self.name
# ❌ 문제: User 클래스가 DB 저장 방법까지 알고 있음
def save_to_database(self):
print(f"{self.name}을(를) 데이터베이스에 저장합니다...")
# (복잡한 DB 로직...)
✅ 준수 예시 (After)
두 개의 책임을 두 개의 클래스로 분리!
1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 사용자 '정보'만 책임지는 클래스
class User:
def __init__(self, name: str):
self.name = name
def get_name(self) -> str:
return self.name
# 2. DB '저장'만 책임지는 클래스
class UserRepository:
def save(self, user: User):
print(f"{user.get_name()}을(를) 데이터베이스에 저장합니다...")
# (복잡한 DB 로직...)
➡️ 왜 이게 좋은가? 나중에 DB 저장 방식이 바뀌어도(예: MySQL -> MongoDB), User 클래스는 건드릴 필요 없이 UserRepository만 수정하면 된다.
⚪ 2. O: 개방-폐쇄 원칙 (OCP)
“확장에는 열려(Open) 있어야 하고, 수정에는 닫혀(Closed) 있어야 한다.” (기능 추가 시, 기존 코드를 고치지 말아야 함)
❌ 위반 예시 (Before)
결제 방식이 추가될 때마다 PaymentProcessor의 if문을 수정해야 함.
1
2
3
4
5
6
7
8
9
class PaymentProcessor:
# ❌ 문제: '계좌 이체'가 추가되면 이 함수를 '수정'해야 함
def process_payment(self, amount: int, method: str):
if method == "card":
print(f"카드로 {amount}원 결제")
elif method == "paypal":
print(f"페이팔로 {amount}원 결제")
# elif method == "bank_transfer": <-- 여기를 고쳐야 함
# ...
✅ 준수 예시 (After)
‘결제’라는 **추상화(인터페이스)**를 만들고, 새 기능을 **새 클래스(확장)**로 추가.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1. '결제'라는 약속(추상 클래스)을 만듦
class PaymentGateway:
def pay(self, amount: int):
raise NotImplementedError # 상속받는 놈은 무조건 pay를 구현해!
# 2. '확장' (새 클래스)
class CardPayment(PaymentGateway):
def pay(self, amount: int):
print(f"카드로 {amount}원 결제")
class PaypalPayment(PaymentGateway):
def pay(self, amount: int):
print(f"페이팔로 {amount}원 결제")
# 3. '수정'할 필요가 없는 기존 코드
class PaymentProcessor:
def process_payment(self, gateway: PaymentGateway, amount: int):
# 어떤 결제 방식이 오든, 그냥 pay()만 호출하면 됨
gateway.pay(amount)
➡️ 왜 이게 좋을까? ‘계좌 이체’ 기능을 추가하고 싶으면, BankTransferPayment 클래스를 새로 만들기만 하면 끝! PaymentProcessor는 고칠 필요가 전혀 없다.
⚪ 3. L: 리스코프 치환 원칙 (LSP)
“자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다.” (자식 클래스가 부모의 기능을 ‘깨트리면’ 안 됨)
❌ 위반 예시 (Before)
그 유명한 ‘직사각형과 정사각형’ 문제임. Square는 Rectangle이 아님!
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
class Rectangle: # 부모
def __init__(self, width: int, height: int):
self.width = width
self.height = height
def set_width(self, width: int):
self.width = width
def set_height(self, height: int):
self.height = height
def get_area(self) -> int:
return self.width * self.height
# ❌ 문제: Square는 부모의 약속(set_width, set_height가 따로 논다)을 깸
class Square(Rectangle): # 자식
def __init__(self, size: int):
super().__init__(size, size)
def set_width(self, width: int): # 부모의 동작을 '변경'함
self.width = width
self.height = width # 정사각형이니까...
def set_height(self, height: int): # 부모의 동작을 '변경'함
self.width = height
self.height = height
# --- 테스트 코드 ---
def test_area(rect: Rectangle):
rect.set_width(5)
rect.set_height(4)
area = rect.get_area()
# 부모(Rectangle)라면 당연히 5 * 4 = 20 이 나와야 함
print(f"넓이: {area}") # Square를 넣으면? 4 * 4 = 16이 나옴!
r = Rectangle(1, 1)
s = Square(1)
test_area(r) # 출력: 넓이: 20 (정상)
test_area(s) # 출력: 넓이: 16 (비정상!) 💥
✅ 준수 예시 (After)
이건 애초에 상속 관계가 잘못된 것임. 공통의 ‘도형(Shape)’으로 묶어야 함.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 공통의 약속
class Shape:
def get_area(self) -> int:
raise NotImplementedError
# 2. 각자 구현
class Rectangle(Shape):
def __init__(self, width: int, height: int):
self.width = width
self.height = height
def get_area(self) -> int:
return self.width * self.height
class Square(Shape):
def __init__(self, size: int):
self.size = size
def get_area(self) -> int:
return self.size * self.size
➡️ 왜 이게 좋을까? Square가 Rectangle인 척하다가 문제를 일으키는 걸 막았음. 자식이 부모의 ‘규칙’을 어기면 안 된다는 게 핵심.
⚪ 4. I: 인터페이스 분리 원칙 (ISP)
“클라이언트는 자신이 쓰지 않는 기능(인터페이스)에 의존하면 안 된다.” (하나의 ‘뚱뚱한’ 인터페이스보다 여러 개의 ‘날씬한’ 인터페이스가 낫다)
❌ 위반 예시 (Before)
BasicPrinter는 fax 기능이 없는데도, ‘뚱뚱한’ Machine 인터페이스 때문에 억지로 fax 메서드를 구현해야 함.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# ❌ '뚱뚱한' 인터페이스
class Machine:
def print_doc(self, doc):
raise NotImplementedError
def scan_doc(self, doc):
raise NotImplementedError
def fax_doc(self, doc):
raise NotImplementedError
class MultiFunctionPrinter(Machine): # 얘는 다 쓰니까 OK
def print_doc(self, doc): ...
def scan_doc(self, doc): ...
def fax_doc(self, doc): ...
class BasicPrinter(Machine): # ❌ 얘는 'print'만 필요한데...
def print_doc(self, doc): ...
# 쓰지도 않을 기능을 억지로 구현해야 함
def scan_doc(self, doc):
pass # 안 씀
def fax_doc(self, doc):
raise Exception("팩스 기능 없음") # 혹은 이렇게
✅ 준수 예시 (After)
인터페이스를 기능별로 ‘분리(Segregation)’한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1. '날씬한' 인터페이스들로 분리
class Printer:
def print_doc(self, doc): raise NotImplementedError
class Scanner:
def scan_doc(self, doc): raise NotImplementedError
class Fax:
def fax_doc(self, doc): raise NotImplementedError
# 2. 필요한 것만 골라서 구현
class MultiFunctionPrinter(Printer, Scanner, Fax): # 3개 다
def print_doc(self, doc): ...
def scan_doc(self, doc): ...
def fax_doc(self, doc): ...
class BasicPrinter(Printer): # 'Printer' 1개만
def print_doc(self, doc): ...
➡️ 왜 이게 좋은가? BasicPrinter는 자기가 쓸데없는 scan, fax 기능에 대해 전혀 알 필요가 없어졌다.
⚪ 5. D: 의존관계 역전 원칙 (DIP)
“구체적인 것(구현체)에 의존하지 말고, 추상적인 것(인터페이스)에 의존해라.” (이게 ‘제어의 역전(IoC)’과 ‘의존성 주입(DI)’의 핵심)
❌ 위반 예시 (Before)
‘알림 서비스(고수준)’가 ‘이메일(저수준/구체적)’이라는 구현체에 직접 의존하고 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 저수준/구체적인 클래스
class EmailSender:
def send_email(self, message: str):
print(f"이메일 발송: {message}")
# 2. 고수준 클래스
class NotificationService:
def __init__(self):
# ❌ 문제: 'EmailSender'라는 구체적인 놈을 직접 생성해서 씀
self.sender = EmailSender()
def send_notification(self, message: str):
self.sender.send_email(message)
➡️ 왜 이게 나쁜가? 나중에 ‘이메일’ 말고 ‘SMS’로 알림을 보내고 싶으면? NotificationService 코드를 뜯어고쳐야 함. (OCP 위반)
✅ 준수 예시 (After)
‘알림 서비스’는 ‘알림 발송기’라는 **추상적인 것(인터페이스)**에만 의존하게 만든다.
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
# 1. '추상화' (약속)
class MessageSender:
def send(self, message: str):
raise NotImplementedError
# 2. '구체적인' 구현체들
class EmailSender(MessageSender):
def send(self, message: str):
print(f"이메일 발송: {message}")
class SmsSender(MessageSender):
def send(self, message: str):
print(f"SMS 발송: {message}")
# 3. 고수준 클래스
class NotificationService:
# 'MessageSender'라는 '추상적인' 놈에 의존함
def __init__(self, sender: MessageSender):
# 밖에서 주입(Injection) 받음
self.sender = sender
def send_notification(self, message: str):
self.sender.send(message)
# --- 사용할 때 (밖에서 의존성을 '주입'해줌) ---
email_sender = EmailSender()
sms_sender = SmsSender()
# 이메일로 알림 보내기
service1 = NotificationService(email_sender)
service1.send_notification("안녕하세요!")
# SMS로 알림 보내기
service2 = NotificationService(sms_sender)
service2.send_notification("안녕하세요!")
➡️ 왜 이게 좋은가? NotificationService는 자기가 쓰는 게 ‘이메일’인지 ‘SMS’인지 전혀 모른다. 그냥 send()만 호출할 뿐. 그래서 의존성이 ‘역전’되었다. (DIP 준수) 그리고 새 알림 방식(예: SlackSender)이 생겨도 NotificationService는 수정할 필요가 없다. (OCP도 준수)
🔵 마치며
어떤 개념을 외워서 시험을 치르는 것도 중요하지만, 이왕 시간을 투자하는거 자세히 들여다보면 도움이 되는 내용에 대해서는 이런식으로 포스팅할 예정이다.
