Post

SOLID 원칙

SOLID 원칙

객체 지향 프로그래밍의 5가지 원칙, SOLID

solid란 객체 지향 프로그래밍을 하면서 지켜야하는 5대 원칙을 뜻한다. SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), DIP(의존 역전 원칙), ISP(인터페이스 분리 원칙)의 앞글자를 따서 만들어졌다. SOLID 원칙을 철저히 지키면서 시간이 지나도 변경이 용이하고, 유지보수와 확장이 쉬운 소프트웨어를 개발하는데 도움이 되는 것으로 알려져있다.

단일 책임 원칙(Single Responsibility Principle)

로버트 마틴은 SOLID 원칙 중 가장 의미가 전달되지 못한 것으로 단일 책임 원칙(SRP)을 뽑았습니다. SRP는 하나의 모듈이 하나의 책임을 가져야 한다는 모호한 원칙으로 해석하면 안된다.

그 대신 모듈이 변경되는 이유가 한가지여야 함으로 받아들여야 한다.

여기서 변경의 이유가 한가지라는 것은 해당 모듈이 여러 대상 또는 액터들에 대한 책임을 가져서는 안되고, 오직 하나의 액터에 대해서만 책임을 져야 한다는 것을 의미한다.

만약 어떤 모듈이 여러 액터에 대해 책임을 가지고 있다면 여러 액터들로부터 변경에 대한 요구가 올 수 있으므로, 해당 모듈을 수정해야하는 이유 역시 여러개가 된다. 반면에 클래스가 단 하나의 책임 만을 가지고 있다면, 특정 액터로부터 변경을 특정할 수 있어, 해당 클래스를 변경해야 하는 이유와 시점이 명확해진다.

예를 들어 다음과 같이 입력으로 사용자의 정보를 받아서(첫번째 책임), 비밀번호를 암호화하여 데이터베이스에 저장하는 로직(두번째 책임)이 있다고 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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 클래스의 사용자 추가 로직에는 다음과 같은 다양한 액터로부터 변경이 발생할 수 있다.

  • 기획팀 : 사용자를 추가할 때 역할에 대한 정의가 필요함
  • 보안팀 : 사용자의 비밀번호 암호화 방식에 개선이 필요함
  • 기타 등등

이러한 문제가 발생하는 이유는 UserService가 여러 액터로부터 단 하나의 책임을 갖고 있지 못하고 있어 발생한다. 이를 위해 비밀번호 암호화에 대한 책임을 분리해야한다.

다음과 같이 비밀번호 암호화를 책임지는 별도의 클래스를 만들어 UserService로부터 이를 추상화하고, 해당 클래스를 합성하여 접근 및 사용하면 우리는 UserService로부터 비밀번호 암호화 방식을 개선해달라는 변경을 분리할 수 있다.

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
@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);
    }
}

처음 코드에서 비밀번호 암호화 부분을 따로 분리한 코드이다. 이런 방식으로 단일 책임 원칙을 제대로 지키면 변경이 필요할 때 수정할 대상이 명확해진다.

이러한 단일 책임 원칙의 장점은 시스템이 커질수록 극대화되는데, 시스템이 커지면서 서로 많은 의존성을 갖게되는 상황에서 변경 요청이 오면 딱 1가지만 수정하면 되기 때문이다.

단일 책임 원칙을 적용하여 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 추상화함으로써 어플리케이션의 변화에 손쉽게 대응할 수 있다.

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

개방 폐쇄 원칙은 확장에 대해 열려있고 수정에 대해서는 닫혀있어야 한다는 원칙이다.

  • 확장에 대해 열려 있다 : 요구사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다 : 기존의 코드를 수정하지 않고 애프릴케이션의 동작을 추가하거나 변경할 수 있다.

쉽게 말해 기존 코드를 어떠한 기능을 추가해야할 때 기존 코드를 수정하지 않고 기능을 추가할 수 있어야 한다는 의미이다.

이번에는 비밀번호 암호화를 강화해야 한다는 요구사항이 새롭게 들어왔다고 가정하자. 비밀번호 암호화를 강화하기 위해 다음과 같이 SHA-256 알고리즘을 사용하는 새로운 PasswordEncoder를 생성하였다.

(물론 SHA256 해시 알고리즘을 비밀번호 암호화에 사용하는 것은 적절하지 못하다. 그 이유는 SHA256으로 해시되는 값들을 미리 적어두어 해킹하는 레인보우 테이블 공격 기법을 사용할 수 있기 때문이다. 그러나 여기서는 개방 폐쇄의 원칙을 설명하기 위함이므로 예시로 사용하고자 한다.)

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
@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를 다음과 같이 수정해야 하는 문제가 발생했다.

1
2
3
4
5
6
7
8
9
10
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final SHA256PasswordEncoder passwordEncoder;

    ...
    
}

이는 기존의 코드를 수정하지 않아야 하는 개방 폐쇄 원칙에 위배된다. 그리고 나중에 다시 비밀번호 암호화 정책을 변경해야 한다면 또 다시 UserService를 수정해야 하는 일이 발생한다.

이러한 문제를 해결하고 개방 폐쇄 원칙을 지키기 위해서는 추상화에 의존해야한다.

추상화란 : 핵심적인 부분만 남기고, 불필요한 부분을 제거함으로써 복잡한 것을 간단히 하는 것이고, 추상화를 통해 변하지 않는 부분만 남김으로써 기능을 구체화하고 확장할 수 있다.

변하지 않는 부분은 고정하고 변하는 부분을 생략하여 추상화함으로써 변경이 필요한 경우에 생략된 부분을 수정하여 개방-폐쇄의 원칙을 지킬 수 있다.

위의 예제에서 변하지 않는 것은 사용자를 추가할 때 암호화가 필요하다는 것이고, 변하는 것은 사용되는 구체적인 암호화 정책이다.

그러므로 UserService는 어떠한 구체적인 암호화 정책이 사용되는지는 알 필요 없이 단지 passwordEncoder 객체를 통해 암호화가 된 비밀번호를 받기만 하면 된다.

그러므로 UserService가 구체적인 암호화 클래스에 의존하지 않고 PasswordEncoder라는 인터페이스에 의존하도록 추상화하면 우리는 개방 폐쇄의 원칙이 충족되는 코드를 작성할 수 있다.

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
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)에 의존한다.

객체가 알아야 하는 지식이 많으면 결합도가 높아지고, 결합도가 높아질수록 개방-폐쇄의 원칙을 따르는 구조를 설계하기가 어려워진다.

추상화를 통해 변하는 것들은 숨기고 변하지 않는 것들에 의존하게 하면 우리는 기존의 코드 및 클래스들을 수정하지 않은 채로 애플리케이션을 확장할 수 있다. 그리고 이것이 개방 폐쇄의 원칙이 의미하는 것이다.

이 이상은 다음에 공부하기로 하자…. 지금의 나에겐 너무 여러운 부분이라 개념만 잡자…

This post is licensed under CC BY 4.0 by the author.