객체 지향 설계(SOLID)

o_jooon_·2024년 2월 6일
0

CS

목록 보기
4/6
post-thumbnail
post-custom-banner

OOP에 이어 SOLID도 포스팅 해보았습니다.


SOLID란?

SOLID는 객체 지향 프로그래밍(OOP) 및 설계의 다섯 가지 기본 원칙

  • SOLID 원칙은 소프트웨어 작업에서 프로그래머가 소스 코드가 읽기 쉽고 확장하기 쉽게 될 때까지 소스 코드를 리팩토링하기 위한 지침이다.
  • SOLID 원칙은 특정 프로그래밍 언어 혹은 프레임워크를 위한 원칙이 아니다.
    → OOP를 지원하는 모든 언어에 적용할 수 있다.

SOLID의 5개 원칙

  • SOLID는 SRP, OCP, LSP, ISP, DIP 5가지의 원칙을 말한다.

SRP(Single Responsibility Principle)란?

SRP는 단일 책임 원칙으로, 하나의 클래스하나의 책임만 가져야 한다는 원칙

  • 여기서 책임은 기능 담당을 의미한다고 할 수 있다.
  • 즉, 하나의 클래스는 하나의 기능을 담당하고 수행하는 데에 집중되어 있어야 함을 말한다.

SRP를 준수하는 이유

  • 책임(기능)을 여러 클래스에 분산하여 유지보수를 쉽게 할 수 있다.
    → 하나의 클래스에 여러 가지 기능이 있는 경우, 그 역할을 수행하는 코드끼리 강하게 결합될 가능성이 높아져 시스템이 복잡해 질 수 있다.
  • 코드를 수정해야 하는 경우, 해당 책임(기능)을 가진 클래스만 수정하면 된다.
    → 코드의 수정(기능 변경)이 일어난 경우, 여러 기능을 가져 강한 결합을 가진 클래스는 하나의 기능을 수정 할 때 그 기능과 관련된 부분의 코드를 모두 수정해야 하는 번거로움이 생길 수 있다.
  • 각 클래스는 하나의 책임(기능)을 가지고 있기 때문에 코드의 가독성이 향상된다.

SRP 적용의 주의점

  1. 클래스명은 책임의 소재를 알 수 있도록 작명해야 한다.
    → 클래스가 하나의 책임을 가지고 있음을 나타애기 위해, 해당 클래스가 어떤 기능을 담당하는지 작명하여 가독성을 향상시킬 수 있다.
  2. 책임을 분리할 경우, 결합도응집도를 고려한다.
    → 응집도란 한 프로글매 요소가 얼마나 뭉쳐있는가를 나타내는 척도이고
    결합도는 프로그램 구성 요소들 사이가 얼마나 의존적인지를 나타내는 척도이다.
    → 좋은 설계는 응집도는 높게, 결합도는 낮게 설계해야 한다.
    따라서, 책임을 나눌 때엔 각 책임간의 결합도를 최소로 하도록 코드를 작성해야 한다.
  3. 하나의 책임이 여러 클래스로 분산되어 있는 경우도 고려한다.
    → 책임을 분산한다고 해서 너무 많이 분산하는 경우, 하나의 책임이 여러 클래스에 분산될 수도 있다.
    이 경우, 각 클래스가 가진 동일한 책임을 따로 분리하여 클래스로 관리해야 한다.

SRP의 예시(Swift)

특정 데이터를 생성을 보고하고 그 보고서를 저장하는 경우
→ 예시이기 때문에 기본적인 틀만 작성하였습니다.

  • 위반
// Report는 두 가지의 책임을 가지고 있다.
// 1: 데이터 생성 보고, 2: 보고서 저장

class Report {
    var data: String
    
    init(data: String) {
        self.data = data
    }
    
    func generateReport() {
        // 데이터 생성 보고
        print("Generating report with data: \(data)")
    }
    
    func saveToFile() {
        // 데이터가 생성되었음을 알리는 보고서 저장
        print("Saving report to file")
    }
}
  • 준수
