SOLID란?
로버트 C. 마틴(“아저씨”라고도 불리는 Uncle Bob)이 정립한 객체지향 프로그래밍 설계 원칙들의 집합인 SOLID는 유지보수성과 확장성을 높이는 데 도움을 줍니다.
아래 다섯 가지 원칙은 각각 단일 책임 원칙 (SRP), 개방-폐쇄 원칙 (OCP), 리스코프 치환 원칙 (LSP), 인터페이스 분리 원칙 (ISP), 의존 역전 원칙 (DIP) 입니다.
목차
- 단일 책임 원칙 (SRP)
- 개방-폐쇄 원칙 (OCP)
- 리스코프 치환 원칙 (LSP)
- 인터페이스 분리 원칙 (ISP)
- 의존 역전 원칙 (DIP)
1. 단일 책임 원칙 (Single Responsibility Principle, SRP)
“클래스(또는 모듈)는 단 하나의 책임만 가져야 한다.”
한 클래스가 여러 책임을 지면, 변경될 이유가 여러 군데에서 생겨 유지보수가 어려워집니다.
예를 들어, 한 클래스가 사용자 정보를 조회하고, DB 처리도 하고, 화면 UI까지 렌더링하는 기능을 전부 처리한다면,
이 클래스가 변경될 이유(책임)가 너무 많아집니다. 이를 여러 클래스로 분할하여 각각 명확한 책임을 지도록 설계하는 것이 좋습니다.
SRP 예시 (Kotlin)
(1) 위반된 코드 예시
class UserManager {
// 1. 사용자 정보를 DB에서 읽고
fun getUser(userId: String): String {
// DB 로직
println("DB에서 사용자 정보를 가져옵니다.")
return "User($userId)"
}
// 2. 사용자 정보를 화면(UI)에 표시하고
fun displayUser(user: String) {
// UI 로직
println("화면에 '$user' 정보를 표시합니다.")
}
// 3. 사용자 정보 변경 시 로그도 기록한다
fun logUserChange(userId: String) {
// 로그 로직
println("사용자($userId) 변경 로그를 기록합니다.")
}
}
UserManager
클래스가 DB 액세스, UI 표시, 로그 기록까지 전부 맡고 있습니다.- 변경될 이유가 여러 군데(예: DB 구조 변경, UI 요구사항 변경, 로그 포맷 변경)라서 유지보수가 어렵습니다.
(2) 개선된 코드 예시
// 책임 1: 사용자 조회(DB, Repository 등)
class UserRepository {
fun getUser(userId: String): String {
println("DB에서 사용자 정보를 가져옵니다.")
return "User($userId)"
}
}
// 책임 2: 사용자 정보 UI 표시
class UserView {
fun displayUser(user: String) {
println("화면에 '$user' 정보를 표시합니다.")
}
}
// 책임 3: 로그 기록
class UserLogger {
fun logUserChange(userId: String) {
println("사용자($userId) 변경 로그를 기록합니다.")
}
}
// SRP를 만족하는 UserManager
class UserManager(
private val userRepository: UserRepository,
private val userView: UserView,
private val userLogger: UserLogger
) {
fun showUser(userId: String) {
val user = userRepository.getUser(userId)
userView.displayUser(user)
}
fun changeUser(userId: String) {
// 사용자 정보를 변경하는 로직이 있다고 가정
userLogger.logUserChange(userId)
}
}
- 각 클래스가 “하나의 책임”만 담당하도록 분리하였습니다.
- 변경될 이유가 명확히 나뉘어져 있으므로 코드가 더욱 깔끔해지고 유지보수하기 쉬워집니다.
2. 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
“소프트웨어 요소는 확장에 대해 열려 있어야 하고, 변경에 대해서는 닫혀 있어야 한다.”
새로운 기능 추가나 요구사항 변경 시, 기존 코드를 최소한으로 수정(또는 전혀 수정하지 않음)하면서도 새로운 기능을 유연하게 추가할 수 있어야 합니다.
예를 들어, 새로운 결제 방식을 추가할 때 기존 코드를 전부 수정하기보다는,
추상화(인터페이스, 추상 클래스 등)를 통해 기존 코드 변경 없이 확장만으로 기능을 추가하도록 만들어야 합니다.
OCP 예시 (Kotlin)
(1) 위반된 코드 예시
class PaymentService {
fun pay(paymentType: String, amount: Int) {
when (paymentType) {
"CARD" -> println("신용카드 결제: $amount 원")
"CASH" -> println("현금 결제: $amount 원")
// 새로운 결제 방식("PAYPAL", "COUPON" 등)이 생길 때마다
// 여기서 코드를 계속 수정해야 합니다.
else -> println("알 수 없는 결제 방식입니다.")
}
}
}
- 새로운 결제 방식을 추가할 때마다
PaymentService
내부 로직(when
분기)을 수정해야 합니다. - 이는 수정에 “열려” 있어서 OCP를 위반합니다.
(2) 개선된 코드 예시
// 1. 확장을 위한 추상화
interface Payment {
fun pay(amount: Int)
}
// 2. 구체적인 구현들
class CardPayment : Payment {
override fun pay(amount: Int) {
println("신용카드 결제: $amount 원")
}
}
class CashPayment : Payment {
override fun pay(amount: Int) {
println("현금 결제: $amount 원")
}
}
// 새로운 결제 방식이 필요하면, Payment 인터페이스만 구현하면 됨
class PaypalPayment : Payment {
override fun pay(amount: Int) {
println("PayPal 결제: $amount 원")
}
}
// 3. PaymentService는 추상화에 의존
class PaymentService {
fun pay(payment: Payment, amount: Int) {
payment.pay(amount)
}
}
PaymentService
는Payment
라는 인터페이스에만 의존하므로, 새로운 결제 방식을 도입해도 기존 코드를 수정할 필요가 없습니다.- 확장에는 열려(Open), 수정에는 닫혀(Close) 있는 구조가 됩니다.
3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
“서브타입은 언제나 자신의 기반 타입(base type)으로 교체해 사용해도 정상적으로 동작해야 한다.”
하위 클래스(자식 클래스)는 상위 클래스(부모 클래스)가 규정하는 계약(인터페이스, 메서드 시그니처, 동작의 결과 등)을 깨뜨리지 않도록 재정의해야 합니다.
예를 들어, “사각형(Rectangle)을 상속받는 정사각형(Square)” 모델은
폭과 높이가 항상 같은 정사각형의 특성 때문에, 사각형이 갖는 일반 setter 로직을 재정의하면 기대치와 맞지 않는 결과가 발생할 수 있습니다.
이는 LSP 위반의 전형적인 예시입니다.
LSP 예시 (Kotlin)
(1) 위반된 코드 예시
open class Rectangle {
open var width: Int = 0
open var height: Int = 0
fun area(): Int = width * height
}
class Square : Rectangle() {
override var width: Int
get() = super.width
set(value) {
super.width = value
super.height = value
}
override var height: Int
get() = super.height
set(value) {
super.width = value
super.height = value
}
}
fun main() {
val rectangle: Rectangle = Square()
rectangle.width = 4
rectangle.height = 5
// 기대하는 값: 4 * 5 = 20
// 실제 값: width,height 모두 5가 되어 area = 25
println("Area: ${rectangle.area()}") // Area: 25
}
Square
가Rectangle
로 치환되었을 때, “사각형”이라는 일반 개념과는 다른 동작(폭과 높이가 항상 동일)이 발생해 계약을 위배하게 됩니다.
(2) 개선된 코드 방향
- “정사각형”을 “사각형”의 단순 하위 개념으로 취급하기보다, 별도의 구조로 설계하는 방법이 있습니다.
- 예를 들어, 아예
Rectangle
을open
으로 하지 않고, 공통 인터페이스Shape
를 두어 각각 독립적으로 동작하게 만들 수 있습니다.
interface Shape {
fun area(): Int
}
class Rectangle(private val width: Int, private val height: Int) : Shape {
override fun area(): Int = width * height
}
class Square(private val side: Int) : Shape {
override fun area(): Int = side * side
}
Rectangle
과Square
는 이제 Shape라는 공통 인터페이스만 구현하므로 서로 간섭이 없습니다.- “치환” 문제도 자연스럽게 사라집니다.
4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
“클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강제되어서는 안 된다.”
인터페이스가 지나치게 커지면, 일부 구현체에겐 불필요한 메서드까지 강제 구현해야 하는 문제가 생깁니다.
예를 들어, “멀티 기능(프린트, 스캔, 팩스, 이메일 등) 기기”를 위한 거대 인터페이스 하나만 만들면,
간단한 기능만 필요한 구현체도 모든 메서드를 구현해야 하므로 문제가 됩니다.
ISP 예시 (Kotlin)
(1) 위반된 코드 예시
interface MultiFunctionDevice {
fun print(document: String)
fun scan(document: String): String
fun fax(document: String)
fun email(document: String)
// ...
}
class SimplePrinter : MultiFunctionDevice {
override fun print(document: String) {
println("문서를 출력합니다: $document")
}
override fun scan(document: String): String {
// 불필요하지만 어쩔 수 없이 구현
println("스캔 기능은 지원하지 않습니다.")
return ""
}
override fun fax(document: String) {
println("팩스 기능은 지원하지 않습니다.")
}
override fun email(document: String) {
println("이메일 전송은 지원하지 않습니다.")
}
}
SimplePrinter
에는 스캔, 팩스, 이메일 기능이 필요 없음에도, 인터페이스 때문에 전부 구현해야 합니다.
(2) 개선된 코드 예시
interface Printer {
fun print(document: String)
}
interface Scanner {
fun scan(document: String): String
}
interface Fax {
fun fax(document: String)
}
interface EmailSender {
fun email(document: String)
}
class SimplePrinter : Printer {
override fun print(document: String) {
println("문서를 출력합니다: $document")
}
}
class AllInOneMachine : Printer, Scanner, Fax, EmailSender {
override fun print(document: String) {
println("문서를 출력합니다: $document")
}
override fun scan(document: String): String {
println("문서를 스캔합니다: $document")
return "Scanned($document)"
}
override fun fax(document: String) {
println("문서를 팩스로 전송합니다: $document")
}
override fun email(document: String) {
println("문서를 이메일로 전송합니다: $document")
}
}
- 기능별로 인터페이스를 분리해, 필요 없는 메서드에 의존하거나 구현할 필요가 없어졌습니다.
- 복합 기능 기기가 필요한 경우에는 여러 인터페이스를 조합하면 됩니다.
5. 의존 역전 원칙 (Dependency Inversion Principle, DIP)
“고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.”
비즈니스 로직(고수준)이 인프라나 구현 세부사항(저수준)에 종속되면, 이를 교체하거나 확장하기가 어려워집니다.
예를 들어, 이메일 서비스를 변경할 때마다 비즈니스 로직 코드를 전부 수정해야 한다면,
이는 “구현 세부사항에 직접 의존”하고 있기 때문입니다. 추상화를 통한 의존 역전으로 문제를 해결할 수 있습니다.
DIP 예시 (Kotlin)
(1) 위반된 코드 예시
class NaverMailService {
fun sendMail(address: String, message: String) {
println("Naver Mail로 $address 에게 메일을 보냅니다. 내용: $message")
}
}
class OrderService {
private val mailService = NaverMailService()
fun order(item: String, email: String) {
println("$item 주문 완료")
mailService.sendMail(email, "주문이 완료되었습니다.")
}
}
OrderService
(고수준 모듈)가 특정 저수준 구현(NaverMailService
)에 직접 의존하고 있습니다.- 만약
GmailService
로 바꾸려면OrderService
코드를 수정해야 하므로, 확장성이 떨어집니다.
(2) 개선된 코드 예시
// 1. 고수준과 저수준이 공통으로 의존할 추상화
interface MailService {
fun sendMail(address: String, message: String)
}
class NaverMailService : MailService {
override fun sendMail(address: String, message: String) {
println("Naver Mail로 $address 에게 메일을 보냅니다. 내용: $message")
}
}
class GmailService : MailService {
override fun sendMail(address: String, message: String) {
println("Gmail로 $address 에게 메일을 보냅니다. 내용: $message")
}
}
// 2. OrderService는 추상화(MailService)에만 의존
class OrderService(private val mailService: MailService) {
fun order(item: String, email: String) {
println("$item 주문 완료")
mailService.sendMail(email, "주문이 완료되었습니다.")
}
}
fun main() {
// 사용하는 쪽에서 어떤 구현체를 쓸지 결정 (DI)
val naverMailService = NaverMailService()
val gmailService = GmailService()
val orderService1 = OrderService(naverMailService)
orderService1.order("상품A", "user@example.com")
val orderService2 = OrderService(gmailService)
orderService2.order("상품B", "user@example.com")
}
OrderService
는MailService
라는 인터페이스에 의존하여, 저수준 구현(NaverMailService
,GmailService
)이 바뀌어도 고수준 로직을 수정할 필요가 없습니다.- 이처럼 의존 역전 원칙을 지키면 확장과 유지보수가 훨씬 수월해집니다.
마무리 정리
- 단일 책임 원칙 (SRP)
- 클래스(또는 모듈)는 한 가지 책임(변경 이유)만 가져야 한다.
- 개방-폐쇄 원칙 (OCP)
- 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.
- 리스코프 치환 원칙 (LSP)
- 하위 클래스는 상위 타입으로 치환해도 계약을 위배하지 말아야 한다.
- 인터페이스 분리 원칙 (ISP)
- 클라이언트가 사용하지 않는 메서드에 의존하지 않도록, 인터페이스를 잘게 분리해야 한다.
- 의존 역전 원칙 (DIP)
- 고수준 모듈과 저수준 모듈 모두 추상화에 의존해야 한다.
이 다섯 가지 원칙을 잘 지키면, 코드가 유연하고 유지보수하기 쉬워집니다. 프로젝트가 커질수록 SOLID 원칙은 더욱 중요해지므로, 실제 개발 과정에서 꾸준히 적용해보는 것이 좋습니다.
'객체지향' 카테고리의 다른 글
객체지향의 5대 원칙 (SOLID) (0) | 2022.08.06 |
---|