From 7d1b5687e12a9f9a4996f152a89215ab31f6a488 Mon Sep 17 00:00:00 2001
From: 1026hz <1026hzz@gmail.com>
Date: Tue, 4 Nov 2025 10:30:31 +0900
Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20OT=20=EC=97=94=EC=A7=84=20?=
=?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?=
=?UTF-8?q?=EC=82=AD=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../syncly/domain/note/dto/EditOperation.java | 211 ---------
.../syncly/domain/note/engine/OTEngine.java | 431 ------------------
.../syncly/domain/note/service/OTService.java | 394 ----------------
.../validator/EditOperationValidator.java | 224 ---------
4 files changed, 1260 deletions(-)
delete mode 100644 src/main/java/com/project/syncly/domain/note/dto/EditOperation.java
delete mode 100644 src/main/java/com/project/syncly/domain/note/engine/OTEngine.java
delete mode 100644 src/main/java/com/project/syncly/domain/note/service/OTService.java
delete mode 100644 src/main/java/com/project/syncly/domain/note/validator/EditOperationValidator.java
diff --git a/src/main/java/com/project/syncly/domain/note/dto/EditOperation.java b/src/main/java/com/project/syncly/domain/note/dto/EditOperation.java
deleted file mode 100644
index b229f5d..0000000
--- a/src/main/java/com/project/syncly/domain/note/dto/EditOperation.java
+++ /dev/null
@@ -1,211 +0,0 @@
-package com.project.syncly.domain.note.dto;
-
-import com.fasterxml.jackson.annotation.JsonCreator;
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.annotation.JsonProperty;
-import lombok.Builder;
-import lombok.Getter;
-
-import java.time.LocalDateTime;
-
-/**
- * OT(Operational Transformation) 기반 편집 연산 DTO
- */
-@Getter
-@Builder(toBuilder = true)
-@JsonIgnoreProperties(ignoreUnknown = true)
-public class EditOperation {
-
- /**
- * 연산 타입 ("insert" 또는 "delete")
- */
- private final String type;
-
- /**
- * 연산 시작 위치 (0-based index)
- */
- private final int position;
-
- /**
- * 영향받는 문자 수
- */
- private final int length;
-
- /**
- * 삽입될 내용 (insert인 경우만)
- */
- private final String content;
-
- /**
- * 연산이 기반한 문서 버전
- */
- private final int revision;
-
- /**
- * 연산 수행 WorkspaceMember ID
- */
- private final Long workspaceMemberId;
-
- /**
- * 연산 타임스탬프
- */
- private final LocalDateTime timestamp;
-
- @JsonCreator
- public EditOperation(
- @JsonProperty("type") String type,
- @JsonProperty("position") int position,
- @JsonProperty("length") int length,
- @JsonProperty("content") String content,
- @JsonProperty("revision") int revision,
- @JsonProperty("workspaceMemberId") Long workspaceMemberId,
- @JsonProperty("timestamp") LocalDateTime timestamp
- ) {
- this.type = type;
- this.position = position;
- this.length = length;
- this.content = content;
- this.revision = revision;
- this.workspaceMemberId = workspaceMemberId;
- this.timestamp = timestamp != null ? timestamp : LocalDateTime.now();
- }
-
- /**
- * Insert 연산 생성
- */
- public static EditOperation insert(int position, String content, int revision, Long workspaceMemberId) {
- return EditOperation.builder()
- .type("insert")
- .position(position)
- .length(content.length())
- .content(content)
- .revision(revision)
- .workspaceMemberId(workspaceMemberId)
- .timestamp(LocalDateTime.now())
- .build();
- }
-
- /**
- * Delete 연산 생성
- */
- public static EditOperation delete(int position, int length, int revision, Long workspaceMemberId) {
- return EditOperation.builder()
- .type("delete")
- .position(position)
- .length(length)
- .content(null)
- .revision(revision)
- .workspaceMemberId(workspaceMemberId)
- .timestamp(LocalDateTime.now())
- .build();
- }
-
- /**
- * Insert 연산인지 확인
- */
- public boolean isInsert() {
- return "insert".equalsIgnoreCase(type);
- }
-
- /**
- * Delete 연산인지 확인
- */
- public boolean isDelete() {
- return "delete".equalsIgnoreCase(type);
- }
-
- /**
- * 연산의 끝 위치 계산 (delete 연산용)
- *
- * @return position + length
- */
- public int getEndPosition() {
- return position + length;
- }
-
- /**
- * 새로운 position으로 연산 복사 (OT 변환용)
- *
- * @param newPosition 새로운 위치
- * @return position이 변경된 새 EditOperation
- */
- public EditOperation withPosition(int newPosition) {
- return EditOperation.builder()
- .type(this.type)
- .position(newPosition)
- .length(this.length)
- .content(this.content)
- .revision(this.revision)
- .workspaceMemberId(this.workspaceMemberId)
- .timestamp(this.timestamp)
- .build();
- }
-
- /**
- * 새로운 length로 연산 복사 (OT 변환용)
- *
- * @param newLength 새로운 길이
- * @return length가 변경된 새 EditOperation
- */
- public EditOperation withLength(int newLength) {
- return EditOperation.builder()
- .type(this.type)
- .position(this.position)
- .length(newLength)
- .content(this.content)
- .revision(this.revision)
- .workspaceMemberId(this.workspaceMemberId)
- .timestamp(this.timestamp)
- .build();
- }
-
- /**
- * 새로운 position과 length로 연산 복사 (OT 변환용)
- *
- * @param newPosition 새로운 위치
- * @param newLength 새로운 길이
- * @return position과 length가 변경된 새 EditOperation
- */
- public EditOperation withPositionAndLength(int newPosition, int newLength) {
- return EditOperation.builder()
- .type(this.type)
- .position(newPosition)
- .length(newLength)
- .content(this.content)
- .revision(this.revision)
- .workspaceMemberId(this.workspaceMemberId)
- .timestamp(this.timestamp)
- .build();
- }
-
- /**
- * No-op (아무것도 하지 않는) 연산으로 변환
- * Delete 연산의 length를 0으로 설정
- *
- * @return length=0인 새 EditOperation
- */
- public EditOperation toNoOp() {
- return this.withLength(0);
- }
-
- /**
- * 이 연산이 no-op인지 확인
- * (delete 연산이면서 length가 0인 경우)
- *
- * @return no-op 여부
- */
- public boolean isNoOp() {
- return isDelete() && length == 0;
- }
-
- @Override
- public String toString() {
- if (isInsert()) {
- return String.format("Insert(pos=%d, content='%s', rev=%d, wmId=%d)",
- position, content, revision, workspaceMemberId);
- } else {
- return String.format("Delete(pos=%d, len=%d, rev=%d, wmId=%d)",
- position, length, revision, workspaceMemberId);
- }
- }
-}
diff --git a/src/main/java/com/project/syncly/domain/note/engine/OTEngine.java b/src/main/java/com/project/syncly/domain/note/engine/OTEngine.java
deleted file mode 100644
index f2bae88..0000000
--- a/src/main/java/com/project/syncly/domain/note/engine/OTEngine.java
+++ /dev/null
@@ -1,431 +0,0 @@
-package com.project.syncly.domain.note.engine;
-
-import com.project.syncly.domain.note.dto.EditOperation;
-import lombok.extern.slf4j.Slf4j;
-
-import java.util.List;
-
-/**
- * OT(Operational Transformation) 엔진
- *
- *
동시 편집 충돌을 해결하기 위한 핵심 변환 로직을 제공합니다.
- *
- *
OT의 원리:
- *
- *
두 사용자가 동시에 같은 문서의 다른 버전을 편집할 때
- *
나중에 도착한 연산을 먼저 적용된 연산에 맞춰 "변환"합니다
- *
이를 통해 모든 클라이언트가 최종적으로 동일한 상태에 도달합니다
- *
- *
- *
예시:
- *
- * 초기 문서: "Hello"
- *
- * 사용자 A: position 5에 " World" 삽입 → "Hello World"
- * 사용자 B: position 0에서 1자 삭제 → "ello"
- *
- * B의 연산이 먼저 서버에 도착:
- * 1. 서버: "ello" 적용, revision=1
- * 2. A의 연산(rev=0)이 도착하면 B의 연산으로 변환:
- * - transform(A_insert, B_delete)
- * - A의 position 5 → 4로 조정 (1자가 삭제되었으므로)
- * 3. 최종: "ello" + " World" at position 4 = "ello World"
- *
- */
-@Slf4j
-public class OTEngine {
-
- /**
- * 두 연산 중 하나를 다른 연산에 맞춰 변환합니다.
- *
- *
OT의 핵심 메서드입니다. op가 appliedOp 이후에 적용될 수 있도록
- * op의 position 또는 length를 조정합니다.
- *
- * @param op 변환하려는 연산 (새로 들어온 연산)
- * @param appliedOp 이미 적용된 연산
- * @return 변환된 연산
- */
- public static EditOperation transform(EditOperation op, EditOperation appliedOp) {
- if (op.isInsert() && appliedOp.isInsert()) {
- return transformInsertInsert(op, appliedOp);
- } else if (op.isInsert() && appliedOp.isDelete()) {
- return transformInsertDelete(op, appliedOp);
- } else if (op.isDelete() && appliedOp.isInsert()) {
- return transformDeleteInsert(op, appliedOp);
- } else if (op.isDelete() && appliedOp.isDelete()) {
- return transformDeleteDelete(op, appliedOp);
- }
-
- // 도달하지 않아야 함
- log.warn("Unknown operation types: op={}, appliedOp={}", op.getType(), appliedOp.getType());
- return op;
- }
-
- /**
- * INSERT vs INSERT 변환
- *
- *
시나리오: 두 사용자가 동시에 텍스트를 삽입
- *
- *
변환 규칙:
- *
- *
appliedOp.position < op.position: op를 오른쪽으로 이동 (appliedOp.length만큼)
- *
appliedOp.position > op.position: op는 그대로 유지
- *
appliedOp.position == op.position: workspaceMemberId로 우선순위 결정
- *
- *
작은 ID가 우선 (왼쪽에 삽입)
- *
큰 ID는 오른쪽으로 이동
- *
- *
- *
- *
- *
예시:
- *
- * 문서: "Hello"
- * appliedOp: Insert "X" at position 2 → "HeXllo"
- * op: Insert "Y" at position 3 → 원래 목표는 "HelYlo"
- *
- * 변환 후 op: Insert "Y" at position 4 (2 + length(X))
- * 최종: "HeXlYlo"
- *
- *
- * @param op 변환할 INSERT 연산
- * @param appliedOp 이미 적용된 INSERT 연산
- * @return 변환된 INSERT 연산
- */
- private static EditOperation transformInsertInsert(EditOperation op, EditOperation appliedOp) {
- // appliedOp가 op보다 앞에 삽입된 경우
- if (appliedOp.getPosition() < op.getPosition()) {
- // op의 위치를 오른쪽으로 이동 (appliedOp가 삽입한 길이만큼)
- return op.withPosition(op.getPosition() + appliedOp.getLength());
- }
- // 같은 위치에 삽입하는 경우: workspaceMemberId로 우선순위 결정
- else if (appliedOp.getPosition() == op.getPosition()) {
- // workspaceMemberId가 작은 쪽이 왼쪽(먼저) 삽입
- // appliedOp의 ID가 작으면 op를 오른쪽으로 이동
- if (appliedOp.getWorkspaceMemberId() < op.getWorkspaceMemberId()) {
- return op.withPosition(op.getPosition() + appliedOp.getLength());
- }
- // op의 ID가 더 작으면 그대로 유지 (op가 왼쪽에 삽입됨)
- return op;
- }
- // appliedOp가 op보다 뒤에 삽입된 경우: op는 영향받지 않음
- else {
- return op;
- }
- }
-
- /**
- * INSERT vs DELETE 변환
- *
- *
시나리오: 한 사용자는 삽입하고, 다른 사용자는 삭제
- *
- *
변환 규칙:
- *
- *
appliedOp(DELETE)가 op(INSERT) 앞쪽을 삭제: op를 왼쪽으로 이동
- *
appliedOp가 op 위치를 포함해서 삭제: op를 삭제 시작점으로 이동
- *
appliedOp가 op 뒤쪽을 삭제: op는 영향받지 않음
- *
- *
- *
예시:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 6 chars at position 5 → "Hello" (뒤의 " World" 삭제)
- * op: Insert "!" at position 11 → 원래 목표는 "Hello World!"
- *
- * 변환 후 op: Insert "!" at position 5 (11 - 6)
- * 최종: "Hello!"
- *
- *
- * @param op 변환할 INSERT 연산
- * @param appliedOp 이미 적용된 DELETE 연산
- * @return 변환된 INSERT 연산
- */
- private static EditOperation transformInsertDelete(EditOperation op, EditOperation appliedOp) {
- int deleteStart = appliedOp.getPosition();
- int deleteEnd = appliedOp.getEndPosition();
-
- // DELETE가 INSERT보다 완전히 뒤에 있는 경우: INSERT는 영향받지 않음
- if (deleteStart >= op.getPosition()) {
- return op;
- }
- // DELETE가 INSERT 위치를 포함하는 경우: INSERT를 DELETE 시작점으로 이동
- else if (deleteEnd >= op.getPosition()) {
- return op.withPosition(deleteStart);
- }
- // DELETE가 INSERT보다 앞에 있는 경우: INSERT를 왼쪽으로 이동
- else {
- return op.withPosition(op.getPosition() - appliedOp.getLength());
- }
- }
-
- /**
- * DELETE vs INSERT 변환
- *
- *
시나리오: 한 사용자는 삭제하고, 다른 사용자는 삽입
- *
- *
변환 규칙:
- *
- *
appliedOp(INSERT)가 op(DELETE) 앞에 삽입: op를 오른쪽으로 이동
- *
appliedOp가 op 범위 내에 삽입: op의 길이를 늘림 (삽입된 텍스트도 삭제)
- *
appliedOp가 op 뒤에 삽입: op는 영향받지 않음
- *
- *
- *
예시:
- *
- * 문서: "Hello World"
- * appliedOp: Insert "Beautiful " at position 6 → "Hello Beautiful World"
- * op: Delete 5 chars at position 6 → 원래 목표는 "Hello " (World 삭제)
- *
- * 변환 후 op: Delete 5 chars at position 16 (6 + length("Beautiful "))
- * 최종: "Hello Beautiful "
- *
- *
- * @param op 변환할 DELETE 연산
- * @param appliedOp 이미 적용된 INSERT 연산
- * @return 변환된 DELETE 연산
- */
- private static EditOperation transformDeleteInsert(EditOperation op, EditOperation appliedOp) {
- int deleteStart = op.getPosition();
- int deleteEnd = op.getEndPosition();
-
- // INSERT가 DELETE보다 앞에 있는 경우: DELETE를 오른쪽으로 이동
- if (appliedOp.getPosition() < deleteStart) {
- return op.withPosition(op.getPosition() + appliedOp.getLength());
- }
- // INSERT가 DELETE 범위 내에 있는 경우: DELETE 길이를 늘림
- else if (appliedOp.getPosition() >= deleteStart && appliedOp.getPosition() < deleteEnd) {
- // 삽입된 텍스트도 함께 삭제하도록 길이 증가
- return op.withLength(op.getLength() + appliedOp.getLength());
- }
- // INSERT가 DELETE보다 뒤에 있는 경우: DELETE는 영향받지 않음
- else {
- return op;
- }
- }
-
- /**
- * DELETE vs DELETE 변환
- *
- *
시나리오: 두 사용자가 동시에 텍스트를 삭제
- *
- *
변환 규칙:
- *
- *
두 DELETE가 겹치지 않음: position만 조정
- *
두 DELETE가 부분적으로 겹침: length 조정
- *
appliedOp가 op를 완전히 포함: op를 no-op으로 변환 (length=0)
- *
- *
- *
예시 1 - 겹치지 않음:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 5 chars at position 0 → " World" ("Hello" 삭제)
- * op: Delete 5 chars at position 6 → 원래 목표는 "Hello " ("World" 삭제)
- *
- * 변환 후 op: Delete 5 chars at position 1 (6 - 5)
- * 최종: " " (양쪽 모두 삭제됨)
- *
- *
- *
예시 2 - 부분 겹침:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 3 chars at position 3 → "Hel World" ("lo " 삭제)
- * op: Delete 5 chars at position 5 → 원래 목표는 "Hello" (" World" 삭제)
- *
- * 변환 후 op: Delete 3 chars at position 3 (겹치는 1자는 이미 삭제됨)
- * 최종: "Hel"
- *
- *
- *
예시 3 - 완전 포함:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 11 chars at position 0 → "" (전체 삭제)
- * op: Delete 5 chars at position 6 → 원래 목표는 "Hello " ("World" 삭제)
- *
- * 변환 후 op: Delete 0 chars (no-op, 이미 삭제된 범위)
- * 최종: ""
- *
- *
- * @param op 변환할 DELETE 연산
- * @param appliedOp 이미 적용된 DELETE 연산
- * @return 변환된 DELETE 연산
- */
- private static EditOperation transformDeleteDelete(EditOperation op, EditOperation appliedOp) {
- int opStart = op.getPosition();
- int opEnd = op.getEndPosition();
- int appliedStart = appliedOp.getPosition();
- int appliedEnd = appliedOp.getEndPosition();
-
- // Case 1: appliedOp가 op보다 완전히 뒤에 있음 → op는 영향받지 않음
- if (appliedStart >= opEnd) {
- return op;
- }
- // Case 2: appliedOp가 op보다 완전히 앞에 있음 → op의 position만 조정
- else if (appliedEnd <= opStart) {
- return op.withPosition(opStart - appliedOp.getLength());
- }
- // Case 3: appliedOp가 op를 완전히 포함 → op를 no-op으로 변환
- else if (appliedStart <= opStart && appliedEnd >= opEnd) {
- // op가 삭제하려던 범위가 이미 모두 삭제됨
- return op.toNoOp(); // length = 0
- }
- // Case 4: op가 appliedOp를 완전히 포함 → op의 length 감소
- else if (opStart < appliedStart && opEnd > appliedEnd) {
- // op의 중간 부분이 이미 삭제됨
- return op.withLength(op.getLength() - appliedOp.getLength());
- }
- // Case 5: 부분 겹침 - appliedOp가 op의 앞부분과 겹침
- else if (appliedStart <= opStart && appliedEnd < opEnd) {
- // op의 시작 부분이 이미 삭제됨
- int newStart = appliedStart;
- int newLength = opEnd - appliedEnd;
- return op.withPositionAndLength(newStart, newLength);
- }
- // Case 6: 부분 겹침 - appliedOp가 op의 뒷부분과 겹침
- else if (appliedStart > opStart && appliedEnd >= opEnd) {
- // op의 끝 부분이 이미 삭제됨
- int newLength = appliedStart - opStart;
- return op.withLength(newLength);
- }
-
- // 이론상 도달하지 않아야 함
- log.warn("Unexpected DELETE-DELETE case: op={}, appliedOp={}", op, appliedOp);
- return op;
- }
-
- /**
- * 연산을 히스토리의 모든 연산에 대해 순차적으로 변환합니다.
- *
- *
op.revision 이후에 적용된 모든 연산들에 대해 transform을 반복 적용합니다.
- *
- *
DELETE: content.substring(0, pos) + content.substring(pos + len)
- *
- * @param content 현재 문서 내용
- * @param op 적용할 연산
- * @return 연산이 적용된 새 문서 내용
- * @throws IllegalArgumentException position/length가 범위를 벗어나는 경우
- */
- public static String applyOperation(String content, EditOperation op) {
- if (content == null) {
- content = "";
- }
-
- // No-op인 경우 아무것도 하지 않음
- if (op.isNoOp()) {
- return content;
- }
-
- if (op.isInsert()) {
- return applyInsert(content, op);
- } else if (op.isDelete()) {
- return applyDelete(content, op);
- }
-
- throw new IllegalArgumentException("Unknown operation type: " + op.getType());
- }
-
- /**
- * INSERT 연산을 문서에 적용
- *
- * @param content 현재 문서 내용
- * @param op INSERT 연산
- * @return 삽입 후 문서 내용
- */
- private static String applyInsert(String content, EditOperation op) {
- int position = op.getPosition();
-
- // position 범위 검증
- if (position < 0 || position > content.length()) {
- throw new IllegalArgumentException(
- String.format("Insert position out of bounds: position=%d, contentLength=%d",
- position, content.length())
- );
- }
-
- if (op.getContent() == null) {
- throw new IllegalArgumentException("Insert operation must have content");
- }
-
- // 삽입 수행: 앞부분 + 삽입 내용 + 뒷부분
- String before = content.substring(0, position);
- String after = content.substring(position);
- return before + op.getContent() + after;
- }
-
- /**
- * DELETE 연산을 문서에 적용
- *
- * @param content 현재 문서 내용
- * @param op DELETE 연산
- * @return 삭제 후 문서 내용
- */
- private static String applyDelete(String content, EditOperation op) {
- int position = op.getPosition();
- int length = op.getLength();
-
- // position 범위 검증
- if (position < 0 || position > content.length()) {
- throw new IllegalArgumentException(
- String.format("Delete position out of bounds: position=%d, contentLength=%d",
- position, content.length())
- );
- }
-
- // length 범위 검증
- if (length < 0) {
- throw new IllegalArgumentException("Delete length cannot be negative: " + length);
- }
-
- if (position + length > content.length()) {
- throw new IllegalArgumentException(
- String.format("Delete range out of bounds: position=%d, length=%d, contentLength=%d",
- position, length, content.length())
- );
- }
-
- // 삭제 수행: 앞부분 + 뒷부분 (중간 부분 제거)
- String before = content.substring(0, position);
- String after = content.substring(position + length);
- return before + after;
- }
-}
diff --git a/src/main/java/com/project/syncly/domain/note/service/OTService.java b/src/main/java/com/project/syncly/domain/note/service/OTService.java
deleted file mode 100644
index aabf5fa..0000000
--- a/src/main/java/com/project/syncly/domain/note/service/OTService.java
+++ /dev/null
@@ -1,394 +0,0 @@
-package com.project.syncly.domain.note.service;
-
-import com.project.syncly.domain.note.dto.CursorPosition;
-import com.project.syncly.domain.note.dto.EditOperation;
-import com.project.syncly.domain.note.engine.OTEngine;
-import com.project.syncly.domain.note.exception.NoteErrorCode;
-import com.project.syncly.domain.note.exception.NoteException;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-/**
- * OT(Operational Transformation) 서비스
- *
- *
OT 엔진과 Redis를 통합하여 실시간 협업 편집을 처리합니다.
- *
- *
주요 기능:
- *
- *
편집 연산 검증
- *
연산 변환 (OT 알고리즘 적용)
- *
Redis에 연산 적용 및 저장
- *
- *
- *
처리 흐름:
- *
- *
클라이언트로부터 EditOperation 수신
- *
연산 유효성 검증 (validateOperation)
- *
현재 revision 확인
- *
op.revision < currentRevision이면 히스토리를 통해 변환 (transformAgainstHistory)
- *
변환된 연산을 문서에 적용 (applyOperation)
- *
Redis 업데이트:
- *
- *
content 업데이트
- *
operations 히스토리에 추가
- *
revision 증가
- *
dirty 플래그 설정
- *
- *
- *
새 content와 revision 반환
- *
- */
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class OTService {
-
- private final NoteRedisService noteRedisService;
-
- /**
- * 편집 연산을 처리합니다.
- *
- *
이 메서드는 다음 단계를 수행합니다:
- *
- *
Redis에서 현재 content, revision, operations 조회
- *
연산 유효성 검증
- *
op.revision이 구버전이면 OT 변환 수행
- *
변환된 연산을 content에 적용
- *
Redis 업데이트
- *
- *
- *
사용 예시:
- *
{@code
- * EditOperation op = EditOperation.insert(10, "Hello", 5, 123L);
- * ProcessEditResult result = otService.processEdit(noteId, op);
- * // result.content: 새 문서 내용
- * // result.revision: 새 버전 번호
- * }
- *
- * @param noteId 노트 ID
- * @param operation 처리할 편집 연산
- * @return 처리 결과 (새 content, 새 revision)
- * @throws NoteException 연산이 유효하지 않거나 적용에 실패한 경우
- */
- public ProcessEditResult processEdit(Long noteId, EditOperation operation) {
- log.debug("편집 연산 처리 시작: noteId={}, operation={}", noteId, operation);
-
- // 1. Redis에서 현재 상태 조회
- String currentContent = noteRedisService.getContent(noteId);
- if (currentContent == null) {
- throw new NoteException(NoteErrorCode.NOTE_NOT_FOUND);
- }
-
- int currentRevision = noteRedisService.getRevision(noteId);
-
- // 2. 연산 유효성 검증
- validateOperation(operation, currentContent);
-
- // 3. 연산 변환 (필요한 경우)
- EditOperation transformedOp = operation;
-
- if (operation.getRevision() < currentRevision) {
- log.debug("구버전 연산 감지: op.revision={}, current={}, 변환 필요",
- operation.getRevision(), currentRevision);
-
- // operation.revision 이후의 모든 히스토리 조회
- List history = noteRedisService.getOperations(noteId, operation.getRevision());
-
- log.debug("히스토리 조회 완료: {} 개의 연산", history.size());
-
- // 히스토리의 각 연산 로깅
- for (int i = 0; i < history.size(); i++) {
- EditOperation histOp = history.get(i);
- log.debug(" [{}] {}", i + 1, histOp);
- }
-
- // OT 변환 수행
- transformedOp = OTEngine.transformAgainstHistory(operation, history);
-
- log.debug("변환 완료: original={}, transformed={}", operation, transformedOp);
-
- // ⚠️ 변환 후 다시 검증하지 않음!
- // 변환된 operation은 현재 content에 대해 유효하지 않을 수 있지만,
- // OT 변환 알고리즘이 유효성을 보장하므로 검증 불필요.
- // 실제 적용 시 DELETE 범위 조정(line 124)으로 안전성 확보.
- }
-
- // 4. 연산을 content에 적용
- String newContent;
- try {
- // DELETE 연산의 경우 범위 초과를 자동으로 조정
- EditOperation adjustedOp = transformedOp;
- if (transformedOp.isDelete()) {
- int deleteStart = transformedOp.getPosition();
- int deleteEnd = transformedOp.getEndPosition();
- int contentLength = currentContent.length();
-
- // DELETE 범위가 content를 초과하는 경우 조정
- if (deleteEnd > contentLength) {
- log.warn("DELETE 범위 조정: pos={}, len={}, content.length={} → len={}",
- deleteStart, transformedOp.getLength(), contentLength,
- Math.max(0, contentLength - deleteStart));
-
- // 조정된 길이로 새 연산 생성
- int adjustedLength = Math.max(0, contentLength - deleteStart);
- adjustedOp = transformedOp.withLength(adjustedLength);
- }
- }
-
- newContent = OTEngine.applyOperation(currentContent, adjustedOp);
- } catch (IllegalArgumentException e) {
- log.error("연산 적용 실패: operation={}, content.length={}, error={}",
- transformedOp, currentContent.length(), e.getMessage());
- throw new NoteException(NoteErrorCode.INVALID_OPERATION);
- }
-
- // 5. Redis 업데이트
- // 5-1. Content 업데이트 (자동으로 dirty 플래그 설정됨)
- noteRedisService.setContent(noteId, newContent);
-
- // 5-2. Revision 증가
- int newRevision = noteRedisService.incrementRevision(noteId);
-
- // 5-3. 변환된 연산을 히스토리에 추가 (새 revision으로)
- EditOperation historyOp = transformedOp.isNoOp() ? transformedOp :
- EditOperation.builder()
- .type(transformedOp.getType())
- .position(transformedOp.getPosition())
- .length(transformedOp.getLength())
- .content(transformedOp.getContent())
- .revision(newRevision) // 새 revision 할당
- .workspaceMemberId(transformedOp.getWorkspaceMemberId())
- .timestamp(transformedOp.getTimestamp())
- .build();
-
- noteRedisService.addOperation(noteId, historyOp);
-
- log.info("편집 연산 처리 완료: noteId={}, revision={}, contentLength={}",
- noteId, newRevision, newContent.length());
-
- return new ProcessEditResult(newContent, newRevision, transformedOp);
- }
-
- /**
- * 편집 연산의 유효성을 검증합니다.
- *
- *
검증 항목:
- *
- *
position이 0 이상이고 content 길이 이하인지
- *
DELETE의 경우 position + length가 content 길이 이하인지
- *
length가 음수가 아닌지
- *
INSERT의 경우 content가 null이 아닌지
- *
- *
- * @param operation 검증할 연산
- * @param content 현재 문서 내용
- * @throws NoteException 연산이 유효하지 않은 경우
- */
- public void validateOperation(EditOperation operation, String content) {
- if (content == null) {
- content = "";
- }
-
- int position = operation.getPosition();
- int length = operation.getLength();
- int contentLength = content.length();
-
- // 1. position 범위 검증
- if (position < 0) {
- throw new NoteException(NoteErrorCode.INVALID_OPERATION,
- String.format("position은 0 이상이어야 합니다: position=%d", position));
- }
-
- if (operation.isInsert()) {
- // INSERT: position은 0 ~ contentLength (끝에 추가 가능)
- if (position > contentLength) {
- throw new NoteException(NoteErrorCode.INVALID_OPERATION,
- String.format("INSERT position 범위 초과: position=%d, contentLength=%d",
- position, contentLength));
- }
-
- if (operation.getContent() == null) {
- throw new NoteException(NoteErrorCode.INVALID_OPERATION,
- "INSERT 연산은 content가 필요합니다");
- }
- } else if (operation.isDelete()) {
- // 2. length 검증 (DELETE인 경우)
- if (length < 0) {
- throw new NoteException(NoteErrorCode.INVALID_OPERATION,
- String.format("length는 0 이상이어야 합니다: length=%d", length));
- }
-
- // DELETE: position은 0 ~ contentLength-1
- if (position > contentLength) {
- throw new NoteException(NoteErrorCode.INVALID_OPERATION,
- String.format("DELETE position 범위 초과: position=%d, contentLength=%d",
- position, contentLength));
- }
-
- // 3. DELETE 범위 검증
- if (position + length > contentLength) {
- throw new NoteException(NoteErrorCode.INVALID_OPERATION,
- String.format("DELETE 범위 초과: position=%d, length=%d, contentLength=%d",
- position, length, contentLength));
- }
- } else {
- throw new NoteException(NoteErrorCode.INVALID_OPERATION,
- "알 수 없는 연산 타입: " + operation.getType());
- }
-
- log.debug("연산 유효성 검증 통과: {}", operation);
- }
-
- /**
- * 편집 연산 후 모든 커서 위치를 조정합니다.
- *
- *
편집 연산(INSERT/DELETE)이 적용되면 다른 사용자들의 커서 위치도
- * 그에 맞춰 조정되어야 합니다.
- *
- *
+ * 1. 클라이언트 A: Y.Doc 편집 → Y.encodeStateAsUpdate() → Base64 → WebSocket 전송
+ * 2. 백엔드: WebSocket 수신 → Base64 디코딩 → Redis 저장 (NOTE:YDOC:{noteId})
+ * 3. 브로드캐스트: 다른 클라이언트에게 Update 전송
+ * 4. 클라이언트 B, C: Y.applyUpdate() → 자동 병합 (CRDT 특성)
+ *
+ *
*
TTL (Time To Live):
*
*
모든 키는 24시간 후 자동 삭제
@@ -42,7 +50,6 @@
*
*
* @see CursorPosition
- * @see EditOperation
*/
@Slf4j
@Service
@@ -56,63 +63,209 @@ public class NoteRedisService {
// TTL 설정: 24시간 동안 활동 없으면 자동 삭제
private static final Duration NOTE_TTL = Duration.ofHours(24);
- // Operation 히스토리 최대 개수: 메모리 절약을 위해 최근 100개만 유지
- private static final int MAX_OPERATIONS_HISTORY = 100;
-
- // ==================== Content 관리 ====================
+ // ==================== Yjs Y.Doc 관리 (CRDT 기반) ====================
/**
- * 노트 내용 조회
+ * Y.Doc 상태 조회 (Yjs Update 바이너리)
*
- *
Redis Key: NOTE:CONTENT:{noteId}
+ *
Redis Key: NOTE:YDOC:{noteId}
+ *
Redis Value: Base64 encoded Yjs Update (바이너리)
*
*
사용 시나리오:
*
- * // 사용자가 노트에 입장했을 때
- * String content = redisService.getContent(noteId);
- * if (content == null) {
+ * // 새 사용자가 노트 입장 시
+ * String base64Update = redisService.getYdocBinary(noteId);
+ * if (base64Update == null) {
* // Redis에 없으면 DB에서 조회 후 초기화
* Note note = noteRepository.findById(noteId);
- * redisService.initializeNote(noteId, note.getContent());
+ * redisService.initializeNote(noteId, note.getYdocBinary());
* }
+ * // 클라이언트에게 base64Update 전송 → applyUpdate()로 동기화
*
*
* @param noteId 노트 ID
- * @return 노트 내용 (없으면 null)
+ * @return Base64 encoded Yjs Update (없으면 null)
*/
- public String getContent(Long noteId) {
- String key = RedisKeyPrefix.NOTE_CONTENT.get(noteId);
- String content = redisStorage.getValueAsString(key);
- log.debug("Get note content: noteId={}, exists={}", noteId, content != null);
- return content;
+ public String getYdocBinary(Long noteId) {
+ String key = RedisKeyPrefix.NOTE_YDOC.get(noteId);
+ String base64Update = redisStorage.getValueAsString(key);
+ log.debug("Get Y.Doc: noteId={}, exists={}, size={}", noteId, base64Update != null,
+ base64Update != null ? base64Update.length() : 0);
+ return base64Update;
}
/**
- * 노트 내용 저장 및 dirty 플래그 자동 설정
+ * Y.Doc 상태 저장 (Yjs Update 바이너리) 및 dirty 플래그 자동 설정
*
- *
Redis Key: NOTE:CONTENT:{noteId}
+ *
Redis Key: NOTE:YDOC:{noteId}
+ *
Redis Value: Base64 encoded Yjs Update
*
*
중요: 이 메서드를 호출하면 자동으로 dirty 플래그가 true로 설정됩니다.
* dirty 플래그는 스케줄러가 "DB에 저장해야 할 노트"를 찾는 데 사용됩니다.
*
*
사용 시나리오:
*
- * // 사용자가 편집했을 때
- * String currentContent = redisService.getContent(noteId);
- * String newContent = applyEdit(currentContent, operation);
- * redisService.setContent(noteId, newContent); // dirty 자동 설정
- *
- * // 30초 후 스케줄러가 자동으로 DB 저장
+ * // WebSocket에서 Yjs Update 수신
+ * String base64Update = wsMessage.getUpdate(); // "SGVsbG8gV29ybGQ="
+ * redisService.setYdocBinary(noteId, base64Update); // dirty 자동 설정
+ *
+ * // 30초 후 스케줄러가 자동으로 DB에 저장
+ * Note note = noteRepository.findById(noteId);
+ * note.setYdocBinary(base64Update);
+ * noteRepository.save(note);
*
주의: 이 메서드는 dirty 플래그를 설정하지 않습니다.
+ * DB에서 노트를 로드하여 Redis를 초기화할 때만 사용하세요.
+ * 사용자 편집으로 인한 Update 저장 시에는 setYdocBinary() 또는 applyYjsUpdate()를 사용하세요.
+ *
+ *
사용 시나리오:
+ *
+ * // 첫 사용자가 노트에 입장했을 때, DB에서 기존 상태 로드
+ * String dbYdoc = note.getYdocBinary();
+ * redisService.setYdocBinaryWithoutDirty(noteId, dbYdoc);
+ * // dirty 플래그가 설정되지 않으므로 자동 저장되지 않음
+ *
- * // 편집 연산 적용 후 버전 증가
- * String currentContent = redisService.getContent(noteId);
- * String newContent = OTEngine.apply(currentContent, operation);
- * redisService.setContent(noteId, newContent);
- *
- * int newRevision = redisService.incrementRevision(noteId); // 원자적으로 증가
- *
- * // 다른 사용자들에게 브로드캐스트
- * broadcast("content updated", newRevision);
- *
- *
- * @param noteId 노트 ID
- * @return 증가된 새 revision 번호
- */
- public int incrementRevision(Long noteId) {
- String key = RedisKeyPrefix.NOTE_REVISION.get(noteId);
- Long newRevision = redisTemplate.opsForValue().increment(key);
- refreshTTL(key);
- log.debug("Increment revision: noteId={}, newRevision={}", noteId, newRevision);
- return newRevision != null ? newRevision.intValue() : 1;
- }
-
- // ==================== Operation 히스토리 (OT용) ====================
+ // ==================== State Vector 관리 (Yjs 상태 추적용) ====================
/**
- * operations 리스트에 추가 (최근 100개만 유지)
+ * State Vector 조회 (상태 추적용)
*
- *
Redis Key: NOTE:OPERATIONS:{noteId}
- *
Redis Type: List (순서가 있는 리스트)
+ *
Redis Key: NOTE:STATE_VECTOR:{noteId}
+ *
Redis Value: Base64 encoded state vector
*
- *
List 구조:
- *
- * NOTE:OPERATIONS:123 = [
- * '{"type":"insert","position":5,"revision":5,...}', // 가장 오래된 연산
- * '{"type":"delete","position":10,"revision":6,...}',
- * ...
- * '{"type":"insert","position":20,"revision":104,...}' // 가장 최근 연산
- * ]
- *
- *
- *
최근 100개만 유지하는 이유:
+ *
State Vector란?
*
- *
메모리 절약: 무한정 쌓이면 메모리 부족
- *
충분한 히스토리: 대부분의 충돌은 최근 몇 개 연산으로 해결
- *
오래된 연산은 이미 DB에 저장되어 복구 가능
+ *
각 클라이언트/서버의 "이 정도까지 봤다"는 상태 정보
+ *
클라이언트가 노트에 입장할 때, 서버가 필요한 Update만 선택적으로 전송하기 위해 사용
+ *
현재 구현에서는 선택적 기능 (모든 Update를 전송해도 CRDT가 처리함)
*
*
*
사용 시나리오:
*
- * // 편집 연산 적용 후 히스토리에 추가
- * EditOperation operation = EditOperation.insert(5, "Hello", 42, workspaceMemberId);
- * redisService.addOperation(noteId, operation);
+ * // 클라이언트가 노트 입장 시
+ * String clientStateVector = wsMessage.getStateVector(); // 클라이언트가 알고 있는 상태
+ * String serverStateVector = redisService.getStateVector(noteId); // 서버의 상태
*
- * // 나중에 다른 사용자가 충돌 해결 시 사용
- * List history = redisService.getOperations(noteId, 40);
+ * // 필요한 Update만 필터링해서 전송 (현재는 모두 전송)
+ * String update = redisService.getYdocBinary(noteId);
+ * wsService.sendUpdate(clientId, update);
*
*
* @param noteId 노트 ID
- * @param operation 추가할 편집 연산
+ * @return Base64 encoded state vector (없으면 null)
*/
- public void addOperation(Long noteId, EditOperation operation) {
- String key = RedisKeyPrefix.NOTE_OPERATIONS.get(noteId);
- try {
- String json = redisObjectMapper.writeValueAsString(operation);
- redisTemplate.opsForList().rightPush(key, json); // 리스트 맨 뒤에 추가
-
- // 최근 100개만 유지 (LTRIM 명령)
- Long size = redisTemplate.opsForList().size(key);
- if (size != null && size > MAX_OPERATIONS_HISTORY) {
- // 예: size=150이면 앞의 50개 삭제, 뒤의 100개만 남김
- redisTemplate.opsForList().trim(key, size - MAX_OPERATIONS_HISTORY, -1);
- }
-
- refreshTTL(key);
- log.debug("Add operation: noteId={}, type={}, position={}", noteId, operation.getType(), operation.getPosition());
- } catch (JsonProcessingException e) {
- log.error("Failed to serialize edit operation", e);
- }
+ public String getStateVector(Long noteId) {
+ String key = RedisKeyPrefix.NOTE_STATE_VECTOR.get(noteId);
+ String stateVector = redisStorage.getValueAsString(key);
+ log.debug("Get state vector: noteId={}, exists={}", noteId, stateVector != null);
+ return stateVector;
}
/**
- * 특정 revision 이후의 operations 조회
- *
- *
OT Transform에서 사용:
- *
- *
클라이언트가 revision 5를 기준으로 편집했는데
- *
서버는 이미 revision 8이면
- *
revision 6, 7, 8 연산을 가져와서 클라이언트 연산과 transform
- *
- *
- *
사용 시나리오:
- *
- * // 클라이언트가 보낸 연산
- * EditOperation clientOp = ...; // revision = 5
- * int serverRevision = redisService.getRevision(noteId); // 8
+ * State Vector 저장
*
- * if (clientOp.getRevision() < serverRevision) {
- * // revision 6, 7, 8 연산 가져오기
- * List missedOps = redisService.getOperations(noteId, 6);
+ *
현재 구현에서는 선택적 기능입니다.
+ * 필요하면 클라이언트가 보낸 state vector를 저장할 수 있습니다.
*
* @param noteId 노트 ID
- * @param fromRevision 이 revision 이후의 연산만 조회 (inclusive)
- * @return 필터링된 연산 리스트 (시간순 정렬)
+ * @param stateVectorBase64 Base64 encoded state vector
*/
- public List getOperations(Long noteId, int fromRevision) {
- String key = RedisKeyPrefix.NOTE_OPERATIONS.get(noteId);
- List