SOLID 원리를 코드로 알아보자

froajnzd·2024년 7월 14일
0

pattern

목록 보기
15/15
post-thumbnail

우리의 밥아저씨 Martin이 제안한 SOLID 설계 원리는 객체지향 소프트웨어 설계를 보다 쉽게 유지보수하고, 쉽게 확장하게 만든다.

정확한 설계 및 원활한 의사소통을 위한 솔리드 원리를 알아보자! (。•̀ᴗ-)✧

Single Responsibility Principle

객체지향 설계에서 클래스는 중요한 모듈 단위이다.
단일 책임 원칙은 클래스의 역할과 책임을 단일화해 클래스를 변경해야 할 이유를 하나로 제한시키는 원리이다.

즉, 한 클래스는 하나의 책임만 가져야 한다.

하나의 클래스를 수정할 때 목적이 하나여야 한다.

"한 명의 역할, 한 명의 직급"
수정을 당하는 그 대상이 하나의 책임으로만 이루어져야 한다.

class Book {
	private String name;
    private String author;
    private Author text;
    
    public String replaceWordInText(String word) {}
    public String isWordIntext(String word) {}
    public void printTextToAnotherMedium(String text) {}
}

위와 같은 코드를 살펴보자
Book 클래스는 책 이름, 저자, 본문을 저장하고 유지하는 책임을 가지고 있다.

하지만 본문 내용을 화면에 디스플레이하는 책임을 가진 printTextToConsole()와 같은 메소드가 추가로 생긴다면 Book 클래스의 책임이 다양해진다.

따라서 BookPrinter라는 클래스를 따로 만들어 책임을 분리하는 게 좋다.

Book과 BookPrinter의 책임을 분리하면, 화면의 해상도가 달라지거나 나타내는 방법이 달라졌을 때 관련 없는 부분까지 수정해야 하는 문제를 피할 수 있다!

수정된 코드는 아래와 같다.

class Book {
	private String name;
    private String author;
    private Author text;
    
    public String replaceWordInText(String word) {}
    public String isWordIntext(String word) {}
}

class BookPrinter {
	public void printTextToAnotherMedium(String text) {}
}

조금 더 보기 편하게 클래스 다이어그램으로 표현해봤다.
(나중에 코드를 보지 않고도 이 그림으로 이해를 빠르게 하고 지나가는 날을 기대하며)

Open Close Principle

기능을 추가할 때는 추가하는 부분 추가만 해야 하며, 기존 코드를 변경하면 안된다.

소프트웨어 개체(클래스, 모듈, 기능)가 확장을 위해서는 열려있어야 하고, 수정을 위해서는 닫혀야 한다는 원리이다.

객체 지향의 특성인 다형성과 연관되어 있는 원리이기도 하다.

상속을 이용해 클래스가 정의되어 있을 때, 다형성이 적용되어 서로 대체할 수 있는 인터페이스를 구현할 수 있다.

또한, 클래스 자체를 수정하지 않고 클래스를 쉽게 확장할 수 있다.

public class Person {
	public String FirstName {get; set;}
    public String LastName {get; set;}
    public DateTime DateOfBirth {get; set;}
    public char Gender {get; set;}
}

public class PersonExporter {
	public Object export(String format, String? location = null) {
    	switch (format) {
        	case "Excel":
            	excel.save(location);
                break;
            case "JSON":
            	return jsonString;
                break;
            default:
            	File.WriteAllText(location.Value, $"{FirstName} {LastName}, born on {DateOfBirth.ToShortDateString() as {Gender}.");
        }
    }
}

예로, Person이라는 클래스와 Person을 파일로 저장하는 PersonExporter라는 클래스가 있다고 하자.

위의 코드에서 Export 형식을 추가하기 위해서는 PersonExporter 클래스를 변경해야 한다.
이는 확장할 때, 기존의 Client가 수정되어야 하기 때문에 OCP 원칙에 위배된다고 볼 수 있다.

OCP를 위배한 위 코드를 상속을 이용하여 OCP원칙에 맞도록 수정해보겠다.

public class Person {
	public String FirstName {get; set;}
    public String LastName {get; set;}
    public DateTime DateOfBirth {get; set;}
    public char Gender {get; set;}
}

public interface PersonExporter {
	public Object export(Person person, string? location);
}

public class ExcelExporter implements PersonExporter {
	public Object export(Person person, string? location) {
    	excel.save(location);
    }
}

public class JsonExporter implements PersonExporter {
	public Object export(Person person, string? location) {
    	excel.save(location);
    }
}

PersonExporter을 상속받은 여러 파일 exporter는 다형성을 이용해 확장될 수 있도록 열려있다.

또한, export() 함수의 수정이 필요할 때, Client 프로그램 부분은 해당 함수의 수정에 영향을 받지 않는다.

