From 08c8253404e36a06e696240c1f611890f9ff96a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4=ED=98=B8?= Date: Fri, 16 Jan 2026 14:38:15 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20kafka=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=B6=94=EC=B2=9C=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + docker-compose.yml | 29 ++++ .../service/AdminAmateurShowService.java | 10 -- .../service/AdminApprovalService.java | 4 +- .../amateurShow/entity/AmateurShow.java | 1 + .../repository/AmateurShowRepository.java | 3 + .../AmateurServiceImpl.java | 38 ++--- .../apiPayLoad/code/status/ErrorStatus.java | 2 + .../backend/board/service/BoardService.java | 2 +- .../backend/board/service/CommentService.java | 4 +- .../cc/backend/event/entity/NewShowEvent.java | 20 --- .../event/service/NewShowEventListener.java | 20 --- .../repository/MemberLikeRepository.java | 8 + .../service/MemberLikeServiceImpl.java | 9 -- .../backend/notice/entity/MemberNotice.java | 22 +-- .../notice/entity/enums/NoticeType.java | 2 +- .../event/entity/ApproveShowEvent.java | 2 +- .../event/entity/CommentEvent.java | 2 +- .../notice/event/entity/NewShowEvent.java | 17 ++ .../event/entity/PromoteHotEvent.java | 2 +- .../event/entity/RejectShowEvent.java | 2 +- .../{ => notice}/event/entity/ReplyEvent.java | 2 +- .../event/entity/TicketReservationEvent.java | 2 +- .../service/ApproveShowEventListener.java | 4 +- .../event/service/CommentEventListener.java | 4 +- .../service/PromoteHotEventListener.java | 5 +- .../service/RejectShowEventListener.java | 5 +- .../event/service/ReplyEventListener.java | 5 +- .../TicketReservationEventListener.java | 5 +- .../notice/kafka/KafkaRecommendConfig.java | 83 ++++++++++ .../MemberRecommendationConsumer.java | 81 ++++++++++ .../MemberRecommendationEvent.java | 18 +++ .../MemberRecommendationProducer.java | 153 ++++++++++++++++++ .../notice/service/MemberNoticeService.java | 4 +- .../backend/notice/service/NoticeService.java | 5 +- .../notice/service/NoticeServiceImpl.java | 39 ++--- .../ticket/service/TempTicketServiceImpl.java | 2 +- src/main/resources/application.yml | 11 ++ 38 files changed, 486 insertions(+), 144 deletions(-) delete mode 100644 src/main/java/cc/backend/event/entity/NewShowEvent.java delete mode 100644 src/main/java/cc/backend/event/service/NewShowEventListener.java rename src/main/java/cc/backend/{ => notice}/event/entity/ApproveShowEvent.java (90%) rename src/main/java/cc/backend/{ => notice}/event/entity/CommentEvent.java (92%) create mode 100644 src/main/java/cc/backend/notice/event/entity/NewShowEvent.java rename src/main/java/cc/backend/{ => notice}/event/entity/PromoteHotEvent.java (89%) rename src/main/java/cc/backend/{ => notice}/event/entity/RejectShowEvent.java (90%) rename src/main/java/cc/backend/{ => notice}/event/entity/ReplyEvent.java (93%) rename src/main/java/cc/backend/{ => notice}/event/entity/TicketReservationEvent.java (93%) rename src/main/java/cc/backend/{ => notice}/event/service/ApproveShowEventListener.java (84%) rename src/main/java/cc/backend/{ => notice}/event/service/CommentEventListener.java (84%) rename src/main/java/cc/backend/{ => notice}/event/service/PromoteHotEventListener.java (79%) rename src/main/java/cc/backend/{ => notice}/event/service/RejectShowEventListener.java (80%) rename src/main/java/cc/backend/{ => notice}/event/service/ReplyEventListener.java (80%) rename src/main/java/cc/backend/{ => notice}/event/service/TicketReservationEventListener.java (80%) create mode 100644 src/main/java/cc/backend/notice/kafka/KafkaRecommendConfig.java create mode 100644 src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java create mode 100644 src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java create mode 100644 src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java diff --git a/build.gradle b/build.gradle index 0d90f7b..c3711a7 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // kafka 추가 + implementation 'org.springframework.kafka:spring-kafka' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index c05bb31..33a5b53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: start_period: 40s depends_on: - redis + - kafka #추가 app-green: #그린 배포 @@ -48,7 +49,35 @@ services: start_period: 40s depends_on: - redis + - kafka #추가 + #추가 + zookeeper: + image: confluentinc/cp-zookeeper:7.5.0 + container_name: zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "2181:2181" + restart: unless-stopped + + kafka: + image: confluentinc/cp-kafka:7.5.0 + container_name: kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + + # 내부/외부 통신 분리 (중요) + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + restart: unless-stopped volumes: redis_data: driver: local \ No newline at end of file diff --git a/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java b/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java index d8650ce..831db9e 100644 --- a/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java +++ b/src/main/java/cc/backend/admin/amateurShow/service/AdminAmateurShowService.java @@ -1,30 +1,20 @@ package cc.backend.admin.amateurShow.service; import cc.backend.admin.amateurShow.dto.AdminAmateurShowListResponseDTO; -import cc.backend.admin.amateurShow.dto.AdminAmateurShowRejectRequestDTO; import cc.backend.admin.amateurShow.dto.AdminAmateurShowReviseRequestDTO; import cc.backend.admin.amateurShow.dto.AdminAmateurShowSummaryResponseDTO; import cc.backend.amateurShow.entity.AmateurShow; import cc.backend.amateurShow.repository.AmateurShowRepository; -import cc.backend.apiPayLoad.ApiResponse; -import cc.backend.apiPayLoad.SliceResponse; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.event.entity.ApproveShowEvent; -import cc.backend.event.entity.NewShowEvent; -import cc.backend.event.entity.RejectShowEvent; -import cc.backend.member.entity.Member; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.bind.annotation.PathVariable; import java.util.List; -import static io.micrometer.common.util.StringUtils.isNotBlank; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java index be3dfe4..8fb69a4 100644 --- a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java +++ b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java @@ -7,8 +7,8 @@ import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.event.entity.ApproveShowEvent; -import cc.backend.event.entity.RejectShowEvent; +import cc.backend.notice.event.entity.ApproveShowEvent; +import cc.backend.notice.event.entity.RejectShowEvent; import cc.backend.member.entity.Member; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; diff --git a/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java b/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java index 27736a7..487d380 100644 --- a/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java +++ b/src/main/java/cc/backend/amateurShow/entity/AmateurShow.java @@ -167,4 +167,5 @@ public void reject(String rejectReason){ this.rejectReason = rejectReason; } + } diff --git a/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java b/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java index 98f628e..6a2de6a 100644 --- a/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java +++ b/src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java @@ -91,4 +91,7 @@ List findHotShows( @Query("UPDATE AmateurShow s SET s.status = 'ENDED' " + "WHERE s.status = 'ONGOING' AND s.end < :today") int updateShowsToEnded(@Param("today") LocalDate today); + + @Query("SELECT s.hashtag FROM AmateurShow s WHERE s.member.id = :memberId") + List findHashtagsByMemberId(@Param("memberId") Long memberId); } \ No newline at end of file diff --git a/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java b/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java index f41e111..c152c1b 100644 --- a/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java +++ b/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java @@ -3,7 +3,6 @@ import cc.backend.amateurShow.dto.AmateurShowResponseDTO; import cc.backend.amateurShow.dto.AmateurUpdateRequestDTO; import cc.backend.amateurShow.entity.*; -import cc.backend.amateurShow.entity.enums.ApprovalStatus; import cc.backend.amateurShow.repository.*; import cc.backend.amateurShow.converter.AmateurConverter; import cc.backend.amateurShow.dto.AmateurEnrollRequestDTO; @@ -11,10 +10,7 @@ import cc.backend.amateurShow.repository.specification.AmateurShowSpecification; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.board.entity.enums.BoardType; -import cc.backend.event.entity.NewShowEvent; import cc.backend.image.DTO.ImageRequestDTO; -import cc.backend.image.DTO.ImageResponseDTO; import cc.backend.image.FilePath; import cc.backend.image.entity.Image; import cc.backend.image.repository.ImageRepository; @@ -22,20 +18,17 @@ import cc.backend.member.entity.Member; import cc.backend.member.enumerate.Role; import cc.backend.member.repository.MemberRepository; -import cc.backend.memberLike.entity.MemberLike; import cc.backend.memberLike.repository.MemberLikeRepository; -import cc.backend.ticket.dto.response.ReserveListResponseDTO; +import cc.backend.notice.event.entity.NewShowEvent; +import cc.backend.notice.kafka.NewShowEvent.MemberRecommendationProducer; +import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.text.Collator; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @@ -54,7 +47,8 @@ public class AmateurServiceImpl implements AmateurService { private final MemberLikeRepository memberLikeRepository; private final ImageService imageService; private final ImageRepository imageRepository; - private final ApplicationEventPublisher eventPublisher; //이벤트 생성 + private final NoticeService noticeService; + private final MemberRecommendationProducer memberRecommendationProducer; // 소극장 공연 등록 @Transactional @@ -93,16 +87,22 @@ public AmateurEnrollResponseDTO.AmateurEnrollResult enrollShow(Long memberId, imageService.saveImageWithImageUrl(memberId, fullImageRequestDTO, Optional.ofNullable(dto.getImageUrl())); - // 좋아요한 멤버리스트 - List memberLikers = memberLikeRepository.findByPerformerId(memberId); - // 좋아요한 멤버가 한 명 이상일 때만 - if(!memberLikers.isEmpty()) { - List likers = memberLikers.stream() - .map(MemberLike::getLiker) - .collect(Collectors.toList()); + //좋아요한 멤버 추출 + List likerIds = memberLikeRepository.findByPerformerId(memberId) + .stream() + .map(l -> l.getLiker().getId()) + .toList(); - eventPublisher.publishEvent(new NewShowEvent(newAmateurShow.getId(), memberId, likers)); //공연등록 이벤트 생성 + // 좋아요 알림: 좋아요한 멤버들만 + if (!likerIds.isEmpty()) { + noticeService.notifyNewShow( + new NewShowEvent(newAmateurShow.getId(), memberId, likerIds) + ); } + // 추천: 해시태그 기반 추천 → 모든 회원 대상 + memberRecommendationProducer.recommendByHashtag( + new NewShowEvent(newAmateurShow.getId(), memberId, null) + ); // response return AmateurConverter.toAmateurEnrollDTO(newAmateurShow); diff --git a/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java b/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java index c4ca635..641b7d4 100644 --- a/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java +++ b/src/main/java/cc/backend/apiPayLoad/code/status/ErrorStatus.java @@ -85,6 +85,7 @@ public enum ErrorStatus implements BaseErrorCode { AMATEUR_TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "AMATEURTICKET4000", "존재하지 않는 소극장 공연 티켓입니다."), AMATEUR_TICKET_STOCK(HttpStatus.BAD_REQUEST, "AMATEURTICKET4001", "주문 수량은 최소 1개 이상이어야 합니다."), AMATEUR_SHOW_MISMATCH(HttpStatus.NOT_FOUND, "AMATEURTICKET4002", "회차와 티켓에 해당하는 공연이 일치하지 않습니다."), + // PHOTOALBUM ERROR PHOTOALBUM_NOT_FOUND(HttpStatus.NOT_FOUND, "PHOTOALBUM4000", "존재하지 않는 사진첩입니다."), @@ -108,6 +109,7 @@ public enum ErrorStatus implements BaseErrorCode { //NOTICE ERROR MEMBERNOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBERNOTICE4001", "존재하지 않는 알림입니다."), + NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE4001", "존재하지 않는 알림입니다."), // INQUIRY ERROR INQUIRY_NOT_FOUND(HttpStatus.NOT_FOUND, "INQUIRY4000", "존재하지 않는 문의글입니다."), FORBIDDEN_INQUIRY_ACCESS(HttpStatus.NOT_FOUND, "INQUIRY4001", "로그인한 멤버가 작성하지 않는 문의글입니다."), diff --git a/src/main/java/cc/backend/board/service/BoardService.java b/src/main/java/cc/backend/board/service/BoardService.java index acbd478..497c120 100644 --- a/src/main/java/cc/backend/board/service/BoardService.java +++ b/src/main/java/cc/backend/board/service/BoardService.java @@ -13,7 +13,7 @@ import cc.backend.board.entity.enums.BoardType; import cc.backend.board.repository.BoardLikeRepository; import cc.backend.board.repository.HotBoardRepository; -import cc.backend.event.entity.PromoteHotEvent; +import cc.backend.notice.event.entity.PromoteHotEvent; import cc.backend.image.DTO.ImageRequestDTO; import cc.backend.image.DTO.ImageResponseDTO; import cc.backend.image.FilePath; diff --git a/src/main/java/cc/backend/board/service/CommentService.java b/src/main/java/cc/backend/board/service/CommentService.java index a460e6d..7615b94 100644 --- a/src/main/java/cc/backend/board/service/CommentService.java +++ b/src/main/java/cc/backend/board/service/CommentService.java @@ -11,8 +11,8 @@ import cc.backend.board.repository.BoardRepository; import cc.backend.board.repository.CommentLikeRepository; import cc.backend.board.repository.CommentRepository; -import cc.backend.event.entity.CommentEvent; -import cc.backend.event.entity.ReplyEvent; +import cc.backend.notice.event.entity.CommentEvent; +import cc.backend.notice.event.entity.ReplyEvent; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/cc/backend/event/entity/NewShowEvent.java b/src/main/java/cc/backend/event/entity/NewShowEvent.java deleted file mode 100644 index b618a28..0000000 --- a/src/main/java/cc/backend/event/entity/NewShowEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.event.entity; - -import cc.backend.member.entity.Member; -import lombok.Getter; - -import java.util.List; - -@Getter -public class NewShowEvent { - private final Long amateurShowId; - private final Long memberId; //등록자 id - private final List members; // 등록자를 좋아요한 member 리스트(memberNotice 생성시 필요 - 알림 전달용) - - public NewShowEvent(Long amateurShowId, Long memberId, List members) { - this.amateurShowId = amateurShowId; - this.memberId = memberId; - this.members = members; - } - -} diff --git a/src/main/java/cc/backend/event/service/NewShowEventListener.java b/src/main/java/cc/backend/event/service/NewShowEventListener.java deleted file mode 100644 index d569b09..0000000 --- a/src/main/java/cc/backend/event/service/NewShowEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.event.service; - -import cc.backend.event.entity.NewShowEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NewShowEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleAmateurShowCreate(NewShowEvent event) { - - return noticeService.notifyNewShow(event); - } -} diff --git a/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java b/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java index 8cc0408..5138df3 100644 --- a/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java +++ b/src/main/java/cc/backend/memberLike/repository/MemberLikeRepository.java @@ -37,4 +37,12 @@ Slice findLikedPerformersSortedBySoonestShow( @Param("now") LocalDateTime now, Pageable pageable ); + + // memberId 기준으로 해당 멤버가 좋아요한 모든 공연자 조회 + @Query("SELECT ml FROM MemberLike ml WHERE ml.liker.id = :memberId") + List findByLikerId(@Param("memberId") Long memberId); + + // 모든 회원을 대상으로, 추천 검사를 위해 distinct Member만 가져오기 + @Query("SELECT DISTINCT ml.liker FROM MemberLike ml") + List findAllDistinctMembers(); } diff --git a/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java b/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java index 759d18a..8c04610 100644 --- a/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java +++ b/src/main/java/cc/backend/memberLike/service/MemberLikeServiceImpl.java @@ -97,15 +97,6 @@ public Slice getLikedPerformers(Long memberId, Pageable p } - private LocalDateTime findSoonestEndingShowDateForPerformer(Member performer) { - return amateurShowRepository.findAllByMemberWithRounds(performer).stream() - .flatMap(show -> show.getAmateurRounds().stream()) - .map(AmateurRounds::getPerformanceDateTime) - .filter(date -> date.isAfter(LocalDateTime.now())) - .min(Comparator.naturalOrder()) - .orElse(LocalDateTime.MAX); - } - private Member getMemberById(Long id) { return memberRepository.findById(id) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); diff --git a/src/main/java/cc/backend/notice/entity/MemberNotice.java b/src/main/java/cc/backend/notice/entity/MemberNotice.java index c1cfbe5..c25d84d 100644 --- a/src/main/java/cc/backend/notice/entity/MemberNotice.java +++ b/src/main/java/cc/backend/notice/entity/MemberNotice.java @@ -18,25 +18,29 @@ public class MemberNotice extends BaseEntity { @Column(nullable = false, columnDefinition = "bigint") private Long id; - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) - @JoinColumn(name = "notice_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "notice_id", nullable = false) private Notice notice; - @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) - @JoinColumn(name = "member_id") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) private Member member; - private Boolean isRead = false; + private String personalMsg; + + private boolean isRead = false; @Builder - public MemberNotice( Notice notice, Member member) { - this.isRead = false ; + public MemberNotice(Notice notice, Member member, String personalMsg, boolean isRead) { this.notice = notice; this.member = member; + this.personalMsg = personalMsg; + this.isRead = isRead; } - public MemberNotice updateIsRead(){ + public void updateIsRead(){ this.isRead = true ; - return this; } + + } diff --git a/src/main/java/cc/backend/notice/entity/enums/NoticeType.java b/src/main/java/cc/backend/notice/entity/enums/NoticeType.java index 02a1158..fe08853 100644 --- a/src/main/java/cc/backend/notice/entity/enums/NoticeType.java +++ b/src/main/java/cc/backend/notice/entity/enums/NoticeType.java @@ -1,5 +1,5 @@ package cc.backend.notice.entity.enums; public enum NoticeType { - AMATEURSHOW, HOT, COMMENT, REPLY, TICKET, REMIND, AD + AMATEURSHOW, HOT, COMMENT, REPLY, TICKET, REMIND, RECOMMEND } diff --git a/src/main/java/cc/backend/event/entity/ApproveShowEvent.java b/src/main/java/cc/backend/notice/event/entity/ApproveShowEvent.java similarity index 90% rename from src/main/java/cc/backend/event/entity/ApproveShowEvent.java rename to src/main/java/cc/backend/notice/event/entity/ApproveShowEvent.java index b8cb2ee..8d676f3 100644 --- a/src/main/java/cc/backend/event/entity/ApproveShowEvent.java +++ b/src/main/java/cc/backend/notice/event/entity/ApproveShowEvent.java @@ -1,4 +1,4 @@ -package cc.backend.event.entity; +package cc.backend.notice.event.entity; import cc.backend.amateurShow.entity.AmateurShow; import cc.backend.member.entity.Member; diff --git a/src/main/java/cc/backend/event/entity/CommentEvent.java b/src/main/java/cc/backend/notice/event/entity/CommentEvent.java similarity index 92% rename from src/main/java/cc/backend/event/entity/CommentEvent.java rename to src/main/java/cc/backend/notice/event/entity/CommentEvent.java index e4197c0..8a851c4 100644 --- a/src/main/java/cc/backend/event/entity/CommentEvent.java +++ b/src/main/java/cc/backend/notice/event/entity/CommentEvent.java @@ -1,4 +1,4 @@ -package cc.backend.event.entity; +package cc.backend.notice.event.entity; import lombok.Getter; diff --git a/src/main/java/cc/backend/notice/event/entity/NewShowEvent.java b/src/main/java/cc/backend/notice/event/entity/NewShowEvent.java new file mode 100644 index 0000000..e60e680 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/entity/NewShowEvent.java @@ -0,0 +1,17 @@ +package cc.backend.notice.event.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class NewShowEvent { + private Long amateurShowId; // 공연 ID + private Long performerId; // 공연 등록자 ID + private List likerIds; // 좋아요한 유저 ID 리스트 +} diff --git a/src/main/java/cc/backend/event/entity/PromoteHotEvent.java b/src/main/java/cc/backend/notice/event/entity/PromoteHotEvent.java similarity index 89% rename from src/main/java/cc/backend/event/entity/PromoteHotEvent.java rename to src/main/java/cc/backend/notice/event/entity/PromoteHotEvent.java index 292954a..c6bf990 100644 --- a/src/main/java/cc/backend/event/entity/PromoteHotEvent.java +++ b/src/main/java/cc/backend/notice/event/entity/PromoteHotEvent.java @@ -1,4 +1,4 @@ -package cc.backend.event.entity; +package cc.backend.notice.event.entity; import cc.backend.member.entity.Member; import lombok.Getter; diff --git a/src/main/java/cc/backend/event/entity/RejectShowEvent.java b/src/main/java/cc/backend/notice/event/entity/RejectShowEvent.java similarity index 90% rename from src/main/java/cc/backend/event/entity/RejectShowEvent.java rename to src/main/java/cc/backend/notice/event/entity/RejectShowEvent.java index d9c16eb..3d72fed 100644 --- a/src/main/java/cc/backend/event/entity/RejectShowEvent.java +++ b/src/main/java/cc/backend/notice/event/entity/RejectShowEvent.java @@ -1,4 +1,4 @@ -package cc.backend.event.entity; +package cc.backend.notice.event.entity; import cc.backend.amateurShow.entity.AmateurShow; import cc.backend.member.entity.Member; diff --git a/src/main/java/cc/backend/event/entity/ReplyEvent.java b/src/main/java/cc/backend/notice/event/entity/ReplyEvent.java similarity index 93% rename from src/main/java/cc/backend/event/entity/ReplyEvent.java rename to src/main/java/cc/backend/notice/event/entity/ReplyEvent.java index 3a73b9a..bfe7ad0 100644 --- a/src/main/java/cc/backend/event/entity/ReplyEvent.java +++ b/src/main/java/cc/backend/notice/event/entity/ReplyEvent.java @@ -1,4 +1,4 @@ -package cc.backend.event.entity; +package cc.backend.notice.event.entity; import lombok.Getter; diff --git a/src/main/java/cc/backend/event/entity/TicketReservationEvent.java b/src/main/java/cc/backend/notice/event/entity/TicketReservationEvent.java similarity index 93% rename from src/main/java/cc/backend/event/entity/TicketReservationEvent.java rename to src/main/java/cc/backend/notice/event/entity/TicketReservationEvent.java index bb1e6da..dfb9851 100644 --- a/src/main/java/cc/backend/event/entity/TicketReservationEvent.java +++ b/src/main/java/cc/backend/notice/event/entity/TicketReservationEvent.java @@ -1,4 +1,4 @@ -package cc.backend.event.entity; +package cc.backend.notice.event.entity; import cc.backend.amateurShow.entity.AmateurShow; import cc.backend.amateurShow.entity.AmateurTicket; diff --git a/src/main/java/cc/backend/event/service/ApproveShowEventListener.java b/src/main/java/cc/backend/notice/event/service/ApproveShowEventListener.java similarity index 84% rename from src/main/java/cc/backend/event/service/ApproveShowEventListener.java rename to src/main/java/cc/backend/notice/event/service/ApproveShowEventListener.java index 0efd877..8b5de57 100644 --- a/src/main/java/cc/backend/event/service/ApproveShowEventListener.java +++ b/src/main/java/cc/backend/notice/event/service/ApproveShowEventListener.java @@ -1,6 +1,6 @@ -package cc.backend.event.service; +package cc.backend.notice.event.service; -import cc.backend.event.entity.ApproveShowEvent; +import cc.backend.notice.event.entity.ApproveShowEvent; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/cc/backend/event/service/CommentEventListener.java b/src/main/java/cc/backend/notice/event/service/CommentEventListener.java similarity index 84% rename from src/main/java/cc/backend/event/service/CommentEventListener.java rename to src/main/java/cc/backend/notice/event/service/CommentEventListener.java index ace7df8..a0cbe4c 100644 --- a/src/main/java/cc/backend/event/service/CommentEventListener.java +++ b/src/main/java/cc/backend/notice/event/service/CommentEventListener.java @@ -1,6 +1,6 @@ -package cc.backend.event.service; +package cc.backend.notice.event.service; -import cc.backend.event.entity.CommentEvent; +import cc.backend.notice.event.entity.CommentEvent; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/cc/backend/event/service/PromoteHotEventListener.java b/src/main/java/cc/backend/notice/event/service/PromoteHotEventListener.java similarity index 79% rename from src/main/java/cc/backend/event/service/PromoteHotEventListener.java rename to src/main/java/cc/backend/notice/event/service/PromoteHotEventListener.java index 06d1fd8..1febc79 100644 --- a/src/main/java/cc/backend/event/service/PromoteHotEventListener.java +++ b/src/main/java/cc/backend/notice/event/service/PromoteHotEventListener.java @@ -1,10 +1,9 @@ -package cc.backend.event.service; +package cc.backend.notice.event.service; -import cc.backend.event.entity.PromoteHotEvent; +import cc.backend.notice.event.entity.PromoteHotEvent; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; -import org.hibernate.procedure.ProcedureOutputs; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; diff --git a/src/main/java/cc/backend/event/service/RejectShowEventListener.java b/src/main/java/cc/backend/notice/event/service/RejectShowEventListener.java similarity index 80% rename from src/main/java/cc/backend/event/service/RejectShowEventListener.java rename to src/main/java/cc/backend/notice/event/service/RejectShowEventListener.java index e58be80..885e064 100644 --- a/src/main/java/cc/backend/event/service/RejectShowEventListener.java +++ b/src/main/java/cc/backend/notice/event/service/RejectShowEventListener.java @@ -1,7 +1,6 @@ -package cc.backend.event.service; +package cc.backend.notice.event.service; -import cc.backend.event.entity.ApproveShowEvent; -import cc.backend.event.entity.RejectShowEvent; +import cc.backend.notice.event.entity.RejectShowEvent; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/cc/backend/event/service/ReplyEventListener.java b/src/main/java/cc/backend/notice/event/service/ReplyEventListener.java similarity index 80% rename from src/main/java/cc/backend/event/service/ReplyEventListener.java rename to src/main/java/cc/backend/notice/event/service/ReplyEventListener.java index 9fa7857..aa591eb 100644 --- a/src/main/java/cc/backend/event/service/ReplyEventListener.java +++ b/src/main/java/cc/backend/notice/event/service/ReplyEventListener.java @@ -1,7 +1,6 @@ -package cc.backend.event.service; +package cc.backend.notice.event.service; -import cc.backend.event.entity.CommentEvent; -import cc.backend.event.entity.ReplyEvent; +import cc.backend.notice.event.entity.ReplyEvent; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/cc/backend/event/service/TicketReservationEventListener.java b/src/main/java/cc/backend/notice/event/service/TicketReservationEventListener.java similarity index 80% rename from src/main/java/cc/backend/event/service/TicketReservationEventListener.java rename to src/main/java/cc/backend/notice/event/service/TicketReservationEventListener.java index c021a85..3fc2885 100644 --- a/src/main/java/cc/backend/event/service/TicketReservationEventListener.java +++ b/src/main/java/cc/backend/notice/event/service/TicketReservationEventListener.java @@ -1,7 +1,6 @@ -package cc.backend.event.service; +package cc.backend.notice.event.service; -import cc.backend.event.entity.ReplyEvent; -import cc.backend.event.entity.TicketReservationEvent; +import cc.backend.notice.event.entity.TicketReservationEvent; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/cc/backend/notice/kafka/KafkaRecommendConfig.java b/src/main/java/cc/backend/notice/kafka/KafkaRecommendConfig.java new file mode 100644 index 0000000..436f1f5 --- /dev/null +++ b/src/main/java/cc/backend/notice/kafka/KafkaRecommendConfig.java @@ -0,0 +1,83 @@ +package cc.backend.notice.kafka; + +import cc.backend.notice.kafka.NewShowEvent.MemberRecommendationEvent; +import org.apache.kafka.common.TopicPartition; +import org.springframework.beans.factory.annotation.Value; +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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.*; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.kafka.support.serializer.JsonDeserializer; +import org.springframework.kafka.support.serializer.JsonSerializer; +import org.springframework.util.backoff.FixedBackOff; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class KafkaRecommendConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Value("${spring.kafka.consumer.group-id}") + private String groupId; + + // Producer: Object 직렬화(모든 이벤트 공용) + @Bean + public ProducerFactory producerFactory() { + Map props = new HashMap<>(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + return new DefaultKafkaProducerFactory<>(props); + } + + @Bean public KafkaTemplate kafkaTemplate() { + return new KafkaTemplate<>(producerFactory()); + } + + // Consumer: Object 역직렬화(모든 이벤트 공용) + @Bean + public ConsumerFactory consumerFactory() { + JsonDeserializer deserializer = new JsonDeserializer<>(MemberRecommendationEvent.class); + deserializer.addTrustedPackages("*"); // 패키지 제한 없앰 + + Map props = new HashMap<>(); + props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + return new DefaultKafkaConsumerFactory<>(props, new StringDeserializer(), deserializer); + } + + // 재시도 + DLQ 설정 + @Bean + public DefaultErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { + DeadLetterPublishingRecoverer recoverer = + new DeadLetterPublishingRecoverer(kafkaTemplate, + (record, ex) -> new TopicPartition(record.topic() + "-dlq", record.partition())); + + // 1초 간격, 최대 3회 재시도 + FixedBackOff backOff = new FixedBackOff(1000L, 3); + + return new DefaultErrorHandler(recoverer, backOff); + } + + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(KafkaTemplate kafkaTemplate) { + ConcurrentKafkaListenerContainerFactory factory = + new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setConcurrency(3); // 컨슈머 병렬 처리 스레드 수 + factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); //에러발생시 처리 로직 + return factory; + } + + +} diff --git a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java new file mode 100644 index 0000000..a1f646d --- /dev/null +++ b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java @@ -0,0 +1,81 @@ +package cc.backend.notice.kafka.NewShowEvent; + +import cc.backend.apiPayLoad.code.status.ErrorStatus; +import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.member.entity.Member; +import cc.backend.member.repository.MemberRepository; +import cc.backend.notice.entity.MemberNotice; +import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.notice.repository.MemberNoticeRepository; +import cc.backend.notice.repository.NoticeRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class MemberRecommendationConsumer { + private final NoticeRepository noticeRepository; + private final MemberNoticeRepository memberNoticeRepository; + private final MemberRepository memberRepository; + + @KafkaListener( + topics = "member-recommendation-event", + groupId = "recommendation-group", + containerFactory = "kafkaListenerContainerFactory" // batch 수신 + ) + @Transactional + public void consume(List events) { // batch 소비 + if (events == null || events.isEmpty()) return; + + // 이벤트에서 MemberId, NoticeId 수집 + Set memberIds = events.stream() + .map(MemberRecommendationEvent::getMemberId) + .collect(Collectors.toSet()); + + Set noticeIds = events.stream() + .map(MemberRecommendationEvent::getNoticeId) + .collect(Collectors.toSet()); + + // DB에서 한 번에 조회 후 Map 생성 + Map memberMap = memberRepository.findAllById(memberIds) + .stream().collect(Collectors.toMap(Member::getId, m -> m)); + + Map noticeMap = noticeRepository.findAllById(noticeIds) + .stream().collect(Collectors.toMap(Notice::getId, n -> n)); + + // MemberNotice 생성 + List memberNotices = new ArrayList<>(); + + for (MemberRecommendationEvent event : events) { + Member member = memberMap.get(event.getMemberId()); + Notice notice = noticeMap.get(event.getNoticeId()); + + if (member == null || notice == null) { + // 일부 이벤트 실패 시 로깅 후 건너뜀 + // (원하면 DLQ로 보내거나 재처리 가능) + System.err.println("Member or Notice not found for event: " + event); + continue; + } + + memberNotices.add(MemberNotice.builder() + .member(member) + .notice(notice) + .personalMsg(event.getMessage()) + .isRead(false) + .build()); + } + + if (!memberNotices.isEmpty()) { + memberNoticeRepository.saveAll(memberNotices); // batch insert + } + } +} diff --git a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java new file mode 100644 index 0000000..df7914c --- /dev/null +++ b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java @@ -0,0 +1,18 @@ +package cc.backend.notice.kafka.NewShowEvent; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MemberRecommendationEvent implements Serializable { + private Long memberId; // 추천 대상 회원 + private Long noticeId; // Notice ID + private String message; // 개인화 메시지 +} diff --git a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java new file mode 100644 index 0000000..d66f049 --- /dev/null +++ b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java @@ -0,0 +1,153 @@ +package cc.backend.notice.kafka.NewShowEvent; + +import cc.backend.amateurShow.entity.AmateurShow; +import cc.backend.amateurShow.repository.AmateurShowRepository; +import cc.backend.apiPayLoad.code.status.ErrorStatus; +import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.member.entity.Member; +import cc.backend.memberLike.entity.MemberLike; +import cc.backend.memberLike.repository.MemberLikeRepository; +import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.notice.event.entity.NewShowEvent; + +import cc.backend.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MemberRecommendationProducer { + + private final MemberLikeRepository memberLikeRepository; + private final AmateurShowRepository amateurShowRepository; + private final NoticeRepository noticeRepository; + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "member-recommendation-event"; + private static final int BATCH_SIZE = 50; + + + public void recommendByHashtag(NewShowEvent event) { + if (event == null) return; + + Long showId = event.getAmateurShowId(); + Long performerId = event.getPerformerId(); + + Notice notice = createNoticeForNewShow(showId); + + // 모든 회원 조회 + List allMembers = memberLikeRepository.findAllDistinctMembers(); + + // 추천 대상 회원 필터링 및 Kafka 이벤트 발행 + sendRecommendations(allMembers, notice, showId); + + } + + //DB 트랜잭션 안에서 Notice 생성 + @Transactional + protected Notice createNoticeForNewShow(Long showId) { + AmateurShow newShow = amateurShowRepository.findById(showId) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + Set newTagsSet = Arrays.stream(Optional.ofNullable(newShow.getHashtag()).orElse("").split("[#,\\s]+")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + if (newTagsSet.isEmpty()) { + throw new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND); // 태그 없으면 추천 불가 + } + + Notice notice = noticeRepository.save( + Notice.builder() + .type(NoticeType.RECOMMEND) + .message("새로운 공연 '" + newShow.getName() + "' 어떠세요? ") + .contentId(newShow.getId()) + .build() + ); + + return notice; + } + + protected void sendRecommendations(List members, Notice notice, Long showId) { + AmateurShow newShow = amateurShowRepository.findById(showId) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + Set newTagsSet = Arrays.stream(Optional.ofNullable(newShow.getHashtag()).orElse("").split("[#,\\s]+")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + List batchEvents = new ArrayList<>(); + + for (Member member : members) { + boolean shouldRecommend = memberShouldBeRecommended(member, newTagsSet); + + if (shouldRecommend) { + // 회원별 msg 생성 + String personalMsg = "새로운 공연 '" + newShow.getName() + "' 어떠세요? " + + newShow.getHashtag() + " " + member.getName() + "님 취향에 딱!"; + + batchEvents.add(new MemberRecommendationEvent(member.getId(), notice.getId(), personalMsg)); + + // 배치 단위로 Kafka 이벤트 발행 (DB 처리는 Consumer에서) + if (batchEvents.size() >= BATCH_SIZE) { + sendBatchToKafka(batchEvents); + batchEvents.clear(); + } + } + } + if (!batchEvents.isEmpty()) { + sendBatchToKafka(batchEvents); + } + } + + + //Kafka 이벤트 비동기 전송 (Transactional 밖) + private void sendBatchToKafka(List events) { + for (MemberRecommendationEvent event : events) { + kafkaTemplate.send(TOPIC, event.getMemberId().toString(), event); + System.out.println("Kafka event sent to topic " + TOPIC + ": " + event); + } + + } + + //회원 추천 여부 판단 (좋아요 유무 관계없이 해시태그 겹치면 true) + private boolean memberShouldBeRecommended(Member member, Set newTagsSet) { + // 회원이 좋아요한 공연자 목록 조회 + List likedPerformers = memberLikeRepository.findByLikerId(member.getId()); + + //아무 계정도 좋아요하지 않은 멤버에게는 추천 X + if (likedPerformers.isEmpty()) { + return false; + } + + for (MemberLike like : likedPerformers) { + Long likedPerformerId = like.getPerformer().getId(); + + // 좋아요한 공연자의 기존 공연 해시태그 조회 + List hashtags = amateurShowRepository.findHashtagsByMemberId(likedPerformerId); + + for (String existingHashtags : hashtags) { + Set existingTagsSet = Arrays.stream(existingHashtags.split("#")) + .map(String::trim) + .collect(Collectors.toSet()); + + Set intersection = new HashSet<>(newTagsSet); + intersection.retainAll(existingTagsSet); + + if (!intersection.isEmpty()) { + return true; // 공통 해시태그 존재시 추천 + } + } + } + + return false; + } +} diff --git a/src/main/java/cc/backend/notice/service/MemberNoticeService.java b/src/main/java/cc/backend/notice/service/MemberNoticeService.java index e5ee185..a235466 100644 --- a/src/main/java/cc/backend/notice/service/MemberNoticeService.java +++ b/src/main/java/cc/backend/notice/service/MemberNoticeService.java @@ -48,7 +48,7 @@ public MemberNoticeResponseDTO.MemberNoticeListDTO getAllMemberNotice( .id(notice.getId()) .noticeType(notice.getNotice().getType()) .message(notice.getNotice().getMessage()) - .isRead(notice.getIsRead()) + .isRead(notice.isRead()) .contentId(notice.getNotice().getContentId()) .createdAt(notice.getCreatedAt()) .build()) @@ -89,7 +89,7 @@ public MemberNoticeResponseDTO.MemberNoticeDTO readNotice(Long noticeId, Long me .id(memberNotice.getId()) .noticeType(memberNotice.getNotice().getType()) .message(memberNotice.getNotice().getMessage()) - .isRead(memberNotice.getIsRead()) + .isRead(memberNotice.isRead()) .contentId(memberNotice.getNotice().getContentId()) .createdAt(memberNotice.getCreatedAt()) .build(); diff --git a/src/main/java/cc/backend/notice/service/NoticeService.java b/src/main/java/cc/backend/notice/service/NoticeService.java index d724423..85aa53b 100644 --- a/src/main/java/cc/backend/notice/service/NoticeService.java +++ b/src/main/java/cc/backend/notice/service/NoticeService.java @@ -1,8 +1,7 @@ package cc.backend.notice.service; -import cc.backend.event.entity.*; -import cc.backend.notice.dto.MemberNoticeResponseDTO; import cc.backend.notice.dto.NoticeResponseDTO; +import cc.backend.notice.event.entity.*; import org.springframework.stereotype.Service; @Service @@ -10,7 +9,7 @@ public interface NoticeService { public NoticeResponseDTO.NoticeDTO notifyHotBoard(PromoteHotEvent event); public NoticeResponseDTO.NoticeDTO notifyNewComment(CommentEvent event); public NoticeResponseDTO.NoticeDTO notifyNewReply(ReplyEvent event); - public NoticeResponseDTO.NoticeDTO notifyNewShow(NewShowEvent event); + public void notifyNewShow(NewShowEvent event); public NoticeResponseDTO.NoticeDTO notifyTicketReservation(TicketReservationEvent event); public NoticeResponseDTO.NoticeDTO notifyApproval(ApproveShowEvent event); public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event); diff --git a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java index 4bdbd31..f2258ce 100644 --- a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java +++ b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java @@ -1,7 +1,6 @@ package cc.backend.notice.service; import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.amateurShow.entity.AmateurTicket; import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.amateurShow.repository.AmateurTicketRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; @@ -10,25 +9,22 @@ import cc.backend.board.entity.Comment; import cc.backend.board.repository.BoardRepository; import cc.backend.board.repository.CommentRepository; -import cc.backend.event.entity.*; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; -import cc.backend.notice.dto.MemberNoticeResponseDTO; +import cc.backend.memberLike.entity.MemberLike; +import cc.backend.memberLike.repository.MemberLikeRepository; import cc.backend.notice.dto.NoticeResponseDTO; import cc.backend.notice.entity.MemberNotice; import cc.backend.notice.entity.Notice; import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.notice.event.entity.*; import cc.backend.notice.repository.MemberNoticeRepository; import cc.backend.notice.repository.NoticeRepository; -import cc.backend.photoAlbum.dto.PhotoAlbumResponseDTO; -import cc.backend.photoAlbum.entity.PhotoAlbum; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -41,6 +37,7 @@ public class NoticeServiceImpl implements NoticeService { private final AmateurShowRepository amateurShowRepository; private final CommentRepository commentRepository; private final AmateurTicketRepository amateurTicketRepository; + private final MemberLikeRepository memberLikeRepository; @Transactional @Override @@ -115,13 +112,15 @@ public NoticeResponseDTO.NoticeDTO notifyNewComment(CommentEvent event) { @Override @Transactional - public NoticeResponseDTO.NoticeDTO notifyNewShow(NewShowEvent event){ + public void notifyNewShow(NewShowEvent event){ Long amateurShowId = event.getAmateurShowId(); AmateurShow amateurShow = amateurShowRepository.findById(amateurShowId) .orElseThrow(()-> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); - List receivers = event.getMembers(); + // 공연자 좋아요한 유저 조회 + List likers = memberLikeRepository.findByPerformerId(event.getPerformerId()); + if (likers.isEmpty()) return; Notice newNotice = noticeRepository.save( Notice.builder() @@ -131,21 +130,15 @@ public NoticeResponseDTO.NoticeDTO notifyNewShow(NewShowEvent event){ .build() ); - memberNoticeRepository.saveAll( - receivers.stream() - .map(member -> MemberNotice.builder() - .notice(newNotice) - .member(member) - .build()) - .collect(Collectors.toList())); + // MemberNotice bulk 생성 + List memberNotices = likers.stream() + .map(liker -> MemberNotice.builder() + .notice(newNotice) + .member(liker.getLiker()) + .build()) + .toList(); - return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .message(newNotice.getMessage()) - .noticeType(newNotice.getType()) - .contentId(newNotice.getContentId()) - .createdAt(newNotice.getCreatedAt()) - .build(); + memberNoticeRepository.saveAll(memberNotices); } diff --git a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java index 7ef1367..ce7f3a4 100644 --- a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java +++ b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java @@ -8,7 +8,7 @@ import cc.backend.amateurShow.repository.AmateurTicketRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.event.entity.TicketReservationEvent; +import cc.backend.notice.event.entity.TicketReservationEvent; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; import cc.backend.ticket.dto.request.TempTicketCreateRequestDTO; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e760e90..6e1453d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,6 +31,17 @@ spring: host: ${REDIS_HOST} port: 6379 + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + consumer: + group-id: cc-backend-group + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer + properties: + spring.json.trusted.packages: "*" url: access-token: https://oauth2.googleapis.com/token From ef4f42a9f1d6f41e8f6de1af6a4d9c719031a553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4=ED=98=B8?= Date: Sat, 17 Jan 2026 04:13:51 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20TransactionalEventLister?= =?UTF-8?q?=EB=8F=84=EC=9E=85,=20=EC=BB=A4=EB=B0=8B=20=ED=9B=84=EC=97=90?= =?UTF-8?q?=20=EC=B9=B4=ED=94=84=EC=B9=B4=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=EB=90=98=EB=8F=84=EB=A1=9D=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 9 +- .../service/AdminApprovalService.java | 25 +- .../AmateurServiceImpl.java | 25 -- .../backend/board/service/BoardService.java | 10 +- .../backend/board/service/CommentService.java | 27 +- .../backend/image/service/ImageService.java | 28 +- .../KafkaConfig.java} | 32 +- .../approvalShowEvent/ApprovalShowEvent.java | 16 + .../ApprovalShowProducer.java | 25 ++ .../consumer/ApprovalConsumer.java | 27 ++ .../consumer/LikerConsumer.java | 31 ++ .../consumer/RecommendConsumer.java | 32 ++ .../event/commentEvent/CommentConsumer.java | 40 ++ .../event/commentEvent/CommentEvent.java | 19 + .../event/commentEvent/CommentProducer.java | 26 ++ .../kafka/event/common/DomainEvent.java | 10 + .../event/common/enums/DomainEventType.java | 10 + .../event/hotBoardEvent/HotBoardConsumer.java | 24 ++ .../event/hotBoardEvent/HotBoardEvent.java | 15 + .../event/hotBoardEvent/HotBoardProducer.java | 24 ++ .../rejectShowEvent/RejectShowConsumer.java | 37 ++ .../rejectShowEvent/RejectShowEvent.java | 17 + .../rejectShowEvent/RejectShowProducer.java | 27 ++ .../kafka/event/replyEvent/ReplyConsumer.java | 25 ++ .../kafka/event/replyEvent/ReplyEvent.java | 18 + .../kafka/event/replyEvent/ReplyProducer.java | 26 ++ .../ReservationCompletedConsumer.java | 27 ++ .../ReservationCompletedEvent.java | 17 + .../ReservationCompletedProducer.java | 28 ++ .../notice/event/ApproveCommitEvent.java | 6 + .../notice/event/CommentCommitEvent.java | 8 + .../notice/event/RejectCommitEvent.java | 7 + .../notice/event/ReplyCommitEvent.java | 8 + .../event/TicketReservationCommitEvent.java | 8 + .../notice/event/entity/ApproveShowEvent.java | 16 - .../notice/event/entity/CommentEvent.java | 19 - .../notice/event/entity/NewShowEvent.java | 17 - .../notice/event/entity/PromoteHotEvent.java | 17 - .../notice/event/entity/RejectShowEvent.java | 16 - .../notice/event/entity/ReplyEvent.java | 18 - .../event/entity/TicketReservationEvent.java | 20 - .../service/ApproveShowEventListener.java | 21 -- .../event/service/CommentEventListener.java | 20 - .../service/PromoteHotEventListener.java | 20 - .../service/RejectShowEventListener.java | 20 - .../event/service/ReplyEventListener.java | 19 - .../TicketReservationEventListener.java | 20 - .../MemberRecommendationConsumer.java | 81 ----- .../MemberRecommendationEvent.java | 18 - .../MemberRecommendationProducer.java | 153 -------- .../service/ApproveCommitEventListener.java | 24 ++ .../service/CommentCommitEventListener.java | 27 ++ .../backend/notice/service/NoticeService.java | 16 +- .../notice/service/NoticeServiceImpl.java | 341 +++++++++++++----- .../service/RejectCommitEventListener.java | 21 ++ .../service/ReplyCommmitEventListener.java | 29 ++ .../ReservationCommitEventListener.java | 24 ++ .../ticket/service/RealTicketService.java | 13 +- .../ticket/service/TempTicketServiceImpl.java | 10 +- src/main/resources/application.yml | 4 + 60 files changed, 1062 insertions(+), 656 deletions(-) rename src/main/java/cc/backend/{notice/kafka/KafkaRecommendConfig.java => kafka/KafkaConfig.java} (73%) create mode 100644 src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowEvent.java create mode 100644 src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java create mode 100644 src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/ApprovalConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/LikerConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/RecommendConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/commentEvent/CommentEvent.java create mode 100644 src/main/java/cc/backend/kafka/event/commentEvent/CommentProducer.java create mode 100644 src/main/java/cc/backend/kafka/event/common/DomainEvent.java create mode 100644 src/main/java/cc/backend/kafka/event/common/enums/DomainEventType.java create mode 100644 src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardEvent.java create mode 100644 src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardProducer.java create mode 100644 src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowEvent.java create mode 100644 src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowProducer.java create mode 100644 src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/replyEvent/ReplyEvent.java create mode 100644 src/main/java/cc/backend/kafka/event/replyEvent/ReplyProducer.java create mode 100644 src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedConsumer.java create mode 100644 src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedEvent.java create mode 100644 src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedProducer.java create mode 100644 src/main/java/cc/backend/notice/event/ApproveCommitEvent.java create mode 100644 src/main/java/cc/backend/notice/event/CommentCommitEvent.java create mode 100644 src/main/java/cc/backend/notice/event/RejectCommitEvent.java create mode 100644 src/main/java/cc/backend/notice/event/ReplyCommitEvent.java create mode 100644 src/main/java/cc/backend/notice/event/TicketReservationCommitEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/entity/ApproveShowEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/entity/CommentEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/entity/NewShowEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/entity/PromoteHotEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/entity/RejectShowEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/entity/ReplyEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/entity/TicketReservationEvent.java delete mode 100644 src/main/java/cc/backend/notice/event/service/ApproveShowEventListener.java delete mode 100644 src/main/java/cc/backend/notice/event/service/CommentEventListener.java delete mode 100644 src/main/java/cc/backend/notice/event/service/PromoteHotEventListener.java delete mode 100644 src/main/java/cc/backend/notice/event/service/RejectShowEventListener.java delete mode 100644 src/main/java/cc/backend/notice/event/service/ReplyEventListener.java delete mode 100644 src/main/java/cc/backend/notice/event/service/TicketReservationEventListener.java delete mode 100644 src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java delete mode 100644 src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java delete mode 100644 src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java create mode 100644 src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java create mode 100644 src/main/java/cc/backend/notice/service/CommentCommitEventListener.java create mode 100644 src/main/java/cc/backend/notice/service/RejectCommitEventListener.java create mode 100644 src/main/java/cc/backend/notice/service/ReplyCommmitEventListener.java create mode 100644 src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java diff --git a/docker-compose.yml b/docker-compose.yml index 33a5b53..f9eb806 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,15 +68,18 @@ services: depends_on: - zookeeper ports: - - "9092:9092" + - "9092:9092" # 도커 내부 통신용 + - "29092:29092" # 로컬호스트 접속용 environment: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 # 내부/외부 통신 분리 (중요) - KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_HOST://0.0.0.0:29092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" restart: unless-stopped volumes: redis_data: diff --git a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java index 8fb69a4..8aaf1d1 100644 --- a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java +++ b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java @@ -7,9 +7,12 @@ import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.notice.event.entity.ApproveShowEvent; -import cc.backend.notice.event.entity.RejectShowEvent; +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; import cc.backend.member.entity.Member; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowProducer; +import cc.backend.notice.event.ApproveCommitEvent; +import cc.backend.notice.event.RejectCommitEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.*; @@ -25,6 +28,8 @@ public class AdminApprovalService { private final AmateurShowRepository amateurShowRepository; private final ApplicationEventPublisher eventPublisher; + private final ApprovalShowProducer approvalShowProducer; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional public AdminAmateurShowSummaryResponseDTO approveShow(Long showId) { @@ -33,8 +38,13 @@ public AdminAmateurShowSummaryResponseDTO approveShow(Long showId) { show.approve(); - Member member = show.getMember(); - eventPublisher.publishEvent(new ApproveShowEvent(show, member)); //공연등록 승인 이벤트 생성 + Member performer = show.getMember(); + + // 등록 승인 커밋 트랜잭션 이벤트 발행 + eventPublisher.publishEvent( + new ApproveCommitEvent(show.getId(), performer.getId() + ) + ); return AdminAmateurShowSummaryResponseDTO.from(show); } @@ -47,7 +57,12 @@ public AdminAmateurShowSummaryResponseDTO rejectShow(Long showId, AdminAmateurSh show.reject(dto.getRejectReason()); Member member = show.getMember(); - eventPublisher.publishEvent(new RejectShowEvent(show, member)); //공연등록 반려 이벤트 생성 + + // 등록 거부 커밋 트랜잭션 이벤트 발행 + eventPublisher.publishEvent( + new RejectCommitEvent(show.getId(), member.getId(), show.getRejectReason() + ) + ); return AdminAmateurShowSummaryResponseDTO.from(show); } diff --git a/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java b/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java index c152c1b..8a273dc 100644 --- a/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java +++ b/src/main/java/cc/backend/amateurShow/service/amateurShowService/AmateurServiceImpl.java @@ -18,10 +18,6 @@ import cc.backend.member.entity.Member; import cc.backend.member.enumerate.Role; import cc.backend.member.repository.MemberRepository; -import cc.backend.memberLike.repository.MemberLikeRepository; -import cc.backend.notice.event.entity.NewShowEvent; -import cc.backend.notice.kafka.NewShowEvent.MemberRecommendationProducer; -import cc.backend.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.*; import org.springframework.data.jpa.domain.Specification; @@ -44,11 +40,8 @@ public class AmateurServiceImpl implements AmateurService { private final AmateurTicketRepository amateurTicketRepository; private final AmateurStaffRepository amateurStaffRepository; private final AmateurRoundsRepository amateurRoundsRepository; - private final MemberLikeRepository memberLikeRepository; private final ImageService imageService; private final ImageRepository imageRepository; - private final NoticeService noticeService; - private final MemberRecommendationProducer memberRecommendationProducer; // 소극장 공연 등록 @Transactional @@ -86,24 +79,6 @@ public AmateurEnrollResponseDTO.AmateurEnrollResult enrollShow(Long memberId, imageService.saveImageWithImageUrl(memberId, fullImageRequestDTO, Optional.ofNullable(dto.getImageUrl())); - - //좋아요한 멤버 추출 - List likerIds = memberLikeRepository.findByPerformerId(memberId) - .stream() - .map(l -> l.getLiker().getId()) - .toList(); - - // 좋아요 알림: 좋아요한 멤버들만 - if (!likerIds.isEmpty()) { - noticeService.notifyNewShow( - new NewShowEvent(newAmateurShow.getId(), memberId, likerIds) - ); - } - // 추천: 해시태그 기반 추천 → 모든 회원 대상 - memberRecommendationProducer.recommendByHashtag( - new NewShowEvent(newAmateurShow.getId(), memberId, null) - ); - // response return AmateurConverter.toAmateurEnrollDTO(newAmateurShow); } diff --git a/src/main/java/cc/backend/board/service/BoardService.java b/src/main/java/cc/backend/board/service/BoardService.java index 497c120..35dfac3 100644 --- a/src/main/java/cc/backend/board/service/BoardService.java +++ b/src/main/java/cc/backend/board/service/BoardService.java @@ -13,13 +13,16 @@ import cc.backend.board.entity.enums.BoardType; import cc.backend.board.repository.BoardLikeRepository; import cc.backend.board.repository.HotBoardRepository; -import cc.backend.notice.event.entity.PromoteHotEvent; +import cc.backend.kafka.event.commentEvent.CommentProducer; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; import cc.backend.image.DTO.ImageRequestDTO; import cc.backend.image.DTO.ImageResponseDTO; import cc.backend.image.FilePath; import cc.backend.image.entity.Image; import cc.backend.image.repository.ImageRepository; import cc.backend.image.service.ImageService; +import cc.backend.kafka.event.hotBoardEvent.HotBoardProducer; +import cc.backend.kafka.event.replyEvent.ReplyProducer; import cc.backend.member.entity.Member; import cc.backend.board.repository.BoardRepository; import cc.backend.member.enumerate.Role; @@ -48,7 +51,7 @@ public class BoardService { private final ImageService imageService; private final ImageRepository imageRepository; - private final ApplicationEventPublisher eventPublisher; + private final HotBoardProducer hotBoardProducer; // 게시글 작성 @Transactional @@ -361,7 +364,8 @@ private void promoteToHotBoard(Board board) { .build(); hotBoardRepository.save(hotBoard); - eventPublisher.publishEvent(new PromoteHotEvent(board.getId(), board.getMember().getId())); //핫게 이벤트 생성 + //핫게 알림용 카프카 이벤트 생성 + hotBoardProducer.publish(new HotBoardEvent(board.getId(), board.getMember().getId())); } } diff --git a/src/main/java/cc/backend/board/service/CommentService.java b/src/main/java/cc/backend/board/service/CommentService.java index 7615b94..d3267b2 100644 --- a/src/main/java/cc/backend/board/service/CommentService.java +++ b/src/main/java/cc/backend/board/service/CommentService.java @@ -11,10 +11,15 @@ import cc.backend.board.repository.BoardRepository; import cc.backend.board.repository.CommentLikeRepository; import cc.backend.board.repository.CommentRepository; -import cc.backend.notice.event.entity.CommentEvent; -import cc.backend.notice.event.entity.ReplyEvent; +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.commentEvent.CommentProducer; +import cc.backend.kafka.event.replyEvent.ReplyEvent; +import cc.backend.kafka.event.replyEvent.ReplyProducer; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; +import cc.backend.notice.event.CommentCommitEvent; +import cc.backend.notice.event.ReplyCommitEvent; +import cc.backend.notice.event.TicketReservationCommitEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -32,7 +37,10 @@ public class CommentService { private final BoardRepository boardRepository; private final MemberRepository memberRepository; - private final ApplicationEventPublisher eventPublisher; //이벤트 생성자 + private final CommentProducer commentProducer; + private final ReplyProducer replyProducer; + private final ApplicationEventPublisher eventPublisher; + //댓글 작성 @Transactional public CommentCreateResponse createComment(Long boardId, Long memberId, CommentRequest req) { @@ -47,7 +55,11 @@ public CommentCreateResponse createComment(Long boardId, Long memberId, CommentR comment = Comment.createComment(req.getContent(), member, board); commentRepository.save(comment); - eventPublisher.publishEvent(new CommentEvent(boardId, board.getMember().getId(), comment.getId(), comment.getMember().getId())); //댓글 이벤트 생성 + //댓글 커밋 이벤트 생성 + eventPublisher.publishEvent( + new CommentCommitEvent(boardId, board.getMember().getId(), comment.getId(), comment.getMember().getId()) + ); + } else { // 대댓글 Comment parent = commentRepository.findById(req.getParentCommentId()) @@ -62,7 +74,12 @@ public CommentCreateResponse createComment(Long boardId, Long memberId, CommentR comment = Comment.createReply(req.getContent(), member, board, parent); commentRepository.save(comment); - eventPublisher.publishEvent(new ReplyEvent(parent.getId(), parent.getMember().getId(), comment.getId(), comment.getMember().getId())); //대댓글 이벤트 생성 + //대댓글 커밋 이벤트 생성 + eventPublisher.publishEvent( + new ReplyCommitEvent(parent.getId(), parent.getMember().getId(), comment.getId(), comment.getMember().getId()) + ); + + } diff --git a/src/main/java/cc/backend/image/service/ImageService.java b/src/main/java/cc/backend/image/service/ImageService.java index ecf0a49..33f956c 100644 --- a/src/main/java/cc/backend/image/service/ImageService.java +++ b/src/main/java/cc/backend/image/service/ImageService.java @@ -15,11 +15,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; +import java.util.*; -import java.util.Optional; import java.util.stream.Collectors; @Slf4j @Service @@ -41,6 +38,10 @@ public class ImageService { @Transactional public ImageResponseDTO.ImageResultWithPresignedUrlDTO saveImage(Long memberId, ImageRequestDTO.FullImageRequestDTO requestDTO) { + if (requestDTO.getKeyName() == null || requestDTO.getKeyName().isEmpty()) { + return null; // 빈 DTO 무시 + } + return saveImageWithImageUrl(memberId, requestDTO, Optional.empty()); } @@ -74,9 +75,22 @@ public ImageResponseDTO.ImageResultWithPresignedUrlDTO saveImageWithImageUrl( //다중 이미지 저장 @Transactional public List saveImages(Long memberId, List requestDTOs){ - return requestDTOs.stream() - .map(requestDTO-> saveImage(memberId, requestDTO)) - .collect(Collectors.toList()); + List results = new ArrayList<>(); + + for (ImageRequestDTO.FullImageRequestDTO dto : requestDTOs) { + try { + ImageResponseDTO.ImageResultWithPresignedUrlDTO saved = saveImage(memberId, dto); + if (saved != null) { + results.add(saved); + } + } catch (GeneralException ex) { + // 실패한 이미지는 로깅, 알림, DLQ 등 처리 가능 + log.warn("이미지 저장 실패: keyName={}, memberId={}, reason={}", + dto.getKeyName(), memberId, ex.getMessage()); + } + } + + return results; } // 이미지 단건 조회 diff --git a/src/main/java/cc/backend/notice/kafka/KafkaRecommendConfig.java b/src/main/java/cc/backend/kafka/KafkaConfig.java similarity index 73% rename from src/main/java/cc/backend/notice/kafka/KafkaRecommendConfig.java rename to src/main/java/cc/backend/kafka/KafkaConfig.java index 436f1f5..1f0f929 100644 --- a/src/main/java/cc/backend/notice/kafka/KafkaRecommendConfig.java +++ b/src/main/java/cc/backend/kafka/KafkaConfig.java @@ -1,6 +1,6 @@ -package cc.backend.notice.kafka; +package cc.backend.kafka; -import cc.backend.notice.kafka.NewShowEvent.MemberRecommendationEvent; +import cc.backend.kafka.event.common.DomainEvent; import org.apache.kafka.common.TopicPartition; import org.springframework.beans.factory.annotation.Value; import org.apache.kafka.clients.consumer.ConsumerConfig; @@ -21,7 +21,7 @@ import java.util.Map; @Configuration -public class KafkaRecommendConfig { +public class KafkaConfig { @Value("${spring.kafka.bootstrap-servers}") private String bootstrapServers; @@ -29,36 +29,42 @@ public class KafkaRecommendConfig { @Value("${spring.kafka.consumer.group-id}") private String groupId; - // Producer: Object 직렬화(모든 이벤트 공용) @Bean - public ProducerFactory producerFactory() { + public ProducerFactory producerFactory() { Map props = new HashMap<>(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class); + + props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, true); + return new DefaultKafkaProducerFactory<>(props); } - @Bean public KafkaTemplate kafkaTemplate() { + @Bean + public KafkaTemplate kafkaTemplate() { return new KafkaTemplate<>(producerFactory()); } - // Consumer: Object 역직렬화(모든 이벤트 공용) @Bean - public ConsumerFactory consumerFactory() { - JsonDeserializer deserializer = new JsonDeserializer<>(MemberRecommendationEvent.class); - deserializer.addTrustedPackages("*"); // 패키지 제한 없앰 + public ConsumerFactory consumerFactory() { + JsonDeserializer deserializer = new JsonDeserializer<>(DomainEvent.class); + deserializer.addTrustedPackages("*"); + deserializer.setUseTypeMapperForKey(false); Map props = new HashMap<>(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + return new DefaultKafkaConsumerFactory<>(props, new StringDeserializer(), deserializer); } // 재시도 + DLQ 설정 @Bean - public DefaultErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { + public DefaultErrorHandler errorHandler(KafkaTemplate kafkaTemplate) { DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(kafkaTemplate, (record, ex) -> new TopicPartition(record.topic() + "-dlq", record.partition())); @@ -70,8 +76,8 @@ public DefaultErrorHandler errorHandler(KafkaTemplate kafkaListenerContainerFactory(KafkaTemplate kafkaTemplate) { - ConcurrentKafkaListenerContainerFactory factory = + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory(KafkaTemplate kafkaTemplate) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory()); factory.setConcurrency(3); // 컨슈머 병렬 처리 스레드 수 diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowEvent.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowEvent.java new file mode 100644 index 0000000..50774a6 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowEvent.java @@ -0,0 +1,16 @@ +package cc.backend.kafka.event.approvalShowEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + +public record ApprovalShowEvent( + Long amateurShowId, + Long performerId +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.SHOW_APPROVED; + } +} + diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java new file mode 100644 index 0000000..8c8eebb --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java @@ -0,0 +1,25 @@ +package cc.backend.kafka.event.approvalShowEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApprovalShowProducer { + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "approval-show-topic"; + + /** + * 승인된 공연 이벤트 발행 + * @param event ApprovalShowEvent + */ + public void publish(ApprovalShowEvent event) { + if (event == null) return; + + // amateurShowId 기준 파티션 + kafkaTemplate.send(TOPIC, event.amateurShowId().toString(), event); + } + +} diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/ApprovalConsumer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/ApprovalConsumer.java new file mode 100644 index 0000000..2b01c67 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/ApprovalConsumer.java @@ -0,0 +1,27 @@ +package cc.backend.kafka.event.approvalShowEvent.consumer; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApprovalConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "approval-show-topic", + groupId = "approved-performer-group", + containerFactory = "kafkaListenerContainerFactory" + ) + + @Transactional + public void consume(ApprovalShowEvent event) { + if (event == null) return; + + noticeService.notifyApproval(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/LikerConsumer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/LikerConsumer.java new file mode 100644 index 0000000..0489a1d --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/LikerConsumer.java @@ -0,0 +1,31 @@ +package cc.backend.kafka.event.approvalShowEvent.consumer; + + +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class LikerConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "approval-show-topic", + groupId = "liker-group", + containerFactory = "kafkaListenerContainerFactory" + ) + + @Transactional + public void consume(ApprovalShowEvent event) { + if (event == null) return; + + noticeService.notifyLikers(event); + + } +} diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/RecommendConsumer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/RecommendConsumer.java new file mode 100644 index 0000000..9ed124e --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/consumer/RecommendConsumer.java @@ -0,0 +1,32 @@ +package cc.backend.kafka.event.approvalShowEvent.consumer; + + +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; + + +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +import java.util.*; + +@Component +@RequiredArgsConstructor +public class RecommendConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "approval-show-topic", + groupId = "recommend-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(ApprovalShowEvent event) { + if (event == null) return; + + noticeService.notifyRecommendation(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java b/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java new file mode 100644 index 0000000..b2f15aa --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java @@ -0,0 +1,40 @@ +package cc.backend.kafka.event.commentEvent; + +import cc.backend.apiPayLoad.code.status.ErrorStatus; +import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.board.entity.Board; +import cc.backend.board.entity.Comment; +import cc.backend.board.repository.BoardRepository; +import cc.backend.board.repository.CommentRepository; +import cc.backend.member.entity.Member; +import cc.backend.member.repository.MemberRepository; +import cc.backend.notice.entity.MemberNotice; +import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.notice.repository.MemberNoticeRepository; +import cc.backend.notice.repository.NoticeRepository; +import cc.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + + +@Component +@RequiredArgsConstructor +public class CommentConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "comment-created-topic", + groupId = "comment-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consume(CommentEvent event) { + + if (event == null) return; + + noticeService.notifyNewComment(event); + + } +} diff --git a/src/main/java/cc/backend/kafka/event/commentEvent/CommentEvent.java b/src/main/java/cc/backend/kafka/event/commentEvent/CommentEvent.java new file mode 100644 index 0000000..2d3b34d --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/commentEvent/CommentEvent.java @@ -0,0 +1,19 @@ +package cc.backend.kafka.event.commentEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + + +public record CommentEvent ( + Long boardId, + Long boardWriterId, //게시물 작성자 id + Long commentId, + Long commentWriterId //댓글 작성자 id +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.COMMENT_ON_BOARD; + } + +} diff --git a/src/main/java/cc/backend/kafka/event/commentEvent/CommentProducer.java b/src/main/java/cc/backend/kafka/event/commentEvent/CommentProducer.java new file mode 100644 index 0000000..10a68ef --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/commentEvent/CommentProducer.java @@ -0,0 +1,26 @@ +package cc.backend.kafka.event.commentEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CommentProducer { + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "comment-created-topic"; + + public void publish(CommentEvent event) { + if (event == null) return; + + // boardId 기준 파티션 + kafkaTemplate.send( + TOPIC, + event.boardId().toString(), + event + ); + } + +} diff --git a/src/main/java/cc/backend/kafka/event/common/DomainEvent.java b/src/main/java/cc/backend/kafka/event/common/DomainEvent.java new file mode 100644 index 0000000..b5f9989 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/common/DomainEvent.java @@ -0,0 +1,10 @@ +package cc.backend.kafka.event.common; + +import cc.backend.kafka.event.common.enums.DomainEventType; + +import java.io.Serializable; + +public interface DomainEvent extends Serializable { + DomainEventType getEventType(); + +} diff --git a/src/main/java/cc/backend/kafka/event/common/enums/DomainEventType.java b/src/main/java/cc/backend/kafka/event/common/enums/DomainEventType.java new file mode 100644 index 0000000..1cdd556 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/common/enums/DomainEventType.java @@ -0,0 +1,10 @@ +package cc.backend.kafka.event.common.enums; + +public enum DomainEventType { + SHOW_APPROVED, + SHOW_REJECTED, + COMMENT_ON_BOARD, + REPLY_ON_COMMENT, + HOT_BOARD_BECAME, + TICKET_RESERVATION_COMPLETED +} diff --git a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java new file mode 100644 index 0000000..e310ee3 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java @@ -0,0 +1,24 @@ +package cc.backend.kafka.event.hotBoardEvent; + +import cc.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class HotBoardConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "hot-board-topic", + groupId = "hot-board-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consume(HotBoardEvent event) { + if (event == null) return; + + noticeService.notifyHotBoard(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardEvent.java b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardEvent.java new file mode 100644 index 0000000..f91bf2f --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardEvent.java @@ -0,0 +1,15 @@ +package cc.backend.kafka.event.hotBoardEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + +public record HotBoardEvent( + Long boardId, + Long writerId +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.HOT_BOARD_BECAME; + } +} diff --git a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardProducer.java b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardProducer.java new file mode 100644 index 0000000..e04c262 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardProducer.java @@ -0,0 +1,24 @@ +package cc.backend.kafka.event.hotBoardEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class HotBoardProducer { + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "hot-board-topic"; + + public void publish(HotBoardEvent event) { + if (event == null) return; + + // boardId 기준 파티션 + kafkaTemplate.send( + TOPIC, + event.boardId().toString(), + event + ); + } +} diff --git a/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowConsumer.java b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowConsumer.java new file mode 100644 index 0000000..ee79b42 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowConsumer.java @@ -0,0 +1,37 @@ +package cc.backend.kafka.event.rejectShowEvent; + +import cc.backend.amateurShow.entity.AmateurShow; +import cc.backend.amateurShow.repository.AmateurShowRepository; +import cc.backend.apiPayLoad.code.status.ErrorStatus; +import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.member.entity.Member; +import cc.backend.member.repository.MemberRepository; +import cc.backend.notice.entity.MemberNotice; +import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; +import cc.backend.notice.repository.MemberNoticeRepository; +import cc.backend.notice.repository.NoticeRepository; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RejectShowConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "reject-show-topic", + groupId = "reject-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(RejectShowEvent event) { + if (event == null) return; + + noticeService.notifyRejection(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowEvent.java b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowEvent.java new file mode 100644 index 0000000..340d92b --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowEvent.java @@ -0,0 +1,17 @@ +package cc.backend.kafka.event.rejectShowEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + + +public record RejectShowEvent( + Long amateurShowId, + Long performerId, + String rejectReason +) implements DomainEvent { + + @Override + public DomainEventType getEventType(){ + return DomainEventType.SHOW_REJECTED; + } +} diff --git a/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowProducer.java b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowProducer.java new file mode 100644 index 0000000..5a2c0de --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/rejectShowEvent/RejectShowProducer.java @@ -0,0 +1,27 @@ +package cc.backend.kafka.event.rejectShowEvent; + + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RejectShowProducer { + + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "reject-show-topic"; + + public void publish(RejectShowEvent event) { + if (event == null) return; + + // 공연 ID 기준으로 파티션 + kafkaTemplate.send( + TOPIC, + event.amateurShowId().toString(), + event + ); + } +} diff --git a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java new file mode 100644 index 0000000..f6a7298 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java @@ -0,0 +1,25 @@ +package cc.backend.kafka.event.replyEvent; + +import cc.backend.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ReplyConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "reply-created-topic", + groupId = "reply-notice-group", + containerFactory = "kafkaListenerContainerFactory" + ) + public void consume(ReplyEvent event) { + if (event == null) return; + + noticeService.notifyNewReply(event); + + } +} diff --git a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyEvent.java b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyEvent.java new file mode 100644 index 0000000..0eace3e --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyEvent.java @@ -0,0 +1,18 @@ +package cc.backend.kafka.event.replyEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + +public record ReplyEvent ( + Long commentId, + Long commentWriterId, //댓글 작성자 id + Long replyId, // 대댓글 commentId + Long replyWriterId //대댓글 작성자 id +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.REPLY_ON_COMMENT; + } +} + diff --git a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyProducer.java b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyProducer.java new file mode 100644 index 0000000..11ae17f --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyProducer.java @@ -0,0 +1,26 @@ +package cc.backend.kafka.event.replyEvent; + + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ReplyProducer { + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "reply-created-topic"; + + public void publish(ReplyEvent event) { + if (event == null) return; + + // commentId 기준으로 파티션 + kafkaTemplate.send( + TOPIC, + event.commentId().toString(), + event + ); + } +} diff --git a/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedConsumer.java b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedConsumer.java new file mode 100644 index 0000000..439e614 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedConsumer.java @@ -0,0 +1,27 @@ +package cc.backend.kafka.event.reservationCompletedEvent; + +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.notice.service.NoticeService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class ReservationCompletedConsumer { + + private final NoticeService noticeService; + + @KafkaListener( + topics = "reservation-completed-topic", + groupId = "reservation-completed-group", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + public void consume(ReservationCompletedEvent event) { + if (event == null) return; + + noticeService.notifyTicketReservation(event); + } +} diff --git a/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedEvent.java b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedEvent.java new file mode 100644 index 0000000..7d439a9 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedEvent.java @@ -0,0 +1,17 @@ +package cc.backend.kafka.event.reservationCompletedEvent; + +import cc.backend.kafka.event.common.DomainEvent; +import cc.backend.kafka.event.common.enums.DomainEventType; + + +public record ReservationCompletedEvent( + Long amateurShowId, + Long realTicketId, + Long memberId //예약자 +) implements DomainEvent { + + @Override + public DomainEventType getEventType() { + return DomainEventType.TICKET_RESERVATION_COMPLETED; + } +} diff --git a/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedProducer.java b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedProducer.java new file mode 100644 index 0000000..da45577 --- /dev/null +++ b/src/main/java/cc/backend/kafka/event/reservationCompletedEvent/ReservationCompletedProducer.java @@ -0,0 +1,28 @@ +package cc.backend.kafka.event.reservationCompletedEvent; + + +import cc.backend.kafka.event.common.DomainEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ReservationCompletedProducer { + private final KafkaTemplate kafkaTemplate; + + private static final String TOPIC = "reservation-completed-topic"; + + + public void publish(ReservationCompletedEvent event) { + if (event == null) return; + + // memberId 기준 파티션 + kafkaTemplate.send( + TOPIC, + event.memberId().toString(), + event + ); + } +} + diff --git a/src/main/java/cc/backend/notice/event/ApproveCommitEvent.java b/src/main/java/cc/backend/notice/event/ApproveCommitEvent.java new file mode 100644 index 0000000..1f1c911 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/ApproveCommitEvent.java @@ -0,0 +1,6 @@ +package cc.backend.notice.event; + +public record ApproveCommitEvent ( + Long amateurShowId, + Long performerId +){} diff --git a/src/main/java/cc/backend/notice/event/CommentCommitEvent.java b/src/main/java/cc/backend/notice/event/CommentCommitEvent.java new file mode 100644 index 0000000..82b53b9 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/CommentCommitEvent.java @@ -0,0 +1,8 @@ +package cc.backend.notice.event; + +public record CommentCommitEvent ( + Long boardId, + Long boardWriterId, + Long commentId, + Long commentWriterId +){} diff --git a/src/main/java/cc/backend/notice/event/RejectCommitEvent.java b/src/main/java/cc/backend/notice/event/RejectCommitEvent.java new file mode 100644 index 0000000..5ce9421 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/RejectCommitEvent.java @@ -0,0 +1,7 @@ +package cc.backend.notice.event; + +public record RejectCommitEvent ( + Long amateurShowId, + Long performerId, + String rejectReason +){ } diff --git a/src/main/java/cc/backend/notice/event/ReplyCommitEvent.java b/src/main/java/cc/backend/notice/event/ReplyCommitEvent.java new file mode 100644 index 0000000..36f8779 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/ReplyCommitEvent.java @@ -0,0 +1,8 @@ +package cc.backend.notice.event; + +public record ReplyCommitEvent ( + Long commentId, + Long commentWriterId, + Long replyId, + Long replyWriterId +){ } diff --git a/src/main/java/cc/backend/notice/event/TicketReservationCommitEvent.java b/src/main/java/cc/backend/notice/event/TicketReservationCommitEvent.java new file mode 100644 index 0000000..1779162 --- /dev/null +++ b/src/main/java/cc/backend/notice/event/TicketReservationCommitEvent.java @@ -0,0 +1,8 @@ +package cc.backend.notice.event; + + +public record TicketReservationCommitEvent( + Long amateurShowId, + Long realTicketId, + Long memberId +) { } diff --git a/src/main/java/cc/backend/notice/event/entity/ApproveShowEvent.java b/src/main/java/cc/backend/notice/event/entity/ApproveShowEvent.java deleted file mode 100644 index 8d676f3..0000000 --- a/src/main/java/cc/backend/notice/event/entity/ApproveShowEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package cc.backend.notice.event.entity; - -import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.member.entity.Member; -import lombok.Getter; - -@Getter -public class ApproveShowEvent { - private final AmateurShow amateurShow; - private final Member member; //공연 등록자 - - public ApproveShowEvent(AmateurShow amateurShow, Member member) { - this.amateurShow = amateurShow; - this.member = member; - } -} diff --git a/src/main/java/cc/backend/notice/event/entity/CommentEvent.java b/src/main/java/cc/backend/notice/event/entity/CommentEvent.java deleted file mode 100644 index 8a851c4..0000000 --- a/src/main/java/cc/backend/notice/event/entity/CommentEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package cc.backend.notice.event.entity; - -import lombok.Getter; - -@Getter -public class CommentEvent { - private final Long boardId; - private final Long boardWriterId; //게시물 작성자 id - private final Long commentId; - private final Long commentWriterId; //댓글 작성자 id - - public CommentEvent(Long boardId, Long boardWriterId, Long commentId, Long commentWriterId) { - this.boardId = boardId; - this.boardWriterId = boardWriterId; - this.commentId = commentId; - this.commentWriterId = commentWriterId; - } - -} diff --git a/src/main/java/cc/backend/notice/event/entity/NewShowEvent.java b/src/main/java/cc/backend/notice/event/entity/NewShowEvent.java deleted file mode 100644 index e60e680..0000000 --- a/src/main/java/cc/backend/notice/event/entity/NewShowEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package cc.backend.notice.event.entity; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.io.Serializable; -import java.util.List; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class NewShowEvent { - private Long amateurShowId; // 공연 ID - private Long performerId; // 공연 등록자 ID - private List likerIds; // 좋아요한 유저 ID 리스트 -} diff --git a/src/main/java/cc/backend/notice/event/entity/PromoteHotEvent.java b/src/main/java/cc/backend/notice/event/entity/PromoteHotEvent.java deleted file mode 100644 index c6bf990..0000000 --- a/src/main/java/cc/backend/notice/event/entity/PromoteHotEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package cc.backend.notice.event.entity; - -import cc.backend.member.entity.Member; -import lombok.Getter; - -import java.util.List; - -@Getter -public class PromoteHotEvent { - private final Long boardId; - private final Long writerId; - - public PromoteHotEvent(Long boardId, Long writerId) { - this.boardId = boardId; - this.writerId = writerId; - } -} diff --git a/src/main/java/cc/backend/notice/event/entity/RejectShowEvent.java b/src/main/java/cc/backend/notice/event/entity/RejectShowEvent.java deleted file mode 100644 index 3d72fed..0000000 --- a/src/main/java/cc/backend/notice/event/entity/RejectShowEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package cc.backend.notice.event.entity; - -import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.member.entity.Member; -import lombok.Getter; - -@Getter -public class RejectShowEvent { - private final AmateurShow amateurShow; - private final Member member; //공연 등록자 - - public RejectShowEvent(AmateurShow amateurShow, Member member) { - this.amateurShow = amateurShow; - this.member = member; - } -} diff --git a/src/main/java/cc/backend/notice/event/entity/ReplyEvent.java b/src/main/java/cc/backend/notice/event/entity/ReplyEvent.java deleted file mode 100644 index bfe7ad0..0000000 --- a/src/main/java/cc/backend/notice/event/entity/ReplyEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -package cc.backend.notice.event.entity; - -import lombok.Getter; - -@Getter -public class ReplyEvent { - private final Long commentId; - private final Long commentWriterId; //댓글 작성자 id - private final Long replyId; // 대댓글 commentId - private final Long replyWriterId; //대댓글 작성자 id - - public ReplyEvent(Long commentId, Long commentWriterId, Long replyId, Long replyWriterId) { - this.commentId = commentId; - this.commentWriterId = commentWriterId; - this.replyId = replyId; - this.replyWriterId = replyWriterId; - } -} diff --git a/src/main/java/cc/backend/notice/event/entity/TicketReservationEvent.java b/src/main/java/cc/backend/notice/event/entity/TicketReservationEvent.java deleted file mode 100644 index dfb9851..0000000 --- a/src/main/java/cc/backend/notice/event/entity/TicketReservationEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.notice.event.entity; - -import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.amateurShow.entity.AmateurTicket; -import cc.backend.member.entity.Member; -import lombok.Builder; -import lombok.Getter; - -@Getter -public class TicketReservationEvent { - private final AmateurShow amateurShow; - private final AmateurTicket amateurTicket; - private final Member member; //예약자 - - public TicketReservationEvent(AmateurShow amateurShow, AmateurTicket amateurTicket, Member member) { - this.amateurShow = amateurShow; - this.amateurTicket = amateurTicket; - this.member = member; - } -} diff --git a/src/main/java/cc/backend/notice/event/service/ApproveShowEventListener.java b/src/main/java/cc/backend/notice/event/service/ApproveShowEventListener.java deleted file mode 100644 index 8b5de57..0000000 --- a/src/main/java/cc/backend/notice/event/service/ApproveShowEventListener.java +++ /dev/null @@ -1,21 +0,0 @@ -package cc.backend.notice.event.service; - -import cc.backend.notice.event.entity.ApproveShowEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ApproveShowEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleApproveShowEvent(ApproveShowEvent event) { - - return noticeService.notifyApproval(event); - } - -} diff --git a/src/main/java/cc/backend/notice/event/service/CommentEventListener.java b/src/main/java/cc/backend/notice/event/service/CommentEventListener.java deleted file mode 100644 index a0cbe4c..0000000 --- a/src/main/java/cc/backend/notice/event/service/CommentEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.notice.event.service; - -import cc.backend.notice.event.entity.CommentEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class CommentEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleCommentCreate(CommentEvent event) { - - return noticeService.notifyNewComment(event); - } -} diff --git a/src/main/java/cc/backend/notice/event/service/PromoteHotEventListener.java b/src/main/java/cc/backend/notice/event/service/PromoteHotEventListener.java deleted file mode 100644 index 1febc79..0000000 --- a/src/main/java/cc/backend/notice/event/service/PromoteHotEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.notice.event.service; - -import cc.backend.notice.event.entity.PromoteHotEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PromoteHotEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleHotPromote(PromoteHotEvent event) { - - return noticeService.notifyHotBoard(event); - } -} diff --git a/src/main/java/cc/backend/notice/event/service/RejectShowEventListener.java b/src/main/java/cc/backend/notice/event/service/RejectShowEventListener.java deleted file mode 100644 index 885e064..0000000 --- a/src/main/java/cc/backend/notice/event/service/RejectShowEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.notice.event.service; - -import cc.backend.notice.event.entity.RejectShowEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class RejectShowEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleApproveShowEvent(RejectShowEvent event) { - - return noticeService.notifyRejection(event); - } -} diff --git a/src/main/java/cc/backend/notice/event/service/ReplyEventListener.java b/src/main/java/cc/backend/notice/event/service/ReplyEventListener.java deleted file mode 100644 index aa591eb..0000000 --- a/src/main/java/cc/backend/notice/event/service/ReplyEventListener.java +++ /dev/null @@ -1,19 +0,0 @@ -package cc.backend.notice.event.service; - -import cc.backend.notice.event.entity.ReplyEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class ReplyEventListener { - private final NoticeService noticeService; - @EventListener - public NoticeResponseDTO.NoticeDTO handleReplyCreate(ReplyEvent event) { - - return noticeService.notifyNewReply(event); - } -} diff --git a/src/main/java/cc/backend/notice/event/service/TicketReservationEventListener.java b/src/main/java/cc/backend/notice/event/service/TicketReservationEventListener.java deleted file mode 100644 index 3fc2885..0000000 --- a/src/main/java/cc/backend/notice/event/service/TicketReservationEventListener.java +++ /dev/null @@ -1,20 +0,0 @@ -package cc.backend.notice.event.service; - -import cc.backend.notice.event.entity.TicketReservationEvent; -import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.service.NoticeService; -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TicketReservationEventListener { - private final NoticeService noticeService; - - @EventListener - public NoticeResponseDTO.NoticeDTO handleReservation(TicketReservationEvent event) { - - return noticeService.notifyTicketReservation(event); - } -} diff --git a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java deleted file mode 100644 index a1f646d..0000000 --- a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationConsumer.java +++ /dev/null @@ -1,81 +0,0 @@ -package cc.backend.notice.kafka.NewShowEvent; - -import cc.backend.apiPayLoad.code.status.ErrorStatus; -import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.member.entity.Member; -import cc.backend.member.repository.MemberRepository; -import cc.backend.notice.entity.MemberNotice; -import cc.backend.notice.entity.Notice; -import cc.backend.notice.entity.enums.NoticeType; -import cc.backend.notice.repository.MemberNoticeRepository; -import cc.backend.notice.repository.NoticeRepository; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@Component -@RequiredArgsConstructor -public class MemberRecommendationConsumer { - private final NoticeRepository noticeRepository; - private final MemberNoticeRepository memberNoticeRepository; - private final MemberRepository memberRepository; - - @KafkaListener( - topics = "member-recommendation-event", - groupId = "recommendation-group", - containerFactory = "kafkaListenerContainerFactory" // batch 수신 - ) - @Transactional - public void consume(List events) { // batch 소비 - if (events == null || events.isEmpty()) return; - - // 이벤트에서 MemberId, NoticeId 수집 - Set memberIds = events.stream() - .map(MemberRecommendationEvent::getMemberId) - .collect(Collectors.toSet()); - - Set noticeIds = events.stream() - .map(MemberRecommendationEvent::getNoticeId) - .collect(Collectors.toSet()); - - // DB에서 한 번에 조회 후 Map 생성 - Map memberMap = memberRepository.findAllById(memberIds) - .stream().collect(Collectors.toMap(Member::getId, m -> m)); - - Map noticeMap = noticeRepository.findAllById(noticeIds) - .stream().collect(Collectors.toMap(Notice::getId, n -> n)); - - // MemberNotice 생성 - List memberNotices = new ArrayList<>(); - - for (MemberRecommendationEvent event : events) { - Member member = memberMap.get(event.getMemberId()); - Notice notice = noticeMap.get(event.getNoticeId()); - - if (member == null || notice == null) { - // 일부 이벤트 실패 시 로깅 후 건너뜀 - // (원하면 DLQ로 보내거나 재처리 가능) - System.err.println("Member or Notice not found for event: " + event); - continue; - } - - memberNotices.add(MemberNotice.builder() - .member(member) - .notice(notice) - .personalMsg(event.getMessage()) - .isRead(false) - .build()); - } - - if (!memberNotices.isEmpty()) { - memberNoticeRepository.saveAll(memberNotices); // batch insert - } - } -} diff --git a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java deleted file mode 100644 index df7914c..0000000 --- a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -package cc.backend.notice.kafka.NewShowEvent; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.io.Serializable; - -@Builder -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class MemberRecommendationEvent implements Serializable { - private Long memberId; // 추천 대상 회원 - private Long noticeId; // Notice ID - private String message; // 개인화 메시지 -} diff --git a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java b/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java deleted file mode 100644 index d66f049..0000000 --- a/src/main/java/cc/backend/notice/kafka/NewShowEvent/MemberRecommendationProducer.java +++ /dev/null @@ -1,153 +0,0 @@ -package cc.backend.notice.kafka.NewShowEvent; - -import cc.backend.amateurShow.entity.AmateurShow; -import cc.backend.amateurShow.repository.AmateurShowRepository; -import cc.backend.apiPayLoad.code.status.ErrorStatus; -import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.member.entity.Member; -import cc.backend.memberLike.entity.MemberLike; -import cc.backend.memberLike.repository.MemberLikeRepository; -import cc.backend.notice.entity.Notice; -import cc.backend.notice.entity.enums.NoticeType; -import cc.backend.notice.event.entity.NewShowEvent; - -import cc.backend.notice.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.*; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class MemberRecommendationProducer { - - private final MemberLikeRepository memberLikeRepository; - private final AmateurShowRepository amateurShowRepository; - private final NoticeRepository noticeRepository; - private final KafkaTemplate kafkaTemplate; - - private static final String TOPIC = "member-recommendation-event"; - private static final int BATCH_SIZE = 50; - - - public void recommendByHashtag(NewShowEvent event) { - if (event == null) return; - - Long showId = event.getAmateurShowId(); - Long performerId = event.getPerformerId(); - - Notice notice = createNoticeForNewShow(showId); - - // 모든 회원 조회 - List allMembers = memberLikeRepository.findAllDistinctMembers(); - - // 추천 대상 회원 필터링 및 Kafka 이벤트 발행 - sendRecommendations(allMembers, notice, showId); - - } - - //DB 트랜잭션 안에서 Notice 생성 - @Transactional - protected Notice createNoticeForNewShow(Long showId) { - AmateurShow newShow = amateurShowRepository.findById(showId) - .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); - - Set newTagsSet = Arrays.stream(Optional.ofNullable(newShow.getHashtag()).orElse("").split("[#,\\s]+")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - - if (newTagsSet.isEmpty()) { - throw new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND); // 태그 없으면 추천 불가 - } - - Notice notice = noticeRepository.save( - Notice.builder() - .type(NoticeType.RECOMMEND) - .message("새로운 공연 '" + newShow.getName() + "' 어떠세요? ") - .contentId(newShow.getId()) - .build() - ); - - return notice; - } - - protected void sendRecommendations(List members, Notice notice, Long showId) { - AmateurShow newShow = amateurShowRepository.findById(showId) - .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); - - Set newTagsSet = Arrays.stream(Optional.ofNullable(newShow.getHashtag()).orElse("").split("[#,\\s]+")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - - List batchEvents = new ArrayList<>(); - - for (Member member : members) { - boolean shouldRecommend = memberShouldBeRecommended(member, newTagsSet); - - if (shouldRecommend) { - // 회원별 msg 생성 - String personalMsg = "새로운 공연 '" + newShow.getName() + "' 어떠세요? " - + newShow.getHashtag() + " " + member.getName() + "님 취향에 딱!"; - - batchEvents.add(new MemberRecommendationEvent(member.getId(), notice.getId(), personalMsg)); - - // 배치 단위로 Kafka 이벤트 발행 (DB 처리는 Consumer에서) - if (batchEvents.size() >= BATCH_SIZE) { - sendBatchToKafka(batchEvents); - batchEvents.clear(); - } - } - } - if (!batchEvents.isEmpty()) { - sendBatchToKafka(batchEvents); - } - } - - - //Kafka 이벤트 비동기 전송 (Transactional 밖) - private void sendBatchToKafka(List events) { - for (MemberRecommendationEvent event : events) { - kafkaTemplate.send(TOPIC, event.getMemberId().toString(), event); - System.out.println("Kafka event sent to topic " + TOPIC + ": " + event); - } - - } - - //회원 추천 여부 판단 (좋아요 유무 관계없이 해시태그 겹치면 true) - private boolean memberShouldBeRecommended(Member member, Set newTagsSet) { - // 회원이 좋아요한 공연자 목록 조회 - List likedPerformers = memberLikeRepository.findByLikerId(member.getId()); - - //아무 계정도 좋아요하지 않은 멤버에게는 추천 X - if (likedPerformers.isEmpty()) { - return false; - } - - for (MemberLike like : likedPerformers) { - Long likedPerformerId = like.getPerformer().getId(); - - // 좋아요한 공연자의 기존 공연 해시태그 조회 - List hashtags = amateurShowRepository.findHashtagsByMemberId(likedPerformerId); - - for (String existingHashtags : hashtags) { - Set existingTagsSet = Arrays.stream(existingHashtags.split("#")) - .map(String::trim) - .collect(Collectors.toSet()); - - Set intersection = new HashSet<>(newTagsSet); - intersection.retainAll(existingTagsSet); - - if (!intersection.isEmpty()) { - return true; // 공통 해시태그 존재시 추천 - } - } - } - - return false; - } -} diff --git a/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java b/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java new file mode 100644 index 0000000..3dd4844 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java @@ -0,0 +1,24 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowProducer; +import cc.backend.notice.event.ApproveCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class ApproveCommitEventListener { + private final ApprovalShowProducer approvalShowProducer; + + @TransactionalEventListener (phase = TransactionPhase.AFTER_COMMIT) + public void handleCommentCreate(ApproveCommitEvent event) { + + //APPROVED 수정 트랜잭션 커밋 완료 후 kafka 이벤트 발송 + approvalShowProducer.publish( + new ApprovalShowEvent(event.amateurShowId(), event.performerId()) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/CommentCommitEventListener.java b/src/main/java/cc/backend/notice/service/CommentCommitEventListener.java new file mode 100644 index 0000000..b3190d1 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/CommentCommitEventListener.java @@ -0,0 +1,27 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.commentEvent.CommentProducer; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; +import cc.backend.notice.dto.NoticeResponseDTO; +import cc.backend.notice.event.CommentCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class CommentCommitEventListener { + private final CommentProducer commentProducer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCommentCommit(CommentCommitEvent event) { + + // Comment 생성 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + commentProducer.publish( + new CommentEvent(event.boardId(), event.boardWriterId(), event.commentId(), event.commentWriterId()) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/NoticeService.java b/src/main/java/cc/backend/notice/service/NoticeService.java index 85aa53b..cf0c818 100644 --- a/src/main/java/cc/backend/notice/service/NoticeService.java +++ b/src/main/java/cc/backend/notice/service/NoticeService.java @@ -1,17 +1,23 @@ package cc.backend.notice.service; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedEvent; import cc.backend.notice.dto.NoticeResponseDTO; -import cc.backend.notice.event.entity.*; +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.replyEvent.ReplyEvent; import org.springframework.stereotype.Service; @Service public interface NoticeService { - public NoticeResponseDTO.NoticeDTO notifyHotBoard(PromoteHotEvent event); + public NoticeResponseDTO.NoticeDTO notifyHotBoard(HotBoardEvent event); public NoticeResponseDTO.NoticeDTO notifyNewComment(CommentEvent event); public NoticeResponseDTO.NoticeDTO notifyNewReply(ReplyEvent event); - public void notifyNewShow(NewShowEvent event); - public NoticeResponseDTO.NoticeDTO notifyTicketReservation(TicketReservationEvent event); - public NoticeResponseDTO.NoticeDTO notifyApproval(ApproveShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyTicketReservation(ReservationCompletedEvent event); public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyRecommendation(ApprovalShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyApproval(ApprovalShowEvent event); + public NoticeResponseDTO.NoticeDTO notifyLikers(ApprovalShowEvent event); } diff --git a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java index f2258ce..999e031 100644 --- a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java +++ b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java @@ -1,6 +1,7 @@ package cc.backend.notice.service; import cc.backend.amateurShow.entity.AmateurShow; +import cc.backend.amateurShow.entity.AmateurTicket; import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.amateurShow.repository.AmateurTicketRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; @@ -9,6 +10,10 @@ import cc.backend.board.entity.Comment; import cc.backend.board.repository.BoardRepository; import cc.backend.board.repository.CommentRepository; +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; +import cc.backend.kafka.event.hotBoardEvent.HotBoardEvent; +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedEvent; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; import cc.backend.memberLike.entity.MemberLike; @@ -17,14 +22,16 @@ import cc.backend.notice.entity.MemberNotice; import cc.backend.notice.entity.Notice; import cc.backend.notice.entity.enums.NoticeType; -import cc.backend.notice.event.entity.*; +import cc.backend.kafka.event.commentEvent.CommentEvent; +import cc.backend.kafka.event.replyEvent.ReplyEvent; import cc.backend.notice.repository.MemberNoticeRepository; import cc.backend.notice.repository.NoticeRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -39,15 +46,17 @@ public class NoticeServiceImpl implements NoticeService { private final AmateurTicketRepository amateurTicketRepository; private final MemberLikeRepository memberLikeRepository; + private static final int BATCH_SIZE = 50; + @Transactional @Override - public NoticeResponseDTO.NoticeDTO notifyHotBoard(PromoteHotEvent event) { - Long boardId = event.getBoardId(); + public NoticeResponseDTO.NoticeDTO notifyHotBoard(HotBoardEvent event) { + Long boardId = event.boardId(); - Member writer = memberRepository.findById(event.getWriterId()) + Member writer = memberRepository.findById(event.writerId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Notice newNotice = noticeRepository.save( + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.HOT) .message("회원님의 게시글이 HOT 게시글에 등록되었습니다!") @@ -58,148 +67,187 @@ public NoticeResponseDTO.NoticeDTO notifyHotBoard(PromoteHotEvent event) { memberNoticeRepository.save( MemberNotice.builder() .member(writer) - .notice(newNotice) + .notice(notice) .build()); return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .message(newNotice.getMessage()) - .noticeType(newNotice.getType()) + .id(notice.getId()) + .message(notice.getMessage()) + .noticeType(notice.getType()) .contentId(boardId) - .createdAt(newNotice.getCreatedAt()) + .createdAt(notice.getCreatedAt()) .build(); } @Override @Transactional public NoticeResponseDTO.NoticeDTO notifyNewComment(CommentEvent event) { - Board board = boardRepository.findById(event.getBoardId()) + // 1. 게시글 조회 + Board board = boardRepository.findById(event.boardId()) .orElseThrow(() -> new GeneralException(ErrorStatus.BOARD_NOT_FOUND)); - Member boardWriter = memberRepository.findById(event.getBoardWriterId()) + + // 2. 게시글 작성자 조회 + Member boardWriter = memberRepository.findById(event.boardWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Comment comment = commentRepository.findById(event.getCommentId()) + + // 3. 댓글 조회 + Comment comment = commentRepository.findById(event.commentId()) .orElseThrow(() -> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND)); - Member commentWriter = memberRepository.findById(event.getCommentWriterId()) + + // 4. 댓글 작성자 조회 + Member commentWriter = memberRepository.findById(event.commentWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - if(boardWriter.equals(commentWriter)) { + // 5. 자기 글에 자기가 댓글 단 경우 알림 X + if (boardWriter.equals(commentWriter)) { return null; } - String preview = comment.getContent().length() > 15 ? comment.getContent().substring(0, 15) + "..." : comment.getContent(); + // 6. 댓글 미리보기 생성 + String preview = comment.getContent().length() > 15 + ? comment.getContent().substring(0, 15) + "..." + : comment.getContent(); - Notice newNotice = noticeRepository.save( + // 7. Notice 생성 + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.COMMENT) - .message("게시글 '" + board.getTitle() + "'에 새로운 댓글이 달렸습니다.\n" + '"' + preview + '"') - .contentId(event.getBoardId()) + .message( + "게시글 '" + board.getTitle() + "'에 새로운 댓글이 달렸습니다.\n" + + "\"" + preview + "\"" + ) + .contentId(board.getId()) .build() ); - memberNoticeRepository.save(MemberNotice.builder() - .notice(newNotice) - .member(boardWriter).build()); + // 8. 게시글 작성자에게 알림 발송 + memberNoticeRepository.save( + MemberNotice.builder() + .notice(notice) + .member(boardWriter) + .build() + ); return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .message(newNotice.getMessage()) - .noticeType(newNotice.getType()) - .contentId(newNotice.getContentId()) - .createdAt(newNotice.getCreatedAt()) + .id(notice.getId()) + .message(notice.getMessage()) + .noticeType(notice.getType()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) .build(); } + @Override @Transactional - public void notifyNewShow(NewShowEvent event){ - Long amateurShowId = event.getAmateurShowId(); - - AmateurShow amateurShow = amateurShowRepository.findById(amateurShowId) - .orElseThrow(()-> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event) { + // 1. 공연 조회 + AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); - // 공연자 좋아요한 유저 조회 - List likers = memberLikeRepository.findByPerformerId(event.getPerformerId()); - if (likers.isEmpty()) return; + // 2. 공연자 조회 + Member performer = memberRepository.findById(event.performerId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Notice newNotice = noticeRepository.save( + // 3. 반려 알림 생성 + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.AMATEURSHOW) - .message("소극장 공연 " + "'" + amateurShow.getName() + "'" + " 등록 완료! 소극장 공연 페이지에서 확인해보세요!") - .contentId(amateurShowId) + .message( + "요청하신 '" + show.getName() + "' 공연 등록이 반려되었습니다.\n" + + event.rejectReason() + ) + .contentId(show.getId()) .build() ); - // MemberNotice bulk 생성 - List memberNotices = likers.stream() - .map(liker -> MemberNotice.builder() - .notice(newNotice) - .member(liker.getLiker()) - .build()) - .toList(); - - memberNoticeRepository.saveAll(memberNotices); + // 4. 공연자에게 알림 발송 + memberNoticeRepository.save( + MemberNotice.builder() + .notice(notice) + .member(performer) + .build() + ); + return NoticeResponseDTO.NoticeDTO.builder() + .id(notice.getId()) + .message(notice.getMessage()) + .noticeType(notice.getType()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) + .build(); } @Override @Transactional public NoticeResponseDTO.NoticeDTO notifyNewReply(ReplyEvent event) { - Comment comment = commentRepository.findById(event.getCommentId()) + Comment comment = commentRepository.findById(event.commentId()) .orElseThrow(()-> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND)); - Comment reply = commentRepository.findById(event.getReplyId()) + Comment reply = commentRepository.findById(event.replyId()) .orElseThrow(()-> new GeneralException(ErrorStatus.COMMENT_NOT_FOUND)); - Member commentWriter = memberRepository.findById(event.getCommentWriterId()) + Member commentWriter = memberRepository.findById(event.commentWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); - Member replyWriter = memberRepository.findById(event.getReplyWriterId()) + Member replyWriter = memberRepository.findById(event.replyWriterId()) .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); if(commentWriter.equals(replyWriter)){ return null; } - String commentPreview = comment.getContent().length() > 15 ? comment.getContent().substring(0, 15) + "..." : comment.getContent(); - String replyPreview = reply.getContent().length() > 15 ? reply.getContent().substring(0, 15) + "..." : reply.getContent(); + String commentPreview = comment.getContent().length() > 15 + ? comment.getContent().substring(0, 15) + "..." + : comment.getContent(); + + String replyPreview = reply.getContent().length() > 15 + ? reply.getContent().substring(0, 15) + "..." + : reply.getContent(); - Notice newNotice = noticeRepository.save( + Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.REPLY) .message("댓글 " + '"' + commentPreview + '"' + "에 새로운 대댓글이 달렸습니다.\n" + '"' + replyPreview + '"') - .contentId(event.getCommentId()) + .contentId(event.commentId()) .build() ); memberNoticeRepository.save( MemberNotice.builder() - .notice(newNotice) + .notice(notice) .member(commentWriter) .build()); return NoticeResponseDTO.NoticeDTO.builder() - .id(newNotice.getId()) - .noticeType(newNotice.getType()) - .message(newNotice.getMessage()) - .contentId(newNotice.getContentId()) - .createdAt(newNotice.getCreatedAt()) + .id(notice.getId()) + .noticeType(notice.getType()) + .message(notice.getMessage()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) .build(); } @Override @Transactional - public NoticeResponseDTO.NoticeDTO notifyTicketReservation(TicketReservationEvent event) { + public NoticeResponseDTO.NoticeDTO notifyTicketReservation(ReservationCompletedEvent event) { + AmateurShow amateurShow = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + Member member = memberRepository.findById(event.memberId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + Notice notice = noticeRepository.save(Notice.builder() .type(NoticeType.TICKET) - .message("'" + event.getAmateurShow().getName() + "'" + " 공연 예약이 완료되었습니다.") - .contentId(event.getAmateurTicket().getId()) + .message("'" + amateurShow.getName() + "'" + " 공연 예약이 완료되었습니다.") + .contentId(event.realTicketId()) .build() ); memberNoticeRepository.save(MemberNotice.builder() .notice(notice) - .member(event.getMember()) + .member(member) .build() ); @@ -212,22 +260,62 @@ public NoticeResponseDTO.NoticeDTO notifyTicketReservation(TicketReservationEven .build(); } - @Override - @Transactional - public NoticeResponseDTO.NoticeDTO notifyApproval(ApproveShowEvent event) { - Notice notice = noticeRepository.save(Notice.builder() - .type(NoticeType.AMATEURSHOW) - .message("요청하신 " + "'" + event.getAmateurShow().getName() + "'"+ " 공연 등록이 승인되었습니다.") - .contentId(event.getAmateurShow().getId()) - .build() - ); + public NoticeResponseDTO.NoticeDTO notifyRecommendation(ApprovalShowEvent event) { - memberNoticeRepository.save(MemberNotice.builder() - .notice(notice) - .member(event.getMember()) - .build() + // 1. 공연 조회 + AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + // 2. Notice 생성 + Notice notice = noticeRepository.save( + Notice.builder() + .type(NoticeType.RECOMMEND) + .message("새로운 공연 '" + show.getName() + "' 어떠세요?") + .contentId(show.getId()) + .build() ); + // 3. 새 공연 해시태그 계산 + Set newTagsSet = Arrays.stream(Optional.ofNullable(show.getHashtag()).orElse("").split("[#,\\s]+")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + if (newTagsSet.isEmpty()) return null; // 태그 없으면 추천 불가 + + // 4. 추천 대상 회원 조회 (좋아요한 회원 기준) + List allMembers = memberLikeRepository.findAllDistinctMembers(); + List batch = new ArrayList<>(); + + for (Member member : allMembers) { + + //추천 대상이 아닌 멤버는 패스 + if (!shouldRecommendToMember(member, newTagsSet)) continue; + + /* 5. 개인화 메시지 생성 */ + String personalMsg = + "새로운 공연 '" + show.getName() + "' 어떠세요? " + + show.getHashtag() + " " + + member.getName() + "님 취향에 딱!"; + + batch.add(MemberNotice.builder() + .member(member) + .notice(notice) + .personalMsg(personalMsg) + .isRead(false) + .build() + ); + + if (batch.size() >= BATCH_SIZE) { + memberNoticeRepository.saveAll(batch); + batch.clear(); + } + } + + if (!batch.isEmpty()) { + memberNoticeRepository.saveAll(batch); + } + return NoticeResponseDTO.NoticeDTO.builder() .id(notice.getId()) .noticeType(notice.getType()) @@ -235,25 +323,95 @@ public NoticeResponseDTO.NoticeDTO notifyApproval(ApproveShowEvent event) { .contentId(notice.getContentId()) .createdAt(notice.getCreatedAt()) .build(); + } - @Override - @Transactional - public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event){ - Notice notice = noticeRepository.save(Notice.builder() - .type(NoticeType.AMATEURSHOW) - .message("요청하신 " + "'" + event.getAmateurShow().getName() + "'" + " 공연 등록이 반려되었습니다." + "\n" - + event.getAmateurShow().getRejectReason()) - .contentId(event.getAmateurShow().getId()) - .build() + private boolean shouldRecommendToMember(Member member, Set newTagsSet) { + // 회원이 좋아요한 공연자 목록 조회 + List likedPerformers = memberLikeRepository.findByLikerId(member.getId()); + if (likedPerformers.isEmpty()) return false; + + for (MemberLike like : likedPerformers) { + Long likedPerformerId = like.getPerformer().getId(); + List hashtags = amateurShowRepository.findHashtagsByMemberId(likedPerformerId); + + for (String existingHashtags : hashtags) { + Set existingTagsSet = Arrays.stream(existingHashtags.split("#")) + .map(String::trim) + .collect(Collectors.toSet()); + Set intersection = new HashSet<>(newTagsSet); + intersection.retainAll(existingTagsSet); + + if (!intersection.isEmpty()) return true; + } + } + return false; + } + + public NoticeResponseDTO.NoticeDTO notifyApproval(ApprovalShowEvent event) { + AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + Member performer = memberRepository.findById(event.performerId()) + .orElseThrow(()-> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + + // 승인된 공연 알림 생성 + Notice notice = noticeRepository.save( + Notice.builder() + .type(NoticeType.AMATEURSHOW) + .message("요청하신 '" + show.getName() + "' 공연 등록이 승인되었습니다.") + .contentId(event.amateurShowId()) + .build() ); - memberNoticeRepository.save(MemberNotice.builder() + // 공연 등록자에게 MemberNotice 발송 + MemberNotice memberNotice = MemberNotice.builder() .notice(notice) - .member(event.getMember()) - .build() + .member(performer) + .build(); + + memberNoticeRepository.save(memberNotice); + + return NoticeResponseDTO.NoticeDTO.builder() + .id(notice.getId()) + .noticeType(notice.getType()) + .message(notice.getMessage()) + .contentId(notice.getContentId()) + .createdAt(notice.getCreatedAt()) + .build(); + + } + + public NoticeResponseDTO.NoticeDTO notifyLikers(ApprovalShowEvent event) { + Long amateurShowId = event.amateurShowId(); + + // 공연 조회 + AmateurShow amateurShow = amateurShowRepository.findById(amateurShowId) + .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); + + // 공연자 좋아요한 유저 조회 + List likers = memberLikeRepository.findByPerformerId(event.performerId()); + if (likers.isEmpty()) return null; + + // 등록 알림 생성 + Notice notice = noticeRepository.save( + Notice.builder() + .type(NoticeType.AMATEURSHOW) + .message("소극장 공연 '" + amateurShow.getName() + "' 등록 완료! 소극장 공연 페이지에서 확인해보세요!") + .contentId(amateurShowId) + .build() ); + // 등록자 계정 좋아요한 사람에게 MemberNotice 발송 + List memberNotices = likers.stream() + .map(liker -> MemberNotice.builder() + .notice(notice) + .member(liker.getLiker()) + .build()) + .toList(); + + memberNoticeRepository.saveAll(memberNotices); + return NoticeResponseDTO.NoticeDTO.builder() .id(notice.getId()) .noticeType(notice.getType()) @@ -263,4 +421,5 @@ public NoticeResponseDTO.NoticeDTO notifyRejection(RejectShowEvent event){ .build(); } + } diff --git a/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java new file mode 100644 index 0000000..555633a --- /dev/null +++ b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java @@ -0,0 +1,21 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.approvalShowEvent.ApprovalShowProducer; +import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; +import cc.backend.kafka.event.rejectShowEvent.RejectShowProducer; +import cc.backend.notice.event.RejectCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RejectCommitEventListener { + private final RejectShowProducer rejectShowProducer; + + // REJECTED 수정 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + public void onRejectCommit(RejectCommitEvent event) { + rejectShowProducer.publish( + new RejectShowEvent(event.amateurShowId(), event.performerId(), event.rejectReason()) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/ReplyCommmitEventListener.java b/src/main/java/cc/backend/notice/service/ReplyCommmitEventListener.java new file mode 100644 index 0000000..6781009 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/ReplyCommmitEventListener.java @@ -0,0 +1,29 @@ +package cc.backend.notice.service; + +import cc.backend.kafka.event.replyEvent.ReplyEvent; +import cc.backend.kafka.event.replyEvent.ReplyProducer; +import cc.backend.notice.event.ReplyCommitEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class ReplyCommmitEventListener { + + private final ReplyProducer replyProducer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReplyCommit(ReplyCommitEvent event) { + + // Reply 생성 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + replyProducer.publish( new ReplyEvent( + event.commentId(), + event.commentWriterId(), + event.replyId(), + event.replyWriterId() + ) + ); + } +} diff --git a/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java b/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java new file mode 100644 index 0000000..63c44b3 --- /dev/null +++ b/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java @@ -0,0 +1,24 @@ +package cc.backend.notice.service; + +import cc.backend.notice.event.TicketReservationCommitEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedProducer; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class ReservationCommitEventListener { + private final ReservationCompletedProducer reservationCompletedProducer; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onReservationCommit(TicketReservationCommitEvent event) { + + // TempTicket 생성 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + reservationCompletedProducer.publish( + new ReservationCompletedEvent(event.amateurShowId(), event.realTicketId(), event.memberId()) + ); + } +} diff --git a/src/main/java/cc/backend/ticket/service/RealTicketService.java b/src/main/java/cc/backend/ticket/service/RealTicketService.java index 5b8f6cc..53c7aec 100644 --- a/src/main/java/cc/backend/ticket/service/RealTicketService.java +++ b/src/main/java/cc/backend/ticket/service/RealTicketService.java @@ -6,6 +6,7 @@ import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; +import cc.backend.notice.event.TicketReservationCommitEvent; import cc.backend.ticket.dto.response.RealTicketResponseDTO; import cc.backend.ticket.dto.response.ShowSnapshot; import cc.backend.ticket.entity.TempTicket; @@ -16,6 +17,7 @@ import cc.backend.ticket.repository.RealTicketRepository; import cc.backend.ticket.util.CancelPolicy; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -32,6 +34,7 @@ public class RealTicketService { private final RealTicketRepository realTicketRepository; private final TempTicketRepository tempTicketRepository; private final AmateurRoundsRepository amateurRoundsRepository; + private final ApplicationEventPublisher eventPublisher; @Transactional @@ -67,7 +70,15 @@ public void createRealTicketFromTempTicket(TempTicket tempTicket) { .kakaoTid(tempTicket.getKakaoTid()) .build(); - realTicketRepository.save(realTicket); + RealTicket ticket = realTicketRepository.save(realTicket); + + // -> 먼저 ApplicationEvent를 완충 이벤트로 커밋 이후를 보장받고 나서 카프카 이벤트 발행 + eventPublisher.publishEvent( + new TicketReservationCommitEvent( + ticket.getAmateurRound().getAmateurShow().getId(), + ticket.getId(), + ticket.getMember().getId()) + ); } public Slice getMyTicketList(Long memberId, String status, int page, int size) { diff --git a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java index ce7f3a4..0df8667 100644 --- a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java +++ b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java @@ -8,7 +8,8 @@ import cc.backend.amateurShow.repository.AmateurTicketRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.notice.event.entity.TicketReservationEvent; +import cc.backend.notice.event.TicketReservationCommitEvent; +import cc.backend.kafka.event.reservationCompletedEvent.ReservationCompletedProducer; import cc.backend.member.entity.Member; import cc.backend.member.repository.MemberRepository; import cc.backend.ticket.dto.request.TempTicketCreateRequestDTO; @@ -39,6 +40,7 @@ public class TempTicketServiceImpl implements TempTicketService { private final ApplicationEventPublisher eventPublisher; private final RealTicketService realTicketService; private final MemberRepository memberRepository; + private final ReservationCompletedProducer reservationCompletedProducer; @Override @@ -90,8 +92,10 @@ public TempTicketCreateResponseDTO createTempTicket(Long amateurShowId, Long ama TempTicket saved = tempTicketRepository.save(ticket); - //티켓 예매 알림 이벤트 생성 - eventPublisher.publishEvent(new TicketReservationEvent(ticket.getAmateurTicket().getAmateurShow(), ticket.getAmateurTicket(), memberRef)); + // -> 먼저 ApplicationEvent를 완충 이벤트로 커밋 이후를 보장받고 나서 카프카 이벤트 발행 + eventPublisher.publishEvent( + new TicketReservationCommitEvent(ticket.getAmateurTicket().getAmateurShow().getId(), ticket.getAmateurTicket().getId(), memberRef.getId()) + ); // realTicket은 API를 사용해 호출 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6e1453d..81991e0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,12 +36,16 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + properties: + spring.json.add.type.headers: true + consumer: group-id: cc-backend-group key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer properties: spring.json.trusted.packages: "*" + auto-offset-reset: earliest url: access-token: https://oauth2.googleapis.com/token From 928bb00b13b220ef5a56fd338c132f280ccecdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4=ED=98=B8?= Date: Sat, 17 Jan 2026 04:44:33 +0900 Subject: [PATCH 3/6] =?UTF-8?q?debug:=20=EC=82=AC=EC=86=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminApprovalService.java | 2 -- .../backend/board/service/CommentService.java | 2 -- .../notice/service/NoticeServiceImpl.java | 19 +++++++++++-------- .../service/RejectCommitEventListener.java | 7 ++++++- ...ner.java => ReplyCommitEventListener.java} | 2 +- .../ReservationCommitEventListener.java | 1 + .../ticket/service/TempTicketServiceImpl.java | 5 ----- src/main/resources/application.yml | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) rename src/main/java/cc/backend/notice/service/{ReplyCommmitEventListener.java => ReplyCommitEventListener.java} (95%) diff --git a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java index 8aaf1d1..806a5ae 100644 --- a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java +++ b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java @@ -28,8 +28,6 @@ public class AdminApprovalService { private final AmateurShowRepository amateurShowRepository; private final ApplicationEventPublisher eventPublisher; - private final ApprovalShowProducer approvalShowProducer; - private final ApplicationEventPublisher applicationEventPublisher; @Transactional public AdminAmateurShowSummaryResponseDTO approveShow(Long showId) { diff --git a/src/main/java/cc/backend/board/service/CommentService.java b/src/main/java/cc/backend/board/service/CommentService.java index d3267b2..288911f 100644 --- a/src/main/java/cc/backend/board/service/CommentService.java +++ b/src/main/java/cc/backend/board/service/CommentService.java @@ -37,8 +37,6 @@ public class CommentService { private final BoardRepository boardRepository; private final MemberRepository memberRepository; - private final CommentProducer commentProducer; - private final ReplyProducer replyProducer; private final ApplicationEventPublisher eventPublisher; //댓글 작성 diff --git a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java index 999e031..636e145 100644 --- a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java +++ b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java @@ -266,7 +266,17 @@ public NoticeResponseDTO.NoticeDTO notifyRecommendation(ApprovalShowEvent event) AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND)); - // 2. Notice 생성 + + // 2. 새 공연 해시태그 계산 + Set newTagsSet = Arrays.stream(Optional.ofNullable(show.getHashtag()).orElse("").split("[#,\\s]+")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + + if (newTagsSet.isEmpty()) return null; // 태그 없으면 추천 불가 + + + // 3. Notice 생성 Notice notice = noticeRepository.save( Notice.builder() .type(NoticeType.RECOMMEND) @@ -275,13 +285,6 @@ public NoticeResponseDTO.NoticeDTO notifyRecommendation(ApprovalShowEvent event) .build() ); - // 3. 새 공연 해시태그 계산 - Set newTagsSet = Arrays.stream(Optional.ofNullable(show.getHashtag()).orElse("").split("[#,\\s]+")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .collect(Collectors.toSet()); - - if (newTagsSet.isEmpty()) return null; // 태그 없으면 추천 불가 // 4. 추천 대상 회원 조회 (좋아요한 회원 기준) List allMembers = memberLikeRepository.findAllDistinctMembers(); diff --git a/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java index 555633a..9e48d45 100644 --- a/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java +++ b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java @@ -6,14 +6,19 @@ import cc.backend.notice.event.RejectCommitEvent; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; @Service @RequiredArgsConstructor public class RejectCommitEventListener { + private final RejectShowProducer rejectShowProducer; - // REJECTED 수정 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onRejectCommit(RejectCommitEvent event) { + + // REJECTED 수정 트랜잭션 커밋 완료 후 Kafka 이벤트 발송 rejectShowProducer.publish( new RejectShowEvent(event.amateurShowId(), event.performerId(), event.rejectReason()) ); diff --git a/src/main/java/cc/backend/notice/service/ReplyCommmitEventListener.java b/src/main/java/cc/backend/notice/service/ReplyCommitEventListener.java similarity index 95% rename from src/main/java/cc/backend/notice/service/ReplyCommmitEventListener.java rename to src/main/java/cc/backend/notice/service/ReplyCommitEventListener.java index 6781009..56b7679 100644 --- a/src/main/java/cc/backend/notice/service/ReplyCommmitEventListener.java +++ b/src/main/java/cc/backend/notice/service/ReplyCommitEventListener.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class ReplyCommmitEventListener { +public class ReplyCommitEventListener { private final ReplyProducer replyProducer; diff --git a/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java b/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java index 63c44b3..1bc1df4 100644 --- a/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java +++ b/src/main/java/cc/backend/notice/service/ReservationCommitEventListener.java @@ -11,6 +11,7 @@ @Service @RequiredArgsConstructor public class ReservationCommitEventListener { + private final ReservationCompletedProducer reservationCompletedProducer; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) diff --git a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java index 0df8667..2835f4a 100644 --- a/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java +++ b/src/main/java/cc/backend/ticket/service/TempTicketServiceImpl.java @@ -92,11 +92,6 @@ public TempTicketCreateResponseDTO createTempTicket(Long amateurShowId, Long ama TempTicket saved = tempTicketRepository.save(ticket); - // -> 먼저 ApplicationEvent를 완충 이벤트로 커밋 이후를 보장받고 나서 카프카 이벤트 발행 - eventPublisher.publishEvent( - new TicketReservationCommitEvent(ticket.getAmateurTicket().getAmateurShow().getId(), ticket.getAmateurTicket().getId(), memberRef.getId()) - ); - // realTicket은 API를 사용해 호출 return TempTicketCreateResponseDTO.builder() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 81991e0..6481356 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,7 +45,7 @@ spring: value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer properties: spring.json.trusted.packages: "*" - auto-offset-reset: earliest + auto-offset-reset: earliest url: access-token: https://oauth2.googleapis.com/token From edc783b8e9e15d662dfd626d97c16fd2c25a81f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4=ED=98=B8?= Date: Sat, 17 Jan 2026 04:56:26 +0900 Subject: [PATCH 4/6] =?UTF-8?q?debug:=20=EC=B9=B4=ED=94=84=EC=B9=B4=20cons?= =?UTF-8?q?umer=EC=97=90=20=EB=B9=BC=EB=A8=B9=EC=9D=80=20Transcational=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cc/backend/kafka/event/commentEvent/CommentConsumer.java | 2 ++ .../cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java | 2 ++ .../java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java b/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java index b2f15aa..363e1f6 100644 --- a/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java +++ b/src/main/java/cc/backend/kafka/event/commentEvent/CommentConsumer.java @@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @@ -30,6 +31,7 @@ public class CommentConsumer { groupId = "comment-notice-group", containerFactory = "kafkaListenerContainerFactory" ) + @Transactional public void consume(CommentEvent event) { if (event == null) return; diff --git a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java index e310ee3..ad394ed 100644 --- a/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java +++ b/src/main/java/cc/backend/kafka/event/hotBoardEvent/HotBoardConsumer.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @@ -16,6 +17,7 @@ public class HotBoardConsumer { groupId = "hot-board-notice-group", containerFactory = "kafkaListenerContainerFactory" ) + @Transactional public void consume(HotBoardEvent event) { if (event == null) return; diff --git a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java index f6a7298..57d05f9 100644 --- a/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java +++ b/src/main/java/cc/backend/kafka/event/replyEvent/ReplyConsumer.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @@ -16,6 +17,7 @@ public class ReplyConsumer { groupId = "reply-notice-group", containerFactory = "kafkaListenerContainerFactory" ) + @Transactional public void consume(ReplyEvent event) { if (event == null) return; From 3a8a73f01aac87bac4b84410111ef76368dce171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4=ED=98=B8?= Date: Mon, 19 Jan 2026 17:01:28 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20zookeeper=EC=82=AD=EC=A0=9C,=20?= =?UTF-8?q?kraft=EB=AA=A8=EB=93=9C=EB=A1=9C=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 179 ++++++++++-------- .../service/AdminApprovalService.java | 5 +- .../java/cc/backend/kafka/KafkaConfig.java | 2 +- .../ApprovalShowProducer.java | 7 +- .../service/ApproveCommitEventListener.java | 4 +- .../service/RejectCommitEventListener.java | 1 - 6 files changed, 103 insertions(+), 95 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f9eb806..e344b1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,86 +1,101 @@ -services: - redis: - image: redis:7-alpine - container_name: redis - ports: - - "6379:6379" - restart: unless-stopped - mem_limit: 128m - command: redis-server --appendonly yes - volumes: - - redis_data:/data - # 블루 배포 - app-blue: - image: ddhi7/ccapp:latest #이미지는 동일 이미지 사용 : (깃허브 플젝 -> ccapp 이미지) - container_name: ccapp-blue - ports: - - "8081:8080" - env_file: - - .env - restart: unless-stopped - pull_policy: always - mem_limit: 400m - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - depends_on: - - redis - - kafka #추가 + services: + redis: + image: redis:7-alpine + container_name: redis + ports: + - "6379:6379" + restart: unless-stopped + mem_limit: 128m + command: redis-server --appendonly yes + volumes: + - redis_data:/data + # 블루 배포 + app-blue: + image: ddhi7/ccapp:latest #이미지는 동일 이미지 사용 : (깃허브 플젝 -> ccapp 이미지) + container_name: ccapp-blue + ports: + - "8081:8080" + env_file: + - .env + restart: unless-stopped + pull_policy: always + mem_limit: 400m + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + depends_on: + - redis + - kafka #추가 - app-green: - #그린 배포 - image: ddhi7/ccapp:latest - container_name: ccapp-green - ports: - - "8082:8080" - env_file: - - .env - restart: unless-stopped - pull_policy: always - mem_limit: 400m - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s - depends_on: - - redis - - kafka #추가 + app-green: + #그린 배포 + image: ddhi7/ccapp:latest + container_name: ccapp-green + ports: + - "8082:8080" + env_file: + - .env + restart: unless-stopped + pull_policy: always + mem_limit: 400m + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:8080/actuator/health" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + depends_on: + - redis + - kafka #추가 - #추가 - zookeeper: - image: confluentinc/cp-zookeeper:7.5.0 - container_name: zookeeper - environment: - ZOOKEEPER_CLIENT_PORT: 2181 - ZOOKEEPER_TICK_TIME: 2000 - ports: - - "2181:2181" - restart: unless-stopped - kafka: - image: confluentinc/cp-kafka:7.5.0 - container_name: kafka - depends_on: - - zookeeper - ports: - - "9092:9092" # 도커 내부 통신용 - - "29092:29092" # 로컬호스트 접속용 - environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + kafka: + image: confluentinc/cp-kafka:latest + container_name: kafka + ports: + - "9092:9092" # 도커 내부 통신용 + - "29092:29092" # 로컬호스트 접속용 + environment: + # 필수 KRaft 설정 + CLUSTER_ID: "hjeeg3q1SoCw7IKoRw-rMQ" + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: "broker,controller" # 브로커와 컨트롤러 역할 + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" # 컨트롤러 지정 - # 내부/외부 통신 분리 (중요) - KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,PLAINTEXT_HOST://0.0.0.0:29092 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" - restart: unless-stopped -volumes: - redis_data: - driver: local \ No newline at end of file + # 리스너 설정 (CONTROLLER 추가 필수) + KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://0.0.0.0:29092' + KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT' + KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' + + # Mac(ARM) 호환 설정 및 성능 최적화 + _JAVA_OPTIONS: "-XX:UseSVE=0" + KAFKA_HEAP_OPTS: "-Xms256M -Xmx256M" # JVM 힙 메모리 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + volumes: + - kafka_data:/var/lib/kafka/data # <- Docker 내부 볼륨만 사용 + restart: unless-stopped + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + ports: + - "8085:8080" # 호스트 8085로 접속 가능 + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 + KAFKA_CLUSTERS_0_ZOOKEEPER: "" # KRaft 모드라 Zookeeper 필요 없음 + depends_on: + - kafka + restart: unless-stopped + + volumes: + redis_data: + driver: local + kafka_data: \ No newline at end of file diff --git a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java index 806a5ae..f99868c 100644 --- a/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java +++ b/src/main/java/cc/backend/admin/amateurShow/service/AdminApprovalService.java @@ -7,10 +7,7 @@ import cc.backend.amateurShow.repository.AmateurShowRepository; import cc.backend.apiPayLoad.code.status.ErrorStatus; import cc.backend.apiPayLoad.exception.GeneralException; -import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; import cc.backend.member.entity.Member; -import cc.backend.kafka.event.approvalShowEvent.ApprovalShowEvent; -import cc.backend.kafka.event.approvalShowEvent.ApprovalShowProducer; import cc.backend.notice.event.ApproveCommitEvent; import cc.backend.notice.event.RejectCommitEvent; import lombok.RequiredArgsConstructor; @@ -38,7 +35,7 @@ public AdminAmateurShowSummaryResponseDTO approveShow(Long showId) { Member performer = show.getMember(); - // 등록 승인 커밋 트랜잭션 이벤트 발행 + // 등록 승인 트랜잭션 커밋에 대해 이벤트 발행 eventPublisher.publishEvent( new ApproveCommitEvent(show.getId(), performer.getId() ) diff --git a/src/main/java/cc/backend/kafka/KafkaConfig.java b/src/main/java/cc/backend/kafka/KafkaConfig.java index 1f0f929..cf269b4 100644 --- a/src/main/java/cc/backend/kafka/KafkaConfig.java +++ b/src/main/java/cc/backend/kafka/KafkaConfig.java @@ -81,7 +81,7 @@ public ConcurrentKafkaListenerContainerFactory kafkaListene new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory()); factory.setConcurrency(3); // 컨슈머 병렬 처리 스레드 수 - factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); //에러발생시 처리 로직 + factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); //에러 발생시 처리 로직 return factory; } diff --git a/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java index 8c8eebb..7de3674 100644 --- a/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java +++ b/src/main/java/cc/backend/kafka/event/approvalShowEvent/ApprovalShowProducer.java @@ -8,17 +8,14 @@ @Component @RequiredArgsConstructor public class ApprovalShowProducer { + private final KafkaTemplate kafkaTemplate; private static final String TOPIC = "approval-show-topic"; - /** - * 승인된 공연 이벤트 발행 - * @param event ApprovalShowEvent - */ public void publish(ApprovalShowEvent event) { if (event == null) return; - // amateurShowId 기준 파티션 + // amateurShowId로 파티션 kafkaTemplate.send(TOPIC, event.amateurShowId().toString(), event); } diff --git a/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java b/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java index 3dd4844..9f73a99 100644 --- a/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java +++ b/src/main/java/cc/backend/notice/service/ApproveCommitEventListener.java @@ -14,9 +14,9 @@ public class ApproveCommitEventListener { private final ApprovalShowProducer approvalShowProducer; @TransactionalEventListener (phase = TransactionPhase.AFTER_COMMIT) - public void handleCommentCreate(ApproveCommitEvent event) { + public void onApproveCommit(ApproveCommitEvent event) { - //APPROVED 수정 트랜잭션 커밋 완료 후 kafka 이벤트 발송 + //APPROVED 수정 트랜잭션 커밋 이벤트 감지 후 kafka 이벤트 발송 approvalShowProducer.publish( new ApprovalShowEvent(event.amateurShowId(), event.performerId()) ); diff --git a/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java index 9e48d45..0125416 100644 --- a/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java +++ b/src/main/java/cc/backend/notice/service/RejectCommitEventListener.java @@ -1,6 +1,5 @@ package cc.backend.notice.service; -import cc.backend.kafka.event.approvalShowEvent.ApprovalShowProducer; import cc.backend.kafka.event.rejectShowEvent.RejectShowEvent; import cc.backend.kafka.event.rejectShowEvent.RejectShowProducer; import cc.backend.notice.event.RejectCommitEvent; From 10a813fd81465ae97c1262cf3a82b2f1cae03a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=9C=A4=ED=98=B8?= Date: Thu, 22 Jan 2026 06:49:40 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=95=8C=EB=A6=BC=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=EC=8A=B9=EC=9D=B8=20=ED=86=A0=ED=94=BD?= =?UTF-8?q?=20=ED=8C=8C=ED=8B=B0=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 5 ++--- .../java/cc/backend/kafka/KafkaConfig.java | 11 ++++++++++- .../java/cc/backend/kafka/TopicConfig.java | 18 ++++++++++++++++++ .../notice/repository/NoticeRepository.java | 3 +++ .../notice/service/NoticeServiceImpl.java | 4 ++++ 5 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/main/java/cc/backend/kafka/TopicConfig.java diff --git a/docker-compose.yml b/docker-compose.yml index e344b1a..f4cc270 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,14 +49,13 @@ start_period: 40s depends_on: - redis - - kafka #추가 + - kafka kafka: image: confluentinc/cp-kafka:latest container_name: kafka ports: - - "9092:9092" # 도커 내부 통신용 - "29092:29092" # 로컬호스트 접속용 environment: # 필수 KRaft 설정 @@ -86,7 +85,7 @@ image: provectuslabs/kafka-ui:latest container_name: kafka-ui ports: - - "8085:8080" # 호스트 8085로 접속 가능 + - "8085:8080" # 호스트의 브라우저는 8085로 접속 가능 environment: KAFKA_CLUSTERS_0_NAME: local KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 diff --git a/src/main/java/cc/backend/kafka/KafkaConfig.java b/src/main/java/cc/backend/kafka/KafkaConfig.java index cf269b4..0044125 100644 --- a/src/main/java/cc/backend/kafka/KafkaConfig.java +++ b/src/main/java/cc/backend/kafka/KafkaConfig.java @@ -80,10 +80,19 @@ public ConcurrentKafkaListenerContainerFactory kafkaListene ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory()); - factory.setConcurrency(3); // 컨슈머 병렬 처리 스레드 수 + factory.setConcurrency(1); // 컨슈머 병렬 처리 스레드 수 = 1 factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); //에러 발생시 처리 로직 return factory; } + @Bean + public ConcurrentKafkaListenerContainerFactory highThroughputFactory(KafkaTemplate kafkaTemplate) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory()); + factory.setConcurrency(3); // 파티션 여러 개인 토픽은 워커 스레드 3개로 처리 + factory.setCommonErrorHandler(errorHandler(kafkaTemplate)); + return factory; + } + } diff --git a/src/main/java/cc/backend/kafka/TopicConfig.java b/src/main/java/cc/backend/kafka/TopicConfig.java new file mode 100644 index 0000000..0a30b89 --- /dev/null +++ b/src/main/java/cc/backend/kafka/TopicConfig.java @@ -0,0 +1,18 @@ +package cc.backend.kafka; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class TopicConfig { + + @Bean + public NewTopic approvalShowTopic() { + return TopicBuilder.name("approval-show-topic") + .partitions(3) // 파티션 수를 3개로 설정 + .replicas(1) // 단일 브로커(Docker 1대) 환경 + .build(); + } +} diff --git a/src/main/java/cc/backend/notice/repository/NoticeRepository.java b/src/main/java/cc/backend/notice/repository/NoticeRepository.java index 80fada6..7ed59d3 100644 --- a/src/main/java/cc/backend/notice/repository/NoticeRepository.java +++ b/src/main/java/cc/backend/notice/repository/NoticeRepository.java @@ -1,9 +1,12 @@ package cc.backend.notice.repository; import cc.backend.notice.entity.Notice; +import cc.backend.notice.entity.enums.NoticeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface NoticeRepository extends JpaRepository { + + Boolean existsByContentIdAndType(Long contentId, NoticeType type); } diff --git a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java index 636e145..0ffdc47 100644 --- a/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java +++ b/src/main/java/cc/backend/notice/service/NoticeServiceImpl.java @@ -262,6 +262,10 @@ public NoticeResponseDTO.NoticeDTO notifyTicketReservation(ReservationCompletedE public NoticeResponseDTO.NoticeDTO notifyRecommendation(ApprovalShowEvent event) { + if (noticeRepository.existsByContentIdAndType( + event.amateurShowId(), NoticeType.RECOMMEND)) { + return null; + } // 1. 공연 조회 AmateurShow show = amateurShowRepository.findById(event.amateurShowId()) .orElseThrow(() -> new GeneralException(ErrorStatus.AMATEURSHOW_NOT_FOUND));