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 변환 - * - *

시나리오: 두 사용자가 동시에 텍스트를 삽입 - * - *

변환 규칙: - *

- * - *

예시: - *

-     * 문서: "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 변환 - * - *

시나리오: 한 사용자는 삽입하고, 다른 사용자는 삭제 - * - *

변환 규칙: - *

- * - *

예시: - *

-     * 문서: "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 변환 - * - *

시나리오: 한 사용자는 삭제하고, 다른 사용자는 삽입 - * - *

변환 규칙: - *

- * - *

예시: - *

-     * 문서: "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 변환 - * - *

시나리오: 두 사용자가 동시에 텍스트를 삭제 - * - *

변환 규칙: - *

- * - *

예시 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을 반복 적용합니다. - * - *

예시: - *

-     * op.revision = 5
-     * history = [op6, op7, op8, op9] (revision 6~9)
-     *
-     * 변환 과정:
-     * 1. op' = transform(op, op6)
-     * 2. op'' = transform(op', op7)
-     * 3. op''' = transform(op'', op8)
-     * 4. op'''' = transform(op''', op9)
-     * 반환: op''''
-     * 
- * - * @param op 변환할 연산 - * @param history 적용된 연산 히스토리 (op.revision 이후의 연산들) - * @return 모든 히스토리에 대해 변환된 연산 - */ - public static EditOperation transformAgainstHistory(EditOperation op, List history) { - EditOperation transformedOp = op; - - for (EditOperation appliedOp : history) { - // op.revision 이후의 연산들만 변환에 사용 - if (appliedOp.getRevision() > op.getRevision()) { - transformedOp = transform(transformedOp, appliedOp); - - // no-op이 되면 더 이상 변환할 필요 없음 - if (transformedOp.isNoOp()) { - log.debug("Operation became no-op after transform: original={}, appliedOp={}", - op, appliedOp); - break; - } - } - } - - return transformedOp; - } - - /** - * 연산을 문서 내용에 실제로 적용합니다. - * - *

INSERT: content.substring(0, pos) + op.content + content.substring(pos) - *

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를 통합하여 실시간 협업 편집을 처리합니다. - * - *

주요 기능: - *

- * - *

처리 흐름: - *

    - *
  1. 클라이언트로부터 EditOperation 수신
  2. - *
  3. 연산 유효성 검증 (validateOperation)
  4. - *
  5. 현재 revision 확인
  6. - *
  7. op.revision < currentRevision이면 히스토리를 통해 변환 (transformAgainstHistory)
  8. - *
  9. 변환된 연산을 문서에 적용 (applyOperation)
  10. - *
  11. Redis 업데이트: - *
      - *
    • content 업데이트
    • - *
    • operations 히스토리에 추가
    • - *
    • revision 증가
    • - *
    • dirty 플래그 설정
    • - *
    - *
  12. - *
  13. 새 content와 revision 반환
  14. - *
- */ -@Service -@RequiredArgsConstructor -@Slf4j -public class OTService { - - private final NoteRedisService noteRedisService; - - /** - * 편집 연산을 처리합니다. - * - *

이 메서드는 다음 단계를 수행합니다: - *

    - *
  1. Redis에서 현재 content, revision, operations 조회
  2. - *
  3. 연산 유효성 검증
  4. - *
  5. op.revision이 구버전이면 OT 변환 수행
  6. - *
  7. 변환된 연산을 content에 적용
  8. - *
  9. Redis 업데이트
  10. - *
- * - *

사용 예시: - *

{@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); - } - - /** - * 편집 연산의 유효성을 검증합니다. - * - *

검증 항목: - *

- * - * @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)이 적용되면 다른 사용자들의 커서 위치도 - * 그에 맞춰 조정되어야 합니다. - * - *

조정 규칙: - *

- * - *

사용 예시: - *

{@code
-     * // 편집 처리 후 커서 조정
-     * ProcessEditResult result = otService.processEdit(noteId, operation);
-     * Map adjustedCursors = otService.adjustCursors(noteId, result.appliedOperation());
-     * // adjustedCursors를 클라이언트에 브로드캐스트
-     * }
- * - * @param noteId 노트 ID - * @param operation 적용된 편집 연산 - * @return 조정된 커서 맵 (workspaceMemberId -> 조정된 CursorPosition) - */ - public Map adjustCursors(Long noteId, EditOperation operation) { - // 1. Redis에서 모든 현재 커서 조회 - Map currentCursors = noteRedisService.getAllCursors(noteId); - - if (currentCursors.isEmpty()) { - log.debug("조정할 커서가 없음: noteId={}", noteId); - return Map.of(); - } - - Map adjustedCursors = new HashMap<>(); - int opPosition = operation.getPosition(); - int opLength = operation.getLength(); - - log.debug("커서 조정 시작: noteId={}, operation={}, cursorCount={}", - noteId, operation, currentCursors.size()); - - // 2. 각 커서에 대해 조정 로직 적용 - for (Map.Entry entry : currentCursors.entrySet()) { - try { - Long workspaceMemberId = Long.parseLong(entry.getKey()); - CursorPosition cursor = entry.getValue(); - - CursorPosition adjustedCursor = adjustCursor(cursor, operation); - - // 3. Redis에 조정된 커서 저장 - noteRedisService.setCursor(noteId, workspaceMemberId, adjustedCursor); - - adjustedCursors.put(workspaceMemberId, adjustedCursor); - - log.trace("커서 조정: workspaceMemberId={}, original={}, adjusted={}", - workspaceMemberId, cursor.getPosition(), adjustedCursor.getPosition()); - - } catch (NumberFormatException e) { - log.warn("잘못된 workspaceMemberId 형식: key={}", entry.getKey()); - } - } - - log.debug("커서 조정 완료: noteId={}, adjustedCount={}", noteId, adjustedCursors.size()); - return adjustedCursors; - } - - /** - * 단일 커서 위치를 편집 연산에 맞춰 조정합니다. - * - * @param cursor 조정할 커서 - * @param operation 편집 연산 - * @return 조정된 커서 - */ - private CursorPosition adjustCursor(CursorPosition cursor, EditOperation operation) { - int cursorPos = cursor.getPosition(); - int cursorRange = cursor.getRange(); - int opPosition = operation.getPosition(); - int opLength = operation.getLength(); - - int newPosition = cursorPos; - int newRange = cursorRange; - - if (operation.isInsert()) { - // INSERT: operation.position <= cursor.position이면 오른쪽으로 이동 - if (opPosition <= cursorPos) { - newPosition = cursorPos + opLength; - } - // 선택 영역이 있고, operation이 선택 범위 내에 있으면 range 조정 - else if (cursorRange > 0 && opPosition < cursorPos + cursorRange) { - newRange = cursorRange + opLength; - } - - } else if (operation.isDelete()) { - int deleteEnd = opPosition + opLength; - - // Case 1: 커서가 삭제 범위보다 뒤에 있음 → 왼쪽으로 이동 - if (cursorPos >= deleteEnd) { - newPosition = cursorPos - opLength; - } - // Case 2: 커서가 삭제 범위 내에 있음 → 삭제 시작점으로 이동 - else if (cursorPos >= opPosition && cursorPos < deleteEnd) { - newPosition = opPosition; - newRange = 0; // 선택 영역 취소 - } - // Case 3: 커서는 앞에 있지만 선택 영역이 삭제 범위와 겹침 - else if (cursorRange > 0) { - int cursorEnd = cursorPos + cursorRange; - if (cursorEnd > opPosition) { - // 선택 영역이 삭제 범위와 겹침 → range 조정 - if (cursorEnd <= deleteEnd) { - // 선택 영역의 일부만 삭제됨 - newRange = opPosition - cursorPos; - } else { - // 선택 영역이 삭제 범위를 포함 - newRange = cursorRange - opLength; - } - } - } - } - - // 음수 방지 - newPosition = Math.max(0, newPosition); - newRange = Math.max(0, newRange); - - return CursorPosition.builder() - .position(newPosition) - .range(newRange) - .workspaceMemberId(cursor.getWorkspaceMemberId()) - .userName(cursor.getUserName()) - .profileImage(cursor.getProfileImage()) - .color(cursor.getColor()) - .build(); - } - - /** - * 편집 처리 결과 DTO - * - * @param content 새 문서 내용 - * @param revision 새 버전 번호 - * @param appliedOperation 실제로 적용된 연산 (변환 후) - */ - public record ProcessEditResult( - String content, - int revision, - EditOperation appliedOperation - ) {} -} diff --git a/src/main/java/com/project/syncly/domain/note/validator/EditOperationValidator.java b/src/main/java/com/project/syncly/domain/note/validator/EditOperationValidator.java deleted file mode 100644 index 9ba0bb1..0000000 --- a/src/main/java/com/project/syncly/domain/note/validator/EditOperationValidator.java +++ /dev/null @@ -1,224 +0,0 @@ -package com.project.syncly.domain.note.validator; - -import com.project.syncly.domain.note.dto.EditOperation; -import com.project.syncly.domain.note.exception.NoteErrorCode; -import com.project.syncly.domain.note.exception.NoteException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -/** - * 편집 연산 유효성 검증 컴포넌트 - * - *

클라이언트로부터 받은 편집 연산(INSERT/DELETE)을 검증합니다. - * OT(Operational Transformation) 알고리즘의 안전성을 보장합니다. - */ -@Slf4j -@Component -public class EditOperationValidator { - - /** - * 편집 연산 전체 유효성 검증 - * - *

다음을 확인합니다: - *

    - *
  • 연산 타입이 INSERT 또는 DELETE인가?
  • - *
  • position이 유효한가? (0 이상)
  • - *
  • length가 유효한가? (0 이상)
  • - *
  • 현재 내용에 대해 연산이 유효한가?
  • - *
  • INSERT 시 content가 null이 아닌가?
  • - *
  • revision이 유효한가? (0 이상)
  • - *
- * - * @param operation 검증할 편집 연산 - * @param currentContent 현재 노트 내용 - * @throws NoteException 연산이 유효하지 않음 - */ - public void validate(EditOperation operation, String currentContent) { - if (operation == null) { - log.warn("null 편집 연산 시도"); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, "연산이 null입니다"); - } - - // 1. 연산 타입 검증 - validateOperationType(operation.getType()); - - // 2. 기본 필드 검증 - validateBasicFields(operation); - - // 3. 연산별 상세 검증 - if ("insert".equalsIgnoreCase(operation.getType())) { - validateInsertOperation(operation, currentContent); - } else if ("delete".equalsIgnoreCase(operation.getType())) { - validateDeleteOperation(operation, currentContent); - } - - // 4. 메타데이터 검증 - validateMetadata(operation); - - log.debug("편집 연산 검증 성공: type={}, position={}, revision={}", - operation.getType(), operation.getPosition(), operation.getRevision()); - } - - /** - * 연산 타입 검증 - * - * @param type 연산 타입 ("insert" 또는 "delete") - * @throws NoteException 지원하지 않는 연산 타입 - */ - private void validateOperationType(String type) { - if (type == null) { - log.warn("null 연산 타입"); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, "연산 타입이 null입니다"); - } - - String lowerType = type.toLowerCase(); - if (!lowerType.equals("insert") && !lowerType.equals("delete")) { - log.warn("지원하지 않는 연산 타입: type={}", type); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - String.format("지원하지 않는 연산 타입: %s (insert 또는 delete만 가능)", type)); - } - } - - /** - * 기본 필드 검증 - * - *

position과 length의 기본 유효성을 확인합니다. - * - * @param operation 편집 연산 - * @throws NoteException position 또는 length가 유효하지 않음 - */ - private void validateBasicFields(EditOperation operation) { - // position 검증 - if (operation.getPosition() < 0) { - log.warn("음수 position: position={}", operation.getPosition()); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - String.format("position은 0 이상이어야 합니다 (현재: %d)", operation.getPosition())); - } - - // length 검증 (DELETE 연산에서만 사용) - if ("delete".equalsIgnoreCase(operation.getType())) { - if (operation.getLength() <= 0) { - log.warn("DELETE 연산 길이 검증 실패: length={}", operation.getLength()); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - String.format("DELETE 연산의 length는 1 이상이어야 합니다 (현재: %d)", operation.getLength())); - } - } - } - - /** - * INSERT 연산 검증 - * - *

다음을 확인합니다: - *

    - *
  • 삽입할 내용(content)이 null이 아닌가?
  • - *
  • 삽입 위치가 현재 내용 길이 이하인가?
  • - *
- * - * @param operation INSERT 연산 - * @param currentContent 현재 노트 내용 - * @throws NoteException INSERT 연산이 유효하지 않음 - */ - private void validateInsertOperation(EditOperation operation, String currentContent) { - // 삽입할 내용 검증 - if (operation.getContent() == null || operation.getContent().isEmpty()) { - log.warn("INSERT 연산에서 빈 내용: content={}", operation.getContent()); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - "INSERT 연산의 content는 비워둘 수 없습니다"); - } - - // 삽입 위치 검증 - int contentLength = currentContent != null ? currentContent.length() : 0; - if (operation.getPosition() > contentLength) { - log.warn("INSERT 연산 위치 초과: position={}, contentLength={}", - operation.getPosition(), contentLength); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - String.format("INSERT 위치는 %d 이하여야 합니다 (현재: %d)", - contentLength, operation.getPosition())); - } - } - - /** - * DELETE 연산 검증 - * - *

다음을 확인합니다: - *

    - *
  • 삭제 범위가 현재 내용을 초과하지 않는가?
  • - *
  • 삭제할 내용이 있는가? (position + length <= content.length())
  • - *
- * - * @param operation DELETE 연산 - * @param currentContent 현재 노트 내용 - * @throws NoteException DELETE 연산이 유효하지 않음 - */ - private void validateDeleteOperation(EditOperation operation, String currentContent) { - int contentLength = currentContent != null ? currentContent.length() : 0; - - // position + length가 content 범위를 초과하지 않는지 확인 - if (operation.getPosition() + operation.getLength() > contentLength) { - log.warn("DELETE 연산 범위 초과: position={}, length={}, contentLength={}", - operation.getPosition(), operation.getLength(), contentLength); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - String.format("DELETE 범위가 내용을 초과합니다 (position+length=%d, content length=%d)", - operation.getPosition() + operation.getLength(), contentLength)); - } - } - - /** - * 메타데이터 검증 - * - *

revision, workspaceMemberId 등을 검증합니다. - * - * @param operation 편집 연산 - * @throws NoteException 메타데이터가 유효하지 않음 - */ - private void validateMetadata(EditOperation operation) { - // revision 검증 - if (operation.getRevision() < 0) { - log.warn("음수 revision: revision={}", operation.getRevision()); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - String.format("revision은 0 이상이어야 합니다 (현재: %d)", operation.getRevision())); - } - - // workspaceMemberId 검증 - if (operation.getWorkspaceMemberId() == null || operation.getWorkspaceMemberId() <= 0) { - log.warn("유효하지 않은 workspaceMemberId: workspaceMemberId={}", - operation.getWorkspaceMemberId()); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, - "workspaceMemberId가 유효하지 않습니다"); - } - } - - /** - * 현재 revision과 연산의 revision 비교 - * - *

OT 알고리즘에서 필요한 검증입니다. - * 클라이언트가 본 revision과 서버의 현재 revision이 일치하지 않으면 - * transformation이 필요합니다. - * - * @param operationRevision 연산의 revision - * @param serverRevision 서버의 현재 revision - * @throws NoteException revision이 일치하지 않음 (transformation 필요) - */ - public void validateRevisionMatch(int operationRevision, int serverRevision) { - if (operationRevision != serverRevision) { - log.warn("revision 불일치: operationRevision={}, serverRevision={}", - operationRevision, serverRevision); - throw new NoteException(NoteErrorCode.REVISION_MISMATCH); - } - } - - /** - * 편집 작업자의 유효성 검증 - * - *

편집 연산을 수행하는 사용자가 노트에 접근 가능한지 확인합니다. - * - * @param operation 편집 연산 - * @param noteCreatorId 노트 작성자 ID - */ - public void validateEditor(EditOperation operation, Long noteCreatorId) { - if (operation.getWorkspaceMemberId() == null || operation.getWorkspaceMemberId() <= 0) { - log.warn("유효하지 않은 편집자: workspaceMemberId={}", operation.getWorkspaceMemberId()); - throw new NoteException(NoteErrorCode.INVALID_OPERATION, "편집자 정보가 유효하지 않습니다"); - } - } -} From c26eda5a6250966ab088c3a7ef104dc2fc6e2b21 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Tue, 4 Nov 2025 10:32:51 +0900 Subject: [PATCH 2/7] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BACKEND_CHANGES_TECHNICAL.md | 303 ------------------- FRONTEND_INTEGRATION_GUIDE.md | 547 ---------------------------------- k6-folder-closure-only.js | 118 -------- k6-recursive-only.js | 122 -------- 4 files changed, 1090 deletions(-) delete mode 100644 BACKEND_CHANGES_TECHNICAL.md delete mode 100644 FRONTEND_INTEGRATION_GUIDE.md delete mode 100644 k6-folder-closure-only.js delete mode 100644 k6-recursive-only.js diff --git a/BACKEND_CHANGES_TECHNICAL.md b/BACKEND_CHANGES_TECHNICAL.md deleted file mode 100644 index e348295..0000000 --- a/BACKEND_CHANGES_TECHNICAL.md +++ /dev/null @@ -1,303 +0,0 @@ -# feat/#125: 노트 기능 + 실시간 협업 편집 기술 명세서 - -## 개요 -이 브랜치는 **노트(Note) 기능**과 **실시간 협업 편집(Operational Transformation 기반)**을 지원하는 백엔드 인프라를 구현합니다. - ---- - -## 1. 노트 도메인 추가 (Note Domain) - -### 1.1 새로운 엔드포인트 -- `POST /api/notes/{noteId}/images/presigned-url` - 노트 이미지 업로드용 Presigned URL 생성 - -### 1.2 WebSocket 엔드포인트 -- `WS /ws/note` - 노트 실시간 협업 편집용 WebSocket 연결 - - 메시지 형식: STOMP 프로토콜 - - 토픽: `/topic/notes/{noteId}/...` (구독) - - 전송: `/app/notes/{noteId}/...` (발행) - ---- - -## 2. 보안 및 인증 강화 - -### 2.1 SecurityConfig 변경사항 -```java -// 기존 WebSocket 엔드포인트 (/ws-stomp)에 추가로 노트 WebSocket 보호 -"/ws/note", // 인증 불필요 (WebSocketInterceptor에서 JWT 검증) -"/ws/note/**" -``` - -**특징:** -- 노트 WebSocket 연결 시 JWT 토큰이 없으면 차단 -- 노트 구독/발행 시 워크스페이스 멤버십 확인 - -### 2.2 StompHandler 권한 검증 -``` -CONNECT: JWT 토큰 검증 - ↓ -SUBSCRIBE/SEND: - - 노트 ID 추출 (destination 파싱) - - 노트 존재 여부 확인 - - 사용자가 해당 워크스페이스 멤버인지 확인 - - 위반 시: NoteException(NOTE_ACCESS_DENIED) 발생 -``` - -**정규식 패턴:** -``` -^/(app|topic|queue)/notes/(\d+)/ -예: /app/notes/123/edit, /topic/notes/123/cursor -``` - ---- - -## 3. 예외 처리 강화 - -### 3.1 GlobalExceptionHandler 추가 -| 예외 타입 | HTTP 상태 | 처리 내용 | -|---------|---------|---------| -| `NoteException` | 동적 | 노트 관련 비즈니스 로직 오류 | -| `RedisConnectionFailureException` | 503 | Redis 서버 연결 실패 | -| `OptimisticLockingFailureException` | 409 | 동시 편집 충돌 (revision 불일치) | -| `NoHandlerFoundException` | 404 | 존재하지 않는 엔드포인트 | -| `Exception` (일반) | 500 | 예상치 못한 서버 오류 | - -**응답 형식:** -```json -{ - "code": "Note409_2", - "message": "동시 편집 충돌이 발생했습니다", - "data": { - "code": "Note409_2", - "timestamp": "2025-10-25T10:30:45", - "path": "/api/notes/123", - "action": "RELOAD" // 클라이언트 액션 지정 - } -} -``` - -### 3.2 WebSocketExceptionHandler 강화 -``` -NoteException - ↓ -determineClientAction(errorCode) - ↓ -클라이언트에게 /queue/errors로 메시지 전송 - ↓ -클라이언트는 action에 따라: - - RELOAD: 페이지 새로고침 - - RETRY: 작업 재시도 - - RECONNECT: WebSocket 재연결 - - REDIRECT_LOGIN: 로그인 페이지로 이동 -``` - ---- - -## 4. WebSocket 세션 관리 - -### 4.1 WebSocketEventListener 개선 - -**CONNECT 시:** -``` -Redis: - SET WS:SESSIONS:{sessionId} → userId - SADD WS:ONLINE_USERS → userId -``` - -**DISCONNECT 시:** -``` -1️⃣ 일반 WebSocket 세션 정리 - DEL WS:SESSIONS:{sessionId} - SREM WS:ONLINE_USERS userId - -2️⃣ 노트 WebSocket 세션 정리 (있는 경우) - noteSessionData = GET WS:NOTE_SESSIONS:{sessionId} - format: "{noteId}:{workspaceMemberId}" - ↓ - noteRedisService.removeUser(noteId, workspaceMemberId) - noteRedisService.removeCursor(noteId, workspaceMemberId) - DEL WS:NOTE_SESSIONS:{sessionId} -``` - -### 4.2 Redis 키 추가 -``` -WS:NOTE_SESSIONS:{sessionId} → "{noteId}:{workspaceMemberId}" -``` - ---- - -## 5. S3 통합 개선 - -### 5.1 노트 이미지 업로드 경로 -``` -기존: uploads/{UUID}.{ext} -신규: notes/{noteId}/{UUID}.{ext} -``` - -### 5.2 S3Util 새로운 메서드 -```java -public boolean objectExists(String objectKey) -``` -- headObject를 사용하여 S3 객체 존재 여부 빠르게 확인 -- Exception 발생 시 false 반환 - ---- - -## 6. 모니터링 및 메트릭 - -### 6.1 Actuator 설정 -```yaml -management: - endpoints: - web: - exposure: - include: health,metrics,prometheus - endpoint: - health: - show-details: always -``` - -**노출 엔드포인트:** -- `GET /actuator/health` - 애플리케이션 상태 -- `GET /actuator/metrics` - 메트릭 목록 -- `GET /actuator/prometheus` - Prometheus 형식 메트릭 - -### 6.2 자동 저장 메트릭 -```yaml -note.autosave.duration # 자동 저장 소요 시간 -note.autosave.save.time # 저장 시점 -note.manual.save.time # 수동 저장 시점 -``` - ---- - -## 7. 로깅 설정 - -### 7.1 WebSocket 편집 디버깅 로그 -```xml - - - -``` - -**실시간 편집 추적:** -- 메시지 수신/발송 -- OT 변환 과정 -- 엔진 상태 변화 - ---- - -## 8. 스케줄러 설정 - -### 8.1 자동 저장 스케줄러 -```yaml -scheduler: - note: - auto-save: - fixed-delay: 30000 # 30초 간격 - initial-delay: 30000 # 시작 후 30초 후 첫 실행 -``` - -**동작:** -- 30초마다 자동 저장 작업 실행 -- Redis에 저장된 변경사항을 DB에 저장 -- Actuator 메트릭으로 모니터링 가능 - ---- - -## 9. 의존성 추가 - -### 9.1 build.gradle -```gradle -// Actuator & Micrometer (모니터링 메트릭용) -implementation 'org.springframework.boot:spring-boot-starter-actuator' -implementation 'io.micrometer:micrometer-core' -``` - ---- - -## 10. 클라이언트 대응 액션 명세 - -WebSocket 에러 응답에 `action` 필드 포함: - -| Action | 의미 | 클라이언트 처리 | -|--------|------|----------------| -| `RELOAD` | 페이지 새로고침 필요 | location.reload() | -| `RETRY` | 작업 재시도 가능 | 지수 백오프로 재시도 | -| `RECONNECT` | WebSocket 재연결 필요 | WebSocket 재연결 시도 | -| `REDIRECT_LOGIN` | 로그인 필요 | 로그인 페이지로 이동 | -| `IGNORE` | 무시 가능한 에러 | 아무것도 하지 않음 | - ---- - -## 11. 데이터 흐름 - -### 11.1 노트 편집 흐름 -``` -클라이언트 A (에디터) - ↓ (operation) -WebSocket /app/notes/123/edit - ↓ (StompHandler: 권한 검증) -NoteWebSocketController - ↓ (OT 변환) -OTService/OTEngine - ↓ (Redis 저장) -모든 클라이언트 - ↓ (WebSocket broadcast) -/topic/notes/123/edit - ↓ -클라이언트 B, C (구독자들) -``` - -### 11.2 이미지 업로드 흐름 -``` -클라이언트 - ↓ (POST /api/notes/{noteId}/images/presigned-url) -S3Controller - ↓ -S3ServiceImpl (경로: notes/{noteId}/{UUID}.{ext}) - ↓ -S3Util (presigned URL 생성) - ↓ -클라이언트 (직접 S3에 업로드) -``` - ---- - -## 12. 주의사항 - -### 12.1 JWT 토큰 형식 -``` -Authorization: Bearer {token} -``` -- 모든 WebSocket CONNECT 요청에 포함 필수 -- Header 이름: `Authorization` -- 형식: `Bearer {token}` - -### 12.2 에러 응답 호환성 -- 기존 CustomException 형식 유지 -- 새로운 에러 코드는 NoteErrorCode에 정의 -- action 필드는 선택사항 (미지원 클라이언트 대응) - -### 12.3 동시 편집 충돌 처리 -- `OptimisticLockingFailureException` 발생 시 409 Conflict -- 클라이언트는 RELOAD 액션으로 페이지 새로고침 -- 충돌 감지는 노트 엔티티의 @Version 필드로 구현 - ---- - -## 13. 마이그레이션 체크리스트 - -- [ ] 노트 엔티티 DB 마이그레이션 (Flyway/Liquibase) -- [ ] Redis 초기화 (기존 WebSocket 데이터와 충돌 여부 확인) -- [ ] 모니터링 대시보드 (Prometheus/Grafana) 설정 -- [ ] 클라이언트에 WebSocket 프로토콜 문서 전달 -- [ ] 로드 테스트 (동시 사용자 100+ 동시 편집) - ---- - -## 참고 자료 - -- JWT 토큰 검증: `JwtProvider.getAuthentication(token)` -- 노트 권한 검증: `WorkspaceMemberRepository.existsByWorkspaceIdAndMemberId()` -- OT 엔진: `OTService`, `OTEngine` (새 클래스) -- Redis 명령: `NoteRedisService` (새 클래스) diff --git a/FRONTEND_INTEGRATION_GUIDE.md b/FRONTEND_INTEGRATION_GUIDE.md deleted file mode 100644 index 9596407..0000000 --- a/FRONTEND_INTEGRATION_GUIDE.md +++ /dev/null @@ -1,547 +0,0 @@ -# feat/#125: 노트 기능 API 및 WebSocket 가이드 - -> 🎯 **프론트엔드 개발자용** 통합 가이드입니다. 백엔드가 제공하는 API와 WebSocket 프로토콜을 이해하고 구현할 수 있도록 작성되었습니다. - ---- - -## 📋 목차 -1. [WebSocket 연결](#1-websocket-연결) -2. [실시간 노트 편집](#2-실시간-노트-편집) -3. [에러 처리](#3-에러-처리) -4. [이미지 업로드](#4-이미지-업로드) -5. [예제 코드](#5-예제-코드) - ---- - -## 1. WebSocket 연결 - -### 1.1 연결 엔드포인트 -``` -ws://localhost:8080/ws/note -또는 -wss://your-domain.com/ws/note (HTTPS) -``` - -### 1.2 연결 방법 (STOMP) - -#### Step 1: 라이브러리 설치 -```bash -npm install stompjs -``` - -#### Step 2: 연결 코드 -```javascript -import { Client } from '@stomp/stompjs'; - -const client = new Client({ - brokerURL: 'ws://localhost:8080/ws/note', - connectHeaders: { - 'Authorization': `Bearer ${jwtToken}` // ⭐ 필수! - }, - onConnect: () => { - console.log('WebSocket 연결 성공'); - }, - onDisconnect: () => { - console.log('WebSocket 연결 해제'); - }, - onStompError: (error) => { - console.error('STOMP 에러:', error); - } -}); - -client.activate(); -``` - -### 1.3 JWT 토큰 -``` -Authorization 헤더 형식: - Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... -``` - -⚠️ **토큰이 없으면 연결이 거부됩니다!** - ---- - -## 2. 실시간 노트 편집 - -### 2.1 메시지 구조 - -#### 발행 (Publish - 클라이언트 → 서버) -```javascript -client.publish({ - destination: '/app/notes/{noteId}/edit', - body: JSON.stringify({ - operation: { - type: 'insert', // 'insert' | 'delete' - position: 5, // 커서 위치 - content: 'hello', // 삽입할 텍스트 - revision: 10 // 현재 리비전 - } - }) -}); -``` - -#### 구독 (Subscribe - 서버 → 클라이언트) -```javascript -client.subscribe('/topic/notes/{noteId}/edit', (message) => { - const data = JSON.parse(message.body); - console.log('원격 변경:', data.operation); - // UI 업데이트 -}); -``` - -### 2.2 Operation 타입 - -| Type | 설명 | 필수 필드 | -|------|------|---------| -| `insert` | 텍스트 삽입 | position, content, revision | -| `delete` | 텍스트 삭제 | position, length, revision | - -### 2.3 예제: 텍스트 삽입 -```javascript -// 5번 위치에 "hello" 삽입 -client.publish({ - destination: '/app/notes/123/edit', - body: JSON.stringify({ - operation: { - type: 'insert', - position: 5, - content: 'hello', - revision: 10 - } - }) -}); -``` - -### 2.4 예제: 텍스트 삭제 -```javascript -// 5번 위치부터 3글자 삭제 -client.publish({ - destination: '/app/notes/123/edit', - body: JSON.stringify({ - operation: { - type: 'delete', - position: 5, - length: 3, - revision: 10 - } - }) -}); -``` - ---- - -## 3. 에러 처리 - -### 3.1 에러 응답 구조 -```json -{ - "code": "Note409_2", - "message": "동시 편집 충돌이 발생했습니다", - "data": { - "code": "Note409_2", - "timestamp": "2025-10-25T10:30:45", - "path": "/ws/notes/123/edit", - "action": "RELOAD" - } -} -``` - -### 3.2 에러 구독 -```javascript -client.subscribe('/user/queue/errors', (message) => { - const error = JSON.parse(message.body); - - switch(error.data.action) { - case 'RELOAD': - // 페이지 새로고침 (동시 편집 충돌) - location.reload(); - break; - - case 'RETRY': - // 재시도 (Redis 오류 등) - setTimeout(() => { - retryLastOperation(); - }, 1000); - break; - - case 'RECONNECT': - // WebSocket 재연결 - client.deactivate().then(() => { - setTimeout(() => client.activate(), 2000); - }); - break; - - case 'REDIRECT_LOGIN': - // 로그인 페이지로 이동 - window.location.href = '/login'; - break; - - default: - console.error('알 수 없는 에러:', error.message); - } -}); -``` - -### 3.3 일반적인 에러 코드 - -| Code | 설명 | Action | -|------|------|--------| -| `Note404` | 노트를 찾을 수 없음 | 페이지 이동 | -| `Note403` | 접근 권한 없음 | REDIRECT_LOGIN | -| `Note409_1` | Revision 불일치 | RELOAD | -| `Note409_2` | 동시 편집 충돌 | RELOAD | -| `Note500_3` | Redis 연결 오류 | RETRY | - ---- - -## 4. 이미지 업로드 - -### 4.1 Presigned URL 요청 -```javascript -const response = await fetch('/api/notes/123/images/presigned-url', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${jwtToken}` - }, - body: JSON.stringify({ - noteId: 123, - fileName: 'screenshot.png', - mimeType: 'image/png' // FileMimeType enum 값 - }) -}); - -const { data } = await response.json(); -const presignedUrl = data.presignedUrl; -``` - -### 4.2 S3 직접 업로드 -```javascript -// 1단계: Presigned URL 획득 -const presignedUrl = await getPresignedUrl(noteId, fileName, mimeType); - -// 2단계: S3에 직접 업로드 -const uploadResponse = await fetch(presignedUrl, { - method: 'PUT', - headers: { - 'Content-Type': mimeType - }, - body: file // File 객체 -}); - -if (uploadResponse.ok) { - console.log('업로드 성공'); - // 3단계: 에디터에 이미지 삽입 - const imageUrl = presignedUrl.split('?')[0]; // 쿼리 제거 - insertImageToNote(imageUrl); -} -``` - -### 4.3 지원하는 MIME 타입 -``` -image/jpeg -image/png -image/gif -image/webp -``` - ---- - -## 5. 예제 코드 - -### 5.1 완전한 WebSocket 클라이언트 - -```javascript -import { Client } from '@stomp/stompjs'; - -class NoteCollaborativeEditor { - constructor(noteId, jwtToken) { - this.noteId = noteId; - this.jwtToken = jwtToken; - this.revision = 0; - this.client = null; - this.editQueue = []; // 전송 대기 중인 작업 - } - - // 연결 초기화 - connect() { - this.client = new Client({ - brokerURL: 'ws://localhost:8080/ws/note', - connectHeaders: { - 'Authorization': `Bearer ${this.jwtToken}` - }, - onConnect: () => this.onConnected(), - onDisconnect: () => this.onDisconnected(), - onStompError: (error) => this.onError(error) - }); - - this.client.activate(); - } - - onConnected() { - console.log('✅ WebSocket 연결 성공'); - - // 서버에서 변경사항 수신 - this.client.subscribe( - `/topic/notes/${this.noteId}/edit`, - (message) => this.handleRemoteEdit(message) - ); - - // 에러 메시지 수신 - this.client.subscribe('/user/queue/errors', (message) => { - this.handleError(JSON.parse(message.body)); - }); - - // 대기 중인 작업 전송 - this.flushEditQueue(); - } - - onDisconnected() { - console.log('❌ WebSocket 연결 해제'); - } - - // 로컬 변경사항 전송 - sendEdit(type, position, content, length = null) { - const operation = { - type, // 'insert' | 'delete' - position, - revision: this.revision - }; - - if (type === 'insert') { - operation.content = content; - } else if (type === 'delete') { - operation.length = length; - } - - if (this.client.connected) { - this.client.publish({ - destination: `/app/notes/${this.noteId}/edit`, - body: JSON.stringify({ operation }) - }); - } else { - // 연결 대기 중이면 큐에 저장 - this.editQueue.push(operation); - } - } - - // 원격 변경사항 처리 - handleRemoteEdit(message) { - const data = JSON.parse(message.body); - const operation = data.operation; - - console.log('📩 원격 편집 수신:', operation); - - // 로컬 에디터에 적용 - this.applyOperation(operation); - - // Revision 업데이트 - if (operation.revision !== undefined) { - this.revision = operation.revision + 1; - } - } - - // Operation 적용 (OT 변환 로직은 별도로 구현) - applyOperation(operation) { - const editor = document.getElementById('note-editor'); - const text = editor.value; - - if (operation.type === 'insert') { - const newText = - text.slice(0, operation.position) + - operation.content + - text.slice(operation.position); - editor.value = newText; - } else if (operation.type === 'delete') { - const newText = - text.slice(0, operation.position) + - text.slice(operation.position + operation.length); - editor.value = newText; - } - - // 이벤트 발생 - editor.dispatchEvent(new Event('change')); - } - - // 에러 처리 - handleError(error) { - const action = error.data?.action || 'ERROR'; - - switch (action) { - case 'RELOAD': - alert('다른 사용자에 의해 노트가 변경되었습니다. 페이지를 새로고침합니다.'); - location.reload(); - break; - - case 'RETRY': - console.warn('일시적 오류입니다. 재시도합니다...'); - setTimeout(() => this.flushEditQueue(), 2000); - break; - - case 'RECONNECT': - console.warn('WebSocket 재연결 시도 중...'); - this.client.deactivate().then(() => { - setTimeout(() => this.client.activate(), 2000); - }); - break; - - case 'REDIRECT_LOGIN': - window.location.href = '/login'; - break; - - default: - console.error('에러:', error.message); - } - } - - // 대기 중인 작업 일괄 전송 - flushEditQueue() { - while (this.editQueue.length > 0 && this.client.connected) { - const operation = this.editQueue.shift(); - this.client.publish({ - destination: `/app/notes/${this.noteId}/edit`, - body: JSON.stringify({ operation }) - }); - } - } - - // 연결 종료 - disconnect() { - this.client?.deactivate(); - } -} - -// 사용 예 -const editor = new NoteCollaborativeEditor(123, jwtToken); -editor.connect(); - -document.getElementById('note-editor').addEventListener('input', (e) => { - const text = e.target.value; - const position = e.target.selectionStart; - - // 텍스트 삽입 감지 - editor.sendEdit('insert', position, 'a'); -}); -``` - -### 5.2 에디터 UI 통합 -```html -

- - - -
-

온라인 사용자

-
    -
    - - - -
    - - -``` - ---- - -## 6. 체크리스트 - -### 필수 구현 항목 - -- [ ] WebSocket 클라이언트 라이브러리 설치 (@stomp/stompjs) -- [ ] JWT 토큰 포함 WebSocket 연결 구현 -- [ ] Operation 메시지 발행/구독 구현 -- [ ] 에러 처리 구현 (action별 대응) -- [ ] UI에 실시간 변경사항 반영 -- [ ] 이미지 업로드 (Presigned URL) 구현 -- [ ] 동시 편집 충돌 처리 (page reload) -- [ ] 연결 상태 표시 (온/오프라인) - -### 권장 사항 - -- [ ] 자동 저장 로직 (30초마다 수동 저장) -- [ ] Operation 큐잉 (오프라인 모드 지원) -- [ ] Cursor 위치 공유 (다른 사용자 커서 표시) -- [ ] 사용자 활동 상태 (typing indicator) -- [ ] 변경 이력 (undo/redo) - ---- - -## 7. 문제 해결 - -### Q: WebSocket 연결이 실패합니다 -**A:** -- JWT 토큰이 유효한지 확인하세요 -- Authorization 헤더 형식: `Bearer {token}` -- 브라우저 콘솔에서 에러 메시지 확인 - -### Q: "NOTE_ACCESS_DENIED" 에러가 발생합니다 -**A:** -- 해당 노트가 속한 워크스페이스의 멤버인지 확인하세요 -- 워크스페이스에 초대되어 있는지 확인하세요 - -### Q: 다른 사용자의 변경사항이 보이지 않습니다 -**A:** -- `/topic/notes/{noteId}/edit` 구독이 제대로 되었는지 확인 -- 네트워크 탭에서 메시지 수신 여부 확인 -- 서버 로그에서 broadcast 여부 확인 - -### Q: 이미지 업로드가 실패합니다 -**A:** -- MIME 타입이 지원되는 형식인지 확인 -- CORS 설정 확인 -- S3 버킷 권한 확인 - ---- - -## 8. API 명세 - -### 이미지 업로드 Presigned URL -``` -POST /api/notes/{noteId}/images/presigned-url -Content-Type: application/json -Authorization: Bearer {token} - -요청: -{ - "noteId": 123, - "fileName": "screenshot.png", - "mimeType": "image/png" -} - -응답: -{ - "code": "200", - "message": "성공", - "data": { - "presignedUrl": "https://s3.amazonaws.com/bucket/notes/123/uuid.png?..." - } -} -``` - ---- - -## 📞 문제 발생 시 - -> 백엔드 팀에 다음 정보와 함께 이슈 생성하세요: -> - 에러 메시지 -> - 브라우저 콘솔 로그 -> - 서버 로그 (stacktrace) -> - 재현 방법 (Step by step) - ---- - -**마지막 업데이트**: 2025-10-25 -**백엔드 브랜치**: feat/#125 diff --git a/k6-folder-closure-only.js b/k6-folder-closure-only.js deleted file mode 100644 index 009c06e..0000000 --- a/k6-folder-closure-only.js +++ /dev/null @@ -1,118 +0,0 @@ -import http from 'k6/http'; -import { check, sleep } from 'k6'; -import { Rate } from 'k6/metrics'; - -// 커스텀 메트릭 정의 -const errorRate = new Rate('errors'); - -// FolderClosure 패턴만 테스트하는 시나리오 -export const options = { - stages: [ - { duration: '30s', target: 50 }, - { duration: '1m', target: 100 }, - { duration: '2m', target: 100 }, - { duration: '30s', target: 0 }, - ], - thresholds: { - http_req_duration: ['p(95)<500', 'p(99)<1000'], - errors: ['rate<0.1'], - }, -}; - -const BASE_URL = __ENV.BASE_URL || 'https://api.syncly-io.com'; -const LOGIN_EMAIL = __ENV.LOGIN_EMAIL || '1026hzz2@gmail.com'; -const LOGIN_PASSWORD = __ENV.LOGIN_PASSWORD || 'Khj86284803!'; - -const WORKSPACE_ID = 48; -const FOLDER_ID = 60; - -function login() { - const loginPayload = JSON.stringify({ - email: LOGIN_EMAIL, - password: LOGIN_PASSWORD, - }); - - const loginParams = { - headers: { - 'Content-Type': 'application/json', - }, - }; - - const loginRes = http.post(`${BASE_URL}/api/auth/login`, loginPayload, loginParams); - - check(loginRes, { - '로그인 성공': (r) => r.status === 200, - }); - - if (loginRes.status !== 200) { - console.error(`로그인 실패: ${loginRes.status} - ${loginRes.body}`); - errorRate.add(1); - return null; - } - - const token = loginRes.json('accessToken') || loginRes.json('token'); - - if (!token) { - console.error('토큰을 찾을 수 없습니다.'); - errorRate.add(1); - return null; - } - - return token; -} - -export default function () { - const token = login(); - - if (!token) { - sleep(1); - return; - } - - const params = { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }; - - const res = http.get( - `${BASE_URL}/api/workspaces/${WORKSPACE_ID}/folders/${FOLDER_ID}/path`, - params - ); - - const success = check(res, { - '상태 200': (r) => r.status === 200, - '응답 시간 < 500ms': (r) => r.timings.duration < 500, - '응답 시간 < 200ms': (r) => r.timings.duration < 200, - 'path 존재': (r) => { - try { - const body = JSON.parse(r.body); - return body.result && body.result.path && Array.isArray(body.result.path); - } catch (e) { - return false; - } - }, - }); - - if (!success) { - errorRate.add(1); - } - - sleep(Math.random() * 1 + 0.5); -} - -export function setup() { - console.log('='.repeat(60)); - console.log('FolderClosure 패턴 단독 성능 테스트'); - console.log(`BASE_URL: ${BASE_URL}`); - console.log(`WORKSPACE_ID: ${WORKSPACE_ID}`); - console.log(`FOLDER_ID: ${FOLDER_ID}`); - console.log('='.repeat(60)); -} - -export function teardown(data) { - console.log('='.repeat(60)); - console.log('FolderClosure 패턴 테스트 종료'); - console.log('='.repeat(60)); -} diff --git a/k6-recursive-only.js b/k6-recursive-only.js deleted file mode 100644 index a368ba4..0000000 --- a/k6-recursive-only.js +++ /dev/null @@ -1,122 +0,0 @@ -import http from 'k6/http'; -import { check, sleep } from 'k6'; -import { Rate } from 'k6/metrics'; - -// 커스텀 메트릭 정의 -const errorRate = new Rate('errors'); - -// 재귀 방식만 테스트하는 시나리오 -export const options = { - stages: [ - { duration: '30s', target: 50 }, - { duration: '1m', target: 100 }, - { duration: '2m', target: 100 }, - { duration: '30s', target: 0 }, - ], - thresholds: { - http_req_duration: ['p(95)<500', 'p(99)<1000'], - errors: ['rate<0.1'], - }, -}; - -const BASE_URL = __ENV.BASE_URL || 'https://api.syncly-io.com'; -const LOGIN_EMAIL = __ENV.LOGIN_EMAIL || '1026hzz2@gmail.com'; -const LOGIN_PASSWORD = __ENV.LOGIN_PASSWORD || 'Khj86284803!'; - -const WORKSPACE_ID = 48; -const FOLDER_ID = 60; - -function login() { - const loginPayload = JSON.stringify({ - email: LOGIN_EMAIL, - password: LOGIN_PASSWORD, - }); - - const loginParams = { - headers: { - 'Content-Type': 'application/json', - }, - }; - - const loginRes = http.post(`${BASE_URL}/api/auth/login`, loginPayload, loginParams); - - check(loginRes, { - '로그인 성공': (r) => r.status === 200, - }); - - if (loginRes.status !== 200) { - console.error(`로그인 실패: ${loginRes.status} - ${loginRes.body}`); - errorRate.add(1); - return null; - } - - // ✅ 실제 응답 구조에 맞게 수정 - const token = - loginRes.json('result') || - loginRes.json('accessToken') || - loginRes.json('token'); - - if (!token) { - console.error('토큰을 찾을 수 없습니다.'); - errorRate.add(1); - return null; - } - - return token; -} - -export default function () { - const token = login(); - - if (!token) { - sleep(1); - return; - } - - const params = { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }; - - const res = http.get( - `${BASE_URL}/api/workspaces/${WORKSPACE_ID}/folders/${FOLDER_ID}/path-recursive`, - params - ); - - const success = check(res, { - '상태 200': (r) => r.status === 200, - '응답 시간 < 500ms': (r) => r.timings.duration < 500, - '응답 시간 < 200ms': (r) => r.timings.duration < 200, - 'path 존재': (r) => { - try { - const body = JSON.parse(r.body); - return body.result && body.result.path && Array.isArray(body.result.path); - } catch (e) { - return false; - } - }, - }); - - if (!success) { - errorRate.add(1); - } - - sleep(Math.random() * 1 + 0.5); -} - -export function setup() { - console.log('='.repeat(60)); - console.log('재귀 방식 단독 성능 테스트'); - console.log(`BASE_URL: ${BASE_URL}`); - console.log(`WORKSPACE_ID: ${WORKSPACE_ID}`); - console.log(`FOLDER_ID: ${FOLDER_ID}`); - console.log('='.repeat(60)); -} - -export function teardown(data) { - console.log('='.repeat(60)); - console.log('재귀 방식 테스트 종료'); - console.log('='.repeat(60)); -} From a9ae91be27b309f6db348d16028fd3972daf2e9c Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Tue, 4 Nov 2025 10:33:35 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20Note=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20DTO=EB=A5=BC=20Y.Doc=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EB=84=88=EB=A6=AC=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 +++++ .../domain/note/converter/NoteConverter.java | 11 ++++++++++- .../syncly/domain/note/dto/NoteResponseDto.java | 11 ++++------- .../project/syncly/domain/note/entity/Note.java | 17 +++++++++++++++++ src/main/resources/application.yml | 4 ++-- 5 files changed, 38 insertions(+), 10 deletions(-) diff --git a/build.gradle b/build.gradle index 28eda03..256381f 100644 --- a/build.gradle +++ b/build.gradle @@ -90,6 +90,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-core' + // MessagePack - Yjs Update 바이너리 처리용 + // Yjs의 encodeStateAsUpdate()는 Uint8Array(바이너리)를 생성 + // 이를 Redis에 저장/로드하기 위해 Base64 인코딩 또는 MessagePack 사용 + implementation 'org.msgpack:msgpack-core:0.9.1' + } tasks.named('test') { diff --git a/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java b/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java index 504ad32..994a474 100644 --- a/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java +++ b/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java @@ -38,12 +38,21 @@ public static NoteResponseDto.Create toCreateResponse(Note note) { /** * Note 엔티티를 상세 응답 DTO로 변환 + * + *

    Yjs CRDT 기반이므로 ydocBinary를 반환합니다. + * OT 기반 content는 더 이상 사용되지 않습니다. */ public static NoteResponseDto.Detail toDetailResponse(Note note, List activeParticipants) { + // Yjs CRDT의 ydocBinary 반환 (프론트엔드에서 contentPreview로 표시) + // ydocBinary가 없으면 content 필드 반환 (하위 호환성) + String contentToReturn = (note.getYdocBinary() != null && !note.getYdocBinary().isEmpty()) + ? note.getYdocBinary() + : note.getContent(); + return new NoteResponseDto.Detail( note.getId(), note.getTitle(), - note.getContent(), + contentToReturn, // ✅ ydocBinary를 반환 note.getWorkspace().getId(), note.getCreator().getId(), note.getCreator().getName(), diff --git a/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java b/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java index 12eb325..404a176 100644 --- a/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java +++ b/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java @@ -65,26 +65,23 @@ public record ParticipantInfo( LocalDateTime joinedAt ) {} - @Schema(description = "노트 저장 응답 DTO") + @Schema(description = "노트 저장 응답 DTO (Yjs CRDT 기반)") public record SaveResponse( @Schema(description = "저장 성공 여부") boolean success, - @Schema(description = "저장된 문서 버전") - int revision, - @Schema(description = "저장 시각") LocalDateTime savedAt, @Schema(description = "메시지") String message ) { - public static SaveResponse success(int revision, LocalDateTime savedAt) { - return new SaveResponse(true, revision, savedAt, "노트가 저장되었습니다"); + public static SaveResponse success(LocalDateTime savedAt) { + return new SaveResponse(true, savedAt, "노트가 저장되었습니다"); } public static SaveResponse failure(String message) { - return new SaveResponse(false, 0, LocalDateTime.now(), message); + return new SaveResponse(false, LocalDateTime.now(), message); } } diff --git a/src/main/java/com/project/syncly/domain/note/entity/Note.java b/src/main/java/com/project/syncly/domain/note/entity/Note.java index 5a91fad..56f01b2 100644 --- a/src/main/java/com/project/syncly/domain/note/entity/Note.java +++ b/src/main/java/com/project/syncly/domain/note/entity/Note.java @@ -38,6 +38,15 @@ public class Note extends BaseTimeDeletedEntity { @Column(columnDefinition = "LONGTEXT") private String content; + /** + * Yjs CRDT 기반 Y.Doc 바이너리 상태 (Base64 인코딩) + * + *

    프론트엔드의 Y.Doc에서 생성된 바이너리 업데이트를 서버에 저장합니다. + * encodeStateAsUpdate() 결과를 Base64로 인코딩한 형태입니다. + */ + @Column(columnDefinition = "LONGTEXT") + private String ydocBinary; + @Column(name = "last_modified_at", nullable = false) private LocalDateTime lastModifiedAt; @@ -57,6 +66,14 @@ public void updateContent(String content) { this.lastModifiedAt = LocalDateTime.now(); } + /** + * Y.Doc 바이너리 업데이트 (Yjs CRDT 기반) + */ + public void updateYdocBinary(String ydocBinary) { + this.ydocBinary = ydocBinary; + this.lastModifiedAt = LocalDateTime.now(); + } + /** * 소프트 삭제 시 lastModifiedAt도 갱신 */ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6332abd..2b8138a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,8 +104,8 @@ logging: scheduler: note: auto-save: - fixed-delay: 30000 # 30초마다 실행 - initial-delay: 30000 # 애플리케이션 시작 후 30초 후 첫 실행 + fixed-delay: 1000 # 1초마다 실행 + initial-delay: 1000 # 애플리케이션 시작 후 1초 후 첫 실행 From e39ed381b847965e5e619fb3a53364b57d4d78b6 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Tue, 4 Nov 2025 10:33:54 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20Yjs=20Y.Doc=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A0=80=EC=9E=A5/=EB=B3=B5=EC=9B=90=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20(Redis/DB=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/service/NoteRedisService.java | 489 +++++++++--------- .../domain/note/service/NoteService.java | 5 - .../domain/note/service/NoteServiceImpl.java | 5 - .../domain/note/service/YjsService.java | 341 ++++++++++++ 4 files changed, 588 insertions(+), 252 deletions(-) create mode 100644 src/main/java/com/project/syncly/domain/note/service/YjsService.java diff --git a/src/main/java/com/project/syncly/domain/note/service/NoteRedisService.java b/src/main/java/com/project/syncly/domain/note/service/NoteRedisService.java index c5c6b4b..7ac3dfd 100644 --- a/src/main/java/com/project/syncly/domain/note/service/NoteRedisService.java +++ b/src/main/java/com/project/syncly/domain/note/service/NoteRedisService.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.project.syncly.domain.note.dto.CursorPosition; -import com.project.syncly.domain.note.dto.EditOperation; import com.project.syncly.global.redis.core.RedisStorage; import com.project.syncly.global.redis.enums.RedisKeyPrefix; import lombok.RequiredArgsConstructor; @@ -14,26 +13,35 @@ import java.time.Duration; import java.util.*; import java.util.stream.Collectors; +import java.util.Base64; /** - * 노트의 실시간 협업 데이터를 Redis에서 관리하는 서비스 + * 노트의 실시간 협업 데이터를 Redis에서 관리하는 서비스 (Yjs CRDT 기반) * - *

    Redis를 사용하는 이유: + *

    아키텍처: *

      - *
    • MySQL: 영구 저장용 (자동 저장 시 최종 상태만 저장)
    • - *
    • Redis: 실시간 작업용 (편집 중인 임시 데이터, 빠른 읽기/쓰기)
    • + *
    • 프론트엔드: Yjs로 CRDT 기반 동시편집 처리 (자동 충돌 해결)
    • + *
    • 백엔드: Yjs Update 바이너리를 Redis에 저장 및 브로드캐스트 (중계 역할)
    • + *
    • MySQL: 영구 저장용 (자동 저장 시 최종 Y.Doc 상태만 저장)
    • *
    * - *

    관리하는 데이터: + *

    데이터 구조: *

      - *
    • content: 현재 편집 중인 노트 내용 (String)
    • + *
    • ydoc: Y.Doc을 encodeStateAsUpdate()로 직렬화한 바이너리 (Base64 String)
    • + *
    • stateVector: 상태 추적용 벡터 (Base64 String)
    • *
    • users: 접속 중인 사용자 목록 (Set)
    • - *
    • cursors: 각 사용자의 커서 위치 (Hash)
    • - *
    • dirty: 변경사항 있는지 플래그 (Boolean)
    • - *
    • revision: 문서 버전 번호 (Integer)
    • - *
    • operations: 편집 연산 히스토리 (List, 최근 100개)
    • + *
    • cursors: 각 사용자의 커서 위치 (Hash) - Awareness 사용
    • + *
    • dirty: 변경사항 있는지 플래그 (Boolean, 자동 저장용)
    • *
    * + *

    Yjs Update 흐름: + *

    + * 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);
          * 
    * * @param noteId 노트 ID - * @param content 저장할 내용 + * @param base64Update Base64 encoded Yjs Update */ - public void setContent(Long noteId, String content) { - String key = RedisKeyPrefix.NOTE_CONTENT.get(noteId); - redisStorage.setValueAsString(key, content, NOTE_TTL); + public void setYdocBinary(Long noteId, String base64Update) { + String key = RedisKeyPrefix.NOTE_YDOC.get(noteId); + redisStorage.setValueAsString(key, base64Update, NOTE_TTL); setDirty(noteId, true); // 변경사항 있음을 표시 - log.debug("Set note content: noteId={}, length={}", noteId, content != null ? content.length() : 0); + log.debug("Set Y.Doc: noteId={}, updateSize={}", noteId, + base64Update != null ? base64Update.length() : 0); + } + + /** + * Y.Doc 상태 저장 (dirty 플래그 설정하지 않음 - 초기화 전용) + * + *

    Redis Key: NOTE:YDOC:{noteId} + *

    Redis Value: Base64 encoded Yjs Update + * + *

    주의: 이 메서드는 dirty 플래그를 설정하지 않습니다. + * DB에서 노트를 로드하여 Redis를 초기화할 때만 사용하세요. + * 사용자 편집으로 인한 Update 저장 시에는 setYdocBinary() 또는 applyYjsUpdate()를 사용하세요. + * + *

    사용 시나리오: + *

    +     * // 첫 사용자가 노트에 입장했을 때, DB에서 기존 상태 로드
    +     * String dbYdoc = note.getYdocBinary();
    +     * redisService.setYdocBinaryWithoutDirty(noteId, dbYdoc);
    +     * // dirty 플래그가 설정되지 않으므로 자동 저장되지 않음
    +     * 
    + * + * @param noteId 노트 ID + * @param base64Update Base64 encoded Yjs Update + */ + public void setYdocBinaryWithoutDirty(Long noteId, String base64Update) { + String key = RedisKeyPrefix.NOTE_YDOC.get(noteId); + redisStorage.setValueAsString(key, base64Update, NOTE_TTL); + log.debug("Set Y.Doc (without dirty flag): noteId={}, updateSize={}", noteId, + base64Update != null ? base64Update.length() : 0); + } + + /** + * Yjs Update 바이너리 머징 (클라이언트가 보낸 Update를 기존 Update와 병합) + * + *

    구현: + *

      + *
    • 기존 Y.Doc 바이너리 (Base64) 디코딩 → 바이너리로 변환
    • + *
    • 새로운 Update (Base64) 디코딩 → 바이너리로 변환
    • + *
    • 바이너리 레벨에서 연결하여 저장 (클라이언트가 applyUpdate 할 때 자동 병합)
    • + *
    + * + *

    중요: 반드시 바이너리 레벨에서 병합해야 함 + *

      + *
    • ✅ 올바른 방법: Base64("AAA") + Base64("BBB") → 바이너리 병합 → Base64 인코딩
    • + *
    • ❌ 잘못된 방법: Base64("AAA") + "|" + Base64("BBB") → Base64 "AAA|BBB" (유효하지 않은 문자)
    • + *
    + * + *

    참고: + *

      + *
    • Yjs Y.applyUpdate()는 여러 바이너리 Update를 순차적으로 처리 가능
    • + *
    • 바이너리 레벨에서 연결하면 클라이언트가 한 번에 applyUpdate() 호출 가능
    • + *
    • Yjs CRDT 특성상 어떤 순서로 applyUpdate해도 최종 결과는 같음
    • + *
    + * + *

    사용 시나리오: + *

    +     * // WebSocket에서 Yjs Update 수신 (클라이언트 A)
    +     * String newUpdate = wsMessage.getUpdate();  // "QnluYXJ5RGF0YQ=="
    +     * redisService.applyYjsUpdate(noteId, newUpdate);
    +     *
    +     * // 내부적으로:
    +     * // 1. 기존 Y.Doc 조회 (Base64): "QnluYXJ5RGF0YTE="
    +     * // 2. 기존과 새 Update 바이너리 레벨에서 병합
    +     * // 3. 병합된 바이너리를 Base64 인코딩: "QnluYXJ5RGF0YTFCaW5hcnlEYXRh"
    +     * // 4. Redis 저장
    +     * // 5. 다른 클라이언트에게 브로드캐스트
    +     * 
    + * + * @param noteId 노트 ID + * @param newUpdateBase64 새로운 Yjs Update (Base64 인코딩된 바이너리) + */ + public void applyYjsUpdate(Long noteId, String newUpdateBase64) { + String key = RedisKeyPrefix.NOTE_YDOC.get(noteId); + + // 🔍 [Step 1] 입력값 검증 + log.info("🔍 [Redis Step 1] Update 수신 - noteId={}, newSize={}, newFirst30={}", + noteId, + newUpdateBase64 != null ? newUpdateBase64.length() : 0, + newUpdateBase64 != null ? + (newUpdateBase64.length() > 30 ? newUpdateBase64.substring(0, 30) : newUpdateBase64) + : "null"); + + // ✅ 개선된 방식: Redis에 "누적된 업데이트"를 저장합니다 + // (프론트엔드에서 보낸 incremental update들을 계속 누적) + // + // 프론트엔드는 매번 Y.Doc.encodeStateAsUpdate()를 호출하므로, + // 이것들은 모두 "현재까지의 모든 변경사항을 포함"하고 있습니다. + // 따라서 가장 최신의 Update만 저장하면 됩니다! + + String currentBase64 = redisStorage.getValueAsString(key); + + // 🔍 [Step 2] 기존 데이터 확인 + if (currentBase64 != null && !currentBase64.isEmpty()) { + log.info("🔍 [Redis Step 2] 기존 Update 확인 - noteId={}, currentSize={}, currentFirst30={}", + noteId, + currentBase64.length(), + currentBase64.length() > 30 ? currentBase64.substring(0, 30) : currentBase64); + } else { + log.info("🔍 [Redis Step 2] 기존 Update 없음 - noteId={}", noteId); + } + + // ⚠️ 기존 데이터가 있으면, 새 Update와 병합해야 합니다 + // 하지만 단순 연결이 아닌, 프론트의 최신 상태를 신뢰합니다 + String mergedBase64; + + if (currentBase64 != null && !currentBase64.isEmpty()) { + // ✅ 핵심: 프론트엔드에서 온 Update는 이미 모든 변경사항을 포함하고 있으므로 + // 단순히 새로운 Update로 대체합니다 (가장 안전한 방법) + mergedBase64 = newUpdateBase64; + log.info("🔍 [Redis Step 3] 병합 - 새로운 Update로 대체 (기존은 폐기)"); + } else { + mergedBase64 = newUpdateBase64; + log.info("🔍 [Redis Step 3] 새로운 Update (기존 없음)"); + } + + try { + // 🔍 [Step 4] Redis 저장 + log.info("🔍 [Redis Step 4] Redis 저장 시작 - noteId={}, mergedSize={}, mergedFirst30={}", + noteId, + mergedBase64.length(), + mergedBase64.length() > 30 ? mergedBase64.substring(0, 30) : mergedBase64); + + redisStorage.setValueAsString(key, mergedBase64, NOTE_TTL); + setDirty(noteId, true); + refreshTTL(key); + + // 🔍 [Step 5] Redis 저장 검증 + String verifyBase64 = redisStorage.getValueAsString(key); + if (verifyBase64 != null && verifyBase64.equals(mergedBase64)) { + log.info("✔️ [Redis Step 5] Redis 저장 검증 성공 - noteId={}, verifySize={}, 일치=true", + noteId, + verifyBase64.length()); + } else { + log.warn("⚠️ [Redis Step 5] Redis 저장 검증 실패 - 저장한Size={}, 조회한Size={}, 일치=false", + mergedBase64.length(), + verifyBase64 != null ? verifyBase64.length() : 0); + } + + log.info("✅ Yjs Update 저장 완료: noteId={}, 최종Size={}", noteId, mergedBase64.length()); + } catch (Exception e) { + log.error("❌ Yjs Update 저장 실패: noteId={}", noteId, e); + throw new RuntimeException("Yjs Update 저장 실패", e); + } } // ==================== 사용자 관리 (Set) ==================== @@ -434,218 +587,68 @@ public void clearDirty(Long noteId) { setDirty(noteId, false); } - // ==================== Revision 관리 (OT 동시성 제어) ==================== - - /** - * 현재 revision 조회 - * - *

    Redis Key: NOTE:REVISION:{noteId} - *

    Redis Value: 정수 (문자열로 저장) - * - *

    Revision이란? - *

      - *
    • 문서의 버전 번호
    • - *
    • 편집 연산이 적용될 때마다 1씩 증가
    • - *
    • OT 알고리즘에서 충돌 감지에 사용
    • - *
    - * - *

    예시: - *

    -     * 초기 상태: revision = 0, content = ""
    -     * 사용자 A가 "Hello" 삽입 → revision = 1
    -     * 사용자 B가 " World" 삽입 → revision = 2
    -     * 
    - * - *

    사용 시나리오: - *

    -     * // 사용자가 편집 연산 전송 시
    -     * int clientRevision = editOperation.getRevision();  // 5 (클라이언트가 본 버전)
    -     * int serverRevision = redisService.getRevision(noteId);  // 7 (현재 서버 버전)
    -     *
    -     * if (clientRevision < serverRevision) {
    -     *     // 중간에 다른 연산이 있었음! Transform 필요
    -     *     List missedOps = redisService.getOperations(noteId, clientRevision);
    -     *     editOperation = OTEngine.transform(editOperation, missedOps);
    -     * }
    -     * 
    - * - * @param noteId 노트 ID - * @return 현재 revision (없으면 0) - */ - public int getRevision(Long noteId) { - String key = RedisKeyPrefix.NOTE_REVISION.get(noteId); - String value = redisStorage.getValueAsString(key); - if (value == null) { - return 0; - } - try { - return Integer.parseInt(value); - } catch (NumberFormatException e) { - log.error("Invalid revision format: noteId={}, value={}", noteId, value); - return 0; - } - } - - /** - * revision 증가 후 반환 (원자적 연산) - * - *

    Redis Command: INCR NOTE:REVISION:{noteId} - * - *

    원자적 연산이란? - *

      - *
    • 여러 사용자가 동시에 호출해도 안전
    • - *
    • Redis의 INCR 명령은 단일 스레드로 실행되어 경쟁 조건 없음
    • - *
    • 중복된 revision 번호가 절대 발생하지 않음
    • - *
    - * - *

    사용 시나리오: - *

    -     * // 편집 연산 적용 후 버전 증가
    -     * 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);
    +     * 

    Redis Key: NOTE:STATE_VECTOR:{noteId} * - * // Transform (6단계에서 구현) - * for (EditOperation missedOp : missedOps) { - * clientOp = OTEngine.transform(clientOp, missedOp); - * } - * - * // 변환된 연산 적용 - * String content = OTEngine.apply(currentContent, clientOp); - * } - *

    + *

    현재 구현에서는 선택적 기능입니다. + * 필요하면 클라이언트가 보낸 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 operations = redisTemplate.opsForList().range(key, 0, -1); // 전체 조회 - - if (operations == null || operations.isEmpty()) { - return new ArrayList<>(); - } - - return operations.stream() - .map(obj -> { - try { - String json = obj.toString(); - return redisObjectMapper.readValue(json, EditOperation.class); - } catch (JsonProcessingException e) { - log.error("Failed to deserialize edit operation", e); - return null; - } - }) - .filter(Objects::nonNull) - .filter(op -> op.getRevision() >= fromRevision) // fromRevision 이후만 필터링 - .collect(Collectors.toList()); + public void setStateVector(Long noteId, String stateVectorBase64) { + String key = RedisKeyPrefix.NOTE_STATE_VECTOR.get(noteId); + redisStorage.setValueAsString(key, stateVectorBase64, NOTE_TTL); + log.debug("Set state vector: noteId={}", noteId); } // ==================== 초기화 및 삭제 ==================== /** - * 노트 최초 진입 시 Redis 초기화 + * 노트 최초 진입 시 Redis 초기화 (Yjs 기반) * *

    초기화하는 데이터: *

      - *
    • content: DB에서 가져온 내용
    • - *
    • revision: 0으로 시작
    • + *
    • ydoc: DB에서 가져온 Y.Doc (Base64 encoded)
    • + *
    • stateVector: 초기 상태 벡터
    • *
    • dirty: false (변경사항 없음)
    • *
    * @@ -654,30 +657,34 @@ public List getOperations(Long noteId, int fromRevision) { * // 첫 사용자가 노트 입장 시 * {@literal @}MessageMapping("/note.{noteId}.enter") * public void handleEnter(Long noteId) { - * String content = redisService.getContent(noteId); + * String ydoc = redisService.getYdocBinary(noteId); * - * if (content == null) { + * if (ydoc == null) { * // Redis에 없으면 DB에서 로드 후 초기화 * Note note = noteRepository.findById(noteId).orElseThrow(); - * redisService.initializeNote(noteId, note.getContent()); + * redisService.initializeNote(noteId, note.getYdocBinary()); * } * - * // 이후 사용자들은 Redis에서 바로 조회 + * // 클라이언트에게 ydoc 전송 → applyUpdate()로 동기화 + * wsService.sendInitialState(clientId, ydoc); * } * * * @param noteId 노트 ID - * @param content 초기 내용 (DB에서 가져온 값, null이면 빈 문자열) + * @param ydocBase64 초기 Y.Doc (Base64 encoded, DB에서 가져온 값, null이면 빈 Y.Doc) */ - public void initializeNote(Long noteId, String content) { + public void initializeNote(Long noteId, String ydocBase64) { log.info("Initialize note in Redis: noteId={}", noteId); - // content 초기화 - setContent(noteId, content != null ? content : ""); - - // revision 초기화 (0부터 시작) - String revisionKey = RedisKeyPrefix.NOTE_REVISION.get(noteId); - redisStorage.setValueAsString(revisionKey, "0", NOTE_TTL); + // Y.Doc 초기화 (DB에서 가져온 상태 또는 빈 Y.Doc) + if (ydocBase64 != null && !ydocBase64.isEmpty()) { + setYdocBinary(noteId, ydocBase64); + } else { + // 빈 Y.Doc으로 초기화 (새 노트인 경우) + // 프론트엔드에서 생성한 빈 Y.Doc의 Update + // ⚠️ 중요: 빈 문자열 저장 금지! dirty 플래그 미설정 + setYdocBinaryWithoutDirty(noteId, ""); + } // dirty 플래그 초기화 (변경사항 없음) clearDirty(noteId); @@ -686,16 +693,15 @@ public void initializeNote(Long noteId, String content) { } /** - * 노트 관련 모든 Redis 데이터 삭제 + * 노트 관련 모든 Redis 데이터 삭제 (Yjs 기반) * *

    삭제되는 데이터: *

      - *
    • content: 노트 내용
    • + *
    • ydoc: Y.Doc 바이너리
    • + *
    • stateVector: 상태 벡터
    • *
    • users: 접속 중인 사용자
    • *
    • cursors: 커서 위치
    • *
    • dirty: dirty 플래그
    • - *
    • revision: 버전 번호
    • - *
    • operations: 연산 히스토리
    • *
    * *

    사용 시나리오: @@ -715,12 +721,11 @@ public void initializeNote(Long noteId, String content) { public void deleteNoteData(Long noteId) { log.info("Delete note data from Redis: noteId={}", noteId); - redisStorage.delete(RedisKeyPrefix.NOTE_CONTENT.get(noteId)); + redisStorage.delete(RedisKeyPrefix.NOTE_YDOC.get(noteId)); + redisStorage.delete(RedisKeyPrefix.NOTE_STATE_VECTOR.get(noteId)); redisStorage.delete(RedisKeyPrefix.NOTE_USERS.get(noteId)); redisStorage.delete(RedisKeyPrefix.NOTE_CURSORS.get(noteId)); redisStorage.delete(RedisKeyPrefix.NOTE_DIRTY.get(noteId)); - redisStorage.delete(RedisKeyPrefix.NOTE_REVISION.get(noteId)); - redisStorage.delete(RedisKeyPrefix.NOTE_OPERATIONS.get(noteId)); log.info("Note data deleted successfully: noteId={}", noteId); } diff --git a/src/main/java/com/project/syncly/domain/note/service/NoteService.java b/src/main/java/com/project/syncly/domain/note/service/NoteService.java index c18f982..f9b0078 100644 --- a/src/main/java/com/project/syncly/domain/note/service/NoteService.java +++ b/src/main/java/com/project/syncly/domain/note/service/NoteService.java @@ -35,9 +35,4 @@ public interface NoteService { * 워크스페이스 멤버 권한 확인 */ void validateWorkspaceMember(Long workspaceId, Long memberId); - - /** - * Redis에서 revision 조회 - */ - int getRevisionFromRedis(Long noteId); } diff --git a/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java b/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java index d1d03e9..1dd9355 100644 --- a/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java +++ b/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java @@ -167,11 +167,6 @@ public void validateWorkspaceMember(Long workspaceId, Long memberId) { validateWorkspaceMembership(workspaceId, memberId); } - @Override - public int getRevisionFromRedis(Long noteId) { - return noteRedisService.getRevision(noteId); - } - /** * WebSocket을 통해 노트 삭제를 모든 워크스페이스 멤버에게 브로드캐스트합니다. */ diff --git a/src/main/java/com/project/syncly/domain/note/service/YjsService.java b/src/main/java/com/project/syncly/domain/note/service/YjsService.java new file mode 100644 index 0000000..99cb353 --- /dev/null +++ b/src/main/java/com/project/syncly/domain/note/service/YjsService.java @@ -0,0 +1,341 @@ +package com.project.syncly.domain.note.service; + +import com.project.syncly.domain.note.dto.CursorPosition; +import com.project.syncly.domain.note.exception.NoteErrorCode; +import com.project.syncly.domain.note.exception.NoteException; +import com.project.syncly.global.redis.enums.RedisKeyPrefix; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * Yjs CRDT 기반 실시간 협업 편집 서비스 + * + *

    아키텍처: + *

      + *
    • 프론트엔드: Yjs로 CRDT 기반 동시편집 처리 (자동 충돌 해결)
    • + *
    • 백엔드: Yjs Update 바이너리를 수신하여 Redis에 저장 및 브로드캐스트
    • + *
    • 클라이언트 간: Update 바이너리 직접 전송 (서버는 중계 역할만)
    • + *
    + * + *

    주요 특징: + *

      + *
    • OT 변환 불필요 (CRDT가 자동으로 충돌 해결)
    • + *
    • Revision 관리 불필요 (Logical Clock으로 자동 처리)
    • + *
    • 구현 단순화 (바이너리 저장만)
    • + *
    • 네트워크 지연에 강함 (순서 상관없이 최종 결과 동일)
    • + *
    + * + *

    Yjs Update 흐름: + *

    + * 1. 클라이언트 A: Y.Doc 편집
    + *    → Y.encodeStateAsUpdate() → Uint8Array
    + *    → Base64 인코딩 → WebSocket 전송
    + *
    + * 2. 백엔드: Update 수신
    + *    → applyYjsUpdate(noteId, base64Update)
    + *    → Redis NOTE:YDOC:{noteId}에 저장
    + *    → dirty 플래그 설정
    + *    → 다른 클라이언트에게 브로드캐스트
    + *
    + * 3. 클라이언트 B, C: Update 수신
    + *    → Y.applyUpdate(ydoc, binaryUpdate)
    + *    → 자동 병합 (CRDT 특성)
    + *    → 충돌 해결됨 (추가 로직 불필요)
    + * 
    + * + *

    VS OT(Operational Transformation): + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    항목OTYjs CRDT
    충돌 해결수동 transform() 필요자동 (CRDT 특성)
    Revision명시적 관리 필요자동 (Logical Clock)
    히스토리최근 100개만 (메모리 절약)무한 히스토리 (CRDT 특성)
    구현 복잡도높음 (500+ 줄 OTEngine)낮음 (간단한 저장만)
    네트워크 순서순서 중요 (OT 변환 필요)순서 무관 (자동 병합)
    + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class YjsService { + + private final NoteRedisService noteRedisService; + + /** + * Yjs Update를 처리하고 Redis에 저장합니다 (State Vector 포함). + * + *

    처리 단계: + *

      + *
    1. Update 유효성 검증
    2. + *
    3. State Vector가 제공되면 저장 (선택적 동기화용)
    4. + *
    5. Redis에 Update 저장 (NOTE:YDOC:{noteId})
    6. + *
    7. dirty 플래그 설정 (자동 저장용)
    8. + *
    9. 처리 결과 반환
    10. + *
    + * + *

    Yjs 표준 프로토콜: + *

      + *
    • base64Update: Y.encodeStateAsUpdate() 결과
    • + *
    • base64StateVector: 클라이언트의 State Vector (선택적)
    • + *
    + * + *

    특징: + *

      + *
    • OT 변환 없음 (CRDT가 처리)
    • + *
    • 단순 저장만 수행
    • + *
    • 충돌 해결 불필요
    • + *
    • State Vector는 향후 선택적 동기화용
    • + *
    + * + * @param noteId 노트 ID + * @param base64Update Base64 인코딩된 Yjs Update + * @param base64StateVector 클라이언트의 State Vector (선택적, null 가능) + * @return 처리 결과 + * @throws NoteException Update가 유효하지 않은 경우 + */ + public ApplyYjsUpdateResult applyYjsUpdate(Long noteId, String base64Update, String base64StateVector) { + log.debug("Yjs Update 처리 시작: noteId={}, updateSize={}", noteId, + base64Update != null ? base64Update.length() : 0); + + // 1. 유효성 검증 + if (base64Update == null || base64Update.trim().isEmpty()) { + log.warn("빈 Update 수신: noteId={}", noteId); + throw new NoteException(NoteErrorCode.INVALID_OPERATION, "Update 데이터가 비어있습니다"); + } + + try { + // Base64 디코딩하여 유효성 확인 (선택적) + // byte[] binaryUpdate = Base64.getDecoder().decode(base64Update); + // log.debug("Update 바이너리 크기: {} bytes", binaryUpdate.length); + } catch (IllegalArgumentException e) { + log.error("Base64 디코딩 실패: noteId={}, error={}", noteId, e.getMessage()); + throw new NoteException(NoteErrorCode.INVALID_OPERATION, "유효하지 않은 Update 형식입니다"); + } + + // 2. State Vector가 제공되면 저장 (선택적 동기화용) + if (base64StateVector != null && !base64StateVector.isEmpty()) { + try { + noteRedisService.setStateVector(noteId, base64StateVector); + log.debug("클라이언트 State Vector 저장: noteId={}, stateVectorSize={}", + noteId, base64StateVector.length()); + } catch (Exception e) { + log.warn("State Vector 저장 실패 (계속 진행): noteId={}, error={}", + noteId, e.getMessage()); + // State Vector 저장 실패는 Update 저장을 막지 않음 + } + } + + // 3. Redis에 Update 저장 (기존 Update와 병합하여 누적, dirty 플래그 자동 설정됨) + noteRedisService.applyYjsUpdate(noteId, base64Update); + log.info("Yjs Update 저장 완료: noteId={}, updateSize={}", noteId, base64Update.length()); + + // 4. 처리 결과 반환 + return new ApplyYjsUpdateResult(true, base64Update.length()); + } + + /** + * Yjs Update를 처리하고 Redis에 저장합니다 (State Vector 없음 - 호환성용). + * + *

    State Vector가 필요 없는 경우 이 메서드를 사용합니다. + * 예: 초기 Y.Doc 로드, DB에서 restore 등 + * + * @param noteId 노트 ID + * @param base64Update Base64 인코딩된 Yjs Update + * @return 처리 결과 + * @throws NoteException Update가 유효하지 않은 경우 + */ + public ApplyYjsUpdateResult applyYjsUpdate(Long noteId, String base64Update) { + return applyYjsUpdate(noteId, base64Update, null); + } + + /** + * Redis에 저장된 Y.Doc 상태를 Update 형식으로 반환합니다. + * + *

    사용 시나리오: + *

    +     * // 새 사용자가 노트에 입장했을 때 기존 상태 전송
    +     * String base64Update = yjsService.getYdocAsUpdate(noteId);
    +     * if (base64Update != null) {
    +     *     // 클라이언트에게 Update 전송
    +     *     wsService.sendInitialState(clientId, base64Update);
    +     * } else {
    +     *     // 새 노트인 경우 빈 상태 초기화
    +     *     yjsService.initializeEmptyDoc(noteId);
    +     * }
    +     * 
    + * + *

    주의: + *

      + *
    • 반환된 Update는 클라이언트가 Y.applyUpdate()로 처리해야 함
    • + *
    • 여러 Update를 연결한 경우, 클라이언트가 순차적으로 applyUpdate 처리
    • + *
    + * + * @param noteId 노트 ID + * @return Base64 인코딩된 Y.Doc Update (없으면 null) + */ + public String getYdocAsUpdate(Long noteId) { + log.debug("Y.Doc 조회: noteId={}", noteId); + + String base64Update = noteRedisService.getYdocBinary(noteId); + if (base64Update != null && !base64Update.isEmpty()) { + log.debug("Y.Doc 반환: noteId={}, size={}", noteId, base64Update.length()); + return base64Update; + } + + log.debug("Y.Doc이 없음: noteId={} (새 노트이거나 초기화 필요)", noteId); + return null; + } + + /** + * 여러 Yjs Update를 병합하여 저장합니다. (선택적 기능) + * + *

    구현 전략: + *

      + *
    • 간단한 병합: Update를 이어붙임 (클라이언트가 순차적으로 처리)
    • + *
    • 고급 병합: 여러 Update를 하나로 인코딩 (구현 복잡)
    • + *
    + * + *

    현재 구현: + *

      + *
    • 간단한 이어붙임 방식 (delimiter: "|")
    • + *
    • 클라이언트가 각 Update를 분리하여 applyUpdate 처리
    • + *
    + * + *

    사용 예시: + *

    {@code
    +     * List updates = List.of(
    +     *     "SGVsbG8gV29ybGQ=",  // "Hello World"
    +     *     "Zm9vYmFy"            // "foobar"
    +     * );
    +     * yjsService.mergeUpdates(noteId, updates);
    +     * }
    + * + * @param noteId 노트 ID + * @param updates 병합할 Update 리스트 + */ + public void mergeUpdates(Long noteId, java.util.List updates) { + if (updates == null || updates.isEmpty()) { + log.debug("병합할 Update가 없음: noteId={}", noteId); + return; + } + + log.debug("Update 병합 시작: noteId={}, count={}", noteId, updates.size()); + + // 간단한 병합: Update를 이어붙임 + String mergedBase64 = String.join("|", updates); + noteRedisService.setYdocBinary(noteId, mergedBase64); + + log.info("Update 병합 완료: noteId={}, mergedSize={}", noteId, mergedBase64.length()); + } + + /** + * DB에서 기존 Y.Doc을 Redis로 로드하여 초기화합니다 (dirty 플래그 미설정). + * + *

    특징: + *

      + *
    • DB에서 로드한 기존 상태를 Redis에 저장
    • + *
    • dirty 플래그를 설정하지 않음 (기존 데이터이므로)
    • + *
    • 이후 사용자 편집으로 인한 Update만 dirty=true로 설정됨
    • + *
    + * + *

    사용 시나리오: + *

    +     * // 사용자가 노트에 입장했을 때, Redis에 없으면 DB에서 로드
    +     * String dbYdoc = note.getYdocBinary();
    +     * if (dbYdoc != null && !dbYdoc.isEmpty()) {
    +     *     yjsService.loadFromDatabase(noteId, dbYdoc);  // dirty 플래그 미설정
    +     * }
    +     * 
    + * + * @param noteId 노트 ID + * @param dbYdoc DB에서 로드한 Y.Doc 바이너리 + */ + public void loadFromDatabase(Long noteId, String dbYdoc) { + log.info("DB에서 Y.Doc 로드: noteId={}, size={}", noteId, dbYdoc != null ? dbYdoc.length() : 0); + noteRedisService.setYdocBinaryWithoutDirty(noteId, dbYdoc); + } + + /** + * 빈 Y.Doc으로 노트를 초기화합니다. + * + *

    주의: dirty 플래그를 설정하지 않으므로 자동 저장되지 않습니다. + * + *

    사용 시나리오: + *

    +     * // 새 노트 생성 시
    +     * yjsService.initializeEmptyDoc(noteId);
    +     *
    +     * // 또는 기존 노트 초기화
    +     * String dbContent = note.getYdocBinary();
    +     * if (dbContent == null) {
    +     *     yjsService.initializeEmptyDoc(noteId);
    +     * } else {
    +     *     yjsService.loadFromDatabase(noteId, dbContent);
    +     * }
    +     * 
    + * + * @param noteId 노트 ID + */ + public void initializeEmptyDoc(Long noteId) { + log.info("빈 Y.Doc 초기화: noteId={}", noteId); + // ⚠️ 중요: setYdocBinaryWithoutDirty()로 호출하여 dirty 플래그 미설정 + noteRedisService.setYdocBinaryWithoutDirty(noteId, ""); + } + + /** + * ℹ️ 커서 관리는 Yjs Awareness API가 전담합니다. + * + *

    더 이상 사용되지 않음: + *

      + *
    • updateCursor() - Redis에 커서 저장 불필요
    • + *
    • getAllCursors() - Awareness가 모든 커서 동기화
    • + *
    • removeCursor() - 사용자 퇴장 시 자동 처리
    • + *
    + * + *

    Yjs Awareness 흐름: + * 1. 클라이언트: awareness.setLocalState()로 커서 정보 설정 + * 2. WebsocketProvider: Awareness 변경을 자동으로 다른 클라이언트에게 전송 + * 3. 클라이언트들: awareness 변경 이벤트를 구독하여 커서 동기화 + * + *

    이전 OT 기반 편집에서는 서버가 커서를 Redis에 저장했지만, + * Yjs CRDT로 마이그레이션하면서 모든 커서 관리를 클라이언트 Awareness에서 처리합니다. + */ + + /** + * Yjs Update 적용 결과 DTO + * + * @param success 처리 성공 여부 + * @param updateSize Update 바이너리 크기 + */ + public record ApplyYjsUpdateResult( + boolean success, + int updateSize + ) {} +} From d41d3ae9d09decf7b3680a765faababe3cf07018 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Tue, 4 Nov 2025 10:34:13 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20WebSocket=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=ED=8F=AC=EB=A7=B7=EC=9D=84=20Yjs=20Y.Doc=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/dto/NoteWebSocketDto.java | 129 +++++++++--------- .../domain/note/dto/WebSocketMessage.java | 34 ++--- .../domain/note/dto/WebSocketMessageType.java | 14 +- 3 files changed, 84 insertions(+), 93 deletions(-) diff --git a/src/main/java/com/project/syncly/domain/note/dto/NoteWebSocketDto.java b/src/main/java/com/project/syncly/domain/note/dto/NoteWebSocketDto.java index 224f31f..4399a04 100644 --- a/src/main/java/com/project/syncly/domain/note/dto/NoteWebSocketDto.java +++ b/src/main/java/com/project/syncly/domain/note/dto/NoteWebSocketDto.java @@ -14,7 +14,7 @@ public class NoteWebSocketDto { /** * 노트 입장 시 입장한 사용자에게만 전송되는 응답 DTO * - *

    노트의 현재 상태를 모두 포함합니다. + *

    노트의 현재 상태를 모두 포함합니다 (Yjs 기반). */ @Schema(description = "노트 입장 응답 DTO") public record EnterResponse( @@ -24,14 +24,14 @@ public record EnterResponse( @Schema(description = "노트 제목") String title, - @Schema(description = "노트 현재 내용") - String content, + @Schema(description = "현재 Y.Doc 상태 (Base64 인코딩된 Yjs Update)") + String ydocBinary, - @Schema(description = "현재 문서 버전 (OT용)") - Integer revision, + @Schema(description = "현재 활성 사용자 목록") + List activeUsers, - @Schema(description = "현재 활성 사용자 목록 (workspaceMemberId 리스트)") - List activeUsers, + @Schema(description = "입장한 현재 사용자의 WorkspaceMember ID (Yjs Awareness 초기화용)") + Long currentUserWorkspaceMemberId, @Schema(description = "입장 시각") LocalDateTime timestamp @@ -109,48 +109,55 @@ public record ActiveUserInfo( String color ) {} - // ========== 실시간 편집 관련 DTO ========== + // ========== 실시간 편집 관련 DTO (Yjs CRDT 기반) ========== /** - * 편집 요청 DTO (클라이언트 → 서버) + * Yjs Update 요청 DTO (클라이언트 → 서버) + * + *

    Yjs 표준 프로토콜 기반: + *

      + *
    • base64Update: Y.encodeStateAsUpdate() 결과를 Base64로 인코딩
    • + *
    • base64StateVector: 클라이언트의 State Vector (선택적)
    • + *
    + * + *

    State Vector가 포함되면 서버는 클라이언트가 아직 가지지 않은 Update만 전송할 수 있습니다. + * CRDT의 특성에 따라 서버는 자동으로 충돌을 해결하므로 추가 변환 로직이 불필요합니다. */ - @Schema(description = "편집 요청 DTO") - public record EditRequest( - @Schema(description = "편집 연산") - EditOperation operation + @Schema(description = "Yjs Update 요청 DTO") + public record YjsUpdateRequest( + @Schema(description = "Base64 인코딩된 Yjs Update (Y.encodeStateAsUpdate() 결과)") + String base64Update, + + @Schema(description = "클라이언트의 State Vector (Base64, 선택적) - 필요한 Update만 요청할 때 사용") + String base64StateVector ) {} /** - * 편집 브로드캐스트 메시지 (서버 → 모든 참여자) + * Yjs Update 브로드캐스트 메시지 (서버 → 모든 참여자) * - *

    한 사용자의 편집이 다른 참여자들에게 전파될 때 사용됩니다. + *

    한 사용자의 편집으로부터 생성된 Yjs Update가 다른 참여자들에게 전파될 때 사용됩니다. + * CRDT는 Update 수신 순서에 관계없이 모든 클라이언트가 동일한 최종 상태에 도달합니다. */ - @Schema(description = "편집 브로드캐스트 메시지") - public record EditBroadcastMessage( - @Schema(description = "변환된 편집 연산") - EditOperation operation, - - @Schema(description = "최신 문서 전체 내용 (10개 연산마다 전송)") - String content, - - @Schema(description = "새 문서 버전 번호") - int revision, + @Schema(description = "Yjs Update 브로드캐스트 메시지") + public record YjsUpdateBroadcastMessage( + @Schema(description = "Base64 인코딩된 Yjs Update (바이너리 형식)") + String base64Update, - @Schema(description = "편집한 사용자의 WorkspaceMember ID") + @Schema(description = "업데이트를 생성한 사용자의 WorkspaceMember ID") Long workspaceMemberId, - @Schema(description = "편집한 사용자 이름") + @Schema(description = "업데이트한 사용자 이름") String userName, - @Schema(description = "편집 시각") - LocalDateTime timestamp, - - @Schema(description = "전체 content 포함 여부 (동기화용)") - boolean includesFullContent + @Schema(description = "업데이트 시각") + LocalDateTime timestamp ) {} /** * 에러 메시지 DTO (서버 → 특정 사용자) + * + *

    CRDT 기반 동기화: 에러 발생 시 클라이언트가 최신 Y.Doc 상태를 요청하여 동기화합니다. + * 따라서 에러 메시지에는 Y.Doc 바이너리를 포함하여 클라이언트가 즉시 동기화할 수 있습니다. */ @Schema(description = "에러 메시지 DTO") public record ErrorMessage( @@ -160,11 +167,8 @@ public record ErrorMessage( @Schema(description = "에러 메시지") String message, - @Schema(description = "현재 문서 내용 (동기화용)") - String content, - - @Schema(description = "현재 문서 버전 (동기화용)") - Integer revision, + @Schema(description = "현재 Y.Doc 상태 (Base64, 동기화용)") + String ydocBinary, @Schema(description = "에러 발생 시각") LocalDateTime timestamp @@ -173,27 +177,30 @@ public record ErrorMessage( * 동기화 정보 없는 단순 에러 메시지 생성 */ public static ErrorMessage of(String code, String message) { - return new ErrorMessage(code, message, null, null, LocalDateTime.now()); + return new ErrorMessage(code, message, null, LocalDateTime.now()); } /** - * 동기화 정보 포함 에러 메시지 생성 + * 동기화 정보 포함 에러 메시지 생성 (Y.Doc 바이너리 포함) */ - public static ErrorMessage withSync(String code, String message, String content, int revision) { - return new ErrorMessage(code, message, content, revision, LocalDateTime.now()); + public static ErrorMessage withSync(String code, String message, String ydocBinary) { + return new ErrorMessage(code, message, ydocBinary, LocalDateTime.now()); } } /** - * 편집 성공 응답 DTO (서버 → 편집 요청한 사용자) + * Yjs Update 성공 응답 DTO (서버 → 업데이트 요청한 사용자) + * + *

    CRDT 기반으로는 Update가 자동으로 병합되므로 "적용된 업데이트"라는 개념이 없습니다. + * 따라서 성공 여부와 Update 크기만 반환합니다. */ - @Schema(description = "편집 성공 응답 DTO") - public record EditResponse( - @Schema(description = "적용된 연산 (변환 후)") - EditOperation appliedOperation, + @Schema(description = "Yjs Update 성공 응답 DTO") + public record YjsUpdateResponse( + @Schema(description = "성공 여부") + boolean success, - @Schema(description = "새 문서 버전") - int newRevision, + @Schema(description = "저장된 Update 바이너리 크기 (bytes)") + int updateSize, @Schema(description = "성공 메시지") String message, @@ -283,20 +290,20 @@ public record CursorsAdjustedMessage( /** * 자동 저장 완료 메시지 (서버 → 모든 참여자) + * + *

    Yjs 기반: Y.Doc 바이너리 전체가 저장되며, 버전 번호가 없습니다. + * Yjs의 Logical Clock이 자동으로 순서를 관리합니다. */ @Schema(description = "자동 저장 완료 메시지") public record SaveCompletedMessage( - @Schema(description = "저장된 문서 버전") - int revision, - @Schema(description = "저장 시각") LocalDateTime savedAt, @Schema(description = "메시지", example = "자동 저장됨") String message ) { - public static SaveCompletedMessage of(int revision) { - return new SaveCompletedMessage(revision, LocalDateTime.now(), "자동 저장됨"); + public static SaveCompletedMessage of() { + return new SaveCompletedMessage(LocalDateTime.now(), "자동 저장됨"); } } @@ -415,6 +422,8 @@ public record GetDetailRequest( /** * 노트 상세 조회 응답 DTO (서버 → 클라이언트) + * + *

    Yjs 기반: Y.Doc 바이너리와 현재 활성 사용자 정보를 포함합니다. */ @Schema(description = "노트 상세 조회 응답 DTO") public record GetDetailResponse( @@ -424,8 +433,8 @@ public record GetDetailResponse( @Schema(description = "노트 제목") String title, - @Schema(description = "노트 내용") - String content, + @Schema(description = "현재 Y.Doc 상태 (Base64 인코딩된 Yjs Update)") + String ydocBinary, @Schema(description = "워크스페이스 ID") Long workspaceId, @@ -446,10 +455,7 @@ public record GetDetailResponse( LocalDateTime lastModifiedAt, @Schema(description = "생성 시각") - LocalDateTime createdAt, - - @Schema(description = "현재 문서 버전 (OT용)") - Integer revision + LocalDateTime createdAt ) {} /** @@ -478,15 +484,14 @@ public record DeleteResponse( /** * 노트 수동 저장 응답 DTO (서버 → 모든 참여자) + * + *

    Yjs 기반: Y.Doc 전체가 저장되며, 버전 번호가 제거되었습니다. */ @Schema(description = "노트 수동 저장 응답 DTO") public record SaveResponse( @Schema(description = "저장된 노트 ID") Long noteId, - @Schema(description = "저장된 문서 버전") - Integer revision, - @Schema(description = "저장 시각") LocalDateTime savedAt, diff --git a/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessage.java b/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessage.java index 16691a4..2651c90 100644 --- a/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessage.java +++ b/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessage.java @@ -16,36 +16,20 @@ *

      *
    • ENTER: 노트에 입장 (payload: null)
    • *
    • LEAVE: 노트에서 퇴장 (payload: null)
    • - *
    • EDIT: 편집 연산 (payload: EditOperation)
    • - *
    • CURSOR: 커서 위치 변경 (payload: CursorPosition)
    • + *
    • EDIT: Yjs 업데이트 (payload: YjsUpdateMessage - Base64 Update)
    • + *
    • CURSOR: 커서 위치 변경 (payload: CursorPosition - Awareness로 처리)
    • *
    • SAVE: 자동 저장 완료 알림 (payload: SaveResult)
    • *
    • ERROR: 에러 발생 (payload: ErrorDetails)
    • *
    * - *

    사용 예시: - *

    {@code
    - * // 편집 연산 메시지 생성
    - * EditOperation operation = EditOperation.insert(10, "Hello", 5, 123L);
    - * WebSocketMessage message = WebSocketMessage.of(
    - *     WebSocketMessageType.EDIT,
    - *     operation,
    - *     123L
    - * );
    - *
    - * // 커서 위치 메시지 생성
    - * CursorPosition cursor = CursorPosition.builder()
    - *     .position(10)
    - *     .range(0)
    - *     .workspaceMemberId(123L)
    - *     .build();
    - * WebSocketMessage cursorMsg = WebSocketMessage.of(
    - *     WebSocketMessageType.CURSOR,
    - *     cursor,
    - *     123L
    - * );
    - * }
    + *

    참고: + *

      + *
    • Yjs CRDT 기반 실시간 협업 편집 사용
    • + *
    • 커서는 Yjs Awareness API로 자동 동기화 (Redis 저장 불필요)
    • + *
    • OT(Operational Transformation)에서 마이그레이션 완료
    • + *
    * - * @param 메시지 payload의 타입 (EditOperation, CursorPosition 등) + * @param 메시지 payload의 타입 (String/Base64 Update, CursorPosition 등) */ @Getter @Builder diff --git a/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessageType.java b/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessageType.java index d8a2dff..b54b09c 100644 --- a/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessageType.java +++ b/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessageType.java @@ -23,18 +23,20 @@ public enum WebSocketMessageType { LEAVE, /** - * 편집 연산 - * - 사용자가 텍스트를 삽입하거나 삭제했을 때 - * - payload: EditOperation (insert/delete 정보) + * Yjs 업데이트 + * - 사용자가 텍스트를 편집했을 때 + * - payload: Base64 인코딩된 Yjs Update (Yjs CRDT 기반) * - 클라이언트 → 서버 → 다른 참여자들에게 브로드캐스트 + * - 참고: OT(Operational Transformation)에서 Yjs CRDT로 마이그레이션 */ EDIT, /** - * 커서 위치 변경 - * - 사용자의 커서 위치가 변경되었을 때 + * 커서/선택 범위 동기화 + * - Yjs Awareness API로 자동 동기화됨 * - payload: CursorPosition (position, range 정보) - * - 클라이언트 → 서버 → 다른 참여자들에게 브로드캐스트 + * - Redis 저장 불필요 (Awareness가 전담) + * - 참고: 사용되지 않음 (Awareness 사용) */ CURSOR, From b075318a3a8ed24a97aab5628d599637fc0d531a Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Tue, 4 Nov 2025 10:34:44 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=8F=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20Yjs=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../note/controller/NoteController.java | 12 +- .../controller/NoteWebSocketController.java | 330 ++++++++---------- .../note/repository/NoteRepository.java | 16 + .../note/scheduler/NoteAutoSaveScheduler.java | 192 ++++++++-- 4 files changed, 334 insertions(+), 216 deletions(-) diff --git a/src/main/java/com/project/syncly/domain/note/controller/NoteController.java b/src/main/java/com/project/syncly/domain/note/controller/NoteController.java index f74e564..fc70b06 100644 --- a/src/main/java/com/project/syncly/domain/note/controller/NoteController.java +++ b/src/main/java/com/project/syncly/domain/note/controller/NoteController.java @@ -172,15 +172,16 @@ public ResponseEntity> updat @Operation( summary = "노트 수동 저장", description = """ - 사용자가 명시적으로 저장 버튼을 클릭했을 때 호출합니다. + 사용자가 명시적으로 저장 버튼을 클릭했을 때 호출합니다. (Yjs CRDT 기반) **처리 흐름:** - 1. Redis에서 현재 content와 revision 조회 - 2. DB의 Note 엔티티 업데이트 + 1. Redis에서 현재 ydocBinary 조회 + 2. DB의 Note 엔티티에 ydocBinary 저장 3. Redis dirty 플래그 false로 변경 4. WebSocket으로 저장 완료 메시지 브로드캐스트 **주의사항:** + - Yjs CRDT 기반이므로 revision 개념 없음 (자동 충돌 해결) - 자동 저장과 동일한 로직 사용 - 독립적인 트랜잭션으로 처리 """ @@ -206,9 +207,8 @@ public ResponseEntity> saveNote( NoteResponseDto.SaveResponse response; if (saved) { - // 저장 성공 시 revision 조회 - int revision = noteService.getRevisionFromRedis(noteId); - response = NoteResponseDto.SaveResponse.success(revision, java.time.LocalDateTime.now()); + // ✅ Yjs CRDT 기반이므로 revision 필드 없음 (자동 충돌 해결) + response = NoteResponseDto.SaveResponse.success(java.time.LocalDateTime.now()); } else { response = NoteResponseDto.SaveResponse.failure("저장에 실패했습니다"); } diff --git a/src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java b/src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java index fd736ce..83e4f12 100644 --- a/src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java +++ b/src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java @@ -1,7 +1,6 @@ package com.project.syncly.domain.note.controller; import com.project.syncly.domain.note.dto.CursorPosition; -import com.project.syncly.domain.note.dto.EditOperation; import com.project.syncly.domain.note.dto.NoteWebSocketDto; import com.project.syncly.domain.note.dto.WebSocketMessage; import com.project.syncly.domain.note.dto.WebSocketMessageType; @@ -12,7 +11,7 @@ import com.project.syncly.domain.note.repository.NoteParticipantRepository; import com.project.syncly.domain.note.repository.NoteRepository; import com.project.syncly.domain.note.service.NoteRedisService; -import com.project.syncly.domain.note.service.OTService; +import com.project.syncly.domain.note.service.YjsService; import com.project.syncly.domain.workspaceMember.entity.WorkspaceMember; import com.project.syncly.domain.workspaceMember.repository.WorkspaceMemberRepository; import com.project.syncly.global.jwt.PrincipalDetails; @@ -59,7 +58,7 @@ public class NoteWebSocketController { private final SimpMessagingTemplate messagingTemplate; private final NoteRedisService noteRedisService; - private final OTService otService; + private final YjsService yjsService; private final NoteRepository noteRepository; private final NoteParticipantRepository noteParticipantRepository; private final WorkspaceMemberRepository workspaceMemberRepository; @@ -153,25 +152,82 @@ public void handleEnter( ); // 7. Redis에서 노트 데이터 조회 (없으면 DB에서 초기화) - String content = noteRedisService.getContent(noteId); - if (content == null) { - // Redis에 없으면 DB에서 로드하여 초기화 - noteRedisService.initializeNote(noteId, note.getContent()); - content = note.getContent(); + // ⚠️ 중요: Redis가 없으면 진행 전에 Redis에 저장된 dirty 노트가 있는지 확인 + // (사용자가 저장 후 곧바로 퇴장했을 경우를 대비) + + // 먼저 Redis에서 확인 + String ydocUpdate = yjsService.getYdocAsUpdate(noteId); + + // Redis에 데이터가 없으면 DB에서 로드하기 전에 스케줄러 호출 + if (ydocUpdate == null || ydocUpdate.isEmpty()) { + log.info("Redis에 Y.Doc 없음 - DB에서 복원: noteId={}", noteId); + + // ✅ 1단계: Redis의 dirty 노트 중에 이 noteId가 있는지 확인 + // (사용자가 저장 버튼을 눌었지만 30초 스케줄 대기 중일 수 있음) + // 이 경우는 Redis에 데이터가 있어야 하므로 여기서는 발생 안 함 + + // ✅ 2단계: DB에서 ydocBinary 확인 + String dbYdoc = note.getYdocBinary(); + log.info("DB ydocBinary 확인: noteId={}, dbYdocSize={}", + noteId, dbYdoc != null ? dbYdoc.length() : 0); + + if (dbYdoc != null && !dbYdoc.isEmpty()) { + // ✅ DB에 데이터가 있으면 Redis에 복원 + log.info("DB의 ydocBinary를 Redis에 복원: noteId={}, size={}", noteId, dbYdoc.length()); + yjsService.loadFromDatabase(noteId, dbYdoc); + } else { + // ❌ DB에도 데이터가 없으면 → 저장되지 않은 상태 + // (사용자가 저장 버튼 클릭 후 퇴장, 스케줄러 대기 중) + log.warn("Redis & DB 모두 비어있음 - 새 노트 또는 저장 미완료: noteId={}", noteId); + // 빈 상태로 초기화 + noteRedisService.setYdocBinaryWithoutDirty(noteId, ""); + } + + ydocUpdate = yjsService.getYdocAsUpdate(noteId); } - Integer revision = noteRedisService.getRevision(noteId); - List activeUserIds = noteRedisService.getActiveUsers(noteId).stream() - .map(Long::valueOf) + // ✅ 로드 후 ENTER 응답 전 최종 확인 + log.info("ENTER 응답 준비 완료: noteId={}, ydocUpdateSize={}", + noteId, ydocUpdate != null ? ydocUpdate.length() : 0); + + // TODO: Yjs State Vector 기반 동기화 + // 클라이언트의 state vector를 받아서 필요한 Update만 전송 + // 현재는 모든 Update를 전송함 + + // 활성 사용자 정보 조회 + List activeParticipants = noteRedisService.getActiveUsers(noteId) + .stream() + .map(userId -> { + Long wm_id = Long.valueOf(userId); + try { + WorkspaceMember wm = workspaceMemberRepository.findById(wm_id).orElse(null); + if (wm != null) { + return new NoteWebSocketDto.ActiveUserInfo( + wm_id, + wm.getName(), + wm.getProfileImage(), + com.project.syncly.domain.note.util.UserColorGenerator.generateColor(wm_id) + ); + } + } catch (Exception e) { + log.warn("워크스페이스 멤버 조회 실패: workspaceMemberId={}", wm_id, e); + } + return new NoteWebSocketDto.ActiveUserInfo( + wm_id, + "Unknown User", + null, + com.project.syncly.domain.note.util.UserColorGenerator.generateColor(wm_id) + ); + }) .collect(Collectors.toList()); // 8. 입장한 사용자에게 EnterResponse 전송 (유니캐스트) NoteWebSocketDto.EnterResponse enterResponse = new NoteWebSocketDto.EnterResponse( noteId, note.getTitle(), - content, - revision, - activeUserIds, + ydocUpdate, // Y.Doc Update (Base64) + activeParticipants, + workspaceMemberId, // 현재 입장한 사용자의 WorkspaceMember ID LocalDateTime.now() ); @@ -192,7 +248,7 @@ public void handleEnter( workspaceMemberId, userName, profileImage, - activeUserIds.size(), + activeParticipants.size(), LocalDateTime.now() ); @@ -208,7 +264,7 @@ public void handleEnter( ); log.info("노트 입장 완료: noteId={}, workspaceMemberId={}, activeUsers={}", - noteId, workspaceMemberId, activeUserIds.size()); + noteId, workspaceMemberId, activeParticipants.size()); } /** @@ -311,34 +367,42 @@ public void handleLeave( } /** - * 노트 편집 핸들러 + * 노트 편집 핸들러 (Yjs CRDT 기반) * - *

    사용자의 편집 연산을 처리하고 OT 알고리즘을 적용하여 다른 참여자들에게 브로드캐스트합니다. + *

    사용자의 Yjs Update를 처리하고 모든 참여자에게 브로드캐스트합니다. * *

    처리 흐름: *

      *
    1. 사용자 권한 검증 (해당 노트의 active user인지)
    2. - *
    3. OTService.processEdit() 호출 (transform + 적용 + Redis 저장)
    4. - *
    5. 성공 시 모든 참여자에게 EditBroadcastMessage 전송
    6. - *
    7. 10개 연산마다 전체 content 포함 (동기화)
    8. - *
    9. 실패 시 재시도 (최대 3회)
    10. + *
    11. YjsService.applyYjsUpdate() 호출 (Update 저장, dirty 플래그 설정)
    12. + *
    13. 성공 시 모든 참여자에게 Update 브로드캐스트
    14. + *
    15. 자동 충돌 해결 (CRDT 특성)
    16. *
    * + *

    특징: + *

      + *
    • OT 변환 불필요 (CRDT가 처리)
    • + *
    • Revision 불필요 (Logical Clock 사용)
    • + *
    • 재시도 불필요 (CRDT 특성)
    • + *
    + * * @param noteId 노트 ID - * @param request 편집 요청 (EditOperation 포함) + * @param request 편집 요청 (Yjs Update 포함) * @param principal 인증된 사용자 정보 (memberId) */ @MessageMapping("/notes/{noteId}/edit") public void handleEdit( @DestinationVariable Long noteId, - @Payload NoteWebSocketDto.EditRequest request, + @Payload NoteWebSocketDto.YjsUpdateRequest request, Principal principal ) { Long memberId = extractMemberId(principal); - EditOperation operation = request.operation(); + String base64Update = request.base64Update(); + String base64StateVector = request.base64StateVector(); - log.debug("편집 요청 수신: noteId={}, memberId={}, operation={}", - noteId, memberId, operation); + log.debug("Yjs Update 수신: noteId={}, memberId={}, updateSize={}, hasStateVector={}", + noteId, memberId, base64Update != null ? base64Update.length() : 0, + base64StateVector != null && !base64StateVector.isEmpty()); try { // 1. 노트 존재 확인 @@ -364,31 +428,22 @@ public void handleEdit( return; } - // 4. 편집 처리 (재시도 로직 포함) - OTService.ProcessEditResult result = processEditWithRetry(noteId, operation, MAX_RETRY_COUNT); - - log.info("편집 성공: noteId={}, workspaceMemberId={}, type={}, position={}, " + - "originalRevision={}, newRevision={}, contentLength={}", - noteId, workspaceMemberId, operation.getType(), operation.getPosition(), - operation.getRevision(), result.revision(), result.content().length()); + // 4. Yjs Update 처리 (State Vector 포함, CRDT가 자동으로 처리) + YjsService.ApplyYjsUpdateResult result = yjsService.applyYjsUpdate(noteId, base64Update, base64StateVector); - // 5. 전체 content 포함 여부 결정 (10개 연산마다) - boolean includeFullContent = (result.revision() % FULL_CONTENT_BROADCAST_INTERVAL == 0); - String contentToSend = includeFullContent ? result.content() : null; + log.info("Yjs Update 처리 성공: noteId={}, workspaceMemberId={}, updateSize={}", + noteId, workspaceMemberId, result.updateSize()); - // 6. 모든 참여자에게 EditBroadcastMessage 브로드캐스트 - NoteWebSocketDto.EditBroadcastMessage broadcastMessage = - new NoteWebSocketDto.EditBroadcastMessage( - result.appliedOperation(), - contentToSend, - result.revision(), + // 5. 모든 참여자에게 Update 브로드캐스트 + NoteWebSocketDto.YjsUpdateBroadcastMessage broadcastMessage = + new NoteWebSocketDto.YjsUpdateBroadcastMessage( + base64Update, workspaceMemberId, userName, - LocalDateTime.now(), - includeFullContent + LocalDateTime.now() ); - WebSocketMessage message = WebSocketMessage.of( + WebSocketMessage message = WebSocketMessage.of( WebSocketMessageType.EDIT, broadcastMessage, workspaceMemberId @@ -399,133 +454,27 @@ public void handleEdit( message ); - log.debug("편집 브로드캐스트 완료: noteId={}, revision={}, includeFullContent={}", - noteId, result.revision(), includeFullContent); + log.debug("Update 브로드캐스트 완료: noteId={}", noteId); } catch (NoteException e) { - log.error("편집 처리 중 NoteException 발생: noteId={}, memberId={}, error={}", + log.error("Update 처리 중 NoteException 발생: noteId={}, memberId={}, error={}", noteId, memberId, e.getMessage()); - handleEditError(principal.getName(), noteId, e); + sendErrorToUser(principal.getName(), noteId, e.getCode().getCode(), e.getMessage()); } catch (Exception e) { - log.error("편집 처리 중 예상치 못한 에러 발생: noteId={}, memberId={}, error={}", + log.error("Update 처리 중 예상치 못한 에러 발생: noteId={}, memberId={}, error={}", noteId, memberId, e.getMessage(), e); sendErrorToUser(principal.getName(), noteId, - NoteErrorCode.OT_TRANSFORM_FAILED.getCode(), + "UPDATE_FAILED", "편집 처리 중 오류가 발생했습니다. 페이지를 새로고침해주세요."); } } - /** - * 편집 처리 with 재시도 로직 - * - *

    동시 편집으로 인한 충돌 발생 시 최대 MAX_RETRY_COUNT번까지 재시도합니다. - * - * @param noteId 노트 ID - * @param operation 편집 연산 - * @param maxRetries 최대 재시도 횟수 - * @return 편집 처리 결과 - * @throws NoteException 재시도 후에도 실패한 경우 - */ - private OTService.ProcessEditResult processEditWithRetry( - Long noteId, - EditOperation operation, - int maxRetries - ) { - int attempt = 0; - Exception lastException = null; - - while (attempt < maxRetries) { - try { - return otService.processEdit(noteId, operation); - - } catch (NoteException e) { - // INVALID_OPERATION은 재시도해도 소용없음 → 즉시 throw - if (e.getCode() == NoteErrorCode.INVALID_OPERATION) { - throw e; - } - - lastException = e; - attempt++; - - if (attempt < maxRetries) { - log.warn("편집 처리 실패, 재시도 {}/{}: noteId={}, error={}", - attempt, maxRetries, noteId, e.getMessage()); - - // 짧은 대기 후 재시도 (exponential backoff) - try { - Thread.sleep(50L * attempt); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new NoteException(NoteErrorCode.OT_TRANSFORM_FAILED); - } - } else { - log.error("편집 처리 최종 실패: noteId={}, attempts={}, error={}", - noteId, attempt, e.getMessage()); - } - } - } - - // 모든 재시도 실패 - throw new NoteException(NoteErrorCode.CONCURRENT_EDIT_CONFLICT, - "동시 편집 충돌이 계속 발생합니다. 잠시 후 다시 시도해주세요."); - } - - /** - * 편집 에러 처리 - * - *

    에러 유형에 따라 적절한 에러 메시지와 동기화 정보를 클라이언트에 전송합니다. - * - * @param userId 사용자 ID (memberId) - * @param noteId 노트 ID - * @param exception 발생한 예외 - */ - private void handleEditError(String userId, Long noteId, NoteException exception) { - com.project.syncly.global.apiPayload.code.BaseErrorCode baseErrorCode = exception.getCode(); - String errorCodeStr = baseErrorCode.getCode(); - - // 동기화가 필요한 에러인 경우 현재 content와 revision 전송 - if (errorCodeStr.equals(NoteErrorCode.INVALID_OPERATION.getCode()) || - errorCodeStr.equals(NoteErrorCode.REVISION_MISMATCH.getCode()) || - errorCodeStr.equals(NoteErrorCode.CONCURRENT_EDIT_CONFLICT.getCode())) { - - try { - String currentContent = noteRedisService.getContent(noteId); - int currentRevision = noteRedisService.getRevision(noteId); - - NoteWebSocketDto.ErrorMessage errorMessage = NoteWebSocketDto.ErrorMessage.withSync( - errorCodeStr, - exception.getMessage(), - currentContent, - currentRevision - ); - - WebSocketMessage message = WebSocketMessage.of( - WebSocketMessageType.ERROR, - errorMessage, - null - ); - - messagingTemplate.convertAndSendToUser( - userId, - "/queue/errors", - message - ); - - log.info("동기화 에러 메시지 전송: userId={}, noteId={}, errorCode={}", - userId, noteId, errorCodeStr); - - } catch (Exception e) { - log.error("에러 처리 중 또 다른 에러 발생: userId={}, noteId={}, error={}", - userId, noteId, e.getMessage()); - // 최소한의 에러 메시지라도 전송 - sendErrorToUser(userId, noteId, errorCodeStr, exception.getMessage()); - } - } else { - // 단순 에러 메시지만 전송 - sendErrorToUser(userId, noteId, errorCodeStr, exception.getMessage()); - } - } + // ==================== OT 관련 메서드 제거 (Yjs 기반으로 변경) ==================== + // processEditWithRetry, handleEditError 메서드는 Yjs CRDT에서 필요 없음 + // - CRDT는 자동으로 충돌 해결 + // - 재시도 불필요 + // - revision 기반 동기화 불필요 /** * 사용자에게 에러 메시지 전송 @@ -593,8 +542,9 @@ public void handleCreate( Note createdNote = noteRepository.save(newNote); - // 4. Redis 초기화 - noteRedisService.initializeNote(createdNote.getId(), ""); + // 4. Redis 초기화 (빈 Y.Doc - dirty 플래그 미설정) + // ⚠️ 주의: setYdocBinaryWithoutDirty()로 호출하여 자동 저장 방지 + noteRedisService.setYdocBinaryWithoutDirty(createdNote.getId(), ""); // 5. 현재 사용자를 자동으로 참여자에 추가 NoteParticipant participant = NoteParticipant.builder() @@ -780,15 +730,20 @@ public void handleGetDetail( .findByWorkspaceIdAndMemberId(workspaceId, memberId) .orElseThrow(() -> new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER)); - // 3. Redis에서 내용 및 버전 조회 - String content = noteRedisService.getContent(noteId); - if (content == null) { - content = note.getContent(); - noteRedisService.initializeNote(noteId, content); + // 3. Redis에서 Y.Doc 조회 (없으면 DB에서 초기화) + String ydocUpdate = yjsService.getYdocAsUpdate(noteId); + if (ydocUpdate == null || ydocUpdate.isEmpty()) { + // Redis에 없으면 DB에서 로드하여 초기화 + String dbYdoc = note.getYdocBinary(); + if (dbYdoc != null && !dbYdoc.isEmpty()) { + yjsService.applyYjsUpdate(noteId, dbYdoc); + } else { + // 새 노트인 경우 빈 상태로 초기화 + yjsService.initializeEmptyDoc(noteId); + } + ydocUpdate = yjsService.getYdocAsUpdate(noteId); } - Integer revision = noteRedisService.getRevision(noteId); - // 4. 활성 참여자 정보 조회 List activeParticipants = noteRedisService.getActiveUsers(noteId) .stream() @@ -822,15 +777,14 @@ public void handleGetDetail( NoteWebSocketDto.GetDetailResponse response = new NoteWebSocketDto.GetDetailResponse( note.getId(), note.getTitle(), - content, + ydocUpdate, // Y.Doc Update (Base64) workspaceId, note.getCreator().getId(), note.getCreator().getName(), note.getCreator().getProfileImage(), activeParticipants, note.getLastModifiedAt(), - note.getCreatedAt(), - revision + note.getCreatedAt() ); WebSocketMessage message = WebSocketMessage.of( @@ -845,7 +799,7 @@ public void handleGetDetail( message ); - log.info("노트 상세 조회 완료: noteId={}, contentLength={}", noteId, content.length()); + log.info("노트 상세 조회 완료: noteId={}, ydocSize={}", noteId, ydocUpdate != null ? ydocUpdate.length() : 0); } catch (NoteException e) { log.error("노트 상세 조회 중 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage()); @@ -981,18 +935,28 @@ public void handleManualSave( .findByWorkspaceIdAndMemberId(workspaceId, memberId) .orElseThrow(() -> new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER)); - // 3. Redis에서 현재 content와 revision 조회 - String currentContent = noteRedisService.getContent(noteId); - int currentRevision = noteRedisService.getRevision(noteId); + // 3. Redis에서 Y.Doc 조회하여 DB에 저장 + String ydocUpdate = yjsService.getYdocAsUpdate(noteId); + if (ydocUpdate != null && !ydocUpdate.isEmpty()) { + note.updateYdocBinary(ydocUpdate); + noteRepository.save(note); + log.info("노트 Y.Doc 저장 완료: noteId={}, ydocSize={}", noteId, ydocUpdate.length()); + } else { + log.warn("저장할 Y.Doc이 없음: noteId={}", noteId); + } - // 4. DB에 저장 (updateContent 메서드 사용) - note.updateContent(currentContent); - noteRepository.save(note); + // 4. Redis dirty 플래그 해제 (이미 DB에 저장했으므로 dirty 상태 제거) + try { + noteRedisService.clearDirty(noteId); + log.debug("Redis dirty 플래그 해제: noteId={}", noteId); + } catch (Exception e) { + log.warn("Redis dirty 플래그 해제 실패 (무시): noteId={}, error={}", noteId, e.getMessage()); + // dirty 플래그 해제 실패는 무시하고 계속 진행 (이미 DB에 저장됨) + } // 5. 모든 참여자에게 저장 완료 메시지 브로드캐스트 NoteWebSocketDto.SaveResponse response = new NoteWebSocketDto.SaveResponse( noteId, - currentRevision, LocalDateTime.now(), "수동 저장되었습니다.", workspaceMember.getId(), @@ -1010,7 +974,7 @@ public void handleManualSave( message ); - log.info("노트 수동 저장 완료: noteId={}, revision={}", noteId, currentRevision); + log.info("노트 수동 저장 완료: noteId={}", noteId); } catch (NoteException e) { log.error("노트 저장 중 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage()); diff --git a/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java b/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java index 366a343..f472d18 100644 --- a/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java +++ b/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java @@ -66,4 +66,20 @@ public interface NoteRepository extends JpaRepository { @EntityGraph(attributePaths = {"creator", "workspace"}) @Query("SELECT n FROM Note n WHERE n.workspace.id = :workspaceId AND n.title LIKE %:keyword% AND n.isDeleted = false") Page searchByTitle(@Param("workspaceId") Long workspaceId, @Param("keyword") String keyword, Pageable pageable); + + /** + * OT 마이그레이션: content가 있지만 ydocBinary가 없는 노트 조회 + * + *

    기존 OT 기반 데이터를 Yjs CRDT로 마이그레이션하기 위해 사용됩니다. + * 이 메서드는 다음을 만족하는 노트를 반환합니다: + *

      + *
    • content 필드에 데이터가 있음
    • + *
    • ydocBinary 필드가 null이거나 비어있음
    • + *
    • 삭제되지 않은 노트만
    • + *
    + * + * @return OT 기반 데이터를 가진 노트 목록 + */ + @Query("SELECT n FROM Note n WHERE n.content IS NOT NULL AND n.content != '' AND (n.ydocBinary IS NULL OR n.ydocBinary = '') AND n.isDeleted = false") + List findLegacyOtNotes(); } diff --git a/src/main/java/com/project/syncly/domain/note/scheduler/NoteAutoSaveScheduler.java b/src/main/java/com/project/syncly/domain/note/scheduler/NoteAutoSaveScheduler.java index 8c7529e..13d7c1b 100644 --- a/src/main/java/com/project/syncly/domain/note/scheduler/NoteAutoSaveScheduler.java +++ b/src/main/java/com/project/syncly/domain/note/scheduler/NoteAutoSaveScheduler.java @@ -20,7 +20,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.Set; +import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -194,60 +194,95 @@ private boolean saveNoteWithRetry(Long noteId, int retryCount) { log.debug("노트 저장 시작: noteId={}, retryCount={}", noteId, retryCount); Timer.Sample sampleSave = Timer.start(meterRegistry); - // 1. Redis에서 현재 상태 조회 - String content; + // 🔍 [AutoSave Step 1] Redis에서 현재 Y.Doc 상태 조회 + String ydocBinary; try { - content = noteRedisService.getContent(noteId); + log.info("🔍 [AutoSave Step 1] Redis 조회 시작: noteId={}", noteId); + ydocBinary = noteRedisService.getYdocBinary(noteId); + log.info("🔍 [AutoSave Step 1] Redis 조회 완료 - size={}, first30={}, last20={}", + ydocBinary != null ? ydocBinary.length() : 0, + ydocBinary != null ? (ydocBinary.length() > 30 ? ydocBinary.substring(0, 30) : ydocBinary) : "null", + ydocBinary != null ? ydocBinary.substring(Math.max(0, ydocBinary.length() - 20)) : "null"); } catch (RedisConnectionFailureException e) { - log.error("Redis 연결 오류: noteId={}, 저장 중단", noteId, e); + log.error("❌ Redis 연결 오류: noteId={}, 저장 중단", noteId, e); redisErrorCount.incrementAndGet(); meterRegistry.counter("note.autosave.redis.errors").increment(); alertRedisError(e); return false; } - if (content == null) { - log.warn("Redis에 content가 없습니다: noteId={}", noteId); + if (ydocBinary == null || ydocBinary.isEmpty()) { + log.warn("⚠️ [AutoSave Step 1] Redis에 Y.Doc이 없습니다: noteId={}", noteId); noteRedisService.clearDirty(noteId); sampleSave.stop(Timer.builder("note.autosave.save.time") - .tag("result", "no_content") + .tag("result", "no_ydoc") .register(meterRegistry)); return false; } - int revision = noteRedisService.getRevision(noteId); - - // 2. DB에서 Note 엔티티 조회 + // 🔍 [AutoSave Step 2] DB에서 Note 엔티티 조회 + log.info("🔍 [AutoSave Step 2] DB에서 노트 조회 시작: noteId={}", noteId); Note note = noteRepository.findById(noteId).orElse(null); if (note == null) { - log.warn("DB에 노트가 존재하지 않습니다: noteId={}", noteId); + log.warn("⚠️ [AutoSave Step 2] DB에 노트가 존재하지 않습니다: noteId={}", noteId); noteRedisService.clearDirty(noteId); sampleSave.stop(Timer.builder("note.autosave.save.time") .tag("result", "not_found") .register(meterRegistry)); return false; } + log.info("🔍 [AutoSave Step 2] DB에서 노트 조회 완료 - 현재 ydocSize={}", + note.getYdocBinary() != null ? note.getYdocBinary().length() : 0); - // 3. Content 업데이트 - note.updateContent(content); + // 🔍 [AutoSave Step 3] Y.Doc 바이너리 업데이트 + log.info("🔍 [AutoSave Step 3] Y.Doc 바이너리 업데이트 - noteId={}, newSize={}", noteId, ydocBinary.length()); + note.updateYdocBinary(ydocBinary); - // 4. DB 저장 + // 🔍 [AutoSave Step 4] DB 저장 + log.info("🔍 [AutoSave Step 4] DB 저장 시작: noteId={}, ydocSize={}", noteId, ydocBinary.length()); noteRepository.save(note); + log.info("✅ [AutoSave Step 4] DB 저장 완료: noteId={}, size={}", noteId, ydocBinary.length()); + + // 🔍 [AutoSave Step 5] DB 저장 검증 + log.info("🔍 [AutoSave Step 5] DB 저장 검증 시작: noteId={}", noteId); + Note savedNote = noteRepository.findById(noteId).orElse(null); + if (savedNote != null) { + String dbYdoc = savedNote.getYdocBinary(); + if (dbYdoc != null && dbYdoc.equals(ydocBinary)) { + log.info("✔️ [AutoSave Step 5] DB 저장 검증 성공 - 저장한Size={}, 조회한Size={}, 일치=true", + ydocBinary.length(), dbYdoc.length()); + } else { + log.warn("⚠️ [AutoSave Step 5] DB 저장 검증 실패 - 저장한Size={}, 조회한Size={}, 일치=false", + ydocBinary.length(), dbYdoc != null ? dbYdoc.length() : 0); + } + } - // 5. Redis dirty 플래그 false로 변경 + // 5. Redis dirty 플래그 false로 변경 (저장 완료 표시) try { noteRedisService.clearDirty(noteId); + log.debug("Redis dirty 플래그 해제: noteId={}", noteId); } catch (RedisConnectionFailureException e) { log.error("Redis dirty 플래그 삭제 실패: noteId={}", noteId, e); redisErrorCount.incrementAndGet(); // 하지만 DB 저장은 성공했으므로 true 반환 } - log.info("노트 저장 완료: noteId={}, revision={}, contentLength={}, retryCount={}", - noteId, revision, content.length(), retryCount); + // ✅ 5-1. Redis의 ydocBinary도 현재 저장된 상태로 유지 + // (사용자가 저장 후 퇴장했을 때 Redis가 정리되어도, DB에서 복원 가능하도록) + try { + // ⚠️ setYdocBinaryWithoutDirty: dirty 플래그 설정 안 함 (이미 저장 완료) + noteRedisService.setYdocBinaryWithoutDirty(noteId, ydocBinary); + log.debug("Redis의 ydocBinary 동기화: noteId={}, size={}", noteId, ydocBinary.length()); + } catch (Exception e) { + log.warn("Redis의 ydocBinary 동기화 실패 (무시): noteId={}", noteId, e); + // Redis 동기화 실패는 치명적이지 않음 (DB에는 저장됨) + } + + log.info("노트 저장 완료: noteId={}, ydocSize={}, retryCount={}", + noteId, ydocBinary.length(), retryCount); - // 6. WebSocket 브로드캐스트 (저장 완료 알림) - broadcastSaveCompleted(noteId, revision); + // 5-1. WebSocket 브로드캐스트 (저장 완료 알림) + broadcastSaveCompleted(noteId); sampleSave.stop(Timer.builder("note.autosave.save.time") .tag("result", "success") @@ -301,19 +336,19 @@ public boolean performRetry(Long noteId, int retryCount) { } /** - * WebSocket으로 저장 완료 메시지 브로드캐스트 + * WebSocket으로 저장 완료 메시지 브로드캐스트 (Yjs 기반) */ - private void broadcastSaveCompleted(Long noteId, int revision) { + private void broadcastSaveCompleted(Long noteId) { try { NoteWebSocketDto.SaveCompletedMessage message = - NoteWebSocketDto.SaveCompletedMessage.of(revision); + NoteWebSocketDto.SaveCompletedMessage.of(); messagingTemplate.convertAndSend( "/topic/notes/" + noteId + "/save", message ); - log.debug("저장 완료 메시지 브로드캐스트: noteId={}, revision={}", noteId, revision); + log.debug("저장 완료 메시지 브로드캐스트: noteId={}", noteId); } catch (Exception e) { // WebSocket 브로드캐스트 실패는 치명적이지 않음 log.warn("저장 완료 메시지 브로드캐스트 실패: noteId={}", noteId, e); @@ -350,10 +385,11 @@ public boolean saveNoteManually(Long noteId) { } /** - * 애플리케이션 시작 시 모든 dirty 노트 즉시 저장 + * 애플리케이션 시작 시 모든 dirty 노트 즉시 저장 및 OT 데이터 마이그레이션 * - *

    애플리케이션이 재시작되었을 때, Redis에 남아있는 - * dirty 노트들을 즉시 DB에 저장하여 데이터 손실을 방지합니다. + *

    애플리케이션이 재시작되었을 때: + * 1. Redis에 남아있는 dirty 노트들을 즉시 DB에 저장하여 데이터 손실을 방지 + * 2. 기존 OT 기반 데이터를 Yjs CRDT 형식으로 마이그레이션 */ @EventListener(ApplicationReadyEvent.class) public void saveAllDirtyNotesOnStartup() { @@ -408,6 +444,108 @@ public void saveAllDirtyNotesOnStartup() { log.error("애플리케이션 시작 시 저장 중 오류 발생", e); meterRegistry.counter("note.startup.critical.errors").increment(); } + + // OT 데이터 마이그레이션 (Yjs로 전환) + try { + migrateOtDataToYjs(); + } catch (Exception e) { + log.error("OT 데이터 마이그레이션 중 오류 발생", e); + meterRegistry.counter("note.migration.critical.errors").increment(); + } + } + + /** + * OT 기반 데이터를 Yjs CRDT 형식으로 마이그레이션 + * + *

    마이그레이션 과정: + *

      + *
    1. content는 있지만 ydocBinary가 없는 노트 찾기
    2. + *
    3. content를 Base64 인코딩하여 ydocBinary에 저장
    4. + *
    5. 마이그레이션된 노트 개수 로깅 및 메트릭 기록
    6. + *
    + * + *

    주의사항: + *

      + *
    • OT 기반 content가 손실되지 않음 (양쪽 모두 저장)
    • + *
    • 대량 마이그레이션 시 시간이 걸릴 수 있음
    • + *
    • 각 노트는 독립적인 트랜잭션으로 처리
    • + *
    + */ + @Transactional + public void migrateOtDataToYjs() { + log.info("OT 데이터 마이그레이션 시작"); + + try { + // 1. content는 있지만 ydocBinary가 없는 노트 조회 + List legacyNotes = noteRepository.findLegacyOtNotes(); + + if (legacyNotes.isEmpty()) { + log.info("마이그레이션할 OT 데이터가 없습니다"); + return; + } + + log.info("마이그레이션 대상 노트 발견: {}개", legacyNotes.size()); + + int migratedCount = 0; + int failedCount = 0; + + // 2. 각 노트를 마이그레이션 + for (Note note : legacyNotes) { + try { + migrateNoteOtToYjs(note); + migratedCount++; + } catch (Exception e) { + log.error("노트 마이그레이션 실패: noteId={}", note.getId(), e); + failedCount++; + meterRegistry.counter("note.migration.failures").increment(); + } + } + + // 3. 메트릭 기록 + meterRegistry.counter("note.migration.success").increment(migratedCount); + meterRegistry.counter("note.migration.failures").increment(failedCount); + + log.info("OT 데이터 마이그레이션 완료: 성공={}, 실패={}", + migratedCount, failedCount); + + } catch (Exception e) { + log.error("OT 데이터 마이그레이션 중 오류 발생", e); + meterRegistry.counter("note.migration.critical.errors").increment(); + } + } + + /** + * 단일 노트의 OT 데이터를 Yjs로 마이그레이션 + * + * @param note 마이그레이션할 노트 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void migrateNoteOtToYjs(Note note) { + try { + // 1. content를 Base64로 인코딩 + String content = note.getContent(); + if (content == null || content.isEmpty()) { + log.warn("마이그레이션 대상 content가 비어있습니다: noteId={}", note.getId()); + return; + } + + // 2. Base64 인코딩 (UTF-8 바이트 배열 기반) + String ydocBinary = Base64.getEncoder() + .encodeToString(content.getBytes("UTF-8")); + + // 3. ydocBinary 업데이트 + note.updateYdocBinary(ydocBinary); + + // 4. DB 저장 + noteRepository.save(note); + + log.info("노트 마이그레이션 완료: noteId={}, contentSize={}, ydocSize={}", + note.getId(), content.length(), ydocBinary.length()); + + } catch (Exception e) { + log.error("노트 마이그레이션 중 오류: noteId={}", note.getId(), e); + throw new RuntimeException("마이그레이션 실패: " + e.getMessage(), e); + } } /** From 29e2d77a5252079a9f6a781c5ad2f14f3a4048bd Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Tue, 4 Nov 2025 10:35:12 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20Redis=20=ED=82=A4=20=EB=B0=8F?= =?UTF-8?q?=20WebSocket=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=EB=84=88=20Yjs=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EA=B2=8C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../syncly/global/event/WebSocketEventListener.java | 2 +- .../syncly/global/redis/enums/RedisKeyPrefix.java | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java b/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java index 7a4ba49..df8daf5 100644 --- a/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java +++ b/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java @@ -98,7 +98,7 @@ public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { // Redis에서 노트 참여 정보 제거 noteRedisService.removeUser(noteId, workspaceMemberId); - noteRedisService.removeCursor(noteId, workspaceMemberId); + // 커서는 Yjs Awareness가 자동으로 처리하므로 제거 불필요 // 노트 세션 매핑 삭제 redisTemplate.opsForHash().delete(RedisKeyPrefix.WS_NOTE_SESSIONS.get(), sessionId); diff --git a/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java b/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java index 52476d2..74f7ab1 100644 --- a/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java +++ b/src/main/java/com/project/syncly/global/redis/enums/RedisKeyPrefix.java @@ -30,13 +30,15 @@ public enum RedisKeyPrefix { //profile MEMBER_PROFILE("PROFILE_CACHE:"), - // Note 실시간 협업 관련 키 - NOTE_CONTENT("NOTE:CONTENT:"), // note:{noteId}:content + // Note 실시간 협업 관련 키 (Yjs CRDT 기반) + NOTE_YDOC("NOTE:YDOC:"), // note:{noteId}:ydoc (Base64 encoded Yjs Update) + NOTE_STATE_VECTOR("NOTE:STATE_VECTOR:"), // note:{noteId}:state_vector (상태 추적용) NOTE_USERS("NOTE:USERS:"), // note:{noteId}:users NOTE_CURSORS("NOTE:CURSORS:"), // note:{noteId}:cursors - NOTE_DIRTY("NOTE:DIRTY:"), // note:{noteId}:dirty - NOTE_REVISION("NOTE:REVISION:"), // note:{noteId}:revision - NOTE_OPERATIONS("NOTE:OPERATIONS:"), // note:{noteId}:operations + NOTE_DIRTY("NOTE:DIRTY:"), // note:{noteId}:dirty (자동 저장 플래그) + + // Deprecated (OT 관련, 마이그레이션 후 제거) + // NOTE_CONTENT, NOTE_REVISION, NOTE_OPERATIONS ; private final String prefix;