From c9cf87cdc8328b562e1252129e3051bd3856a76b Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Fri, 13 Feb 2026 22:48:19 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=EB=A0=88=EB=94=94=EC=8A=A4?= =?UTF-8?q?=EC=99=80=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/build.gradle | 1 + backend/docker-compose.yml | 13 ++++++++++++- infra/prod/docker-compose.yml | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/api/build.gradle b/backend/api/build.gradle index 7290ca4b9..2b6f7b3bd 100644 --- a/backend/api/build.gradle +++ b/backend/api/build.gradle @@ -37,6 +37,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework:spring-webflux' implementation 'com.github.f4b6a3:uuid-creator:6.0.0' diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 4212660b7..a38752bf9 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -34,7 +34,18 @@ services: volumes: - minio-data:/data command: server /data --console-address ":9001" + + redis: + image: redis:7-alpine + container_name: episode-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis-data:/data + command: [ "redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}" ] + volumes: mysql-data: minio-data: - + redis-data: diff --git a/infra/prod/docker-compose.yml b/infra/prod/docker-compose.yml index 5501a6ff5..9a47b5960 100644 --- a/infra/prod/docker-compose.yml +++ b/infra/prod/docker-compose.yml @@ -26,6 +26,7 @@ services: ports: - "80:80" - "443:443" + - "443:443/udp" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data From 7f5943bbb7d2ba23d80f2756bcbd97ed38eca773 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Fri, 13 Feb 2026 23:40:56 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20redis=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/src/main/resources/application.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/api/src/main/resources/application.yml b/backend/api/src/main/resources/application.yml index e3727ae07..afe0c1aee 100644 --- a/backend/api/src/main/resources/application.yml +++ b/backend/api/src/main/resources/application.yml @@ -37,6 +37,12 @@ spring: resources: add-mappings: false + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + server: port: 8080 forward-headers-strategy: framework From 64318ba216f72ba8ef424f0b1667fa0c733eeffe Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Fri, 13 Feb 2026 23:42:01 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20redis=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../episode/global/config/RedisConfig.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/api/src/main/java/com/yat2/episode/global/config/RedisConfig.java diff --git a/backend/api/src/main/java/com/yat2/episode/global/config/RedisConfig.java b/backend/api/src/main/java/com/yat2/episode/global/config/RedisConfig.java new file mode 100644 index 000000000..585a14967 --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/global/config/RedisConfig.java @@ -0,0 +1,31 @@ +package com.yat2.episode.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Slf4j +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisBinaryTemplate( + RedisConnectionFactory connectionFactory + ) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + + template.setValueSerializer(RedisSerializer.byteArray()); + template.setHashValueSerializer(RedisSerializer.byteArray()); + + template.afterPropertiesSet(); + return template; + } +} From 727194eb6919b6e7fd1c27aacb6aefb79bc4244d Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Fri, 13 Feb 2026 23:42:26 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20RedisStreamStore=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collaboration/CollaborationService.java | 2 +- .../collaboration/RedisStreamRepository.java | 12 ------ .../collaboration/RedisStreamStore.java | 41 +++++++++++++++++++ 3 files changed, 42 insertions(+), 13 deletions(-) delete mode 100644 backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamRepository.java create mode 100644 backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java index c27678635..3094616b1 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java @@ -16,7 +16,7 @@ @Service public class CollaborationService { private final SessionRegistry sessionRegistry; - private final RedisStreamRepository redisStreamRepository; + private final RedisStreamStore redisStreamStore; public void handleConnect(WebSocketSession session) { sessionRegistry.addSession(getMindmapId(session), session); diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamRepository.java b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamRepository.java deleted file mode 100644 index e80f92a7d..000000000 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.yat2.episode.collaboration; - -import org.springframework.stereotype.Repository; - -import java.util.UUID; - -@Repository -public class RedisStreamRepository { - public void append(UUID mindmapId, byte[] payload) { - //TODO - } -} diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java new file mode 100644 index 000000000..7195f682c --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java @@ -0,0 +1,41 @@ +package com.yat2.episode.collaboration; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class RedisStreamStore { + + private final RedisTemplate redisBinaryTemplate; + + private static final String FIELD_UPDATE = "u"; + + private String streamKey(UUID roomId) { + return "collaboration:room:" + roomId; + } + + public RecordId appendUpdate(UUID roomId, byte[] update) { + String key = streamKey(roomId); + + StreamOperations ops = redisBinaryTemplate.opsForStream(); + + MapRecord record = + StreamRecords.newRecord().in(key).ofMap(Map.of(FIELD_UPDATE, update)); + + RecordId id = ops.add(record); + + redisBinaryTemplate.expire(key, Duration.ofDays(7)); + + return id; + } +} From e6f0d3332db49d0bbdc3a3d846dad8cdaf19a8f7 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sat, 14 Feb 2026 19:01:10 +0900 Subject: [PATCH 05/14] =?UTF-8?q?chore:=20redis=20key=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20ttl=20=EB=8B=A8=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yat2/episode/collaboration/RedisStreamStore.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java index 7195f682c..58775ee3e 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/RedisStreamStore.java @@ -21,7 +21,7 @@ public class RedisStreamStore { private static final String FIELD_UPDATE = "u"; private String streamKey(UUID roomId) { - return "collaboration:room:" + roomId; + return "collab:room:" + roomId; } public RecordId appendUpdate(UUID roomId, byte[] update) { @@ -34,7 +34,7 @@ public RecordId appendUpdate(UUID roomId, byte[] update) { RecordId id = ops.add(record); - redisBinaryTemplate.expire(key, Duration.ofDays(7)); + redisBinaryTemplate.expire(key, Duration.ofDays(2)); return id; } From 9ad0cd0f292c4538b54fab71918068d5267e07a1 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sat, 14 Feb 2026 23:33:18 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20stage=20=ED=99=98=EA=B2=BD=20redi?= =?UTF-8?q?s=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/stage/docker-compose.yml | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/infra/stage/docker-compose.yml b/infra/stage/docker-compose.yml index a24a286d0..f90298ce5 100644 --- a/infra/stage/docker-compose.yml +++ b/infra/stage/docker-compose.yml @@ -7,10 +7,15 @@ services: networks: - web healthcheck: - test: [ "CMD", "wget", "-qO-", "http://localhost:8080/actuator/health" ] + test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health" ] interval: 30s timeout: 5s retries: 3 + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy caddy: image: caddy:2.10.2-alpine @@ -59,6 +64,26 @@ services: volumes: - minio-data:/data command: server /data --console-address ":9001" + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] + interval: 30s + timeout: 20s + retries: 3 + networks: + - web + + redis: + image: redis:7-alpine + container_name: episode-redis + restart: unless-stopped + volumes: + - redis-data:/data + command: [ "redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}" ] + healthcheck: + test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping" ] + interval: 10s + timeout: 5s + retries: 5 networks: - web @@ -71,3 +96,4 @@ volumes: caddy_config: mysql-data: minio-data: + redis-data: From f99814de9d23e6cab000c9eeec6f5161b04dd5a0 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sun, 15 Feb 2026 01:55:00 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20update=20=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=9E=84=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20redis=20append?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collaboration/CollaborationService.java | 24 +++++++++++++++---- .../collaboration/SessionRegistry.java | 2 ++ .../collaboration/YjsProtocolUtil.java | 20 ++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 backend/api/src/main/java/com/yat2/episode/collaboration/YjsProtocolUtil.java diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java index 3094616b1..f50d5a7cc 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java @@ -23,19 +23,33 @@ public void handleConnect(WebSocketSession session) { } public void processMessage(WebSocketSession sender, BinaryMessage message) { - ByteBuffer buffer = message.getPayload(); - byte[] payload = new byte[buffer.remaining()]; - buffer.get(payload); + UUID roomId = getMindmapId(sender); - sessionRegistry.broadcast(getMindmapId(sender), sender, payload); + byte[] payload = toByteArray(message.getPayload()); + + sessionRegistry.broadcast(roomId, sender, payload); + + if (YjsProtocolUtil.isUpdateFrame(payload)) { + try { + redisStreamStore.appendUpdate(roomId, payload); + } catch (Exception e) { + log.error("Error while appending update frame to redis. roomId={}", roomId, e); + } + } } public void handleDisconnect(WebSocketSession session) { - //TODO: Collaboration room 세션 수 0일때 스냅샷 트리거 sessionRegistry.removeSession(getMindmapId(session), session); } private UUID getMindmapId(WebSocketSession session) { return (UUID) session.getAttributes().get(AttributeKeys.MINDMAP_ID); } + + private byte[] toByteArray(ByteBuffer buffer) { + ByteBuffer dup = buffer.duplicate(); + byte[] bytes = new byte[dup.remaining()]; + dup.get(bytes); + return bytes; + } } diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java b/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java index 1b61aee7d..2fac5daeb 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java @@ -1,6 +1,7 @@ package com.yat2.episode.collaboration; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.WebSocketSession; @@ -17,6 +18,7 @@ @RequiredArgsConstructor @Component +@Slf4j public class SessionRegistry { private final Map> rooms = new ConcurrentHashMap<>(); private final WebSocketProperties wsProperties; diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/YjsProtocolUtil.java b/backend/api/src/main/java/com/yat2/episode/collaboration/YjsProtocolUtil.java new file mode 100644 index 000000000..c380d7a8b --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/YjsProtocolUtil.java @@ -0,0 +1,20 @@ +package com.yat2.episode.collaboration; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class YjsProtocolUtil { + public static final int MSG_SYNC = 0; + + /* 추후 sync 라우팅을 위함 */ + public static final int SYNC_STEP_1 = 0; + public static final int SYNC_STEP_2 = 1; + public static final int SYNC_UPDATE = 2; + + public static boolean isUpdateFrame(byte[] payload) { + if (payload == null || payload.length < 3) { + return false; + } + return payload[0] == MSG_SYNC && payload[1] == SYNC_UPDATE; + } +} From 7ebee7ebd32e08f9c4d2e9cca9c99b19fcf72880 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sun, 15 Feb 2026 02:01:38 +0900 Subject: [PATCH 08/14] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/yat2/episode/collaboration/SessionRegistry.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java b/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java index 2fac5daeb..1b61aee7d 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/SessionRegistry.java @@ -1,7 +1,6 @@ package com.yat2.episode.collaboration; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.WebSocketSession; @@ -18,7 +17,6 @@ @RequiredArgsConstructor @Component -@Slf4j public class SessionRegistry { private final Map> rooms = new ConcurrentHashMap<>(); private final WebSocketProperties wsProperties; From 558f681314643b8a089ca9d6e2a1b0ca33411602 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sun, 15 Feb 2026 02:14:19 +0900 Subject: [PATCH 09/14] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollaborationServiceTest.java | 157 ++++++++++++++++++ .../collaboration/SessionRegistryTest.java | 150 +++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java create mode 100644 backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java diff --git a/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java new file mode 100644 index 000000000..fdec1eec2 --- /dev/null +++ b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java @@ -0,0 +1,157 @@ +package com.yat2.episode.collaboration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import com.yat2.episode.global.constant.AttributeKeys; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CollaborationServiceTest { + + @Mock + SessionRegistry sessionRegistry; + + @Mock + RedisStreamStore redisStreamStore; + + CollaborationService service; + + @BeforeEach + void setUp() { + service = new CollaborationService(sessionRegistry, redisStreamStore); + } + + @Test + void handleConnect_addsSessionToRoom() { + UUID roomId = UUID.randomUUID(); + WebSocketSession session = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(session.getAttributes()).thenReturn(attrs); + + service.handleConnect(session); + + verify(sessionRegistry).addSession(roomId, session); + verifyNoMoreInteractions(sessionRegistry); + } + + @Test + void handleDisconnect_removesSessionFromRoom() { + UUID roomId = UUID.randomUUID(); + WebSocketSession session = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(session.getAttributes()).thenReturn(attrs); + + service.handleDisconnect(session); + + verify(sessionRegistry).removeSession(roomId, session); + verifyNoMoreInteractions(sessionRegistry); + } + + @Test + void processMessage_alwaysBroadcasts() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 9, 9, 9 }; + BinaryMessage message = new BinaryMessage(frame); + + service.processMessage(sender, message); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(byte[].class); + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), payloadCaptor.capture()); + + assertArrayEquals(frame, payloadCaptor.getValue()); + verifyNoMoreInteractions(sessionRegistry); + } + + @Test + void processMessage_whenUpdateFrame_appendsToRedis() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 0, 2, 1, 2, 3, 4 }; + BinaryMessage message = new BinaryMessage(frame); + + service.processMessage(sender, message); + + ArgumentCaptor broadcastCaptor = ArgumentCaptor.forClass(byte[].class); + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), broadcastCaptor.capture()); + assertArrayEquals(frame, broadcastCaptor.getValue()); + + ArgumentCaptor redisCaptor = ArgumentCaptor.forClass(byte[].class); + verify(redisStreamStore).appendUpdate(eq(roomId), redisCaptor.capture()); + + assertArrayEquals(frame, redisCaptor.getValue()); + } + + @Test + void processMessage_whenNotUpdateFrame_doesNotAppendToRedis() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 0, 1, 9, 9 }; + BinaryMessage message = new BinaryMessage(frame); + + service.processMessage(sender, message); + + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + verifyNoInteractions(redisStreamStore); + } + + @Test + void processMessage_whenRedisThrows_doesNotCrash() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); + + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); + + byte[] frame = new byte[]{ 0, 2, 1, 2, 3 }; + BinaryMessage message = new BinaryMessage(frame); + + doThrow(new RuntimeException("redis down")).when(redisStreamStore).appendUpdate(eq(roomId), any(byte[].class)); + + assertThatCode(() -> service.processMessage(sender, message)).doesNotThrowAnyException(); + + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + verify(redisStreamStore).appendUpdate(eq(roomId), any(byte[].class)); + } +} diff --git a/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java b/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java new file mode 100644 index 000000000..9d4e9552b --- /dev/null +++ b/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java @@ -0,0 +1,150 @@ +package com.yat2.episode.collaboration; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.WebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.yat2.episode.collaboration.config.WebSocketProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SessionRegistryTest { + + SessionRegistry registry; + + WebSocketProperties wsProperties; + + @BeforeEach + void setUp() { + wsProperties = mock(WebSocketProperties.class); + when(wsProperties.sendTimeout()).thenReturn(1000); + when(wsProperties.bufferSize()).thenReturn(1024 * 1024); + + registry = new SessionRegistry(wsProperties); + } + + @SuppressWarnings("unchecked") + private Map> rooms() { + return (Map>) ReflectionTestUtils.getField(registry, "rooms"); + } + + @Test + void addSession_addsDecoratedSession() { + UUID roomId = UUID.randomUUID(); + + WebSocketSession s1 = mock(WebSocketSession.class); + when(s1.getId()).thenReturn("s1"); + when(s1.isOpen()).thenReturn(true); + + registry.addSession(roomId, s1); + + assertThat(rooms().get(roomId)).hasSize(1); + } + + @Test + void removeSession_removesById() { + UUID roomId = UUID.randomUUID(); + + WebSocketSession s1 = mock(WebSocketSession.class); + when(s1.getId()).thenReturn("s1"); + when(s1.isOpen()).thenReturn(true); + + registry.addSession(roomId, s1); + assertThat(rooms().get(roomId)).hasSize(1); + + registry.removeSession(roomId, s1); + assertThat(rooms().get(roomId)).isNull(); + } + + @Test + void broadcast_sendsToOthersButNotSender() throws Exception { + UUID roomId = UUID.randomUUID(); + + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); + + WebSocketSession r1 = mock(WebSocketSession.class); + when(r1.getId()).thenReturn("r1"); + when(r1.isOpen()).thenReturn(true); + + registry.addSession(roomId, sender); + registry.addSession(roomId, r1); + + byte[] payload = new byte[]{ 1, 2, 3, 4 }; + + registry.broadcast(roomId, sender, payload); + + verify(sender, never()).sendMessage(any(BinaryMessage.class)); + + verify(r1, times(1)).sendMessage(argThat((WebSocketMessage msg) -> { + if (!(msg instanceof BinaryMessage bm)) return false; + + ByteBuffer bb = bm.getPayload().duplicate(); + byte[] got = new byte[bb.remaining()]; + bb.get(got); + + return java.util.Arrays.equals(got, payload); + })); + } + + @Test + void broadcast_removesClosedSessions() { + UUID roomId = UUID.randomUUID(); + + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); + + WebSocketSession closed = mock(WebSocketSession.class); + when(closed.getId()).thenReturn("closed"); + when(closed.isOpen()).thenReturn(false); + + registry.addSession(roomId, sender); + registry.addSession(roomId, closed); + + registry.broadcast(roomId, sender, new byte[]{ 9 }); + + assertThat(rooms().get(roomId)).hasSize(1); + assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); + } + + @Test + void broadcast_removesSessionsThatThrowOnSend() throws Exception { + UUID roomId = UUID.randomUUID(); + + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); + + WebSocketSession bad = mock(WebSocketSession.class); + when(bad.getId()).thenReturn("bad"); + when(bad.isOpen()).thenReturn(true); + doThrow(new IOException("boom")).when(bad).sendMessage(any(BinaryMessage.class)); + + registry.addSession(roomId, sender); + registry.addSession(roomId, bad); + + registry.broadcast(roomId, sender, new byte[]{ 1, 2 }); + + assertThat(rooms().get(roomId)).hasSize(1); + assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); + } +} From 1f94943a7ab11cef789c0f5ba9adb93baf3964d3 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sun, 15 Feb 2026 02:15:45 +0900 Subject: [PATCH 10/14] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20dis?= =?UTF-8?q?playName=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollaborationServiceTest.java | 179 ++++++++++-------- .../collaboration/SessionRegistryTest.java | 160 +++++++++------- 2 files changed, 188 insertions(+), 151 deletions(-) diff --git a/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java index fdec1eec2..78ab05e1b 100644 --- a/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java +++ b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java @@ -1,6 +1,8 @@ package com.yat2.episode.collaboration; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -17,8 +19,8 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -27,6 +29,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) +@DisplayName("CollaborationService 단위 테스트") class CollaborationServiceTest { @Mock @@ -42,116 +45,132 @@ void setUp() { service = new CollaborationService(sessionRegistry, redisStreamStore); } - @Test - void handleConnect_addsSessionToRoom() { - UUID roomId = UUID.randomUUID(); - WebSocketSession session = mock(WebSocketSession.class); + @Nested + @DisplayName("세션 연결/해제") + class ConnectionTests { - Map attrs = new HashMap<>(); - attrs.put(AttributeKeys.MINDMAP_ID, roomId); - when(session.getAttributes()).thenReturn(attrs); + @Test + @DisplayName("연결 시 room에 세션을 등록한다") + void handleConnect_addsSessionToRoom() { + UUID roomId = UUID.randomUUID(); + WebSocketSession session = mock(WebSocketSession.class); - service.handleConnect(session); + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(session.getAttributes()).thenReturn(attrs); - verify(sessionRegistry).addSession(roomId, session); - verifyNoMoreInteractions(sessionRegistry); - } + service.handleConnect(session); + + verify(sessionRegistry).addSession(roomId, session); + verifyNoMoreInteractions(sessionRegistry); + } - @Test - void handleDisconnect_removesSessionFromRoom() { - UUID roomId = UUID.randomUUID(); - WebSocketSession session = mock(WebSocketSession.class); + @Test + @DisplayName("해제 시 room에서 세션을 제거한다") + void handleDisconnect_removesSessionFromRoom() { + UUID roomId = UUID.randomUUID(); + WebSocketSession session = mock(WebSocketSession.class); - Map attrs = new HashMap<>(); - attrs.put(AttributeKeys.MINDMAP_ID, roomId); - when(session.getAttributes()).thenReturn(attrs); + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(session.getAttributes()).thenReturn(attrs); - service.handleDisconnect(session); + service.handleDisconnect(session); - verify(sessionRegistry).removeSession(roomId, session); - verifyNoMoreInteractions(sessionRegistry); + verify(sessionRegistry).removeSession(roomId, session); + verifyNoMoreInteractions(sessionRegistry); + } } - @Test - void processMessage_alwaysBroadcasts() { - UUID roomId = UUID.randomUUID(); - WebSocketSession sender = mock(WebSocketSession.class); + @Nested + @DisplayName("메시지 처리") + class MessageTests { - Map attrs = new HashMap<>(); - attrs.put(AttributeKeys.MINDMAP_ID, roomId); - when(sender.getAttributes()).thenReturn(attrs); + @Test + @DisplayName("항상 브로드캐스트한다") + void processMessage_alwaysBroadcasts() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); - byte[] frame = new byte[]{ 9, 9, 9 }; - BinaryMessage message = new BinaryMessage(frame); + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); - service.processMessage(sender, message); + byte[] frame = new byte[]{ 9, 9, 9 }; + BinaryMessage message = new BinaryMessage(frame); - ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(byte[].class); - verify(sessionRegistry).broadcast(eq(roomId), eq(sender), payloadCaptor.capture()); + service.processMessage(sender, message); - assertArrayEquals(frame, payloadCaptor.getValue()); - verifyNoMoreInteractions(sessionRegistry); - } + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(byte[].class); + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), payloadCaptor.capture()); - @Test - void processMessage_whenUpdateFrame_appendsToRedis() { - UUID roomId = UUID.randomUUID(); - WebSocketSession sender = mock(WebSocketSession.class); + assertArrayEquals(frame, payloadCaptor.getValue()); + verifyNoMoreInteractions(sessionRegistry); + } - Map attrs = new HashMap<>(); - attrs.put(AttributeKeys.MINDMAP_ID, roomId); - when(sender.getAttributes()).thenReturn(attrs); + @Test + @DisplayName("Update 프레임이면 Redis에 저장한다") + void processMessage_whenUpdateFrame_appendsToRedis() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); - byte[] frame = new byte[]{ 0, 2, 1, 2, 3, 4 }; - BinaryMessage message = new BinaryMessage(frame); + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); - service.processMessage(sender, message); + byte[] frame = new byte[]{ 0, 2, 1, 2, 3, 4 }; + BinaryMessage message = new BinaryMessage(frame); - ArgumentCaptor broadcastCaptor = ArgumentCaptor.forClass(byte[].class); - verify(sessionRegistry).broadcast(eq(roomId), eq(sender), broadcastCaptor.capture()); - assertArrayEquals(frame, broadcastCaptor.getValue()); + service.processMessage(sender, message); - ArgumentCaptor redisCaptor = ArgumentCaptor.forClass(byte[].class); - verify(redisStreamStore).appendUpdate(eq(roomId), redisCaptor.capture()); + ArgumentCaptor broadcastCaptor = ArgumentCaptor.forClass(byte[].class); + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), broadcastCaptor.capture()); + assertArrayEquals(frame, broadcastCaptor.getValue()); - assertArrayEquals(frame, redisCaptor.getValue()); - } + ArgumentCaptor redisCaptor = ArgumentCaptor.forClass(byte[].class); + verify(redisStreamStore).appendUpdate(eq(roomId), redisCaptor.capture()); + assertArrayEquals(frame, redisCaptor.getValue()); + } - @Test - void processMessage_whenNotUpdateFrame_doesNotAppendToRedis() { - UUID roomId = UUID.randomUUID(); - WebSocketSession sender = mock(WebSocketSession.class); + @Test + @DisplayName("Update 프레임이 아니면 Redis에 저장하지 않는다") + void processMessage_whenNotUpdateFrame_doesNotAppendToRedis() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); - Map attrs = new HashMap<>(); - attrs.put(AttributeKeys.MINDMAP_ID, roomId); - when(sender.getAttributes()).thenReturn(attrs); + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); - byte[] frame = new byte[]{ 0, 1, 9, 9 }; - BinaryMessage message = new BinaryMessage(frame); + byte[] frame = new byte[]{ 0, 1, 9, 9 }; + BinaryMessage message = new BinaryMessage(frame); - service.processMessage(sender, message); + service.processMessage(sender, message); - verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); - verifyNoInteractions(redisStreamStore); - } + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + verifyNoInteractions(redisStreamStore); + } - @Test - void processMessage_whenRedisThrows_doesNotCrash() { - UUID roomId = UUID.randomUUID(); - WebSocketSession sender = mock(WebSocketSession.class); + @Test + @DisplayName("Redis 저장 중 예외가 발생해도 처리 흐름이 죽지 않는다") + void processMessage_whenRedisThrows_doesNotCrash() { + UUID roomId = UUID.randomUUID(); + WebSocketSession sender = mock(WebSocketSession.class); - Map attrs = new HashMap<>(); - attrs.put(AttributeKeys.MINDMAP_ID, roomId); - when(sender.getAttributes()).thenReturn(attrs); + Map attrs = new HashMap<>(); + attrs.put(AttributeKeys.MINDMAP_ID, roomId); + when(sender.getAttributes()).thenReturn(attrs); - byte[] frame = new byte[]{ 0, 2, 1, 2, 3 }; - BinaryMessage message = new BinaryMessage(frame); + byte[] frame = new byte[]{ 0, 2, 1, 2, 3 }; + BinaryMessage message = new BinaryMessage(frame); - doThrow(new RuntimeException("redis down")).when(redisStreamStore).appendUpdate(eq(roomId), any(byte[].class)); + doThrow(new RuntimeException("redis down")).when(redisStreamStore) + .appendUpdate(eq(roomId), any(byte[].class)); - assertThatCode(() -> service.processMessage(sender, message)).doesNotThrowAnyException(); + assertThatCode(() -> service.processMessage(sender, message)).doesNotThrowAnyException(); - verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); - verify(redisStreamStore).appendUpdate(eq(roomId), any(byte[].class)); + verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + verify(redisStreamStore).appendUpdate(eq(roomId), any(byte[].class)); + } } } diff --git a/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java b/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java index 9d4e9552b..b356c866f 100644 --- a/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java +++ b/backend/api/src/test/java/com/yat2/episode/collaboration/SessionRegistryTest.java @@ -1,6 +1,8 @@ package com.yat2.episode.collaboration; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.socket.BinaryMessage; @@ -25,6 +27,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@DisplayName("SessionRegistry 단위 테스트") class SessionRegistryTest { SessionRegistry registry; @@ -45,106 +48,121 @@ private Map> rooms() { return (Map>) ReflectionTestUtils.getField(registry, "rooms"); } - @Test - void addSession_addsDecoratedSession() { - UUID roomId = UUID.randomUUID(); + @Nested + @DisplayName("세션 등록/제거") + class SessionManagementTests { - WebSocketSession s1 = mock(WebSocketSession.class); - when(s1.getId()).thenReturn("s1"); - when(s1.isOpen()).thenReturn(true); + @Test + @DisplayName("세션을 등록한다") + void addSession_addsDecoratedSession() { + UUID roomId = UUID.randomUUID(); - registry.addSession(roomId, s1); + WebSocketSession s1 = mock(WebSocketSession.class); + when(s1.getId()).thenReturn("s1"); + when(s1.isOpen()).thenReturn(true); - assertThat(rooms().get(roomId)).hasSize(1); - } + registry.addSession(roomId, s1); + + assertThat(rooms().get(roomId)).hasSize(1); + } - @Test - void removeSession_removesById() { - UUID roomId = UUID.randomUUID(); + @Test + @DisplayName("세션 ID 기준으로 제거한다") + void removeSession_removesById() { + UUID roomId = UUID.randomUUID(); - WebSocketSession s1 = mock(WebSocketSession.class); - when(s1.getId()).thenReturn("s1"); - when(s1.isOpen()).thenReturn(true); + WebSocketSession s1 = mock(WebSocketSession.class); + when(s1.getId()).thenReturn("s1"); + when(s1.isOpen()).thenReturn(true); - registry.addSession(roomId, s1); - assertThat(rooms().get(roomId)).hasSize(1); + registry.addSession(roomId, s1); + assertThat(rooms().get(roomId)).hasSize(1); - registry.removeSession(roomId, s1); - assertThat(rooms().get(roomId)).isNull(); + registry.removeSession(roomId, s1); + assertThat(rooms().get(roomId)).isNull(); + } } - @Test - void broadcast_sendsToOthersButNotSender() throws Exception { - UUID roomId = UUID.randomUUID(); + @Nested + @DisplayName("브로드캐스트") + class BroadcastTests { - WebSocketSession sender = mock(WebSocketSession.class); - when(sender.getId()).thenReturn("sender"); - when(sender.isOpen()).thenReturn(true); + @Test + @DisplayName("발신자를 제외한 세션에게 전송한다") + void broadcast_sendsToOthersButNotSender() throws Exception { + UUID roomId = UUID.randomUUID(); - WebSocketSession r1 = mock(WebSocketSession.class); - when(r1.getId()).thenReturn("r1"); - when(r1.isOpen()).thenReturn(true); + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); - registry.addSession(roomId, sender); - registry.addSession(roomId, r1); + WebSocketSession r1 = mock(WebSocketSession.class); + when(r1.getId()).thenReturn("r1"); + when(r1.isOpen()).thenReturn(true); - byte[] payload = new byte[]{ 1, 2, 3, 4 }; + registry.addSession(roomId, sender); + registry.addSession(roomId, r1); - registry.broadcast(roomId, sender, payload); + byte[] payload = new byte[]{ 1, 2, 3, 4 }; - verify(sender, never()).sendMessage(any(BinaryMessage.class)); + registry.broadcast(roomId, sender, payload); - verify(r1, times(1)).sendMessage(argThat((WebSocketMessage msg) -> { - if (!(msg instanceof BinaryMessage bm)) return false; + verify(sender, never()).sendMessage(any(BinaryMessage.class)); - ByteBuffer bb = bm.getPayload().duplicate(); - byte[] got = new byte[bb.remaining()]; - bb.get(got); + verify(r1, times(1)).sendMessage(argThat((WebSocketMessage msg) -> { + if (!(msg instanceof BinaryMessage bm)) return false; - return java.util.Arrays.equals(got, payload); - })); - } + ByteBuffer bb = bm.getPayload().duplicate(); + byte[] got = new byte[bb.remaining()]; + bb.get(got); - @Test - void broadcast_removesClosedSessions() { - UUID roomId = UUID.randomUUID(); + return java.util.Arrays.equals(got, payload); + })); + } - WebSocketSession sender = mock(WebSocketSession.class); - when(sender.getId()).thenReturn("sender"); - when(sender.isOpen()).thenReturn(true); + @Test + @DisplayName("닫힌 세션은 제거한다") + void broadcast_removesClosedSessions() { + UUID roomId = UUID.randomUUID(); - WebSocketSession closed = mock(WebSocketSession.class); - when(closed.getId()).thenReturn("closed"); - when(closed.isOpen()).thenReturn(false); + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); - registry.addSession(roomId, sender); - registry.addSession(roomId, closed); + WebSocketSession closed = mock(WebSocketSession.class); + when(closed.getId()).thenReturn("closed"); + when(closed.isOpen()).thenReturn(false); - registry.broadcast(roomId, sender, new byte[]{ 9 }); + registry.addSession(roomId, sender); + registry.addSession(roomId, closed); - assertThat(rooms().get(roomId)).hasSize(1); - assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); - } + registry.broadcast(roomId, sender, new byte[]{ 9 }); + + assertThat(rooms().get(roomId)).hasSize(1); + assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); + } - @Test - void broadcast_removesSessionsThatThrowOnSend() throws Exception { - UUID roomId = UUID.randomUUID(); + @Test + @DisplayName("전송 중 예외가 발생한 세션은 제거한다") + void broadcast_removesSessionsThatThrowOnSend() throws Exception { + UUID roomId = UUID.randomUUID(); - WebSocketSession sender = mock(WebSocketSession.class); - when(sender.getId()).thenReturn("sender"); - when(sender.isOpen()).thenReturn(true); + WebSocketSession sender = mock(WebSocketSession.class); + when(sender.getId()).thenReturn("sender"); + when(sender.isOpen()).thenReturn(true); - WebSocketSession bad = mock(WebSocketSession.class); - when(bad.getId()).thenReturn("bad"); - when(bad.isOpen()).thenReturn(true); - doThrow(new IOException("boom")).when(bad).sendMessage(any(BinaryMessage.class)); + WebSocketSession bad = mock(WebSocketSession.class); + when(bad.getId()).thenReturn("bad"); + when(bad.isOpen()).thenReturn(true); + doThrow(new IOException("boom")).when(bad).sendMessage(any(BinaryMessage.class)); - registry.addSession(roomId, sender); - registry.addSession(roomId, bad); + registry.addSession(roomId, sender); + registry.addSession(roomId, bad); - registry.broadcast(roomId, sender, new byte[]{ 1, 2 }); + registry.broadcast(roomId, sender, new byte[]{ 1, 2 }); - assertThat(rooms().get(roomId)).hasSize(1); - assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); + assertThat(rooms().get(roomId)).hasSize(1); + assertThat(rooms().get(roomId).stream().map(WebSocketSession::getId)).containsExactly("sender"); + } } } From 4f32cbeed029068b82e2346f6e63883a39209a3a Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sun, 15 Feb 2026 04:24:34 +0900 Subject: [PATCH 11/14] =?UTF-8?q?chore:=20liveness=20readiness=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/src/main/resources/application.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/api/src/main/resources/application.yml b/backend/api/src/main/resources/application.yml index 81da0937f..dec2532e9 100644 --- a/backend/api/src/main/resources/application.yml +++ b/backend/api/src/main/resources/application.yml @@ -122,6 +122,11 @@ management: include: health endpoint: health: + group: + readiness: + include: readinessState,db,redis + liveness: + include: livenessState show-details: never collaboration: From da01bd54aeb28dea6db6c223c9e584daea3f3063 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sun, 15 Feb 2026 04:37:18 +0900 Subject: [PATCH 12/14] =?UTF-8?q?chore:=20healthcheck=EC=9D=80=20liveness?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-production.yml | 2 +- .github/workflows/deploy-staging.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 94b274779..24411677f 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -169,7 +169,7 @@ jobs: id: health_check run: | set -e - URL="https://api.episode.io.kr/actuator/health" + URL="https://api.episode.io.kr/actuator/health/liveness" echo "Checking health:" for i in $(seq 1 30); do diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 0c1bb3a9f..c5ecc258f 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -116,7 +116,7 @@ jobs: id: health_check run: | set -e - URL="https://stage.episode.io.kr/actuator/health" + URL="https://stage.episode.io.kr/actuator/health/liveness" echo "Checking health:" for i in $(seq 1 30); do From ebf4fe8be1c91cc58224c8af7f722248f5c4770b Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Sun, 15 Feb 2026 05:13:42 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20Redis=20append=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20=EC=8A=A4=EB=A0=88=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../collaboration/CollaborationService.java | 12 ++++- .../config/CollaborationAsyncConfig.java | 46 +++++++++++++++++++ .../CollaborationServiceTest.java | 20 +++++++- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java index f50d5a7cc..62c166c13 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java @@ -8,6 +8,7 @@ import java.nio.ByteBuffer; import java.util.UUID; +import java.util.concurrent.Executor; import com.yat2.episode.global.constant.AttributeKeys; @@ -17,6 +18,7 @@ public class CollaborationService { private final SessionRegistry sessionRegistry; private final RedisStreamStore redisStreamStore; + private final Executor redisExecutor; public void handleConnect(WebSocketSession session) { sessionRegistry.addSession(getMindmapId(session), session); @@ -31,9 +33,15 @@ public void processMessage(WebSocketSession sender, BinaryMessage message) { if (YjsProtocolUtil.isUpdateFrame(payload)) { try { - redisStreamStore.appendUpdate(roomId, payload); + redisExecutor.execute(() -> { + try { + redisStreamStore.appendUpdate(roomId, payload); + } catch (Exception e) { + log.warn("Redis append failed. roomId={}", roomId, e); + } + }); } catch (Exception e) { - log.error("Error while appending update frame to redis. roomId={}", roomId, e); + log.error("Redis append failed. roomId={}", roomId, e); } } } diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java b/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java new file mode 100644 index 000000000..1c3951d0d --- /dev/null +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java @@ -0,0 +1,46 @@ +package com.yat2.episode.collaboration.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionHandler; +import java.util.concurrent.atomic.AtomicLong; + +@Slf4j +@Configuration +public class CollaborationAsyncConfig { + + @Bean(name = "redisExecutor") + public Executor redisExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setThreadNamePrefix("redis-append-"); + exec.setCorePoolSize(1); + exec.setMaxPoolSize(2); + exec.setQueueCapacity(10_000); + exec.setKeepAliveSeconds(30); + exec.setAllowCoreThreadTimeOut(true); + + exec.setRejectedExecutionHandler(dropAndLogError()); + exec.initialize(); + return exec; + } + + private RejectedExecutionHandler dropAndLogError() { + AtomicLong dropped = new AtomicLong(); + AtomicLong lastLogMs = new AtomicLong(0); + + return (r, executor) -> { + long n = dropped.incrementAndGet(); + + long now = System.currentTimeMillis(); + long prev = lastLogMs.get(); + if (now - prev >= 1000 && lastLogMs.compareAndSet(prev, now)) { + int qSize = executor.getQueue().size(); + log.error("Redis queue full. Dropping tasks. dropped={}, queueSize={}", n, qSize); + } + }; + } +} diff --git a/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java index 78ab05e1b..0279a5bb5 100644 --- a/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java +++ b/backend/api/src/test/java/com/yat2/episode/collaboration/CollaborationServiceTest.java @@ -14,6 +14,7 @@ import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Executor; import com.yat2.episode.global.constant.AttributeKeys; @@ -38,11 +39,14 @@ class CollaborationServiceTest { @Mock RedisStreamStore redisStreamStore; + @Mock + Executor redisExecutor; + CollaborationService service; @BeforeEach void setUp() { - service = new CollaborationService(sessionRegistry, redisStreamStore); + service = new CollaborationService(sessionRegistry, redisStreamStore, redisExecutor); } @Nested @@ -109,7 +113,7 @@ void processMessage_alwaysBroadcasts() { } @Test - @DisplayName("Update 프레임이면 Redis에 저장한다") + @DisplayName("Update 프레임이면 Redis에 저장한다 (Executor에 task를 넣고, task 실행 시 append된다)") void processMessage_whenUpdateFrame_appendsToRedis() { UUID roomId = UUID.randomUUID(); WebSocketSession sender = mock(WebSocketSession.class); @@ -127,6 +131,11 @@ void processMessage_whenUpdateFrame_appendsToRedis() { verify(sessionRegistry).broadcast(eq(roomId), eq(sender), broadcastCaptor.capture()); assertArrayEquals(frame, broadcastCaptor.getValue()); + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(redisExecutor).execute(taskCaptor.capture()); + + taskCaptor.getValue().run(); + ArgumentCaptor redisCaptor = ArgumentCaptor.forClass(byte[].class); verify(redisStreamStore).appendUpdate(eq(roomId), redisCaptor.capture()); assertArrayEquals(frame, redisCaptor.getValue()); @@ -148,6 +157,7 @@ void processMessage_whenNotUpdateFrame_doesNotAppendToRedis() { service.processMessage(sender, message); verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + verifyNoInteractions(redisExecutor); verifyNoInteractions(redisStreamStore); } @@ -170,6 +180,12 @@ void processMessage_whenRedisThrows_doesNotCrash() { assertThatCode(() -> service.processMessage(sender, message)).doesNotThrowAnyException(); verify(sessionRegistry).broadcast(eq(roomId), eq(sender), any(byte[].class)); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(redisExecutor).execute(taskCaptor.capture()); + + assertThatCode(() -> taskCaptor.getValue().run()).doesNotThrowAnyException(); + verify(redisStreamStore).appendUpdate(eq(roomId), any(byte[].class)); } } From 0fb4fa505d6e123ab239dc42831891b582a59c79 Mon Sep 17 00:00:00 2001 From: w0uldy0u Date: Mon, 16 Feb 2026 03:27:42 +0900 Subject: [PATCH 14/14] =?UTF-8?q?chore:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yat2/episode/collaboration/CollaborationService.java | 5 +++++ .../collaboration/config/CollaborationAsyncConfig.java | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java index 62c166c13..494412362 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/CollaborationService.java @@ -26,6 +26,10 @@ public void handleConnect(WebSocketSession session) { public void processMessage(WebSocketSession sender, BinaryMessage message) { UUID roomId = getMindmapId(sender); + if (roomId == null) { + log.error("Mindmap Id is null."); + return; + } byte[] payload = toByteArray(message.getPayload()); @@ -47,6 +51,7 @@ public void processMessage(WebSocketSession sender, BinaryMessage message) { } public void handleDisconnect(WebSocketSession session) { + //TODO: Collaboration room 세션 수 0일때 스냅샷 트리거 sessionRegistry.removeSession(getMindmapId(session), session); } diff --git a/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java b/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java index 1c3951d0d..c932578f6 100644 --- a/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java +++ b/backend/api/src/main/java/com/yat2/episode/collaboration/config/CollaborationAsyncConfig.java @@ -31,7 +31,7 @@ public Executor redisExecutor() { private RejectedExecutionHandler dropAndLogError() { AtomicLong dropped = new AtomicLong(); AtomicLong lastLogMs = new AtomicLong(0); - + //TODO: Update가 drop 될 경우 Yjs Runner에게 알려 Sync 프로토콜로 복구 return (r, executor) -> { long n = dropped.incrementAndGet();