SOLID 원칙은 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 나타냅니다.
"클래스는 변경할 이유가 하나만 있어야 한다"는 개념을 강조합니다. 즉, 클래스는 하나의 핵심 기능에 집중해야 하며, 여러 가지 책임을 가지면 안 됩니다. 이는 코드의 유지보수성을 높이고, 변경 사항이 다른 기능에 미치는 영향을 최소화합니다.
안드로이드 앱에서 이 원칙을 적용할 때, 예를 들어 네트워크 통신, 데이터베이스 작업, UI 업데이트 등을 별도의 클래스로 분리할 수 있습니다.
나쁜 예시
// 이 예시에서 UserManager 클래스는 사용자 등록,
// 데이터베이스 저장, 이메일 전송 등 여러 책임을 가지고 있습니다.
class UserManager {
fun registerUser(user: User) {
// 사용자 등록 로직
saveToDatabase(user)
sendWelcomeEmail(user)
}
private fun saveToDatabase(user: User) {
// 데이터베이스 저장 로직
}
private fun sendWelcomeEmail(user: User) {
// 이메일 전송 로직
}
}
좋은 예시
// 이 예시에서는 각 클래스가 단일 책임을 가지며,
// UserManager는 이들을 조정하는 역할만 합니다.
class UserRegistration {
fun registerUser(user: User) {
// 사용자 등록 로직
}
}
class UserDatabase {
fun saveUser(user: User) {
// 데이터베이스 저장 로직
}
}
class EmailService {
fun sendWelcomeEmail(user: User) {
// 이메일 전송 로직
}
}
// 사용 예
class UserManager(
private val registration: UserRegistration,
private val database: UserDatabase,
private val emailService: EmailService
) {
fun registerNewUser(user: User) {
registration.registerUser(user)
database.saveUser(user)
emailService.sendWelcomeEmail(user)
}
}
"소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 한다"는 개념입니다. 이는 기존 코드를 변경하지 않고도 새로운 기능을 추가할 수 있어야 함을 의미합니다.
안드로이드에서 이 원칙을 적용할 때, 인터페이스나 추상 클래스를 사용하여 확장 가능한 구조를 만들 수 있습니다.
나쁜 예시
// 이 예시에서는 새로운 결제 방식을 추가할 때마다
// PaymentProcessor 클래스를 수정해야 합니다.
class PaymentProcessor {
fun processPayment(paymentType: String, amount: Double) {
when (paymentType) {
"CREDIT_CARD" -> processCreditCardPayment(amount)
"PAYPAL" -> processPayPalPayment(amount)
// 새로운 결제 방식을 추가할 때마다 이 메서드를 수정해야 함
}
}
private fun processCreditCardPayment(amount: Double) {
// 신용카드 결제 처리
}
private fun processPayPalPayment(amount: Double) {
// PayPal 결제 처리
}
}
좋은 예시
// 이 예시에서는 새로운 결제 방식을 추가할 때 PaymentMethod 인터페이스를
// 구현하는 새 클래스만 만들면 되며, 기존 코드를 수정할 필요가 없습니다.
interface PaymentMethod {
fun processPayment(amount: Double)
}
class CreditCardPayment : PaymentMethod {
override fun processPayment(amount: Double) {
// 신용카드 결제 처리
}
}
class PayPalPayment : PaymentMethod {
override fun processPayment(amount: Double) {
// PayPal 결제 처리
}
}
class PaymentProcessor {
fun processPayment(paymentMethod: PaymentMethod, amount: Double) {
paymentMethod.processPayment(amount)
}
}
// 새로운 결제 방식 추가
class CryptoPayment : PaymentMethod {
override fun processPayment(amount: Double) {
// 암호화폐 결제 처리
}
}
"프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다"는 개념입니다. 즉, 상위 클래스의 인스턴스를 하위 클래스의 인스턴스로 대체해도 프로그램이 여전히 정확하게 동작해야 합니다.
안드로이드에서 이 원칙을 적용할 때, 특히 상속 관계에서 주의해야 합니다.
나쁜 예시
// 이 예시에서 Square는 Rectangle의 행동을 위반합니다.
// Rectangle의 너비와 높이를 독립적으로 설정할 수 있어야 하는데,
// Square는 그렇지 않습니다.
open class Rectangle {
open var width: Int = 0
open var height: Int = 0
open fun setWidth(width: Int) {
this.width = width
}
open fun setHeight(height: Int) {
this.height = height
}
fun area(): Int = width * height
}
class Square : Rectangle() {
override fun setWidth(width: Int) {
super.setWidth(width)
super.setHeight(width)
}
override fun setHeight(height: Int) {
super.setHeight(height)
super.setWidth(height)
}
}
좋은 예시
// 이 예시에서는 Rectangle과 Square가 공통 인터페이스 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
}
"클라이언트가 자신이 사용하지 않는 인터페이스에 의존하도록 강제해서는 안 된다"는 개념입니다. 큰 인터페이스를 더 작고 구체적인 여러 인터페이스로 분리하여 클라이언트가 필요한 메서드만 알 수 있도록 합니다.
안드로이드에서 이 원칙을 적용할 때, 특히 리사이클러뷰의 어댑터나 프래그먼트 인터페이스 등에서 유용하게 사용할 수 있습니다.
나쁜 예시
// 이 예시에서 Robot은 먹거나 잠잘 필요가 없지만, Worker 인터페이스를
// 구현하기 위해 불필요한 메서드를 구현해야 합니다.
interface Worker {
fun work()
fun eat()
fun sleep()
}
class Human : Worker {
override fun work() { /* 일하기 */ }
override fun eat() { /* 먹기 */ }
override fun sleep() { /* 잠자기 */ }
}
class Robot : Worker {
override fun work() { /* 일하기 */ }
override fun eat() { /* 불필요한 구현 */ }
override fun sleep() { /* 불필요한 구현 */ }
}
좋은 예시
// 이 예시에서는 인터페이스가 분리되어 있어 Robot은 필요한
// Workable 인터페이스만 구현하면 됩니다.
interface Workable {
fun work()
}
interface Eatable {
fun eat()
}
interface Sleepable {
fun sleep()
}
class Human : Workable, Eatable, Sleepable {
override fun work() { /* 일하기 */ }
override fun eat() { /* 먹기 */ }
override fun sleep() { /* 잠자기 */ }
}
class Robot : Workable {
override fun work() { /* 일하기 */ }
}
"고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다"는 개념입니다. 또한 "추상화는 세부 사항에 의존해서는 안 되며, 세부 사항이 추상화에 의존해야 한다"고 말합니다.
안드로이드에서 이 원칙을 적용할 때, 의존성 주입(Dependency Injection)을 사용하여 구현할 수 있습니다. Dagger나 Hilt 같은 라이브러리를 사용하거나, 간단한 경우 생성자 주입을 통해 구현할 수 있습니다.
나쁜 예시
// 이 예시에서 NotificationService는 구체적인 EmailService 클래스에
// 직접 의존하고 있어, 다른 알림 방식으로 변경하기 어렵습니다.
class EmailService {
fun sendEmail(message: String) {
// 이메일 전송 로직
}
}
class NotificationService {
private val emailService = EmailService()
fun sendNotification(message: String) {
emailService.sendEmail(message)
}
}
좋은 예시
// 이 예시에서 NotificationService는 추상화된 MessageService 인터페이스에 의존하며,
// 구체적인 구현(이메일 또는 SMS)은 런타임에 주입됩니다.
// 이로써 새로운 알림 방식을 쉽게 추가할 수 있고, 기존 코드 수정 없이
// 알림 방식을 변경할 수 있습니다.
interface MessageService {
fun sendMessage(message: String)
}
class EmailService : MessageService {
override fun sendMessage(message: String) {
// 이메일 전송 로직
}
}
class SMSService : MessageService {
override fun sendMessage(message: String) {
// SMS 전송 로직
}
}
class NotificationService(private val messageService: MessageService) {
fun sendNotification(message: String) {
messageService.sendMessage(message)
}
}
이러한 SOLID 원칙들을 안드로이드 개발에 적용하면 코드의 유지보수성, 확장성, 테스트 용이성이 크게 향상됩니다. 각 컴포넌트의 책임을 명확히 하고, 느슨한 결합을 유지하며, 재사용 가능한 모듈을 만들 수 있습니다.