SSAFY-Book-Study / modern-java-in-action

모던 자바 인 액션 북스터디입니다.
1 stars 10 forks source link

3 Weeks - [리팩터링의 방법] #53

Open DeveloperYard opened 1 year ago

DeveloperYard commented 1 year ago

문제

코드를 구현하면서 유지보수성이 중요해짐에 따라 리팩터링의 중요성이 떠오르는데 코드의 리팩터링 방법에는 어떤 것들이 있을까요?

contents - 세부 내용

코드의 크기가 커지면서 리팩터링의 필요성이 높아지는데, 책에서는 리팩터링 방법으로 디자인 패턴이 제시되고 있습니다. 디자인 패턴 외에 리팩터링을 사용해서 사용해서 얻을 수 있는 이점과 디자인 패턴 외에 어떤 방법을 이용해 코드를 깔끔하게 정리할 수 있을 지 궁금합니다.

참고

293p

Seongeuniii commented 1 year ago

[리팩토링] 캡슐화

모듈을 분리하는 가장 중요한 기준은 노출되지 않아야 하는 정보를 얼마나 잘 숨기는지이다. 이는 캡슐화를 통해 가능해진다.

1. 컬렉션 캡슐화하기

가변 데이터를 캡슐화하면 어떤 장점이 있을까?

가변 데이터를 캡슐화하면 데이터가 언제 어떻게 변경되는지 추적하기 쉬워진다. 따라서 필요한 시점에 데이터 구조를 변경하기도 쉬워진다.

컬렉션 캡슐화 과정에서의 실수?

public List<Course> getCourses() { return courses }

컬렉션 변수로의 접근을 캡슐화하면서 getter가 컬렉션의 원본을 반환하도록 하면, 그 컬렉션을 감싼 클래스가 눈치채지 못한 상태에서 컬렉션의 원소들이 바뀌어버릴 수 있다.

수업(course) 목록을 필드로 지니고 있는 Person 클래스를 예시로 들어보자.

public class Person {
    protected String name; 
    protected List<Course> courses = new ArrayList<>();

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public List<Course> getCourses() {
        return courses;
    }

    public void setCourses(List<Course> courses) {
        this.courses = courses;
    }
}

public class Course {
    protected String name; 
    protected boolean isAdvanced;

        public Course (String name, boolean isAdvanced) {
        this.name = name;
                this.isAdvanced = isAdvanced;
    }

    public String getName() {
        return name;
    }

    public boolean isAdvanced() {
        return isAdvanced;
    }
}

모든 필드가 접근자 메서드로 보호받고 있으니 데이터가 캡슐화된 것일까?

person.courses.add(new Course(name)); // 컬렉션 임의 수정

컬렉션을 제어하는 메소드가 없다는 문제가 있다. 누구든 setter를 통해 컬렉션을 임의로 수정할 수 있으므로 캡슐화가 깨진다.

public class Person {
        ...
    public void addCourse(Course course) {
        this.courses.add(course);
    }

    public void removeCourse(Course course) {
        this.courses.remove(course);
    }

    public void removeCourse(int index) {
        try {
            this.courses.remove(index);   
        } catch (IndexOutOfBoundsException e) {
            throw e; 
        }
    }
}

1️⃣컬렉션에 원소를 하나씩 추가(addCourse)/제거(remove)하는 함수를 추가했다. 컬렉션 안의 요소를 제거할 때 에러를 잡을 수 있도록 해놨다. 상황에 맞게 호출자가 대응할 여지를 남겨둘 수 있는 것이다.

person.addCourse(new Course(name, false));

2️⃣ 컬렉션의 변경자를 직접 호출하던 부분을 모두 찾아 추가한 메서드를 사용하도록 수정한다.

public class Person {
        ...
        public void setCourses(List<Course> courses) {
        this.courses = courses; // 1. 제거하거나
                this.courses = new ArrayList<>(); // 2. 컬렉션 복제해 저장 
    }
        ...
}

