Post

[3장] 설계원칙 - SOLID

[3장] 설계원칙 - SOLID

SOLID

SRP (단일 책임 원칙)

  • 하나의 클래스나 모듈은 하나의 책임만 가져야 한다.
  • 클래스와 모듈은 서로 의미가 동일하다.

클래스를 분리해야하는 기준

  • 클래스에 코드, 함수 또는 속성이 너무 많아 코드의 가독성과 유지 보수성에 영향을 미치는 경우
  • 클래스가 너무 과하게 다른 클래스에 의존하는 경우
  • 클래스에 private 메소드가 너무 많은 경우
  • 클래스의 이름을 명확하게 지정하기 어려운 경우(Manager, Context.. 뜨끔😅)
  • 클래스의 많은 메소드나 항목이 일부 로직에서만 작동하는 경우

생각해보기

  • 클래스 외에 단일 책임 원칙을 적용할 수 있는 설계에는 어떤 것들이 있을까

OCP (개방 폐쇠 원칙)

  • 확장에는 열려있고, 변경에는 닫혀있어야 한다.
  • 가장 어렵지만 유용한 원칙

이해하기 어려운 이유

  • 코드를 변경할 때 확장으로 보아야 하는지 수정으로 보아야 하는지 판단하기 어렵기 떄문

유용한 이유

  • 객체지향에서는 확장성이 코드 품질의 중요한 척도이기 때문
  • 디자인 패턴의 대부분은 확장성 문제를 해결하기 위함임

코드를 수정하는 것은 개방 폐쇠 원칙을 위반하는 것일까?

  • 결론만 말하자면 기존 코드를 전혀 수정하지 않는 것은 불가능함
  • 단위테스트를 깨트리지 않는 한 코드를 수정하는 것은 개방 폐쇠 원칙를 위반하는 것이 아님

개방 폐쇠 원칙을 달성하는 방법

  • 코드를 작성할 때 앞으로 요구사항이 추가될 가능성이 있는지 고려해야 함(다형성)
  • 코드의 변경가능한 부분과 변경할 수 없는 부분을 잘 식별해야 함(캡슐화)
  • 코드의 확장성은 코드 품질을 판단하는 중요한 기준임

실무에 적용하기

  • 아래와 같은 경우 확장 포인트를 미리 준비
    • 단기간에 진행할 수 있는 확장
    • 코드 구조 변경에 미치는 영향이 큰 확장
    • 비용이 많이 들지않는 확장
  • 아래와 같은 경우 필요할 때 리펙토링
    • 후에 지원해야 하는 여부가 확실하지 않은 경우
    • 확장이 코드개발에 부하를 주는 경우

생각해보기

  • 개방 폐쇠 원칙이 생겨난 이유가 무엇일까

LSP (리스코프 치환 원칙)

  • 자식 클래스는 부모 클래스에서 가능한 행위를 수행할 수 있어야 한다.

리스코프 치환 원칙과 다형성의 차이점

  • 보기에는 비슷하나 완전히 다른 의미임
  • 리스코프 치환 원칙은 상위 클래스를 대체할 때 프로그램의 원래 논리적 동작이 변경되지 않고 프로그램의 정확성이 손상되지 않도록 해야한다는 의미임(비즈니스 로직의 통일성이 중요)

리스코프 치환 원칙을 위반하는 경우

  • 계약에 따른 설계
    • 하위 클래스를 설계할 때는 상위 클래스의 동작 규칙을 따라야함
    • 하위 클래스는 내부 구현 논리를 변경할 수 있지만 동작 규칙은 변경할 수 없음
    • 동작 규칙에 포함되는 것들
      • 함수의 입출력
      • 예외에 대한 규칙
      • 주석에 나열된 특수 지침

선언한 기능을 위반하는 경우

  • ex> 메소드를 오버라이드해서 완전히 다른 기능을 구현한 경우

입력, 출력 및 예외에 대한 계약을 위반하는 경우

  • ex> 상위 클래스의 메소드가 장애 시 CustomException을 발생시킨다는 계약을 가지고 있지만 하위 클래스에서는 다른 예외를 발생하는 경우

주석에 나열된 특별 지침을 위반하는 경우

  • ex> 상위 클래스에서 선언한 비즈니스로직을 하위 클래스에서 재정의하는 경우
  • 이 경우 상위 클래스의 주석을 수정하는 편이 나음

리스코프 치환 원칙을 확인하는 꿀팁

  • 상위 클래스의 단위 테스트로 하위 클래스의 코드를 확인

