Java Refactoring -9, 잘못된 이해로 생긴 상속 구조 개선

박태건·2021년 7월 21일
0

리팩토링-자바

목록 보기
9/13
post-thumbnail

레거시 코드를 클린 코드로 누구나 쉽게, 리팩토링

위 책을 보면서 정리한 글입니다.

잘못된 이해로 생긴 상속 구조 개선

상속 구조는 초기 설계에 포함하여 설계하자

  • 보통 기존에 있던 클래스를 상속하여 비슷한 기능을 담당하는 새로운 클래스를 작성한다.
    • 상속을 통해 구현된 클래스는 부모 클래스의 기능을 직접 호출해 사용 가능하므로 적은 양의 코드로 기능 구현이 가능

  • 상속 사용에 있어 개발자들이 주로 하는 실수
    • 상속 구조를 초기 설계에 포함하여 정확한 'is-a' 관계로 구현하지 않는 것.
      • (상속 관계를 컴포넌트 개발 이후에 고려하는 것)
      • 공통된 기능을 추출하여 부모 객체를 만들어 상속하지만, 이 경우 공통 기능을 그대로 사용 가능하지만 잘못도니 상속으로 인해 수정과 확장성에 문제가 생긴다.

  • 잘못된 상속 사용은 계속되는 기능 확장으로 자식 클래스에서 사용하는 기능을 모두 부모 클래스에 두어 확장과 수정이 어려워지고, 가독성을 떨어트린다.
  • 특정 몇몇 기능을 사용하기 위한 불필요한 상속, 캡슐화 위배 때문에 구현한 클래스를 수정하게 어렵게 만들고, 부모 클래스의 수정이 자식 클래스에도 영향을 미치는 심각한 결과를 나타낸다.

  • 컴포지션 구조
    • 잘못된 상속 구조의 해결책
    • 새로운 기능의 클래스를 구현하면 컴포넌트 기능을 쉽게 재사용 가능
    • 컴포넌트의 기능을 수정할 때 영향 범위가 클라이언트가 아닌 사용하는 기능 클래스에 국한되는 수정 용이성

개선방향

필요한 것만 조립하자

해결 방법 : 결합도가 높은 상속 구조는 각 기능을 객체로 추출하여 필요한 모듈에서 사용하도록 컴포지션 구조로 변경해야 한다.

  • 개선된 구조에서 특정 기능을 수정하거나 추가하면 해당 기능을 사용하는 곳에만 영향을 미치기 때문에 각 기능에 대한 수정 및 추가에 대한 유연성과 가독성을 높힌다.

질문답

상속은 자바의 강력한 기능인데, 왜 상속을 가급적 피하고 컴포지션을 사용해야 할까

  • 상속은 클래스를 재사용할 수 있어 매우 편리하지만, 재사용, 수정, 확장면에서 상속보다 컴포지션이 용이하다.
    • 부모 클래스가 수정되었을 때 상속받아서 구현된 클래스 중 어떤 클래스가 어느 정도 영향을 받을지를 파악하기는 어렵다.
    • 같은 패키지 외에서 상속이 가능한 클래스는 지속적인 관리가 힘들어 유지보수가 어려워진다.
    • 따라서, 상속을 통한 재사용은 구현한 개발자가 아니라면 사용하지 않는 것이 좋다.
    • 해당 상속을 필수로 사용해야 한다면, 클래스 상속에 대한 문서를 정확하게 만들어 놓는 것이 중요.

생성만 별도로 분리하면 결제 실행은 어떻게 진행할까

  • 결제 객체들은 공통의 인터페이스로 상속하면 결제 요청 객체에서는 인터페이스로 결제 실행을 처리.
    • 결제 객체를 받는 요청객체에서는 어떤 객체를 반환하는지 알 필요 없이 결제만 수행 가능

