Skip to content

Commit 26565aa

Browse files
authored
AIM-29-웹소켓을-활용한-스톱워치-기능-구현 (#26)
* fix: SecurityConfig 비로그인 시에도 챌린지 목록 조회 가능하도록 수정 * feat: 웹소켓 타이머 DTO 추가 * feat: 챌린지 타이머 실행 세션 메모리 관리자 구현 * feat: STOMP WebSocket CONNECT 단계 JWT 인증 인터셉터 구현 * feat: STOMP WebSocket 사용자 식별용 Principal 도입 * feat: STOMP WebSocket 기본 설정 구성 * feat: WebSocket 의존성 추가 * feat: STOMP CONNECT 시 JWT 기반 사용자 인증 처리 추가 * feat: 챌린지 타이머 서비스 로직 구현 * feat: 챌린지 타이머 WebSocket 컨트롤러 구현 * docs: WebSocket 타이머 Swagger 문서화 및 에러 처리 RestException 통일
1 parent f4537fd commit 26565aa

File tree

12 files changed

+457
-0
lines changed

12 files changed

+457
-0
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ dependencies {
7474
implementation 'dev.langchain4j:langchain4j:1.0.0-beta3'
7575
implementation 'dev.langchain4j:langchain4j-spring-boot-starter:1.0.0-beta3'
7676
implementation 'dev.langchain4j:langchain4j-google-ai-gemini:1.0.0-beta3'
77+
78+
// WebSocket + STOMP
79+
implementation 'org.springframework.boot:spring-boot-starter-websocket'
7780
}
7881

7982
tasks.named('test') {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package targeter.aim.domain.challenge.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.tags.Tag;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.messaging.handler.annotation.DestinationVariable;
7+
import org.springframework.messaging.handler.annotation.MessageMapping;
8+
import org.springframework.messaging.handler.annotation.Payload;
9+
import org.springframework.messaging.simp.SimpMessagingTemplate;
10+
import org.springframework.stereotype.Controller;
11+
import org.springframework.web.bind.annotation.PostMapping;
12+
import org.springframework.web.bind.annotation.RequestBody;
13+
import targeter.aim.domain.challenge.dto.TimerDto;
14+
import targeter.aim.domain.challenge.entity.Challenge;
15+
import targeter.aim.domain.challenge.repository.ChallengeRepository;
16+
import targeter.aim.domain.challenge.service.ChallengeTimerService;
17+
import targeter.aim.domain.user.entity.User;
18+
import targeter.aim.domain.user.repository.UserRepository;
19+
import targeter.aim.system.exception.model.ErrorCode;
20+
import targeter.aim.system.exception.model.RestException;
21+
22+
import java.security.Principal;
23+
import java.time.LocalDateTime;
24+
import java.util.Map;
25+
26+
@Controller
27+
@RequiredArgsConstructor
28+
@Tag(name = "Timer WebSocket Docs")
29+
public class ChallengeTimerWebSocketController {
30+
31+
private final ChallengeTimerService challengeTimerService;
32+
private final ChallengeRepository challengeRepository;
33+
private final UserRepository userRepository;
34+
private final SimpMessagingTemplate messagingTemplate;
35+
36+
@MessageMapping("/challenge/{challengeId}/timer")
37+
public void handleTimerAction(
38+
@DestinationVariable Long challengeId,
39+
@Payload TimerDto.TimerActionRequest request,
40+
Principal principal
41+
) {
42+
43+
try {
44+
Long userId = Long.valueOf(principal.getName());
45+
46+
if (userId == null) {
47+
throw new RestException(ErrorCode.AUTH_LOGIN_REQUIRED);
48+
}
49+
50+
Challenge challenge = challengeRepository.findById(challengeId)
51+
.orElseThrow(() -> new RestException(ErrorCode.CHALLENGE_NOT_FOUND));
52+
53+
User user = userRepository.findById(userId)
54+
.orElseThrow(() -> new RestException(ErrorCode.USER_NOT_FOUND));
55+
56+
switch (request.getAction()) {
57+
case "START" -> handleStart(challenge, user);
58+
case "STOP" -> handleStop(challenge, user);
59+
default -> throw new RestException(ErrorCode.CHALLENGE_INVALID_TIMER_ACTION);
60+
}
61+
62+
} catch (RestException e) {
63+
64+
messagingTemplate.convertAndSend(
65+
"/sub/challenge/" + challengeId,
66+
Map.of(
67+
"status", "ERROR",
68+
"code", e.getErrorCode().name(),
69+
"message", e.getErrorCode().getMessage()
70+
)
71+
);
72+
}
73+
}
74+
75+
private void handleStart(Challenge challenge, User user) {
76+
77+
LocalDateTime startedAt =
78+
challengeTimerService.startTimer(challenge, user);
79+
80+
TimerDto.TimerUpdateResponse response =
81+
TimerDto.TimerUpdateResponse.builder()
82+
.senderId(user.getId())
83+
.status("ON")
84+
.startedAt(startedAt)
85+
.message("상대방이 챌린지를 시작했습니다.")
86+
.build();
87+
88+
messagingTemplate.convertAndSend(
89+
"/sub/challenge/" + challenge.getId(),
90+
response
91+
);
92+
}
93+
94+
private void handleStop(Challenge challenge, User user) {
95+
96+
long accumulatedTime =
97+
challengeTimerService.stopTimer(challenge, user);
98+
99+
messagingTemplate.convertAndSend(
100+
"/sub/challenge/" + challenge.getId(),
101+
TimerDto.TimerUpdateResponse.builder()
102+
.senderId(user.getId())
103+
.status("OFF")
104+
.accumulatedTime((int) accumulatedTime)
105+
.message("상대방이 챌린지를 종료했습니다.")
106+
.build()
107+
);
108+
}
109+
110+
@Operation(
111+
summary = "챌린지 타이머 WebSocket 명세 (Swagger 문서용)",
112+
description = """
113+
WebSocket(STOMP) 기반 챌린지 타이머 제어 명세
114+
115+
[WebSocket Endpoint]
116+
ws://{host}/ws-stomp
117+
118+
[Publish]
119+
/pub/challenge/{challengeId}/timer
120+
121+
[Subscribe]
122+
/sub/challenge/{challengeId}
123+
124+
※ 본 API는 Swagger 문서화를 위한 dummy 엔드포인트입니다.
125+
"""
126+
)
127+
@PostMapping("/docs/websocket/timer")
128+
public TimerDto.TimerUpdateResponse timerWebSocketDoc(
129+
@RequestBody TimerDto.TimerActionRequest request
130+
) {
131+
return null; // Swagger 문서용
132+
}
133+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package targeter.aim.domain.challenge.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
import java.time.LocalDateTime;
10+
11+
public class TimerDto {
12+
13+
@Getter
14+
@NoArgsConstructor
15+
@AllArgsConstructor
16+
@Schema(description = "타이머 액션 요청 DTO")
17+
public static class TimerActionRequest {
18+
19+
@Schema(description = "타이머 액션", example = "START", allowableValues = { "START", "STOP" })
20+
private String action;
21+
22+
}
23+
24+
@Getter
25+
@Builder
26+
@NoArgsConstructor
27+
@AllArgsConstructor
28+
@Schema(description = "타이머 상태 업데이트 응답 DTO")
29+
public static class TimerUpdateResponse {
30+
31+
@Schema(description = "이벤트 발생 사용자 ID", example = "1")
32+
private Long senderId;
33+
34+
@Schema(description = "타이머 상태", example = "ON | OFF")
35+
private String status;
36+
37+
@Schema(description = "타이머 시작 시간", example =
38+
"2026-01-15T04:22:37.184284")
39+
private LocalDateTime startedAt;
40+
41+
@Schema(description = "누적 시간 (초 단위)", example = "120")
42+
private Integer accumulatedTime;
43+
44+
@Schema(description = "상태 메시지", example = "상대방이 챌린지를 시작했습니다.")
45+
private String message;
46+
}
47+
48+
}

src/main/java/targeter/aim/domain/challenge/entity/WeeklyProgress.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,13 @@ public class WeeklyProgress extends TimeStampedEntity {
4444
@Column(name = "is_complete", nullable = false)
4545
private Boolean isComplete;
4646

47+
public void complete() {
48+
this.isComplete = true;
49+
}
50+
51+
public boolean isComplete() {
52+
return Boolean.TRUE.equals(this.isComplete);
53+
}
54+
4755

4856
}

src/main/java/targeter/aim/domain/challenge/repository/ChallengeMemberRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
import targeter.aim.domain.challenge.entity.Challenge;
55
import targeter.aim.domain.challenge.entity.ChallengeMember;
66
import targeter.aim.domain.challenge.entity.ChallengeMemberId;
7+
import targeter.aim.domain.user.entity.User;
78

89
import java.util.List;
910

1011
public interface ChallengeMemberRepository extends JpaRepository<ChallengeMember, ChallengeMemberId> {
1112

1213
List<ChallengeMember> findAllById_Challenge(Challenge challenge);
14+
15+
boolean existsById_ChallengeAndId_User(Challenge challenge, User user);
1316
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package targeter.aim.domain.challenge.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
import targeter.aim.domain.challenge.entity.Challenge;
8+
import targeter.aim.domain.challenge.entity.WeeklyProgress;
9+
import targeter.aim.domain.challenge.repository.ChallengeMemberRepository;
10+
import targeter.aim.domain.challenge.repository.WeeklyProgressRepository;
11+
import targeter.aim.domain.challenge.timer.ChallengeRunningSessionManager;
12+
import targeter.aim.domain.user.entity.User;
13+
14+
import java.time.LocalDate;
15+
import java.time.LocalDateTime;
16+
import java.time.temporal.ChronoUnit;
17+
18+
@Slf4j
19+
@Service
20+
@RequiredArgsConstructor
21+
public class ChallengeTimerService {
22+
23+
private final ChallengeRunningSessionManager sessionManager;
24+
private final WeeklyProgressRepository weeklyProgressRepository;
25+
private final ChallengeMemberRepository memberRepository;
26+
27+
// 현재 주차 계산
28+
public int calculateCurrentWeek(Challenge challenge) {
29+
LocalDate startDate = challenge.getStartedAt();
30+
LocalDate today = LocalDate.now();
31+
32+
long days = ChronoUnit.DAYS.between(startDate, today);
33+
int week = (int) (days / 7) + 1;
34+
35+
if (week < 1) {
36+
return 1;
37+
}
38+
if (week > challenge.getDurationWeek()) {
39+
return challenge.getDurationWeek();
40+
}
41+
42+
return week;
43+
}
44+
45+
@Transactional
46+
public LocalDateTime startTimer(Challenge challenge, User user) {
47+
48+
log.error("START user={} challenge={} week={}",
49+
user.getId(),
50+
challenge.getId(),
51+
calculateCurrentWeek(challenge)
52+
);
53+
54+
int weekNumber = calculateCurrentWeek(challenge);
55+
56+
if (!memberRepository.existsById_ChallengeAndId_User(challenge, user)) {
57+
throw new IllegalStateException("챌린지 참가자가 아닙니다.");
58+
}
59+
60+
WeeklyProgress progress =
61+
weeklyProgressRepository
62+
.findByChallengeAndUserAndWeekNumber(challenge, user, weekNumber)
63+
.orElseThrow(() -> new IllegalStateException("주차 기록이 없습니다."));
64+
65+
if (progress.isComplete()) {
66+
throw new IllegalStateException("이미 완료된 주차 챌린지입니다.");
67+
}
68+
69+
if (sessionManager.isRunning(challenge.getId(), user.getId())) {
70+
throw new IllegalStateException("이미 실행 중입니다.");
71+
}
72+
73+
return sessionManager.start(challenge.getId(), user.getId());
74+
}
75+
76+
@Transactional
77+
public long stopTimer(Challenge challenge, User user) {
78+
79+
int weekNumber = calculateCurrentWeek(challenge);
80+
81+
if (!memberRepository.existsById_ChallengeAndId_User(challenge, user)) {
82+
throw new IllegalStateException("챌린지 참가자가 아닙니다.");
83+
}
84+
85+
WeeklyProgress progress =
86+
weeklyProgressRepository
87+
.findByChallengeAndUserAndWeekNumber(challenge, user, weekNumber)
88+
.orElseThrow(() -> new IllegalStateException("주차 기록이 없습니다."));
89+
90+
if (progress.isComplete()) {
91+
throw new IllegalStateException("이미 완료된 주차 챌린지입니다.");
92+
}
93+
94+
long elapsedSeconds = sessionManager.stop(challenge.getId(), user.getId());
95+
96+
progress.setStopwatchTimeSeconds((int) elapsedSeconds);
97+
progress.complete();
98+
99+
return progress.getStopwatchTimeSeconds();
100+
}
101+
102+
}
103+
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package targeter.aim.domain.challenge.timer;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
import java.time.Duration;
6+
import java.time.LocalDateTime;
7+
import java.util.concurrent.ConcurrentHashMap;
8+
9+
@Component
10+
public class ChallengeRunningSessionManager {
11+
12+
private final ConcurrentHashMap<String, LocalDateTime> runningSessions =
13+
new ConcurrentHashMap<>();
14+
15+
public LocalDateTime start(Long challengeId, Long userId) {
16+
String key = key(challengeId, userId);
17+
LocalDateTime startedAt = LocalDateTime.now();
18+
19+
if (runningSessions.putIfAbsent(key, startedAt) != null) {
20+
throw new IllegalStateException("이미 실행 중인 타이머입니다.");
21+
}
22+
23+
return startedAt;
24+
}
25+
26+
public long stop(Long challengeId, Long userId) {
27+
String key = key(challengeId, userId);
28+
29+
LocalDateTime startedAt = runningSessions.remove(key);
30+
if (startedAt == null) {
31+
throw new IllegalStateException("이미 정지된 타이머입니다.");
32+
}
33+
34+
long seconds = Duration.between(startedAt, LocalDateTime.now()).getSeconds();
35+
36+
return seconds;
37+
}
38+
39+
public boolean isRunning(Long challengeId, Long userId) {
40+
return runningSessions.containsKey(key(challengeId, userId));
41+
}
42+
43+
private String key(Long challengeId, Long userId) {
44+
return challengeId + ":" + userId;
45+
}
46+
}

0 commit comments

Comments
 (0)