Java Refactoring -3, 독립된 중복 메서드를 효율적으로 개선

박태건·2021년 7월 15일
3

리팩토링-자바

목록 보기
3/13
post-thumbnail

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

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

독립된 중복 메서드를 효율적으로 개선하기

클래스 내에서 관계가 독립적인 메서드는 시간이 지날수록 효율적으로 재사용되지 않는다.

  • 클래스 내 구성된 기능 중 멤버 변수와 메서드의 참조 없이 독립적으로 사용되는 기능이 존재
  • 이런 기능은 다른 객체를 구현할 때에도 사용할 수 있는 경우가 많다.
  • 이렇게 비슷한 기능을 Copy & Paste나, 클래스를 인스턴스화하여 사용하는 것은 유지보수 측면에서 정말 좋지 않다.

기능만을 반복하여 사용할 때 가장 좋은 방법은 독립적인 기능을 떼어 내어 모듈화하는 것.
↪ 모듈화하는 방법 중 하나는 static 메서드로 전환하여 참조 관계가 없는 독립된 기능을 만드는 것.

개선방향

독립된 공통 기능의 효과적인 사용

불필요하게 중복된 기능을 개선하는 방법으로 보통, 클래스 추출을 통해 기능을 재사용하도록 변경하는 것을 생각하게 된다.
↪ 보통은 클래스 추출로 해결이 되는 경우가 많으나, 효율적으로 개선이 되지 않는 경우가 존재.

현재 문제 상황을 가정해보자면,

공통된 로직을 가지면서, 각 위치에 있는 객체 내 멤버 변수나 다른 메서드와의 참조 관계가 전혀 없는 독립된 기능
이 경우에, 클래스 추출 후 사용할 때마다 인스턴스를 생성하는 것이 불필요할 수 있다.
따라서 static 메서드로 추출하여 효율적으로 공통 로직의 기능을 개선.

static

static 패턴이란

  • 자바에서 static으로 선언되면, 프로그램이 시작될 때 Static Area 메모리 영역에 올라 프로그램이 ㅈ종료될 때 사라진다.
  • 객체를 생성하지 않고 변수나 메서드를 사용 가능
  • static 메서드는 non static보다 먼저 생성되므로 non satic 필드나 메서드에는 접근 불가.

static으로 선언할 수 있는 대상

  • static 변수
    - 클래스 객체의 생성 없이 모든 객체가 공유 가능
  • static 메서드
    - 객체 내용에 의존적이지 않은 하나의 작업을 수행. static으로 선언된 메서드는 오버라이딩/상속받은 클래스에서 사용불가
  • static 초기화 블록
    - 클래스에서 static {} 형태로 지정 가능. 이렇게 지정된 블록은 클래스가 최초 로드될 때 한 번만 수행
  • static import
    - 다른 클래스에 있는 static 멤버들을 불러오기 위해 사용.
    • import static PackageName.ClassName
    • import static PackageName.ClassName.merberName (클래스명의 선언 없이 static 멤버만 선언해서 사용 가능)

static 메서드로 추출하기 위한 조건
1. 위치한 객체 내에서 독립적으로 사용되는 경우
2. 독립된 로직 사용 때문에 인스턴스 생성이 불필요한 경우
3. 공통 메서드로 추출할 때 사용 규약이 명확해야 하는 경우

레거시 코드

Payment Inerface

public interface Payment {
    /*
     * 결제
     */
    public void pay();

    /*
     * 결제 취소
     */
    public void cancel();
}

CreditCard 클래스

/* 신용카드 결제 담당 
 *
 * pay() 메서드를 사용하여 신용카드/실시간 계좌이체/마일리지 결제를 진행하고, 
 * totalCharge() 메서드로 객실 종류, 투숙 기간에 따른
 * 요금을 반환받아 결제 처리
 */

public class CreditCared implements Payment {
    private final int INSTALLMENT = 1;      // 할부
    private final int LUMP_SUM = 2;         // 일시불
    private final int STANDARAD = 1;
    private final int DELUXE = 2;
    private final int SUITE = 3;
    private final int STANDARD_CHARGE = 200000;
    private final int DELUXE_CHARGE = 300000;
    private final int SUITE_CHARGE = 400000;

