Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
603c53f
[feat/#589] 안 읽은 메시지 조회 API 구현 (#619)
Dimo-2562 Jun 14, 2026
8cbecab
[refactor/#600] ExerciseQueryService 유스케이스별 서비스 분리 (#623)
Dimo-2562 Jun 16, 2026
bb17ad8
[refactor/#626] 채팅 REST/WebSocket presentation 계층 정리 (#627)
Dimo-2562 Jun 16, 2026
bd915a9
[chore/#628] 쿼리 카운트 테스트 인프라 구축 (#634)
kanghana1 Jun 17, 2026
030431b
[refactor/#630] Bookmark 도메인 N+1 제거 (#636)
kanghana1 Jun 19, 2026
bf3cd66
[refactor/#638] 운동 참여 인원 수 조회 로직 공통화 (#639)
Dimo-2562 Jun 23, 2026
903cecd
[feat/#625] 채팅 unread status WebSocket 이벤트 발행 (#637)
Dimo-2562 Jun 23, 2026
081d094
[debug/#611] 프로필 동시성 처리 문제 해결 (#642)
kanghana1 Jun 29, 2026
bc87518
[refactor/#624] 채팅 읽음 처리 쿼리 배치화 (#641)
Dimo-2562 Jun 29, 2026
098f73f
[refactor/#632] 안 읽은 알림 조회 최적화 (#645)
kanghana1 Jul 1, 2026
a89bf21
[refactor/#631] 메달 개수 쿼리 최적화 (#644)
kanghana1 Jul 1, 2026
3981bf5
[chore/#582] 부하테스트용 FCM 인위적 지연 주입 (#652)
kanghana1 Jul 5, 2026
ec7b164
[feat/#647] 로깅 인프라 구축 (#649)
Dimo-2562 Jul 5, 2026
1b4dac7
[refactor/#633] 회원 프로필 조회 최적화 (#646)
kanghana1 Jul 6, 2026
1abd02f
[refactor/#582] FCM과 채팅 스레드풀 분리 (#653)
kanghana1 Jul 6, 2026
8cb3442
[refactor/#650] JWT 토큰 무효화 체계 및 RT 재사용 탐지 도입 (#651)
kanghana1 Jul 6, 2026
6c7e74a
[feat/#654] Staging 계층 모니터링(OTLP) 도입 (#655)
kanghana1 Jul 7, 2026
b9b7113
[refactor/#656] 도메인 값 SSOT 외부화 (#657)
kanghana1 Jul 12, 2026
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
16 changes: 15 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ jobs:
envs: >-
DB_PASSWORD,GCS_BUCKET,GCS_BACKUP_BUCKET,
KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,KAKAO_REDIRECT_URI_PROD,KAKAO_REDIRECT_URI_STAGING,KAKAO_ADMIN_KEY,
JWT_SECRET_KEY,FIREBASE_SERVICE_ACCOUNT_KEY
JWT_SECRET_KEY,FIREBASE_SERVICE_ACCOUNT_KEY,FCM_FAKE_LATENCY_MS,
OTLP_METRICS_ENABLED,OTEL_URL,OTEL_AUTH,OTLP_STEP,
CORS_ALLOWED_ORIGINS_PROD,CORS_ALLOWED_ORIGINS_STAGING,COOKIE_DOMAIN,NGINX_PROD_SERVER_NAME,NGINX_STAGING_SERVER_NAME
script: |
chmod +x /home/ubuntu/cockple/scripts/deploy.sh
bash /home/ubuntu/cockple/scripts/deploy.sh \
Expand All @@ -89,3 +91,15 @@ jobs:
KAKAO_ADMIN_KEY: ${{ secrets.KAKAO_ADMIN_KEY }}
JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }}
FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }}
FCM_FAKE_LATENCY_MS: ${{ vars.FCM_FAKE_LATENCY_MS }}
# 관측성(OTLP) — URL/토글/step은 Variable, 인증 토큰은 Secret
OTLP_METRICS_ENABLED: ${{ vars.OTLP_METRICS_ENABLED }}
OTEL_URL: ${{ vars.OTEL_URL }}
OTEL_AUTH: ${{ secrets.OTEL_AUTH }}
OTLP_STEP: ${{ vars.OTLP_STEP }}
# 도메인(SSOT) — 비밀 아님 → Variables. 미설정 시 빈 값 → docker-compose 기본값(현재 도메인) 사용(no-op).
CORS_ALLOWED_ORIGINS_PROD: ${{ vars.CORS_ALLOWED_ORIGINS_PROD }}
CORS_ALLOWED_ORIGINS_STAGING: ${{ vars.CORS_ALLOWED_ORIGINS_STAGING }}
COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }}
NGINX_PROD_SERVER_NAME: ${{ vars.NGINX_PROD_SERVER_NAME }}
NGINX_STAGING_SERVER_NAME: ${{ vars.NGINX_STAGING_SERVER_NAME }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,7 @@ src/main/resources/firebase/*.json
cockple_dump.sql

k6

### logs ###
logs/
/result-bulkhead-*.json
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ Nearest `AGENTS.md` wins. Read this file first, then the closest child file for
|------|----------|-------|
| App bootstrap | `src/main/java/umc/cockple/demo/Application.java` | Enables JPA auditing and caching |
| Runtime config | `src/main/resources/application*.yml` | `local` is default; `staging` and `prod` override DB/Redis |
| Security/websocket ingress | `src/main/java/umc/cockple/demo/global/config/` | JWT, CORS, WebSocket handler registration |
| Security ingress | `src/main/java/umc/cockple/demo/global/config/` | HTTP security, JWT filters, CORS, shared runtime config |
| Chat WebSocket ingress | `src/main/java/umc/cockple/demo/domain/chat/presentation/websocket/` | `/ws/chats` handler, auth interceptor, endpoint config |
| Business APIs | `src/main/java/umc/cockple/demo/domain/` | Vertical slices by feature |
| Exercise hotspot | `src/main/java/umc/cockple/demo/domain/exercise/` | Largest converter/query/test surface |
| Chat hotspot | `src/main/java/umc/cockple/demo/domain/chat/` | REST + WebSocket + cache/event flow |
Expand All @@ -48,13 +49,13 @@ Nearest `AGENTS.md` wins. Read this file first, then the closest child file for

## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated QueryDSL Q-types.
- Do not widen security whitelist or CORS origins casually; both are explicit in `SecurityConfig` and `WebSocketConfig`.
- Do not widen security whitelist or CORS origins casually; HTTP origins are explicit in `SecurityConfig` and chat WebSocket origins are explicit in `ChatWebSocketConfig`.
- Do not rely on JPA auto-DDL for schema work; startup uses validation only.
- Do not scatter test fixtures inside feature test packages; shared fixtures already live under `src/test/java/umc/cockple/demo/support/fixture/`.
- Do not assume everything under `global/` is generic; JWT/OAuth code is coupled to member/auth flows.

## UNIQUE STYLES
- `domain/chat` has extra realtime sublayers: `handler/`, `interceptor/`, `events/`, `service/websocket/`.
- `domain/chat` has extra realtime sublayers: `presentation/websocket/`, `events/`, `service/websocket/`.
- `domain/exercise` is the densest slice: large converter, query service, command internals, and the biggest integration tests.
- `domain/party` and `domain/notification` use events/notification wiring more than simpler slices like `bookmark` or `terms`.

Expand Down
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ dependencies {
// flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

// observability (Actuator + OTLP 메트릭 export)
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-otlp'
}

tasks.named('test') {
Expand Down
22 changes: 21 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ services:
environment:
JAVA_TOOL_OPTIONS: "-Xms768m -Xmx768m"
SPRING_PROFILES_ACTIVE: prod
LOG_PATH: /logs
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS_PROD:-http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.cockple.store}
DB_PASSWORD: ${DB_PASSWORD}
GCS_BUCKET: ${GCS_BUCKET}
KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID}
Expand All @@ -64,6 +67,8 @@ services:
KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY}
volumes:
- ./logs/prod:/logs
depends_on:
mysql:
condition: service_healthy
Expand All @@ -79,6 +84,15 @@ services:
environment:
JAVA_TOOL_OPTIONS: "-Xms128m -Xmx512m"
SPRING_PROFILES_ACTIVE: staging
LOG_PATH: /logs
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS_STAGING:-http://localhost:5173,https://cockple.store,https://www.cockple.store,https://staging.cockple.store,https://cockple-fe.vercel.app,https://cockple-fe.vercel.app/}
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.cockple.store}
FCM_FAKE_LATENCY_MS: ${FCM_FAKE_LATENCY_MS:-0}
# 관측성(Observability) — OTLP 메트릭 push (staging 전용). 미설정 시 off.
OTLP_METRICS_ENABLED: ${OTLP_METRICS_ENABLED:-false}
OTEL_URL: ${OTEL_URL:-}
OTEL_AUTH: ${OTEL_AUTH:-}
OTLP_STEP: ${OTLP_STEP:-1m}
DB_PASSWORD: ${DB_PASSWORD}
GCS_BUCKET: ${GCS_BUCKET}
KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID}
Expand All @@ -87,6 +101,8 @@ services:
KAKAO_ADMIN_KEY: ${KAKAO_ADMIN_KEY}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
FIREBASE_SERVICE_ACCOUNT_KEY: ${FIREBASE_SERVICE_ACCOUNT_KEY}
volumes:
- ./logs/staging:/logs
depends_on:
mysql:
condition: service_healthy
Expand All @@ -101,9 +117,13 @@ services:
restart: always
ports:
- "80:80"
environment:
PROD_SERVER_NAME: ${NGINX_PROD_SERVER_NAME:-api.cockple.store}
STAGING_SERVER_NAME: ${NGINX_STAGING_SERVER_NAME:-staging.cockple.store}
NGINX_ENVSUBST_FILTER: SERVER_NAME
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/templates:/etc/nginx/templates:ro
mem_limit: 64m
memswap_limit: 128m

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
server {
listen 80;
server_name api.cockple.store;
server_name ${PROD_SERVER_NAME};

location /ws/ {
proxy_pass http://cockple-app:8080;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
server {
listen 80;
server_name staging.cockple.store;
server_name ${STAGING_SERVER_NAME};

location /ws/ {
proxy_pass http://cockple-app-staging:8080;
Expand Down
12 changes: 12 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,22 @@ KAKAO_REDIRECT_URI_STAGING=${KAKAO_REDIRECT_URI_STAGING}
KAKAO_ADMIN_KEY=${KAKAO_ADMIN_KEY}
JWT_SECRET_KEY=${JWT_SECRET_KEY}
FIREBASE_SERVICE_ACCOUNT_KEY=${FIREBASE_SERVICE_ACCOUNT_KEY}
FCM_FAKE_LATENCY_MS=${FCM_FAKE_LATENCY_MS:-0}
OTLP_METRICS_ENABLED=${OTLP_METRICS_ENABLED:-false}
OTEL_URL=${OTEL_URL:-}
OTEL_AUTH=${OTEL_AUTH:-}
OTLP_STEP=${OTLP_STEP:-1m}
CORS_ALLOWED_ORIGINS_PROD=${CORS_ALLOWED_ORIGINS_PROD:-}
CORS_ALLOWED_ORIGINS_STAGING=${CORS_ALLOWED_ORIGINS_STAGING:-}
COOKIE_DOMAIN=${COOKIE_DOMAIN:-}
NGINX_PROD_SERVER_NAME=${NGINX_PROD_SERVER_NAME:-}
NGINX_STAGING_SERVER_NAME=${NGINX_STAGING_SERVER_NAME:-}
EOF

echo "${FIREBASE_SERVICE_ACCOUNT_KEY}" > /home/ubuntu/cockple/firebase-service-account.json

mkdir -p /home/ubuntu/cockple/logs/prod /home/ubuntu/cockple/logs/staging

echo "=== 배포 전 상태 ==="
sudo docker ps

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
public class BookmarkConverter {

public GetAllExerciseBookmarksResponseDTO exerciseBookmarkToDTO(ExerciseBookmark bookmark,
boolean includeParty, boolean includeExercise) {
boolean includeParty, boolean includeExercise,
int nowMemberCnt) {
Exercise exercise = bookmark.getExercise();

return GetAllExerciseBookmarksResponseDTO.builder()
Expand All @@ -34,7 +35,7 @@ public GetAllExerciseBookmarksResponseDTO exerciseBookmarkToDTO(ExerciseBookmark
.startExerciseTime(exercise.getStartTime())
.endExerciseTime(exercise.getEndTime())
.maxMemberCnt(exercise.getMaxCapacity())
.nowMemberCnt(exercise.getNowCapacity())
.nowMemberCnt(nowMemberCnt)
.includeParty(includeParty)
.includeExercise(includeExercise)
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ public interface ExerciseBookmarkRepository extends JpaRepository<ExerciseBookma

List<ExerciseBookmark> findAllByMember(Member member);

@Query("""
SELECT DISTINCT eb
FROM ExerciseBookmark eb
JOIN FETCH eb.exercise e
JOIN FETCH e.party p
LEFT JOIN FETCH e.exerciseAddr
LEFT JOIN FETCH p.partyImg
LEFT JOIN FETCH p.chatRoom
LEFT JOIN FETCH p.levels
WHERE eb.member = :member
""")
List<ExerciseBookmark> findAllByMemberWithDetails(@Param("member") Member member);


@Query("""
SELECT eb.exercise.id FROM ExerciseBookmark eb
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public interface PartyBookmarkRepository extends JpaRepository<PartyBookmark, Lo
SELECT DISTINCT pb
FROM PartyBookmark pb
JOIN FETCH pb.party p
LEFT JOIN FETCH p.partyAddr
LEFT JOIN FETCH p.partyImg
LEFT JOIN FETCH p.chatRoom
LEFT JOIN FETCH p.exercises
WHERE pb.member = :member
""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository;
import umc.cockple.demo.domain.bookmark.repository.PartyBookmarkRepository;
import umc.cockple.demo.domain.exercise.domain.Exercise;
import umc.cockple.demo.domain.exercise.service.query.lookup.ExerciseParticipantCountLookupService;
import umc.cockple.demo.domain.file.service.FileService;
import umc.cockple.demo.domain.member.domain.Member;
import umc.cockple.demo.domain.member.exception.MemberErrorCode;
Expand All @@ -28,8 +29,11 @@
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

@Service
@Transactional(readOnly = true)
Expand All @@ -41,6 +45,7 @@ public class BookmarkQueryService {
private final PartyBookmarkRepository partyBookmarkRepository;
private final MemberPartyRepository memberPartyRepository;
private final MemberExerciseRepository memberExerciseRepository;
private final ExerciseParticipantCountLookupService exerciseParticipantCountLookupService;
private final MemberRepository memberRepository;
private final BookmarkConverter bookmarkConverter;
private final FileService fileService;
Expand All @@ -50,7 +55,7 @@ public List<GetAllExerciseBookmarksResponseDTO> getAllExerciseBookmarks(Long mem
Member member = findByMemberId(memberId);

// 찜한 운동 가져오기
List<ExerciseBookmark> bookmarks = exerciseBookmarkRepository.findAllByMember(member);
List<ExerciseBookmark> bookmarks = exerciseBookmarkRepository.findAllByMemberWithDetails(member);

// orderType에 따른 정렬
Comparator<ExerciseBookmark> comparator = Comparator.comparing(ExerciseBookmark::getCreatedAt);
Expand All @@ -61,20 +66,25 @@ public List<GetAllExerciseBookmarksResponseDTO> getAllExerciseBookmarks(Long mem
List<Long> partyIds = bookmarks.stream().map(b -> b.getExercise().getParty().getId()).toList();
List<Long> exerciseIds = bookmarks.stream().map(b -> b.getExercise().getId()).toList();

List<Long> myParties = memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(memberId, partyIds);
List<Long> myExercises = memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(memberId, exerciseIds);
Set<Long> myParties = new HashSet<>(memberPartyRepository.findAllPartyIdsByMemberAndPartyIds(memberId, partyIds));
Set<Long> myExercises = new HashSet<>(memberExerciseRepository.findAllExerciseIdsByMemberAndExerciseIds(memberId, exerciseIds));

// exercise 도메인의 공통 참여 인원수 조회 로직 사용 (N+1 제거)
Map<Long, Integer> nowCntByExerciseId = exerciseParticipantCountLookupService
.getParticipantCountsByExerciseIds(exerciseIds);

// bookmark -> dto 변환
return bookmarks.stream()
.map(bookmark -> {
Long exerciseId = bookmark.getExercise().getId();
boolean includeParty = myParties.contains(bookmark.getExercise().getParty().getId());
boolean includeExercise = myExercises.contains(bookmark.getExercise().getId());
return bookmarkConverter.exerciseBookmarkToDTO(bookmark, includeParty, includeExercise);
boolean includeExercise = myExercises.contains(exerciseId);
int nowMemberCnt = nowCntByExerciseId.getOrDefault(exerciseId, 0);
return bookmarkConverter.exerciseBookmarkToDTO(bookmark, includeParty, includeExercise, nowMemberCnt);
})
.toList();
}


public List<GetAllPartyBookmarkResponseDTO> getAllPartyBookmarks(Long memberId, PartyOrderType orderType) {
// 회원 조회하기
Member member = findByMemberId(memberId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package umc.cockple.demo.domain.bookmark.service.query.lookup;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import umc.cockple.demo.domain.bookmark.repository.ExerciseBookmarkRepository;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ExerciseBookmarkLookupService {

private final ExerciseBookmarkRepository exerciseBookmarkRepository;

public Map<Long, Boolean> getBookmarkStatus(Long memberId, List<Long> exerciseIds) {
if (exerciseIds.isEmpty()) {
return Collections.emptyMap();
}

List<Long> bookmarkedExerciseIds = exerciseBookmarkRepository
.findAllExerciseIdsByMemberIdAndExerciseIds(memberId, exerciseIds);
Set<Long> bookmarkedExerciseIdSet = new HashSet<>(bookmarkedExerciseIds);

return exerciseIds.stream()
.collect(Collectors.toMap(
exerciseId -> exerciseId,
bookmarkedExerciseIdSet::contains
));
}
}
12 changes: 8 additions & 4 deletions src/main/java/umc/cockple/demo/domain/chat/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ Chat mixes REST queries with WebSocket transport, Redis-backed subscription/cach
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| WebSocket ingress | `handler/ChatWebSocketHandler.java` | handles connect/message/close/error |
| Auth for sockets | `interceptor/` | JWT auth is enforced before handler logic |
| WebSocket ingress | `presentation/websocket/ChatWebSocketHandler.java` | handles connect/message/close/error |
| Auth for sockets | `presentation/websocket/` | JWT auth is enforced before handler logic |
| WebSocket dispatch | `presentation/websocket/ChatWebSocketRequestDispatcher.java` | parses socket payloads and delegates authenticated requests |
| WebSocket commands | `presentation/websocket/ChatWebSocketCommandHandler.java` | handles socket request types and publishes command events |
| WebSocket responses | `presentation/websocket/WebSocketResponseSender.java` | serializes and sends socket responses |
| WebSocket response assembly | `converter/ChatWebSocketResponseAssembler.java` | shapes socket response payloads |
| Realtime services | `service/websocket/` | subscription, room list cache, message fanout |
| Read/query flows | `service/ChatQueryServiceImpl.java` | room lists, unread counts, history |
| Events | `events/` | send/subscription events bridge transport and async handlers |
| DTO conversion | `converter/ChatConverter.java` | shapes REST/socket payloads |
| DTO conversion | `converter/ChatConverter.java` | shapes REST/API and common message payloads |

## CONVENTIONS
- `WebSocketConfig` registers `/ws/chats` and wires the JWT interceptor.
- `presentation/websocket/ChatWebSocketConfig` registers `/ws/chats` and wires the JWT interceptor.
- Request `type()` drives socket branching: send, subscribe, unsubscribe, and chat-list variants.
- Party chat and direct chat share the slice but differ in display-name/image/read-status logic.
- Room list freshness depends on `service/websocket/ChatRoomListCacheService`.
Expand Down
Loading
Loading