Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 8 additions & 18 deletions data/generators/brand_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,43 +253,33 @@ def generate_brand_sponsors(self):
print(f"\n[브랜드 협찬] 브랜드 협찬 상품 생성 중...")

with self.connection.cursor() as cursor:
cursor.execute("SELECT id FROM campaign")
campaign_ids = [row['id'] for row in cursor.fetchall()]

cursor.execute("SELECT id FROM brand")
brand_ids = [row['id'] for row in cursor.fetchall()]

if not campaign_ids or not brand_ids:
print("[경고] 캠페인 또는 브랜드가 없습니다.")
if not brand_ids:
print("[경고] 브랜드가 없습니다.")
return

sponsors = []
for campaign_id in campaign_ids:
# 각 캠페인당 1~3개의 협찬 상품 생성
for brand_id in brand_ids:
# 각 브랜드당 1~3개의 협찬 상품 생성
num_sponsors = self.fake.random_int(1, 3)
brand_id = self.fake.random_element(brand_ids)

for _ in range(num_sponsors):
total_count = self.fake.random_int(5, 20)
current_count = self.fake.random_int(0, total_count)

sponsors.append({
'campaign_id': campaign_id,
'brand_id': brand_id,
'name': random.choice(self.SPONSOR_NAMES),
'content': random.choice(self.SPONSOR_CONTENTS),
'total_count': total_count,
'current_count': current_count,
'is_deleted': False,
'created_at': datetime.now() - timedelta(days=self.fake.random_int(0, 30)),
'updated_at': datetime.now()
})

sql = """
INSERT INTO brand_available_sponsor (campaign_id, brand_id, name, content,
total_count, current_count, is_deleted, created_at, updated_at)
VALUES (%(campaign_id)s, %(brand_id)s, %(name)s, %(content)s,
%(total_count)s, %(current_count)s, %(is_deleted)s, %(created_at)s, %(updated_at)s)
INSERT INTO brand_available_sponsor (brand_id, name, content,
is_deleted, created_at, updated_at)
VALUES (%(brand_id)s, %(name)s, %(content)s,
%(is_deleted)s, %(created_at)s, %(updated_at)s)
"""
self.execute_many(sql, sponsors, "브랜드 협찬 상품")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
ALTER TABLE brand_available_sponsor
ADD COLUMN shipping_type VARCHAR(50) NULL;
Comment on lines +1 to +2
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

The migration script adds a shipping_type column to the brand_available_sponsor table. However, the BrandAvailableSponsor JPA entity (src/main/java/com/example/RealMatch/brand/domain/entity/BrandAvailableSponsor.java) does not include this field. This inconsistency will lead to a mismatch between the database schema and the application's entity model, potentially causing runtime errors or unexpected behavior during data persistence or retrieval. Given that BrandSponsorItem already has a shippingType, it's likely that shippingType is intended to be item-specific, not at the BrandAvailableSponsor level.

ALTER TABLE brand_available_sponsor
    -- REMOVE: ADD COLUMN shipping_type VARCHAR(50) NULL;


ALTER TABLE brand_sponsor_item
ADD COLUMN sponsor_id BIGINT NULL;

UPDATE brand_available_sponsor bas
JOIN brand_sponsor_info bsi ON bas.id = bsi.sponsor_id
SET bas.shipping_type = bsi.shipping_type;

UPDATE brand_sponsor_item bsi_item
JOIN brand_sponsor_info bsi ON bsi_item.sponsor_info_id = bsi.id
SET bsi_item.sponsor_id = bsi.sponsor_id;

ALTER TABLE brand_sponsor_item
DROP FOREIGN KEY FKet9dtmuqr2ba3moigtsxiabq2,
DROP COLUMN sponsor_info_id,
MODIFY sponsor_id BIGINT NOT NULL,
ADD CONSTRAINT fk_brand_sponsor_item_sponsor
FOREIGN KEY (sponsor_id) REFERENCES brand_available_sponsor(id);

