OOP의 디자인 패턴
목차
- SOLID
- 디자인 패턴
- 생성 패턴
- 구조 패턴
- 행위 패턴
객체지향 프로그래밍의 5가지 설계 원칙
SOLID란 객체 지향 프로그래밍을 하면서 지켜야하는 5대 원칙이다.
아래의 원칙을 지킨다면 시간이 지나도 변경이 용이하고, 유지보수와 확장이 쉬운 소프트웨어를 개발하는데 도움이 된다.
[ 단일 책임 원칙 : SRP(Single Responsibility Principle) ]
모듈이 변경되는 이유가 1가지 이어야 한다는 의미.
여기서 변경되는 이유가 한가지라는 것은 해당 모듈이 여러 대상 또는 액터들에 대해 책임을 가져서는 안되고, 오직 하나의 액터에 대해서만 책임을 져야 한다는 것을 의미한다.
만약 어떤 모듈이 여러 액터에 대해 책임을 가지고 있다면 여러 액터들로부터 변경에 대한 요구가 올 수 있으므로, 해당 모듈을 수정해야 하는 이유 역시 여러 개가 될 수 있다. 반면에 어떤 모듈이 단 하나의 책임만을 갖고 있다면, 특정 액터로부터 변경을 특정할 수 밌으므로 해당 모듈을 변경해야 하는 이유와 시점이 명확해진다. 여기서 모듈이란 클래스 혹은 클래스의 모음으로 해석 가능하다.
만약 UserService라는 클래스가 (1) 사용자의 입력 정보를 받는 로직과 (2) 비밀번호를 암호화하여 데이터베이스에 저장하는 로직이 있다고 하자.
@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);
}
}
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
UserService는 해당 로직에 다음과 같은 여러 액터로부터 변경이 발생할 수 있다.
- 기획팀 : 저장할 사용자의 역할 정보를 추가해줘. ← 사용자 입력 정보 받는 로직에 대한 변경
- 보안팀 : 사용자 비밀번호 암호화 방식을 변경해줘. ← 비밀번호 암호화 로직에 대한 변경
이러한 이유는 UserService가 여러 액터(기획팀, 보안팀)로부터 단 하나의 책임을 가지는 것이 아니기 때문이다.
이를 위해 비밀번호 암호화에 대한 책임을 분리해야 한다.
비밀번호 암호화를 책임지는 별도의 클래스를 만들어 UserService로부터 이를 추상화하고,
해당 클래스를 합성하여 접근 및 사용하면 UserService로부터 비밀번호 암호화 방식의 변경(요구사항 변경)을 분리할 수 있다.
@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);
}
}
단일 책임 원칙을 적용하여 적절하게 책임과 관심이 다른 코드를 분리하고, 서로 영향을 주지 않도록 추상화함으로써 애플리케이션의 변화에 손쉽게 대응할 수 있다. ( ∵ 단일 책임 원칙을 지키면 변경이 필요할 때 수정할 대상이 명확해짐 )
그러나, 유스케이스나 요구사항에 따라 SRP 원칙을 준수하는지 판단하는 기준이 달라진다.
@Getter
@RequiredArgsConstructor
public class Serializable<T> {
private final Gson gson;
public String serialize(T t) {
return ...
}
public T deserialize(String input) {
return ...
}
}
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
해당 클래스의 책임을 직렬화 자체만으로 보는지 또는 직렬화와 역직렬화는 사실상 세트니까 직렬화 전반에 대한 책임으로 보는지에 따라 다르다.
[ 개방 폐쇄 원칙 : OCP(Open-Closed Principle) ]
확장에 대해 열려있고, 수정에대해서는 닫혀있어야 한다는 원칙
확장에 대해 열려 있다 : 요구사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장할 수 있다.
수정에 대해 닫혀 있다 : 기존의 코드를 수정하지 않고 애플리케이션의 동작을 추가하거나 변경할 수 있다.
다형성을 지원하는 객체지향 프로그래밍에서 런타임 의존성과 컴파일 타임 의존성은 동일하지 않다.
예제에서 UserService는 컴파일 시점에 추상화된 PasswordEncoder에 의존하고 있지만 런타임 시점에는 구체 클래스(SHA256PasswordEncoder)에 의존한다.
추상화를 통해 변하는 것들은 숨기고 변하지 않는 것들에 의존하게 하면 우리는 기존의 코드 및 클래스들을 수정하지 않은 채로 애플리케이션을 확장할 수 있다.
이번에는 비밀번호 암호화를 강화해야 한다는 요구사항이 새롭게 들어왔다고 가정하자.
- 비밀번호 암호화를 강화하려고 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();
}
}
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
새로운 암호화 정책을 적용하기 위해 UserService를 수정해야하는 문제 발생 → 개방 폐쇄 원칙 위배
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final SHA256PasswordEncoder passwordEncoder;
...
}
이러한 문제를 해결하고 개방 폐쇄 원칙을 지키기 위해서는 추상화에 의존해야 한다. 추상화란 핵심적인 부분만 남기고, 불필요한 부분은 제거함으로써 복잡한 것을 간단히 하는 것이고, 추상화를 통해 변하지 않는 부분만 남김으로써 기능을 구체화하고 확장할 수 있다.
변하지 않는 부분은 고정하고 변하는 부분을 생략하여 추상화함으로써 변경이 필요한 경우에 생략된 부분을 수정하여 개방-폐쇄의 원칙을 지킬 수 있다.
- 변하지 않는 부분 : 사용자를 추가할 때 암호화가 필요하다는 것
- 변하는 부분 : 구체적인 암호화 정책
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);
}
}
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
[ 인터페이스 분리 원칙 : ISP(Interface Segregation Principle) ]
목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 적절하게 분리해줄 필요가 있다.
ISP는 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공하는 것이다.
모든 클라이언트가 자신의 관심에 맞는 퍼블릭 인터페이스(외부에서 접근 가능한 메세지)만을 접근하여 불필요한 간섭을 최소화할 수 있으며, 기존 클라이언트에 영향을 주지 않은 채로 유연하게 객체의 기능을 확장하거나 수정할 수 있다.
예를 들어 파일 읽기/쓰기 기능을 갖는 구현 클래스가 있는데 어떤 클라이언트는 읽기 작업만을 필요로 한다면 별도의 읽기 인터페이스를 만들어 제공해주는 것이다.
예시 ) 인증 로직 추가
인증 로직에는 isCorrectPassword에 접근하기 위해 구체 클래스 SHA256비번인코더를 주입받아야 함.
그러면 불필요한 encryptPassword에도 접근이 가능해짐. 인터페이스 분리 원칙에 위배
@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);
}
}
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);
}
}
클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더욱 세밀하게 제어할 수 있다. 그리고 이렇게 인터페이스를 클라이언트의 기대에 따라 분리하여 변경에 의한 영향을 제어하는 것을 인터페이스 분리 원칙이라고 부른다.
여기서 인터페이스는 꼭 하나의 인터페이스 파일에만 해당하지 않는다. 인터페이스 분리 원칙에서 이야기하는 인터페이스는 넓게 보아 아래의 내용들 까지 확장될 수도 있다.
- API나 기능의 집합
- 단일 API 또는 기능
- 객체지향 프로그래밍의 인터페이스
[ 리스코프 치환 원칙 : LSP(Liskov Substitution Principle) ]
올바른 상속 관계의 특징을 정의하기 위해 발표한 것.
하위 타입은 상위 타입을 대체할 수 있어야 한다는 것이다.
즉, 해당 객체를 사용하는 클라이언트는 상위 타입이 하위 타입으로 변경되어도, 차이점을 인식하지 못한 채 상위 타입의 퍼블릭 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다는 것이다.
즉, 이는 클라이언트와 객체 사이의 계약이 존재하고, 이를 준수해야 한다는 원칙이므로 계약에 따른 설계라고 표현할 수도 있다.
하위 클래스는 상위 클래스의 동작 규칙을 따라야 하는 것이다.
@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);
}
}
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
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();
}
}
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
Rectangle rectangle = new Square();
resize(rectangle, 100, 150);
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다는 것을 강조한다.
위의 예시에서 클라이언트는 직사각형의 너비와 높이는 다를 것이라고 가정하는데, 정사각형은 이를 준수하지 못한다.
우리는 여기서 대체 가능성을 결정해야 하는 것은 해당 객체를 이용하는 클라이언트임을 반드시 잊지 말아야 한다.
[ 의존 역전 원칙 : DIP(Dependency Inversion Principle) ]
고수준 모듈은 저수준 모듈의 구현에 의존해서는 안되며, 저수준 모듈이 고수준 모듈에 의존해야 한다는 것이다.
- 고수준 모듈 : 입력과 출력으로부터 먼(비지니스와 관련된) 추상화된 모듈
- 저수준 모듈 : 입력과 출력으로부터 가까운(HTTP, 데이터베이스, 캐시 등과 관련된) 구현 모듈
비지니스와 관련된 부분이 세부 사항에는 의존하지 않는 설계 원칙을 의미한다.
UserService가 SimplePasswordEncoder에 직접 의존하는 것은 DIP에 위배되는 것이다. 그러므로 UserService가 변하지 않는 추상화에 의존하도록 변경이 필요하고, 우리는 PasswordEncoder 인터페이스를 만들어 이에 의존하도록 변경하였다.
의존 역전 원칙은 개방 폐쇄 원칙과 밀접한 관련이 있으며, 의존 역전 원칙이 위배되면 개방 폐쇠 원칙 역시 위배될 가능성이 높다. 또한 의존 역전 원칙에서 주의해야 하는 것이 있는데, 의존 역전 원칙에서 의존성이 역전되는 시점은 컴파일 시점이라는 것이다. 런타임 시점에는 UserService가 SHA256PasswordEncoder라는 구체 클래스에 의존한다. 하지만 의존 역전 원칙은 컴파일 시점 또는 소스 코드 단계에서의 의존성이 역전되는 것을 의미하며, 코드에서는 UserService가 PasswordEncoder라는 인터페이스에 의존한다.
SOLID가 얘기하는 핵심은 추상화와 다형성이다.
디자인 패턴(Design Pattern)이란?
디자인 패턴은 개발하면서 발생하는 반복적인 문제들을 어떻게 해결할 것인지에 대한 해결 방안으로 실제 현업에서 비지니스 요구 사항을 프로그래밍으로 처리하면서 만들어진 다양한 해결책 중에서 많은 사람들이 인정한 모범사례다. 이러한 디자인 패턴은 객체 지향 4대 특성(캡슐화, 상속, 추상화, 다형성)과 설계 원칙(SOLID)을 기반으로 구현되어 있다.
디자인 패턴을 왜 배워야 하는가?
1. 개발자들과의 소통
2. 더 우아하고 더 빠른 개발 가능
디자인 패턴의 장점
1. 재사용성 : 반복적인 문제에 대한 일반적인 해결책을 제공하므로, 이를 재사용하여 유사한 상황에서 코드를 더 쉽게 작성할 수 있다.
2. 가독성 : 일정한 구조로 정리하고 명확하게 작성하여 개발자가 코드를 이해하고 유지보수하기 쉽게 만든다.
3. 유지보수성 : 코드를 쉽게 모듈화 할 수 있으며, 변경이 필요한 경우 해당 모듈만 수정하여 유지보수가 쉬워진다.
4. 확장성 : 새로운 기능을 추가하거나 변경할 때 디자인 패턴을 활용하여 기존 코드를 변경하지 않고도 새로운 기능을 통합할 수 있다.
5. 안정성과 신뢰성 : 수많은 사람들이 인정한 모범 사례로 검증된 솔루션을 제공한다.
디자인 패턴은 객체지향의 특성 중 상속과 인터페이스, 합성(객체를 속성으로 사용)을 이용한다.
GoF(Gang of Four) 디자인 패턴에 나오는 23가지 패턴
생성 패턴
객체를 생성하여 구조를 변경해 목적을 달성하는 패턴이라고 생각함.
1. Singleton Pattern(싱글톤 패턴)
: 하나의 클래스 인스턴스를 전역에서 접근 가능하게 하면서 해당 인스턴스가 한번만 생성되도록 보장하는 패턴
2. Factory Method Pattern(팩토리 메서드 패턴)
: 객체를 생성하기 위한 인터페이스를 정의하고, 서브클래스에서 어떤 클래스의 인스턴스를 생성할지 결정하는 패턴
3. Abstract Factory Pattern(추상 팩터리 패턴)
: 관련된 객체들의 집합을 생성하는 인터페이스를 제공하며, 구체적인 팩토리 클래스를 통해 객체 생성을 추상화하는 패턴(?)
4. Builder Pattern(빌더 패턴)
: 복잡한 객체의 생성 과정을 단순화하고, 객체를 단계적으로 생성하며 구성하는 패턴
5. Prototype Pattern(프로토타입 패턴)
: 객체를 복제하여 새로운 객체를 생성하는 패턴으로, 기존 객체를 템플릿으로 사용하는 패턴이다.
구조 패턴
객체를 생성하여 더 유연한 구조를 만든다.
1. Adapter Pattern(어댑터 패턴)
: 인터페이스 호환성을 제공하지 않는 클래스를 사용하기 위해 래퍼를 제공하는 패턴이다.
2. Bridge Pattern(브릿지 패턴)
: 추상화와 구현을 분리하여 두 가지를 독립적으로 확장할 수 있는 패턴이다.
3. Composite Pattern(컴포지트 패턴)
: 개별 객체와 복합 객체를 동일하게 다루어, 트리 구조의 객체를 구성하는 패턴이다.
4. Decorator Pattern(데코레이터 패턴)
: 객체에 동적으로 새로운 기능을 추가하여 객체를 확장할 수 있는 패턴이다.
5. Facade Pattern(퍼사드 패턴)
: 서브시스템을 더 쉽게 사용할 수 있도록 단순한 인터페이스를 제공하는 패턴이다.
6. Flyweight Pattern(플라이웨이트 패턴)
: 공유 가능한 객체를 통해 메모리 사용을 최적화하는 패턴이다.
7. Proxy Pattern(프록시 패턴)
: 다른 객체에 대한 대리자(Proxy)를 제공하여 접근 제어, 지연 로딩 등을 구현하는 패턴이다.
행위 패턴
1. Observer Pattern(옵저버 패턴)
: 객체 간의 일대다 종속 관계를 정의하여 한 객체의 상태 변경이 다른 객체들에게 알려지도록 한다.
2. Strategy Pattern(전략 패턴)
: 알고리즘을 정의하고, 실행 중에 선택할 수 있게 한다.
3. Command Pattern(커맨드 패턴)
: 요청을 객체로 캡슐화하여 요청을 매개변수화 하고, 요청을 큐에 저장하거나 로깅하고 실행을 지연시킨다.
4. State Pattern(상태 패턴)
: 객체의 상태를 캡슐화하고, 상태 전환을 관리한다.
5. Chain of Responsibility Pattern(책임 연쇄 패턴)
: 요청을 보내는 객체와 이를 처리하는 객체를 분리하여, 다양한 처리자 중 하나가 요청을 처리한다.
6. Visitor Pattern(방문자 패턴)
: 객체 구조를 순회하면서 다양한 연산을 수행할 수 있게 한다.
7. Interpreter Pattern(인터프리터 패턴)
: 언어나 문법에 대한 해석기를 제공하여, 주어진 언어로 표현된 문제를 해결하는 패턴이다.
8. Memento Pattern(메멘토 패턴)
: 객체의 내부 상태를 저장하고 복원할 수 있는 기능을 제공하는 패턴이다.
9. Mediator Pattern(중재자 패턴)
: 객체 간의 상호 작용을 캡슐화하여, 객체 간의 직접적인 통신을 방지하는 패턴이다.
10. Template Method Pattern(템플릿 메서드 패턴)
: 알고리즘의 구조를 정의하면서 하위 클래스에서 각 단계의 구현을 제공하는 디자인 패턴이다.
11. Iterator Pattern(이터레이터 패턴)
: 컬렉션 내의 요소들에 접근하는 방법을 표준화하여 컬렉션의 내부 구조에 독립적으로 접근할 수 있는 패턴이다.
출처