Computer Science/객체지향

SRP(단일 책임 원칙) in SOLID

sh1mj1 2024. 12. 31. 02:24

소개

SOLID란?

SOLID 는 객체지향 설계를 더 이해하기 쉽고, 유연하며, 유지보수하기 쉽게 만드는 다섯 가지 원칙을 말합니다.
이 원칙들은 시스템을 확장 가능하고, 견고하며, 변경에 적응할 수 있도록 설계하도록 안내합니다.

하지만 이러한 원칙을 이해하는 것은 실용적인 예제가 없으면 어렵게 느껴질 수 있습니다.

왜 이 시리즈를 작성했나요?

이 시리즈의 목적은 간단합니다:

  1. SOLID 원칙이 어떻게 위반되는지를 명확히 설명합니다.
  2. 각 원칙을 효과적으로 준수하는 방법을 리팩토링 과정을 통해 보여줍니다.

SRP(단일 책임 원칙)이란?

SRP(Single Responsibility Principle)는 객체지향 설계 원칙의 기초로, 다음을 의미합니다:

  • 모듈은 오직 하나의 책임만 가져야 한다.
  • 모듈은 변경해야 하는 이유가 단 하나여야 한다.

더 간단히 말하면,
각 클래스, 함수, 또는 모듈은 하나의 책임에만 집중해야 하며,
이를 통해 유지보수와 확장이 쉬워지고 테스트도 간단해집니다.

그럼 이 원칙이 실제로 어떻게 적용되는지 살펴보겠습니다.

SRP를 위반하면 어떻게 될까요?

예제: SRP 위반

아래의 `LottoSeller` 클래스는 로또를 판매하는 책임을 가지고 있습니다.
처음에는 단일 책임처럼 보이지만, 실제로는 여러 역할을 수행하고 있습니다:

  • 돈과 가격을 바탕으로 로또 개수를 계산
  • 로또 번호를 무작위로 생성
  • 생성된 로또 번호를 검증

아래는 SRP를 위반한 코드입니다:

class LottoSeller {
    fun soldLotto(money: Int): List<Lottery> {
        val count = money / LOTTO_PRICE

        val lotteries = (1..count)
            .map { Lottery((lottoRange).shuffled().take(6).sorted()) }

        lotteries.forEach { validate(it) }

        return lotteries
    }

    private fun validate(lottery: Lottery) {
        require(lottery.numbers.size == LOTTO_NUMBER_COUNT) { "Invalid lotto number count" }
        lottery.numbers.forEach {
            require(it in MIN_LOTTO_NUMBER..MAX_LOTTO_NUMBER) { "Invalid lotto number" }
        }
    }

    companion object {
        private const val LOTTO_PRICE = 1000
        private const val MIN_LOTTO_NUMBER = 1
        private const val MAX_LOTTO_NUMBER = 45
        private const val LOTTO_NUMBER_COUNT = 6

        private val lottoRange = (MIN_LOTTO_NUMBER..MAX_LOTTO_NUMBER)
    }
}

data class Lottery(val numbers: List<Int>)

문제점은 무엇인가요?

`LottoSeller` 클래스는 과도한 책임을 가지고 있습니다:

  1. 검증 로직 변경:
    • 로또 번호 검증 방식이 바뀌면 `LottoSeller` 클래스를 수정해야 합니다.
  2. 번호 생성 전략 변경:
    • 로또 번호를 생성하는 방식이 변경되면 `LottoSeller` 클래스를 수정해야 합니다.
  3. 가격 정책 변경:
    • 로또 가격 정책이 바뀌어도 `LottoSeller`를 수정해야 합니다.

이러한 결합(coupling)은 코드를 취약하고 유지보수하기 어렵게 만듭니다.
또한, 각각의 책임(기능)이 독립적으로 테스트되지 않습니다.

SRP에 맞게 리팩토링하기

SRP를 준수하려면 각각의 책임을 별도 클래스로 분리해야 합니다.

리팩토링된 코드

class LottoSeller {
    fun soldLotto(money: Int): List<Lottery> {
        val count = money / LOTTO_PRICE
        return List(count) { LotteryGenerateStrategy().autoGenerate() }
    }

    companion object {
        private const val LOTTO_PRICE = 1000
    }
}

data class Lottery(val numbers: List<Int>) {
    init {
        numbers.forEach {
            require(numbers.size == NUMBER_COUNT) { "Invalid lotto number count" }
            require(it in MIN_NUMBER..MAX_NUMBER) { "Invalid lotto number" }
        }
    }

    companion object {
        private const val MIN_NUMBER = 1
        private const val MAX_NUMBER = 45
        val numberRange = (MIN_NUMBER..MAX_NUMBER)
        const val NUMBER_COUNT = 6
    }
}

class LotteryGenerateStrategy {
    fun autoGenerate(): Lottery =
        Lottery(
            (Lottery.numberRange).shuffled().take(Lottery.NUMBER_COUNT).sorted(),
        )
}

무엇이 개선되었나요?

  1. LottoSeller의 역할 축소:
    • 이제 `LottoSeller`는 오직 로또를 판매하는 역할만 담당합니다.
  2. 번호 생성 전략 캡슐화:
    • `LotteryGenerateStrategy` 클래스는 로또 번호 생성 로직을 캡슐화합니다.
    • 새로운 생성 전략(e.g., `ManualLottoGenerateStrategy`)을 쉽게 추가할 수 있습니다.
  3. 검증 로직의 이동:
    • 로또 번호 검증은 이제 Lottery 클래스에서 처리되며, 검증 책임이 명확해졌습니다.
  4. 테스트 가능성 향상:
    • 각각의 책임이 독립적으로 분리되어, 기능별 테스트가 용이해졌습니다.
    • 예를 들어, 번호 생성 전략이나 검증 로직을 따로 테스트할 수 있습니다.

결론

SRP를 준수함으로써:

  • 코드는 더 이해하기 쉽고 유지보수하기 쉬워집니다.
  • 하나의 기능이 변경되더라도 다른 기능에 불필요한 영향을 주지 않습니다.
  • 개별 구성요소를 독립적으로 테스트할 수 있어 신뢰성이 향상됩니다.

SRP는 객체지향 설계의 가장 기본적이지만 가장 중요한 원칙입니다.
여러분의 프로젝트에서도 SRP를 적용해보세요!
코드 품질, 유지보수성, 그리고 테스트 용이성에서 확실한 차이를 느낄 수 있을 것입니다.

 

다음 글은?

다음 글에서는 SOLID 원칙 중 두 번째인 **OCP(Open-Closed Principle)**에 대해 다뤄보겠습니다.