별도의 생성 관리 개체를 사용하지 않고, 기존의 결제 요청 객체 내에서 결제 객체를 생성하는 메서드를 사용해도 되지 않을까

  • 기존의 객체는 결제를 요청하고 응답을 처리하는 기능을 가졌는데 이는 결제 객체 생성과는 무관한 기능
    • 나중에 다른곳에서 객체 생성을 재사용하는 상황이 생기면 엉뚱한 곳에서 객체 생성을 담당한다고 생각할 수 있다.

레거시 코드

Merge 클래스

  • 자식 클래스들이 사용할 공통 기능 또는 자식 클래스가 사용하는 기능을 포함하는 부모 클래스
    • 기능
      • 전체 고객의 전화번호 추출
      • 예약 고객의 전화번호 추출
      • 전체 고객에게 즉시 문자 발송
      • 전체 고객에게 예약 문자 발송
구분기능
정보 추출1. 발송해야할 전체 번화번호 추출
2. 예약 고객 전화번호 추출
3. 문자를 발송할 전체 고객의 수 추출
4. 문자를 발송할 예약 고객의 수 반환
발송 관련1. 예약 고객에게 문자 발송 예약 처리
2. 전체 고객에게 문자 발송 예약 처리
3. 예약 고객에게 문자 발송
4. 전체 고객에게 문자 발송
public class Merge {
    private DBManager dbManager;
    private SMSSender smsSender;

    public Merge() {
        dbManager = new DBManager();
        smsSender = new SMSSender();
    }

    public List<String> getAllPhoneNum() {
        // 발송해야 할 전체 전화번호 추출
        return dbManager.getAllMemberPhoneNum();
    }

    protected String getReservationPhoneNum(int reservationNum) {
        // 예약 고객 전화번호 추출
        return dbManager.getReservationMemberPhoneNum(reservationNum);
    }

    protected int getAllSendCount() {
        // 문자를 발송할 전체 고객의 수 추출
        return dbManager.getAllMemberCount();
    }

    protected int getReservationSendCount(int reservationNum) {
        // 문자를 발송할 예약 고객의 수 반환
        return dbManager.getReservationMemberCount(reservationNum);
    }

    protected boolean reserveTargetSendSMS(String message, String phoneNum) {
        // 예약 고객에게 문자 발송 예약 처리
        return smsSender.reserveTargetSMS(message, phoneNum);
    }

    protected boolean reserveAllSendSMS(String message, List<String> phoneList) {
        // 전체 고객에게 문자 발송 예약 처리
        return smsSender.reserveAllSMS(message, phoneList);
    }

    protected boolean sendTargetSMS(String message, String phoneNum) {
        // 예약 고객에게 문자 발송
        return smsSender.sendTargetSMS(message, phoneNum);
    }

    protected boolean sendAllSMS(String message, List<String> phoneList) {
        // 전체 고객에게 문자 발송
        return smsSender.sendAllSMS(message, phoneList);
    }
}

AllReservationMerge 클래스

public class AllReservationMerge extends Merge {

    public void merge(String message) {
        // 전체 가입 고객의 전화번호 추출
        List<String> phoneList = getAllPhoneNum();

        // 문자 발송할 전체 수 추출
        int allSendCnt = getAllSendCount();

        // 추출한 2개의 정보를 DB에 저장
        // 중략

        // SMS 발송
        boolean result = resrveAllSendSMS(message, phoneList);
    }

}

AllSendMerge 클래스

public class AllSendMerge extends Merge {

    public void merge(String message) {
        // 전체 가입 고객의 전화번호 추출
        List<String> phoneList = getAllPhoneNum();

        // 문자 발송할 전체 수 추출
        int allSendCnt = getAllSendCount();

        // 추출한 2개의 정보를 DB에 저장
        // 중략

        // SMS 발송
        boolean result = sendAllSMS(message, phoneList);
    }

}

TargetReservationMerge 클래스

public class TargetReservationMerge extends Merge {