생각해보기

  • 리스코프 치환 원칙이 중요한 이유

ISP (인터페이스 분리 원칙)

클라이언트는 필요하지 않은 인터페이스에 의존하도록 강요받지 않아야 한다.

by 로버트 마틴

API나 기능의 집합에서의 인터페이스

설계할 때 인터페이스 또는 기능의 일부가 호출자 중 일부에만 사용되거나 전혀 사용되지 않는다면 불필요한 항목을 강요하는 대신, 인터페이스나 기능에서 해당 부분을 분리하여 해당 호출자에게 별도로 제공해야 하며, 사용하지 않는 인터페이스나 기능에는 접근하지 못하게 해야함

예제코드

1
2
3
4
5
6
7
8
9
10
11
// 일반 사용자는 회원가입, 로그인, 로그아웃만 가능
interface UserService {
  fun register(user: User)
  fun login(user: User)
  fun logout(user: User)
}

// 삭제는 관리자만 가능
interface AdminService {
  fun delete(user: User)
}

단일 API나 기능에서의 인터페이스

  • API나 기능은 가능한 한 단순해야 하며 하나의 기능에 여러 다른 기능 논리를 구현하지 않아야 함
  • 인터페이스 분리 원칙은 단일 책임 원칙과 유사함
    • 호출자가 인터페이스 일부만 사용하는 경우 단일 책임 원칙을 위반하는 것

예제코드

1
2
3
4
5
6
7
8
9
10
11
data class Statistics(
  val count: Int,
  val sum: Int,
  val average: Int
)

fun count(dataSet: Collection<Long>): Statistics {
  val statistics = Statistics()
  // 로직 생략...
  return statistics
}
  • 위 코드에서는 count 함수가 sumaverage를 계산하는 로직을 포함하고 있음
  • 분할 후 코드는 아래와 같음
1
2
3
4
5
6
7
8
9
10
11
fun count(dataSet: Collection<Long>): Int {
  // 로직 생략...
}

fun sum(dataSet: Collection<Long>): Int {
  // 로직 생략...
}

fun average(dataSet: Collection<Long>): Int {
  // 로직 생략...
}

객체지향 프로그래밍에서의 인터페이스

인터페이스 분리 원칙을 지킨 경우

  • 시스템이 Redis, Kafka, MySql 시스템에 연계되어있다고 가정
  • Config 클래스를 구현해야함
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class RedisConfig(
  val configSource: ConfigSource,
  val host: String,
  val port: Int,
  val timeout: Int
) {

  fun getAddress(): String {
    return "$host:$port"
  }

  fun update() {
    // configSource를 통해 Redis의 설정을 업데이트
  }
}

class KafkaConfig(...)

class MySqlConfig(...)
요구사항 : 실시간으로 설정정보를 업데이트 해야함 (Redis, Kafka)
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
interface Updater {
  fun update()
}

class RedisConfig : Updater {
  // 생략...
  override fun update() {
    // configSource를 통해 Redis의 설정을 업데이트
  }
}

class KafkaConfig : Updater {
  // 생략...
  override fun update() {
    // configSource를 통해 Kafka의 설정을 업데이트
  }
}

class MySqlConfig(...)

class ScheduledUpdater(
  val configs: List<Updater>
) {
  @Scheduled(fixedDelay = 60 * 1000)
  fun run() {
    for (config in configs) {
      config.update()
    }
  }
}
요구사항 추가 : 모니터링을 해야함 (Redis, MySql)
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
interface Viewer {
  fun outputInPlainText()
}

class RedisConfig : Updater, Viewer {
  // 생략...
  override fun update() {
    // configSource를 통해 Redis의 설정을 업데이트
  }

  override fun outputInPlainText() {
    // Redis의 설정을 텍스트로 출력
  }
}

class KafkaConfig : Updater {
  // 생략...
  override fun update() {
    // configSource를 통해 Kafka의 설정을 업데이트
  }
}

class MySqlConfig : Viewer {
  // 생략...
  override fun outputInPlainText() {
    // MySql의 설정을 텍스트로 출력
  }
}

class ScheduledUpdater(...)

class ViewerMonitor(
  val host: String,
  val port: Int,
  val viewers: List<Viewer> = mutableListOf()
) {

  fun addViewer(viewer: Viewer) {
    viewers.add(viewer)
  }

  fun run() {
    // 생략...
  }
}

