(Java/Kotlin) SOLID - SRP 단일 책임 원칙 완전 정리

개요


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)
    }
}

문제점:


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, "회원가입")
    }
}

개선 효과:


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의 책임이 너무 많습니다:


✅ 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)
    }
}

9. 정리

항목 내용
정의 클래스를 변경해야 하는 이유가 단 하나여야 한다
책임의 기준 변경을 요청하는 관계자(Actor)의 수
위반 신호 And/Manager/Helper 남용, 메서드 과다, 충돌 빈번
핵심 효과 변경 범위 최소화, 테스트 용이성, 유지보수성 향상
주의 사항 무조건 쪼개는 것이 아닌 “변경 이유” 기준으로 분리

참고



Related Posts