DROP TABLE brand_sponsor_info;
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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.enums.IndustryType;
import com.example.RealMatch.brand.domain.repository.BrandAvailableSponsorRepository;
import com.example.RealMatch.brand.domain.repository.BrandCategoryRepository;
Expand All @@ -31,7 +32,6 @@
import com.example.RealMatch.brand.presentation.dto.request.BrandBeautyUpdateRequestDto;
import com.example.RealMatch.brand.presentation.dto.request.BrandFashionCreateRequestDto;
import com.example.RealMatch.brand.presentation.dto.request.BrandFashionUpdateRequestDto;
import com.example.RealMatch.brand.presentation.dto.response.ActionDto;
import com.example.RealMatch.brand.presentation.dto.response.BeautyFilterDto;
import com.example.RealMatch.brand.presentation.dto.response.BrandCreateResponseDto;
import com.example.RealMatch.brand.presentation.dto.response.BrandDetailResponseDto;
Expand All @@ -56,12 +56,9 @@
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;
Expand All @@ -78,7 +75,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,54 +218,100 @@ 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"
);
return buildSponsorProductDetailResponse(brand, product);
}

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<String> categories = buildCategories(brand);

SponsorInfoDto sponsorInfo = SponsorInfoDto.builder()
.items(mockItems)
.shippingType("CREATOR_PAY")
.build();
return products.stream()
.map(product -> buildSponsorProductListResponse(brand, product, categories))
.collect(Collectors.toList());
Comment on lines 232 to 234
Copy link
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
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
) {
List<String> imageUrls = buildProductImageUrls(product);
List<String> categories = buildCategories(brand);
SponsorInfoDto sponsorInfoDto = buildSponsorInfo(brand, product);

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)
.action(action)
.productImageUrls(imageUrls)
.categories(categories)
.sponsorInfo(sponsorInfoDto)
.build();
}

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

List<BrandAvailableSponsor> products = brandAvailableSponsorRepository.findByBrandIdWithImages(brandId);
return SponsorProductListResponseDto.from(brand, product, imageUrls, categories, sponsorInfoDto);
}

return products.stream()
.map(SponsorProductListResponseDto::from)
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());
}

private List<String> buildCategories(Brand brand) {
if (brand.getIndustryType() == null) {
return List.of();
}
if (brand.getIndustryType() == IndustryType.BEAUTY) {
return tagBrandRepository.findTagNamesByBrandIdAndTagCategory(
brand.getId(), TagCategory.BEAUTY_INTEREST_STYLE.getDescription());
}
if (brand.getIndustryType() == IndustryType.FASHION) {
Comment on lines +284 to +285
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

buildCategories 메서드에서 industryType을 확인할 때 두 개의 독립적인 if 문을 사용하고 있습니다. BEAUTYFASHION은 상호 배타적이므로 if-else if 구조를 사용하는 것이 더 명확하고 효율적입니다.

        } else if (brand.getIndustryType() == IndustryType.FASHION) {

return tagBrandRepository.findTagNamesByBrandIdAndTagCategory(
brand.getId(), TagCategory.FASHION_INTEREST_ITEM.getDescription());
}
Comment on lines +281 to +288
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

두 개의 독립적인 if 문을 사용하는 것보다 if-else if 구조를 사용하는 것이 더 명확하고 안전합니다. 이렇게 하면 코드가 상호 배타적인 조건을 다루고 있음을 명확히 나타낼 수 있으며, 나중에 새로운 IndustryType이 추가되었을 때 의도치 않게 빈 리스트가 반환되는 실수를 방지할 수 있습니다.

        if (brand.getIndustryType() == IndustryType.BEAUTY) {
            return tagBrandRepository.findTagNamesByBrandIdAndTagCategory(
                    brand.getId(), TagCategory.BEAUTY_INTEREST_STYLE.getDescription());
        } else if (brand.getIndustryType() == IndustryType.FASHION) {
            return tagBrandRepository.findTagNamesByBrandIdAndTagCategory(
                    brand.getId(), TagCategory.FASHION_INTEREST_ITEM.getDescription());
        }

return List.of();
}
Comment on lines 277 to 290
Copy link
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
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(Brand brand, BrandAvailableSponsor sponsor) {
if (sponsor.getItems().isEmpty()) {
return null;
}
List<SponsorItemDto> items = sponsor.getItems().stream()
.map(item -> {
SponsorItemDto.SponsorItemDtoBuilder builder = SponsorItemDto.builder()
.availableQuantity(item.getAvailableQuantity());
if (brand.getIndustryType() == IndustryType.BEAUTY) {
builder.itemId(item.getId())
.availableType(item.getAvailableType())
.availableSize(item.getAvailableSize())
.shippingType(item.getShippingType());
}
return builder.build();
})
.collect(Collectors.toList());

return SponsorInfoDto.builder()
.items(items)
.build();
}
Comment on lines +292 to +313
Copy link
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
Expand Up @@ -3,7 +3,6 @@
import java.util.ArrayList;
import java.util.List;