3️⃣컬렉션 자체를 통째로 바꾸는 setter는 제거한다. 제거할 수 없다면 인수로 받은 컬렉션을 복제해 저장한다.

public class Person {
        ...
        public List<Course> getCourses() {
                // return courses;
        return new ArrayList<>(courses); // 복사본 제공
    }
        ...
}

4️⃣ 새롭게 추가한 메서드들을 통해서만 컬렉션을 변경할 수 있도록 하고싶다면, getter를 수정해서 읽기 전용 프락시나 복제본을 반환하도록 한다.

2. 기본형을 객체로 바꾸기

레코드 구조에서 데이터를 읽어들이는 단순한 주문 (Order) 클래스를 예시로 들어보자.

public class Order {
    protected String priority;

    public Order(String priority) {
        this.priority = priority;
    }
}

현재 priority는 기본형 데이터이다. priority가 전용 클래스로 생성되고 사용되는 과정을 볼 것이다.

public class Order {
        protected String priority;

        public Order(String priority) {
        this.priority = priority;
    }

    public String getPriority() {
        return priority;
    }

    public void setPriority(String priority) {
        this.priority = priority;
    }
}

1️⃣ 데이터를 객체로 다루기 전에 항상 변수부터 캡슐화한다.

*priority setter를 생성자에서 사용하여 필드를 자가 캡슐화하면 필드 이름을 바꿔도 클라이언트 코드를 유지할 수 있다.

public class Priority {
    protected String value;

    public Priority(String value) {
        this.value = value;
    }

    public String toString() {
        return value; 
    }
}

2️⃣ Priority 클래스를 만든다. 문자열을 요청한 클라이언트 입장에서는 getter 보다 toString이라는 메서드가 더 자연스러울 수 있다.

public class Order {
    protected Priority priority;

    public Order(String priority) {
        this.priority = new Priority(priority);
    }

    public String getPriorityString() {
        return priority.toString();
    }

    public void setPriority(String priority) {
        this.priority = new Priority(priority);
    }
}

3️⃣ Priority 클래스를 사용하도록 Order 클래스의 접근자들을 수정한다.

기본형 데이터였던 priority가 객체로써 이용되고 있다. 이제 priority를 비교하는 작업과 같은 요구사항이 들어오면 Priority 클래스 메서드로 추가해주면 된다.

3. 임시 변수를 질의 함수로 바꾸기

임시 변수는 계산된 결과를 반복적으로 계산하지 않기 위해 사용한다. 함수 안에서 사용하는 임시 변수를 메서드로 추출하는 것이 유용할 때가 있다.

예시를 통해 알아보자.

public class Order {
    protected int quantity;
    protected Item item;

    public Order(int quantity, Item item) {
        this.quantity = quantity;
        this.item = item;
    }

    public double getPrice() {
        int basePrice = quantity * item.price;
        double discountFactor = 0.98; 

        if (basePrice > 1000) discountFactor -= 0.03; 
        return basePrice * discountFactor;
    }
}

1️⃣ 변수가 사용되기 전에 값이 확실히 결정되는지, 매번 다른 결과를 내지는 않는지 확인한다.

현재 코드에서 임시 변수 basePrice와 discountFactor는 매번 다른 결과를 낼 수 있는 값이므로 메서드로 바꿔보자.

public class Order {
    protected int quantity;
    protected Item item;

    public Order(int quantity, Item item) {
        this.quantity = quantity;
        this.item = item;
    }

    public double getPrice() {
                // int basePrice = quantity * item.price;
        double discountFactor = 0.98;

        if (getBasePrice() > 1000) discountFactor -= 0.03;
        return getBasePrice() * discountFactor;
    }

    private int getBasePrice() { 
        return quantity * item.price;
    }
}

2️⃣ basePrice 변수는 읽기 전용으로 사용할 수 있기 때문에 먼저 빼냈다.

