(Java/Kotlin) SOLID - SRP 단일 책임 원칙 완전 정리
개요
- SOLID 원칙 중 첫 번째 SRP(Single Responsibility Principle) 를 다룹니다.
- SRP는 가장 간단해 보이지만, 실무에서 가장 많이 위반되는 원칙이기도 합니다.
- 이 글에서는 다음을 설명합니다.
- SRP가 정확히 무엇인지
- “책임(Responsibility)”의 올바른 의미
- 위반 사례와 개선 방법
- Android / Kotlin 실전 예제
1. 정의
하나의 클래스는 변경해야 할 이유가 단 하나여야 한다.
— Robert C. Martin (Clean Code 저자)
클래스를 변경해야 하는 이유 = 책임의 수
- 클래스가 여러 책임을 가지면 → 한 기능의 변경이 다른 기능에 영향을 줌
- 클래스가 하나의 책임만 가지면 → 변경 범위가 명확하고 영향 최소화
2. “책임”이란 무엇인가
“책임”은 변경의 이유(actor, 관계자) 로 이해하는 것이 정확합니다.
책임 ≠ 단순히 메서드의 수
책임 = 누가 이 코드의 변경을 요청하는가
예를 들어 UserManager 클래스가 아래 기능을 모두 가진다면:
| 기능 | 변경 요청자 |
|---|---|
| 사용자 정보 저장 | 백엔드 팀 (DB 변경 시) |
| 비밀번호 암호화 | 보안 팀 (암호화 정책 변경 시) |
| 이메일 발송 | 마케팅 팀 (메일 양식 변경 시) |
| 로그 기록 | 운영 팀 (로그 포맷 변경 시) |
→ 4개의 팀이 같은 클래스를 변경할 이유가 있음 = 4개의 책임 = SRP 위반
3. 위반 사례 — Before
❌ 하나의 클래스가 너무 많은 일을 하는 경우
class UserManager {
// 1. 사용자 데이터 처리
fun saveUser(user: User) {
val encrypted = encrypt(user.password)
database.save(user.copy(password = encrypted))
}
fun getUser(id: String): User {
return database.find(id)
}
// 2. 비밀번호 암호화
private fun encrypt(password: String): String {
return MessageDigest.getInstance("SHA-256")
.digest(password.toByteArray())
.joinToString("") { "%02x".format(it) }
}
// 3. 이메일 발송
fun sendWelcomeEmail(user: User) {
val subject = "회원가입을 축하합니다"
val body = "안녕하세요, ${user.name}님!"
EmailClient.send(user.email, subject, body)
}
// 4. 로그 기록
fun logUserActivity(user: User, action: String) {
val log = "[${LocalDateTime.now()}] ${user.id} - $action"
LogWriter.write(log)
}
}
문제점:
- DB 구조가 바뀌면 →
UserManager수정 - 암호화 알고리즘이 바뀌면 →
UserManager수정 - 이메일 내용이 바뀌면 →
UserManager수정 - 로그 포맷이 바뀌면 →
UserManager수정 - 한 기능을 수정하다가 다른 기능에 버그가 생길 수 있음
4. 개선 — After
✅ 각 책임을 별도 클래스로 분리
// 1. 사용자 데이터 처리만 담당
class UserRepository(private val database: Database) {
fun save(user: User) = database.save(user)
fun findById(id: String): User = database.find(id)
}
// 2. 암호화만 담당
class PasswordEncryptor {
fun encrypt(password: String): String {
return MessageDigest.getInstance("SHA-256")
.digest(password.toByteArray())
.joinToString("") { "%02x".format(it) }
}
}
// 3. 이메일 발송만 담당
class WelcomeEmailSender {
fun send(user: User) {
val subject = "회원가입을 축하합니다"
val body = "안녕하세요, ${user.name}님!"
EmailClient.send(user.email, subject, body)
}
}
// 4. 로그 기록만 담당
class UserActivityLogger {
fun log(user: User, action: String) {
val log = "[${LocalDateTime.now()}] ${user.id} - $action"
LogWriter.write(log)
}
}
// 조합해서 사용 (상위 레벨에서 조율)
class UserService(
private val repository: UserRepository,
private val encryptor: PasswordEncryptor,
private val emailSender: WelcomeEmailSender,
private val logger: UserActivityLogger
) {
fun registerUser(user: User) {
val securedUser = user.copy(password = encryptor.encrypt(user.password))
repository.save(securedUser)
emailSender.send(securedUser)
logger.log(securedUser, "회원가입")
}
}
개선 효과:
- 암호화 정책이 바뀌면 →
PasswordEncryptor만 수정 - DB가 바뀌면 →
UserRepository만 수정 - 이메일 양식이 바뀌면 →
WelcomeEmailSender만 수정 - 각 클래스 단위로 테스트 가능
5. Android 실전 예제
❌ SRP 위반 — Activity가 너무 많은 일을 함
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// UI 설정
setContentView(R.layout.activity_main)
val button = findViewById<Button>(R.id.btnLogin)
button.setOnClickListener {
val id = etId.text.toString()
val pw = etPw.text.toString()
// 유효성 검사
if (id.isEmpty() || pw.isEmpty()) {
Toast.makeText(this, "아이디/비밀번호를 입력하세요", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
// 네트워크 요청
RetrofitClient.api.login(id, pw).enqueue(object : Callback<LoginResponse> {
override fun onResponse(call: Call<LoginResponse>, response: Response<LoginResponse>) {
if (response.isSuccessful) {
// SharedPreferences 저장
getSharedPreferences("prefs", MODE_PRIVATE).edit()
.putString("token", response.body()?.token)
.apply()
startActivity(Intent(this@MainActivity, HomeActivity::class.java))
}
}
override fun onFailure(call: Call<LoginResponse>, t: Throwable) {
Toast.makeText(this@MainActivity, "로그인 실패", Toast.LENGTH_SHORT).show()
}
})
}
}
}
Activity의 책임이 너무 많습니다:
- UI 렌더링
- 입력 유효성 검사
- 네트워크 요청
- 토큰 저장 (로컬 저장소)
- 화면 전환
✅ SRP 적용 — 책임 분리
// 1. 유효성 검사만 담당
class LoginValidator {
fun validate(id: String, password: String): String? {
if (id.isEmpty()) return "아이디를 입력하세요"
if (password.isEmpty()) return "비밀번호를 입력하세요"
if (password.length < 6) return "비밀번호는 6자 이상이어야 합니다"
return null // null = 유효
}
}
// 2. 토큰 저장만 담당
class TokenStorage(private val context: Context) {
private val prefs by lazy {
context.getSharedPreferences("auth_prefs", Context.MODE_PRIVATE)
}
fun saveToken(token: String) = prefs.edit().putString("token", token).apply()
fun getToken(): String? = prefs.getString("token", null)
fun clearToken() = prefs.edit().remove("token").apply()
}
// 3. 로그인 비즈니스 로직만 담당 (ViewModel)
class LoginViewModel(
private val validator: LoginValidator,
private val tokenStorage: TokenStorage,
private val authRepository: AuthRepository
) : ViewModel() {
private val _loginResult = MutableLiveData<Result<Unit>>()
val loginResult: LiveData<Result<Unit>> = _loginResult
fun login(id: String, password: String) {
val error = validator.validate(id, password)
if (error != null) {
_loginResult.value = Result.failure(Exception(error))
return
}
viewModelScope.launch {
authRepository.login(id, password)
.onSuccess { response ->
tokenStorage.saveToken(response.token)
_loginResult.value = Result.success(Unit)
}
.onFailure {
_loginResult.value = Result.failure(it)
}
}
}
}
// 4. Activity는 UI 처리만 담당
class MainActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnLogin.setOnClickListener {
viewModel.login(etId.text.toString(), etPw.text.toString())
}
viewModel.loginResult.observe(this) { result ->
result
.onSuccess {
startActivity(Intent(this, HomeActivity::class.java))
}
.onFailure { e ->
Toast.makeText(this, e.message, Toast.LENGTH_SHORT).show()
}
}
}
}
각 클래스의 책임:
| 클래스 | 책임 | 변경 이유 |
|---|---|---|
LoginValidator |
입력 유효성 검사 | 검증 규칙이 바뀔 때 |
TokenStorage |
토큰 저장/불러오기 | 저장 방식이 바뀔 때 |
LoginViewModel |
로그인 흐름 제어 | 비즈니스 로직이 바뀔 때 |
MainActivity |
UI 렌더링 & 이벤트 처리 | 화면 디자인이 바뀔 때 |
6. SRP 위반을 감지하는 신호
아래 신호들이 보이면 SRP 위반을 의심해 보세요.
✔ 클래스 이름에 And, Manager, Helper, Util이 남용된다
예) UserAndOrderManager, DataFetchAndSaveHelper
✔ 메서드가 10개 이상이고 서로 연관성이 낮다
✔ 하나의 수정이 전혀 관계없어 보이는 다른 기능 테스트를 실패시킨다
✔ 클래스를 설명하려면 "그리고(And)"를 써야 한다
예) "이 클래스는 유저를 저장하고, 이메일도 보내고, 로그도 남깁니다"
✔ 하나의 클래스를 여러 팀원이 동시에 수정하는 충돌이 자주 난다
7. 자주 하는 오해
❌ “메서드가 하나면 SRP를 지킨 것이다”
// 메서드가 하나지만 여러 책임이 섞여 있음
class DataProcessor {
fun process(data: String): String {
val validated = data.trim().takeIf { it.isNotEmpty() } ?: throw Exception("빈 데이터")
val parsed = JSONObject(validated) // 파싱
val saved = database.save(parsed.toString()) // 저장
return EmailClient.send(saved.id) // 발송
}
}
→ 메서드 수가 아니라 변경 이유의 수가 기준입니다.
❌ “클래스를 무조건 작게 쪼개면 된다”
// 과도한 분리 — 오히려 복잡도가 올라감
class UserNameGetter { fun getName(user: User) = user.name }
class UserEmailGetter { fun getEmail(user: User) = user.email }
class UserAgePrinter { fun print(age: Int) = println(age) }
→ 의미 없이 쪼개면 클래스 간 관계 파악이 더 어려워집니다.
“변경 이유”가 같은 것들은 함께 묶어도 됩니다.
8. SRP와 테스트 용이성
SRP를 지키면 단위 테스트가 훨씬 쉬워집니다.
// SRP가 적용된 LoginValidator는 독립 테스트 가능
class LoginValidatorTest {
private val validator = LoginValidator()
@Test
fun `아이디가 비어있으면 오류 반환`() {
val result = validator.validate("", "password123")
assertEquals("아이디를 입력하세요", result)
}
@Test
fun `비밀번호가 6자 미만이면 오류 반환`() {
val result = validator.validate("user01", "123")
assertEquals("비밀번호는 6자 이상이어야 합니다", result)
}
@Test
fun `유효한 입력은 null 반환`() {
val result = validator.validate("user01", "password123")
assertNull(result)
}
}
- 네트워크, DB, UI 없이도 독립적으로 테스트할 수 있습니다.
- 책임이 분리되어 있으므로 Mock 없이도 테스트 가능한 경우가 많습니다.
9. 정리
| 항목 | 내용 |
|---|---|
| 정의 | 클래스를 변경해야 하는 이유가 단 하나여야 한다 |
| 책임의 기준 | 변경을 요청하는 관계자(Actor)의 수 |
| 위반 신호 | And/Manager/Helper 남용, 메서드 과다, 충돌 빈번 |
| 핵심 효과 | 변경 범위 최소화, 테스트 용이성, 유지보수성 향상 |
| 주의 사항 | 무조건 쪼개는 것이 아닌 “변경 이유” 기준으로 분리 |
- SRP는 단순히 “작게 만들어라”가 아니라 “변경 이유를 하나로 모아라” 입니다.
- 클래스가 왜 변경되는지를 항상 생각하며 설계하는 것이 SRP의 핵심입니다.
참고
- Clean Code — Robert C. Martin
- SOLID 전체 포스팅 보기