[우아한테크코스 4기] 220810 F12 개발일지

Jihoon Oh·2022년 8월 10일
0

우아한테크코스 4기

목록 보기
35/43
post-thumbnail

오늘 진행한 일

7인 몹 프로그래밍

어제 하지 못했던 7인 몹 프로그래밍을 진행했다. 몹으로 진행하기로 한 핵심 기능은 제품 검색 기능이었는데, 백엔드 쪽에서는 인수 테스트 시나리오를 작성한 뒤 레포지토리 단부터 TDD로 올라오는 방식의 프로그래밍을 진행했다. if문으로 카테고리를 분기하고 있던 부분을 검색 키워드까지 포함한 동적 쿼리로 만들어주어야 했는데, 프로필 검색 기능을 구현할 때 작성한 코드를 많이 참고할 수 있어서 어렵지 않았다.

프론트엔드 코드에서는 기존에 프로필 조회에만 존재하던 검색 컴포넌트들을 제품 조회로도 옮겨오고, 검색에 필요한 값들을 추상화하는 작업을 진행했다. 굉장히 오랜만에 리액트를 하게 되었는데, 오랜만에 하는 리액트는 나름 재미있었다. 팀원들이 처음에 Props에 대해서 잘 이해하지 못하는 경우도 있었는데, 약간 우리가 쓰는 Dependency Injection하고 비슷한거야라고 설명해주었다. 음… 이게 맞나? 어쨌든 뭐 비슷한 것 같다.

당연하게도 백엔드나 프론트엔드나 몹 과정에서 서로의 코드에 큰 영향력은 주지 못했다. 하지만 이렇게 다같이 서로의 코드를 짜 보면서 코드의 동작 flow를 이해할 수 있어서 의미 있는 시간이었던 것 같다.

오늘 발생한 이슈

제네릭을 이용해 QueryDSL 리팩토링하기

제품 검색 기능을 구현하고 나니 QueryDSL 관련된 코드에 중복 코드가 많았다.

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    ...
    private OrderSpecifier[] makeOrderSpecifiers(final Pageable pageable) {
        return pageable.getSort()
                .stream()
                .map(this::toOrderSpecifier)
                .collect(Collectors.toList()).toArray(OrderSpecifier[]::new);
    }

    private OrderSpecifier toOrderSpecifier(final Sort.Order sortOrder) {
        final Order orderMethod = toOrder(sortOrder);
        final PathBuilder<Member> pathBuilder = new PathBuilder<>(member.getType(), member.getMetadata());
        return new OrderSpecifier(orderMethod, pathBuilder.get(sortOrder.getProperty()));
    }

    private Order toOrder(final Sort.Order sortOrder) {
        if (sortOrder.isAscending()) {
            return Order.ASC;
        }
        return Order.DESC;
    }

    private BooleanExpression containsKeyword(final String keyword) {
        if (Objects.isNull(keyword) || keyword.isBlank()) {
            return null;
        }
        return member.gitHubId.contains(keyword);
    }

    private BooleanExpression notNullMemberInfo() {
        return member.careerLevel.isNotNull().and(member.jobType.isNotNull());
    }

    private BooleanExpression eqCareerLevel(final CareerLevel careerLevel) {
        if (Objects.isNull(careerLevel)) {
            return null;
        }
        return member.careerLevel.eq(careerLevel);
    }

    private BooleanExpression eqJobType(final JobType jobType) {
        if (Objects.isNull(jobType)) {
            return null;
        }
        return member.jobType.eq(jobType);
    }

    private Slice<Member> toSlice(final Pageable pageable, final List<Member> members) {
        if (members.size() > pageable.getPageSize()) {
            members.remove(members.size() - 1);
            return new SliceImpl<>(members, pageable, true);
        }
        return new SliceImpl<>(members, pageable, false);
    }
}

