inflearn logo
강의

강의

N
챌린지

챌린지

멘토링

멘토링

N
클립

클립

로드맵

로드맵

지식공유

제미니의 개발실무 - 커머스 백엔드 기본편

쿠폰 - 코드 느끼기

궁금한점이 여러개 생겼습니다.

13

구코딩

작성한 질문수 12

0

안녕하세요, 2회독 하는중입니다. 확실히 강의 활용하면서 진행하니깐 느끼기(?)가 뭔지 알것같고 궁금한점이 생기네요. CouponService 에서 이미 다운받은 쿠폰은 내려주지 않는 로직을 만들면서 궁금한점이 여러개 생겼습니다.

/// CouponService
public List<Coupon> getCouponsForMenus(Principal principal, Collection<Long> couponIds) {
        Set<Long> applicableCouponIds = couponFinder.findApplicableCouponIds(couponIds);
        Set<Long> downloadedCouponIds = issuedCouponFinder.findDownloadedCouponIds(principal.getId());
        Set<Long> downloadableCouponIds = applicableCouponIds.stream()
                .filter(id -> !downloadedCouponIds.contains(id))
                .collect(Collectors.toSet());
        return couponFinder.findAllById(downloadableCouponIds);
    }
 
 // CouponFinder
 public Set<Long> findApplicableCouponIds(Collection<Long> menuIds) {
        List<CouponTargetEntity> menuTargets = couponTargetRepository.findByTargetTypeAndTargetIdIn(
                CouponTargetType.MENU,
                menuIds
        ).stream().filter(CouponTargetEntity::isActive).toList();

        List<CouponTargetEntity> categoryTargets = couponTargetRepository.findByTargetTypeAndTargetIdIn(
                CouponTargetType.MENU_CATEGORY,
                menuCategoryRepository.findByCategoryIdIn(menuIds).stream()
                        .filter(MenuCategoryEntity::isActive)
                        .map(MenuCategoryEntity::getCategoryId).collect(Collectors.toSet())
        );

        return Stream.concat(menuTargets.stream(), categoryTargets.stream())
                .map(CouponTargetEntity::getCouponId)
                .collect(Collectors.toSet());

    }
  1. CouponService 에서 implement 계층 CouponFinder, IssuedCouponFinder 를 의존하고 있는 상황에서 각 계층끼리 소통할 때

강사님 Q/A 답변중에서 가능한 db 엔티티 대신에 개념객체를 넘기는 방향으로 설계하는걸 지향하신다고 말씀하신 답변이 기억이 나는데요, 지금 상황에서는 개념객체까지 변환하는것보다는 id 컬렉션만 뽑아내서 넘기는게 더 효율적이고 깔끔한 것 같습니다.

  1. 회원/비회원 요구사항

https://inf.run/GTxAx

해당 Q/A 를 보기도 했고 마침 강의 활용해서 커피 판매 어플리케이션을 만들고 싶어서 비회원처리가 필요했습니다.

public class PrincipalArgumentResolver implements HandlerMethodArgumentResolver {

    private static final String PRINCIPAL_ID_HEADER = "Gu-Coffee-Principal-Id";
    private static final String PRINCIPAL_TYPE_HEADER = "Gu-Coffee-Principal-Type";

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(Principal.class);
    }

    @Override
    public @Nullable Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        if (request == null) throw new CoreException(ErrorType.INVALID_REQUEST, null);
        Authenticated annotation = parameter.getParameterAnnotation(Authenticated.class);

        String id = request.getHeader(PRINCIPAL_ID_HEADER);
        String type = request.getHeader(PRINCIPAL_TYPE_HEADER);

        validatePrincipal(annotation, id, type);

        return Principal.of(id, type);

    }

    private void validatePrincipal(Authenticated annotation, String id, String type) {
        boolean isRequired = (annotation != null && annotation.required());

        if (isRequired) {
            if (id == null) throw new CoreException(ErrorType.UNAUTHORIZED, null);
            if (!PrincipalType.USER.name().equals(type)) {
                throw new CoreException(ErrorType.UNAUTHORIZED, "회원 전용 서비스입니다.");
            }
        }
        if (type == null) {
            throw new CoreException(ErrorType.INVALID_REQUEST, null);
        }

    }
}

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Authenticated {
    boolean required() default true;
}

저는 Authenticated 라는 메타 어노테이션을 사용해서