// Report는 데이터 생성 알림이라는 하나의 책임을 가지고 있고,
// ReportSaver는 데이터 저장이라는 하나의 책임을 가지고 있다.

class Report {
    var data: String
    
    init(data: String) {
        self.data = data
    }
    
    func generateReport() {
        // 데이터 생성 보고
        print("Generating report with data: \(data)")
    }
}

class ReportSaver {
    func saveToFile(report: Report) {
        // 데이터가 생성되었음을 알리는 보고서 저장
        print("Saving report to file")
    }
}

OCP(Open Closed Principle)란?

OCP는 개방 폐쇄 원칙으로, 소프트웨어 개체(클래스, 함수 등)는 확장에 대해 열려있어야 하고 수정에 대해 닫혀있어야 한다는 원칙

  • 확장(기능 추가)에 대해 열려있어야 함은 기능을 추가할 수 있도록 설계해야 함이다.
  • 수정에 대해 닫혀있어야 함은 기능을 수정할 경우 기존의 코드를 변경하지 않도록 설계해야 함이다.
  • 즉, OCP는 기능을 추가할 때 확장을 통해 손쉽게 구현하면서, 확장에 따른 클래스 수정은 최소화 하도록 설계해야 한다는 원칙이다.
  • OCP는 OOP의 대표적인 특징 중 하나인 추상화를 의미한다고 보면 되기 때문에, 다형성과 확장을 가능하게 하는 객체 지향의 장점을 극대화하는 원칙이다.

OCP를 준수하는 이유

  • 새로운 기능을 추가하는 경우, 기존의 코드는 변경하지 않고 해당 기능만 추가하면 되기 때문에 유연한 확장이 가능하여 유지보수가 쉽다.

OCP 적용의 주의점

  1. 변경(확장)될 수 있는 것과 변하지 않는 것을 엄격히 구분해야 한다.
  2. 이 두 가지의 모듈이 만나는 지점에 추상화(클래스 또는 인터페이스)를 정의한다.
  3. 구현체에 의존하기 보다는 정의한 추상화에 의존하도록 코드를 작성한다.

OCP의 예시(Swift)

새로운 유형의 객체를 생성하는 경우

  • 위반
// 새로운 멤버가 추가되는 경우, 기존에 있던 speakName() 내부의 switch문 또한 수정해야 한다.

enum Member {
	case Jun
	case Jung
	case Hyeok
	case Won
}

class Person {
	let name: Member

	init(name: Member) {
		self.name = name
	}
}

func speakName(person: Person) {
	switch person.name {
		case .Jun:
			print("Hi, I'm Jun.")
		case .Jung:
			print("Hi, I'm Jung.")
		case .Hyeok:
			print("Hi, I'm Hyeok.")
		case .Won:
			print("Hi, I'm Won.")
	}
}
  • 준수
// 새로운 멤버가 추가되는 경우, 기존에 있던 Member 프로토콜을 채택한 struct만 새로 만들면 된다.

protocol Member {
	var name: String { get }
}

struct Jun: Member {
	let name: String = "Jun"
}

struct Jung: Member {
	let name: String = "Jung"
}

struct Hyeok: Member {
	let name: String = "Hyeok"
}

struct Won: Member {
	let name: String = "Won"
}

class Person {
	let name: Member

	init(name: Member) {
		self.name = name
	}
}

func speakName(person: Person) {
	print("Hi, I'm \(person.name).")
}

LSP(Liskov Substitution Principle)란?

LSP는 리스코프 치환 원칙으로, 부모 유형의 객체가 자식 유형의 객체로 치환(교체)되어도 프로그램이 문제 없이 실행. 즉, 자식 유형의 객체부모 유형의 객체의 기능의도대로 실행해야 한다는 원칙

  • 유형이라고 표현한 이유는 오직 클래스만 상속을 하는 것이 아니기 때문이다.
    → Swift의 경우, protocol을 활용해서도 LSP를 준수할 수 있다.
  • 부모 객체를 호출하는 동작이 존재하는 경우, 자식 객체가 부모 객체를 완전히 대체할 수 있어야 한다.
  • LSP는 OOP의 대표적인 특징 중 하나인 다형성과 같은 개념이다.