위에 보이는 MemberRepositoryCustomImpl의 private 메서드 들 중에서 containsKeyword, makeOrderSpecifiers, eqXXX, toSlice 메서드는 반환이나 파라미터로 들어가는 매개변수 타입만 조금씩 다를 뿐이지 내부 로직이 똑같은 코드가 ProductRepositoryCustomImpl에서도 쓰이고 있었다. 그래서 이 부분을 중복 코드를 줄이고 유지보수를 원활하게 하고자 제네릭을 사용해 유틸 클래스로 분리하기로 결정했다.

public class RepositorySupport {

    private RepositorySupport() {
    }

    public static <T> OrderSpecifier[] makeOrderSpecifiers(final EntityPathBase<T> qClass, final Pageable pageable) {
        return pageable.getSort()
                .stream()
                .map(sort -> toOrderSpecifier(qClass, sort))
                .collect(Collectors.toList()).toArray(OrderSpecifier[]::new);
    }

    private static <T> OrderSpecifier toOrderSpecifier(final EntityPathBase<T> qClass, final Sort.Order sortOrder) {
        final Order orderMethod = toOrder(sortOrder);
        final PathBuilder<T> pathBuilder = new PathBuilder<>(qClass.getType(), qClass.getMetadata());
        return new OrderSpecifier(orderMethod, pathBuilder.get(sortOrder.getProperty()));
    }

    private static Order toOrder(final Sort.Order sortOrder) {
        if (sortOrder.isAscending()) {
            return Order.ASC;
        }
        return Order.DESC;
    }

    public static BooleanExpression containsKeyword(final StringPath stringPath, final String keyword) {
        if (Objects.isNull(keyword) || keyword.isBlank()) {
            return null;
        }
        return stringPath.contains(keyword);
    }

    public static <T> BooleanExpression toEqExpression(final SimpleExpression<T> simpleExpression, final T compared) {
        if (Objects.isNull(compared)) {
            return null;
        }
        return simpleExpression.eq(compared);
    }

    public static <T> Slice<T> toSlice(final Pageable pageable, final List<T> items) {
        if (items.size() > pageable.getPageSize()) {
            items.remove(items.size() - 1);
            return new SliceImpl<>(items, pageable, true);
        }
        return new SliceImpl<>(items, pageable, false);
    }
}

다른 부분들에서는 크게 특이한 부분이 없었는데, toEqExpression의 파라미터를 고르느라 조금 애를 먹었다. eq로 어떤 타입이 들어오든 비교를 할 수 있게 해주려고 했는데, 문제는 Q클래스에서 가져오는 프로퍼티 값을 어떻게 제네릭으로 쓰느냐였다.

@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QMember extends EntityPathBase<Member> {

    private static final long serialVersionUID = -641381270L;

    public static final QMember member = new QMember("member1");

    public final EnumPath<CareerLevel> careerLevel = createEnum("careerLevel", CareerLevel.class);

    public final StringPath gitHubId = createString("gitHubId");

    public final NumberPath<Long> id = createNumber("id", Long.class);

    public final StringPath imageUrl = createString("imageUrl");

    public final ListPath<com.woowacourse.f12.domain.inventoryproduct.InventoryProduct, com.woowacourse.f12.domain.inventoryproduct.QInventoryProduct> inventoryProducts = this.<com.woowacourse.f12.domain.inventoryproduct.InventoryProduct, com.woowacourse.f12.domain.inventoryproduct.QInventoryProduct>createList("inventoryProducts", com.woowacourse.f12.domain.inventoryproduct.InventoryProduct.class, com.woowacourse.f12.domain.inventoryproduct.QInventoryProduct.class, PathInits.DIRECT2);

    public final EnumPath<JobType> jobType = createEnum("jobType", JobType.class);

    public final StringPath name = createString("name");

    public QMember(String variable) {
        super(Member.class, forVariable(variable));
    }

    public QMember(Path<? extends Member> path) {
        super(path.getType(), path.getMetadata());
    }

    public QMember(PathMetadata metadata) {
        super(Member.class, metadata);
    }

}