Liskov Substitution Principle

자식은 부모가 정의한대로 구현되어야 한다.!

클래스 B가 클래스 A에서 상속받은 하위 유형이면 프로그램의 동작을 방해하지 않고 A를 B로 대체할 수 있어야 한다는 원리이다.

인터페이스는 “공통 규약”이다.

인터페이스를 구현한 하위 인스턴스가 그 이상을 구현하면 “공통 규약”이라는 인터페이스의 의미가 퇴색된다.

상속을 이용한 다형성이 적용되기에 하위 클래스(파생 클래스)가 부모 클래스로 대체 가능해야 한다는 것이다.

하위 클래스는 클라이언트 관점에서 기능을 손상시키지 않는 방식으로 상위클래스 메소드를 대체해야 한다.

public interface IPersonFormatter {
	Stream format(Person person);
}

public class XMLPersonFormatter implements PersonExporter {
	public String format(Person person) {
    	return xmlStringMemoryStream;
    }
}

public class ExcelPersonFormatter implements PersonExporter {
	public Object format(Person person) {
    	excel.save(location);
        // null 을 리턴하지는 않지만, MemoryStream에 리턴할 것이 없다
    	return excelWorkBookMemoryStream;
    }
}

위 코드는 Interface를 상속받고 있는 두 Xml, Excel PersonFormatter이다. 인터페이스에서는 format함수가 person을 매개변수로 받아 Stream으로 변환하는 역할을 한다고 추측할 수 있다.

그러나 ExcelPersonFormatter에서는 excel을 저장하는 코드가 포함되어 있다.
그러나 인터페이스가 원하는 동작은 그것이 아니다. 인터페이스를 상속하는 클래스는 인터페이스가 명시하고 있는 그 동작을 모두 동일하게 구현해야 한다.

다음과 같이 고칠 수 있다.

public interface IPersonFormatter {
	Stream format(Person person);
}

public class XMLPersonFormatter implements PersonExporter {
	public String format(Person person) {
    	return xmlStringMemoryStream;
    }
}

public class ExcelPersonFormatter implements PersonExporter {
	public Object format(Person person) {
    	return excelWorkBookMemoryStream;
    }
}

Interface Segregation Principle

하위 모듈을 추상화하는 인터페이스를 설계할 때 하위 모듈이 사용하지 않는 일부 메서드(더미 메소드)가 생기지 않도록 설계해야 한다는 원리이다.

즉, 클라이언트가 사용하지 않는 인터페이스를 강제로 구현해서는 안된다.

하나의 Bulky한 인터페이스 대신, 여러 개의 작은 인터페이스를 구현하여 필요한 것만 사용하도록 해야한다.

ISP 원칙에 맞춰서 설계하면, 불필요한 다수의 책임이 있는 클래스에 의존하지 않고, 작은 필요한 책임으로 분리된 시스템이 된다. 즉, 결합력이 느슨해진다.

아래의 코드로 예를 보자.

public interface IPersonRepository {
    void save(Person person);
    void save(IEnumerable<Person> people);
    Person get(int id);
    IEnumerable<Person> get();
    IEnumerable<Person> get(Func<Person, bool> predicate);
}

public class PersonReadOnlyRepository implements IPersonRepository {
    public void save(Person person) {
        throw NotImplementedException();
    }
    public void save(IEnumerable<Person> people) {
        throw NotImplementedException();
    }
    public Person get(int id) {
        throw NotImplementedException();
    }
    public IEnumerable<Person> get() {
        using(var db = new DbContext()) {
            return db.People.ToEnumerable();
        }
    }
    public IEnumerable<Person> get(Func<Person, bool> predicate) {
        using(var db = new DbContext()) {
            return db.People.Where(p => predicate(p)).ToEnumerable();
        }        
    }
}

IPersonRepository라는 인터페이스가 너무 비대하여 이 인터페이스를 상속하는 PersonReadOnlyRepository 클래스는 사용하지도 않는 save(Person person), save(IEnumerable people), get(int id)를 구현하고 있다.

IPersonRepository 인터페이스를 분리하여 해당 문제를 막아야 한다.

public interface IWriteablePersonRepository {
    void save(Person person);
    void save(IEnumerable<Person> people);
}

public interface IReadablePersonRepository {
    Person get(int id);
    IEnumerable<Person> get();
    IEnumerable<Person> get(Func<Person, bool> predicate);
}

public class PersonReadOnlyRepository implements IReadablePersonRepository {
    public Person get(int id) {
        using(var db = new DbContext()) {
            return db.People.GetById(id);
        }
    }
    public IEnumerable<Person> get() {
        using(var db = new DbContext()) {
            return db.People.ToEnumerable();
        }
    }
    public IEnumerable<Person> get(Func<Person, bool> predicate) {
        using(var db = new DbContext()) {
            return db.People.Where(p => predicate(p)).ToEnumerable();
        }        
    }
}