    public void merge(String message) {
        // 전체 가입 고객의 전화번호 추출
        List<String> phoneList = getAllPhoneNum();

        // 문자 발송할 전체 수 추출
        int allSendCnt = getAllSendCount();

        // 추출한 2개의 정보를 DB에 저장
        // 중략

        // SMS 발송
        boolean result = reserveTargetSendSMS(message, phoneList);
    }

}

TargetSendMerge 클래스

public class TargetSendMerge extends Merge {

    public void merge(String message) {
        // 전체 가입 고객의 전화번호 추출
        List<String> phoneList = getAllPhoneNum();

        // 문자 발송할 전체 수 추출
        int allSendCnt = getAllSendCount();

        // 추출한 2개의 정보를 DB에 저장
        // 중략

        // SMS 발송
        boolean result = sendTargetSMS(message, phoneList);
    }

}

위 코드의 문제점
1. 비슷하거나 여러 곳에서 공통으로 사용하는 기능을 부모 클래스에서 집중하여 자식클래스에서 상속받아 사용
2. 계속 기능이 추가되면 부모 클래스가 거대화
3. 그에 따라 기능 수정의 유연성과 확장성을 어렵게 만드는 구조로 변하게 된다.

레거시 코드 개선 과정

컴포지션 구조를 이용하여 부모 클래스와 자식 클래스 간의 상속 구조를 개선

  • TargetMemberInfoManager, AllMemberInfoManager로 클래스를 추출하여 단일 책임의 원칙을 지키며 자식 클래스들은 해당 클래스를 사용하여 원하는 기능을 구현
  • 자식 클래스는 공통 인터페이스를 통해 기능을 사용하는 클라이언트에 공통된 개발 로직을 제공

  • 상속 구조를 컴포지션 구조로 변경함으로써 모든 기능이 집중된 부모 클래스를 제거
  • 구현된 컴포넌트를 사용하는 클라이언트에서는 기능 수정, 확장에 영향을 받지 않으면서 유연성을 확보

개선 순서

  1. Merge 클래스에서 전체 고객 기능 클래스 추출
  2. Merge 클래스에서 예약 고객 기능 클래스 추출
  3. 자식 클래스의 상속 구조 제거 및 추출 클래스 사용
  4. 자식 클래스의 공통 메서드에 해당하는 인터페이스 생성

단위 테스트를 이용한 개선의 흐름

  1. 추출할 기능에 대해 단위 테스트 작성
  2. 추출 기능에 해당하는 객체와 메서드 생성
  3. 기존 비즈니스 로직을 추출하여 이동
  4. 단위 테스트 성공 확인

전체 고객 관련 기능 클래스 추출

  • Merge 클래스에서 전체 고객에 관련된 기능을 클래스로 추출하기 위해 관련 기능에 대한 단위 테스트 작성
public class AllMemberInfoManagerTest {
    
    private AllMemberInfoManager allMemberInfoManager;

    @Before
    public void setUp() {
        allMemberInfoManager = new AllMemberInfoManager();
    }

    @Test
    public void testGetAllPhoneNum() {
        // Given

        // When
        List<String> result = allMemberInfoManager.getAllPhoneNum();

        // Then
        Assert.assertNotNull(result);
    }

    @Test
    public void testGetAllSendCount() {
        // Given

        // When
        int result = allMemberInfoManager.getAllSendCount();

        // Thean
        Assert.assertNotSame(0, result);
    }
}

AllMemberInfoManager 클래스

  • Merge 클래스에 있는 getAllPhoneNum() 메서드와 getAllSendCount() 메서드를 추출하여 새로 생성하는 AllMemberInfoManager
public class AllMemberInfoManager {
    private DBManager dbManager;

    public AllMemberInfoManager() {
        dbManager = new DBManger();
    }

    public List<String> getAllPhnoeNum() {
        // 전체 발송할 전화번호 추출
        return dbManager.getAllMemberPhoneNum();
    }

    public int getAllSendCount() {
        // 문자 발송할 인원 추출
        return dbManager.getAllMemberCount();
    }
}

