From 4d180c2a9f23999116d119014ddd1117812cffa8 Mon Sep 17 00:00:00 2001 From: merrycoral Date: Mon, 27 Oct 2025 11:04:03 +0900 Subject: [PATCH] =?UTF-8?q?Event=20Service=20=EA=B0=9C=EB=B0=9C=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=B6=95=20=EB=B0=8F=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경사항: - UserPrincipal 및 JWT 인증 시스템을 Long에서 UUID로 변경 - Event 엔티티 JPA 설정 최적화 (Lazy loading 및 fetch 전략 개선) - 개발 환경용 DevAuthenticationFilter 추가 (User Service 구현 전까지 임시 사용) - EventServiceApplication 문법 오류 수정 - Hibernate multiple bags 문제 해결 (List를 Set으로 변경) 기술 세부사항: - common/UserPrincipal: Long → UUID 타입 변경, @Builder 어노테이션 추가 - common/JwtTokenProvider: UUID 지원 추가 - event-service/Event: Set 컬렉션 사용, Lazy loading 최적화 - event-service/EventService: Hibernate.initialize()로 컬렉션 초기화 - event-service/EventRepository: fetch join 쿼리 최적화 - event-service/SecurityConfig: DevAuthenticationFilter 통합 테스트 결과: - 모든 Event CRUD API 정상 작동 확인 - PostgreSQL 연결 정상 - 비즈니스 로직 검증 정상 작동 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../common/security/JwtTokenProvider.java | 15 +-- .../event/common/security/UserPrincipal.java | 7 +- .../eventservice/EventServiceApplication.java | 12 +- .../application/service/EventService.java | 61 +++++----- .../config/DevAuthenticationFilter.java | 53 +++++++++ .../eventservice/config/KafkaConfig.java | 107 ++++++++++++++++++ .../eventservice/config/SecurityConfig.java | 65 +++++++++++ .../eventservice/domain/entity/Event.java | 17 +-- .../domain/repository/EventRepository.java | 5 +- 9 files changed, 291 insertions(+), 51 deletions(-) create mode 100644 event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java create mode 100644 event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java create mode 100644 event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java diff --git a/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java b/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java index 4ac51db..7bd50c3 100644 --- a/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java +++ b/common/src/main/java/com/kt/event/common/security/JwtTokenProvider.java @@ -12,6 +12,7 @@ import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; +import java.util.UUID; /** * JWT 토큰 생성 및 검증 제공자 @@ -55,13 +56,13 @@ public class JwtTokenProvider { * @param roles 역할 목록 * @return Access Token */ - public String createAccessToken(Long userId, Long storeId, String email, String name, List roles) { + public String createAccessToken(UUID userId, UUID storeId, String email, String name, List roles) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + accessTokenValidityMs); return Jwts.builder() .subject(userId.toString()) - .claim("storeId", storeId) + .claim("storeId", storeId.toString()) .claim("email", email) .claim("name", name) .claim("roles", roles) @@ -78,7 +79,7 @@ public class JwtTokenProvider { * @param userId 사용자 ID * @return Refresh Token */ - public String createRefreshToken(Long userId) { + public String createRefreshToken(UUID userId) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + refreshTokenValidityMs); @@ -97,9 +98,9 @@ public class JwtTokenProvider { * @param token JWT 토큰 * @return 사용자 ID */ - public Long getUserIdFromToken(String token) { + public UUID getUserIdFromToken(String token) { Claims claims = parseToken(token); - return Long.parseLong(claims.getSubject()); + return UUID.fromString(claims.getSubject()); } /** @@ -111,8 +112,8 @@ public class JwtTokenProvider { public UserPrincipal getUserPrincipalFromToken(String token) { Claims claims = parseToken(token); - Long userId = Long.parseLong(claims.getSubject()); - Long storeId = claims.get("storeId", Long.class); + UUID userId = UUID.fromString(claims.getSubject()); + UUID storeId = UUID.fromString(claims.get("storeId", String.class)); String email = claims.get("email", String.class); String name = claims.get("name", String.class); @SuppressWarnings("unchecked") diff --git a/common/src/main/java/com/kt/event/common/security/UserPrincipal.java b/common/src/main/java/com/kt/event/common/security/UserPrincipal.java index da1a278..ff99809 100644 --- a/common/src/main/java/com/kt/event/common/security/UserPrincipal.java +++ b/common/src/main/java/com/kt/event/common/security/UserPrincipal.java @@ -1,6 +1,7 @@ package com.kt.event.common.security; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -8,6 +9,7 @@ import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -15,18 +17,19 @@ import java.util.stream.Collectors; * JWT 토큰에서 추출한 사용자 정보를 담는 객체 */ @Getter +@Builder @AllArgsConstructor public class UserPrincipal implements UserDetails { /** * 사용자 ID */ - private final Long userId; + private final UUID userId; /** * 매장 ID */ - private final Long storeId; + private final UUID storeId; /** * 사용자 이메일 diff --git a/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java index 6108ed2..e3fd04e 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java +++ b/event-service/src/main/java/com/kt/event/eventservice/EventServiceApplication.java @@ -2,6 +2,7 @@ package com.kt.event.eventservice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.kafka.annotation.EnableKafka; @@ -18,10 +19,13 @@ import org.springframework.kafka.annotation.EnableKafka; * @version 1.0.0 * @since 2025-10-23 */ -@SpringBootApplication(scanBasePackages = { - "com.kt.event.eventservice", - "com.kt.event.common" -}) +@SpringBootApplication( + scanBasePackages = { + "com.kt.event.eventservice", + "com.kt.event.common" + }, + exclude = {UserDetailsServiceAutoConfiguration.class} +) @EnableJpaAuditing @EnableKafka @EnableFeignClients diff --git a/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java index 6543f0b..5e0ba67 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java +++ b/event-service/src/main/java/com/kt/event/eventservice/application/service/EventService.java @@ -10,6 +10,7 @@ import com.kt.event.eventservice.domain.enums.EventStatus; import com.kt.event.eventservice.domain.repository.EventRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.hibernate.Hibernate; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -38,20 +39,20 @@ public class EventService { /** * 이벤트 생성 (Step 1: 목적 선택) * - * @param userId 사용자 ID (Long) - * @param storeId 매장 ID (Long) + * @param userId 사용자 ID (UUID) + * @param storeId 매장 ID (UUID) * @param request 목적 선택 요청 * @return 생성된 이벤트 응답 */ @Transactional - public EventCreatedResponse createEvent(Long userId, Long storeId, SelectObjectiveRequest request) { + public EventCreatedResponse createEvent(UUID userId, UUID storeId, SelectObjectiveRequest request) { log.info("이벤트 생성 시작 - userId: {}, storeId: {}, objective: {}", userId, storeId, request.getObjective()); - // 이벤트 엔티티 생성 (Long ID를 UUID로 변환) + // 이벤트 엔티티 생성 Event event = Event.builder() - .userId(UUID.nameUUIDFromBytes(("user-" + userId).getBytes())) - .storeId(UUID.nameUUIDFromBytes(("store-" + storeId).getBytes())) + .userId(userId) + .storeId(storeId) .objective(request.getObjective()) .eventName("") // 초기에는 비어있음, AI 추천 후 설정 .status(EventStatus.DRAFT) @@ -73,24 +74,28 @@ public class EventService { /** * 이벤트 상세 조회 * - * @param userId 사용자 ID (Long) + * @param userId 사용자 ID (UUID) * @param eventId 이벤트 ID * @return 이벤트 상세 응답 */ - public EventDetailResponse getEvent(Long userId, UUID eventId) { + public EventDetailResponse getEvent(UUID userId, UUID eventId) { log.info("이벤트 조회 - userId: {}, eventId: {}", userId, eventId); - UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes()); - Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid) + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getChannels()); + Hibernate.initialize(event.getGeneratedImages()); + Hibernate.initialize(event.getAiRecommendations()); + return mapToDetailResponse(event); } /** * 이벤트 목록 조회 (페이징, 필터링) * - * @param userId 사용자 ID (Long) + * @param userId 사용자 ID (UUID) * @param status 상태 필터 * @param search 검색어 * @param objective 목적 필터 @@ -98,7 +103,7 @@ public class EventService { * @return 이벤트 목록 */ public Page getEvents( - Long userId, + UUID userId, EventStatus status, String search, String objective, @@ -107,24 +112,28 @@ public class EventService { log.info("이벤트 목록 조회 - userId: {}, status: {}, search: {}, objective: {}", userId, status, search, objective); - UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes()); - Page events = eventRepository.findEventsByUser(userUuid, status, search, objective, pageable); + Page events = eventRepository.findEventsByUser(userId, status, search, objective, pageable); - return events.map(this::mapToDetailResponse); + return events.map(event -> { + // Lazy 컬렉션 초기화 + Hibernate.initialize(event.getChannels()); + Hibernate.initialize(event.getGeneratedImages()); + Hibernate.initialize(event.getAiRecommendations()); + return mapToDetailResponse(event); + }); } /** * 이벤트 삭제 * - * @param userId 사용자 ID (Long) + * @param userId 사용자 ID (UUID) * @param eventId 이벤트 ID */ @Transactional - public void deleteEvent(Long userId, UUID eventId) { + public void deleteEvent(UUID userId, UUID eventId) { log.info("이벤트 삭제 - userId: {}, eventId: {}", userId, eventId); - UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes()); - Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid) + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); if (!event.isDeletable()) { @@ -139,15 +148,14 @@ public class EventService { /** * 이벤트 배포 * - * @param userId 사용자 ID (Long) + * @param userId 사용자 ID (UUID) * @param eventId 이벤트 ID */ @Transactional - public void publishEvent(Long userId, UUID eventId) { + public void publishEvent(UUID userId, UUID eventId) { log.info("이벤트 배포 - userId: {}, eventId: {}", userId, eventId); - UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes()); - Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid) + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); // 배포 가능 여부 검증 및 상태 변경 @@ -161,15 +169,14 @@ public class EventService { /** * 이벤트 종료 * - * @param userId 사용자 ID (Long) + * @param userId 사용자 ID (UUID) * @param eventId 이벤트 ID */ @Transactional - public void endEvent(Long userId, UUID eventId) { + public void endEvent(UUID userId, UUID eventId) { log.info("이벤트 종료 - userId: {}, eventId: {}", userId, eventId); - UUID userUuid = UUID.nameUUIDFromBytes(("user-" + userId).getBytes()); - Event event = eventRepository.findByEventIdAndUserId(eventId, userUuid) + Event event = eventRepository.findByEventIdAndUserId(eventId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.EVENT_001)); event.end(); diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java b/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java new file mode 100644 index 0000000..fb56ea8 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/DevAuthenticationFilter.java @@ -0,0 +1,53 @@ +package com.kt.event.eventservice.config; + +import com.kt.event.common.security.UserPrincipal; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.UUID; + +/** + * 개발 환경용 인증 필터 + * + * User Service가 구현되지 않은 개발 환경에서 테스트를 위해 + * 기본 UserPrincipal을 자동으로 생성하여 SecurityContext에 설정합니다. + * + * TODO: 프로덕션 환경에서는 이 필터를 비활성화하고 실제 JWT 인증 필터를 사용해야 합니다. + */ +public class DevAuthenticationFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 이미 인증된 경우 스킵 + if (SecurityContextHolder.getContext().getAuthentication() != null) { + filterChain.doFilter(request, response); + return; + } + + // 개발용 기본 UserPrincipal 생성 + UserPrincipal userPrincipal = new UserPrincipal( + UUID.fromString("11111111-1111-1111-1111-111111111111"), // userId + UUID.fromString("22222222-2222-2222-2222-222222222222"), // storeId + "dev@test.com", // email + "개발테스트사용자", // name + Collections.singletonList("USER") // roles + ); + + // Authentication 객체 생성 및 SecurityContext에 설정 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userPrincipal, null, userPrincipal.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java b/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java new file mode 100644 index 0000000..0391c46 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/KafkaConfig.java @@ -0,0 +1,107 @@ +package com.kt.event.eventservice.config; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.*; +import org.springframework.kafka.listener.ContainerProperties; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.HashMap; +import java.util.Map; + +/** + * Kafka 설정 클래스 + * + * Producer와 Consumer 설정을 정의합니다. + * - Producer: event-created 토픽에 이벤트 발행 + * - Consumer: ai-event-generation-job, image-generation-job 토픽 구독 + */ +@Configuration +@EnableKafka +public class KafkaConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String consumerGroupId; + + /** + * Kafka Producer 설정 + * + * @return ProducerFactory 인스턴스 + */ + @Bean + public ProducerFactory producerFactory() { + Map config = new HashMap<>(); + config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + config.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); + + // Producer 성능 최적화 설정 + config.put(ProducerConfig.ACKS_CONFIG, "all"); + config.put(ProducerConfig.RETRIES_CONFIG, 3); + config.put(ProducerConfig.LINGER_MS_CONFIG, 1); + config.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy"); + + return new DefaultKafkaProducerFactory<>(config); + } + + /** + * KafkaTemplate 빈 생성 + * + * @return KafkaTemplate 인스턴스 + */ + @Bean + public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + + /** + * Kafka Consumer 설정 + * + * @return ConsumerFactory 인스턴스 + */ + @Bean + public ConsumerFactory consumerFactory() { + Map config = new HashMap<>(); + config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + config.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId); + config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + config.put(JsonDeserializer.TRUSTED_PACKAGES, "*"); + config.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); + config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); + + // Consumer 성능 최적화 설정 + config.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500); + config.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000); + + return new DefaultKafkaConsumerFactory<>(config); + } + + /** + * Kafka Listener Container Factory 설정 + * + * @return ConcurrentKafkaListenerContainerFactory 인스턴스 + */ + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setConcurrency(3); // 동시 처리 스레드 수 + factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); + return factory; + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java b/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java new file mode 100644 index 0000000..5aea9e1 --- /dev/null +++ b/event-service/src/main/java/com/kt/event/eventservice/config/SecurityConfig.java @@ -0,0 +1,65 @@ +package com.kt.event.eventservice.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security 설정 클래스 + * + * 현재 User Service가 구현되지 않았으므로 임시로 모든 API 접근을 허용합니다. + * TODO: User Service 구현 후 JWT 기반 인증/인가 활성화 필요 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + /** + * Spring Security 필터 체인 설정 + * - 모든 요청에 대해 인증 없이 접근 허용 + * - CSRF 보호 비활성화 (개발 환경) + * + * @param http HttpSecurity 설정 객체 + * @return SecurityFilterChain 보안 필터 체인 + * @throws Exception 설정 중 예외 발생 시 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 보호 비활성화 (개발 환경) + .csrf(AbstractHttpConfigurer::disable) + + // CORS 설정 + .cors(AbstractHttpConfigurer::disable) + + // 폼 로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + + // 로그아웃 비활성화 + .logout(AbstractHttpConfigurer::disable) + + // HTTP Basic 인증 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + + // 세션 관리 - STATELESS (세션 사용 안 함) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + + // 요청 인증 설정 + .authorizeHttpRequests(authz -> authz + // 모든 요청 허용 (개발 환경) + .anyRequest().permitAll() + ) + + // 개발용 인증 필터 추가 (User Service 구현 전까지 임시 사용) + .addFilterBefore(new DevAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java index f205540..9602b65 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/entity/Event.java @@ -4,12 +4,12 @@ import com.kt.event.common.entity.BaseTimeEntity; import com.kt.event.eventservice.domain.enums.EventStatus; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; import org.hibernate.annotations.GenericGenerator; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; +import java.util.*; /** * 이벤트 엔티티 @@ -69,22 +69,23 @@ public class Event extends BaseTimeEntity { @Column(name = "selected_image_url", length = 500) private String selectedImageUrl; - @ElementCollection + @ElementCollection(fetch = FetchType.LAZY) @CollectionTable( name = "event_channels", joinColumns = @JoinColumn(name = "event_id") ) @Column(name = "channel", length = 50) + @Fetch(FetchMode.SUBSELECT) @Builder.Default private List channels = new ArrayList<>(); - @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default - private List generatedImages = new ArrayList<>(); + private Set generatedImages = new HashSet<>(); - @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default - private List aiRecommendations = new ArrayList<>(); + private Set aiRecommendations = new HashSet<>(); // ==== 비즈니스 로직 ==== // diff --git a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java index 05470f5..22add09 100644 --- a/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java +++ b/event-service/src/main/java/com/kt/event/eventservice/domain/repository/EventRepository.java @@ -25,9 +25,8 @@ public interface EventRepository extends JpaRepository { /** * 사용자 ID와 이벤트 ID로 조회 */ - @Query("SELECT e FROM Event e " + - "LEFT JOIN FETCH e.generatedImages " + - "LEFT JOIN FETCH e.aiRecommendations " + + @Query("SELECT DISTINCT e FROM Event e " + + "LEFT JOIN FETCH e.channels " + "WHERE e.eventId = :eventId AND e.userId = :userId") Optional findByEventIdAndUserId( @Param("eventId") UUID eventId,