Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
150 changes: 98 additions & 52 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,54 +1,100 @@
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
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
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

volumes:
redis_data:
driver: local

kafka:
image: confluentinc/cp-kafka:latest
container_name: kafka
ports:
- "29092:29092" # 로컬호스트 접속용
environment:
# 필수 KRaft 설정
CLUSTER_ID: "hjeeg3q1SoCw7IKoRw-rMQ"
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: "broker,controller" # 브로커와 컨트롤러 역할
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" # 컨트롤러 지정

# 리스너 설정 (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
Comment on lines +55 to +82
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Kafka service lacks a healthcheck, making depends_on unreliable.

The app containers (app-blue, app-green) depend on kafka, but without a healthcheck defined on the kafka service, Docker Compose only waits for the container to start, not for Kafka to be ready. This can cause transient connection failures on application startup.

Consider adding a healthcheck and using depends_on with condition: service_healthy:

🛠️ Suggested healthcheck for kafka
       volumes:
         - kafka_data:/var/lib/kafka/data
       restart: unless-stopped
+      healthcheck:
+        test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092 || exit 1"]
+        interval: 15s
+        timeout: 10s
+        retries: 5
+        start_period: 30s

Then update the dependents:

       depends_on:
-        - redis
-        - kafka
+        redis:
+          condition: service_started
+        kafka:
+          condition: service_healthy
🤖 Prompt for AI Agents
In `@docker-compose.yml` around lines 55 - 82, Add a Docker healthcheck to the
kafka service so Compose can wait for Kafka to be ready (not just started) and
update the dependents to wait on service health; specifically, under the kafka
service (the one with KAFKA_LISTENERS/KAFKA_ADVERTISED_LISTENERS env vars) add a
healthcheck that probes the broker (e.g., attempts a TCP connect to the
PLAINTEXT listener or runs a lightweight Kafka client check) with sensible
interval, timeout, retries and start_period settings, and then modify the
depends_on entries for app-blue and app-green to use condition: service_healthy
so they only start when kafka is healthy.


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:
2 changes: 2 additions & 0 deletions src/main/java/cc/backend/BackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
@EnableAsync
public class BackendApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,26 @@
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.PageResponse;
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)
public class AdminAmateurShowService {
private final AmateurShowRepository amateurShowRepository;
private final AmateurShowRepository amateurShowRepository;

public PageResponse<AdminAmateurShowListResponseDTO> getShowList(int page, int size, String keyword){
Pageable pageable = PageRequest.of(page, size, Sort.by("id").ascending());
Expand All @@ -44,6 +34,7 @@ public PageResponse<AdminAmateurShowListResponseDTO> getShowList(int page, int s
return PageResponse.of(dtoPage);
}


private AdminAmateurShowListResponseDTO toListDto(AmateurShow show){
return AdminAmateurShowListResponseDTO.builder()
.showId(show.getId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import cc.backend.apiPayLoad.PageResponse;
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.member.entity.Member;
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.*;
Expand All @@ -34,8 +34,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);
}
Expand All @@ -48,7 +53,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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,5 @@ public void reject(String rejectReason){
this.status = AmateurShowStatus.REJECT; //이거 수정 필요
this.rejectReason = rejectReason;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,7 @@ List<AmateurShow> 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<String> findHashtagsByMemberId(@Param("memberId") Long memberId);
Comment on lines +88 to +89
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine the AmateurShow entity class
fd -e java 'AmateurShow\.java' --exclude '*Repository*' --exclude '*test*'

Repository: SeeATheater/CC_Backend

Length of output: 126


🏁 Script executed:

#!/bin/bash
# Search for hashtag field in AmateurShow entity
rg -n 'private.*hashtag|@Column.*hashtag|@NotNull.*hashtag' --type=java -B 2 -A 2

Repository: SeeATheater/CC_Backend

Length of output: 2781


🏁 Script executed:

#!/bin/bash
# Check how findHashtagsByMemberId is used
rg -n 'findHashtagsByMemberId' --type=java -B 3 -A 5

Repository: SeeATheater/CC_Backend

Length of output: 1738


🏁 Script executed:

#!/bin/bash
# Get the full AmateurShow entity class to check field annotations
head -60 src/main/java/cc/backend/amateurShow/entity/AmateurShow.java | tail -20

Repository: SeeATheater/CC_Backend

Length of output: 406


🏁 Script executed:

#!/bin/bash
# Check if there are other usages of findHashtagsByMemberId that might also be affected
rg -n 'findHashtagsByMemberId' --type=java -C 10

Repository: SeeATheater/CC_Backend

Length of output: 3928


Add null check to prevent NullPointerException in downstream recommendation logic.

The hashtag field in the AmateurShow entity is nullable, so this query can return null elements in the list. At line 346 in NoticeServiceImpl, the code calls .split("#") directly on each element without null checking, which will throw NullPointerException if any AmateurShow record has a null hashtag.

Suggested fix
-    `@Query`("SELECT s.hashtag FROM AmateurShow s WHERE s.member.id = :memberId")
+    `@Query`("SELECT s.hashtag FROM AmateurShow s WHERE s.member.id = :memberId AND s.hashtag IS NOT NULL")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Query("SELECT s.hashtag FROM AmateurShow s WHERE s.member.id = :memberId")
List<String> findHashtagsByMemberId(@Param("memberId") Long memberId);
`@Query`("SELECT s.hashtag FROM AmateurShow s WHERE s.member.id = :memberId AND s.hashtag IS NOT NULL")
List<String> findHashtagsByMemberId(`@Param`("memberId") Long memberId);
🤖 Prompt for AI Agents
In `@src/main/java/cc/backend/amateurShow/repository/AmateurShowRepository.java`
around lines 88 - 89, The query method findHashtagsByMemberId can return null
elements because AmateurShow.hashtag is nullable; update the query to exclude
nulls (e.g., add "AND s.hashtag IS NOT NULL") or modify the service path by
filtering nulls before splitting in NoticeServiceImpl (where it currently calls
.split("#") on each element) so no null value is passed to split; locate
findHashtagsByMemberId and the splitting logic in NoticeServiceImpl and apply
one of these fixes to prevent NullPointerException.

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,21 @@
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;
import cc.backend.image.service.ImageService;
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 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;

Expand All @@ -51,10 +41,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 ApplicationEventPublisher eventPublisher; //이벤트 생성

// 소극장 공연 등록
@Transactional
Expand Down Expand Up @@ -92,18 +80,6 @@ public AmateurEnrollResponseDTO.AmateurEnrollResult enrollShow(Long memberId,

imageService.saveImageWithImageUrl(memberId, fullImageRequestDTO, Optional.ofNullable(dto.getImageUrl()));


// 좋아요한 멤버리스트
List<MemberLike> memberLikers = memberLikeRepository.findByPerformerId(memberId);
// 좋아요한 멤버가 한 명 이상일 때만
if(!memberLikers.isEmpty()) {
List<Member> likers = memberLikers.stream()
.map(MemberLike::getLiker)
.collect(Collectors.toList());

eventPublisher.publishEvent(new NewShowEvent(newAmateurShow.getId(), memberId, likers)); //공연등록 이벤트 생성
}

// response
return AmateurConverter.toAmateurEnrollDTO(newAmateurShow);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,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", "존재하지 않는 사진첩입니다."),

Expand All @@ -110,6 +111,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", "로그인한 멤버가 작성하지 않는 문의글입니다."),
Expand Down
10 changes: 7 additions & 3 deletions src/main/java/cc/backend/board/service/BoardService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.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;
Expand Down Expand Up @@ -48,7 +51,7 @@ public class BoardService {
private final ImageService imageService;
private final ImageRepository imageRepository;

private final ApplicationEventPublisher eventPublisher;
private final HotBoardProducer hotBoardProducer;

// 게시글 작성
@Transactional
Expand Down Expand Up @@ -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()));
}
}

Expand Down
Loading