@GetMapping("/v1/issued-coupons")
    private ApiResponse<List<IssuedCouponResponse>> getIssuedCoupons(@Authenticated Principal principal) {
        List<IssuedCoupon> issuedCoupons = issuedCouponService.getIssuedCoupons(principal);
        return ApiResponse.success(IssuedCouponResponse.from(issuedCoupons));
    }

    @PostMapping("/v1/coupons/{couponId}/download")
    private ApiResponse<?> download(
            Principal principal,
            @PathVariable Long couponId) {
        couponService.download(principal, couponId);
        return ApiResponse.success();
    }
    
    public void download(Principal principal, Long couponId) {
        if (principal.getType().equals(PrincipalType.GUEST)) {
            throw new CoreException(ErrorType.UNAUTHORIZED, "비회원은 쿠폰을 다운로드할 수 없습니다.");
    }

애초에 회원만 접근 가능한 api 는 어노테이션으로 처리하던가 서비스 단에서 예외를 던지게 할 수도 있을 것 같아서 이렇게 만들어 봤습니다.

@Table(name = "cart_item")
@Entity
public class CartItemEntity extends BaseEntity {
    private Long userId;
    private String guestKey;
    @Enumerated(EnumType.STRING)
    private PrincipalType principalType;
    private Long menuId;
    private Long quantity;
    
 }
 
 public class Cart {
    private Principal principal;
    private List<CartItem> items;
}

마지막으로 장바구니에 비회원도 담을 수 있다는 요구사항이 있다고 가정하면 테이블에는 userId 만 둬서 null 로 구분하는 것 보다 key 필드를 추가하고

public interface CartItemRepository extends JpaRepository<CartItemEntity, Long> {

    @Query("SELECT c FROM CartItemEntity c " +
            "WHERE (:type = 'USER' AND c.userId = :id) " +
            "OR (:type = 'GUEST' AND c.guestKey = :key)")
    List<CartItemEntity> findByPrincipal(
            @Param("type") String type,
            @Param("id") Long id,
            @Param("key") String key
    );
}

이런식으로 쿼리로 분기처리해야지 생각했는데요. 예전 강사님 Q/A 답변 중에서 쿼리중심의 어플리케이션이 되는걸 지양한다는 느낌의 답변을 봤던 것 같습니다.

AI 한테 물어봤을때는 해당 로직은 필터링 목적이니 쿼리로 처리하는게 성능상 효과적이라는 답변을 줬는데요.

List<IssuedCouponEntity> issuedCoupons = issuedCouponRepository.findByUserId(userId)
                .stream()
                .filter(IssuedCouponEntity::isActive)
                .toList();

isActive 인 데이터를 가져올 때 강사님이 findByXXAndStatus 로 한번에 가져오거나 혹은 위처럼 filter로 처리하는 두가지 패턴을 보여주셨습니다.

이걸 처음 봤을 때, 미세한 성능보다는 filter 로 직관적으로 활성상태인 데이터만 가져온다는 목적을 코드로 보여주는게 더 괜찮겠다고 생각했는데요.

spring-boot 도메인 backend

답변 0

다양한 관점의 코드 경험을 위해 개선하지 않은 코드

1

50

1

histories() 응답에 PointHistory.id를 포함한 이유가 궁금합니다/

1

44

2

SettlementTargetRepository Jquery 질문

1

48

2

부가 기능을 이벤트 핸들러로 분리하는 기준이 있을까요?

1

60

2

엔티티의 pk 를 0으로 초기화하시는 이유가 있을까요??

1

67

2

제미니님 안녕하세요!

1

74

2

개념 간 격벽 분리와 목록 조회 시 발생하는 참조 구조

1

82

2

프로덕트와 프로덕트카테고리 사이의 삭제 정책

1

75

2

새로 개발한다면 구현 순서

1

134

1

의존 방향에 대한 고민

1

124

2

어드민(Back-office)에서 예약 변경 시, '할인 조건 재검증(쿠폰 회수)' vs '기존 혜택 유지' 중 어떤 정책이 일반적인가요?

1

96

2

OrderKeyGenerator 인스턴스화 generate() 질문

1

83

1

외부 API 통합 시 데이터 제어 범위 설계 질문

1

96

1

PG 결제 승인 로직

1

129

2

QnA에서 Join 필드 표현법

1

89

1

결제서비스 콜백 동시성문제 가능성

1

107

2

굿

1

108

1

도메인/엔티티 분리 상황에서 쓰기 작업 하는 방법

1

135

2

도메인 객체와 엔티티 객체 사용

1

138

2

CouponService 의존성 의문

1

97

2

상품 목록 조회 고도화 질문

1

111

2

표현 계층에서의 접근 지점이 다양해지는것과 이를 해결하기 위한 파사드의 도입에 대해 제미니님의 생각이 궁금합니다.

1

123

2

제품상세 코드 느끼기

1

144

2

격벽의 순환 참조(?)

1

113

2