Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import com.example.RealMatch.brand.domain.entity.BrandDescribeTag;
import com.example.RealMatch.brand.domain.entity.BrandImage;
import com.example.RealMatch.brand.domain.entity.BrandLike;
import com.example.RealMatch.brand.domain.entity.BrandSponsorImage;
import com.example.RealMatch.brand.domain.entity.BrandSponsorInfo;
import com.example.RealMatch.brand.domain.entity.enums.IndustryType;
import com.example.RealMatch.brand.domain.repository.BrandAvailableSponsorRepository;
import com.example.RealMatch.brand.domain.repository.BrandCategoryRepository;
Expand All @@ -26,6 +28,7 @@
import com.example.RealMatch.brand.domain.repository.BrandImageRepository;
import com.example.RealMatch.brand.domain.repository.BrandLikeRepository;
import com.example.RealMatch.brand.domain.repository.BrandRepository;
import com.example.RealMatch.brand.domain.repository.BrandSponsorInfoRepository;
import com.example.RealMatch.brand.exception.BrandErrorCode;
import com.example.RealMatch.brand.presentation.dto.request.BrandBeautyCreateRequestDto;
import com.example.RealMatch.brand.presentation.dto.request.BrandBeautyUpdateRequestDto;
Expand Down Expand Up @@ -56,19 +59,17 @@
import com.example.RealMatch.user.domain.repository.UserRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class BrandService {

private final BrandRepository brandRepository;
private final BrandLikeRepository brandLikeRepository;
private final BrandCategoryViewRepository brandCategoryViewRepository;
private final BrandCategoryRepository brandCategoryRepository;
private final BrandAvailableSponsorRepository brandAvailableSponsorRepository;
private final BrandSponsorInfoRepository brandSponsorInfoRepository;
private final BrandDescribeTagRepository brandDescribeTagRepository;
private final BrandImageRepository brandImageRepository;

Expand All @@ -78,7 +79,6 @@ public class BrandService {
private final TagRepository tagRepository;

private final UserRepository userRepository;

private static final Pattern URL_PATTERN = Pattern.compile("^https?://([\\da-z.-]+)\\.([a-z.]{2,6})[/\\w .-]*/?$");

// ******** //
Expand Down Expand Up @@ -222,52 +222,115 @@ public SponsorProductDetailResponseDto getSponsorProductDetail(Long brandId, Lon
throw new IllegalArgumentException("해당 브랜드의 제품이 아닙니다.");
}

List<String> mockImageUrls = List.of(
"https://cdn.example.com/products/100/1.png",
"https://cdn.example.com/products/100/2.png",
"https://cdn.example.com/products/100/3.png"
);
BrandSponsorInfo sponsorInfo = brandSponsorInfoRepository.findBySponsorIdWithItems(product.getId())
.orElse(null);
return buildSponsorProductDetailResponse(brand, product, sponsorInfo);
}

List<String> mockCategories = List.of("스킨케어", "메이크업");
@Transactional(readOnly = true)
public List<SponsorProductListResponseDto> getSponsorProducts(Long brandId) {
Brand brand = brandRepository.findById(brandId)
.orElseThrow(() -> new ResourceNotFoundException("브랜드 정보를 찾을 수 없습니다."));

List<SponsorItemDto> mockItems = List.of(
SponsorItemDto.builder().itemId(1L).availableType("SAMPLE").availableQuantity(1).availableSize(50).sizeUnit("ml").build(),
SponsorItemDto.builder().itemId(2L).availableType("FULL").availableQuantity(1).availableSize(100).sizeUnit("ml").build()
);
List<BrandAvailableSponsor> products = brandAvailableSponsorRepository.findByBrandIdWithImages(brandId);
List<Long> sponsorIds = products.stream()
.map(BrandAvailableSponsor::getId)
.collect(Collectors.toList());
Map<Long, BrandSponsorInfo> sponsorInfoBySponsorId = sponsorIds.isEmpty()
? Map.of()
: brandSponsorInfoRepository.findBySponsorIdInWithItems(sponsorIds)
.stream()
.collect(Collectors.toMap(info -> info.getSponsor().getId(), Function.identity()));

SponsorInfoDto sponsorInfo = SponsorInfoDto.builder()
.items(mockItems)
.shippingType("CREATOR_PAY")
.build();
return products.stream()
.map(product -> buildSponsorProductListResponse(brand, product, sponsorInfoBySponsorId.get(product.getId())))
.collect(Collectors.toList());
Comment on lines +232 to +234
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

getSponsorProducts 메소드 내에서 products 리스트를 순회하며 buildSponsorProductListResponse를 호출하고, 이어서 SponsorProductListResponseDto.from 메소드가 호출됩니다. 이 과정에서 product.getCampaign().getDescription()을 통해 각 제품의 캠페인 정보에 접근하게 되는데, BrandAvailableSponsor 엔티티의 campaign 연관 필드가 지연 로딩(LAZY loading)으로 설정되어 있어 제품마다 캠페인을 조회하는 추가 쿼리가 발생하여 N+1 문제가 생깁니다.

BrandAvailableSponsorRepository.findByBrandIdWithImages 메소드에서 campaign도 함께 fetch join 하도록 수정하여 이 문제를 해결할 수 있습니다.

예를 들어, BrandAvailableSponsorRepository에 다음과 같은 메소드를 추가하고 사용하는 것을 제안합니다:

@Query("SELECT s FROM BrandAvailableSponsor s LEFT JOIN FETCH s.campaign LEFT JOIN FETCH s.images WHERE s.brand.id = :brandId")
List<BrandAvailableSponsor> findByBrandIdWithCampaignAndImages(@Param("brandId") Long brandId);
References
  1. To prevent N+1 query issues, fetch multiple items in a single batch query (e.g., using an 'IN' clause) instead of querying for each item within a loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영하겠습니다.

}

ActionDto action = ActionDto.builder()
.canProposeCampaign(true)
.proposeCampaignCtaText("캠페인 제안하기")
.build();
private SponsorProductDetailResponseDto buildSponsorProductDetailResponse(
Brand brand,
BrandAvailableSponsor product,
BrandSponsorInfo sponsorInfo
) {
List<String> imageUrls = buildProductImageUrls(product);
List<String> categories = buildCategories(brand);
SponsorInfoDto sponsorInfoDto = buildSponsorInfo(brand.getIndustryType(), sponsorInfo);
ActionDto action = buildAction();

return SponsorProductDetailResponseDto.builder()
.brandId(brand.getId())
.brandName(brand.getBrandName())
.productId(product.getId())
.productName(product.getName())
.productDescription(product.getCampaign().getDescription())
.productImageUrls(mockImageUrls)
.categories(mockCategories)
.sponsorInfo(sponsorInfo)
.productImageUrls(imageUrls)
.categories(categories)
.sponsorInfo(sponsorInfoDto)
.action(action)
.build();
}

@Transactional(readOnly = true)
public List<SponsorProductListResponseDto> getSponsorProducts(Long brandId) {
brandRepository.findById(brandId)
.orElseThrow(() -> new ResourceNotFoundException("브랜드 정보를 찾을 수 없습니다."));
private SponsorProductListResponseDto buildSponsorProductListResponse(
Brand brand,
BrandAvailableSponsor product,
BrandSponsorInfo sponsorInfo
) {
List<String> imageUrls = buildProductImageUrls(product);
List<String> categories = buildCategories(brand);
SponsorInfoDto sponsorInfoDto = buildSponsorInfo(brand.getIndustryType(), sponsorInfo);
ActionDto action = buildAction();

return SponsorProductListResponseDto.from(brand, product, imageUrls, categories, sponsorInfoDto, action);
}

List<BrandAvailableSponsor> products = brandAvailableSponsorRepository.findByBrandIdWithImages(brandId);
private List<String> buildProductImageUrls(BrandAvailableSponsor product) {
List<BrandSponsorImage> images = product.getImages();
if (images == null || images.isEmpty()) {
return List.of();
}
return images.stream()
.map(BrandSponsorImage::getImageUrl)
.collect(Collectors.toList());
}

return products.stream()
.map(SponsorProductListResponseDto::from)
private List<String> buildCategories(Brand brand) {
if (brand.getIndustryType() == null) {
return List.of();
}
return List.of(brand.getIndustryType().name());
}
Comment on lines +277 to +290
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

buildCategories 메소드가 List.of(brand.getIndustryType().name())을 반환하도록 구현되어 있는데, 이는 단순히 "BEAUTY" 또는 "FASHION" 문자열만 리스트에 담아 반환합니다. 이전 목(mock) 데이터에서는 "스킨케어", "메이크업"과 같은 더 상세한 카테고리를 사용했습니다. getBrandDetail 메소드에서처럼 tagBrandRepository를 사용하여 브랜드에 연관된 상세 카테고리 태그들을 가져오는 것이 사용자에게 더 유용한 정보를 제공할 것 같습니다. 현재 구현이 의도된 것인지 확인이 필요하며, 아니라면 상세 카테고리를 반환하도록 개선하는 것을 고려해 보세요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정완료 상세 카테고리를 반환해야 하는게 맞습니다.


private SponsorInfoDto buildSponsorInfo(IndustryType industryType, BrandSponsorInfo sponsorInfo) {
if (sponsorInfo == null) {
return null;
}
List<SponsorItemDto> items = sponsorInfo.getItems() == null
? List.of()
: sponsorInfo.getItems().stream()
.map(item -> {
SponsorItemDto.SponsorItemDtoBuilder builder = SponsorItemDto.builder()
.itemId(item.getId())
.availableQuantity(item.getAvailableQuantity());
if (industryType == IndustryType.BEAUTY) {
builder.availableType(item.getAvailableType())
.availableSize(item.getAvailableSize())
.sizeUnit(item.getSizeUnit());
}
return builder.build();
})
.collect(Collectors.toList());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

BrandSponsorInfo 엔티티에서 items 필드가 new ArrayList<>()로 초기화되어 있으므로 sponsorInfo.getItems()null을 반환하지 않습니다. 따라서 sponsorInfo.getItems() == null 확인은 불필요합니다. 해당 null 체크 로직을 제거하여 코드를 간결하게 만들 수 있습니다.

        List<SponsorItemDto> items = sponsorInfo.getItems().stream()
                .map(item -> {
                    SponsorItemDto.SponsorItemDtoBuilder builder = SponsorItemDto.builder()
                            .itemId(item.getId())
                            .availableQuantity(item.getAvailableQuantity());
                    if (industryType == IndustryType.BEAUTY) {
                        builder.availableType(item.getAvailableType())
                                .availableSize(item.getAvailableSize())
                                .sizeUnit(item.getSizeUnit());
                    }
                    return builder.build();
                })
                .collect(Collectors.toList());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

완료


return SponsorInfoDto.builder()
.items(items)
.shippingType(sponsorInfo.getShippingType())
.build();
}

private ActionDto buildAction() {
return ActionDto.builder()
.canProposeCampaign(true)
.proposeCampaignCtaText("캠페인 제안하기")
.build();
}
Comment on lines +292 to +313
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

In the buildSponsorInfo method, shippingType is conditionally added to SponsorItemDto for BEAUTY industry types. This implies shippingType is an attribute of an individual sponsor item. However, the migration script data/migrations/2026-02-11_merge_sponsor_info_into_available_sponsor.sql adds shipping_type to the brand_available_sponsor table, not just brand_sponsor_item. This creates a discrepancy. If shippingType is item-specific, it should only exist in BrandSponsorItem and be populated from there. If it's a property of the overall sponsor, it should be in BrandAvailableSponsor and SponsorInfoDto.


// ******** //
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.example.RealMatch.brand.domain.entity;

import java.util.ArrayList;
import java.util.List;

import com.example.RealMatch.global.common.DeleteBaseEntity;

import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "brand_sponsor_info")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BrandSponsorInfo extends DeleteBaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sponsor_id", nullable = false, unique = true)
private BrandAvailableSponsor sponsor;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "brand_id", nullable = false)
private Brand brand;

