오봉이와 함께하는 개발 블로그
내 코드가 그렇게 이상한가요? - 조건 분기 : 미궁처럼 복잡한 분기 처리를 무너뜨리는 방법 본문
조건 분기 : 미궁처럼 복잡한 분기 처리를 무너뜨리는 방법
아래 작성하는 내용들은 다소 불친절 할 수 있으나, 내가 책을 보고 느낀 내용을 작성하려 한다.
책 내용이 필요한 분은 구글에 검색하여 찾아보심이 좋을 거 같다.
조건 분기가 중첩되어 낮아지는 가독성
이번 장표는 조건 분기가 중첩되어 엄청나게 긴 중첩이 발생한 코드에 대해 어떻게 해결할 수 있는지 알려준다. early retrun
을 통해 해결하는 방법이다.
switch 조건문 중복
책에서는 마법 스킬 사용을 통해 예시를 들고 있다.
기존 버그없이 구현된 switch 코드가 있고, 해당 switch 코드는 각 n개의 클래스에 나눠서 구현되어 있다.
새로운 스킬이 추가되어 각 n개의 클래스에 모두 추가해줘야 하는데, 문제가 발생한다.
특정 클래스에는 case 구문을 추가했는데, 다른 클래스는 추가하지 않거나, 추가는 했는데 비어있다.
SRP를 지키기 위해서 각 클래스로 나눠놓고 사용해서 이런 불상사가 발생한 걸까? 그렇다면 하나는 알고 둘은 모르는 상황이다.
객체 지향 소프트웨어 설계에서는 SRP를 다음과 같이 설명한다.
소프트웨어 시스템이 선택지를 제공해야 한다면, 그 시스템 내부의 어떤 한 모듈만으로 모든 선택지를 파악할 수 있어야 한다.
정말 SRP를 지키고 싶었다면 해당 switch-case를 한 곳에 모아놓고 의미있게 사용했어야 한다. 이렇게 하면 누락하는 실수를 줄일 수 있을 것이다.
인터페이스로 switch 조건문 중복 해소하기
switch-case를 한 곳에 모아놓고 의미있게 사용하면 누락하는 실수를 줄일 수 있다. 책의 예시 코드는 스킬이 3개라서 간단하지만, 보통 RPG 게임을 생각했을 때 스킬이 과연 몇 개가 있는가 생각하면 저 switch-case는 정말 1,000 줄은 정말 보수적으로 잡아도 넘을 것이다.
클래스가 거대해지면 데이터와 로직의 관계를 알기 힘들어진다 한다. 즉, 유지 보수와 변경이 어려워진다. -> 비용 상승이다.
따라서 클래스가 거대해지면 클래스를 분할해야 한다. 근데 저런 조건 분기 클래스를 분할하면 다시 n개의 클래스에 분할되어 있는 switch-case를 모두 수정해야 하는가? 이건 문제가 있다.
그렇다면 어떻게 분할하냐면 인터페이스
를 통해 분할하면 된다.
Magic(...) {
switch (magicType) {
case fire:
// 매직 포인트 소비량 계산
// name 변경
// 공격력 계산
// 테크니컬포인트 소비량 계산
case lightning:
// 매직 포인트 소비량 계산
// name 변경
// 공격력 계산
// 테크니컬포인트 소비량 계산
case hellFire:
// 매직 포인트 소비량 계산
// name 변경
// 공격력 계산
// 테크니컬포인트 소비량 계산
default:
...
}
이런 로직이 있을 때 인터페이스로 분할하려면 다르게 처리하고 싶은 동작을 인터페이스의 메서드로 정의한다.
즉 위 예시로 하면
- 매직 포인트 소비량 계산
- name 변경
- 공격력 계산
- 테크니컬포인트 소비량 계산
4개가 메서드로 추출된다.
다음으로 인터페이스의 이름은 구현하고자 하는 것들의 공통된 점을 찾으면 된다.
마법이니까 Magic으로 한다.
interface Magic() {
String name();
int costMagicPoint();
int attackPower();
int costTechnicalPoint();
}
이제 종류별로 클래스를 생성해서(Fire implements Magic, Lightning implements Magic, HellFire implements Magic) switch-case에 있는 로직을 옮겨 담아 구현만 하면 된다.
그 후 책에서는 Map 구조를 활용한 전략 패턴을 통해 추상화된 인터페이스를 사용한다.
조건 분기 중복과 중첩
boolean isGoldCustomer(PurchaseHistory history) {
if (1000000 < history.totalAmount) {
if (10 <= history.purchaseFrequencyPerMonth) {
if (history.returnRate <= 0.001) {
return trune;
}
}
}
return false;
}
boolean isSilverCustomer(PurchaseHistory history) {
if (10 <= history.purchaseFrequencyPerMonth) {
if (history.returnRate <= 0.001) {
return trune;
}
}
return false;
}
이런 조건 중첩이 있을 때 인터페이스 추상화를 통해 해결하는 방법을 알려준다.
각 조건문에 들어가는 조건에 따라 인터페이스를 통해 추상화하고, 정책(Policy) 패턴을 통해 구현한다.
해당 정책을 모두 통과 여부에 따라 true, false를 반환하게 된다.
자세한 내용은 정책 패턴을 더 검색해보자.
이하 생략
이하 내용은 인터페이스 사용 방법에 대해 더 설명하고 있다. 힘들게 인터페이스 구현해놓고 instanceOf 사용하지 말자고 한다. 또 플래그 조건문을 인터페이스와 정책 패턴으로 변경하는 방법에 대해 알려준다.
마무리
이번 장은 인터페이스를 어떻게 사용하는 지에 대해 알려주는 것으로 보인다.
출처: https://mangkyu.tistory.com/194 [MangKyu's Diary:티스토리]
아주 좋은 글이 있어 첨부한다.
SOLID 원칙 중 내가 생각하기에 인터페이스와 직접적 연관이 있는 것은 OCP, ISP, LSP로 보인다
OCP
- 확장에 대해 열려있고 수정에 대해서는 닫혀있어야 한다.
- 확장에 열러있다
- 요구사항이 변경될 때 새로운 동작을 추가하여 애플리케이션의 기능을 확장할 수 있다.
- 수정에 닫혀있다
- 기존의 코드를 변경하지 수정하지 않고 애플리케이션의 동작을 추가하거나 변경할 수 있다.
- 확장에 열러있다
인터페이스를 사용하는 클라이언트 코드 입장에서 생각했을 때 동작 변경에 따라 구현체만 갈아 끼우면 되기 때문에 코드 변경도 필요없고(수정에 닫혀있다.), 인터페이스에 메서드 추가 후 구현체만 수정하면 되기 때문에 기능 추가도 용이하다.(확장에 열려있다.)
ISP
- 목적과 관심이 각기 다른 클라이언트가 있다면 인터페이스를 통해 적절하게 분리해야 한다.
- 클라이언트의 목적과 용도에 적합한 인터페이스만 제공한다.
- 덕분에 자신의 관심에 맞는 인터페이스에만 접근하여 불필요한 간섭을 최소화할 수 있다.
@Service
@RequireArgsConstructor
public class UserService {
private final PasswordEncoder passwordEncoder;
}
@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);
}
}
이런 코드가 있을 때 UserSerivce에서는 isCorrectPassword()가 필요 없다. 현재 PasswordEncoder만 주입받아 사용하므로 isCorrectPassword()에 접근 불가능 하여 잘~~ 분리된 것 처럼 보이지만
isCorrectPassword()가 필요한 다른 Authentication 로직이 있을 때는 얘기가 달라진다.
Authentication에 isCorrectPassword()를 사용하기 위해 PasswordEncoder를 주입 받아 사용하면 필요 없는 encryptPassword()도 접근이 가능해지는 문제가 있다. 불필요한 encryptPassword()에 접근이 가능해지는 순간 인터페이스 분리 원칙을 위반한다.
그렇다면 아래와 같이 코드 수정을 통해 해결할 수 있다.
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);
}
}
Authentication에서는 PasswordChecker를 주입받아 사용하도록 하자.
LSP
- 하위 타입은 상위 타입을 대체할 수 있어야 한다.
- 해당 객체를 사용하는 클라이언트는 상위 타입이 하위 타입으로 변경되어도, 차이점을 인식하지 못한 채 상위 타입의 퍼블릭 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다.
상위 클래스를 상속받아 하위 클래스를 정의했을 때 상위 클래스의 설계를 벗어나면 안 된다는 것이다.
@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);
}
}
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();
}
}
이런 코드를 클라이언트가 사용할 때 Rectangle이 아닌 Square가 파라미터로 입력되어도 문제 없을 것이다.
Rectangle rectangle = new Square();
resize(rectangle, 100, 150);
resize의 파라미터로 Square 객체가 입력되었기 때문에 아마 100 * 150이 아니라 150 * 150의 결과가 나올 것이다
즉, 자식 클래스가 부모 클래스를 대체할 수 없다.
여기서 대체 가능성을 결정해야 하는 것은 해당 객체를 이용하는 클라이언트 관정이다.
클라이언트 관점에서 세부 구현을 모른 상태로 호출만 했을 때 문제 없이 동작되어야 한다.
'이론' 카테고리의 다른 글
내 코드가 그렇게 이상한가요? - 응집도 : 흩어져 있는 것들 (2) | 2024.01.09 |
---|---|
CleanCode - 단위 테스트 (1) | 2024.01.04 |
CleanCode - 경계 (1) | 2024.01.04 |
CleanCode - 오류 처리 (0) | 2024.01.03 |
내 코드가 그렇게 이상한가요? - 컬렉션 : 중첩을 제거하는 구조화 테크닉 (0) | 2024.01.01 |