주의

만약 이미 구현되어 있는(동작하고 있는) 인터페이스이고, 이미 많은 클래스들이 해당 인터페이스를 상속하고 있다면, ISP원칙에 따라 인터페이스를 분리하는 것이 더 많은 비용을 초래할 수 있다.

따라서,, 해당 인터페이스의 사용에 객체지향 설계 위반이 일어나지 않도록 초기 인터페이스를 설계할 때 Up Front 설계를 하자.

Dependency Inversion Principle

일반적으로 복잡한 논리를 제공하는 높은 수준의 모듈은 재사용이 가능하고 유틸리티 기능을 제공하는 낮은 수준의 모듈 변경에 의해 쉽게 영향을 받지 않아야 한다.

즉, 추상화된 모듈이 구체화된 모듈에 의존하지 않아야 한다. 반대로 구체화된 모듈이 추상화된 모듈에게 의존이 역전되도록 설계해야 한다.

여기서 말하는 Dependency(종속성)란, 새 키보드(인스턴스 생성), static methods, third party libraries, database(s), file system, configuration, ftp, web services, system resources 등이 있다.

DIP를 위반하는 경우

  • 고수준 모듈은 주로 저수준 모듈을 호출하는데, 따라서 대부분 new로 인스턴스화한다.
  • Façade 로 사용되는 static method들

아래 코드로 DIP 위반 예시와 고친 코드를 살펴보겠다.
public class PersonRepository implements IReadablePersonRepository, IWriteablePersonRepository {
    public void save(Person person) {
        using(var db = new DbContext()) {
            return db.People.Add(person);
        }
    }
    public void save(IEnumerable<Person> people) {
        using(var db = new DbContext()) {
            return db.People.AddRange(people);
        }
    }
    public Person get(int id) {
        using(var db = new DbContext()) {
            return db.People.GetById(id);
        }
    }
    public IEnumerable<Person> get() {
        using(var db = new DbContext()) {
            return db.People.ToEnumerable();
        }
    }
    public IEnumerable<Person> get(Func<Person, bool> predicate) {
        using(var db = new DbContext()) {
            return db.People.Where(p => predicate(p)).ToEnumerable();
        }        
    }
}

PersonRepository 클래스에서는 Default 생성자를 가지며, 각 메소드에서 매번 새로운 객체를 인스턴스화할 때 DbContext를 사용한다.

new를 사용해서 새로운 인스턴스들을 생성하고 있고,
static method들이나 properity들을 사용하고 있다.

매게변수 IDbContext를 가진 생성자를 도입하여 PersonPepository를 사용할 때, 클래스는 IDbContext에 대한 종속성이 동일해진다.

public class PersonRepository implements IReadablePersonRepository, IWriteablePersonRepository {
    private readonly _db;

    public PersonRepository(IDbContext db) {
        _db = db;
    }

    public void save(Person person) {
        _db.People.Add(person);        
    }
    public void save(IEnumerable<Person> people) {
        _db.People.AddRange(people);        
    }
    public Person get(int id) {
        _db.People.GetById(id);        
    }
    public IEnumerable<Person> get() {
        _db.People.ToEnumerable();        
    }
    public IEnumerable<Person> get(Func<Person, bool> predicate) {
        _db.People.Where(p => predicate(p)).ToEnumerable();                
    }
}

마무리

이제 SOLID 원리에 대해 조금 이해한 것 같은 느낌적인 느낌이 들기도 하다.

존경하는 교수님의 소공을 들었을 때 이해되는 듯 아닌 듯..
개념을 봤을 때 아하 이런거구나 했다가 막상 코드를 주고 SOLID 원칙에 따라 고쳐보라고 하니
진짜 이게 뭐지.. 이러면서 아무 코드도 작성하지 못했던 기억이 새록새록새록이 난다

그 때 좀 더 열싀미 할 걸.. 이해 안가면 교수님께 여쭤볼 걸...하는 후회 맞는 후회를 이번 기회에 하게됐다

항상 후회를 남긴다는 걸 또다시 느낀다.

이번에는 같은 후회를 하지 않기 위해 Dive Deep해서 개념을 익혀보고 모르는 게 있으면 이런저런 사람들에게 물어보겠다 다짐한다

후회 재귀를 만들지 말아라 이놈아

일단 SOLID하면 이분들 대신 Robert C Martin씨가 먼저 생각날 것이라고 확신한다..ㅎ

우리가 아는 SOLID

이제부터 누군가 "솔리드!"라고 말했을 때, 이분들이 생각나지 말도록 하자
(Too Old햇나.. 설직히 내 동갑내기 친구들도 잘 모를 거같다잉..)

profile
Hi I'm 열쯔엉

0개의 댓글