ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] interface 상속, default method 개념을 적용해 여러 interface들 간의 공통 기본 동작 정의하기
    Today I Learned 2023. 6. 24. 01:45

    서로 다른 interface들의 구현 객체들의 동작에서 중복이 나타난다면?

    QueryDSL을 적용해 다른 검색 동작을 개선하던 중 비슷한 로직의 중복이 나타났다. 지금까지 개선을 진행한 두 종류의 검색 기능들을 하나씩 보면서 발생한 중복을 살펴보도록 하자.
     
    다음은 QueryDSL을 적용해 최초로 개선을 진행했던 참가신청자를 키워드로 검색하는 동작에서 필요한 Entity 그룹들을 List로 쿼리하는 메서드 시그니쳐를 정의한 GameSearchByApplicantRepository interface와, 해당 interface의 구현체의 구현 내용이다.
     

    // repositories/GameSearchByApplicantRepository.java
    
    public interface GameSearchByApplicantRepository {
        List<GameSearchQueryResultDto> findAllSearchResultsByApplicant(String keyword);
    }
    // repositories/GameSearchByApplicantRepositoryImpl.java
    
    import static kr.megaptera.smash.models.game.QGame.game;
    import static kr.megaptera.smash.models.place.QPlace.place;
    import static kr.megaptera.smash.models.register.QRegister.register;
    import static kr.megaptera.smash.models.user.QUser.user;
    
    @Repository
    public class GameSearchByApplicantRepositoryImpl implements GameSearchByApplicantRepository {
        private final JPAQueryFactory jpaQueryFactory;
    
        public GameSearchByApplicantRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
            this.jpaQueryFactory = jpaQueryFactory;
        }
    
        @Override
        public List<GameSearchQueryResultDto> findAllSearchResultsByApplicant(
            String keyword
        ) {
            // Game, Place, Register 테이블을 join시킨 뒤, 조건에 맞는 Tuple들을 쿼리해
            // Game, Place, Register가 연결된 List<Tuple>을 생성한다.
            // 해당 로직에서는 조건 검사를 위해 User 테이블도 같이 join시킨다.
            List<Tuple> tuples = jpaQueryFactory
                .select(game, place, register)
                .from(game)
                .rightJoin(place).on(place.id.eq(game.placeId))
                .rightJoin(register).on(register.gameId.eq(game.id))
                .rightJoin(user).on(register.userId.eq(user.id))
                .where(user.name.value.contains(keyword)
                    .and(game.status.eq(GameStatus.ACTIVE))
                    .and(register.status.eq(RegisterStatus.APPLIED)))
                .orderBy(game.createdAt.desc())
                .fetch();
    
            Map<Game, List<Register>> gameAndRegisters
                = new HashMap<>();
    
            // tuples를 순회하면서 특정 Game과 연결된 모든 Register들을 집계한다.
            tuples.forEach(tuple -> {
                Game gameQueried = tuple.get(game);
                Register registerQueried = tuple.get(register);
                gameAndRegisters.computeIfAbsent(
                    gameQueried,
                    key -> new ArrayList<>()
                ).add(registerQueried);
            });
    
            // tuples를 다시 순회하면서 특정 Game, Game과 연결된 Place, Game과 연결된 List<Register>를
            // 하나로 그룹화하는 쿼리 결과 DTO를 생성한 뒤, List로 반환한다.
            return tuples.stream()
                .map(tuple -> {
                    Game gameQueried = tuple.get(game);
    
                    if (!gameAndRegisters.containsKey(gameQueried)) {
                        return null;
                    }
    
                    Place placeQueried = tuple.get(place);
                    List<Register> registers = gameAndRegisters.get(gameQueried);
    
                    gameAndRegisters.remove(gameQueried);
    
                    return new GameSearchQueryResultDto.Builder()
                        .game(gameQueried)
                        .place(placeQueried)
                        .registers(registers)
                        .build();
                })
                .filter(Objects::nonNull)
                .toList();
        }
    }

     
    쿼리 로직에 대한 자세한 설명이나 구현을 진행했던 과정은 이전에 작성했던 글을 참고할 수 있다.

     

    [QueryDSL] Entity 쿼리 로직의 데이터베이스 접근 횟수를 최소화해 목록 리소스 생성 동작 개선하기

    최종보스 프로젝트를 진행하면서 코드를 구현하는 데 가장 많은 시간을 써야 했고, 가장 마지막까지 방치했던 부분은 목록과 관련된 리소스를 반환하는 로직이었다. 하나의 리소스를 조합하는

    innu3368.tistory.com

     
    이번에는 추가로 개선을 진행했던, 장소를 키워드로 경기 목록 검색을 수행하는 동작에서 수행해야 하는 쿼리 메서드 시그니쳐를 정의한 GameSearchByPlaceRepository interface와 구현체를 한번 살펴보자.
     

    // repositories/GameSearchByPlaceRepository.java
    
    public interface GameSearchByPlaceRepository {
        List<GameSearchQueryResultDto> findAllSearchResultsByPlace(String keyword);
    }
    // repositories/GameSearchByPlaceRepositoryImpl.java
    
    import static kr.megaptera.smash.models.game.QGame.game;
    import static kr.megaptera.smash.models.place.QPlace.place;
    import static kr.megaptera.smash.models.register.QRegister.register;
    
    @Repository
    public class GameSearchByApplicantRepositoryImpl implements GameSearchByApplicantRepository {
        // JPAQueryFactory 의존성을 주입받는다.
        // ...
    
        @Override
        public List<GameSearchQueryResultDto> findAllSearchResultsByPlace(
            String keyword
        ) {
            List<Tuple> tuples = jpaQueryFactory
                .select(game, place, register)
                .from(game)
                .rightJoin(place).on(place.id.eq(game.placeId))
                .rightJoin(register).on(register.gameId.eq(game.id))
                .where(place.information.name.contains(keyword)
                    .and(game.status.eq(GameStatus.ACTIVE)))
                .orderBy(game.createdAt.desc())
                .fetch();
    
            Map<Game, List<Register>> gameAndRegisters
                = new HashMap<>();
    
            tuples.forEach(tuple -> {
                Game gameQueried = tuple.get(game);
                Register registerQueried = tuple.get(register);
                gameAndRegisters.computeIfAbsent(
                    gameQueried,
                    key -> new ArrayList<>()
                ).add(registerQueried);
            });
    
            return tuples.stream()
                .map(tuple -> {
                    Game gameQueried = tuple.get(game);
    
                    if (!gameAndRegisters.containsKey(gameQueried)) {
                        return null;
                    }
    
                    Place placeQueried = tuple.get(place);
                    List<Register> registers = gameAndRegisters.get(gameQueried);
    
                    gameAndRegisters.remove(gameQueried);
    
                    return new GameSearchQueryResultDto.Builder()
                        .game(gameQueried)
                        .place(placeQueried)
                        .registers(registers)
                        .build();
                })
                .filter(Objects::nonNull)
                .toList();
        }
    }

     
    장소를 기반으로 경기를 검색하는 쿼리문은 WHERE문의 조건이 해당 특성에 맞게 변경되고, User Entity를 고려할 필요가 없기 때문에 User 테이블을 join시키지 않는 것을 확인할 수 있다.
     
    중복이 보이는가? 쿼리문을 제외하고 Game에 연결되는 모든 Register들을 집계한 뒤 GameSearchQueryResultDto 컬렉션을 생성해 반환하는 로직은 앞서 개선을 진행했던 GameSearchByApplicantRepositoryImpl의 메서드와 동일한 것을 확인할 수 있다.
     
    이렇게 서로 다른 interface들의 구현체들 간에 나타난 공통된 동작들을 한번 추상화해보도록 하자.
     

    default method

    interface는 다른 interface를 상속받을 수 있다. (다른 interface로부터 확장될 수 있다.) 이때 상속받은 다른 interface에 기본으로 구현된 default 메서드가 존재하는 경우, 해당 interface에서도 상속받은 interface에 존재하는 default 메서드를 사용할 수 있다.
     
    default 메서드는 interface를 구현한 구체에서 별도로 재정의하지 않아도 해당 default 메서드를 그대로 사용할 수 있다는 특징을 이용하면, 공통 동작을 상위 interface에 default 메서드로 정의할 수 있다.
     
    먼저 다음과 같이 모든 GameSearchByXXXRepository interface들이 공통으로 상속받을 GameSearchRepository interface를 정의한 뒤, default 메서드를 정의한다.
     

    // repositories/GameSearchRepository.java
    
    public interface GameSearchRepository {
        default List<GameSearchQueryResultDto> toGameSearchQueryResultDtos(
            List<Tuple> tuples
        ) {
            Map<Game, List<Register>> gameAndRegisters
                = new HashMap<>();
    
            tuples.forEach(tuple -> {
                Game gameQueried = tuple.get(game);
                Register registerQueried = tuple.get(register);
                gameAndRegisters.computeIfAbsent(
                    gameQueried,
                    key -> new ArrayList<>()
                ).add(registerQueried);
            });
    
            return tuples.stream()
                .map(tuple -> {
                    Game gameQueried = tuple.get(game);
    
                    if (!gameAndRegisters.containsKey(gameQueried)) {
                        return null;
                    }
    
                    Place placeQueried = tuple.get(place);
                    List<Register> registers = gameAndRegisters.get(gameQueried);
    
                    gameAndRegisters.remove(gameQueried);
    
                    return new GameSearchQueryResultDto.Builder()
                        .game(gameQueried)
                        .place(placeQueried)
                        .registers(registers)
                        .build();
                })
                .filter(Objects::nonNull)
                .toList();
        }
    }

     
    정의한 GameSearchRepository interface는 검색 조건에 맞는 Entity들을 QueryDSL을 이용해 쿼리해야 하는 메서드 시그니쳐가 정의되어 있는 interface들이 상속받도록 한다.
     

    // repositories/GameSearchByApplicantRepository.java
    
    public interface GameSearchByApplicantRepository extends GameSearchRepository {
        List<GameSearchQueryResultDto> findAllSearchResultsByApplicant(String keyword);
    }
    // repositories/GameSearchByPlaceRepository.java
    
    public interface GameSearchByPlaceRepository extends GameSearchRepository {
        List<GameSearchQueryResultDto> findAllSearchResultsByPlace(String keyword);
    }

     
    이제 각 GameSearchByXXXRepository interface를 구현하는 구현체 클래스들에서, 중복이 발생한 로직을 최상위 interface의 default 메서드를 호출하는 것으로 대체할 수 있다.
     

    // repositories/GameSearchByApplicantRepositoryImpl.java
    
    @Override
    public List<GameSearchQueryResultDto> findAllSearchResultsByApplicant(
        String keyword
    ) {
        List<Tuple> tuples = jpaQueryFactory
            .select(game, place, register)
            .from(game)
            .rightJoin(place).on(place.id.eq(game.placeId))
            .rightJoin(register).on(register.gameId.eq(game.id))
            .rightJoin(user).on(register.userId.eq(user.id))
            .where(user.name.value.contains(keyword)
                .and(game.status.eq(GameStatus.ACTIVE))
                .and(register.status.eq(RegisterStatus.APPLIED)))
            .orderBy(game.createdAt.desc())
            .fetch();
    
        return toGameSearchQueryResultDtos(tuples);
    }
    // repositories/GameSearchByPlaceRepositoryImpl.java
    
    @Override
    public List<GameSearchQueryResultDto> findAllSearchResultsByPlace(
        String keyword
    ) {
        List<Tuple> tuples = jpaQueryFactory
            .select(game, place, register)
            .from(game)
            .rightJoin(place).on(place.id.eq(game.placeId))
            .rightJoin(register).on(register.gameId.eq(game.id))
            .where(place.information.name.contains(keyword)
                .and(game.status.eq(GameStatus.ACTIVE)))
            .orderBy(game.createdAt.desc())
            .fetch();
    
        return toGameSearchQueryResultDtos(tuples);
    }

     

    아니 그냥 한 곳에 같이 몰아넣고 단순 메서드화로 추상화하는 게 더 낫나?

    글을 작성하다 보니 다른 관점의 생각도 들었다. 지금 개선하고 있는 검색 기능들이 이렇게까지 별개의 interface들로 나눠서 나타낼 것들인가 싶기도 했다. 사실 다음과 같이 하나의 interface와 하나의 구현 객체를 두고, 구현 객체에서 그냥 메서드화하는 식으로도 충분히 추상화가 가능하다.
     

    public interface GameSearchRepository {
        List<GameSearchQueryResultDto> findAllSearchResultsByPlace(String keyword);
    
        List<GameSearchQueryResultDto> findAllSearchResultsByApplicant(String keyword);
    }
    @Repository
    public class GameSearchRepositoryImpl implements GameSearchRepository {
        // JPAQueryFactory 의존성을 주입받는다.
        // ...
    
        @Override
        public List<GameSearchQueryResultDto> findAllSearchResultsByPlace(
            String keyword
        ) {
            List<Tuple> tuples = jpaQueryFactory
                // 쿼리를 수행한다.
                // ...
                .fetch();
    
            return toGameSearchQueryResultDtos(tuples);
        }
    
        @Override
        public List<GameSearchQueryResultDto> findAllSearchResultsByApplicant(
            String keyword
        ) {
            List<Tuple> tuples = jpaQueryFactory
                // 쿼리를 수행한다.
                // ...
                .fetch();
    
            return toGameSearchQueryResultDtos(tuples);
        }
    
        private List<GameSearchQueryResultDto> toGameSearchQueryResultDtos(
            List<Tuple> tuples
        ) {
            // Game에 해당하는 Register들을 집계한 뒤
            // 각 Game과, 연결되는 Entity들을 GameSearchQueryResultDto로 변환해 반환한다.
        }
    }

     
    검색과 관련된 모든 쿼리 메서드 시그니쳐들을 하나의 interface에 집중시키고 하나의 구현체 객체에서 모두 구현할 것인지, 개별 interface들로 분리하고 상위 interface에 공통 메서드들을 default method로 정의할 것인지는 구현 쿼리의 복잡도나 검색 기능들의 비슷한 정도, 개별 검색 동작의 책임에 대한 측면 등 여러 관점들을 가늠해 트레이드오프를 해야 할 것으로 생각된다.
     
     
     
     

    댓글

Designed by Tistory.