diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f19a139 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,101 @@ +name: Deploy to Amazon Linux EC2 (compose-prod) + +on: + push: + branches: [ main ] # main 브랜치 푸시 시 배포 + workflow_dispatch: {} + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # 1) 코드 체크아웃 + - name: Checkout + uses: actions/checkout@v4 + + # 2) JDK 설치 (Spring 빌드) + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + # 3) Gradle 빌드 (테스트 생략 예시) + - name: Build Spring Boot + run: ./gradlew clean build -x test + + # 4) EC2로 파일 전송 + # - compose 파일: docker-compose-prod.yml + # - backendProject 폴더 안의 Dockerfile + # - CI에서 생성한 JAR (backendProject/build/libs/*.jar) + # - (있다면) .env + - name: Upload artifacts to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: | + docker-compose-prod.yml + backendProject/Dockerfile + backendProject/build/libs/*.jar + .env + target: ${{ secrets.EC2_APP_DIR }} + + # 5) EC2에서 배포 (Amazon Linux) + - name: Deploy on EC2 + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + set -e + APP_DIR="${{ secrets.EC2_APP_DIR }}" + cd "$APP_DIR" + + # Docker/compose 설치(없을 때) + if ! command -v docker >/dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then + sudo dnf -y update + sudo dnf -y install docker + else + sudo yum -y update + sudo yum -y install docker + fi + sudo systemctl enable docker + sudo systemctl start docker + sudo usermod -aG docker $USER || true + fi + if ! docker compose version >/dev/null 2>&1; then + if command -v dnf >/dev/null 2>&1; then + sudo dnf -y install docker-compose-plugin + else + sudo yum -y install docker-compose-plugin || true + fi + fi + + # 기존 컨테이너 중지 후 재빌드/재기동 + sudo docker compose -f docker-compose-prod.yml down || true + sudo docker compose -f docker-compose-prod.yml build --no-cache + sudo docker compose -f docker-compose-prod.yml up -d + + # (옵션) 안 쓰는 이미지 정리 + sudo docker image prune -f + + # 6) (선택) CloudFront 캐시 무효화 + - name: Configure AWS credentials + if: ${{ secrets.CF_DISTRIBUTION_ID != '' }} + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Invalidate CloudFront + if: ${{ secrets.CF_DISTRIBUTION_ID != '' }} + run: | + aws cloudfront create-invalidation \ + --distribution-id "${{ secrets.CF_DISTRIBUTION_ID }}" \ + --paths "/*" diff --git a/backendProject/build.gradle b/backendProject/build.gradle index e2377e7..51eb830 100644 --- a/backendProject/build.gradle +++ b/backendProject/build.gradle @@ -57,7 +57,7 @@ dependencies { /* ElasticSearch */ implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' - implementation 'co.elastic.clients:elasticsearch-java:8.13.0' + //implementation 'co.elastic.clients:elasticsearch-java:8.13.0' /* JSONObject */ implementation 'org.json:json:20240303' diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java index 2e6cd04..071459f 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/draft/entity/Draft.java @@ -22,8 +22,9 @@ public class Draft { @Column(name = "id", nullable = false) private UUID id; - @Column(name = "room_no", nullable = false) - private long roomNo; + @Column(name = "room_no", nullable = false, updatable = false, insertable = false) + @org.hibernate.annotations.Generated(org.hibernate.annotations.GenerationTime.INSERT) + private Long roomNo; @Column(name = "is_deleted", nullable = false) private boolean isDeleted; diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/controller/AssignmentController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/controller/AssignmentController.java new file mode 100644 index 0000000..a76cdab --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/controller/AssignmentController.java @@ -0,0 +1,31 @@ +package likelion.mlb.backendProject.domain.match.controller; + +import likelion.mlb.backendProject.domain.match.dto.AssignDto; +import likelion.mlb.backendProject.domain.match.service.AssignmentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/match") +@RequiredArgsConstructor +public class AssignmentController { + + private final AssignmentService assignmentService; + + @GetMapping("/assignment") + public ResponseEntity getAssignment( + //@AuthenticationPrincipal CustomUser principal // FIXME: 실제 타입으로 + + ) { + //UUID userId = principal.getId(); // 로그인 유저 PK + UUID userId = UUID.fromString("969a6b7d-2a24-41ca-9f46-d1d2f8012844"); // FIXME 수정할 것 + AssignDto dto = assignmentService.getMyAssignmentOrThrow(userId); // 없으면 BaseException 던짐 + return ResponseEntity.ok(dto); + } +} + diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/AssignDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/AssignDto.java new file mode 100644 index 0000000..87baa1e --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/AssignDto.java @@ -0,0 +1,13 @@ +package likelion.mlb.backendProject.domain.match.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.UUID; + +@Getter +@AllArgsConstructor +public class AssignDto { + private UUID draftId; + private short userNumber; +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/DraftStartMessage.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/DraftStartMessage.java new file mode 100644 index 0000000..b7449d6 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/dto/DraftStartMessage.java @@ -0,0 +1,16 @@ +package likelion.mlb.backendProject.domain.match.dto; + +import lombok.*; + +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DraftStartMessage { + private String type; // "DRAFT_START" + private UUID draftId; + private short userNumber; // 1~4 +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java index 29f1010..cd040e3 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/entity/Participant.java @@ -22,7 +22,7 @@ public class Participant { private short userNumber; @Column(name = "isDummy", nullable = false) - private boolean isDummy; + private boolean dummy; @Column(name = "score", nullable = false) private Integer score = 0; diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java index 2407689..814b1a6 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/handler/MatchHandler.java @@ -1,20 +1,25 @@ package likelion.mlb.backendProject.domain.match.handler; -import likelion.mlb.backendProject.domain.match.dto.MatchStatusResponse; -import likelion.mlb.backendProject.domain.match.dto.RoundInfo; +import likelion.mlb.backendProject.domain.match.dto.AssignDto; import likelion.mlb.backendProject.domain.match.service.MatchService; +import likelion.mlb.backendProject.domain.match.service.MatchTimeoutProcessor; +import likelion.mlb.backendProject.global.security.dto.CustomUserDetails; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.json.JSONObject; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; +import java.security.Principal; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; @Slf4j @Component @@ -22,50 +27,70 @@ public class MatchHandler extends TextWebSocketHandler { private final MatchService matchService; + private final MatchTimeoutProcessor matchTimeoutProcessor; private final Set sessions = ConcurrentHashMap.newKeySet(); + private final Map latestByUser = new ConcurrentHashMap<>(); + + // 라운드별 타임아웃 배치 중복 실행 방지용 + private final AtomicReference lastProcessedRoundId = new AtomicReference<>(null); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { - //FIXME 로그인 구현 후 실제 유저 PK를 받아올 것. - String userId = UUID.randomUUID().toString(); - session.getAttributes().put("userId", userId); - sessions.add(session); - - log.info("🟢 WebSocket 연결됨: {}", session.getId()); - - if (session.isOpen()) { - try { - // ✅ 1. USER_ID 전송 - session.sendMessage(new TextMessage("{\"type\":\"USER_ID\",\"userId\":\"" + userId + "\"}")); - - // ✅ 2. STATUS 즉시 전송 - MatchStatusResponse status = matchService.getCurrentStatus(); - JSONObject response = new JSONObject(); - response.put("type", "STATUS"); - response.put("count", status.getCount()); - response.put("remainingTime", status.getRemainingTime()); - response.put("state", status.getState()); - - JSONObject round = new JSONObject(); - round.put("no", status.getRound().getNo()); - round.put("id", status.getRound().getId()); - round.put("openAt", status.getRound().getOpenAt()); - round.put("lockAt", status.getRound().getLockAt()); - - response.put("round", round); + var principal = session.getPrincipal(); + if (!(principal instanceof Authentication auth) || !auth.isAuthenticated()) { + session.close(CloseStatus.NOT_ACCEPTABLE); + return; + } + var cud = (CustomUserDetails) auth.getPrincipal(); + String userId = cud.getUser().getId().toString(); - session.sendMessage(new TextMessage(response.toString())); + // 기존 세션 있으면 닫기 (유저당 1세션 유지) + WebSocketSession prev = latestByUser.put(userId, session); + if (prev != null && prev.isOpen() && prev != session) { + try { prev.close(CloseStatus.NORMAL); } catch (Exception ignored) {} + } - } catch (IOException e) { - log.warn("초기 메시지 전송 실패 → 세션 제거: {}", e.getMessage()); - sessions.remove(session); + session.getAttributes().put("userId", userId); + sessions.add(session); + log.info("🟢 WebSocket 연결됨: {} (userId={})", session.getId(), userId); + + try { + // 1) USER_ID + session.sendMessage(new TextMessage("{\"type\":\"USER_ID\",\"userId\":\"" + userId + "\"}")); + + // 2) STATUS 즉시 전송 + var status = matchService.getCurrentStatus(); + var resp = new JSONObject(); + resp.put("type", "STATUS"); + resp.put("count", status.getCount()); + resp.put("remainingTime", status.getRemainingTime()); + resp.put("state", status.getState()); + var round = status.getRound(); + if (round != null) { + var r = new JSONObject(); + r.put("id", round.getId().toString()); + r.put("no", round.getNo()); + r.put("openAt", round.getOpenAt()); + r.put("lockAt", round.getLockAt()); + resp.put("round", r); } - } else { - log.warn("❌ 연결은 됐지만 session이 열려있지 않음"); + session.sendMessage(new TextMessage(resp.toString())); + } catch (IOException ex) { + log.warn("초기 메시지 전송 중 끊김: {}", ex.getMessage()); } } + private String extractUserId(WebSocketSession session) { + Principal principal = session.getPrincipal(); + if (principal instanceof org.springframework.security.core.Authentication auth) { + Object p = auth.getPrincipal(); + if (p instanceof CustomUserDetails cud) { + return cud.getUser().getId().toString(); // UUID + } + } + return null; + } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException { @@ -96,41 +121,110 @@ protected void handleTextMessage(WebSocketSession session, TextMessage message) public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { sessions.remove(session); String userId = (String) session.getAttributes().get("userId"); - if (userId != null && matchService.isInMatch(userId)) { - matchService.cancelMatch(userId); + if (userId != null) { + // latestByUser 최신세션만 정리 + latestByUser.compute(userId, (k, v) -> (v == session) ? null : v); + + // ❌ 자동 취소 제거: 재연결/탭 이동 중에도 대기열 유지 + // if (matchService.isInMatch(userId)) { + // matchService.cancelMatch(userId); + // } } } + @Scheduled(fixedRate = 1000) public void broadcastStatus() { - MatchStatusResponse status = matchService.getCurrentStatus(); - - JSONObject response = new JSONObject(); - response.put("type", "STATUS"); - response.put("count", status.getCount()); - response.put("remainingTime", status.getRemainingTime()); - response.put("state", status.getState()); - - RoundInfo round = status.getRound(); - if (round != null) { - JSONObject roundJson = new JSONObject(); - roundJson.put("id", round.getId().toString()); - roundJson.put("no", round.getNo()); - roundJson.put("openAt", round.getOpenAt()); - roundJson.put("lockAt", round.getLockAt()); - response.put("round", roundJson); - } + var status = matchService.getCurrentStatus(); + // 1) 상태 브로드캐스트 (세션별 LOCKED_HOLD 처리) for (WebSocketSession s : sessions) { try { - if (s.isOpen()) { - s.sendMessage(new TextMessage(response.toString())); + if (!s.isOpen()) continue; + String uid = (String) s.getAttributes().get("userId"); + + var msg = new JSONObject(); + msg.put("type", "STATUS"); + msg.put("count", status.getCount()); + msg.put("remainingTime", status.getRemainingTime()); + + String state = status.getState(); + if ("LOCKED".equals(state) && uid != null && matchService.isInMatch(uid)) { + msg.put("state", "LOCKED_HOLD"); + } else { + msg.put("state", state); } + + var round = status.getRound(); + if (round != null) { + var r = new JSONObject(); + r.put("id", round.getId().toString()); + r.put("no", round.getNo()); + r.put("openAt", round.getOpenAt()); + r.put("lockAt", round.getLockAt()); + msg.put("round", r); + } + + s.sendMessage(new TextMessage(msg.toString())); } catch (IOException e) { log.warn("WebSocket 전송 실패: {}", e.getMessage()); sessions.remove(s); } } + + // 2) LOCKED로 넘어간 첫 틱에만 타임아웃 배치 실행 + DRAFT_START 푸시 + var round = status.getRound(); + if ("LOCKED".equals(status.getState()) && round != null) { + String rid = round.getId().toString(); + String prev = lastProcessedRoundId.get(); + if (prev == null || !prev.equals(rid)) { + // 동시성 가드 (여러 스레드가 들어오더라도 최초 1회만) + if (lastProcessedRoundId.compareAndSet(prev, rid)) { + try { + log.info("⏰ LOCKED 진입: roundId={} → 타임아웃 배치 실행", rid); + + // DB 반영 (Draft/Participant 생성, Redis 정리) + Map assignments = matchTimeoutProcessor.processTimeoutAndInsert(); + + log.info("🧩 배정 완료: {} 명", assignments.size()); + + // 개인 푸시 + assignments.forEach((uid, a) -> { + WebSocketSession target = latestByUser.get(uid); + if (target != null && target.isOpen()) { + var msg = new JSONObject(); + msg.put("type", "DRAFT_START"); + msg.put("draftId", a.getDraftId().toString()); + msg.put("userNumber", a.getUserNumber()); + try { + target.sendMessage(new TextMessage(msg.toString())); + } catch (Exception e) { + log.warn("DRAFT_START 전송 실패 userId={}: {}", uid, e.getMessage()); + } + } + }); + } catch (Exception e) { + log.error("타임아웃 배치 실행 실패: {}", e.getMessage(), e); + // 실패 시 다시 시도하게 하려면 가드 해제 + lastProcessedRoundId.set(prev); + } + } + } + } } + public void notifyDraftStart(String userId, UUID draftId, short userNumber) { + WebSocketSession s = latestByUser.get(userId); + if (s != null && s.isOpen()) { + var msg = new JSONObject(); + msg.put("type", "DRAFT_START"); + msg.put("draftId", draftId.toString()); + msg.put("userNumber", userNumber); + try { + s.sendMessage(new TextMessage(msg.toString())); + } catch (IOException e) { + log.warn("DRAFT_START 전송 실패: {}", e.getMessage()); + } + } + } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java index 7a8a8f9..e01190b 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/repository/ParticipantRepository.java @@ -1,9 +1,14 @@ package likelion.mlb.backendProject.domain.match.repository; + +import likelion.mlb.backendProject.domain.match.dto.AssignDto; + import likelion.mlb.backendProject.domain.draft.entity.Draft; import likelion.mlb.backendProject.domain.match.entity.Participant; import likelion.mlb.backendProject.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; import java.util.UUID; @@ -11,5 +16,17 @@ public interface ParticipantRepository extends JpaRepository { boolean existsByDraft_IdAndUser_Id(UUID draftId, UUID userId); + + @Query(""" + select new likelion.mlb.backendProject.domain.match.dto.AssignDto(p.draft.id, p.userNumber) + from Participant p + where p.dummy = false + and p.user.id = :userId + and p.draft.isDeleted = false + and p.draft.round.id = :roundId + """) + AssignDto findAssignment(@Param("userId") UUID userId, + @Param("roundId") UUID roundId); + Optional findByUserAndDraft(User user, Draft draft); } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/scheduler/MatchScheduler.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/scheduler/MatchScheduler.java new file mode 100644 index 0000000..d5fb3f7 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/scheduler/MatchScheduler.java @@ -0,0 +1,31 @@ +package likelion.mlb.backendProject.domain.match.scheduler; + +import likelion.mlb.backendProject.domain.match.service.DraftTimingService; +import likelion.mlb.backendProject.domain.match.service.MatchTimeoutProcessor; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class MatchScheduler { + private final DraftTimingService draftTimingService; + private final MatchTimeoutProcessor processor; + + private volatile UUID processedRoundId; + + @Scheduled(cron = "*/5 * * * * *") // 5초마다 체크 + public void tick() { + var round = draftTimingService.getNextDraftWindowOrThrow(); + var lockAt = LocalDateTime.parse(round.getLockAt()); + if (LocalDateTime.now(ZoneId.of("Asia/Seoul")).isAfter(lockAt) + && !round.getId().equals(processedRoundId)) { + var map = processor.processTimeoutAndInsert(); + processedRoundId = round.getId(); + } + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/AssignmentService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/AssignmentService.java new file mode 100644 index 0000000..99de804 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/AssignmentService.java @@ -0,0 +1,37 @@ +package likelion.mlb.backendProject.domain.match.service; + +import likelion.mlb.backendProject.domain.match.dto.AssignDto; +import likelion.mlb.backendProject.domain.match.repository.ParticipantRepository; +import likelion.mlb.backendProject.global.exception.BaseException; +import likelion.mlb.backendProject.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AssignmentService { + + private final ParticipantRepository participantRepository; + private final DraftTimingService draftTimingService; + + @Transactional(readOnly = true) + public AssignDto getMyAssignmentOrThrow(UUID userId) { + var roundInfo = draftTimingService.getNextDraftWindowOrThrow(); + var roundId = roundInfo.getId(); + try { + AssignDto dto = participantRepository.findAssignment(userId, roundId); + if (dto == null) { + throw new BaseException(ErrorCode.ASSIGNMENT_NOT_FOUND); // 404 등 + } + return dto; + } catch (IncorrectResultSizeDataAccessException e) { + // 데이터 이상(동시에 두 드래프트에 배정 등) + throw new BaseException(ErrorCode.ASSIGNMENT_CONFLICT); // 409 등 + } + } +} + diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java index d501439..5a2ebf9 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/DraftTimingService.java @@ -31,8 +31,8 @@ public RoundInfo getNextDraftWindowOrThrow() { LocalDateTime startedAtKST = r.getStartedAt().atZoneSameInstant(KST).toLocalDateTime(); LocalDate draftDay = startedAtKST.toLocalDate().minusDays(2); - LocalDateTime openAt = LocalDateTime.of(draftDay, LocalTime.of(10, 00)); - LocalDateTime lockAt = LocalDateTime.of(draftDay, LocalTime.of(18, 30)); + LocalDateTime openAt = LocalDateTime.of(draftDay, LocalTime.of(8, 00)); + LocalDateTime lockAt = LocalDateTime.of(draftDay, LocalTime.of(12,20)); return new RoundInfo( r.getId(), diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java new file mode 100644 index 0000000..504068f --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/match/service/MatchTimeoutProcessor.java @@ -0,0 +1,130 @@ +package likelion.mlb.backendProject.domain.match.service; + +import jakarta.transaction.Transactional; +import likelion.mlb.backendProject.domain.chat.service.ChatRoomService; +import likelion.mlb.backendProject.domain.draft.entity.Draft; +import likelion.mlb.backendProject.domain.draft.repository.DraftRepository; +import likelion.mlb.backendProject.domain.match.dto.AssignDto; +import likelion.mlb.backendProject.domain.match.dto.RoundInfo; +import likelion.mlb.backendProject.domain.match.entity.Participant; +import likelion.mlb.backendProject.domain.match.repository.ParticipantRepository; +import likelion.mlb.backendProject.domain.round.entity.Round; +import likelion.mlb.backendProject.domain.round.repository.RoundRepository; +import likelion.mlb.backendProject.domain.user.entity.User; +import likelion.mlb.backendProject.global.exception.BaseException; +import likelion.mlb.backendProject.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +@RequiredArgsConstructor +public class MatchTimeoutProcessor { + + private final RedisTemplate redisTemplate; + private final DraftRepository draftRepository; + private final ParticipantRepository participantRepository; + private final RoundRepository roundRepository; + private final DraftTimingService draftTimingService; + private final ChatRoomService chatRoomService; + + private static final String SESSION_KEY = "match:session"; + + /** + * 타임아웃 시점: 대기중 유저를 4인 그룹으로 나눠 Draft/Participant 저장하고, + * 사용된 실제 유저는 Redis SET에서 제거. + * 반환: 실제 유저에게 전달할 (userId -> draftId, userNumber) 매핑 + */ + @Transactional + public Map processTimeoutAndInsert() { + // 1) 현재 대기 인원 스냅샷 + Set members = redisTemplate.opsForSet().members(SESSION_KEY); + if (members == null || members.isEmpty()) return Map.of(); + + // 공정성 위해 셔플 + List users = new ArrayList<>(members); + Collections.shuffle(users); + + // 2) 4인씩 그룹핑 + 마지막 그룹 패딩 + List> groups = splitBySize(users, 4); + if (!groups.isEmpty()) { + List last = groups.get(groups.size() - 1); + if (last.size() < 4) padWithDummies(last); + } + + // 3) 라운드 조회 + RoundInfo roundInfo = draftTimingService.getNextDraftWindowOrThrow(); + UUID roundId = roundInfo.getId(); + Round round = roundRepository.findById(roundId) + .orElseThrow(() -> new BaseException(ErrorCode.ROUND_NOT_FOUND)); + + Map result = new HashMap<>(); + List usedRealUserIds = new ArrayList<>(); + + // 4) 그룹별 Draft/Participant INSERT + for (List group : groups) { + if (group.size() != 4) continue; // 방은 항상 4명 + + Draft draft = new Draft(); + draft.setId(UUID.randomUUID()); + draft.setRound(round); + // draft.setDeleted(false); // ← is_deleted NOT NULL이고 default 없다면 주석 해제 + draftRepository.save(draft); + + // 필요 시 채팅방 생성 + chatRoomService.createRoom(draft.getId()); + + short userNumber = 1; + List participants = new ArrayList<>(4); + + for (String uid : group) { + Participant p = new Participant(); + p.setId(UUID.randomUUID()); + p.setDraft(draft); + p.setUserNumber(userNumber++); + + if (uid.startsWith("DUMMY:")) { + p.setDummy(true); + p.setUser(null); + } else { + p.setDummy(false); + User userRef = new User(); // 프록시 참조만 세팅 + userRef.setId(UUID.fromString(uid)); + p.setUser(userRef); + + // 결과 매핑 + result.put(uid, new AssignDto(draft.getId(), (short) (p.getUserNumber()))); + usedRealUserIds.add(uid); + } + participants.add(p); + } + participantRepository.saveAll(participants); + } + + // 5) 사용된 실제 유저만 Redis에서 제거 + if (!usedRealUserIds.isEmpty()) { + redisTemplate.opsForSet().remove(SESSION_KEY, usedRealUserIds.toArray()); + } + + return result; + } + + /** n개씩 분할 */ + private static List> splitBySize(List list, int n) { + List> res = new ArrayList<>(); + for (int i = 0; i < list.size(); i += n) { + res.add(new ArrayList<>(list.subList(i, Math.min(i + n, list.size())))); + } + return res; + } + + /** 4명이 될 때까지 더미 추가 */ + private static void padWithDummies(List group) { + int need = 4 - group.size(); + for (int i = 0; i < need; i++) { + group.add("DUMMY:" + UUID.randomUUID()); + } + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java new file mode 100644 index 0000000..e141869 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/controller/PlayerCacheController.java @@ -0,0 +1,36 @@ +package likelion.mlb.backendProject.domain.player.cache.controller; + +import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto; +import likelion.mlb.backendProject.domain.player.cache.service.PlayerCacheService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.*; + +@RestController +@RequestMapping("/api/playerCache") +@RequiredArgsConstructor +public class PlayerCacheController { + + private final PlayerCacheService playerCacheService; + + // DB → Redis 저장 (캐시 초기화) + @PostMapping("/cache") + public ResponseEntity cachePlayerList() { + playerCacheService.loadPlayersToRedis(); + return ResponseEntity.ok("Player list cached to Redis."); + } + + // Redis → 조회 (캐시 미스 시 자동 로드) + @GetMapping + public ResponseEntity> getPlayerList() { + List playerList = playerCacheService.getPlayersFromRedis(); + return ResponseEntity.ok(playerList); + } +} \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java new file mode 100644 index 0000000..030be55 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/dto/PlayerDto.java @@ -0,0 +1,63 @@ +package likelion.mlb.backendProject.domain.player.cache.dto; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; +import likelion.mlb.backendProject.domain.player.entity.Player; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.*; +import java.util.stream.Collectors; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PlayerDto { + private UUID id; // player pk값 + + private String webName; // player 영어 이름 + + private String krName; // player 한글 이름 + + private String pic; // player 사진 + + //a: 뛸 수 있음, d: 출전 불투명, i: 부상, s: 징계, u: 사용불(임대 등), n: 자격 없음(미등록 선수) + //x로 업데이트 된 건 fpl api로부터 받아온 데이터에서 삭제된 선수 + private String status; + + private String teamName; // 소속팀 영어명 + + private String teamKrName; // 소속팀 한글명 + + private UUID elementTypeId; // 포지션 pk값 + + private String elementTypePluralName; // 포지션 영어명 + + private String elementTypeKrName; // 포지션 한글명 + + /** + * PlayerDto 리스트 → PlayerEsDocument 리스트 변환 + */ + public static List toDocumentList(List playerDtoList) { + return playerDtoList.stream().map(p -> PlayerEsDocument.builder() + .id(p.getId()) + .webName(p.getWebName()) + .krName(p.getKrName()) + .pic(p.getPic()) + .status(p.getStatus()) + + // team관련 설정 + .teamName(p.getTeamName()) + .teamKrName(p.getTeamKrName()) + + // 포지션(elementType) 관련 설정 + .elementTypeId(p.getElementTypeId()) + .elementTypePluralName(p.getElementTypePluralName()) + .elementTypeKrName(p.getElementTypeKrName()) + .build() + ).collect(Collectors.toList()); + } + +} \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java new file mode 100644 index 0000000..f48385d --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/cache/service/PlayerCacheService.java @@ -0,0 +1,56 @@ +package likelion.mlb.backendProject.domain.player.cache.service; + +import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto; +import likelion.mlb.backendProject.domain.player.elasticsearch.service.PlayerEsService; +import likelion.mlb.backendProject.domain.player.entity.Player; +import likelion.mlb.backendProject.domain.player.repository.PlayerRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class PlayerCacheService { + + private final PlayerRepository playerRepository; + private final PlayerEsService playerEsService; + private final RedisTemplate redisTemplate; + private static final String PLAYER_CACHE_KEY = "players:all"; + private static final long CACHE_TTL_SECONDS = 7200; // 2시간 + + /** + * DB에서 선수 리스트를 조회하여 Redis에 DTO 형태로 저장 (TTL 적용) + */ + public void loadPlayersToRedis() { + List players = playerRepository.findAllWithTeamAndElementType(); + List playerDtoList = Player.toDtoList(players); + redisTemplate.opsForValue().set(PLAYER_CACHE_KEY, playerDtoList, CACHE_TTL_SECONDS, TimeUnit.SECONDS); + + // Elasticsearch에도 저장 + playerEsService.saveAll(PlayerDto.toDocumentList(playerDtoList)); + } + + /** + * Redis에서 DTO 리스트 조회 (캐시 미스 시 DB 조회 후 저장) + */ + public List getPlayersFromRedis() { + Object cached = redisTemplate.opsForValue().get(PLAYER_CACHE_KEY); + + // 1. redis에 저장 된 데이터 가져오기 + if (cached instanceof List) { + @SuppressWarnings("unchecked") + List playerDtoCacheList = (List) cached; + return playerDtoCacheList; + } + // 2. 캐시 미스 → DB 조회 + Redis 저장 + List playerList = playerRepository.findAllWithTeamAndElementType(); + List playerDtoList = Player.toDtoList(playerList); + redisTemplate.opsForValue().set(PLAYER_CACHE_KEY, playerDtoList, CACHE_TTL_SECONDS, TimeUnit.SECONDS); + return playerDtoList; + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/controller/ElementTypeController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/controller/ElementTypeController.java new file mode 100644 index 0000000..c54a14e --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/controller/ElementTypeController.java @@ -0,0 +1,29 @@ +package likelion.mlb.backendProject.domain.player.controller; + + +import likelion.mlb.backendProject.domain.player.dto.ElementTypeDto; +import likelion.mlb.backendProject.domain.player.dto.PreviousBestPlayerDto; +import likelion.mlb.backendProject.domain.player.service.ElementTypeService; +import likelion.mlb.backendProject.domain.player.service.PlayerService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/elementType") +public class ElementTypeController { + + private final ElementTypeService elementTypeService; + + @GetMapping("/all") + public ResponseEntity> getAllElementType() { + List elementTypeDtos = elementTypeService.getAllElementType(); + return new ResponseEntity<>(elementTypeDtos, HttpStatus.OK); + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/dto/ElementTypeDto.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/dto/ElementTypeDto.java new file mode 100644 index 0000000..32ef016 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/dto/ElementTypeDto.java @@ -0,0 +1,23 @@ +package likelion.mlb.backendProject.domain.player.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.UUID; + +@Data +@Builder +public class ElementTypeDto { + + private UUID id; + + //fpl의 각 포지션별 id + private Integer fplId; + + //ex) Goalkeepers, Defenders, fpl에서 받아오는 영어 이름 + private String pluralName; + + // 선수 포지션 한글명 + private String krName = ""; + +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java new file mode 100644 index 0000000..7a851d8 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/controller/PlayerEsController.java @@ -0,0 +1,51 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.controller; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; +import likelion.mlb.backendProject.domain.player.elasticsearch.service.PlayerEsService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("api/playerEs") +public class PlayerEsController { + + private final PlayerEsService playerEsService; + + // elasticsearch 검색 결과를 json List로 반환 + @GetMapping("/search") + public ResponseEntity> searchWithKeyword( + @RequestParam(value = "keyword", defaultValue = "") String keyword + , @RequestParam(value = "elementTypeId", defaultValue = "") String elementTypeId + ) { + + return ResponseEntity.ok(playerEsService.search(keyword, elementTypeId)); + } + + /* + @GetMapping("/searchWithKeyword") + public ResponseEntity> searchWithKeyword( + @RequestParam(value = "keyword", defaultValue = "") String keyword) { + + return ResponseEntity.ok(playerEsService.searchWithKeyword(keyword)); + } + + // elasticsearch 검색 결과를 json List로 반환 + @GetMapping("/searchWithElementTypeId") + public ResponseEntity> searchWithElementTypeId( + @RequestParam(value = "elementTypeId", defaultValue = "") String elementTypeId) { + + return ResponseEntity.ok(playerEsService.searchWithElementTypeId(elementTypeId)); + } + */ +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java new file mode 100644 index 0000000..f42eb02 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/dto/PlayerEsDocument.java @@ -0,0 +1,48 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.elasticsearch.annotations.Document; + +import java.util.UUID; + +/** +* 엘라스틱서치에 적용 될 문서를 자바 객체로 정의한 클래스 + * 엘라스틱 전용 DTO +* */ + +@JsonIgnoreProperties(ignoreUnknown = true) +@Document(indexName = "player-index") // 인덱스명 +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PlayerEsDocument { + + @Id + private UUID id; // player pk값 + + private String webName; // player 영어 이름 + + private String krName; // player 한글 이름 + + private String pic; // player 사진 + + //a: 뛸 수 있음, d: 출전 불투명, i: 부상, s: 징계, u: 사용불(임대 등), n: 자격 없음(미등록 선수) + //x로 업데이트 된 건 fpl api로부터 받아온 데이터에서 삭제된 선수 + private String status; + + private String teamName; // 소속팀 영어명 + + private String teamKrName; // 소속팀 한글명 + + private UUID elementTypeId; // 포지션 pk값 + + private String elementTypePluralName; // 포지션 영어명 + + private String elementTypeKrName; // 포지션 한글명 +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java new file mode 100644 index 0000000..a4e6667 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/repository/PlayerEsRepository.java @@ -0,0 +1,11 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.repository; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PlayerEsRepository extends ElasticsearchRepository { + +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java new file mode 100644 index 0000000..95f2862 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/elasticsearch/service/PlayerEsService.java @@ -0,0 +1,165 @@ +package likelion.mlb.backendProject.domain.player.elasticsearch.service; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.*; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; + +import likelion.mlb.backendProject.domain.player.elasticsearch.dto.PlayerEsDocument; +import likelion.mlb.backendProject.domain.player.elasticsearch.repository.PlayerEsRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PlayerEsService { + + // 엘라스틱서치에 명령을 전달하는 자바 API + private final ElasticsearchClient elasticsearchClient; + + private final PlayerEsRepository playerEsRepository; + + // 단건 데이터 저장 메서드 + public void save(PlayerEsDocument document) { + playerEsRepository.save(document); + } + + // 복수 데이터 저장 메서드 + public void saveAll(List documentList) { + playerEsRepository.saveAll(documentList); + } + + // 데이터 삭제 메서드 + public void deleteById(String id) { + playerEsRepository.deleteById(id); + } + + // 검색 키워드를 받아서 엘라스틱서치에서 검색하는 메서드 + public List search(String keyword, String elementTypeId) { + + try { + // 엘라스틱서치에서 사용할 검색 조건을 담는 객체 + Query query; + + // 포지션과 검색어가 없으면 모든 문서를 검색하는 matchAll쿼리 + if ((keyword == null || keyword.isBlank()) && (elementTypeId == null || elementTypeId.isBlank())) { + query = MatchAllQuery.of(m->m)._toQuery(); // 전체 문서를 가져오는 쿼리를 생성하는 람다 함수 + } + // 검색어가 있을 때 + else { + // BoolQuery는 복수 조건을 조합할 때 사용하는 쿼리 + // 이 쿼리 안에서 여러 개의 조건을 나열 + query = BoolQuery.of(b ->{ + + // keyword 조건이 있으면 should + if (keyword != null && !keyword.isBlank()) { + b.should(MatchQuery.of(m -> m.field("webName.ngram").query(keyword))._toQuery()); + b.should(MatchQuery.of(m -> m.field("krName.ngram").query(keyword))._toQuery()); + b.minimumShouldMatch("1"); // keyword 조건 중 최소 1개는 만족해야 함 + } + + // elementTypeId 조건이 있으면 must + if (elementTypeId != null && !elementTypeId.isBlank()) { + b.must(TermQuery.of(t -> t.field("elementTypeId.keyword").value(elementTypeId))._toQuery()); + } + + return b; + })._toQuery(); + } + // searchRequest는 엘라스틱서치에서 검색을 하기 위한 검색요청 객체 + // 인덱스명, 쿼리를 포함한 검색 요청 + SearchRequest request = SearchRequest.of(s->s + .index("player-index") + + // 한 번에 최대 700개 데이터 가져올 수 있음 + // 화면에서 페이징을 안 쓰기 때문에 scroll혹은 search_after방식은 적합 X + .size(700) + .query(query) + ); + + // SearchResponse는 엘라스틱서치의 검색 결과를 담고 있는 응답 객체 + SearchResponse response = + // 엘라스틱서치에 명령을 전달하는 자바 API검색 요청을 담아서 응답 객체로 반환 + elasticsearchClient.search(request, PlayerEsDocument.class); + + // 위 응답객체에서 받은 검색 결과 중 문서만 추출해서 리스트로 만듬 + // hit는 엘라스틱서치에서 검색 된 문서 1개를 감싸고 있는 객체 + List content = response.hits() // 엘라스틱 서치 응답에서 hits(문서 검색결과) 전체를 꺼냄 + .hits() // 검색 결과 안에 개별 리스트를 가져옴 + .stream() // 자바 stream api를 사용 + .map(Hit::source) // 각 Hit 객체에서 실제 문서를 꺼내는 작업 + .collect(Collectors.toList()); // 위에서 꺼낸 객체를 자바 List에 넣음 + + return content; + } catch (IOException ioException) { + log.error("검색 오류 ", ioException.getMessage()); + throw new RuntimeException("검색 오류 중 발생 ", ioException); + } + + } + + // 선수 포지션 pk값 받아서 엘라스틱서치에서 검색하는 메서드 + /* + public List searchWithElementTypeId(String elementTypeId) { + + try { + // 엘라스틱서치에서 사용할 검색 조건을 담는 객체 + Query query; + + // 검색어가 없으면 모든 문서를 검색하는 matchAll쿼리 + if (elementTypeId == null || elementTypeId.isBlank()) { + query = MatchAllQuery.of(m->m)._toQuery(); // 전체 문서를 가져오는 쿼리를 생성하는 람다 함수 + } + // 검색어가 있을 때 + else { + // BoolQuery는 복수 조건을 조합할 때 사용하는 쿼리 + // 이 쿼리 안에서 여러 개의 조건을 나열 + query = BoolQuery.of(b ->{ + + // MatchQuery는 해당 단어가 포함되어있는 지 검사하는 쿼리 + b.should(MatchQuery.of(m->m.field("elementTypeId.ngram").query(elementTypeId))._toQuery()); + + return b; + })._toQuery(); + } + // searchRequest는 엘라스틱서치에서 검색을 하기 위한 검색요청 객체 + // 인덱스명, 쿼리를 포함한 검색 요청 + SearchRequest request = SearchRequest.of(s->s + .index("player-index") + .query(query) + ); + + // SearchResponse는 엘라스틱서치의 검색 결과를 담고 있는 응답 객체 + SearchResponse response = + // 엘라스틱서치에 명령을 전달하는 자바 API검색 요청을 담아서 응답 객체로 반환 + elasticsearchClient.search(request, PlayerEsDocument.class); + + // 위 응답객체에서 받은 검색 결과 중 문서만 추출해서 리스트로 만듬 + // hit는 엘라스틱서치에서 검색 된 문서 1개를 감싸고 있는 객체 + List content = response.hits() // 엘라스틱 서치 응답에서 hits(문서 검색결과) 전체를 꺼냄 + .hits() // 검색 결과 안에 개별 리스트를 가져옴 + .stream() // 자바 stream api를 사용 + .map(Hit::source) // 각 Hit 객체에서 실제 문서를 꺼내는 작업 + .collect(Collectors.toList()); // 위에서 꺼낸 객체를 자바 List에 넣음 + + return content; + } catch (IOException ioException) { + log.error("검색 오류 ", ioException.getMessage()); + throw new RuntimeException("검색 오류 중 발생 ", ioException); + } + + } + */ + +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java index a3695e7..62a02bf 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/ElementType.java @@ -1,6 +1,10 @@ package likelion.mlb.backendProject.domain.player.entity; import jakarta.persistence.*; +import likelion.mlb.backendProject.domain.draft.dto.DraftResponse; +import likelion.mlb.backendProject.domain.player.dto.ElementTypeDto; +import likelion.mlb.backendProject.domain.player.dto.PreviousBestPlayerDto; +import likelion.mlb.backendProject.domain.player.entity.live.PlayerFixtureStat; import likelion.mlb.backendProject.global.staticdata.dto.bootstrap.FplElementType; import lombok.AllArgsConstructor; import lombok.Builder; @@ -10,6 +14,7 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Entity @Table(name = "element_type") @@ -62,4 +67,14 @@ public static List elementTypeBuilder(List fplEleme } return ets; } + + public static List toDtoList(List elementTypes) { + return elementTypes.stream().map(e -> ElementTypeDto.builder() + .id(e.getId()) // 선수 포지션 pk값 + .fplId(e.getFplId()) // fpl의 각 포지션별 id + .pluralName(e.getPluralName()) //ex) Goalkeepers, Defenders, fpl에서 받아오는 영어 이름 + .krName(e.getKrName()) // 선수 포지션 한글명 + .build() + ).collect(Collectors.toList()); + } } \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java index 760f333..4a99d9e 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/entity/Player.java @@ -1,6 +1,7 @@ package likelion.mlb.backendProject.domain.player.entity; import jakarta.persistence.*; +import likelion.mlb.backendProject.domain.player.cache.dto.PlayerDto; import likelion.mlb.backendProject.domain.team.entity.Team; import likelion.mlb.backendProject.global.jpa.entity.BaseTime; import likelion.mlb.backendProject.global.staticdata.dto.bootstrap.FplElement; @@ -13,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; @Entity @Table(name = "player") @@ -148,5 +150,29 @@ public void updatePlayer(FplElement element, this.team = teamMap.get(element.getTeam()); this.elementType = typeMap.get(element.getElementType()); } + + /** + * Player 엔티티 리스트 → PlayerDto 리스트 변환 + */ + public static List toDtoList(List players) { + return players.stream().map(p -> PlayerDto.builder() + .id(p.getId()) + .webName(p.getWebName()) + .krName(p.getKrName()) + .pic(p.getPic()) + .status(p.getStatus()) + + // team관련 설정 + .teamName(p.getTeam().getName()) + .teamKrName(p.getTeam().getKrName()) + + // 포지션(elementType) 관련 설정 + .elementTypeId(p.getElementType().getId()) + .elementTypePluralName(p.getElementType().getPluralName()) + .elementTypeKrName(p.getElementType().getKrName()) + .build() + ).collect(Collectors.toList()); + } + } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/service/ElementTypeService.java b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/service/ElementTypeService.java new file mode 100644 index 0000000..cc93ab3 --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/domain/player/service/ElementTypeService.java @@ -0,0 +1,23 @@ +package likelion.mlb.backendProject.domain.player.service; + + +import likelion.mlb.backendProject.domain.player.dto.ElementTypeDto; +import likelion.mlb.backendProject.domain.player.entity.ElementType; +import likelion.mlb.backendProject.domain.player.repository.ElementTypeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ElementTypeService { + + private final ElementTypeRepository elementTypeRepository; + + public List getAllElementType(){ + List elementTypes = elementTypeRepository.findAll(); + + return ElementType.toDtoList(elementTypes); + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/AuthHandshakeHandler.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/AuthHandshakeHandler.java new file mode 100644 index 0000000..b993bdd --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/AuthHandshakeHandler.java @@ -0,0 +1,48 @@ +package likelion.mlb.backendProject.global.configuration; + +import likelion.mlb.backendProject.global.security.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; + +import java.security.Principal; +import java.util.Map; + +@RequiredArgsConstructor +public class AuthHandshakeHandler extends DefaultHandshakeHandler { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected Principal determineUser(ServerHttpRequest request, + WebSocketHandler wsHandler, + Map attributes) { + + // 1) 세션/쿠키 기반: SecurityContext에 이미 인증이 있으면 그걸 그대로 사용 + Authentication ctxAuth = SecurityContextHolder.getContext().getAuthentication(); + if (ctxAuth != null && ctxAuth.isAuthenticated() + && !(ctxAuth instanceof AnonymousAuthenticationToken)) { + return ctxAuth; // Authentication 자체가 Principal + } + + // 2) JWT 기반: 쿼리 파라미터 token 로 들어온 경우 검증 후 Authentication 생성 + if (request instanceof ServletServerHttpRequest servlet) { + String token = servlet.getServletRequest().getParameter("token"); + if (token != null && !token.isBlank()) { + if (token.startsWith("Bearer ")) token = token.substring(7); + if (jwtTokenProvider.validateToken(token)) { + Authentication auth = jwtTokenProvider.getAuthentication(token); // 너희 구현 그대로 사용 + return auth; // Authentication 반환 → session.getPrincipal()로 접근 가능 + } + } + } + + // 인증 정보가 없으면 null → 이후 핸들러에서 거절 + return null; + } +} \ No newline at end of file diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java index ec528ad..d9dc768 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/KafkaConfig.java @@ -15,25 +15,25 @@ @Configuration public class KafkaConfig { - @Bean - public ProducerFactory producerFactory() { - Map props = new HashMap<>(); - props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, - "${spring.kafka.bootstrap-servers}"); - props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, - StringSerializer.class); - // JsonSerializer 설정: ChatMessageEvent를 자동 직렬화 - props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, - JsonSerializer.class); - // 타입 헤더 없이 순수 JSON만 보내고 싶으면 아래 옵션 추가 - props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); - - return new DefaultKafkaProducerFactory<>(props); - } - - @Bean - public KafkaTemplate kafkaTemplate( - ProducerFactory pf) { - return new KafkaTemplate<>(pf); - } +// @Bean +// public ProducerFactory producerFactory() { +// Map props = new HashMap<>(); +// props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, +// "${spring.kafka.bootstrap-servers}"); +// props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, +// StringSerializer.class); +// // JsonSerializer 설정: ChatMessageEvent를 자동 직렬화 +// props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, +// JsonSerializer.class); +// // 타입 헤더 없이 순수 JSON만 보내고 싶으면 아래 옵션 추가 +// props.put(JsonSerializer.ADD_TYPE_INFO_HEADERS, false); +// +// return new DefaultKafkaProducerFactory<>(props); +// } +// +// @Bean +// public KafkaTemplate kafkaTemplate( +// ProducerFactory pf) { +// return new KafkaTemplate<>(pf); +// } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java index ec796de..5dcdb0b 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/MatchWebSocketConfig.java @@ -1,7 +1,9 @@ package likelion.mlb.backendProject.global.configuration; import likelion.mlb.backendProject.domain.match.handler.MatchHandler; +import likelion.mlb.backendProject.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; @@ -13,10 +15,15 @@ public class MatchWebSocketConfig implements WebSocketConfigurer { private final MatchHandler matchHandler; + private final JwtTokenProvider jwtTokenProvider; + + @Value("${frontend.http.url:${FRONTEND_HTTP_URL:http://localhost:5173}}") + private String frontendHttpUrl; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(matchHandler, "/ws/match") // ✅ 분리된 경로 - .setAllowedOrigins("*"); + registry.addHandler(matchHandler, "/ws/match") + .setHandshakeHandler(new AuthHandshakeHandler(jwtTokenProvider)) // ✅ 여기 한 줄 + .setAllowedOrigins(frontendHttpUrl); } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java index 74e7f03..40e1975 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/configuration/RedisConfig.java @@ -13,6 +13,7 @@ import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -41,11 +42,17 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); - // 문자열 직렬화 설정 (key, value 모두) + /* + * 문자열 직렬화 설정 (key, value 모두) -> value (역)직렬화 설정은 문자열이 아닌 Object로 변경 + * new StringRedisSerializer() -> new GenericJackson2JsonRedisSerializer() + * */ template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new StringRedisSerializer()); +// template.setValueSerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new StringRedisSerializer()); +// template.setHashValueSerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return template; } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java index 15858e7..a91b76f 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/exception/ErrorCode.java @@ -26,6 +26,10 @@ public enum ErrorCode { /* 404 - Not Found */ USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "해당 사용자를 찾을 수 없습니다."), ROUND_NOT_FOUND(HttpStatus.NOT_FOUND, "ROUND_NOT_FOUND", "시작 예정 라운드를 찾을 수 없습니다."), + ASSIGNMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "ASSIGNMENT_NOT_FOUND", "배정된 드래프트가 없습니다."), + + /* 409 - CONFLICT */ + ASSIGNMENT_CONFLICT(HttpStatus.CONFLICT, "ASSIGNMENT_CONFLICT", "중복 배정이 감지되었습니다."), CHATROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "CHATROOM_NOT_FOUND", "채팅방을 찾을 수 없습니다."), DRAFT_NOT_FOUND(HttpStatus.NOT_FOUND, "DRAFT_NOT_FOUND", "드래프트방을 찾을 수 없습니다."), PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND, "PARTICIPANT_NOT_FOUND", "참가자를 찾을 수 없습니다."), diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/health/HealthController.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/health/HealthController.java new file mode 100644 index 0000000..c0d4b5b --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/health/HealthController.java @@ -0,0 +1,13 @@ +package likelion.mlb.backendProject.global.health; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + @GetMapping("/health") + public ResponseEntity healthCheck() { + return ResponseEntity.ok("ok"); + } +} diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java index 998fc21..d6c0b9e 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/config/SecurityConfig.java @@ -36,8 +36,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers("/**").permitAll() + .requestMatchers("/api/auth/**", "/oauth2/**", "/login.html").permitAll() + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers("/login.html", "/login-success.html").permitAll() .requestMatchers("/api/**", "/oauth2/**", "/login.html").permitAll() .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers("/health").permitAll() .requestMatchers("/ws/**").permitAll() .requestMatchers( "/swagger-ui/**", diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java index 86e8c03..4bb75d3 100644 --- a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/jwt/JwtTokenProvider.java @@ -11,6 +11,8 @@ import java.util.Date; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Component; @Component @@ -22,6 +24,8 @@ public class JwtTokenProvider { private Key key; + private final UserDetailsService userDetailsService; + private static final long ACCESS_TOKEN_VALID_TIME = 60 * 60 * 1000L; // 1시간 private static final long REFRESH_TOKEN_VALID_TIME = 60 * 60 * 24 * 14 * 1000L; // 14일 @@ -64,4 +68,12 @@ public String getEmail(String token) { .getSubject(); } + // ✅ 추가: 토큰 → Authentication + public Authentication getAuthentication(String token) { + String email = getEmail(token); + var userDetails = userDetailsService.loadUserByUsername(email); // CustomUserDetails 기대 + return new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + } } diff --git a/backendProject/src/main/java/likelion/mlb/backendProject/global/security/oauth/CustomUserDetailsService.java b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/oauth/CustomUserDetailsService.java new file mode 100644 index 0000000..7d73d3e --- /dev/null +++ b/backendProject/src/main/java/likelion/mlb/backendProject/global/security/oauth/CustomUserDetailsService.java @@ -0,0 +1,22 @@ +package likelion.mlb.backendProject.global.security.oauth; + +import likelion.mlb.backendProject.domain.user.repository.UserRepository; +import likelion.mlb.backendProject.global.security.dto.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + var user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); + return new CustomUserDetails(user); // 네 프로젝트의 CustomUserDetails + } +} \ No newline at end of file diff --git a/backendProject/src/main/resources/application-dev.example.properties b/backendProject/src/main/resources/application-dev.example.properties deleted file mode 100644 index 06c110c..0000000 --- a/backendProject/src/main/resources/application-dev.example.properties +++ /dev/null @@ -1,40 +0,0 @@ -# Redis enviroments -redis.host= -redis.port= - -# Elasticsearch -es.uri= - -# Database ?? -db.server= -db.port= -db.name= -db.username= -db.password= - -# PostgreSQL ?? -spring.datasource.url= -spring.datasource.driverClassName= -spring.datasource.username= -spring.datasource.password= -spring.jpa.database-platform= -spring.jpa.properties.hibernate.globally_quoted_identifiers= - -# Redis -spring.data.redis.host= -spring.data.redis.port= - -# WebSocket -# ?: WebSocket ?????, ?? ?? ? ? - -# Swagger ?? ?? ?? ???? -# springdoc.api-docs.enabled= -# springdoc.swagger-ui.enabled= - -# Elasticsearch -spring.elasticsearch.uris= - -jwt.secret = jwt_token - -FRONTEND_HTTP_URL= -FRONTEND_HTTPS_URL= \ No newline at end of file diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 0000000..ecf3712 --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,139 @@ +version: '3.8' + +services: + + api1: + build: + context: ./backendProject + container_name: mlb-api-1 + ports: + - "8080:8080" + env_file: + - .env + depends_on: + mlb-redis1: + condition: service_healthy + mlb-elasticsearch: + condition: service_healthy + mlb-kafka: + condition: service_healthy + networks: + - backend + + api2: + build: + context: ./backendProject + container_name: mlb-api-2 + ports: + - "8081:8080" + env_file: + - .env + depends_on: + mlb-redis1: + condition: service_healthy + mlb-elasticsearch: + condition: service_healthy + mlb-kafka: + condition: service_healthy + networks: + - backend + + # Redis - 3 인스턴스 + mlb-redis1: + image: redis:7.2 + container_name: mlb-redis1 + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - backend + + mlb-redis2: + image: redis:7.2 + container_name: mlb-redis2 + ports: + - "6380:6379" + networks: + - backend + + mlb-redis3: + image: redis:7.2 + container_name: mlb-redis3 + ports: + - "6381:6379" + networks: + - backend + + # Elasticsearch + mlb-elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4 + container_name: mlb-elasticsearch + environment: + - node.name=es01 + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.security.transport.ssl.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m + ports: + - "9200:9200" + - "9300:9300" + volumes: + - es_data:/usr/share/elasticsearch/data + healthcheck: + # TCP 열림 확인(의존 패키지 없이 동작) + test: ["CMD-SHELL", "bash -lc 'echo > /dev/tcp/127.0.0.1/9200'"] + interval: 10s + timeout: 3s + retries: 30 + networks: + - backend + + # Zookeeper (for Kafka) + mlb-zookeeper: + image: confluentinc/cp-zookeeper:7.4.1 + container_name: mlb-zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + ports: + - "2181:2181" + healthcheck: + test: ["CMD-SHELL", "bash -lc 'echo > /dev/tcp/127.0.0.1/2181'"] + interval: 10s + timeout: 3s + retries: 30 + networks: + - backend + + # Kafka + mlb-kafka: + image: confluentinc/cp-kafka:7.4.1 + container_name: mlb-kafka + depends_on: + - mlb-zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: mlb-zookeeper:2181 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://mlb-kafka:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + healthcheck: + test: ["CMD-SHELL", "bash -lc 'echo > /dev/tcp/127.0.0.1/9092'"] + interval: 10s + timeout: 3s + retries: 30 + networks: + - backend + +volumes: + es_data: + +networks: + backend: + driver: bridge