*읽기 전용 변수가 아닌 경우에 메서드로 빼기 어렵기 때문에 임시 변수에 final 키워드를 통해 읽기 전용인지 확인해 볼 수 있다.

public class Order {
    protected int quantity;
    protected Item item;

    public Order(int quantity, Item item) {
        this.quantity = quantity;
        this.item = item;
    }

    public double getPrice() {
                return getBasePrice() * getDiscountFactor();
    }

    private int getBasePrice() {
        return quantity * item.price;
    }

        private double getDiscountFactor() {
                double discountFactor = 0.98;
                if (getBasePrice() > 1000) discountFactor -= 0.03;
                return discountFactor;
        }
}

3️⃣ discountFactor 변수를 빼내야 하는데 이는 대입하는 경우가 있으므로 이 부분까지 고려해서 함수를 추출해야 한다.

4. 클래스 추출하기

복잡해지는 클래스

클래스는 반드시 명확하게 추상화하고 소수의 주어진 역할만 수행해야 한다. 하지만 역할이 많아질수록 클래스가 점점 커지는 경우가 많다.

클래스를 분리하라는 신호?

단순한 Person 클래스를 예시로 알아보자.

public class Person {
    protected String name;
    protected String officeAreaCode;
    protected String officeNumber;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getOfficeAreaCode() {
        return officeAreaCode;
    }

    public void setOfficeAreaCode(String officeAreaCode) {
        this.officeAreaCode = officeAreaCode;
    }

    public String getOfficeNumber() {
        return officeNumber;
    }

    public void setOfficeNumber(String officeNumber) {
        this.officeNumber = officeNumber;
    }
}

1️⃣ 클래스의 역할을 어떻게 분리해야 할까. 여기서는 전화번호 관련 동작을 별도의 클래스로 뽑아볼 수 있다.

public class TelephoneNumber { }

2️⃣ TelephoneNumber 클래스를 새로 정의한다.

public class Person {
    protected String name;
    protected String officeNumber;
    protected TelephoneNumber telephoneNumber;

    public Person() {
        telephoneNumber = new TelephoneNumber();
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    public String getOfficeAreaCode() {
        return telephoneNumber.officeAreaCode;
    }

    public void setOfficeAreaCode(String officeAreaCode) {
        this.telephoneNumber.officeAreaCode = officeAreaCode;
    }

    public String getOfficeNumber() {
        return officeNumber;
    }

    public void setOfficeNumber(String officeNumber) {
        this.officeNumber = officeNumber;
    }
}

3️⃣ 원래 클래스 Person의 생성자에서 새로운 클래스의 인스턴스를 생성하여 필드에 저장해둔다. 즉, Person 클래스의 인스턴스를 생성할 때 전화번호 인스턴스도 함께 생성되는 것이다.

public class Person {
    protected String name;
    protected TelephoneNumber telephoneNumber;

    public Person() {
        telephoneNumber = new TelephoneNumber();
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    public String getOfficeAreaCode() {
        return telephoneNumber.officeAreaCode;
    }

    public void setOfficeAreaCode(String officeAreaCode) {
        this.telephoneNumber.officeAreaCode = officeAreaCode;
    }

    public String getOfficeNumber() {
        return this.telephoneNumber.officeNumber;
    }

    public void setOfficeNumber(String officeNumber) {
        this.telephoneNumber.officeNumber = officeNumber;
    }
}
public class TelephoneNumber {
    protected String officeAreaCode;
    protected String officeNumber;
}

4️⃣ 필드들을 하나씩 새 클래스로 옮긴다.

public class Person {
    protected String name;
    protected TelephoneNumber telephoneNumber;

    public Person() {
        telephoneNumber = new TelephoneNumber();
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    public String getOfficeAreaCode() {
        return telephoneNumber.getAreaCode();
    }

    public void setOfficeAreaCode(String officeAreaCode) {
        this.telephoneNumber.setAreaCode(officeAreaCode);
    }

    public String getOfficeNumber() {
        return this.telephoneNumber.getNumber();
    }

    public void setOfficeNumber(String officeNumber) {
        this.telephoneNumber.setNumber(officeNumber);
    }
}
public class TelephoneNumber {
    protected String areaCode;
    protected String number;