    private String cardNumber;
    private String cvc;
    private String lastYear;
    private String lastMonth;
    private int paymentType;
    private String paymentCode;
    private String startDate;
    private String endDate;
    private int roomType;

    @Override
    public void pay() {
        float charge = totalCharge(roomType);
        if(!validateCardNumber(cardNumber)) {
            // 신용카드 번호 오류
        }
        
        if(creditCardPayment(cardNumber, cvc, lastYear, lastMonth, paymentType, charge)) {
            // 신용카드 결제 성공
        } else {
            // 신용카드 결제 실패
        }
    }

    @Override
    public void cancel() {
        // 신용카드 결제 취소
        if(paymentCancel(cardNumber, paymentCode)) {
            // 신용카드 결제 취소 성공
        } else {
            // 신용카드 결제 취소 실패
        }
    }

    // 신용카드 결제 취소
    public CreditCard(String cardNumber, String paymentCode) {
        this.cardNumer = cardNumber;
        this.paymentCode = paymentCode;
    }

    // 신용카드 결제
    public CreditCard(String cardNumber, String cvc, String lastYear, String lastMongth, int paymentCode, String startDate, String endDate, int roomType) {
        this.cardNumber = cardNumber;
        this.cvc = cvc;
        this.lastYear = lastYear;
        this.lastMonth = lastMongth;
        this.paymentCode = paymentCode;
        this.startDate = startDate;
        this.endDate = endDate;
        this.roomType = roomType;
    }
}

AccountTransfer 클래스

/* 실시간 계좌이체 결제 담당
 *
 * pay() 메서드를 사용하여 신용카드/실시간 계좌이체/마일리지 결제를 진행하고, 
 * totalCharge() 메서드로 객실 종류, 투숙 기간에 따른
 * 요금을 반환받아 결제 처리
 */

public class AccountTransfer implements Payment {
    private final int STANDARAD = 1;
    private final int DELUXE = 2;
    private final int SUITE = 3;
    private final int STANDARD_CHARGE = 200000;
    private final int DELUXE_CHARGE = 300000;
    private final int SUITE_CHARGE = 400000;

    private String bankName;
    private String accountNumber;
    private String paymentCode;
    private String startDate;
    private String endDate;
    private int roomType;

    @Override
    public void pay() {
        float charge = totalCharge(roomType);
        if(!validateAccountNumber(bankName, accountNumber)) {
            // 계좌번호 오류
        }
        
        if(AccountTransferPayment(bankName, accountNumber, paymentType, charge)) {
            // 계좌이체 성공
        } else {
            // 계좌이체 오류
        }
    }

    @Override
    public void cancel() {        
        if(paymentCancel(bankName, accountNumber, paymentCode)) {
            // 계좌이체 취소
        } else {
            // 계좌이체 취소 실패
        }
    }

    private boolean AccountTransferPayment(String bankName, String accountNumber, String paymentCode, float charge) {
        return true;
    }

    private boolean validateAccountNumber(String namkName2, String accountNumber2) {
        return true;
    }

    public AccountTransfer(String cardNumber, String paymentCode) {
        this.cardNumer = cardNumber;
        this.paymentCode = paymentCode;
    }

    public AccountTransfer(String bankName, String accountNumber, String startDate, String endDate, int roomType) {
        this.bankName = bankName;
        this.accountNumber = accountNumber;
        this.startDate = startDate;
        this.endDate = endDate;
        this.roomType = roomType;
    }

    public AccountTransfer(String bankName, String accountNumber, String paymentCode) {
        this.bankName = bankName;
        this.accountNumber = accountNumber;
        this.paymentCode = paymentCode;
    }
}

Mileage 클래스

/* 마일리지 결제 담당
 *
 * pay() 메서드를 사용하여 신용카드/실시간 계좌이체/마일리지 결제를 진행하고, 
 * totalCharge() 메서드로 객실 종류, 투숙 기간에 따른
 * 요금을 반환받아 결제 처리
 */

public class Mileage implements Payment {
    private final int STANDARAD = 1;
    private final int DELUXE = 2;
    private final int SUITE = 3;
    private final int STANDARD_CHARGE = 200000;
    private final int DELUXE_CHARGE = 300000;
    private final int SUITE_CHARGE = 400000;