import com.example.RealMatch.campaign.domain.entity.Campaign;
import com.example.RealMatch.global.common.DeleteBaseEntity;

import jakarta.persistence.CascadeType;
Expand Down Expand Up @@ -32,10 +31,6 @@ public class BrandAvailableSponsor extends DeleteBaseEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "campaign_id", nullable = false)
private Campaign campaign;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "brand_id", nullable = false)
private Brand brand;
Expand All @@ -46,25 +41,20 @@ public class BrandAvailableSponsor extends DeleteBaseEntity {
@Column(length = 1000)
private String content;

// 1. 총 모집 인원 필드 추가
@Column(name = "total_count", nullable = false)
private Integer totalCount;

// 2. 현재 모집된 인원 필드 추가 (기본값 0)
@Column(name = "current_count", nullable = false)
private Integer currentCount = 0;
@OneToMany(mappedBy = "sponsor", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<BrandSponsorItem> items = new ArrayList<>();
Comment on lines +44 to +45
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

The migration script data/migrations/2026-02-11_merge_sponsor_info_into_available_sponsor.sql adds a shipping_type column to the brand_available_sponsor table. However, this BrandAvailableSponsor entity does not include a corresponding shippingType field. This will cause a mismatch between your JPA entity and the database schema, leading to potential runtime errors when interacting with this column. Please ensure the entity reflects the database schema.


// 3. 이미지 리스트 (1:N 관계) 필드 추가
// mappedBy는 BrandSponsorImage 엔티티에 있는 변수명과 일치해야 합니다.
@OneToMany(mappedBy = "sponsor", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<BrandSponsorImage> images = new ArrayList<>();

@Builder
public BrandAvailableSponsor(Campaign campaign, Brand brand, String name, String content, Integer totalCount) {
this.campaign = campaign;
public BrandAvailableSponsor(
Brand brand,
String name,
String content
) {
this.brand = brand;
this.name = name;
this.content = content;
this.totalCount = totalCount;
}
}
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_id", nullable = false)
private BrandAvailableSponsor sponsor;

@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 = "shipping_type", length = 30)
private String shippingType;

@Builder
public BrandSponsorItem(
BrandAvailableSponsor sponsor,
String availableType,
Integer availableQuantity,
Integer availableSize,
String shippingType
) {
this.sponsor = sponsor;
this.availableType = availableType;
this.availableQuantity = availableQuantity;
this.availableSize = availableSize;
this.shippingType = shippingType;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,4 @@ public interface BrandAvailableSponsorRepository extends JpaRepository<BrandAvai

@Query("SELECT s FROM BrandAvailableSponsor s LEFT JOIN FETCH s.images WHERE s.brand.id = :brandId")
List<BrandAvailableSponsor> findByBrandIdWithImages(@Param("brandId") Long brandId);

List<BrandAvailableSponsor> findByCampaignId(Long campaignId);

@Query("SELECT s FROM BrandAvailableSponsor s LEFT JOIN FETCH s.images WHERE s.campaign.id = :campaignId")
List<BrandAvailableSponsor> findByCampaignIdWithImages(@Param("campaignId") Long campaignId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,4 @@
@AllArgsConstructor
public class SponsorInfoDto {
private List<SponsorItemDto> items;
private String shippingType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ public class SponsorItemDto {
private String availableType;
private Integer availableQuantity;
private Integer availableSize;
private String sizeUnit;
private String shippingType;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ public class SponsorProductDetailResponseDto {
private String brandName;
private Long productId;
private String productName;
private String productDescription;
private List<String> productImageUrls;
private List<String> categories;
private SponsorInfoDto sponsorInfo;
private ActionDto action;
}
Loading