[Design Pattern] SOLID 원칙

문승연·2024년 1월 8일
0

Computer Science

목록 보기
1/2

0. Design Smells

Design Smell이란 나쁜 디자인을 나타내는 증상같은 것을 의미한다.

Design Smell에는 아래 4가지 종류가 있다.
1. Rigidity(경직성)
시스템이 변경하기 어렵다. 기능 하나의 변경을 위해서 다른 것들도 같이 변경해야할 때 경직성이 높다고 말한다.
2. Fragility(취약성)
시스템의 특정 부분을 수정했을 때, 관련이 없는 다른 부분에 영향을 줄 가능성이 높다면 취약성이 높다고 한다. 수정사항과 관련되어있지 않는 부분에도 영향을 끼치기 때문에 관리 비용이 커지며 시스템의 신용성 또한 떨어진다.
3. Immobility(부동성)
부동성이 높다면 재사용하기 위해서 시스템을 분리해서 컴포넌트를 만드는 것이 어렵다. 주로 개발자가 이전에 구현되었던 모듈과 비슷한 기능을 하는 모듈을 만들려고 할 때 문제점을 발견한다.
4. Viscosity(점착성)
디자인 점착성과 환경 점착성. 2가지로 나눌 수 있다. 이 중 환경 점착성은 개발환경이 느리고 효율적이지 못할 때 나타난다. 컴파일 시간이 매우 길다면 큰 규모의 수정이 필요하더라도 개발자는 recompile 시간이 길기 때문에 작은 규모의 수정으로 문제를 해결하려고 할 것이다.

1. SOLID 원칙이란?

『클린 코드』, 『클린 아키텍처』등 유명한 IT 도서의 저자인 Robert C. Martin이 고안한 개념으로 객체 지향 프로그래밍에서 지켜져야할 소프트웨어 설계의 5가지 기본 원칙을 뜻한다.

이러한 원칙은 소프트웨어 설계와 구조를 개선하여 유지보수정, 확장성, 재사용성이 좋고 이해하기 쉬운 코드를 작성하는데 도움을 주며 위의 Design Smell도 줄일 수 있다.

1. SRP 단일 책임 원칙(Single Responsibility Principle)

  • 모듈(클래스)은 오직 하나의 이유로 수정되어야 한다.
  • 수정의 이유가 하나라는 것은 해당 모듈이 여러 대상 or 액터들에 대해 책임을 가져서는 안되고, 오직 하나의 액터에 대해서만 책임을 져야 한다.

만약 어떤 모듈이 여러 액터에 대해 책임을 가지고 있다면??
-> 여러 액터들로부터 변경 요구가 올 수 있고 따라서 해당 모듈을 수정해야하는 이유 역시 여러개가 될 수 있다.

예를 들어, 사용자의 입력 정보를 받고 비밀번호를 암호화하여 데이터베이스에 저장하는 로직이 있다고 하자.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public void addUser(final String email, final String pw) {
        final StringBuilder sb = new StringBuilder();

        for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
            sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        }

        final String encryptedPassword = sb.toString();
        final User user = User.builder()
            .email(email)
            .pw(encryptedPassword).build();

        userRepository.save(user);
    }
}

UserService에게 들어올 수 있는 변경사항 요청 예시는 아래와 같다.
1. 사용자를 추가(addUser)할 때, 역할(Role)에 대한 정의가 필요하다.
2. 사용자의 비밀번호 암호화 방식에 개선이 필요하다.
3. 기타 등등...

UserSErvice의 수정에 필요한 이유가 최소 2가지 이상이다. 그 이유는 위 코드가 2가지 이상의 책임을 갖기 때문이다.
1. 비밀번호 암호화
2. User 생성

따라서 위 코드는 SRP 원칙에 위배된다. 이를 고치기 위해선 비밀번호 암호화 책임을 맡는 클래스와 유저 생성 책임을 맡는 클래스를 분리해야한다.

@Component
public class SimplePasswordEncoder {

    public String encryptPassword(final String pw) {
        final StringBuilder sb = new StringBuilder();

        for(byte b : pw.getBytes(StandardCharsets.UTF_8)) {
            sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1));
        }

        return sb.toString();
    }
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final SimplePasswordEncoder passwordEncoder;

    public void addUser(final String email, final String pw) {
        final String encryptedPassword = passwordEncoder.encryptPassword(pw);

        final User user = User.builder()
            .email(email)
            .pw(encryptedPassword).build();

        userRepository.save(user);
    }
}