Q클래스의 필드들은 원래 클래스의 타입에 따라 Path 타입들로 만들어지게 되는데, 처음에는 Path니까 Path<T>로 묶어서 쓰면 된다고 생각했다. 하지만 문제는, Path<T> 타입에는 eq 메서드가 존재하지 않았다. 애초에 Path 타입은 타입과 메타데이터를 가져오기 위한 타입이었고, BooleanExpression을 만드는 메서드들은 추상 클래스 쪽에 있었다. 그래서 Expression 클래스를 타고 타고 올라가다보니 eq 메서드가 구현된 추상 클래스 SimpleExpression에 도달하게 되었고, 메서드 파라미터로 들어오는 값을 SimpleExpression<T> 타입으로 선언해주었다. 리팩토링이 완료되고 나니 레포지토리 코드가 훨씬 깔끔해졌다.

public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory jpaQueryFactory;

    public MemberRepositoryCustomImpl(final JPAQueryFactory jpaQueryFactory) {
        this.jpaQueryFactory = jpaQueryFactory;
    }

    public Slice<Member> findBySearchConditions(final String keyword, final CareerLevel careerLevel,
                                                final JobType jobType,
                                                final Pageable pageable) {
        final JPAQuery<Member> jpaQuery = jpaQueryFactory.select(member)
                .from(member)
                .where(
                        containsKeyword(member.gitHubId, keyword),
                        toEqExpression(member.careerLevel, careerLevel),
                        toEqExpression(member.jobType, jobType),
                        member.careerLevel.isNotNull(),
                        member.jobType.isNotNull()
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .orderBy(makeOrderSpecifiers(member, pageable));

        return toSlice(pageable, jpaQuery.fetch());
    }
}
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {

    private final JPAQueryFactory jpaQueryFactory;

    public ProductRepositoryCustomImpl(final JPAQueryFactory jpaQueryFactory) {
        this.jpaQueryFactory = jpaQueryFactory;
    }

    @Override
    public Slice<Product> findBySearchConditions(final String keyword, final Category category, final Pageable pageable) {
        final JPAQuery<Product> jpaQuery = jpaQueryFactory.selectFrom(product)
                .where(
                        containsKeyword(product.name, keyword),
                        toEqExpression(product.category, category)
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .orderBy(makeOrderSpecifiers(product, pageable));
        return toSlice(pageable, jpaQuery.fetch());
    }
}
public class ReviewRepositoryCustomImpl implements ReviewRepositoryCustom {

    private final JPAQueryFactory jpaQueryFactory;

    public ReviewRepositoryCustomImpl(final JPAQueryFactory jpaQueryFactory) {
        this.jpaQueryFactory = jpaQueryFactory;
    }

    @Override
    public List<CareerLevelCount> findCareerLevelCountByProductId(final Long productId) {
        return jpaQueryFactory.select(new QCareerLevelCount(member.careerLevel, member.id.count()))
                .from(review)
                .innerJoin(review.member, member)
                .where(
                        review.product.id.eq(productId),
                        member.careerLevel.isNotNull(),
                        member.jobType.isNotNull()
                )
                .groupBy(member.careerLevel)
                .fetch();
    }

    @Override
    public List<JobTypeCount> findJobTypeCountByProductId(final Long productId) {
        return jpaQueryFactory.select(new QJobTypeCount(member.jobType, member.id.count()))
                .from(review)
                .innerJoin(review.member, member)
                .where(
                        review.product.id.eq(productId),
                        member.careerLevel.isNotNull(),
                        member.jobType.isNotNull()
                )
                .groupBy(member.jobType)
                .fetch();
    }
}

내일 목표

내일은 클레이와 현재 부족한 예외 사항에 대한 REST Docs 볼륨을 채우는 페어를 진행할 예정이다. 그리고 QueryDSL과 WebClient의 사용에 대해 토미를 설득하는 것이 예정되어 있다. 꼭 설득해야 한다… 그렇지 않으면 위에 저렇게 열심히 리팩토링한 코드가 무위로 돌아간다 ㅠㅠ

profile
Backend Developeer

0개의 댓글