예약 고객 관련 클래스 추출

  • Merge 클래스에서 전체 고객에 관련된 기능을 클래스로 추출하기 위해 관련 기능에 대한 단위 테스트 작성
public class TargetMemberInfoManagerTest {
    
    private TargetMemberInfoManager targetMemberInfoManager;

    @Before
    public void setUp() {
        targetMemberInfoManager = new TargetMemberInfoManager();
    }

    @Test
    public void testGetReservationPhoneNum() {
        // Given
        int reservationNum = 2413;

        // When
        List<String> result = targetMemberInfoManager.getReservationPhoneNum(reservationNum);

        // Then
        Assert.assertNotNull(result);
    }

    @Test
    public void testGetReservationSendCount() {
        // Given
        int reservationNum = 2413;

        // When
        int result = targetMemberInfoManager.getReservationSendCount(reservationNum);

        // Thean
        Assert.assertNotSame(1, result);
    }
}

TargetMemberInfoManager 클래스

  • Merge 클래스에 있는 getReservationPhoneNum()와 getReservationSendCount() 메서드를 추출하여 새로 생성한 클래스
public class TargetMemberInfoManager {
    private DBManager dbManager;

    public TargetMemberInfoManager() {
        dbManager = new DBManger();
    }

    public List<String> getReservationPhoneNum(int reservationNum) {
        // 예약 고객 전화번호 정보 추출
        return dbManager.getReservationPhoneNum(reservationNum);
    }

    public int getReservationSendCount(int reservationNum) {
        // 문자 발송할 인원 추출
        return dbManager.getAllMemberCount(reservationNum);
    }
}

자식 클래스의 공통 메서드에 해당하는 인터페이스 생성

  • 해당 컴포넌트를 사용하는 클라이언트에서 사용 가능한 인터페이스 생성
  • 클라이언트에서 비슷한 기능을 사용할 때 동일한 로직으로 구서오딜 수 있도록 인터페이스가 존재해야 한다.
  • 각 컴포넌트 관계가 느슨하게 되며, 컴포넌트 내부의 수정 및 확장에 컴포넌트만 영향 받을뿐 실제 사용하는 클라이언트에는 아무런 영향을 끼치지 않게 된다.
// 이게 정확한지는 잘 모르겠다.. 책에서는 reservationNum에 관한 내용이 빠져있었다.
// 이렇게 되면 reservationNum을 필요하지 않아도 AllReservationMerge나, AllSendMerge에서는 구현해야 하고
// 또한, TargetReservationMerge, TargetSendMerge에서는 필요하지 않은 merge(message) 메서드를 구현해야 한다.

public interface Merge {
	public void merge(String message);
    public void merge(String message, int reservationNum);
}

상속 구조 제거 및 추출 클래스 사용 및 인터페이스 구현

  • 기존 Merge 클래스의 기능을 클래스로 추출하였고, 마지막으로 상속 구조를 개선
  • 문자 발송은 직접 필요한 곳에서 SMSSender를 사용할 예정이므로 Merge는 필요하지 않다.
  • Merge를 제거하고 자식 클래스에서 상속 제거 후, 추출한 클래스들을 사용하도록 수정

  • 추출된 클래스들은 각각 필요한 클래스를 사용하여 원하는 기능을 구현하고 있다.
  • 이렇게 함으로써 기능 추가시에도 원하는 기능의 클래스를 조합하여 기능을 구현하며 되므로 수정과 확장이 용이하게 된다.

AllReservationMerge

// 전체 고객 예약 발송
public class AllReservationnMerge implements Merge {
	@Override
    public void merge(String message) {
        AllMemberInfoManager allMemberInfoManager = new AllMemberInfoManager();
        SMSSender smsSender = new SMSSender();

        // 전체 고객 전화번호 정보 추출
        List<String> phoneList = allMemberInfoManager.getAllPhoneNum();

        // 전체 발송 수량 추출
        int allSendCnt = allMemberInfoManager.getAllSendCount();

        // 추출한 2개 정보 DB 저장
        // 중략

        // SMS 발송
        boolean result = smsSender.reservationAllSMS(message, phoneList);
    }
    
