Backend/☕️ Java

[Java] 1급 컬렉션으로 리팩토링

Hugehoo 2024. 2. 2. 08:47

목차

  • 서론
  • 배경 설명 및 요구사항
  • Before Refactoring
  • 일급 컬렉션이란?
  • After Refactoring
  • 일급 컬렉션 사용시 이점
  • 요약 및 결론

 

서론

일급 컬렉션의 개념을 모호하게 알고 있었는데, 이번 기능 개발을 통해 일급 컬렉션을 사용해야 이유와 사용 후 코드 응집력이 높아지는 것을 직접 경험한 내용을 공유합니다.

 

배경 설명 및 요구사항

호텔 도메인의 백오피스를 개발하고 있다. 호텔에 등록된 객실을 한눈에 볼 수 있는 페이지에서 객실의 순서를 클릭-드래그하여 변경할 수 있는 기능이 개발 사항이었다.

 

검은 박스로 표시된 두 객실의 순서를 변경하는 것으로 인접한 객실의 순서 뿐만 아니라 인접하지 않은 객실의 순서도 드래그로 변경할 수 있어야 했다.

추가로 아래 이미지의 검은 박스로 표시된 요금제(Rate)의 순서도 변경할 수 있는 기능이 추가돼야 했다. 객실 과 마찬가지로 요금제도 순서를 변경이 가능하도록 개발을 해야한다.

 

 

Before Refactoring

 

기능은 잘 구현됐지만 코드가 한눈에 들어오지 않는다. changeSort() 라는 메서드에서 너무 많은 행위가 일어난다.

 

@Service
@RequiredArgsConstructor
public class RoomService {


    @Transactional
    public List<RoomForm.Response.RoomSort> changeSort(List<NumberingObject> numberingObject) {
            List<Long> roomIdList = numberingObject.stream().map(NumberingObject::getId).collect(Collectors.toList());
            List<Integer> sortNumbers = numberingObject.stream()
                    .map(NumberingObject::getSort)
                    .sorted()
                    .collect(toList());
    
            Map<Long, Integer> getSortedMap = new HashMap<>();
            for (int i = 0; i < roomIdList.size(); i++) {
                Long key = roomIdList.get(i);
                Integer value = sortNumbers.get(i);
                roomSortMap.put(key, value);
            }
    
            List<Room> rooms = roomRepository.findAllById(roomIdList);
            rooms.forEach(room -> room.changeSort(roomSortMap.get(room.getId())));
            return rooms.stream()
                    .map(room -> new RoomForm.Response.RoomSort(room.getId(), room.getSort()))
                    .collect(toList());
        }
}
// changeSort() 의 인자로 받는 NumberingObejct 클래스
public class NumberingObject {

    private Long id;
    private Integer sort;

}

 

더군다나 문제가 한가지 더 있다.

위 코드는 Room(객실) 도메인의 정렬 순서를 바꾸는 메서드지만, 현재 우리 프로젝트에는 Rate(요금제)의 순서를 변경하는 메서드도 존재한다. 해당 메서드는 RateService 라는 별도의 서비스 레이어에 존재하지만 RoomService 의 changeSort()와 repository 의존을 제외하면 동일한 로직을 가진다.

 

정렬을 변경(change sort)하는 동일한 로직이 도메인 마다(Room, Rate) 분산되어 따로 작성된 것이 문제다. 만약 정렬 변경 로직에 기능이 추가되거나 수정할 일이 생기면, 두 도메인에 각각 작성된 changeSort() 메서드를 모두 수정해야 하기 때문에 작업이 번거로워진다. 또한 새로운 도메인(ex, Hotel)에도 정렬을 변경해야 하는 요구사항이 들어오면 hotelService 클래스 내부에도 changeSort() 로직을 일일이 작성해야 한다. 물론 복붙하면 빠르겠지만 repository 등 의존해야하는 객체에 오타를 낼 위험도 있으니 좋은 방법은 아니라고 생각한다.

 

일급 컬렉션이란?

 

일급 컬렉션이란, Collection을 Wrapping하면서, Wrapping한 Collection 외 다른 멤버 변수가 없는 상태를 일급 컬렉션이라 한다.(우테코 블로그 발췌)

 

changeSort() 메서드는 numberingObject Collection을 인자를 받고 있다. 이 Collection을 Wrapping 하고 다른 멤버 변수가 없는 클래스를 만들면 일급 컬렉션이 된다.

문자 그대로는 직관적으로 이해하기 힘들 수 있다. changeSort() 메서드의 비즈니스 로직을 일급 컬렉션으로 옮기면서 이해해보자. 해당 일급 컬렉션 클래스의 이름은 Sorting 이라 정했다.

 

@Getter
public class Sorting {

    // numberingObject 이외에는 다른 멤버 변수가 없다.
    final List<NumberingObject> numberingObject;

    public Sorting(List<NumberingObject> numberingObject) {
        this.numberingObject = numberingObject;
    }

    public List<Long> getIdList() {
        return numberingObject.stream()
                .map(NumberingObject::getId)
                .collect(Collectors.toList());
    }

    public List<Integer> getSortNumbers() {
        return numberingObject.stream()
                .map(NumberingObject::getSort)
                .sorted()
                .collect(toList());
    }

    public Map<Long, Integer> getSortedMap() {
        List<Long> idList = getIdList();
        List<Integer> sortNumbers = getSortNumbers();
        if (idList.size() != sortNumbers.size()) {
            return Collections.emptyMap();
        }

        return sortingDto.stream()
                    .collect(toMap(SortingDto::getId, SortingDto::getSort));    
    }
}

 