    public String getAreaCode() {
        return areaCode;
    }

    public void setAreaCode(String areaCode) {
        this.areaCode = areaCode;
    }

    public String getNumber() {
        return number;
    }

    public void setNumber(String number) {
        this.number = number;
    }
}

5️⃣ 메서드를 옮기고 메서드 이름은 새로운 환경에 맞게 바꾼다. TelephoneNumber 클래스는 순수한 전화번호를 의미하므로 office란 단어를 쓸 이유가 없다.

이후에 전화번호와 관련된 동작이 필요하다면 TelephoneNumber 클래스에 추가하면된다.

5. 클래스 인라인 하기

클래스 인라인이란

클래스 추출하기와 반대되는 리팩토링 기법이다. 더 이상 제 역할을 하지 못하는 클래스가 있을 때 인라인해버린다.

언제 클래스 인라인 기법을 사용할까?

배송 추적 정보를 표현하는 TrackingInformation 클래스를 예시로 알아보자.

public class TrackingInformation {
    protected String shippingCompany;
        protected String trackingNumber;

    public String display() {
        return String.format("%s: %s", shippingCompany, trackingNumber);
    }

    public String getShippingCompany() {
        return shippingCompany;
    }

    public void setShippingCompany(String shippingCompany) {
        this.shippingCompany = shippingCompany;
    }

    public String getTrackingNumber() {
        return trackingNumber;
    }

    public void setTrackingNumber(String trackingNumber) {
        this.trackingNumber = trackingNumber;
    }
}
public class Shipment {
    protected TrackingInformation trackingInformation;

    public String trackingInfo() {
        return trackingInformation.display();
    }

    public TrackingInformation getTrackingInformation() {
        return trackingInformation;
    }

    public void setTrackingInformation(TrackingInformation trackingInformation) {
        this.trackingInformation = trackingInformation;
    }
}

TrackingInformation 클래스는 배송 (Shipment) 클래스의 일부처럼 사용된다.

TrackingInformation은 예전에는 유용했을지 몰라도 현재는 제 역할을 하고 있지 못하다. TrackingInformation 클래스를 Shipment 클래스로 인라인하자.

public class Shipment {
    protected TrackingInformation trackingInformation;
    protected String shippingCompany;
    protected String trackingNumber;

    public String display() {
        return String.format("%s: %s", shippingCompany, trackingNumber);
    }

    public String trackingInfo() {
        return trackingInformation.display();
    }

    public TrackingInformation getTrackingInformation() {
        return trackingInformation;
    }

    public void setTrackingInformation(TrackingInformation trackingInformation) {
        this.trackingInformation = trackingInformation;
    }

    public String getShippingCompany() {
        return shippingCompany;
    }

    public void setShippingCompany(String shippingCompany) {
        this.shippingCompany = shippingCompany;
    }

    public String getTrackingNumber() {
        return trackingNumber;
    }

    public void setTrackingNumber(String trackingNumber) {
        this.trackingNumber = trackingNumber;
    }
}

먼저 기존의 TrackingInformation 에서 사용하는 메소드들을 모두 Shipment 클래스로 옮긴다. 그 다음 TrackingInformation 의 모든 요소를 옮긴다.다 옮겼다면 TrackingInformation 클래스를 지운다.

6. 위임 숨기기

모듈화 설계의 핵심

모듈화 설계의 핵심은 캡슐화다. 캡슐화는 모듈이 노출하는 요소를 제한해서 꼭 필요한 부분을 위주로 협력하도록 해준다.

캡슐화가 잘 되어 있다면?

캡슐화가 잘 되어 있다면 무언가를 변경할 때 함께 고려해야 할 모듈의 수가 줄어들어 코드를 변경하기 쉬워진다. 예를 들어, 한 객체가 다른 객체의 메서드를 호출하려면 그 객체를 알아야 한다. 이때 호출 당하는 객체의 인터페이스가 변경되면 그 객체를 알고 있는 모든 객체를 변경해야 한다. 이런 경우가 예상된다면 객체를 노출시키지 않으면 된다. 즉, 객체가 다른 객체를 안되는 경우와 객체와 다른 객체가 결합하면 안되는 경우에 이 기법을 쓰면 좋다.

사람(person) 과 사람이 속한 부서(department) 클래스를 예시로 알아보자.

public class Person {
    protected String name;
    protected Department department;