    private String userId;
    private String paymentCode;
    private String startDate;
    private String endDate;
    private int roomType;

    @Override
    public void pay() {
        float charge = totalCharge(roomType);
        
        // 사용자 마일리지 조회
        int mileage = getUserMileage(userId);
        if(MileagePayment(mileage, userId, charge)) {
            // 마일리지 결제 성공
        } else {
            // 마일리지 결제 오류
        }
    }

    @Override
    public void cancel() {        
        if(paymentCancel(userId, paymentCode)) {
            // 마일리지 결제 취소
        } else {
            // 마일리지 결제 취소 실패
        }
    }

    private boolean MileagePayment(String userId, String accountNumber, String paymentCode, float charge) {
        return true;
    }

    private boolean validateAccountNumber(String namkName2, String accountNumber2) {
        return true;
    }

    public Mileage(String userId, String startDate, String endDate, int roomType) {
        this.userId = userId;
        this.startDate = startDate;
        this.endDate = endDate;
        this.roomType = roomType;
    }

    public Mileage(String userId, String paymentCode) {
        this.userId = userId;
        this.paymentCode = paymentCode;
    }
}

totalCharge 메서드

    private float totalCharge(int roomType) {
        float totalCharge = 0.0f;
        List<String> period = getPriod(startDate, endDate);
        
        for(String data : period) {
            float charge = 0.0f;
            float weigh = getDateCharge(date);

            if(roomType == STANDARD) {
                charge = STANDARD_CHARGE * weigh;
            } else if(roomType == DELUXE) {
                charge =  DELUXE_CHARGE * weigh;
            } else if(roomType == SUITE) {
                charge = SUITE_CHARGE * weigh;
            }

            totalCharge += charge;
        }

        DecimalFormat format = new DecimalFormat(".");
        return Float.parseFloat(format.format(totalCharge));
    }

    /*
     * 일자별 요금계산
     */

     private float getDateCharge(String date) {
         // 성수기: 기본요금 1.2배
         // 주말: 기본요금 1.3배

         List<String> weekend = new ArrayList<String>();
         weekend.add("Sat");
         weekend.add("Sun");

         List<String> peakSeason = new ArrayLst<String>();
         peakSeason.add("August");
         peakSeason.add("September");

         float weight = 0f;

        if(weekend.contains(getTodayWeek(date))) {
             weight += 1.3f;
        } else {
             weight += 1.0f;
        }

        if(weekend.contains(getTodayMonth(date))) {
            weight += 1.3f;
        } else {
            weight += 1.0f;
        }

        DecimalFormat format = new DecimalFormat(".##");
        return Float.parseFloat(format.format(weight));
     }

     /*
      * 해당일의 달을 반환
      */

    private String getTodayMonth(String date) {
        DateTimeFormatter formatter = DateTimeFormatter.forPattern("yyyyMMdd");
        DateTime dt = formatter.parseDateTime(date);
        return dt.monthOfYer().getAsText(Locale.US);
    }

    /*
     * 해당일의 요일을 반환
     */
    private List<String> getPriod(String startDate, String endDate) {
        List<String> period = new ArrayList<>();
        DateTimeFormatter formatter = DateTimeFormat.forPattern("yyyMMdd");
        DateTime startDt = formatter.parseDateTime(startDate);
        DateTime endDt = formatter.parseDateTime(endDate);
        int betweenDay = Days.daysBetween(startDt.withTimeAsStartOfDay(), endDt.withTimeAtStartOfDay()).getDays();
        for(int i=0; i<=betweenDay; i++) {
            period.add(formatter.print(startDt.plusDays(i)));
        }
        return period;
    }

모든 결제 클래스는 결제할 때 요금을 계산하는 totalCharge() 메서드를 이용한다.

  • 객실 종류, 투숙 기간에 따른 성수기와, 비성수기, 평일, 주말을 체크하여 총 결제 금액을 반환

  • 각 결제 클래스의 기능은 포함한 멤버 변수에 의존하여 처리된다.

  • 각 결제 클래스에서 공통으로 사용하는 요금 계산 기능인 totalCharge() 메서드와 해당 메서드가 호출하는 메서드들은 startDate, endDate 멤버 변수에 의존