이렇게 하면 기존 RoomService의 changeSort() 비즈니스 로직을 Sorting 클래스 내부로 옮길 수 있다. 드래그 기능을 위해 필요한 세부 로직이 모두 Sorting 클래스 내부로 이동하여 이를 가져다 쓰는 클라이언트 측에선 드래그 기능의 상세한 구현 내용을 알지 못해도 괜찮다.

이제 RoomService 의 changeSort() 메서드를 확인해보자.

 

After Refactoring

 

기존 18 라인의 메서드를 7 라인 짜리 메서드로 리팩토링 할 수 있었다. 전보다 깔끔하고 직관적으로 어떤 행위를 하는지 파악할 수 있다.

 

public class RoomService {

    @Transactional
    public List<RoomForm.Response.RoomSort> changeSort(Sorting numberingObject) {
        Map<Long, Integer> roomSortMap = numberingObject.getSortedMap();
        List<Room> rooms = roomRepository.findAllById(numberingObject.getIdList());
        rooms.forEach(room -> room.changeSort(roomSortMap.get(room.getId())));
    
        return rooms.stream()
                .map(RoomForm.Response.RoomSort::new)
                .collect(toList());
    }
}

 

어떻게 가능했을까? Sorting 이라는 일급 컬렉션을 만들고 기존 changeSort() 에서 일어난 모든 비즈니스 로직을 응집했기 때문이다. 처음 changeSort()의 인자로 받은 List<numberingObject> numberingObject 의 목표는 roomSortMap 이라는 hashMap 타입을 만들어 내는 것 뿐이었다. 리팩토링 이전의 코드는 roomSortMap 을 만들기 위한 사전 과정이 길었지만, 리팩토링 이후에는 roomSortMap 생성 로직을 모두 일급 컬렉션 내부에 구현하여 코드를 줄일 수 있었다.

 

 

일급 컬렉션을 사용하면서 얻은 이점

 

위에서도 언급했듯 changeSort() 메서드는 Room 도메인 뿐만 아니라 Rate 도메인도 사용하고 있다. 리팩토링 이전에는 비즈니스 로직이 객체에 종속적이지 않고 개별 서비스 레이어에 종속적이었다. 즉 비즈니스 로직의 응집력이 떨어지고 개별 서비스에 결합도만 높아진다.

하지만 일급 컬렉션으로 리팩토링 하면서 정렬의 순서 변경 이라는 비즈니스 로직을 모두 Sorting 클래스 내부로 응집하여 분산된 도메인에서 안전하게 비즈니스 로직을 호출할 수 있게 됐다. 즉 일급 컬렉션을 사용하면 비즈니스에 종속적인 자료구조를 만들 수 있는 이점이 존재한다.

 

추가 리팩토링

getIdSet(), getSortNumbers() 등 없앨 수 있는 부분의 코드는 삭제했다.

 

@Getter
public class Sorting {

    final List<SortingDto> sortingDto;

    public Sorting(List<SortingDto> sortingDto) {
        this.sortingDto = sortingDto;
    }

    public List<Long> getIdList() {
        return sortingDto.stream()
                .map(SortingDto::getId)
                .collect(Collectors.toList());
    }

    public Map<Long, Integer> getSortedMap() {
        return sortingDto.stream()
                .collect(toMap(SortingDto::getId, SortingDto::getSort));
    }

}

 

Room 도메인과 Rate 도메인 모두 Sorting 객체를 사용하여 정렬의 순서 변경 을 구현할 수 있게 됐다. 두 서비스 레이어 모두 하나의 객체에서 구현된 비즈니스 로직을 호출한다.

 

@Service
@RequiredArgsConstructor
public class RateService {

    private final RateRepository rateRepository;

    @Transactional
    public List<RateForm.Response.ChangeSort> changeSort(Sorting sorting) {

        Map<Long, Integer> roomSortMap = sorting.getSortedMap();
        List<Rate> rates = rateRepository.findAllById(sorting.getIdList());
        rates.forEach(rate -> rate.changeSort(roomSortMap.get(rate.getId())));

        return rates.stream()
                .map(RateForm.Response.ChangeSort::new)
                .collect(toList());
    }

}



@Service
@RequiredArgsConstructor
public class RoomService {

    private final RoomRepository roomRepository;

    @Transactional
    public List<RoomForm.Response.RoomSort> changeSort(Sorting sorting) {

        Map<Long, Integer> roomSortMap = sorting.getSortedMap();
        List<Room> rooms = roomRepository.findAllById(sorting.getIdList());
        rooms.forEach(room -> room.changeSort(roomSortMap.get(room.getId())));

        return rooms.stream()
                .map(RoomForm.Response.RoomSort::new)
                .collect(toList());
    }

}

 

요약 및 결론

  • 기존 : 정렬 순서 변경이라는 로직을 서비스 레이어에 구현함. 하지만 다른 도메인의 서비스 레이어에서도 동일한 정렬 순서 변경 기능이 추가되면서 동일한 로직의 코드 관리가 어려워짐.
  • 해결 : 해당 비즈니스 로직은 인자로 받은 Collection(List)에서 시작되기 때문에 일급 컬렉션을 활용하여 비즈니스 로직(정렬 순서 변경)을 한 곳에 응집함. 이로써 새로운 도메인에서 정렬 순서 변경 기능을 사용하더라도, 코드가 분산되지 않고 하나의 일급 컬렉션에서 해당 기능을 호출하여 사용할 수 있게 됨.