+ *
* @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/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/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/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/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로 처리)
*
- * @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,
diff --git a/src/main/java/com/project/syncly/domain/note/engine/OTEngine.java b/src/main/java/com/project/syncly/domain/note/engine/OTEngine.java
deleted file mode 100644
index f2bae88..0000000
--- a/src/main/java/com/project/syncly/domain/note/engine/OTEngine.java
+++ /dev/null
@@ -1,431 +0,0 @@
-package com.project.syncly.domain.note.engine;
-
-import com.project.syncly.domain.note.dto.EditOperation;
-import lombok.extern.slf4j.Slf4j;
-
-import java.util.List;
-
-/**
- * OT(Operational Transformation) 엔진
- *
- *
동시 편집 충돌을 해결하기 위한 핵심 변환 로직을 제공합니다.
- *
- *
OT의 원리:
- *
- *
두 사용자가 동시에 같은 문서의 다른 버전을 편집할 때
- *
나중에 도착한 연산을 먼저 적용된 연산에 맞춰 "변환"합니다
- *
이를 통해 모든 클라이언트가 최종적으로 동일한 상태에 도달합니다
- *
- *
- *
예시:
- *
- * 초기 문서: "Hello"
- *
- * 사용자 A: position 5에 " World" 삽입 → "Hello World"
- * 사용자 B: position 0에서 1자 삭제 → "ello"
- *
- * B의 연산이 먼저 서버에 도착:
- * 1. 서버: "ello" 적용, revision=1
- * 2. A의 연산(rev=0)이 도착하면 B의 연산으로 변환:
- * - transform(A_insert, B_delete)
- * - A의 position 5 → 4로 조정 (1자가 삭제되었으므로)
- * 3. 최종: "ello" + " World" at position 4 = "ello World"
- *
- */
-@Slf4j
-public class OTEngine {
-
- /**
- * 두 연산 중 하나를 다른 연산에 맞춰 변환합니다.
- *
- *
OT의 핵심 메서드입니다. op가 appliedOp 이후에 적용될 수 있도록
- * op의 position 또는 length를 조정합니다.
- *
- * @param op 변환하려는 연산 (새로 들어온 연산)
- * @param appliedOp 이미 적용된 연산
- * @return 변환된 연산
- */
- public static EditOperation transform(EditOperation op, EditOperation appliedOp) {
- if (op.isInsert() && appliedOp.isInsert()) {
- return transformInsertInsert(op, appliedOp);
- } else if (op.isInsert() && appliedOp.isDelete()) {
- return transformInsertDelete(op, appliedOp);
- } else if (op.isDelete() && appliedOp.isInsert()) {
- return transformDeleteInsert(op, appliedOp);
- } else if (op.isDelete() && appliedOp.isDelete()) {
- return transformDeleteDelete(op, appliedOp);
- }
-
- // 도달하지 않아야 함
- log.warn("Unknown operation types: op={}, appliedOp={}", op.getType(), appliedOp.getType());
- return op;
- }
-
- /**
- * INSERT vs INSERT 변환
- *
- *
시나리오: 두 사용자가 동시에 텍스트를 삽입
- *
- *
변환 규칙:
- *
- *
appliedOp.position < op.position: op를 오른쪽으로 이동 (appliedOp.length만큼)
- *
appliedOp.position > op.position: op는 그대로 유지
- *
appliedOp.position == op.position: workspaceMemberId로 우선순위 결정
- *
- *
작은 ID가 우선 (왼쪽에 삽입)
- *
큰 ID는 오른쪽으로 이동
- *
- *
- *
- *
- *
예시:
- *
- * 문서: "Hello"
- * appliedOp: Insert "X" at position 2 → "HeXllo"
- * op: Insert "Y" at position 3 → 원래 목표는 "HelYlo"
- *
- * 변환 후 op: Insert "Y" at position 4 (2 + length(X))
- * 최종: "HeXlYlo"
- *
- *
- * @param op 변환할 INSERT 연산
- * @param appliedOp 이미 적용된 INSERT 연산
- * @return 변환된 INSERT 연산
- */
- private static EditOperation transformInsertInsert(EditOperation op, EditOperation appliedOp) {
- // appliedOp가 op보다 앞에 삽입된 경우
- if (appliedOp.getPosition() < op.getPosition()) {
- // op의 위치를 오른쪽으로 이동 (appliedOp가 삽입한 길이만큼)
- return op.withPosition(op.getPosition() + appliedOp.getLength());
- }
- // 같은 위치에 삽입하는 경우: workspaceMemberId로 우선순위 결정
- else if (appliedOp.getPosition() == op.getPosition()) {
- // workspaceMemberId가 작은 쪽이 왼쪽(먼저) 삽입
- // appliedOp의 ID가 작으면 op를 오른쪽으로 이동
- if (appliedOp.getWorkspaceMemberId() < op.getWorkspaceMemberId()) {
- return op.withPosition(op.getPosition() + appliedOp.getLength());
- }
- // op의 ID가 더 작으면 그대로 유지 (op가 왼쪽에 삽입됨)
- return op;
- }
- // appliedOp가 op보다 뒤에 삽입된 경우: op는 영향받지 않음
- else {
- return op;
- }
- }
-
- /**
- * INSERT vs DELETE 변환
- *
- *
시나리오: 한 사용자는 삽입하고, 다른 사용자는 삭제
- *
- *
변환 규칙:
- *
- *
appliedOp(DELETE)가 op(INSERT) 앞쪽을 삭제: op를 왼쪽으로 이동
- *
appliedOp가 op 위치를 포함해서 삭제: op를 삭제 시작점으로 이동
- *
appliedOp가 op 뒤쪽을 삭제: op는 영향받지 않음
- *
- *
- *
예시:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 6 chars at position 5 → "Hello" (뒤의 " World" 삭제)
- * op: Insert "!" at position 11 → 원래 목표는 "Hello World!"
- *
- * 변환 후 op: Insert "!" at position 5 (11 - 6)
- * 최종: "Hello!"
- *
- *
- * @param op 변환할 INSERT 연산
- * @param appliedOp 이미 적용된 DELETE 연산
- * @return 변환된 INSERT 연산
- */
- private static EditOperation transformInsertDelete(EditOperation op, EditOperation appliedOp) {
- int deleteStart = appliedOp.getPosition();
- int deleteEnd = appliedOp.getEndPosition();
-
- // DELETE가 INSERT보다 완전히 뒤에 있는 경우: INSERT는 영향받지 않음
- if (deleteStart >= op.getPosition()) {
- return op;
- }
- // DELETE가 INSERT 위치를 포함하는 경우: INSERT를 DELETE 시작점으로 이동
- else if (deleteEnd >= op.getPosition()) {
- return op.withPosition(deleteStart);
- }
- // DELETE가 INSERT보다 앞에 있는 경우: INSERT를 왼쪽으로 이동
- else {
- return op.withPosition(op.getPosition() - appliedOp.getLength());
- }
- }
-
- /**
- * DELETE vs INSERT 변환
- *
- *
시나리오: 한 사용자는 삭제하고, 다른 사용자는 삽입
- *
- *
변환 규칙:
- *
- *
appliedOp(INSERT)가 op(DELETE) 앞에 삽입: op를 오른쪽으로 이동
- *
appliedOp가 op 범위 내에 삽입: op의 길이를 늘림 (삽입된 텍스트도 삭제)
- *
appliedOp가 op 뒤에 삽입: op는 영향받지 않음
- *
- *
- *
예시:
- *
- * 문서: "Hello World"
- * appliedOp: Insert "Beautiful " at position 6 → "Hello Beautiful World"
- * op: Delete 5 chars at position 6 → 원래 목표는 "Hello " (World 삭제)
- *
- * 변환 후 op: Delete 5 chars at position 16 (6 + length("Beautiful "))
- * 최종: "Hello Beautiful "
- *
- *
- * @param op 변환할 DELETE 연산
- * @param appliedOp 이미 적용된 INSERT 연산
- * @return 변환된 DELETE 연산
- */
- private static EditOperation transformDeleteInsert(EditOperation op, EditOperation appliedOp) {
- int deleteStart = op.getPosition();
- int deleteEnd = op.getEndPosition();
-
- // INSERT가 DELETE보다 앞에 있는 경우: DELETE를 오른쪽으로 이동
- if (appliedOp.getPosition() < deleteStart) {
- return op.withPosition(op.getPosition() + appliedOp.getLength());
- }
- // INSERT가 DELETE 범위 내에 있는 경우: DELETE 길이를 늘림
- else if (appliedOp.getPosition() >= deleteStart && appliedOp.getPosition() < deleteEnd) {
- // 삽입된 텍스트도 함께 삭제하도록 길이 증가
- return op.withLength(op.getLength() + appliedOp.getLength());
- }
- // INSERT가 DELETE보다 뒤에 있는 경우: DELETE는 영향받지 않음
- else {
- return op;
- }
- }
-
- /**
- * DELETE vs DELETE 변환
- *
- *
시나리오: 두 사용자가 동시에 텍스트를 삭제
- *
- *
변환 규칙:
- *
- *
두 DELETE가 겹치지 않음: position만 조정
- *
두 DELETE가 부분적으로 겹침: length 조정
- *
appliedOp가 op를 완전히 포함: op를 no-op으로 변환 (length=0)
- *
- *
- *
예시 1 - 겹치지 않음:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 5 chars at position 0 → " World" ("Hello" 삭제)
- * op: Delete 5 chars at position 6 → 원래 목표는 "Hello " ("World" 삭제)
- *
- * 변환 후 op: Delete 5 chars at position 1 (6 - 5)
- * 최종: " " (양쪽 모두 삭제됨)
- *
- *
- *
예시 2 - 부분 겹침:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 3 chars at position 3 → "Hel World" ("lo " 삭제)
- * op: Delete 5 chars at position 5 → 원래 목표는 "Hello" (" World" 삭제)
- *
- * 변환 후 op: Delete 3 chars at position 3 (겹치는 1자는 이미 삭제됨)
- * 최종: "Hel"
- *
- *
- *
예시 3 - 완전 포함:
- *
- * 문서: "Hello World"
- * appliedOp: Delete 11 chars at position 0 → "" (전체 삭제)
- * op: Delete 5 chars at position 6 → 원래 목표는 "Hello " ("World" 삭제)
- *
- * 변환 후 op: Delete 0 chars (no-op, 이미 삭제된 범위)
- * 최종: ""
- *
- *
- * @param op 변환할 DELETE 연산
- * @param appliedOp 이미 적용된 DELETE 연산
- * @return 변환된 DELETE 연산
- */
- private static EditOperation transformDeleteDelete(EditOperation op, EditOperation appliedOp) {
- int opStart = op.getPosition();
- int opEnd = op.getEndPosition();
- int appliedStart = appliedOp.getPosition();
- int appliedEnd = appliedOp.getEndPosition();
-
- // Case 1: appliedOp가 op보다 완전히 뒤에 있음 → op는 영향받지 않음
- if (appliedStart >= opEnd) {
- return op;
- }
- // Case 2: appliedOp가 op보다 완전히 앞에 있음 → op의 position만 조정
- else if (appliedEnd <= opStart) {
- return op.withPosition(opStart - appliedOp.getLength());
- }
- // Case 3: appliedOp가 op를 완전히 포함 → op를 no-op으로 변환
- else if (appliedStart <= opStart && appliedEnd >= opEnd) {
- // op가 삭제하려던 범위가 이미 모두 삭제됨
- return op.toNoOp(); // length = 0
- }
- // Case 4: op가 appliedOp를 완전히 포함 → op의 length 감소
- else if (opStart < appliedStart && opEnd > appliedEnd) {
- // op의 중간 부분이 이미 삭제됨
- return op.withLength(op.getLength() - appliedOp.getLength());
- }
- // Case 5: 부분 겹침 - appliedOp가 op의 앞부분과 겹침
- else if (appliedStart <= opStart && appliedEnd < opEnd) {
- // op의 시작 부분이 이미 삭제됨
- int newStart = appliedStart;
- int newLength = opEnd - appliedEnd;
- return op.withPositionAndLength(newStart, newLength);
- }
- // Case 6: 부분 겹침 - appliedOp가 op의 뒷부분과 겹침
- else if (appliedStart > opStart && appliedEnd >= opEnd) {
- // op의 끝 부분이 이미 삭제됨
- int newLength = appliedStart - opStart;
- return op.withLength(newLength);
- }
-
- // 이론상 도달하지 않아야 함
- log.warn("Unexpected DELETE-DELETE case: op={}, appliedOp={}", op, appliedOp);
- return op;
- }
-
- /**
- * 연산을 히스토리의 모든 연산에 대해 순차적으로 변환합니다.
- *
- *
op.revision 이후에 적용된 모든 연산들에 대해 transform을 반복 적용합니다.
- *
- *
DELETE: content.substring(0, pos) + content.substring(pos + len)
- *
- * @param content 현재 문서 내용
- * @param op 적용할 연산
- * @return 연산이 적용된 새 문서 내용
- * @throws IllegalArgumentException position/length가 범위를 벗어나는 경우
- */
- public static String applyOperation(String content, EditOperation op) {
- if (content == null) {
- content = "";
- }
-
- // No-op인 경우 아무것도 하지 않음
- if (op.isNoOp()) {
- return content;
- }
-
- if (op.isInsert()) {
- return applyInsert(content, op);
- } else if (op.isDelete()) {
- return applyDelete(content, op);
- }
-
- throw new IllegalArgumentException("Unknown operation type: " + op.getType());
- }
-
- /**
- * INSERT 연산을 문서에 적용
- *
- * @param content 현재 문서 내용
- * @param op INSERT 연산
- * @return 삽입 후 문서 내용
- */
- private static String applyInsert(String content, EditOperation op) {
- int position = op.getPosition();
-
- // position 범위 검증
- if (position < 0 || position > content.length()) {
- throw new IllegalArgumentException(
- String.format("Insert position out of bounds: position=%d, contentLength=%d",
- position, content.length())
- );
- }
-
- if (op.getContent() == null) {
- throw new IllegalArgumentException("Insert operation must have content");
- }
-
- // 삽입 수행: 앞부분 + 삽입 내용 + 뒷부분
- String before = content.substring(0, position);
- String after = content.substring(position);
- return before + op.getContent() + after;
- }
-
- /**
- * DELETE 연산을 문서에 적용
- *
- * @param content 현재 문서 내용
- * @param op DELETE 연산
- * @return 삭제 후 문서 내용
- */
- private static String applyDelete(String content, EditOperation op) {
- int position = op.getPosition();
- int length = op.getLength();
-
- // position 범위 검증
- if (position < 0 || position > content.length()) {
- throw new IllegalArgumentException(
- String.format("Delete position out of bounds: position=%d, contentLength=%d",
- position, content.length())
- );
- }
-
- // length 범위 검증
- if (length < 0) {
- throw new IllegalArgumentException("Delete length cannot be negative: " + length);
- }
-
- if (position + length > content.length()) {
- throw new IllegalArgumentException(
- String.format("Delete range out of bounds: position=%d, length=%d, contentLength=%d",
- position, length, content.length())
- );
- }
-
- // 삭제 수행: 앞부분 + 뒷부분 (중간 부분 제거)
- String before = content.substring(0, position);
- String after = content.substring(position + length);
- return before + after;
- }
-}
diff --git a/src/main/java/com/project/syncly/domain/note/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/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 형식으로 마이그레이션
+ *
+ *
마이그레이션 과정:
+ *
+ *
content는 있지만 ydocBinary가 없는 노트 찾기
+ *
content를 Base64 인코딩하여 ydocBinary에 저장
+ *
마이그레이션된 노트 개수 로깅 및 메트릭 기록
+ *
+ *
+ *
주의사항:
+ *
+ *
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);
+ }
}
/**
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 기반)
*
- *
+ * 1. 클라이언트 A: Y.Doc 편집 → Y.encodeStateAsUpdate() → Base64 → WebSocket 전송
+ * 2. 백엔드: WebSocket 수신 → Base64 디코딩 → Redis 저장 (NOTE:YDOC:{noteId})
+ * 3. 브로드캐스트: 다른 클라이언트에게 Update 전송
+ * 4. 클라이언트 B, C: Y.applyUpdate() → 자동 병합 (CRDT 특성)
+ *
+ *
*
TTL (Time To Live):
*
*
모든 키는 24시간 후 자동 삭제
@@ -42,7 +50,6 @@
*
*
* @see CursorPosition
- * @see EditOperation
*/
@Slf4j
@Service
@@ -56,63 +63,209 @@ public class NoteRedisService {
// TTL 설정: 24시간 동안 활동 없으면 자동 삭제
private static final Duration NOTE_TTL = Duration.ofHours(24);
- // Operation 히스토리 최대 개수: 메모리 절약을 위해 최근 100개만 유지
- private static final int MAX_OPERATIONS_HISTORY = 100;
-
- // ==================== Content 관리 ====================
+ // ==================== Yjs Y.Doc 관리 (CRDT 기반) ====================
/**
- * 노트 내용 조회
+ * Y.Doc 상태 조회 (Yjs Update 바이너리)
*
- *
Redis Key: NOTE:CONTENT:{noteId}
+ *
Redis Key: NOTE:YDOC:{noteId}
+ *
Redis Value: Base64 encoded Yjs Update (바이너리)
*
*
사용 시나리오:
*
- * // 사용자가 노트에 입장했을 때
- * String content = redisService.getContent(noteId);
- * if (content == null) {
+ * // 새 사용자가 노트 입장 시
+ * String base64Update = redisService.getYdocBinary(noteId);
+ * if (base64Update == null) {
* // Redis에 없으면 DB에서 조회 후 초기화
* Note note = noteRepository.findById(noteId);
- * redisService.initializeNote(noteId, note.getContent());
+ * redisService.initializeNote(noteId, note.getYdocBinary());
* }
+ * // 클라이언트에게 base64Update 전송 → applyUpdate()로 동기화
*
*
* @param noteId 노트 ID
- * @return 노트 내용 (없으면 null)
+ * @return Base64 encoded Yjs Update (없으면 null)
*/
- public String getContent(Long noteId) {
- String key = RedisKeyPrefix.NOTE_CONTENT.get(noteId);
- String content = redisStorage.getValueAsString(key);
- log.debug("Get note content: noteId={}, exists={}", noteId, content != null);
- return content;
+ public String getYdocBinary(Long noteId) {
+ String key = RedisKeyPrefix.NOTE_YDOC.get(noteId);
+ String base64Update = redisStorage.getValueAsString(key);
+ log.debug("Get Y.Doc: noteId={}, exists={}, size={}", noteId, base64Update != null,
+ base64Update != null ? base64Update.length() : 0);
+ return base64Update;
}
/**
- * 노트 내용 저장 및 dirty 플래그 자동 설정
+ * Y.Doc 상태 저장 (Yjs Update 바이너리) 및 dirty 플래그 자동 설정
*
- *
Redis Key: NOTE:CONTENT:{noteId}
+ *
Redis Key: NOTE:YDOC:{noteId}
+ *
Redis Value: Base64 encoded Yjs Update
*
*
중요: 이 메서드를 호출하면 자동으로 dirty 플래그가 true로 설정됩니다.
* dirty 플래그는 스케줄러가 "DB에 저장해야 할 노트"를 찾는 데 사용됩니다.
*
*
사용 시나리오:
*
- * // 사용자가 편집했을 때
- * String currentContent = redisService.getContent(noteId);
- * String newContent = applyEdit(currentContent, operation);
- * redisService.setContent(noteId, newContent); // dirty 자동 설정
- *
- * // 30초 후 스케줄러가 자동으로 DB 저장
+ * // WebSocket에서 Yjs Update 수신
+ * String base64Update = wsMessage.getUpdate(); // "SGVsbG8gV29ybGQ="
+ * redisService.setYdocBinary(noteId, base64Update); // dirty 자동 설정
+ *
+ * // 30초 후 스케줄러가 자동으로 DB에 저장
+ * Note note = noteRepository.findById(noteId);
+ * note.setYdocBinary(base64Update);
+ * noteRepository.save(note);
*
주의: 이 메서드는 dirty 플래그를 설정하지 않습니다.
+ * DB에서 노트를 로드하여 Redis를 초기화할 때만 사용하세요.
+ * 사용자 편집으로 인한 Update 저장 시에는 setYdocBinary() 또는 applyYjsUpdate()를 사용하세요.
+ *
+ *
사용 시나리오:
+ *
+ * // 첫 사용자가 노트에 입장했을 때, DB에서 기존 상태 로드
+ * String dbYdoc = note.getYdocBinary();
+ * redisService.setYdocBinaryWithoutDirty(noteId, dbYdoc);
+ * // dirty 플래그가 설정되지 않으므로 자동 저장되지 않음
+ *
- * // 편집 연산 적용 후 버전 증가
- * String currentContent = redisService.getContent(noteId);
- * String newContent = OTEngine.apply(currentContent, operation);
- * redisService.setContent(noteId, newContent);
- *
- * int newRevision = redisService.incrementRevision(noteId); // 원자적으로 증가
- *
- * // 다른 사용자들에게 브로드캐스트
- * broadcast("content updated", newRevision);
- *
- *
- * @param noteId 노트 ID
- * @return 증가된 새 revision 번호
- */
- public int incrementRevision(Long noteId) {
- String key = RedisKeyPrefix.NOTE_REVISION.get(noteId);
- Long newRevision = redisTemplate.opsForValue().increment(key);
- refreshTTL(key);
- log.debug("Increment revision: noteId={}, newRevision={}", noteId, newRevision);
- return newRevision != null ? newRevision.intValue() : 1;
- }
-
- // ==================== Operation 히스토리 (OT용) ====================
+ // ==================== State Vector 관리 (Yjs 상태 추적용) ====================
/**
- * operations 리스트에 추가 (최근 100개만 유지)
+ * State Vector 조회 (상태 추적용)
*
- *
Redis Key: NOTE:OPERATIONS:{noteId}
- *
Redis Type: List (순서가 있는 리스트)
+ *
Redis Key: NOTE:STATE_VECTOR:{noteId}
+ *
Redis Value: Base64 encoded state vector
*
- *
List 구조:
- *
- * NOTE:OPERATIONS:123 = [
- * '{"type":"insert","position":5,"revision":5,...}', // 가장 오래된 연산
- * '{"type":"delete","position":10,"revision":6,...}',
- * ...
- * '{"type":"insert","position":20,"revision":104,...}' // 가장 최근 연산
- * ]
- *
- *
- *
최근 100개만 유지하는 이유:
+ *
State Vector란?
*
- *
메모리 절약: 무한정 쌓이면 메모리 부족
- *
충분한 히스토리: 대부분의 충돌은 최근 몇 개 연산으로 해결
- *
오래된 연산은 이미 DB에 저장되어 복구 가능
+ *
각 클라이언트/서버의 "이 정도까지 봤다"는 상태 정보
+ *
클라이언트가 노트에 입장할 때, 서버가 필요한 Update만 선택적으로 전송하기 위해 사용
+ *
현재 구현에서는 선택적 기능 (모든 Update를 전송해도 CRDT가 처리함)
*
*
*
사용 시나리오:
*
- * // 편집 연산 적용 후 히스토리에 추가
- * EditOperation operation = EditOperation.insert(5, "Hello", 42, workspaceMemberId);
- * redisService.addOperation(noteId, operation);
+ * // 클라이언트가 노트 입장 시
+ * String clientStateVector = wsMessage.getStateVector(); // 클라이언트가 알고 있는 상태
+ * String serverStateVector = redisService.getStateVector(noteId); // 서버의 상태
*
- * // 나중에 다른 사용자가 충돌 해결 시 사용
- * List history = redisService.getOperations(noteId, 40);
+ * // 필요한 Update만 필터링해서 전송 (현재는 모두 전송)
+ * String update = redisService.getYdocBinary(noteId);
+ * wsService.sendUpdate(clientId, update);
*
*
* @param noteId 노트 ID
- * @param operation 추가할 편집 연산
+ * @return Base64 encoded state vector (없으면 null)
*/
- public void addOperation(Long noteId, EditOperation operation) {
- String key = RedisKeyPrefix.NOTE_OPERATIONS.get(noteId);
- try {
- String json = redisObjectMapper.writeValueAsString(operation);
- redisTemplate.opsForList().rightPush(key, json); // 리스트 맨 뒤에 추가
-
- // 최근 100개만 유지 (LTRIM 명령)
- Long size = redisTemplate.opsForList().size(key);
- if (size != null && size > MAX_OPERATIONS_HISTORY) {
- // 예: size=150이면 앞의 50개 삭제, 뒤의 100개만 남김
- redisTemplate.opsForList().trim(key, size - MAX_OPERATIONS_HISTORY, -1);
- }
-
- refreshTTL(key);
- log.debug("Add operation: noteId={}, type={}, position={}", noteId, operation.getType(), operation.getPosition());
- } catch (JsonProcessingException e) {
- log.error("Failed to serialize edit operation", e);
- }
+ public String getStateVector(Long noteId) {
+ String key = RedisKeyPrefix.NOTE_STATE_VECTOR.get(noteId);
+ String stateVector = redisStorage.getValueAsString(key);
+ log.debug("Get state vector: noteId={}, exists={}", noteId, stateVector != null);
+ return stateVector;
}
/**
- * 특정 revision 이후의 operations 조회
- *
- *
OT Transform에서 사용:
- *
- *
클라이언트가 revision 5를 기준으로 편집했는데
- *
서버는 이미 revision 8이면
- *
revision 6, 7, 8 연산을 가져와서 클라이언트 연산과 transform
- *
- *
- *
사용 시나리오:
- *
- * // 클라이언트가 보낸 연산
- * EditOperation clientOp = ...; // revision = 5
- * int serverRevision = redisService.getRevision(noteId); // 8
+ * State Vector 저장
*
- * if (clientOp.getRevision() < serverRevision) {
- * // revision 6, 7, 8 연산 가져오기
- * List missedOps = redisService.getOperations(noteId, 6);
+ *
현재 구현에서는 선택적 기능입니다.
+ * 필요하면 클라이언트가 보낸 state vector를 저장할 수 있습니다.
*
* @param noteId 노트 ID
- * @param fromRevision 이 revision 이후의 연산만 조회 (inclusive)
- * @return 필터링된 연산 리스트 (시간순 정렬)
+ * @param stateVectorBase64 Base64 encoded state vector
*/
- public List getOperations(Long noteId, int fromRevision) {
- String key = RedisKeyPrefix.NOTE_OPERATIONS.get(noteId);
- List