2024. 4. 13. 22:21ㆍJava/OOP
잡담
항상 자바의 캡슐화, 다형성, 추상화, 상속과 SOLID라는 원칙은 참 이해가 안된다. 디자인 패턴 책을 한 번 보고는 완전히 이해가 안돼서 계속 반복해서 본 것 같다. 그 과정에서 내가 이해한 바를 정리하고자 한다. 책에서 나온 예시도 그렇고 전체적인 내용은 다음 글의 작성자 분이 정말 잘 정리해주셨다고 생각한다.
개인적으로 이는 간단한 정리를 위한 것이며 필요할 때마다 예시를 찾고 싶다면 위의 링크 글 혹은 최범균님의 "개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴" 책의 2장을 읽어보길 바란다.
1. 단일 책임 원칙
계속 공부를 하다보면 단일 책임 원칙과 인터페이스 분리 원칙이 헷갈리기 시작한다. 둘 다 최대한 역할을 작게 만들어라 같은데 어떤 차이가 있는지 한 번 고민해보자.
단일 책임 원칙은 클래스의 책임에 관한 내용이다. 그런데, 책임을 정의하고 책임을 도출하는 행위는 정말 어렵다. 예를 들어, 뷰와 데이터 처리 관점으로 보면 클라이언트 내부에 해당 코드를 섞어놓는 경우, 이후에 뷰의 변경이 클라이언트에게 영향이 가고 데이터 처리도 클라이언트에 영향이 간다. 이는 적절히 뷰와 데이터 처리를 별도의 클래스로 빼서 추상화하고 조립을 이용하면 해결할 수 있다.
하지만, 위처럼 기능적인 내용 외에도 DI와 관련된 내용을 이해할 때, 생성의 책임 분리 같은 문제도 있었다. 아래는 과거 스프링 강의를 들으며 가장 이해가 안됐던 내용인데, 아래의 코드는 구체 클래스를 직접 생성까지 하는 역할과 서비스 기능까지 하는 역할이 존재한다는 것이다.
public class OrderServiceImpl implements OrderService {
private MemberRepository memberRepository = new MemoryMemberRepository();
private DiscountPolicy discountPolicy = new RateDiscountPolicy();
@Override
public Order createOrder(Long memberId, String itemName, int itemPrice) {
Member member = memberRepository.findById(memberId);
int discountPrice = discountPolicy.discount(member, itemPrice);
return new Order(memberId, itemName, itemPrice, discountPrice);
}
}
그래서 스프링에서는 스프링 컨테이너에서 컴포넌트 스캔을 통한 자동주입을 해주거나 수동으로 @Configuration 등록을 통해서 문제를 해결하게 해준다. 여기서 스프링 컨테이너의 책임은 구체 클래스를 적절한 대상에게 생성자 주입의 방식으로 전달해주는 것이다. 이런 종류의 책임은 경험해보지 않으면 분리할 방법에 관해서 찾기가 어렵다.
@Component
public class OrderServiceImpl implements OrderService {
private MemberRepository memberRepository;
private DiscountPolicy discountPolicy;
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy){
this.memberRepository = memberRepository;
this.discountPolicy = discountPolicy;
}
@Override
public Order createOrder(Long memberId, String itemName, int itemPrice) {
Member member = memberRepository.findById(memberId);
int discountPrice = discountPolicy.discount(member, itemPrice);
return new Order(memberId, itemName, itemPrice, discountPrice);
}
}
따라서, 단일 책임 원칙을 잘 지키는 방법은 경험을 쌓아서 책임의 종류에 관해서 익히는 것이다. 그리고 클래스에 정의된 메서드 내용이 너무 크지 않은가? 한 가지 이유가 아닌 여러 이유로 클래스 내용이 변경되지 않는가에 대해 지속적으로 고민하는 방법 밖에 없다고 생각한다.
2. 인터페이스 분리 원칙
인터페이스 분리 원칙은 책에서 가장 불만이 많은 부분이었다. 왜냐하면 C++에서의 컴파일과 링크의 관점에서 이야기를 했기 때문이다. 그래서 뭔가 와닿지 않았다. 또한 인터페이스 분리 원칙을 작게 만들라는 내용이 단일 책임 원칙과 연관이 있다고 해서 굉장히 이해가 안갔다.
인터페이스 분리 원칙을 이해하기 위해서는 인터페이스의 특징에 관해서 생각해보면 좋을 것 같다. 인터페이스는 상속 받는 클래스 모두에게 인터페이스에 정의된 내용을 오버라이드하여 모두 구현하게 강제한다.
스타크래프트를 예시로 들어보면 스타크래프트 유닛을 자바로 만든다고 하고 아래처럼 유닛을 정의하면 굉장히 곤란하다. 왜냐하면 테란 유닛은 자가 치유가 안되는 유닛도 있고, 메카닉이 아니라면 리페어도 불가능하다. 또한 오버로드 같이 공격이 불가능한 유닛도 있다. 그런데 아래의 Unit을 상속 받아 모든 유닛을 구현해버리면 사용하지 않는 메서드도 강제로 오버라이딩해서 빈칸으로 놔두어야 하는 문제가 발생한다.
public interface Unit {
void selfHealing();
void repair();
void move();
void attack();
void stop();
void pause();
}
결론은 위의 모든 내용이 모든 유닛에 관한 공통적 특징이 아님을 깨닫고 다른 방법을 찾아보는 게 좋다. 인터페이스를 정의할 때에는 가능한 하위 클래스와 추상화하는 내용의 범위에 관해서 고민해 보아야 한다. 그 과정에서 추상화된 개념 중 공통적인 특성만을 뽑아서 인터페이스를 정의해야 불필요한 상속이 없어진다. 그래서 인터페이스를 작게 기능 중심으로 인터페이스를 분리하라고 이야기를 한 것이며, 이게 마치 단일 책임 원칙과 다르지 않아보였다고 생각한다.
3. 개방-폐쇄 원칙
개방 폐쇄 원칙 또한 설명만 들으면 굉장히 이해가 어렵다. 변화에는 닫혀있고 확장에 대해서는 열려있단 말이 무슨 말인가 싶다. 이를 이해하려면 상속과 추상화에 관해서 잘 알고 있어야 한다고 생각한다. 아래와 같이 유저에 대해서 저장하는 코드가 있다고 해보자.
@RequiredArgsConstructor
public class UserRegister {
UserRepository userRepository;
PasswordEncoder passwordEncoder;
public void register(String username, String password) {
if(userRepository.isExists(username))
throw new DuplicateUserException();
String encodedPassword = passwordEncoder.encoding(password);
userRepository.save(username, encodedPassword);
}
}
public interface PasswordEncoder {
String encoding(String password);
}
public interface UserRepository {
public void save(String username, String password);
public boolean isExists(String username);
}
그럼 이제 BCrypt방식으로 암호화하는 PasswordEncoder를 만들고, Memory 내에서 작동하는 UserRepository를 아래처럼 만들 수 있다.
public class BCryptPasswordEncoder implements PasswordEncoder {
@Override
public String encoding(String password) {
// BCrypt 방식의 패스워드 암호화 구현
return password;
}
}
public class MemoryUserRepository implements UserRepository {
ConcurrentHashMap<String, String> users = new ConcurrentHashMap<>();
@Override
public void save(String username, String password) {
if(isExists(username))
throw new DuplicateUserException();
users.put(username, password);
}
@Override
public boolean isExists(String username) {
if(users.get(username) == null)
return false;
return true;
}
}
위처럼 코드를 작성하면, 실제 코드가 동작하기 전에 생성자 주입으로 받은 PasswordEncoder의 암호화 방식이 변경되거나 UserRepository에서 DB를 사용하건 인메모리 방식을 사용하건 상관없이 UserRegister의 내용은 변경되지 않는다. UserRegister는 변경되지 않았다 하지만, BCryptPasswordEncoder에서는 BCrypt암호화 방식이 추가되었고, UserRepository에서는 추가적인 자료 구조인 ConcurrentHashMap이 생겼다.
따라서 상속과 추상화를 통해서 추상화된 모듈에 의존하는 클라이언트의 변경에는 닫혀있고 추상화를 구현하는 구체 모듈은 확장할 수 있었다. 이 내용이 이해가 되면 어떻게 보면 가장 쉬운 내용이라고 생각한다.
4. 리스코프 치환 원칙
리스코프 치환 원칙은 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다는 내용이다. 이 말도 쉽지 않은데, 상위 클래스, 인터페이스를 정의했을 때 암묵적이건 명시적이건 규칙이 있을 것이고 그 규칙에서 벗어나는 행동을 해서는 안된다는 말이다.
예를 들어서 지갑이 있다고 해보자. 암묵적으로 지갑의 금액은 항상 0이상의 값을 가진다. 그리고 Wallet 클래스에서는 명시적으로 금액을 지불할 때에 지갑의 금액이 음수가 되는 것을 막을 수 있게 만들었고 예외까지 붙였다.
public abstract class Wallet {
int money;
public int getMoney() {
return money;
}
public void payAmount(int amount) {
if (money < amount)
throw new NotEnoughMoneyException();
money = money - amount;
}
}
그런데, 새로운 은행에서 전자 지갑을 만들었는데, 그 지갑에는 원래 금액에 대해 300만원까지 초과해서 사용이 가능하다. 따라서 Wallet을 상속 받아 RecfliWallet이라는 클래스를 만들고 다음처럼 정의를 했다.
public class RecfliWallet extends Wallet {
@Override
public void payAmount(int amount) {
if (money - amount < -300) {
throw new NotEnoughMoneyException();
}
money = money - amount;
}
}
이렇게 코드를 작성하면 어떤 문제가 발생할까? 특정 클라이언트가 추상화된 Wallet을 사용하고 있었다. 그리고 돈이 없는지 모르고 계속 결제를 했는데, 알고보니 잔고가 없어도 돈을 쓸 수 있는 RecfliWallet이라는 구체 클래스 때문이었다. 그러면 추상화된 Wallet을 사용하는 모든 클라이언트들은 예상하지 못한 결과를 확인해서 무엇이 문제인지 찾기 어려워진다.
따라서, 리스코프 치환 원칙은 추상화와 상속을 통해 기능을 확장해나가는 것은 좋은데, 기능의 확장이 확장전 규칙을 벗어나는 것을 막자는 것이다. 여기서는 값에 대한 내용이지만, 예외나 부가 기능 등 갑자기 생각하지 못한 현상들이 일어나는 모든 것을 의미한다.
만약에 이런 현상이 계속 반복된다면 추상화가 잘못된 인터페이스를 사용하는 것이거나 명세에 관한 확실한 공유가 덜된 것이니 이것에 관해서 꼭 확인을 하도록 하자! 또한 정사각형 직사각형 문제가 있는데, 실제로 우리가 생각한 개념으로 추상화하는 것과 리스코프 치환 원칙에서 요구하는 추상화가 다름을 고민해 볼 수 있어서 좋은 것 같다.
5. 의존 역전 원칙
의존 역전 원칙은 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다는 내용이다. 우선 고수준 모듈과 저수준 모듈의 차이는 무엇일까? 책에 있는 예시를 하나 가져왔다.
고수준 모듈 | 저수준 모듈 |
바이트 데이터를 읽어와 암호화 하고 결과 바이트 데이터를 쓴다. | 파일에서 바이트 데이터를 읽어온다. |
AES 알고리즘으로 암호화한다. | |
파일에 바이트 데이터를 쓴다. |
위의 예시를 보면 클라이언트 입장이 고수준 모듈이고 각각의 부가적으로 조립해서 쓰는 기능들이 저수준 모듈을 의미하는 것 같다. 보통 고수준 모듈을 쓰며 하위 내용이 바뀌는 경우, 그 모듈에서 추가적인 내용이 발생할 때마다 IF문을 사용해서 개별적으로 처리해주어야 한다. 하지만 추상화하고 DI를 통해 구체 클래스 주입을 받으면 개별적으로 처리해야 하는 문제를 해결할 수 있다. 이 과정이 의존 역전 원칙을 지키기 위한 방법이다.
아래의 플로우는 책에서 가져왔다. 의존 역전 원칙은 결과적으로 추상화에 대해서 잘 설명하기 위한 내용이라고 생각한다. 고수준 모듈과 저수준 모듈의 의존관계를 추상화라는 중간 단계를 두어 끊어내고 둘 다 추상화된 내용에 의존하게 함으로써 저수준의 변화를 고수준으로 가져가지 말라는 의미라고 생각한다.
그래서 결과적으로 의존 역전 원칙은 소스 코드의 의존 관계에 관한 내용이다. 런타임의 의존 관계는 보통 DI로 외부에서 FlowController를 생성하는 Configuration 같은 파일에 의해 결정된다. 이렇게 외부에서 DI를 통해 구체 클래스를 주입해주는 방식을 제어의 역전(IoC)라고 이야기를 한다. FlowController가 직접적으로 저수준 모듈을 선택하는게 아닌 외부에서 저수준 모듈을 선택하여 DI를 통해 구체 클래스를 주입해주기 때문이다. 결과적으로는 런타임 시 의존관계는 추상화 전과 똑같이 돌아온다.
따라서, 의존 역전 원칙은 추상화와 상속을 통해 모듈 간의 의존 방향을 결정해주는 방법에 관한 소개라고 생각된다. 결과적으로, 의존 역전 원칙과 DI가 합쳐져서 결과적으로 제어의 역전을 만들게 된다.
마지막으로 생각해보면 리스코프치환 원칙, 의존 역전 원칙, 개방 폐쇄 원칙 모두 다 똑같은 구조이다. 모두 추상화와 상속을 잘 쓰는 방법에 관한 설명이지만 그 의도와 목적을 분리해 설명하는 것 뿐이 아닌가 싶다.
[ 참고 자료 ]
https://mangkyu.tistory.com/194
https://en.wikipedia.org/wiki/Circle%E2%80%93ellipse_problem
개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴 - 최범균
'Java > OOP' 카테고리의 다른 글
스프링 컨테이너와 서비스 로케이터가 필요한 이유 (0) | 2024.04.16 |
---|---|
자바의 상속, 추상, 다형에 관하여(TIL) (0) | 2024.04.12 |
자바의 Getter에 대하여 (1) | 2024.04.12 |