    @Override
    public void merge(String message, int reservationNum) {
    	//
    }
}

AllSendMerge

// 전체 고객 즉시 발송
public class AllSendMerge implements Merge {
    @Override
    public void merge(String message) {
        AllMemberInfoManager allMemberInfoManager = new AllMemberInfoManager();
        SMSender smsSender = new SMSSender();

        // 전체 고객의 전화번호 정보 추출
        List<String> phoneList = allMemberInfoManager.getAllPhoneNum();

        // 전체 발송 수량 추출
        int allSendCnt = allMemberInfoManager.getAllSendCount();
        
        // 추출한 2개 정보 DB 저장
        // 중략

        // SMS 발송
        boolean result = smsSender.sendAllSMS(message, phoneList);
    }
    
    @Override
    public void merge(String message, int reservationNum) {
    	//
    }
}

TargetReservationMerge

// 예약 고객 예약 발송
public class TargetReservationMerge implements ReservationMerge  {
    @Override
    public void merge(String message) {
    	//
    }
    
    @Override
    public void merge(String message, int reservationNum) {
        TargetMemberInfoManager targetMemberInfoManager = new TargetMemberInfoManager();
        SMSender smsSender = new SMSSender();

        // 전체 고객의 전화번호 정보 추출
        List<String> phoneList = targetMemberInfoManager.getReservationPhoneNum(reservationNum);

        // 전체 발송 수량 추출
        int allSendCnt = targetMemberInfoManager.getReservationSendCount(reservationNum);

        // 추출한 2개 정보 DB 저장
        // 중략

        // SMS 발송
        boolean result = smsSender.reserveTargetSMS(message, phoneList);
    }
}

TargetReservationMerge

// 예약 고객 즉시 발송
public class TargetReservationMerge implements ReservationMerge {
	@Override
    public void merge(String message) {
    	//
    }
    
    @Override
    public void merge(String message, int reservationNum) {
        TargetMemberInfoManager targetMemberInfoManager = new TargetMemberInfoManager();
        SMSender smsSender = new SMSSender();

        // 전체 고객의 전화번호 정보 추출
        List<String> phoneList = targetMemberInfoManager.getReservationPhoneNum(reservationNum);

        // 전체 발송 수량 추출
        int allSendCnt = targetMemberInfoManager.getReservationSendCount(reservationNum);

        // 추출한 2개 정보 DB 저장
        // 중략

        // SMS 발송
        boolean result = smsSender.sendTargetSMS(message, phoneList);
    }
}

개선된 레거시 코드

요약 및 정리

기능 재사용의 강력함, 단숨함에 의해 상속을 남용하면 프로젝트를 수정하기 어려워진다.

  • 컴포지션 구조로 정확한 기능의 클래스를 구현하여 필요한 곳에 원하는 기능의 클래스를 사용해야 한다.
  • 컴포넌트의 수정 및 확장 시 그 영향이 해당 컴포넌트에 국한되고 사용하는 클라이언트에는 미치지 않도록 하는 생각의 흐름을 길러야 한다.

상속 구조 개선을 위한 생각의 흐름

  1. 부모와 자식 관계인지 확인
  2. 부모 클래스가 자식 클래스에서 사용하는 공통 기능을 모두 담고 잇는 거대 클래스인지 확인
  3. 부모 클래스의 기능을 분류하여 클래스로 추출
  4. 컴포넌트에서 원하는 기능의 클래스를 조합하여 사용
  5. 인터페이스를 추출하여 클라이언트에 일관된 기능 호출 방식을 제공하고 컴포넌트와의 결합도를 낮춘다
profile
노드 리액트 스프링 자바 등 웹개발에 관심이 많은 초보 개발자 입니다

0개의 댓글