ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Java에서 interface와 구현체 class들을 이용해 소스코드의 중복 제거하기
    Today I Learned 2023. 5. 12. 03:46

     

    ... 구체적으로는 내가 구상한 방식을 따를 때 코드의 중복을 최소화할 수 있는 방안을 마련할 수 있어야 했는데 이를 고려할 시간이 부족했고, 결국 코드의 중복이 상당 부분 존재하는 상태로 코어 로직을 구현하게 되었다.
    (2023년 5월 1주차 주간회고 중)

     
    이 소스코드의 중복이 도저히 눈 뜨고 넘어갈 수준이 아니었다. 마감까지 남은 시간이 굉장히 촉박한 상태에서 코어 로직을 구현한 관계로 구현 과정에서 무거운 기술 부채를 쌓게 되었다. 어떤 문제가 발생했는지 살펴보면서 이를 조금씩 개선해나가는 것을 시도해보자.
     
     

    상황 살펴보기

    특정 강의에 수강신청한 학생들의 수강신청 상태를 정해진 우선순위 기준들을 바탕으로 성공 상태로 변화시키거나, 실패 상태로 변화시키는 프로세스를 구현해야 하는 상황을 가정해보자.
     
    우선 다음의 상황으로부터 시작한다.

    - 강의의 수강 제한 인원보다 수강신청한 학생 수가 적으면
    → 모든 수강신청한 학생들의 수강신청 상태를 성공으로 바꾼다.

    - 강의의 수강 제한 인원보다 수강신청한 학생 수가 많으면
    → 정해진 수강 우선순위들을 바탕으로 높은 우선순위에 해당하는 수강신청한 학생들의 수강신청 상태를 성공으로 바꾼다. 그렇지 못한 학생들의 수강신청 상태는 실패로 바꾼다.

     
    수강생 확정에 사용할 인자들로 다음의 요소들이 있다고 해 보자.

    - Course: 강의가 속한 과목 (ex. 창조적사고와표현 과목)
    - Lecture: 과목이 구체적으로 개설되는 강의 (ex. A 교수님이 09:00-12:00에 진행하는 창조적사고와표현 강의)
    - Map<Student, Application>: 해당 강의에 수강신청한 학생들과, 각 학생에 대한 수강신청서 쌍의 모음
    - Map<Student, List<Enrollment>>: 해당 강의에 수강신청한 학생들과, 각 학생에 대한 과거 수강 기록 쌍의 모음

     
     
    그럼 한번 강의의 수강생을 확정지어보기 시작해보자.

    @Entity
    public class EnrollmentManager {
        // field values
        
        // constructors
        
        public List<Enrollment> enroll(
            Lecture lecture,
            Course course,
            Map<Student, Application> studentAndApplications,
            Map<Student, List<Enrollment>> studentAndEnrollments
        ) {
            if (lecture.limitIsLargerThanOrEqual(studentAndApplications.size())) {
                changeApplicationStatus(
                    studentAndApplications,
                    ApplicationResult.SUCCESSFUL
                );
                return enrollStudents(studentAndApplications);
            }
            
            if (course.isRequired()) {
                return enrollFollowingRequiredCourseProcess(
                    lecture,
                    course,
                    studentAndApplications,
                    studentAndEnrollments
                );
            }
    
            return enrollFollowingOptionalCourseProcess(
                lecture,
                course,
                studentAndApplications,
                studentAndEnrollments
            );
        }
        
        private void changeApplicationStatus(
            Map<Student, Application> studentAndApplications,
            ApplicationResult result
        ) {
            // 전달받은 모든 Application들의 ApplicationResult enum 필드를
            // 전달받은 상태로 변경시킨다.
        }
    
        public List<Enrollment> enrollStudents(
            Map<Student, Application> studentAndApplications
        ) {
            // 전달받은 모든 Application들의 정보를 이용해
            // Enrollment들을 생성해 List를 반환한다.
        }
    }

     
     
    만약 수강 제한 인원보다 수강신청자 수가 많고, 강의가 필수 강의라면 해당 소스코드에서 enrollFollowingRequiredProcess가 실행될 것이다. 해당 메서드가 실행된 결과에는 강의의 제한 인원 수에 맞춰서 수강신청에 성공한 학생들의 수강 내역 List가 생성되어 반환되어야 할 것이다.
     
    기준으로는 여러가지가 주어질 수 있을 것이다. 예를 들자면 졸업까지 얼마 안 남은 학생을 먼저 수강시켜야 한다던지, 그 다음으로는 그 과목을 들은 적이 없는 학생을 먼저 수강시켜야 한다던지 같은.
     
    이를 코드로 나타내기 위해 작성해본 프로세스와 코드를 살펴보자.
     

    - List<Enrollment>를 정의한다.
    - 첫 번째로 우선순위가 높은 학생들을 선별해 수강신청을 성공시키고 수강 내역을 생성한다.
    - 그 다음으로 우선순위가 높은 학생들을 선별해 수강신청을 성공시키고 수강 내역을 생성한다.
    - 또 그 다음으로 우선순위가 높은 학생들을 선별해 수강신청을 성공시키고 수강 내역을 생성한다.
    - ...
    - 수강신청을 성공시킨 인원이 제한 인원만큼 가득 찼을 경우, 남은 학생들의 수강신청 상태는 실패로 변경시킨다.
    public List<Enrollment> enrollFollowingRequiredCourseProcess(
        Lecture lecture,
        Course course,
        Map<Student, Application> studentAndApplications,
        Map<Student, List<Enrollment>> studentAndEnrollments
    ) {
        List<Enrollment> enrollments = new ArrayList<>();
            
        enrollments.addAll(
            enrollStudentsFollowingStrategy1(
                enrollments.size(),
                lecture,
                course,
                studentAndApplications,
                studentAndEnrollments
            )
        );
        enrollments.addAll(
            enrollStudentsFollowingStrategy2(
                enrollments.size(),
                lecture,
                course,
                studentAndApplications,
                studentAndEnrollments
            )
        );
        // enrollments.addAll(
        //     enrollStudentsFollowingStrategyN(
        // ...
    
        changeApplicationStatus(
            studentAndApplications,
            ApplicationResult.FAILED
        );
        return enrollments;
    }

     
    우선순위 선별 전략에 따라 우선순위가 높은 학생들을 선별해 수강 내역 List를 생성해 반환하는 enrollStudentFollowingStrategy1과 2를 한 번 각각 살펴보자.
     

    public List<Enrollment> enrollStudentsFollowingStrategy1(
        int currentEnrolledCount,
        Lecture lecture,
        Course course,
        Map<Student, Application> studentAndApplications,
        Map<Student, List<Enrollment>> studentAndEnrollments
    ) {
        // 잔여석이 없어 더 이상 수강신청을 받을 수 없으면
        // 비어 있는 리스트를 반환한다.
        int remainingCount = lecture.limitCount() - currentEnrolledCount;
        if (remainingCount <= 0) {
            return List.of();
        }
    
        // 남아있는 수강생 수보다 잔여석이 많으면
        // 모든 수강생들의 수강신청을 성공시키고 수강 내역을 생성한다.
        if (remainingCount > studentAndApplications.size()) {
            changeApplicationStatus(studentAndApplications, ApplicationResult.SUCCESSFUL);
            return enrollStudents(studentAndApplications);
        }
    
        // 가장 우선순위가 높은 학생들을 선별한다.
        List<Student> studentsMeetCondition
            = prioritizeFollowingEnrollmentPrioritizationStrategy1(
                course,
                studentAndEnrollments
            );
        
        // 선별된 학생들이 잔여석보다 많을 경우
        // 선별된 학생들 사이에서 수강신청을 먼저 한 순서대로 다시 선별한다.
        if (studentsMeetCondition.size() > remainingCount) {
            limitStudentsByApplyTime(
                remainingCount,
                studentsMeetCondition,
                studentAndApplications
            );
        }
    
        // 선별된 학생들의 수강신청 상태를 성공으로 변경한다.
        // 해당 학생들을 아직 수강신청되지 않은 학생들 목록에서 제거한 뒤,
        // 해당 학생들의 수강신청서 정보를 이용해 수강 내역을 생성한다.
        Map<Student, Application> studentAndApplicationsMeetCondition
            = pairStudentAndApplications(
                studentAndApplications,
                studentsMeetCondition
            );
        changeApplicationStatus(
            studentAndApplicationsMeetCondition,
            ApplicationResult.SUCCESSFUL
        );
        removeEnrolledStudents(
            studentAndApplications,
            studentsMeetCondition
        );
        return enrollStudents(studentAndApplicationsMeetCondition);
    }
    
    // other helper methods
    // ...
    public List<Enrollment> enrollStudentsFollowingStrategy2(
        int currentEnrolledCount,
        Lecture lecture,
        Course course,
        Map<Student, Application> studentAndApplications,
        Map<Student, List<Enrollment>> studentAndEnrollments
    ) {
        int remainingCount = lecture.limitCount() - currentEnrolledCount;
        if (remainingCount <= 0) {
            return List.of();
        }
    
        if (remainingCount > studentAndApplications.size()) {
            changeApplicationStatus(studentAndApplications, ApplicationResult.SUCCESSFUL);
            return enrollStudents(studentAndApplications);
        }
    
        List<Student> studentsMeetCondition
            = prioritizeFollowingEnrollmentPrioritizationStrategy2(
                course,
                studentAndEnrollments
            );
        
        if (studentsMeetCondition.size() > remainingCount) {
            limitStudentsByApplyTime(
                remainingCount,
                studentsMeetCondition,
                studentAndApplications
            );
        }
    
        Map<Student, Application> studentAndApplicationsMeetCondition
            = pairStudentAndApplications(
                studentAndApplications,
                studentsMeetCondition
            );
        changeApplicationStatus(
            studentAndApplicationsMeetCondition,
            ApplicationResult.SUCCESSFUL
        );
        removeEnrolledStudents(
            studentAndApplications,
            studentsMeetCondition
        );
        return enrollStudents(studentAndApplicationsMeetCondition);
    }

     
     

    심각한 수준의 코드 중복

    아마 어떤 문제가 있는지 느낌이 올 것이다. enrollStudentFollowingStrategy1과 2는 해당되는 우선순위 기준에 맞는 학생들을 선별하기 위해 호출하는 메서드를 제외한 나머지 소스코드들이 토씨 하나 틀리지 않고 똑같다. 이 방식으로 우선순위 기준에 맞춰 학생을 선별하고 수강 내역을 생성하는 로직을 작성했더니 우선순위 기준의 개수만큼 똑같은 소스코드들이 반복적으로 생성되었다. 엄청난 소스코드의 중복이 발생하고 말았다.
     
    소위 복붙은 동작의 종류나 개수를 단순히 늘리기에는 가장 쉬운 방법이지만, 만약 다음과 같은 일이 생긴다면 어떨까, 선별 프로세스가 아닌 다른 부분이 살짝 바뀌어 모든 enrollStudentFollowingStrategyN들에서 일부분이 수정되어야 하는 일이 발생하면, 모든 enrollStudentFollowingStrategyN들을 코드 상에서 찾아다니면서 일일이 바꿔주어야 할 것이다. 우선순위 기준이 많아지면 많아질수록 이들을 일일이 수정하는 데 쓸데없는 비용이 늘어날 것임을 짐작할 수 있다.
     
    어떻게 문제를 해결해야 할까?
     
     

    우선순위가 높은 학생들을 선별하는 프로세스만 다르다

    문제를 해결하기 위해 이 부분에 주목했다. 해당 부분에만 차이를 둘 수 있으면, 나머지 부분은 동작이 똑같기 때문에 똑같은 코드를 재사용할 수 있을 것이라 생각했다. 그리고 한 가지 더. 우선순위가 높은 학생들을 선별하는 프로세스들도 자세히 보면 반환받는 메서드 시그니쳐가 동일했다. 여기서 그동안 줄곧 순수 Java의 구현 영역에서 어떻게 적용할 수 있을지 쉽사리 감이 오지 않았던 interface가 떠올랐다.
     
    다음의 프로세스를 따라 코드를 리팩터링해보기로 했다.
     
     
    - interface에 '우선순위가 높은 학생들을 선별하는' 메서드 시그니쳐를 정의한다.
    - 각 우선순위 판별 class를 생성한다. 해당 class는 정의한 interface를 implement하는데, interface에 정의되어 있는 '우선순위가 높은 학생들을 선별하는' 메서드를 각자의 여건에 맞게 구현한다.
    - enrollStudentsFollowingStrategy1, 2, 3... 메서드를 모두 enrollStudentsFollowingStrategy 하나로 통일시킨다. 대신, 해당 메서드를 호출할 때, interface를 구현한 구현체 인스턴스를 인자로 전달한다.
    - enrollStudentsFollowingStrategy 메서드 내에서는 해당 interface가 '우선순위가 높은 학생들을 선별하는' 메서드를 수행하게 한다.
     
     
    리팩터링한 코드를 한 번 살펴보자. 먼저 다음과 같이 interface를 정의한다.

    public interface EnrollmentPrioritizationStrategy {
        List<Student> prioritize(
            Course course,
            Map<Student, List<Enrollment>> studentAndEnrollments
        );
    }

     
    이제 각 우선순위 전략에 맞는 interface를 구현한 클래스를 정의한다.

    public class EnrollmentPrioritizationRequiredCourseStrategy1
        implements EnrollmentPrioritizationStrategy {
        @Override
        public List<Student> prioritize(
            Course course,
            Map<Student, List<Enrollment>> studentAndEnrollments
        ) {
            // 예시 1. 졸업까지 남은 학기가 적은
            // 학생들을 선별해 목록을 반환한다.
        }
    }
    public class EnrollmentPrioritizationRequiredCourseStrategy2
        implements EnrollmentPrioritizationStrategy {
        @Override
        public List<Student> prioritize(
            Course course,
            Map<Student, List<Enrollment>> studentAndEnrollments
        ) {
            // 예시 2. 이전에 해당 과목을 수강한 기록이 없는
            // 학생들을 선별해 목록을 반환한다.
        }
    }

     
    그리고 enrollFollowingRequiredProcess에서 enrollStudentFollowingStrategy를 호출할 때 이전처럼 각각의 Strategy1, 2, ...를 호출하는 게 아니라 단일한 enrollStudentFollowingStrategy만을 호출하게 한다. 대신 각 Strategy에 필요한 PrioritizationStrategy 구현체의 인스턴스를 전달한다.

    public List<Enrollment> enrollFollowingRequiredCourseProcess(
        Lecture lecture,
        Course course,
        Map<Student, Application> studentAndApplications,
        Map<Student, List<Enrollment>> studentAndEnrollments
    ) {
        List<Enrollment> enrollments = new ArrayList<>();
            
        enrollments.addAll(
            enrollStudentsFollowingStrategy(
                enrollments.size(),
                lecture,
                course,
                studentAndApplications,
                studentAndEnrollments,
                new EnrollmentPrioritizationRequiredCourseStrategy1()
            )
        );
        enrollments.addAll(
            enrollStudentsFollowingStrategy(
                enrollments.size(),
                lecture,
                course,
                studentAndApplications,
                studentAndEnrollments,
                new EnrollmentPrioritizationRequiredCourseStrategy2()
            )
        );
        // enrollments.addAll(
        //     enrollStudentsFollowingStrategy(
        // ...
    
        changeApplicationStatus(
            studentAndApplications,
            ApplicationResult.FAILED
        );
        return enrollments;
    }

     
    최종적으로 enrollStudentsFollowingStrategy는 다음과 같이 변경되었다.

    public List<Enrollment> enrollStudentsFollowingStrategy(
        int currentEnrolledCount,
        Lecture lecture,
        Course course,
        Map<Student, Application> studentAndApplications,
        Map<Student, List<Enrollment>> studentAndEnrollments,
        EnrollmentPrioritizationStrategy prioritizationStrategy
    ) {
        int remainingCount = lecture.limitCount() - currentEnrolledCount;
        if (remainingCount <= 0) {
            return List.of();
        }
    
        if (remainingCount > studentAndApplications.size()) {
            changeApplicationStatus(studentAndApplications, ApplicationResult.SUCCESSFUL);
            return enrollStudents(studentAndApplications);
        }
        
        // 어떤 우선순위 판별 Strategy가 전달되더라도
        // 수행하는 메서드와 전달되는 인자의 타입, 반환 타입은 동일하다.
        // 전달된 Strategy에 맞는 동작을 수행해 결과를 반환한다.
        List<Student> studentsMeetCondition
            = prioritizationStrategy.prioritize(
                course,
                studentAndEnrollments
            );
        
        if (studentsMeetCondition.size() > remainingCount) {
            limitStudentsByApplyTime(
                remainingCount,
                studentsMeetCondition,
                studentAndApplications
            );
        }
    
        Map<Student, Application> studentAndApplicationsMeetCondition
            = pairStudentAndApplications(
                studentAndApplications,
                studentsMeetCondition
            );
        changeApplicationStatus(
            studentAndApplicationsMeetCondition,
            ApplicationResult.SUCCESSFUL
        );
        removeEnrolledStudents(
            studentAndApplications,
            studentsMeetCondition
        );
        return enrollStudents(studentAndApplicationsMeetCondition);
    }
    
    // other helper methods
    // ...

     
    해당 리팩터링을 수행함으로써 enrollStudentsFollowingStrategy 동작에 대한 정의는 이제 단 하나만 있어도 각기 다른 우선순위 선별 전략에 대응해 동작할 수 있게 되었기 때문에, 상당량의 중복을 제거할 수 있게 되었다.
     
     

    어려웠던 점

    우선순위 선별 전략의 요구사항이나 구현 여부에 따라 사용되는 인자들은 다를 수 있다. 어떤 상황에서는 학생의 과거 수강 기록만 확인하면 되는 경우도 있을 수 있고, 어떤 상황에서는 과목 식별자도 같이 확인해야 하는 경우도 있을 수 있다. 그 부분을 고려해서 코드를 작성해보고 싶었다. 각 구현체의 메서드가 받는 인자가 서로 다르더라도 실행 시점에서 그조차도 알맞게 식별해서 실행해줄 수 있다면 더할 나위 없이 좋을 것 같았기 때문이다.
     
    ChatGPT한테 너는 왜 그런 것도 모르냐고 어깨 붙잡고 흔들어도 보고, 개발 서적에서 interface에 대해 다룬 부분도 찾아보았지만, 일단 내 수준에서 내린 결론은 그런 방식으로 코드를 작성하는 것은 불가능하다는 것이다. 어떤 구현체는 메서드의 인자로 2개를 받고, 어떤 구현체는 메서드의 인자를 4개를 받는다고 할 때, interface의 메서드를 실행하는 시점에서 어떤 메서드가 구현체로 주입되어 실행될지 알 길이 없기 때문이다.
     
    그래서 특정 구현체의 메서드에서는 사용되지 않는 인자임에도 어쩔 수 없이 인자로 받아야 하는 경우가 존재한다. 이 부분을 해결할 수 있는 방법이 있을까...?
     
     
     
     

    댓글

Designed by Tistory.