class Application(
  val redisConfig: RedisConfig,
  val kafkaConfig: KafkaConfig,
  val mySqlConfig: MySqlConfig,
) {

  fun main(args: Array<String>) {
    val scheduledUpdater = ScheduledUpdater(listOf(redisConfig, kafkaConfig))
    val viewerMonitor = ViewerMonitor("localhost", 8080, listOf(redisConfig, mySqlConfig))
    viewerMonitor.addViewer(kafkaConfig)
    viewerMonitor.addViewer(mySqlConfig)
    viewerMonitor.run()
  }
}

인터페이스 분리 원칙을 지키지 않는 경우

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
interface Config {
  fun update()
  fun outputInPlainText()
}

class RedisConfig : Config {
  // 생략...
  override fun update() {
    // configSource를 통해 Redis의 설정을 업데이트
  }

  override fun outputInPlainText() {
    // Redis의 설정을 텍스트로 출력
  }
}

class KafkaConfig : Config {
  // 생략...
  override fun update() {
    // configSource를 통해 Kafka의 설정을 업데이트
  }

  override fun outputInPlainText() {
    // Kafka의 설정을 텍스트로 출력
  }
}

class MySqlConfig : Config {
  // 생략...
  override fun update() {
    // configSource를 통해 MySql의 설정을 업데이트
  }

  override fun outputInPlainText() {
    // MySql의 설정을 텍스트로 출력
  }
}

두 코드의 비교

  • 첫 번째 설계는 더 유연하기 때문에 확장성이 높고 재사용이 쉬움
    • ScheduledUpdaterUpdater 인터페이스를 구현하는 객체만 받음
    • 요구사항이 추가되었을 때에도 대응이 쉬움
  • 두 번째 설계는 코드에서 쓸모없는 작업을 수행함
    • KafkaConfig 객체는 outputInPlainText 기능이 필요없음
    • 인터페이스의 밀도가 작은 경우 변경에 따라 수정해야하는 클래스도 줄어듬

생각해보기

  • AtomicInteger 클래스의 getAndIncremnet() 메소드는 인터페이스 분리 원칙을 위반하는가?

DIP (의존 역전 원칙)

의존 역전 원칙은 사용하기는 쉬운 반면 이해하기는 어려움

  • 의존 역전이 뜻하는 것은 어떤 대상 사이의 역전인가? 어떤 의존이 역전되는 것인가? 역전은 무엇을 의미하는가?
  • 제어 반전의존성 주입이라는 두 가지 다른 개념을 접할 수 있는데, 이 개념은 의존 역전과 같은 개념에 속하는가? 아니라면 어떤 차이가 있는가?
  • Spring 의 IoC는 앞에서 언급한 3가지 개념과 어떤 관계가 있는가?

제어 반전

  • 아래 코드는 제어 반전의 일반적인 형태임
  • 프레임워크는 전체 실행흐름을 관리하기 위한 확장 가능한 코드 골격을 제공
  • 프로그래머가 프레임워크를 사용할 때는 제공되는 확장 포인트에 비즈니스 코드를 작성하는 것만으로 프로그램을 완성할 수 있음

예제 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
abstract class TestCase {
  abstract fun doTest()
}

class TestExecutor(
  val testCases: List<TestCase>
) {
  fun execute() {
    for (testCase in testCases) { // 제어의 흐름을 역전시킴
      testCase.doTest()
    }
  }
}
  • 제어 : 프로그램의 실행 흐름을 제어하는 것
  • 역전 : 프로그램의 실행 흐름을 제어하는 권한을 프레임워크에 넘김

의존성 주입

예제 코드

1
2
3
class Notification(
  val messageSender: MessageSender // 의존성 주입
)

의존성 주입 프레임워크

  • 객체 생성 주입은 비즈니스 논리에 속하지 않기 떄문에 프레임워크에 의해 자동으로 완성되는 프레임워크를 의존성 주입 프레임워크라고 함
  • 의존성 주입 프레임워크는 프레임워크가 자동으로 객체를 생성하고, 객체의 라이프사이클을 관리하고, 의존성 주입을 할 수 있음

의존 역전 원칙

  • 상위 모듈은 하위 모듈에 의존하지 않아야 하며, 추상화에 의존하여야 함
  • 또한 추상화가 세부사항에 의존하는 것이 아니라, 세부사항이 추상화에 의존해야 함

생각해보기

  • 인터페이스 기반과 의존성 주입은 서로 유사한데, 두 개념의 차이점은 무엇일까?
This post is licensed under CC BY 4.0 by the author.