LSP를 준수하는 이유

  • 부모 유형과 자식 유형이 서로 대체될 수 있기 때문에 유지보수하기 쉽다.
    → 기존 코드를 수정하지 않고 새로운 자식 유형을 추가하여 시스템을 쉽게 확장할 수 있다.
  • LSP를 준수하지 않으면 다형성이 분해되어 OOP와 맞지 않는 방향으로 개발을 하게될 수 있다.
    → 준수하는 경우, 코드가 공통 인터페이스를 통해 다른 클래스의 객체와 함께 작동된다.

LSP 적용의 주의점

  1. 모든 상황에서 LSP를 적용해야 하는 것은 아니다.
    → 성능을 중요시하는 코드에서 개발자들은 효율성을 위해 LSP를 희생하는 경우도 있다.
  2. 추상화가 잘 정의되지 않거나 상속 계층을 제대로 구성되지 않으면 LSP를 준수하기 어려워진다.
  3. 상속을 적절하게 사용하여 복잡한 계층 구조를 갖는 코드를 피한다.
  4. 자식 클래스에서 메서드를 오버라이딩 할 때, 자식 클래스의 인스턴스가 슈퍼클래스의 인스턴스로 대체될 수 있어야 한다.
    → 자식 클래스에서 부모 클래스의 메서드를 오버라이딩 하면서 자식 클래스 고유의 기능을 추가하고 싶다면, 부모 클래스에서 예상한 동작이 유지되면서 새로운 기능을 추가해야 한다.

LSP의 예시(Swift)

직사각형과 정사각형의 넓이를 구하는 경우

  • 위반
// 일반적으로 우리는 정사각형은 직사각형에 속한다고 알고 있지만,
// 코드로 작성하여 정사각형이 직사각형을 상속 받는 경우,
// 정사각형의 너비와 높이를 각각 3, 5로 set을 하고 출력을 하면
// 25라는 결과가 도출된다. 부모 클래스에서 의도된 결과는 직사각형 기준 15이기 때문에 
// 이는 LSP를 위반한다.

class Rectangle {
    var width: Double
    var height: Double
    
    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }
    
    func setWidth(_ width: Double) {
        self.width = width
    }
    
    func setHeight(_ height: Double) {
        self.height = height
    }
    
    func calculateArea() -> Double {
        return width * height
    }
}

class Square: Rectangle {
    override func setWidth(_ width: Double) {
        super.setWidth(width)
        super.setHeight(width)
    }
    
    override func setHeight(_ height: Double) {
        super.setWidth(height)
        super.setHeight(height)
    }
}
  • 준수
// 위 코드와는 다르게 넓이를 계산하는 기능을 가진 Shape라는 protocol을 따로 선언하였다.
// 직사각형의 넓이나 정사각형의 넓이나 결과는 부모인 Shape의 calculateArea()가 나타내는
// 넓이 계산이라는 예상 동작이 그대로 유지되기 때문에 LSP를 준수한다.

protocol Shape {
    func calculateArea() -> Double
}

class Rectangle: Shape {
    var width: Double
    var height: Double
    
    init(width: Double, height: Double) {
        self.width = width
        self.height = height
    }
    
    func calculateArea() -> Double {
        return width * height
    }
}

class Square: Shape {
    var sideLength: Double
    
    init(sideLength: Double) {
        self.sideLength = sideLength
    }
    
    func calculateArea() -> Double {
        return sideLength * sideLength
    }
}

ISP(Interface Segregation Principle)이란?

ISP는 인터페이스 분리 원칙으로, 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙. 즉, 인터페이스를 사용에 맞게 끔 각각 분리를 해야한다는 설계 원칙

  • SRP는 클래스의 단일 책임, ISP는 인터페이스의 단일 책임을 강조한다.
  • 인터페이스를 분리하여, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공해야한다.

ISP를 준수하는 이유

  • 시스템 내부 의존성을 약화시켜 유지보수를 쉽게 할 수 있다.
    → SRP와 마찬가지로, 인터페이스를 분리하여 결합도를 낮추면 의존성을 약화시킬 수 있다.