SimplePasswordEncoder 라는 비밀번호 암호화 책임을 갖는 클래스를 생성해 UserService에서 분리함으로써 SRP 원칙을 지켰다.

2. OCP 개방 폐쇄 원칙 (Open-Closed Principle)

  • 자신의 확장에는 열려있고 주변의 변화에는 닫혀 있어야한다.
  • 확장에 대해 열려있다: 요구사항이 변경될 때, 새로운 동작을 추가하여 애플리케이션의 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다: 기존의 코드를 수정하지 않고 애플리케이션의 동작을 추가하거나 변경할 수 있다.
  • 즉, 기존의 코드를 변경하지 않고 기능을 수정 및 추가할 수 있어야한다는 뜻이다.

만약 OCP 원칙이 지켜지지않는다면 객체지향 프로그래밍의 가장 큰 장점인 유연성, 재사용성, 유지보수성 등을 모두 잃어버리는 셈이고 OOP를 사용하는 의미가 사라진다.

위 예시에서 비밀번호 암호화를 강화해야한다는 요구사항이 들어왔다고 가정하자. 이를 위해 SHA-256 알고리즘을 사용하는 방식으로 새롭게 PasswordEncoder를 생성했다.

@Component
public class SHA256PasswordEncoder {

    private final static String SHA_256 = "SHA-256";

    public String encryptPassword(final String pw)  {
        final MessageDigest digest;
        try {
            digest = MessageDigest.getInstance(SHA_256);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException();
        }

        final byte[] encodedHash = digest.digest(pw.getBytes(StandardCharsets.UTF_8));

        return bytesToHex(encodedHash);
    }

    private String bytesToHex(final byte[] encodedHash) {
        final StringBuilder hexString = new StringBuilder(2 * encodedHash.length);

        for (final byte hash : encodedHash) {
            final String hex = Integer.toHexString(0xff & hash);
            if (hex.length() == 1) {
                hexString.append('0');
            }
            hexString.append(hex);
        }

        return hexString.toString();
    }
}

그런데 이 새로운 비밀번호 암호화 정책을 적용하려고 하니 암호화 정책과 무관한 UserService를 수정해줘야하는 문제가 발생한다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final SHA256PasswordEncoder passwordEncoder;

    ...
    
}

이는 새로운 기능의 확장이 기존 코드를 수정하지 않아야한다는 OCP 원칙에 위배된다. 만약 나중에 또 다시 암호화 정책을 변경해야한다는 요구사항이 오면 또 다시 UserService도 변경이 필요해진다.

OCP 는 추상화 (인터페이스)상속 (다형성) 등을 통해 구현해낼 수 있다. 자주 변화하는 부분을 추상화함으로써 기존 코드를 수정하지 않고도 기능을 확장할 수 있도록 함으로써 유연함을 높이는 것이 핵심이다.

public interface PasswordEncoder {
    String encryptPassword(final String pw);
}

@Component
public class SHA256PasswordEncoder implements PasswordEncoder {

    @Override
    public String encryptPassword(final String pw)  {
        ...
    }
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public void addUser(final String email, final String pw) {
        final String encryptedPassword = passwordEncoder.encryptPassword(pw);

        final User user = User.builder()
            .email(email)
            .pw(encryptedPassword).build();

        userRepository.save(user);
    } 
}

OCP가 본질적으로 이야기하는 것은 추상화이다. 그리고 이는 런타임 의존성컴파일타임 의존성에 대한 이야기이다.

  • 런타임 의존성: 애플리케이션 실행 시점에서의 객체들의 관계(의존성)
  • 컴파일타임 의존성: 코드에 표현된 클래스들의 관계(의존성)

객체지향 프로그래밍에서 런타임 의존성과 컴파일타임 의존성은 동일하지않다. 위 예제에서 UserService는 컴파일 시점에서 추상화된 PasswordEncoder에 의존하고 있지만 런타임 시점에서는 구현체 클래스 SHA256PasswordEncoder에 의존한다.

핵심은 변하는 것들은 숨기고(추상화) 변하지 않는 것들에 의존하게 하는 것이다.