@Column(name = "shipping_type", length = 50)
private String shippingType;

@OneToMany(mappedBy = "sponsorInfo", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<BrandSponsorItem> items = new ArrayList<>();

@Builder
public BrandSponsorInfo(BrandAvailableSponsor sponsor, Brand brand, String shippingType) {
this.sponsor = sponsor;
this.brand = brand;
this.shippingType = shippingType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.example.RealMatch.brand.domain.entity;

import com.example.RealMatch.global.common.DeleteBaseEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "brand_sponsor_item")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BrandSponsorItem extends DeleteBaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sponsor_info_id", nullable = false)
private BrandSponsorInfo sponsorInfo;

@Column(name = "available_type", length = 30)
private String availableType;

@Column(name = "available_quantity")
private Integer availableQuantity;

@Column(name = "available_size")
private Integer availableSize;

@Column(name = "size_unit", length = 20)
private String sizeUnit;

@Builder
public BrandSponsorItem(
BrandSponsorInfo sponsorInfo,
String availableType,
Integer availableQuantity,
Integer availableSize,
String sizeUnit
) {
this.sponsorInfo = sponsorInfo;
this.availableType = availableType;
this.availableQuantity = availableQuantity;
this.availableSize = availableSize;
this.sizeUnit = sizeUnit;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.example.RealMatch.brand.domain.repository;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.example.RealMatch.brand.domain.entity.BrandSponsorInfo;

public interface BrandSponsorInfoRepository extends JpaRepository<BrandSponsorInfo, Long> {

@Query("SELECT DISTINCT si FROM BrandSponsorInfo si LEFT JOIN FETCH si.items WHERE si.sponsor.id = :sponsorId")
Optional<BrandSponsorInfo> findBySponsorIdWithItems(@Param("sponsorId") Long sponsorId);

@Query("SELECT DISTINCT si FROM BrandSponsorInfo si LEFT JOIN FETCH si.items WHERE si.sponsor.id IN :sponsorIds")
List<BrandSponsorInfo> findBySponsorIdInWithItems(@Param("sponsorIds") List<Long> sponsorIds);
}
Loading
Loading