소개
SOLID는 로버트 C. 마틴(Robert C. Martin)이 소개한 객체 지향 설계의 다섯 가지 원칙을 나타내는 두문자어 약어로, 그는 흔히 엉클 밥이라 불립니다. 이 원칙들은 소프트웨어 디자이너, 아키텍트, 엔지니어, 개발자가 더 유연하고 유지보수하기 쉬우며 확장 가능한 소프트웨어를 만들 수 있도록 돕기 위한 것입니다. 이 원칙들을 따르면 테스트, 리팩터링, 재사용, 확장이 더 쉬운 클래스를 설계할 수 있습니다.
SOLID 약어는 다음을 의미합니다:
S – 단일 책임 원칙
O – 개방-폐쇄 원칙
L – 리스코프 치환 원칙
I – 인터페이스 분리 원칙
D – 의존역전 원칙
이 글에서는 각 원칙이 더 나은 코드를 작성하는 데 어떻게 도움이 되는지 이해하기 위해 개별적으로 설명할 것입니다. 또한, 코딩 여정에서 이를 어떻게 적용할 수 있는지 보여주는 코드 스니펫을 각 원칙마다 추가하고, 클린 코드 아키텍처에서 피해야 할 사항도 함께 다룰 것입니다. 개념을 설명하기 위해, JetBrains와 오픈소스 기여자들이 개발한 Kotlin 프로그래밍 언어를 사용할 것입니다.
단일 책임 원칙
단일 책임 원칙(SRP)은 프로그램의 모든 클래스나 모듈이 단 하나의 잘 정의된 책임을 가져야 한다는 소프트웨어 설계 원칙입니다. 이는 클래스가 변경되어야 하는 이유가 단 하나여야 하며, 프로그램 기능의 단 한 부분만 책임져야 함을 의미합니다.
이 원칙이 다음 언어에서 어떻게 적용될 수 있는지 보여주는 예로 아래 코드 스니펫을 참고하세요: Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Kotlin의 data 키워드는 이 클래스가 데이터 클래스임을 나타내며, 이는 데이터를 보유하기 위한 것이고 복잡한 동작을 가지지 않음을 의미합니다. 이 데이터 클래스를 사용하여 아래와 같이 사용자를 관리하기 위한 UserService 클래스를 생성할 수 있습니다:
|
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 |
class UserService { fun createUser(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } fun deleteUser(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } fun updateUser(user: User) { // 데이터베이스에서 사용자 정보를 업데이트하는 코드 } fun getUser(id: Int): User { // 데이터베이스에서 사용자 정보를 조회하는 코드 } } |
이 예제에서 UserService 클래스는 데이터베이스에서 사용자를 관리하는 단일 책임을 가집니다. 각 사용자는 이전에 공유한 data class User 코드로 표현됩니다. UserService 클래스의 모든 메서드는 이 책임과 관련이 있으므로 응집력이 있습니다. 클래스의 모든 메서드가 단일의 잘 정의된 작업과 관련되어 있음이 명확하므로 클래스를 더 쉽게 이해하고 유지보수할 수 있습니다.
A method that violates the Single Responsibility Principle (SRP) in the UserService 클래스에서 단일 책임 원칙(SRP)을 위반하는 메서드는 클래스의 기본 책임(데이터베이스의 사용자 관리)과 관련이 없는 메서드일 것입니다. 예를 들어, 다음과 같이 변형된 UserService 클래스를 고려해 보세요:
|
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 |
class UserService { fun createUser(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } fun deleteUser(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } fun updateUser(user: User) { // 데이터베이스에서 사용자를 업데이트하는 코드 } fun getUser(id: Int): User { // 데이터베이스에서 사용자를 조회하는 코드 } fun sendEmail(user: User, subject: String, message: String) { // 사용자에게 이메일을 보내는 코드 } } |
이 예제에서 sendEmail 메서드는 UserService 클래스의 주요 책임인 데이터베이스 내 사용자 관리와 관련이 없습니다. 이 메서드는 이메일 전송을 담당하며, 이는 사용자 관리와는 별개의 관심사입니다. 결과적으로 이 메서드는 UserService 클래스가 변경되어야 할 두 번째 이유를 제공하므로 SRP를 위반합니다.
단일 책임 원칙을 준수하려면 이메일 전송 기능을 다음과 같은 별도의 클래스로 분리하는 것이 좋습니다. EmailService 클래스입니다. 이렇게 하면 UserService 클래스는 사용자 관리라는 주요 책임에 집중할 수 있고, EmailService 클래스는 이메일 전송이라는 책임에 집중할 수 있습니다.
단일 책임 원칙은 클래스가 가진 메서드의 수가 아니라, 메서드들의 응집도와 클래스 내부의 명확한 책임 분리에 관한 것임을 유의해야 합니다.
개방-폐쇄 원칙
개방-폐쇄 원칙(OCP)은 소프트웨어 개체(클래스, 모듈, 함수 등)가 확장에 대해서는 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다는 소프트웨어 설계 원칙입니다. 즉, 기존 코드를 변경하지 않고도 클래스나 모듈에 새로운 기능을 추가할 수 있어야 함을 의미합니다.
이전 예제의 UserService 클래스를 생각해 보겠습니다. 이메일 주소로 사용자를 검색할 수 있는 새로운 기능을 UserService 클래스에 추가하고 싶다고 가정해 봅시다. 이를 수행하는 한 가지 방법은 아래 코드에 강조 표시된 것처럼 UserService 클래스에 새로운 메서드를 추가하는 것입니다.
|
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 |
class UserService { fun createUser(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } fun deleteUser(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } fun updateUser(user: User) { // 데이터베이스에서 사용자를 업데이트하는 코드 } fun getUser(id: Int): User { // 데이터베이스에서 사용자를 조회하는 코드 } fun searchUsersByEmail(email: String): List<User> { // 이메일 주소로 사용자를 검색하는 코드 } } |
이 방법은 작동하지만, 새로운 기능을 추가하기 위해 기존 UserService 클래스를 수정해야 했기 때문에 개방-폐쇄 원칙을 위반합니다. 더 나은 방법은 상속이나 구성을 사용하여 기존 코드를 수정하지 않고 UserService 클래스의 기능을 확장하는 것입니다.
이를 달성하기 위해, UserSearchService 클래스를 생성하여 UserService 클래스를 확장하고 이메일 검색 기능을 추가할 수 있습니다.
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // 이메일 주소로 사용자를 검색하는 코드 } } |
이 예제에서 UserSearchService 클래스는 에서 제공하는 기능 외에 추가적인 기능을 제공하므로 확장에 열려 있습니다.UserService class. At the same time, the UserService 클래스는 이메일 검색 기능을 추가하기 위해 코드를 수정할 필요가 없었으므로 수정에 대해 닫혀 있는 상태를 유지합니다.
리스코프 치환 원칙
리스코프 치환 원칙(LSP)은 프로그램의 정확성을 해치지 않으면서 상위 클래스의 객체를 하위 클래스의 객체로 대체할 수 있어야 한다는 소프트웨어 설계 원칙입니다. 이는 하위 클래스가 상위 클래스의 유효한 대체재여야 하며, 동일한 컨텍스트에서 사용될 때 상위 클래스와 동일하게 동작해야 함을 의미합니다.
이전 예제의 User 및 UserService 클래스를 사용하여 데모를 계속 진행하겠습니다. User 클래스를 확장할 수 있도록 Kotlin에서 open 키워드를 사용합니다:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
위의 UserService 데이터 클래스를 사용하는 원래의 User 클래스입니다:
|
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 |
class UserService { fun createUser(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } fun deleteUser(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } fun updateUser(user: User) { // 데이터베이스에서 사용자 정보를 업데이트하는 코드 } fun getUser(id: Int): User { // 데이터베이스에서 사용자 정보를 조회하는 코드 } } |
관리 권한이 있는 사용자를 나타내기 위해 User의 하위 클래스인 AdminUser를 생성하고자 한다고 가정해 보겠습니다. 다음과 같이 작성할 수 있습니다:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
이 예제에서 AdminUser 클래스는 User 클래스의 유효한 대체재입니다. User 클래스와 동일하게 동작하며, User 객체가 필요한 모든 곳에 사용될 수 있기 때문입니다. 예를 들어, 다음과 같이 AdminUser 클래스를 UserService 클래스와 함께 사용할 수 있습니다:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
이 코드는 올바릅니다. AdminUser 클래스가 User 클래스의 유효한 대체재이며, User 객체와 동일한 방식으로 사용될 수 있기 때문입니다.
리스코프 치환 원칙은 단순한 상속 그 이상에 관한 것임을 유의하는 것이 중요합니다. 이는 하위 클래스가 어떻게 구현되었는지에 관계없이 하위 클래스의 객체가 상위 클래스의 객체와 동일한 방식으로 동작하도록 보장하는 것입니다. 예를 들어, 만약 AdminUser 클래스가 어떤 방식으로든 User 클래스와 다르게 동작한다면, 이는 User 클래스의 유효한 대체재가 되지 못하므로 리스코프 치환 원칙을 위반하게 됩니다.
인터페이스 분리 원칙
인터페이스 분리 원칙(ISP)은 클라이언트가 자신이 사용하지 않는 인터페이스에 의존하도록 강제되어서는 안 된다는 소프트웨어 설계 원칙입니다. 이는 여러 가지 일을 하려고 하는 크고 다목적인 인터페이스를 만들기보다, 한 가지 일을 잘 수행하는 작고 집중된 인터페이스를 만드는 것이 일반적으로 좋은 방법임을 의미합니다.
이전 예제의 User 및 UserService 클래스를 재작성하여 이 원칙을 적용하는 방법의 예는 다음과 같습니다:
|
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 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } override fun deleteUser(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } override fun updateUser(user: User) { // 데이터베이스에서 사용자 정보를 업데이트하는 코드 } override fun getUser(id: Int): User { // 데이터베이스에서 사용자 정보를 조회하는 코드 } } |
이 예제에서 UserService 인터페이스는 데이터베이스의 사용자 관리와 관련된 네 개의 메서드를 정의합니다. DatabaseUserService 클래스는 이 인터페이스를 구현하고 이러한 메서드에 대한 구체적인 구현을 제공합니다.
만약 UserService 인터페이스에 이메일 주소로 사용자를 검색할 수 있는 새로운 기능을 추가하고 싶다고 가정해 봅시다. 이를 수행하는 한 가지 방법은 UserService 인터페이스에 새로운 메서드를 추가하는 것입니다:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
이 메서드를 구현하지 않으면 코드가 실행되지 않습니다. 대상은 UserService 인터페이스를 구현하는 모든 클래스입니다:
|
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 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } override fun deleteUser(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } override fun updateUser(user: User) { // 데이터베이스에서 사용자 정보를 업데이트하는 코드 } override fun getUser(id: Int): User { // 데이터베이스에서 사용자 정보를 조회하는 코드 } override fun searchUsersByEmail(email: String): List<User> { // 이메일 주소로 사용자를 검색하는 코드 } } |
이 방법은 작동하긴 하지만, 인터페이스 분리 원칙(Interface Segregation Principle)을 위반합니다. 왜냐하면 DatabaseUserService 클래스가 필요하지 않거나 사용하지 않을 수도 있는 메서드( searchUsersByEmail )를 강제로 구현하도록 만들기 때문입니다.
더 나은 방법은 이메일 검색 기능을 위한 별도의 인터페이스 를 만드는 것입니다:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
이제 우리는 분리되고 작으며 집중된 인터페이스를 갖게 되었습니다. 즉, UserService 및 UserSearchService단일 책임을 가집니다. 이러한 인터페이스의 모든 기능이 필요한 클래스는 아래 코드 조각에 표시된 대로 구현할 수 있습니다:
|
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 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } override fun deleteUser(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } override fun updateUser(user: User) { // 데이터베이스에서 사용자 정보를 업데이트하는 코드 } override fun getUser(id: Int): User { // 데이터베이스에서 사용자 정보를 조회하는 코드 } override fun searchUsersByEmail(email: String): List<User> { // 이메일 주소로 사용자를 검색하는 코드 } } |
이는 인터페이스 분리 원칙을 준수하는 것으로, 클라이언트(예: DatabaseUserService 클래스)가 사용하지 않는 인터페이스에 강제로 의존하지 않도록 보장합니다.
이 개념을 더 잘 이해하기 위해, 다음과 같은 또 다른 클래스가 있다고 가정해 보겠습니다. MemoryUserService 이 클래스는 다음 인터페이스를 구현하지만 UserService 이메일 검색 기능은 필요하지 않습니다. 코드를 다음과 같이 작성할 수 있습니다:
|
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 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
이 예제에서 MemoryUserService 클래스는 다음에 정의된 메서드만 구현하면 되며, UserService 인터페이스의 이메일 검색 기능에 대해서는 신경 쓸 필요가 없습니다. 이를 통해 MemoryUserService 클래스는 관련 없는 기능을 강제로 구현하는 대신, 메모리에서 사용자를 관리하는 기본 책임에 집중할 수 있습니다.
의존 역전 원칙
의존 역전 원칙(DIP)은 고수준 모듈이 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다는 소프트웨어 설계 원칙입니다. 이는 일반적으로 고수준 구성 요소가 저수준 구성 요소의 구체적인 구현에 결합되지 않고, 다양한 방식으로 구현될 수 있는 추상화(인터페이스 또는 추상 클래스 등)에 의존하도록 소프트웨어를 설계하는 것이 좋다는 것을 의미합니다.
이 원칙이 이전 코드 조각에서 사용된 User 및 UserService 클래스에 어떻게 적용될 수 있는지 예제를 살펴보겠습니다:
|
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 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
이 예제에서 UserService 클래스는 UserRepository 인터페이스)에 의존하며, 사용자 리포지토리의 구체적인 구현에 의존하지 않습니다. 이를 통해 우리는 UserRepository 인터페이스를 데이터베이스나 인메모리 저장소를 사용하는 등 다양한 방식으로 구현할 수 있으며, 이 과정에서 UserService 클래스에는 영향을 주지 않습니다.
예를 들어, 다음은 UserRepository 인터페이스를 데이터베이스를 사용하여 구현한 예시입니다:
|
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 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // 데이터베이스에 사용자를 생성하는 코드 } override fun delete(user: User) { // 데이터베이스에서 사용자를 삭제하는 코드 } override fun update(user: User) { // 데이터베이스에서 사용자 정보를 업데이트하는 코드 } override fun get(id: Int): User { // 데이터베이스에서 사용자 정보를 조회하는 코드 } } |
다음은 UserRepository 인터페이스를 인메모리 저장소를 사용하여 구현한 또 다른 예시입니다:
|
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 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
이를 통해 시스템의 나머지 부분에 영향을 주지 않고 리포지토리 구현을 변경할 수 있으므로, 시스템이 더 유연해지고 유지보수하기 쉬워집니다. 또한 테스트에서 UserRepository 의존성을 모킹할 수 있으므로 UserService 클래스를 테스트하기가 더 쉬워집니다.
결론
이 글에서는 SOLID 각 원칙을 충족하는 코드 및 공유 코드 스니펫입니다. 이러한 원칙을 준수하면 더 유연하고 유지 관리하기 쉬우며 확장 가능한 소프트웨어 시스템을 설계하는 데 도움이 될 수 있습니다. 하지만 이러한 원칙은 엄격한 규칙이라기보다는 가이드라인이며, 특정 프로젝트의 맥락에서 이를 언제 어떻게 적용할지 결정하는 것은 개발자의 몫이라는 점을 명심하는 것이 중요합니다. 저희 블로그에서 더 심도 있고 최신 정보가 담긴 기사와 튜토리얼 (주제: 클라우드 컴퓨팅 및 DevOps, 소프트웨어 설계 및 개발, 주목해야 할 기술 트렌드 등)을 확인하며 학습을 이어가 보세요.
즐거운 코딩 하세요!
댓글
아직 댓글이 없습니다. 첫 번째로 작성해 보세요.