ISP 적용의 주의점

  1. 인터페이스를 계속해서 분리하는 행위를 하지 않는다.
    → 이미 구현되어 있는 인터페이스를 계속 분리한다면, 해당 인터페이스를 상속받고 구현을 하는 많은 클래스에서 문제가 발생할 수 있다.
    → 처음 설계를 할 때, 이를 잘 생각하여 설계를 하는 것이 좋지만 완벽한 설계는 어렵기 때문에 개발자의 역량에 따라 달렸다.

ISP의 예시(Swift)

원격 근무와 일반 근무를 하는 회사원에 대한 클래스를 구현 경우

  • 위반
// OfficeWorker는 Worker 프로토콜을 상속 받고, 추상 메서드인
// 일(work), 휴식(takeBreak), 회의 참석(attendMeeting)
// 세 가지 기능을 모두 수행할 수 있다.
// RemoteWorker는 Worker 프로토콜을 상속 받지만,
// 회의 참석을 할 수는 없다. Worker 프로토콜이 하는 일이 많아
// 원격 회사원이 불필요한 기능까지 구현하고 있는 것이다.

protocol Worker {
    func work()
    func takeBreak()
    func attendMeeting()
}

class OfficeWorker: Worker {
    func work() {
        print("회사에서 일하기.")
    }

    func takeBreak() {
        print("회사에서 휴식하기.")
    }

    func attendMeeting() {
        print("회사 내부 회의실에서 열리는 회의에 참석하기.")
    }
}

class RemoteWorker: Worker {
    func work() {
        print("원격으로 집에서 일하기.")
    }

    func takeBreak() {
        print("원격으로 집에서 편하게 휴식하기.")
    }

    func attendMeeting() {
        print("회사에 가지 않으니 회의실을 가지 못해.")
    }
}
  • 준수
// OfficeWorker는 일(Workable), 휴식(Breakable), 회의 참석(Meetingattenable)에 대한
// protocol을 각각 상속 받아 세 가지 기능을 모두 수행한다.
// RemoteWorker는 일, 휴식에 대한 protocol만을 상속 받고
// 회의 참석은 어짜피 수행하지 못하기에 받지 않는다.
// 그렇기에 위 코드와 다르게 불필요한 코드를 구현하지 않아도 된다.

protocol Workable {
    func work()
}

protocol Breakable {
    func takeBreak()
}

protocol MeetingAttendable {
    func attendMeeting()
}

class OfficeWorker: Workable, Breakable, MeetingAttendable {
    func work() {
        print("회사에서 일하기.")
    }

    func takeBreak() {
        print("회사에서 휴식하기.")
    }

    func attendMeeting() {
        print("회사 내부 회의실에서 열리는 회의에 참석하기.")
    }
}

class RemoteWorker: Workable, Breakable {
    func work() {
        print("원격으로 집에서 일하기.")
    }

    func takeBreak() {
        print("원격으로 집에서 편하게 휴식하기.")
    }

		// attendMetting()은 MeetingAttendable protocol을 상속받지 않아 구현할 필요 없음
}

DIP(Dependency Inversion Principle)란?

DIP는 의존관계 역전 원칙 으로, 상위 계층이 하위 계층에 의존하는 관계를 역전 시켜 상위 계층하위 계층의 구현으로부터 독립 되게 할 수 있는 원칙. 죽, 객체에서 클래스를 참조해야 한다면, 직접 클래스를 참조하는 것이 아닌 대상의 상위 요소(추상 클래스 또는 인터페이스)를 참조 하라는 원칙

  • 상위 모듈은 하위 모듈에 의존해서는 안된다.
    → 상위 모듈과 하위 모듈 모두 추상화에 의존 해야 한다.
  • 추상화는 세부 사항에 의존해서는 안된다.
    → 세부 사항이 추상화에 의존해야 한다.
  • 의존 관계를 예로 들면, A 클래스의 메소드에서 B 클래스의 타입을 매개변수로 받고 B 객체의 메서드를 사용하는 경우, A와 B 클래스는 의존관계이다.
  • 쉽게 말하면, 클라이언트가 상속 관계로 이루어진 모듈을 사용할 때, 하위 모듈을 직접 인스턴스로 가져다 쓰지 말아야 한다.
    → 하위 모듈의 구체적인 내용에 클라이언트가 의존하게 되어 하위 모듈에 변화가 생기면 상위 모듈의 코드까지 수정해야 하는 일이 생길 수 있기 때문이다.