레거시 코드 개선 과정

개선 과정 순서

  1. 요금관련 긴으을 담당하는 Charge 클래스를 생성 후, totalCharge() 메서드에서 사용하는 private 메서드들도 함께 읻오
  2. totalCharge() 메서드를 인스턴스 생성 없이 편리하게 사용하기 위해 static 메서드로 수정하며, totalCharge() 메서드에서 사용하는 메서드들 역시 static 메서드로 수정.
  3. totalCharge() 메서드를 static 메서드로 변경하고, startDate와 endDate를 파라미터로 추가하여 totalCharge() 에서 기존 클래스의 멤버 변수 의존 관계를 제거하고, 확실한 독립된 기능으로 변경하여 중복 기능 추출 및 독립적인 기능으로 만든다.

Charge 클래스

public class Charge {

    final static int STANDARD = 1;
    final static int DELUXE = 2;
    final static int SUITE = 3;
    final static int STANDARD_CHARGE = 1;
    final static int DELUXE_CHARGE = 1;
    final static int SUITE_CHARGE = 1;

    public static float totalCharge(String startDate, String endDate, int roomType) {
        float totalCharge = 0.0f;
        List<String> period = getPriod(startDate, endDate);
        
        for(String data : period) {
            float charge = 0.0f;
            float weigh = getDateCharge(date);

            if(roomType == STANDARD) {
                charge = STANDARD_CHARGE * weigh;
            } else if(roomType == DELUXE) {
                charge =  DELUXE_CHARGE * weigh;
            } else if(roomType == SUITE) {
                charge = SUITE_CHARGE * weigh;
            }

            totalCharge += charge;
        }

        DecimalFormat format = new DecimalFormat(".");
        return Float.parseFloat(format.format(totalCharge));
    }

    /*
     * 일자별 요금계산
     */

     public static float getDateCharge(String date) {
         // 성수기: 기본요금 1.2배
         // 주말: 기본요금 1.3배

         List<String> weekend = new ArrayList<String>();
         weekend.add("Sat");
         weekend.add("Sun");

         List<String> peakSeason = new ArrayLst<String>();
         peakSeason.add("August");
         peakSeason.add("September");

         float weight = 0f;

        if(weekend.contains(getTodayWeek(date))) {
             weight += 1.3f;
        } else {
             weight += 1.0f;
        }

        if(weekend.contains(getTodayMonth(date))) {
            weight += 1.3f;
        } else {
            weight += 1.0f;
        }

        DecimalFormat format = new DecimalFormat(".##");
        return Float.parseFloat(format.format(weight));
     }

     /*
      * 해당일의 달을 반환
      */

    public static String getTodayMonth(String date) {
        DateTimeFormatter formatter = DateTimeFormatter.forPattern("yyyyMMdd");
        DateTime dt = formatter.parseDateTime(date);
        return dt.monthOfYer().getAsText(Locale.US);
    }

    /*
     * 해당일의 요일을 반환
     */
    public static List<String> getPriod(String startDate, String endDate) {
        List<String> period = new ArrayList<>();
        DateTimeFormatter formatter = DateTimeFormat.forPattern("yyyMMdd");
        DateTime startDt = formatter.parseDateTime(startDate);
        DateTime endDt = formatter.parseDateTime(endDate);
        int betweenDay = Days.daysBetween(startDt.withTimeAsStartOfDay(), endDt.withTimeAtStartOfDay()).getDays();
        for(int i=0; i<=betweenDay; i++) {
            period.add(formatter.print(startDt.plusDays(i)));
        }
        return period;
    }
}

ChargeTest 클래스

public class ChargeTest extends TestCase {
    @Test
    public void testGetTotalCharge_기간별_호텔요금_계산() {
        //Given
        String startDate = "20140719";
        String endDate = "20140721";

        //When
        float charge = Charge.totalCharge(startDate, endDate, 1);
        
        //Then
        assertEquals("19/20/21", 720000f, charge);
    }

    @Test
    public void testGetDateCharge_해당_일에_대한_가중치_요금계산_주말() {
        //Given
        String date ="20240719";
        //When
        float weight = Charge.getDateCharge(date);
        //Then
        assertEuqals("비성수기_주말", 1.3f, weight);
    }