3. ISP 인터페이스 분리 원칙 (Interface Segregation Principle)

  • 사용하지 않는 메소드에 의존하면 안된다.
  • 클라이언트는 반드시 자신이 사용하는 메소드에만 의존해야한다는 원칙이다.
  • 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 않아야한다.
    -> 하나의 통상적인 인터페이스보다 차라리 여러개의 세부적인(구체적인) 인터페이스가 낫다.

각 클라이언트가 필요로하는 인터페이스들을 분리함으로써, 클라이언트가 사용하지 않는 인터페이스에 변경이 발생하더라도 영향을 받지 않아야한다는 것이 핵심이다.

예를 들어 파일 읽기/쓰기 기능을 갖는 구현 클래스가 있는데 어떤 클라이언트는 읽기 작업만을 필요로 한다면 별도의 읽기 인터페이스를 만들어 제공해주는 것이다.

위 예시에 이어서 비밀번호를 변경할 때, 입력한 비밀번호가 기존의 비밀번호와 동일한지 검사해야하는 로직을 다른 Authentication 로직에 추가해야 한다고 가정하자. 그러면 우리는 다음과 같은 isCorrectPassword라는 퍼블릭 인터페이스를 SHA256PasswordEncoder에 추가해줄 것이다.

@Component
public class SHA256PasswordEncoder implements PasswordEncoder {

    @Override
    public String encryptPassword(final String pw)  {
        ...
    }

    public String isCorrectPassword(final String rawPw, final String pw) {
        final String encryptedPw = encryptPassword(rawPw);
        return encryptedPw.equals(pw);
    }
}

하지만 UserService에서는 비밀번호 암호화를 위한 encryptPassword 만 필요로 하고 isCorrectPassword는 알 필요가 없다.
그리고 새로 추가될 Authentication 로직에서는 isCorrectPassword 에 접근하기 위해 구체 클래스인 SHA256PasswordEncoder를 주입받아야 하는데 그러면 불필요한 encryptPassword에도 접근 가능해지고 ISP 분리 원칙을 위반하게 된다.

위 상황을 해결하기 위해서는 비밀번호 검사를 의미하는 PasswordChecker라는 별도의 인터페이스를 만들고 이를 주입받도록 하는 것이다.

public interface PasswordChecker {
    String isCorrectPassword(final String rawPw, final String pw);
}

@Component
public class SHA256PasswordEncoder implements PasswordEncoder, PasswordChecker {

    @Override
    public String encryptPassword(final String pw)  {
        ...
    }
  
    @Override
    public String isCorrectPassword(final String rawPw, final String pw) {
        final String encryptedPw = encryptPassword(rawPw);
        return encryptedPw.equals(pw);
    }
}

4. DIP 의존성 역전 원칙 (Dependency Inversion Principle)

  • **자신(high level module)보다 변하기 쉬운 모듈(low level module)에 의존해서는 안된다.

  • 의존 관계를 맺을 떄, 변하기 쉬운 것(구체적인 것)보다는 변하기 어려운 것(추상적인 것)에 의존해야한다는 원칙이다.
    -> 구체화된 클래스가 아닌 추상 클래스인터페이스에 의존하는 것이 좋다.

  • 저수준 모듈이 변경되어도 고수준 모듈은 변경이 필요없는 형태가 이상적이다.

  • 고수준 모듈: 입력과 출력으로부터 먼(비즈니스와 관련된) 추상화된 모듈

  • 저수준 모듈: 입력과 출력으로부터 가까운(HTTP, 데이터베이스, 캐시 등과 관련된) 구현 모듈

객체 지향 프로그래밍에서는 객체들 사이에 메세지를 주고 받기 위해 의존성이 생기는데, 의존성 역전의 원칙은 올바른 의존 관계를 위한 원칙에 해당된다.

위의 예시에서 SimplePasswordEncoder 는 변하기 쉬운 암호화 알고리즘 구현체 클래스인데 UserServiceSimplePasswordEncoder에 직접 의존하는 것은 DIP에 위배된다. 따라서 UserService가 추상화에 의존하도록 변경해야했고 PassowordEncoder 인터페이스를 만들어 의존케함으로써 해결했다.

