Skip to content

Commit fee0f68

Browse files
✨ feat : 채팅 정답 처리 동시성 제어 (#170)
* 🔧 chore : dev 기준으로 rebase 후 변경사항 반영 * ✅ test : 정답 처리 동시성 제어 * chore: Java 스타일 수정 --------- Co-authored-by: github-actions <>
1 parent ecdd66d commit fee0f68

File tree

5 files changed

+475
-2
lines changed

5 files changed

+475
-2
lines changed

backend/src/main/java/io/f1/backend/domain/game/app/ChatService.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
public class ChatService {
2121

2222
private final RoomService roomService;
23-
private final TimerService timerService;
2423
private final MessageSender messageSender;
2524
private final ApplicationEventPublisher eventPublisher;
2625

@@ -41,7 +40,12 @@ public void chat(Long roomId, UserPrincipal userPrincipal, ChatMessage chatMessa
4140

4241
String answer = currentQuestion.getAnswer();
4342

44-
if (answer.equals(chatMessage.message())) {
43+
if (!answer.equals(chatMessage.message())) {
44+
return;
45+
}
46+
47+
// false -> true
48+
if (room.compareAndSetAnsweredFlag(false, true)) {
4549
eventPublisher.publishEvent(
4650
new GameCorrectAnswerEvent(
4751
room, userPrincipal.getUserId(), chatMessage, answer));

backend/src/main/java/io/f1/backend/domain/game/app/GameService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ public void onCorrectAnswer(GameCorrectAnswerEvent event) {
123123
ofPlayerEvent(chatMessage.nickname(), RoomEventType.CORRECT_ANSWER));
124124

125125
timerService.cancelTimer(room);
126+
room.compareAndSetAnsweredFlag(true, false);
126127

127128
if (!timerService.validateCurrentRound(room)) {
128129
gameEnd(room);
@@ -142,6 +143,12 @@ public void onCorrectAnswer(GameCorrectAnswerEvent event) {
142143
@EventListener
143144
public void onTimeout(GameTimeoutEvent event) {
144145
Room room = event.room();
146+
147+
// false -> true 여야 하는데 실패했을 때 => 이미 정답 처리가 된 경우 (onCorrectAnswer 로직 실행 중)
148+
if (!room.compareAndSetAnsweredFlag(false, true)) {
149+
return;
150+
}
151+
145152
log.debug(room.getId() + "번 방 타임아웃! 현재 라운드 : " + room.getCurrentRound());
146153

147154
String destination = getDestination(room.getId());
@@ -167,6 +174,8 @@ public void onTimeout(GameTimeoutEvent event) {
167174
destination,
168175
MessageType.QUESTION_START,
169176
toQuestionStartResponse(room, CONTINUE_DELAY));
177+
178+
room.compareAndSetAnsweredFlag(true, false);
170179
}
171180

172181
public void gameEnd(Room room) {

backend/src/main/java/io/f1/backend/domain/game/model/Room.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import java.util.concurrent.Executors;
1717
import java.util.concurrent.ScheduledExecutorService;
1818
import java.util.concurrent.ScheduledFuture;
19+
import java.util.concurrent.atomic.AtomicBoolean;
1920

2021
@Getter
2122
public class Room {
@@ -42,6 +43,8 @@ public class Room {
4243

4344
private ScheduledFuture<?> timer;
4445

46+
private final AtomicBoolean answered = new AtomicBoolean(false);
47+
4548
public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player host) {
4649
this.id = id;
4750
this.roomSetting = roomSetting;
@@ -194,4 +197,12 @@ public boolean isPlayerInState(Long userId, ConnectionState state) {
194197
public boolean isPasswordIncorrect(String password) {
195198
return roomSetting.locked() && !roomSetting.password().equals(password);
196199
}
200+
201+
public boolean compareAndSetAnsweredFlag(boolean expected, boolean newValue) {
202+
return answered.compareAndSet(expected, newValue);
203+
}
204+
205+
public AtomicBoolean getAnsweredFlag() {
206+
return this.answered;
207+
}
197208
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package io.f1.backend.domain.game.app;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.junit.jupiter.api.Assertions.*;
5+
import static org.mockito.ArgumentMatchers.any;
6+
import static org.mockito.BDDMockito.given;
7+
import static org.mockito.Mockito.mock;
8+
import static org.mockito.Mockito.never;
9+
import static org.mockito.Mockito.times;
10+
import static org.mockito.Mockito.verify;
11+
12+
import io.f1.backend.domain.game.dto.ChatMessage;
13+
import io.f1.backend.domain.game.event.GameCorrectAnswerEvent;
14+
import io.f1.backend.domain.game.model.GameSetting;
15+
import io.f1.backend.domain.game.model.Player;
16+
import io.f1.backend.domain.game.model.Room;
17+
import io.f1.backend.domain.game.model.RoomSetting;
18+
import io.f1.backend.domain.game.model.RoomState;
19+
import io.f1.backend.domain.game.websocket.MessageSender;
20+
import io.f1.backend.domain.question.entity.Question;
21+
import io.f1.backend.domain.user.dto.UserPrincipal;
22+
import io.f1.backend.domain.user.entity.User;
23+
24+
import lombok.extern.slf4j.Slf4j;
25+
26+
import org.junit.jupiter.api.BeforeEach;
27+
import org.junit.jupiter.api.DisplayName;
28+
import org.junit.jupiter.api.Test;
29+
import org.junit.jupiter.api.extension.ExtendWith;
30+
import org.mockito.ArgumentCaptor;
31+
import org.mockito.Mock;
32+
import org.mockito.MockitoAnnotations;
33+
import org.mockito.junit.jupiter.MockitoExtension;
34+
import org.springframework.context.ApplicationEventPublisher;
35+
import org.springframework.security.core.context.SecurityContextHolder;
36+
37+
import java.lang.reflect.Field;
38+
import java.time.Instant;
39+
import java.time.LocalDateTime;
40+
import java.util.Collections;
41+
import java.util.concurrent.CountDownLatch;
42+
import java.util.concurrent.ExecutorService;
43+
import java.util.concurrent.Executors;
44+
45+
@Slf4j
46+
@ExtendWith(MockitoExtension.class)
47+
class ChatServiceTests {
48+
49+
private ChatService chatService;
50+
51+
@Mock private RoomService roomService;
52+
@Mock private ApplicationEventPublisher eventPublisher;
53+
@Mock private MessageSender messageSender;
54+
55+
@BeforeEach
56+
void setUp() {
57+
MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다.
58+
59+
chatService = new ChatService(roomService, messageSender, eventPublisher);
60+
61+
SecurityContextHolder.clearContext();
62+
}
63+
64+
@Test
65+
@DisplayName("정답이 아닐 때 이벤트가 발행되지 않는다.")
66+
void noEventWhenIncorrect() throws Exception {
67+
68+
// given
69+
Long roomId = 1L;
70+
ChatMessage wrongMessage = new ChatMessage("뜨거운제티", "오답", Instant.now());
71+
72+
Room room = mock(Room.class);
73+
Question question = mock(Question.class);
74+
User user = createUser(1);
75+
UserPrincipal userPrincipal = new UserPrincipal(user, Collections.emptyMap());
76+
77+
given(roomService.findRoom(roomId)).willReturn(room);
78+
given(room.isPlaying()).willReturn(true);
79+
given(room.getCurrentQuestion()).willReturn(question);
80+
given(question.getAnswer()).willReturn("정답");
81+
82+
// when
83+
chatService.chat(roomId, userPrincipal, wrongMessage);
84+
85+
// then
86+
verify(eventPublisher, never()).publishEvent(any(GameCorrectAnswerEvent.class));
87+
}
88+
89+
@Test
90+
@DisplayName("정답일 때 GameCorrectAnswerEvent가 발행된다. ")
91+
void EventPublishedWhenCorrect() throws Exception {
92+
93+
// given
94+
Long roomId = 1L;
95+
ChatMessage answer = new ChatMessage("뜨거운제티", "정답", Instant.now());
96+
97+
Room room = mock(Room.class);
98+
Question question = mock(Question.class);
99+
User user = createUser(1);
100+
UserPrincipal userPrincipal = new UserPrincipal(user, Collections.emptyMap());
101+
102+
given(roomService.findRoom(roomId)).willReturn(room);
103+
given(room.isPlaying()).willReturn(true);
104+
given(room.getCurrentQuestion()).willReturn(question);
105+
given(question.getAnswer()).willReturn("정답");
106+
given(room.compareAndSetAnsweredFlag(false, true)).willReturn(true);
107+
108+
// when
109+
chatService.chat(roomId, userPrincipal, answer);
110+
111+
// then
112+
verify(eventPublisher, times(1)).publishEvent(any(GameCorrectAnswerEvent.class));
113+
}
114+
115+
@Test
116+
@DisplayName("동시에 여러 명의 사용자가 채팅을 보냈을 때, 한 명만 정답인정")
117+
void onlyOneCorrectPlayerWhenConcurrentCorrectAnswers() throws Exception {
118+
119+
// given
120+
Long roomId = 1L;
121+
Long quizId = 1L;
122+
Long playerId = 1L;
123+
int maxUserCount = 5;
124+
String password = "123";
125+
boolean locked = true;
126+
String correctAnswer = "정답";
127+
128+
Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked);
129+
Question question = mock(Question.class);
130+
room.updateRoomState(RoomState.PLAYING);
131+
room.increaseCurrentRound();
132+
room.updateQuestions(Collections.singletonList(question));
133+
134+
// room이 실제 객체이므로, RoomService만 mock으로 대체
135+
given(roomService.findRoom(roomId)).willReturn(room);
136+
given(question.getAnswer()).willReturn(correctAnswer);
137+
138+
int userCount = 8;
139+
ExecutorService executor = Executors.newFixedThreadPool(userCount);
140+
141+
CountDownLatch countDownLatch = new CountDownLatch(userCount);
142+
143+
ChatMessage msg = new ChatMessage("닉네임", correctAnswer, Instant.now());
144+
145+
for (int i = 0; i < userCount; i++) {
146+
final int idx = i;
147+
executor.submit(
148+
() -> {
149+
try {
150+
User user = createUser(idx);
151+
UserPrincipal principal =
152+
new UserPrincipal(user, Collections.emptyMap());
153+
chatService.chat(roomId, principal, msg);
154+
} finally {
155+
countDownLatch.countDown();
156+
}
157+
});
158+
}
159+
160+
countDownLatch.await();
161+
162+
// then: 이벤트는 단 1번만 발행돼야 함
163+
ArgumentCaptor<GameCorrectAnswerEvent> captor =
164+
ArgumentCaptor.forClass(GameCorrectAnswerEvent.class);
165+
verify(eventPublisher, times(1)).publishEvent(captor.capture());
166+
167+
GameCorrectAnswerEvent event = captor.getValue();
168+
log.info("정답 인정된 유저 ID : {}", event.userId());
169+
assertThat(event.userId()).isBetween(0L, 7L);
170+
171+
verify(eventPublisher, times(1)).publishEvent(any(GameCorrectAnswerEvent.class));
172+
assertThat(room.getAnswered().get()).isTrue();
173+
}
174+
175+
private Room createRoom(
176+
Long roomId,
177+
Long playerId,
178+
Long quizId,
179+
String password,
180+
int maxUserCount,
181+
boolean locked) {
182+
RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password);
183+
GameSetting gameSetting = new GameSetting(quizId, 10, 60);
184+
Player host = new Player(playerId, "nickname");
185+
186+
return new Room(roomId, roomSetting, gameSetting, host);
187+
}
188+
189+
private User createUser(int i) {
190+
Long userId = i + 1L;
191+
String provider = "provider +" + i;
192+
String providerId = "providerId" + i;
193+
LocalDateTime lastLogin = LocalDateTime.now();
194+
195+
User user =
196+
User.builder()
197+
.provider(provider)
198+
.providerId(providerId)
199+
.lastLogin(lastLogin)
200+
.build();
201+
202+
try {
203+
Field idField = User.class.getDeclaredField("id");
204+
idField.setAccessible(true);
205+
idField.set(user, userId);
206+
} catch (Exception e) {
207+
throw new RuntimeException("ID 설정 실패", e);
208+
}
209+
210+
return user;
211+
}
212+
}

0 commit comments

Comments
 (0)