    @Test
    public void testGetDateCharge_해당_일에_대한_가중치_요금계산_성수기_주말() {
        //Given
        String date = "20140816";
        //When
        float weight = Charge.getDateCharge(date);
        //Then
        assertEquals("성수기_주말", 1.56f, weight);
    }

    @Test
    public void testGetTodayMonth_날짜를_입력받아_해당_월을_반환() {
        //Given
        String date = "20140719";
        //When
        String month = Charge.getTodayMonth(date);
        //Then
        assertEquals("July", month);
    }

    @Test
    public void testGetTodayMonth_날짜를_입력받아_해당_요일을_반환() {
        //Given
        String date = "20140719";
        //When
        String month = Charge.getTodayWekk(date);
        //Then
        assertEqulas("Sat", month);
    }

    @Test
    public void testGetPeriod_날짜사이의_일자를_리턴() {
        //Given
        String startDate = "20140719";
        String endDate = "20140725";

        //When
        List<String> betweenDay = Charge.getPriod(startDate, endDate);

        //Then
        assertEquals(7, betweenDay.size());
    }
}

수정 후 CreditCared 클래스

public class CreditCared implements Payment {
    private final int INSTALLMENT = 1;      // 할부
    private final int LUMP_SUM = 2;         // 일시불
    private final int STANDARAD = 1;

    private String cardNumber;
    private String cvc;
    private String lastYear;
    private String lastMonth;
    private int paymentType;
    private String paymentCode;
    private String startDate;
    private String endDate;
    private int roomType;

    @Override
    public void pay() {
        float charge = Charge.totalCharge(roomType);
        // 중략...
    }
    
    // 중략...
}

static으로 변경된 메서드는 독립적으로 수행 가능하므로 각 메서드에 대해서 테스트를 진행 가능하다.
단위 기능별로 테스트케이스를 통해 기능 변경이 발생할 때에도 사이트 이펙트를 방지 가능하며, 오류가 발생해도 수정하기 편하다.

개선된 레거시 코드

Charge 클래스

  • totalCharge() 메서드와 그와 관련된 메서드들이 static 메서드로 구성되어 새롭게 생성

CreditCard/AccountTransfer/Mileage 를래스

  • totalCharege() 멤버 메서드와 그와 관련된 멤버 메서드가 삭제.
  • Chrage 클래스의 totalCharge() 메서드로 결제 금액 계산을 구현.

요약 및 정리

레거시 코드에서 중복된 코드는 생산성 저하를 일으키는 원인이므로 서둘러 개선해야 한다.
static 메서드를 활용한 레거시 코드 개선은 중복 코드 개선 방법의 하나이다. 중복 코드 개선에서 주로 사용하는 방법은 메서드 추출이지만, static 메서드의 추출은 멤버 메서드의 추출과는 다른 상황으로 판단해야 한다.
단순히, 메서드 추출이 아닌 책임과 역할을 분리해야 하는 경우가 아닌지를 판단하고 클래스의 생성과 함께 메서드 추출로 개선해야 한다.

책임과 역할이 좀더 명확히 분리되고, 기능 오류가 발생했을 때 빠르게 문제점을 찾아낼 수있으며 관리 지점이 줄어들어 수정에 대한 부담감이 줄어든다.

이는 결국, 유지보수가 쉬워지고, 모듈화로 인한 재사용성 역시 개선됨을 의미.

독립된 중복 메서드를 효율적으로 개선하기 위한 의식의 흐름

  1. 비슷한 기능이 여러 곳에 구현되어 있는지를 확인
  2. 기능으로 구현된 메서드의 단위가 클래스의 멤버 메서드와 멤버 필드를 활용하고 있는지를 확인
  3. 멤버 필드와 멤버 메서드를 참조한다면 참조된 내용 역시 지역화가 가능한지 확인
  4. 모든 조건을 만족한다면 static 메서드를 구현
  5. static 메서드가 속한 클래스와 다른 역할이나 책임을 가지고 있다면 별도의 클래스를 만들어서 구현.
profile
노드 리액트 스프링 자바 등 웹개발에 관심이 많은 초보 개발자 입니다

0개의 댓글