    public Person(String name) {
        this.name = name;
    }

    public Department getDepartment() {
        return department;
    }

    public void setDepartment(Department department) {
        this.department = department;
    }
}
public class Department {
    protected int chargeCode;
    protected Person manager;

    public int getChargeCode() {
        return chargeCode;
    }

    public void setChargeCode(int chargeCode) {
        this.chargeCode = chargeCode;
    }

    public Person getManager() {
        return manager;
    }

    public void setManager(Person manager) {
        this.manager = manager;
    }
}
manager = person.department.manager;

클라이언트가 어떤 사람이 속한 부서의 관리자를 알고 싶다면 위의 코드와 같이 부서 객체를 얻어와야 한다. Department 클래스가 관리자 정보를 제공하고 있기 때문이다. 이러한 의존성을 어떻게 줄일 수 있을까.

class Person {
        ...
        public Person manager() {
            return department.getManager();
        }
        ...
}

의존성을 줄이려면 클라이언트가 부서 클래스를 볼 수 없게 숨기고 대신 사람 클래스에서 간단한 위임 메서드를 만들면 된다. 그리고 위임 객체를 얻는 클래스, 사람 클래스의 Department() 접근자를 삭제한다.

7. 중개자 제거하기

클래스에 위임이 너무 많다면?

위임 숨기기는 접근하려는 객체를 제한하는 캡슐화를 제공하여 불필요한 결합이나 의존성을 제거하는 기법이다. 근데 만약 클래스에 위임이 너무 많다면 그냥 접근을 허용하도록 하는게 더 나을 수도 있다. 즉, 결합을 해야하는 구조라면 결합을 하는게 나을 수 있다.

중개자 제거하기

중개자 제거하기는 위임 숨기기의 반대되는 리팩토링 기법이다. 객체가 단순히 중개자 역할만 해준다면 이러한 리팩토링 기법을 고려해볼 수 있다.

자신이 속한 부서(Department) 클래스와 이 객체를 통해 관리자(Manager)를 찾는 사람(Person) 클래스를 예로 들어보자.

public class Person {
    protected String name;
    protected Department department;

    public Person(String name) {
        this.name = name;
    }

    public void setDepartment(Department department) {
        this.department = department;
    }

    public Person manager() {
        return department.getManager();
    }
}

Person 객체에는 department를 거치지 않고 Manager를 조회하는 위임 메서드가 있다. 여기서 중개자를 제거해보자.

public class Person {
    protected String name;
    protected Department department;

    public Person(String name) {
        this.name = name;
    }

    public Department getDepartment() {
        return department;
    }

    public void setDepartment(Department department) {
        this.department = department;
    }

    public Person manager() {
        return department.getManager();
    }
}

먼저, Person 객체에서 department 를 조회하는 getter를 만들어야 한다. 이제 클라이언트는 department 객체를 통해서 Manager에 접근할 수 있다.

public class Person {
    protected String name;
    protected Department department;

    public Person(String name) {
        this.name = name;
    }

    public Department getDepartment() {
        return department;
    }

    public void setDepartment(Department department) {
        this.department = department;
    }
}

이제 manager() 메소드는 필요없으므로 지운다.

Seongeuniii commented 1 year ago

발표 자료