DIP를 준수하는 이유

  • 구성 요소 간의 결합력(의존)을 줄여 유지보수 가 쉬워진다.
    → 상위 모듈과 하위 모듈의 의존성을 줄이면 수정과 확장에 걸림돌이 적어지기 때문에 유연성이 높아진다.

DIP 적용의 주의점

  1. 과도한 추상화 를 하지 않는다.
    → 너무 많은 인터페이스나 추상 클래스는 복잡성이 증가하고 가독성이 떨어질 수 있다.
  2. 추상화 정의를 알맞게 해야한다.
    → 사용하기 모호하고 잘못 사용될 수 있는 가능성을 줄이기 위해 구성 요소와 메서드를 정확하게 표현하여 설계해야 한다.

DIP의 예시(Swift)

사용자를 생성하고 데이터베이스에 저장하는 경우

  • 위반
// UserManager는 MySQLDB라는 특정 클래스를 참조하고, 메서드를 사용한다.
// 이 경우, 두 클래스 사이의 결합력이 높아지며 의존 관계가 형성된다.
// 또한, MySQLDB가 아닌 OracleDB를 사용하고 싶은 경우엔
// database 변수의 타입을 직접 수정까지 해주어야 한다.

class MySQLDB {
    func connect() -> String {
        return "MySQL 데이터베이스에 연결되었습니다."
    }

    func disconnect() -> String {
        return "MySQL 데이터베이스와 연결이 끊겼습니다."
    }
}

class OracleDB {
		func connect() -> String {
        return "Oracle 데이터베이스에 연결되었습니다."
    }

    func disconnect() -> String {
        return "Oracle 데이터베이스와 연결이 끊겼습니다."
    }
}

class UserManager {
    private let database = MySQLDB()

    func createUser(name: String) {
        print("유저 생성중: \(name)")
        let connection = database.connect()
        print("유저 생성 완료: \(name)")
        let disconnection = database.disconnect()
        print(disconnection)
    }
}
  • 준수
// Database라는 protocol을 정의하고, 
// 내부에 connect()와 disconnect()라는 메서드를 정의했다.
// UserManager에서는 이제 특정 클래스가 아닌 Database라는 protocol만 참조하고
// MySQLDB와 OracleDB는 모두 Database를 상속받는다.
// 따라서, UserManager에서는 원하는 데이터 베이스를 수정 없이 참조할 수 있게 되었고
// 의존도가 낮아졌기 때문에 DIP를 준수한다.

protocol Database {
    func connect() -> String
    func disconnect() -> String
}

class MySQLDB: Database {
    func connect() -> String {
        return "MySQL 데이터베이스에 연결되었습니다."
    }

    func disconnect() -> String {
        return "MySQL 데이터베이스와 연결이 끊겼습니다."
    }
}

class OracleDB: Database {
    func connect() -> String {
        return "Oracle 데이터베이스에 연결되었습니다."
    }

    func disconnect() -> String {
        return "Oracle 데이터베이스와 연결이 끊겼습니다."
    }
}

class UserManager {
    private let database: Database

    init(database: Database) {
        self.database = database
    }

    func createUser(name: String) {
        print("유저 생성중: \(name)")
        let connection = database.connect()
        print("유저 생성 완료: \(name)")
        let disconnection = database.disconnect()
        print(disconnection)
    }
}

참조

https://ko.wikipedia.org/wiki/SOLID(객체지향_설계)

https://inpa.tistory.com/entry/OOP-💠-객체-지향-설계의-5가지-원칙-SOLID

https://inpa.tistory.com/entry/OOP-💠-아주-쉽게-이해하는-DIP-의존-역전-원칙?category=967430

profile
안녕하세요.
post-custom-banner

0개의 댓글