배경
2000년대 초반 로버트 마틴(Uncle Bob)이 명명한 객체 지향 프로그래밍 및 설계의 5가지 기본 원칙입니다.
유연하고 재사용 가능한 시스템을 만들고자 할 때 이 원칙들을 적용할 수 있습니다.
원칙
1. SRP (Single Responsbility Principle, 단일 책임 원칙)
한 클래스는 하나의 책임만 가져야 한다는 원칙입니다.
쉽게 풀어 설명하면 요구사항이 변경에 따라 영향받는 요소가 한 가지여야 한다는 것입니다.
책임의 범위는 상황에 따라 다르거나 모호할 수 있지만 한 클래스의 변경에 대한 영향력이 적어야 한다는 의미입니다.
Controller -> Service -> Repository <- (UserRepository, PostRepository) 형태의 웹 애플리케이션 구조가 있다고 가정해 봅시다.
만약, Service가 두 Repository에 의존하고 있고 사용자(User) 등록/조회와 게시물(Post) 등록/조회에 대한 책임을 가지고 있다면 이는 단일 책임 원칙이 깨진 경우라고 할 수 있습니다. 사용자 기능이 변경되거나 게시물 기능이 변경되는 경우 모두 영향을 받기 때문입니다.
따라서 UserService, PostService로 분리하는 등의 책임을 나누는 작업이 필요합니다.
2. OCP (Open/Closed Principle, 개방 폐쇄 원칙)
소프트웨어 요소는 확장에는 열려(Open) 있으나 변경에는 닫혀(Closed) 있어야 한다는 원칙입니다.
기능을 추가/변경해야 할 때 이미 잘 동작하고 있던 원래 코드를 변경하지 않아도, 새로운 코드를 추가함으로써 목적을 달성할 수 있습니다.
UserService -> UserRepository (findById, findByEmail) <- InMemoryUserRepository의 구조를 가정해 봅시다.
만약 여기서 데이터베이스에 저장하고 싶다면 DatabaseUserRepository 구현체를 새롭게 정의해서 기능을 확장할 수 있습니다.
이때, 기존에 있던 UserService의 코드에는 영향을 주지 않게 됩니다. 이것이 바로 확장에는 열려있다는 의미입니다.
만약 데이터베이스에 저장하기 위한 구현체를 새롭게 추가하는 과정에서 인터페이스에 의존하는 것이 아니라 실제 구현체에 의존했다면 UserService의 Repository 레퍼런스 변수나 다른 코드가 영향을 받을 수 있습니다. 이는 변경에도 열려있다는 의미이기 때문에 원칙이 깨진다고 할 수 있습니다.
예시와 같이 인터페이스나 그 외 다양한 기법을 통해 변경에는 닫히도록 설계하여 기능 변경에 따른 영향을 최소화하는 것이 좋습니다.
3. LSP (Liskov Substitution Principle, 리스코프 치환 원칙)
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다는 원칙입니다.
부모 클래스의 잘 동작한 연산이 자식 클래스에서는 정상적으로 수행할 수 없는 상황을 피해야 한다는 것입니다.
아래의 코드 예시를 통해 리스코프 치환 원칙이 깨진 경우의 문제점을 이해할 수 있습니다.
// 직사각형
class Rectangle {
private int width;
private int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public long getArea() {
return width * height;
}
}
// 정사각형
class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
// 문제 예시
class Problem {
public static void main(String[] args) {
Rectangle a = new Rectangle();
a.setWidth(2);
a.setHeight(4);
System.out.println(a.getArea()); // 2 * 4 = 8
Square b = new Square();
b.setWidth(4);
b.setHeight(8);
System.out.println(b.getArea()); // 8 * 8 = 64
// 문제 발생
Rectangle c = new Square();
c.setWidth(2);
c.setHeight(4);
System.out.println(c.getArea()); // 4 * 4 = 16
// Rectangle에서 기대했던 것과 다르게 가로, 세로 길이가 모두 한 번에 설정되면서 목적 달성 X
}
}
직사각형 넓이를 구하는 getArea 메서드의 정확성이 깨지기 때문에 리스코프 치환 원칙을 위배했다고 해석할 수 있습니다.
리스코프 치환 원칙이 깨진 경우에는 관계 해석이 잘못 됐거나 부모, 자식 간의 관계를 뒤집을 필요가 있습니다.
4. ISP (Interface Segregation Principle, 인터페이스 분리 원칙)
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다는 원칙입니다.
큰 덩어리의 인터페이스들을 구체적인 작은 단위로 분리함으로써 명확성, 구체성을 높이고 역할 인터페이스 간 영향력을 줄일 수 있습니다.
단일 책임 원칙에서 설명했던 Service -> Repository <- (UserRepository, PostRepository) 구조에서 만약 Repository가 다음과 같이 메서드를 선언하고 있다고 가정해 봅시다.
interface Repository {
// 사용자 관련 메서드
User save(User user);
Optional<User> findById(Long id);
// 게시물 관련 메서드
Post save(Post post);
Optional<Post> findById(String id);
}
이를 구현한 UserRepository는 다음과 같을 것입니다.
class UserRepository implements Repository {
// 사용자 관련 메서드
@Override
public User save(User user) {
// 실제 사용자를 저장한 후 반환
// ...
return user;
}
@Override
public Optional<User> findById(Long id) {
// 실제 사용자를 찾은 후 반환
return ...;
}
// 게시물 관련 메서드
@Override
Post save(Post post) {
// 아무 것도 수행하지 않음
return null;
}
@Override
Optional<Post> findById(String id) {
// 아무 것도 수행하지 않음
return Optional.empty();
}
}
UserRepository에서 관련 없고 구현할 필요가 없는 Post 관련 메서드를 구현해야 합니다.
또 UserRepository를 사용하는 쪽에서는 불필요하게 Post 관련 메서드가 노출되어 어떤 메서드를 호출해야 하는지 헷갈릴 수 있습니다.
따라서 다음과 같이 인터페이스를 세분화하여 분리하는 것이 유지보수, 재사용 측면에서 유리할 가능성이 높습니다.
interface UserRepositoryInterface {
User save(User user);
Optional<User> findById(Long id);
}
interface PostRepositoryInterface {
Post save(Post post);
Optional<Post> findById(String id);
}
5. DIP (Dependency Inversion Principle, 의존관계 역전 원칙)
추상화에 의존해야지, 구체화에 의존하면 안 된다는 원칙입니다.
따라서, 저수준의 컴포넌트를 묶어 고수준 컴포넌트로 추상화하여 의존하도록 변경해야 합니다.
Controller -> Service -> (UserRepository, PostRepository) 에서 고수준 컴포넌트인 Repository 인터페이스를 정의하고 이를 UserRepository, PostRepository에서 각각 구현하게 되면 각 구현체들은 Repository 인터페이스에 의존하게 됩니다. 또한, Service가 구현체가 아닌 Repository를 의존하게 되면 위 구조는 아래와 같이 변경됩니다.
Controller -> Service -> Repository <- (UserRepository, PostRepository)
의존관계 역전이라고 설명하는 이유는 위의 빨간색 화살표처럼 의존관계 방향이 기존과 달리 반대쪽으로 향하기 때문입니다.
위 예시는 소프트웨어 확장, 변경에 따른 다른 코드에 영향을 줄인다는 관점에서 개방 폐쇄 원칙으로, 저수준 컴포넌트(구현체)를 고수준 컴포넌트(추상체)로 추상화한다는 관점에서 의존관계 역전 원칙으로 해석할 수 있습니다.
이 객체 지향 설계의 원칙을 준수하면서 다양한 시스템의 공통적인 특성을 일반화한 설계법을 디자인 패턴이라고 합니다.
다음에는 디자인 패턴에 대해서 다뤄보도록 하겠습니다.
'개발 > Java' 카테고리의 다른 글
[Java] Functional Interface와 익명 클래스 (0) | 2022.10.19 |
---|---|
[Java] Default Method (0) | 2022.10.19 |
[Java] JVM? JRE? JDK? (0) | 2022.10.18 |
객체지향 프로그래밍과 설계 (0) | 2022.10.18 |
[Java] String, StringBuilder, StringBuffer (0) | 2022.10.18 |