티스토리 뷰
객체 지향 프로그래밍(OOP)을 하다 보면 이런 고민을 하게 된다.
- "코드를 이렇게 짜도 되나...?"
- "이 클래스가 너무 많은 걸 하는 거 같은데..."
- "나중에 유지보수할 때 문제 생기면 어떡하지?"
사실 OOP는 "잘" 사용하면 확장성도 좋고 유지보수도 쉬운 코드가 되지만, "잘못" 사용하면 의존성이 꼬여서 유지보수하기 힘든 코드가 되어버린다.
그래서 등장한 게 SOLID 원칙이다.
이 원칙을 따르면 더 유연하고, 변경에 강한 객체 지향 설계를 할 수 있다.
근데 솔직히 처음 접하면 이게 왜 필요한지, 실제 코드에서 어떻게 적용해야 하는지 감이 잘 안 온다.
오늘은 예제 코드와 함께, "진짜 실무에서 어떻게 쓰면 좋은지" 고민해보자!
SOLID 원칙이란?
SOLID는 객체 지향 설계에서 중요한 5가지 원칙을 의미한다.
| 원칙 | 의미 |
| S | 단일 책임 원칙 (Single Responsibility Principle) |
| O | 개방-폐쇄 원칙 (Open/Closed Principle) |
| L | 리스코프 치환 원칙 (Liskov Substitution Principle) |
| I | 인터페이스 분리 원칙 (Interface Segregation Principle) |
| D | 의존 역전 원칙 (Dependency Inversion Principle) |
이 원칙들을 하나씩 살펴보면서,
왜 필요한지, 그리고 실제로 어떻게 적용해야 하는지 고민해보자.
1. 단일 책임 원칙 (SRP - Single Responsibility Principle)
"하나의 클래스는 하나의 책임만 가져야 한다"
❌ 잘못된 예시 (SRP 위반)
class ReportManager {
func generateReport() {
print("리포트 생성")
}
func saveToFile() {
print("파일로 저장")
}
func sendEmail() {
print("이메일 전송")
}
}
이 클래스는 리포트 생성, 파일 저장, 이메일 전송까지 너무 많은 일을 하고 있다.
만약 이메일 전송 방식을 바꾸려면? 이 클래스까지 수정해야 한다.
✅ SRP 적용 (책임을 분리)
class ReportGenerator {
func generate() {
print("리포트 생성")
}
}
class FileSaver {
func save(_ report: String) {
print("파일로 저장")
}
}
class EmailSender {
func send(_ report: String) {
print("이메일 전송")
}
}
각 클래스가 한 가지 일만 담당하도록 분리했다.
이제 이메일 전송 방식을 바꾸더라도 EmailSender만 수정하면 된다!
2. 개방-폐쇄 원칙 (OCP - Open/Closed Principle)
"기능을 확장할 수 있어야 하지만, 기존 코드를 수정하면 안 된다"
확장에는 열려 있고, 수정에는 닫혀 있어야 한다.라는 원칙인데
이 원칙을 지키면, 기존 코드를 건드리지 않고 새로운 기능을 추가할 수 있다.
❌ 잘못된 예시 (OCP 위반)
class DiscountCalculator {
func calculate(price: Double, type: String) -> Double {
if type == "Student" {
return price * 0.9
} else if type == "VIP" {
return price * 0.8
}
return price
}
}
새로운 할인 정책이 생길 때마다 calculate 함수를 계속 수정해야 한다.
✅ OCP 적용 (확장 가능하도록 리팩토링)
protocol DiscountStrategy {
func apply(price: Double) -> Double
}
class StudentDiscount: DiscountStrategy {
func apply(price: Double) -> Double {
return price * 0.9
}
}
class VIPDiscount: DiscountStrategy {
func apply(price: Double) -> Double {
return price * 0.8
}
}
class DiscountCalculator {
func calculate(price: Double, strategy: DiscountStrategy) -> Double {
return strategy.apply(price: price)
}
}
// 새로운 할인 정책이 필요하면 새로운 클래스를 추가하면 된다!
이제 새로운 할인 정책이 필요해도 기존 코드를 수정할 필요 없이, 새로운 클래스를 추가하면 된다.
OCP 원칙을 적용하면 유지보수가 훨씬 쉬워진다.
3. 리스코프 치환 원칙 (LSP - Liskov Substitution Principle)
"부모 클래스의 인스턴스를 자식 클래스로 교체해도 코드가 정상적으로 동작해야 한다"
자식 클래스는 부모 클래스를 대체할 수 있어야 한다.
즉, 상속을 올바르게 사용해야 한다는 의미다. 잘못하면 오히려 상속이 독이 된다. 😱
❌ 잘못된 예시 (LSP 위반)
class Bird {
func fly() {
print("날아간다!")
}
}
class Penguin: Bird {
override func fly() {
fatalError("펭귄은 날지 못해!")
}
}
"펭귄은 날지 못하는데, Bird를 상속받으면 fly()를 호출할 수 있어야 한다"
이건 논리적으로 잘못됐다.
✅ LSP 적용 (상속 대신 프로토콜 사용)
protocol Bird {
func move()
}
class FlyingBird: Bird {
func move() {
print("날아간다!")
}
}
class SwimmingBird: Bird {
func move() {
print("헤엄친다!")
}
}
let birds: [Bird] = [FlyingBird(), SwimmingBird()]
for bird in birds {
bird.move()
}
이제 펭귄이 이상하게 동작하는 문제를 방지할 수 있다!
LSP 원칙을 지키려면, 상속보다 프로토콜을 활용하는 게 좋다.
4. 인터페이스 분리 원칙 (ISP - Interface Segregation Principle)
"클라이언트가 사용하지 않는 기능에 의존하지 않아야 한다"
즉, 필요한 기능만 제공하는 인터페이스(프로토콜)를 만들어야 한다.
❌ 잘못된 예시 (ISP 위반)
protocol Worker {
func work()
func attendMeeting()
}
class Developer: Worker {
func work() {
print("코딩 중...")
}
func attendMeeting() {
print("회의 중... (하지만 가고 싶지 않다)")
}
}
개발자는 코딩만 하고 싶을 수도 있는데, 강제로 attendMeeting()을 구현해야 한다.
✅ ISP 적용 (필요한 기능만 제공)
protocol Workable {
func work()
}
protocol Attending {
func attendMeeting()
}
class Developer: Workable {
func work() {
print("코딩 중...")
}
}
class Manager: Workable, Attending {
func work() {
print("관리 중...")
}
func attendMeeting() {
print("회의 참석 중...")
}
}
이제 필요한 기능만 선택적으로 구현할 수 있다!
5. 의존성 역전 원칙 (DIP - Dependency Inversion Principle)
"고수준 모듈이 저수준 모듈에 의존하면 안 된다. 대신, 둘 다 추상화(인터페이스)에 의존해야 한다"
쉽게 말해, 구체적인 구현이 아니라, 인터페이스(프로토콜)에 의존해야 한다!
✅ DIP 적용 (의존성을 추상화)
protocol Database {
func save(data: String)
}
class MySQLDatabase: Database {
func save(data: String) {
print("MySQL에 데이터 저장")
}
}
class DataManager {
let database: Database
init(database: Database) {
self.database = database
}
func saveData(data: String) {
database.save(data: data)
}
}
let database = MySQLDatabase()
let manager = DataManager(database: database)
manager.saveData(data: "Hello, DIP!")
이제 DB를 바꿔도 DataManager를 수정할 필요가 없다.
정리 – SOLID 원칙, 꼭 지켜야 할까?
솔직히, 모든 코드에 SOLID를 강박적으로 적용할 필요는 없다.
하지만, 유지보수가 어려워질 때마다 "SOLID 원칙을 더 잘 지킬 방법이 없을까?" 고민해보면 좋다.
이제 실무에서 코드를 볼 때,
"이거 SOLID 원칙 위반 아닌가?" 하는 감이 좀 생기지 않나?
'프로그래밍 공부' 카테고리의 다른 글
| ARC(Automatic Reference Counting) – Swift는 어떻게 메모리를 관리할까? (1) | 2025.02.20 |
|---|---|
| Swift – iOS 개발자가 알아야 할 모든 것 (2) | 2025.02.19 |
| 함수형 프로그래밍(FP) – 진짜 왜 써야 할까? (0) | 2025.02.17 |
| 반응형 프로그래밍(React Programming, RP) – 데이터를 흐름처럼 다루는 방식 (0) | 2025.02.15 |
| 프로토콜 지향 프로그래밍 (POP, Protocol-Oriented Programming) (0) | 2025.02.13 |