PassowrdEncoder의 다른 구현체 클래스에 변경이 생겨도 UserService는 고수준 모듈인 PasswordEncoder 인터페이스에 의존하므로 암호화 정책이 바껴도 다른 곳으로 전파되지 않는다.

이처럼 의존성 역전 원칙(DIP)은 개방 폐쇄 원칙(OCP)와 밀접한 관련이 있다.

5. LSP 리스코프 치환 원칙 (Liskov Substitution Principle)

  • base 클래스에서 파생된 클래스는 base 클래스를 대체해서 사용할 수 있어야한다.
  • 하위 타입은 상위 타입을 대체할 수 있어야한다는 뜻이다.
  • 리스코프 치환 원칙이 잘 지켜지면 해당 객체를 사용하는 클라이언트는 객체 타입이 하위 타입으로 변경되어도 차이점을 인식하지 못해야한다.

아래 예시를 살펴보자.

@Getter
@Setter
@AllArgsConstructor
public class Rectangle {

    private int width, height;

    public int getArea() {
        return width * height;
    }

}

public class Square extends Rectangle {

    public Square(int size) {
        super(size, size);
    }
	
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

Rectangle은 직사각형을 뜻하는 상위 클래스이고 Square는 정사각형으로 Rectangle로부터 상속받는 하위 클래스이다.

Square의 생성자를 보면 size라는 1개의 변수만을 생성자로 받고 이를 이용해 width, height가 모두 설정되도록 오버라이딩 되어있다.

하지만 이를 이용하는 클라이언트는 직사각형의 너비와 높이가 다르다고 가정할 것이고 직사각형을 resize하기 위해 아래와 같은 resize() 메소드를 만들 수 있다.

public void resize(Rectangle rectangle, int width, int height) {
    rectangle.setWidth(width);
    rectangle.setHeight(height);
    if (rectangle.getWidth() != width && rectangle.getHeight() != height) {
        throw new IllegalStateException();
    }
}

문제는 resize()의 파라미터로 정사각형인 Square이 전달되는 경우다. RectangleSquare의 부모 클래스이므로 Square 역시 전달이 가능하다. 하지만 Square는 가로와 세로가 모두 동일하게 설정되므로 예를 들어 다음과 같은 메소드를 호출하면 문제가 발생할 것이다.

Rectangle rectangle = new Square();
resize(rectangle, 100, 150);

이 경우 엄연히 부모 클래스와 자식 클래스의 행동이 호환되지 않으므로 리스코프 치환 원칙이 위배된다.

이 외에도 리스코프 치환 원칙이 위반되는 상황 예시로는 다음과 같다.

  1. 하위 클래스가 상위 클래스에서 선언한 기능을 위반하는 경우
    • 상위 클래스가 주문 정렬을 위한 sortOrdersByAmount() 함수를 구현해두었는데, 하위 클래스에서 생성 날짜에 따라 정렬되도록 변경한 경우
  1. 하위 클래스가 입력, 출력 및 예외에 대한 상위 클래스의 계약을 위반하는 경우
    • 상위 클래스에서 오류가 발생하면 null을, 값을 얻을 수 없으면 빈 컬렉션을 반환하게 해두었는데, 하위 클래스에서 오류가 발생하면 예외를 발생시키고, 값을 얻을 수 없을 때 null을 반환하도록 변경한 경우
    • 상위 클래스에서는 입력 시 모든 정수를 허용하지만, 하위 클래스에서는 음수일 때 예외를 발생시키는 경우
    • 상위 클래스에서 던지는 예외는 ArgumentException 뿐인데, 하위 클래스에서 다른 예외도 던지는 경우
  1. 하위 클래스가 상위 클래스의 주석에 나열된 특별 지침을 위반하는 경우
    • 상위 클래스에 예금을 인출하는 withdraw() 메서드에 사용자의 출금 금액이 잔액을 초과해서는 안된다는 주석이 있을 때, 하위 클래스에서는 가능한 경우

출처
1. An overview of design pattern - SOLID, GRASP
2. [OOP] 객체지향 프로그래밍의 5가지 설계 원칙, 실무 코드로 살펴보는 SOLID
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]

3. SOLID 원칙, 어렵지 않다!

profile
"비몽(Bemong)"이라는 앱을 개발 및 운영 중인 안드로이드 개발자입니다.

0개의 댓글