message = WebSocketMessage.of(
+ WebSocketMessageType.EDIT,
+ broadcastMessage,
+ workspaceMemberId
+ );
+
+ messagingTemplate.convertAndSend(
+ "/topic/notes/" + noteId + "/edits",
+ message
+ );
+
+ log.debug("편집 브로드캐스트 완료: noteId={}, revision={}, includeFullContent={}",
+ noteId, result.revision(), includeFullContent);
+
+ } catch (NoteException e) {
+ log.error("편집 처리 중 NoteException 발생: noteId={}, memberId={}, error={}",
+ noteId, memberId, e.getMessage());
+ handleEditError(principal.getName(), noteId, e);
+
+ } catch (Exception e) {
+ log.error("편집 처리 중 예상치 못한 에러 발생: noteId={}, memberId={}, error={}",
+ noteId, memberId, e.getMessage(), e);
+ sendErrorToUser(principal.getName(), noteId,
+ NoteErrorCode.OT_TRANSFORM_FAILED.getCode(),
+ "편집 처리 중 오류가 발생했습니다. 페이지를 새로고침해주세요.");
+ }
+ }
+
+ /**
+ * 편집 처리 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());
+ }
+ }
+
+ /**
+ * 사용자에게 에러 메시지 전송
+ *
+ * @param userId 사용자 ID (memberId)
+ * @param noteId 노트 ID
+ * @param errorCode 에러 코드
+ * @param errorMessage 에러 메시지
+ */
+ private void sendErrorToUser(String userId, Long noteId, String errorCode, String errorMessage) {
+ NoteWebSocketDto.ErrorMessage error = NoteWebSocketDto.ErrorMessage.of(errorCode, errorMessage);
+
+ WebSocketMessage message = WebSocketMessage.of(
+ WebSocketMessageType.ERROR,
+ error,
+ null
+ );
+
+ messagingTemplate.convertAndSendToUser(
+ userId,
+ "/queue/errors",
+ message
+ );
+
+ log.debug("에러 메시지 전송: userId={}, noteId={}, errorCode={}", userId, noteId, errorCode);
+ }
+
+ /**
+ * 노트 생성 핸들러
+ *
+ * 사용자가 새로운 노트를 생성합니다.
+ *
+ * @param request 노트 생성 요청 (title)
+ * @param principal 인증된 사용자 정보 (memberId)
+ */
+ @MessageMapping("/notes/create")
+ @Transactional
+ public void handleCreate(
+ @Payload NoteWebSocketDto.CreateRequest request,
+ Principal principal
+ ) {
+ Long memberId = extractMemberId(principal);
+
+ log.info("노트 생성 요청: memberId={}, title={}", memberId, request.title());
+
+ try {
+ // 1. workspaceId 검증
+ Long workspaceId = request.workspaceId();
+ if (workspaceId == null || workspaceId <= 0) {
+ throw new NoteException(NoteErrorCode.NOTE_NOT_FOUND);
+ }
+
+ // 2. 워크스페이스 멤버 확인
+ WorkspaceMember workspaceMember = workspaceMemberRepository
+ .findByWorkspaceIdAndMemberId(workspaceId, memberId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER));
+
+ // 3. 노트 생성
+ Note newNote = Note.builder()
+ .title(request.title())
+ .content("")
+ .workspace(workspaceMember.getWorkspace())
+ .creator(workspaceMember.getMember())
+ .build();
+
+ Note createdNote = noteRepository.save(newNote);
+
+ // 4. Redis 초기화
+ noteRedisService.initializeNote(createdNote.getId(), "");
+
+ // 5. 현재 사용자를 자동으로 참여자에 추가
+ NoteParticipant participant = NoteParticipant.builder()
+ .note(createdNote)
+ .member(workspaceMember.getMember())
+ .isOnline(false)
+ .build();
+ noteParticipantRepository.save(participant);
+
+ // 6. 생성 응답 전송 (요청한 사용자에게만)
+ NoteWebSocketDto.CreateResponse response = new NoteWebSocketDto.CreateResponse(
+ createdNote.getId(),
+ createdNote.getTitle(),
+ workspaceId,
+ workspaceMember.getName(),
+ workspaceMember.getProfileImage(),
+ createdNote.getCreatedAt()
+ );
+
+ WebSocketMessage message = WebSocketMessage.of(
+ WebSocketMessageType.CREATE,
+ response,
+ null
+ );
+
+ messagingTemplate.convertAndSendToUser(
+ principal.getName(),
+ "/queue/notes/create",
+ message
+ );
+
+ // 7. 같은 워크스페이스를 보고 있는 모든 사용자에게 브로드캐스트
+ NoteWebSocketDto.NoteCreatedMessage broadcastMessage = new NoteWebSocketDto.NoteCreatedMessage(
+ createdNote.getId(),
+ createdNote.getTitle(),
+ workspaceId,
+ workspaceMember.getName(),
+ workspaceMember.getProfileImage(),
+ createdNote.getCreatedAt()
+ );
+
+ WebSocketMessage broadcastMsg = WebSocketMessage.of(
+ WebSocketMessageType.NOTE_CREATED,
+ broadcastMessage,
+ null
+ );
+
+ messagingTemplate.convertAndSend(
+ "/topic/workspace/" + workspaceId + "/notes/list",
+ broadcastMsg
+ );
+
+ log.info("노트 생성 완료: noteId={}, title={}, 브로드캐스트 완료", createdNote.getId(), createdNote.getTitle());
+
+ } catch (NoteException e) {
+ log.error("노트 생성 중 에러 발생: memberId={}, error={}", memberId, e.getMessage());
+ sendErrorToUser(principal.getName(), null,
+ e.getCode().getCode(),
+ e.getMessage());
+ } catch (Exception e) {
+ log.error("노트 생성 중 예상치 못한 에러 발생: memberId={}, error={}", memberId, e.getMessage(), e);
+ sendErrorToUser(principal.getName(), null,
+ "INTERNAL_ERROR",
+ "노트 생성 중 오류가 발생했습니다.");
+ }
+ }
+
+ /**
+ * 노트 목록 조회 핸들러
+ *
+ * 사용자가 노트 목록을 조회합니다.
+ *
+ * @param request 노트 목록 조회 요청 (page, size, sortBy, direction)
+ * @param principal 인증된 사용자 정보 (memberId)
+ */
+ @MessageMapping("/notes/list")
+ public void handleList(
+ @Payload NoteWebSocketDto.ListRequest request,
+ Principal principal
+ ) {
+ Long memberId = extractMemberId(principal);
+
+ log.info("노트 목록 조회 요청: memberId={}, page={}, size={}", memberId, request.page(), request.size());
+
+ try {
+ // 1. workspaceId 검증
+ Long workspaceId = request.workspaceId();
+ if (workspaceId == null || workspaceId <= 0) {
+ throw new NoteException(NoteErrorCode.NOTE_NOT_FOUND);
+ }
+
+ // 2. 워크스페이스 멤버 확인
+ WorkspaceMember workspaceMember = workspaceMemberRepository
+ .findByWorkspaceIdAndMemberId(workspaceId, memberId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER));
+
+ // 3. 노트 목록 조회 (HTTP 메서드의 로직과 동일하게 처리)
+ // org.springframework.data.domain.PageRequest를 사용해서 페이징 처리
+ var pageRequest = org.springframework.data.domain.PageRequest.of(
+ request.page(),
+ request.size(),
+ org.springframework.data.domain.Sort.Direction.fromString(request.direction()),
+ request.sortBy()
+ );
+
+ var notesPage = noteRepository.findByWorkspaceId(workspaceId, pageRequest);
+
+ // 4. NoteListItem으로 변환
+ List noteItems = notesPage.getContent().stream()
+ .map(note -> new NoteWebSocketDto.NoteListItem(
+ note.getId(),
+ note.getTitle(),
+ note.getCreator().getName(),
+ note.getCreator().getProfileImage(),
+ note.getLastModifiedAt(),
+ Math.toIntExact(noteParticipantRepository.countByNoteId(note.getId())),
+ note.getCreatedAt()
+ ))
+ .toList();
+
+ // 4. 응답 생성
+ NoteWebSocketDto.ListResponse response = new NoteWebSocketDto.ListResponse(
+ noteItems,
+ (int) notesPage.getTotalElements(),
+ notesPage.getNumber(),
+ notesPage.getTotalPages()
+ );
+
+ WebSocketMessage message = WebSocketMessage.of(
+ WebSocketMessageType.LIST,
+ response,
+ null
+ );
+
+ messagingTemplate.convertAndSendToUser(
+ principal.getName(),
+ "/queue/notes/list",
+ message
+ );
+
+ log.info("노트 목록 조회 완료: memberId={}, count={}", memberId, noteItems.size());
+
+ } catch (NoteException e) {
+ log.error("노트 목록 조회 중 에러 발생: memberId={}, error={}", memberId, e.getMessage());
+ sendErrorToUser(principal.getName(), null,
+ e.getCode().getCode(),
+ e.getMessage());
+ } catch (Exception e) {
+ log.error("노트 목록 조회 중 예상치 못한 에러 발생: memberId={}, error={}", memberId, e.getMessage(), e);
+ sendErrorToUser(principal.getName(), null,
+ "INTERNAL_ERROR",
+ "노트 목록 조회 중 오류가 발생했습니다.");
+ }
+ }
+
+ /**
+ * 노트 상세 조회 핸들러
+ *
+ * 사용자가 특정 노트의 상세 정보를 조회합니다.
+ *
+ * @param request 노트 상세 조회 요청 (noteId)
+ * @param principal 인증된 사용자 정보 (memberId)
+ */
+ @MessageMapping("/notes/detail")
+ public void handleGetDetail(
+ @Payload NoteWebSocketDto.GetDetailRequest request,
+ Principal principal
+ ) {
+ Long memberId = extractMemberId(principal);
+ Long noteId = request.noteId();
+
+ log.info("노트 상세 조회 요청: memberId={}, noteId={}", memberId, noteId);
+
+ try {
+ // 1. 노트 존재 확인
+ Note note = noteRepository.findById(noteId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ Long workspaceId = note.getWorkspace().getId();
+
+ // 2. 워크스페이스 멤버 확인
+ WorkspaceMember workspaceMember = workspaceMemberRepository
+ .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);
+ }
+
+ Integer revision = noteRedisService.getRevision(noteId);
+
+ // 4. 활성 참여자 정보 조회
+ List activeParticipants = noteRedisService.getActiveUsers(noteId)
+ .stream()
+ .map(userId -> {
+ Long workspaceMemberId = Long.valueOf(userId);
+ // 워크스페이스 멤버 정보 조회
+ try {
+ WorkspaceMember wm = workspaceMemberRepository.findById(workspaceMemberId).orElse(null);
+ if (wm != null) {
+ return new NoteWebSocketDto.ActiveUserInfo(
+ workspaceMemberId,
+ wm.getName(),
+ wm.getProfileImage(),
+ com.project.syncly.domain.note.util.UserColorGenerator.generateColor(workspaceMemberId)
+ );
+ }
+ } catch (Exception e) {
+ log.warn("워크스페이스 멤버 조회 실패: workspaceMemberId={}", workspaceMemberId, e);
+ }
+ // Fallback: 정보 없을 경우
+ return new NoteWebSocketDto.ActiveUserInfo(
+ workspaceMemberId,
+ "Unknown User",
+ null,
+ com.project.syncly.domain.note.util.UserColorGenerator.generateColor(workspaceMemberId)
+ );
+ })
+ .collect(java.util.stream.Collectors.toList());
+
+ // 5. 응답 생성
+ NoteWebSocketDto.GetDetailResponse response = new NoteWebSocketDto.GetDetailResponse(
+ note.getId(),
+ note.getTitle(),
+ content,
+ workspaceId,
+ note.getCreator().getId(),
+ note.getCreator().getName(),
+ note.getCreator().getProfileImage(),
+ activeParticipants,
+ note.getLastModifiedAt(),
+ note.getCreatedAt(),
+ revision
+ );
+
+ WebSocketMessage message = WebSocketMessage.of(
+ WebSocketMessageType.GET_DETAIL,
+ response,
+ null
+ );
+
+ messagingTemplate.convertAndSendToUser(
+ principal.getName(),
+ "/queue/notes/" + noteId + "/detail",
+ message
+ );
+
+ log.info("노트 상세 조회 완료: noteId={}, contentLength={}", noteId, content.length());
+
+ } catch (NoteException e) {
+ log.error("노트 상세 조회 중 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage());
+ sendErrorToUser(principal.getName(), noteId,
+ e.getCode().getCode(),
+ e.getMessage());
+ } catch (Exception e) {
+ log.error("노트 상세 조회 중 예상치 못한 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage(), e);
+ sendErrorToUser(principal.getName(), noteId,
+ "INTERNAL_ERROR",
+ "노트 상세 조회 중 오류가 발생했습니다.");
+ }
+ }
+
+ /**
+ * 노트 삭제 핸들러
+ *
+ * 사용자가 노트를 삭제합니다.
+ *
+ * @param request 노트 삭제 요청 (noteId)
+ * @param principal 인증된 사용자 정보 (memberId)
+ */
+ @MessageMapping("/notes/delete")
+ @Transactional
+ public void handleDelete(
+ @Payload NoteWebSocketDto.DeleteRequest request,
+ Principal principal
+ ) {
+ Long memberId = extractMemberId(principal);
+ Long noteId = request.noteId();
+
+ log.info("노트 삭제 요청: memberId={}, noteId={}", memberId, noteId);
+
+ try {
+ // 1. 노트 존재 확인
+ Note note = noteRepository.findById(noteId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ // 2. 노트 소유자 확인 (소유자만 삭제 가능)
+ if (!note.getCreator().getId().equals(memberId)) {
+ throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED);
+ }
+
+ // 3. 노트 삭제 (소프트 삭제)
+ note.markAsDeleted();
+ noteRepository.save(note);
+
+ // 4. Redis 정리 (편집 데이터 및 사용자 정보 삭제)
+ noteRedisService.deleteNoteData(noteId);
+
+ Long workspaceId = note.getWorkspace().getId();
+
+ // 5. 응답 전송 (요청한 사용자에게만)
+ NoteWebSocketDto.DeleteResponse response = new NoteWebSocketDto.DeleteResponse(
+ true,
+ "노트가 삭제되었습니다.",
+ LocalDateTime.now()
+ );
+
+ WebSocketMessage message = WebSocketMessage.of(
+ WebSocketMessageType.DELETE,
+ response,
+ null
+ );
+
+ messagingTemplate.convertAndSendToUser(
+ principal.getName(),
+ "/queue/notes/" + noteId + "/delete",
+ message
+ );
+
+ // 6. 같은 워크스페이스를 보고 있는 모든 사용자에게 브로드캐스트
+ NoteWebSocketDto.NoteDeletedMessage broadcastMessage = new NoteWebSocketDto.NoteDeletedMessage(
+ noteId,
+ workspaceId,
+ LocalDateTime.now()
+ );
+
+ WebSocketMessage broadcastMsg = WebSocketMessage.of(
+ WebSocketMessageType.NOTE_DELETED,
+ broadcastMessage,
+ null
+ );
+
+ messagingTemplate.convertAndSend(
+ "/topic/workspace/" + workspaceId + "/notes/list",
+ broadcastMsg
+ );
+
+ log.info("노트 삭제 완료: noteId={}, 브로드캐스트 완료", noteId);
+
+ } catch (NoteException e) {
+ log.error("노트 삭제 중 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage());
+ sendErrorToUser(principal.getName(), noteId,
+ e.getCode().getCode(),
+ e.getMessage());
+ } catch (Exception e) {
+ log.error("노트 삭제 중 예상치 못한 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage(), e);
+ sendErrorToUser(principal.getName(), noteId,
+ "INTERNAL_ERROR",
+ "노트 삭제 중 오류가 발생했습니다.");
+ }
+ }
+
+ /**
+ * 노트 수동 저장 핸들러
+ *
+ * 사용자가 수동으로 저장 버튼을 클릭했을 때 호출됩니다.
+ * Redis의 현재 내용을 DB에 저장합니다.
+ *
+ * @param noteId 노트 ID (URL 경로에서 추출)
+ * @param principal 인증된 사용자 정보 (memberId)
+ */
+ @MessageMapping("/notes/{noteId}/save")
+ @Transactional
+ public void handleManualSave(
+ @DestinationVariable Long noteId,
+ Principal principal
+ ) {
+ Long memberId = extractMemberId(principal);
+
+ log.info("노트 수동 저장 요청: memberId={}, noteId={}", memberId, noteId);
+
+ try {
+ // 1. 노트 존재 확인
+ Note note = noteRepository.findById(noteId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ Long workspaceId = note.getWorkspace().getId();
+
+ // 2. 워크스페이스 멤버 확인
+ WorkspaceMember workspaceMember = workspaceMemberRepository
+ .findByWorkspaceIdAndMemberId(workspaceId, memberId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER));
+
+ // 3. Redis에서 현재 content와 revision 조회
+ String currentContent = noteRedisService.getContent(noteId);
+ int currentRevision = noteRedisService.getRevision(noteId);
+
+ // 4. DB에 저장 (updateContent 메서드 사용)
+ note.updateContent(currentContent);
+ noteRepository.save(note);
+
+ // 5. 모든 참여자에게 저장 완료 메시지 브로드캐스트
+ NoteWebSocketDto.SaveResponse response = new NoteWebSocketDto.SaveResponse(
+ noteId,
+ currentRevision,
+ LocalDateTime.now(),
+ "수동 저장되었습니다.",
+ workspaceMember.getId(),
+ workspaceMember.getName()
+ );
+
+ WebSocketMessage message = WebSocketMessage.of(
+ WebSocketMessageType.MANUAL_SAVE,
+ response,
+ null
+ );
+
+ messagingTemplate.convertAndSend(
+ "/topic/notes/" + noteId + "/save",
+ message
+ );
+
+ log.info("노트 수동 저장 완료: noteId={}, revision={}", noteId, currentRevision);
+
+ } catch (NoteException e) {
+ log.error("노트 저장 중 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage());
+ sendErrorToUser(principal.getName(), noteId,
+ e.getCode().getCode(),
+ e.getMessage());
+ } catch (Exception e) {
+ log.error("노트 저장 중 예상치 못한 에러 발생: memberId={}, noteId={}, error={}", memberId, noteId, e.getMessage(), e);
+ sendErrorToUser(principal.getName(), noteId,
+ "INTERNAL_ERROR",
+ "노트 저장 중 오류가 발생했습니다.");
+ }
+ }
+
+ /**
+ * 커서 위치 업데이트 핸들러
+ *
+ * 사용자의 커서 위치를 Redis에 저장하고 다른 참여자들에게 브로드캐스트합니다.
+ *
+ *
처리 흐름:
+ *
+ * 사용자 권한 검증 (활성 사용자인지)
+ * CursorPosition 객체 생성 (위치, 범위, 사용자 정보, 색상)
+ * Redis에 커서 저장 (TTL 10분)
+ * 본인을 제외한 다른 참여자들에게 브로드캐스트
+ *
+ *
+ * 성능 최적화:
+ *
+ * 클라이언트는 100ms throttling 권장
+ * Redis TTL 10분 (자동 정리)
+ *
+ *
+ * @param noteId 노트 ID
+ * @param request 커서 업데이트 요청 (position, range)
+ * @param principal 인증된 사용자 정보 (memberId)
+ */
+ @MessageMapping("/notes/{noteId}/cursor")
+ public void handleCursorUpdate(
+ @DestinationVariable Long noteId,
+ @Payload NoteWebSocketDto.CursorUpdateRequest request,
+ Principal principal
+ ) {
+ Long memberId = extractMemberId(principal);
+
+ try {
+ // 1. 노트 존재 확인
+ Note note = noteRepository.findById(noteId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ Long workspaceId = note.getWorkspace().getId();
+
+ // 2. 워크스페이스 멤버 확인
+ WorkspaceMember workspaceMember = workspaceMemberRepository
+ .findByWorkspaceIdAndMemberId(workspaceId, memberId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER));
+
+ Long workspaceMemberId = workspaceMember.getId();
+
+ // 3. 활성 사용자 검증
+ if (!noteRedisService.getActiveUsers(noteId).contains(String.valueOf(workspaceMemberId))) {
+ log.warn("비활성 사용자의 커서 업데이트 시도: noteId={}, workspaceMemberId={}", noteId, workspaceMemberId);
+ return;
+ }
+
+ // 4. CursorPosition 객체 생성
+ CursorPosition cursor = CursorPosition.builder()
+ .position(request.position())
+ .range(request.range())
+ .workspaceMemberId(workspaceMemberId)
+ .userName(workspaceMember.getName())
+ .profileImage(workspaceMember.getProfileImage())
+ .color(com.project.syncly.domain.note.util.UserColorGenerator.generateColor(workspaceMemberId))
+ .build();
+
+ // 5. Redis에 저장
+ noteRedisService.setCursor(noteId, workspaceMemberId, cursor);
+
+ // 6. 다른 참여자들에게 브로드캐스트
+ messagingTemplate.convertAndSend(
+ "/topic/notes/" + noteId + "/cursors",
+ cursor
+ );
+
+ log.debug("커서 업데이트 브로드캐스트: noteId={}, workspaceMemberId={}, position={}",
+ noteId, workspaceMemberId, request.position());
+
+ } catch (NoteException e) {
+ log.error("커서 업데이트 중 에러 발생: noteId={}, memberId={}, error={}",
+ noteId, memberId, e.getMessage());
+ } catch (Exception e) {
+ log.error("커서 업데이트 중 예상치 못한 에러 발생: noteId={}, memberId={}, error={}",
+ noteId, memberId, e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 참여자 조회 또는 생성 (동시성 안전 처리)
+ *
+ * 각 호출이 새로운 트랜잭션에서 실행되므로,
+ * 이전 트랜잭션의 exception이 영향을 주지 않습니다.
+ *
+ * @param noteId 노트 ID
+ * @param memberId 멤버 ID
+ * @param note 노트 엔티티
+ * @param workspaceMember 워크스페이스 멤버 엔티티
+ * @return 참여자 엔티티 (생성되거나 기존 것)
+ * @throws RuntimeException 재시도 필요한 경우
+ */
+ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
+ private NoteParticipant getOrCreateParticipantInTransaction(
+ Long noteId,
+ Long memberId,
+ Note note,
+ WorkspaceMember workspaceMember
+ ) {
+ // 1. 먼저 기존 참여자 조회
+ Optional existing = noteParticipantRepository
+ .findByNoteIdAndMemberId(noteId, memberId);
+
+ if (existing.isPresent()) {
+ NoteParticipant participant = existing.get();
+ // 온라인 상태 업데이트
+ if (!participant.getIsOnline()) {
+ participant.setOnline();
+ noteParticipantRepository.save(participant);
+ }
+ log.debug("기존 참여자 업데이트: noteId={}, memberId={}", noteId, memberId);
+ return participant;
+ }
+
+ // 2. 새로운 참여자 생성
+ NoteParticipant newParticipant = NoteParticipant.builder()
+ .note(note)
+ .member(workspaceMember.getMember())
+ .isOnline(true)
+ .build();
+
+ try {
+ NoteParticipant saved = noteParticipantRepository.save(newParticipant);
+ log.debug("새 참여자 생성: noteId={}, memberId={}", noteId, memberId);
+ return saved;
+ } catch (org.springframework.dao.DataIntegrityViolationException e) {
+ // 동시 요청으로 인한 duplicate entry
+ // 재시도 필요 (새로운 트랜잭션에서 다시 시도하면 이미 생성된 레코드를 찾을 수 있음)
+ log.debug("Duplicate entry 감지, 재시도 필요: noteId={}, memberId={}", noteId, memberId);
+ throw new RuntimeException("Concurrent creation detected, retry needed", e);
+ }
+ }
+
+ /**
+ * Principal에서 memberId를 추출하는 헬퍼 메서드
+ *
+ * Principal.getName()은 이메일을 반환할 수 있으므로,
+ * PrincipalDetails에서 직접 member ID를 추출합니다.
+ *
+ * @param principal WebSocket Principal 객체
+ * @return member ID
+ * @throws IllegalArgumentException Principal이 올바른 형식이 아닌 경우
+ */
+ private Long extractMemberId(Principal principal) {
+ if (!(principal instanceof org.springframework.security.authentication.UsernamePasswordAuthenticationToken)) {
+ log.error("인증 타입이 잘못됨: {}", principal.getClass().getSimpleName());
+ throw new IllegalArgumentException("Invalid authentication type");
+ }
+
+ Object principalObj = ((org.springframework.security.authentication.UsernamePasswordAuthenticationToken) principal).getPrincipal();
+ if (!(principalObj instanceof PrincipalDetails)) {
+ log.error("Principal 타입이 잘못됨: {}", principalObj.getClass().getSimpleName());
+ throw new IllegalArgumentException("Invalid principal type");
+ }
+
+ return ((PrincipalDetails) principalObj).getMember().getId();
+ }
+}
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
new file mode 100644
index 0000000..24b1d97
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java
@@ -0,0 +1,108 @@
+package com.project.syncly.domain.note.converter;
+
+import com.project.syncly.domain.member.entity.Member;
+import com.project.syncly.domain.note.dto.NoteRequestDto;
+import com.project.syncly.domain.note.dto.NoteResponseDto;
+import com.project.syncly.domain.note.entity.Note;
+import com.project.syncly.domain.note.entity.NoteParticipant;
+import com.project.syncly.domain.workspace.entity.Workspace;
+
+import java.util.List;
+
+public class NoteConverter {
+
+ /**
+ * 노트 생성 요청 DTO를 Note 엔티티로 변환
+ */
+ public static Note toNote(NoteRequestDto.Create dto, Workspace workspace, Member creator) {
+ return Note.builder()
+ .workspace(workspace)
+ .creator(creator)
+ .title(dto.title())
+ .content("") // 초기 생성 시 빈 내용
+ .build();
+ }
+
+ /**
+ * Note 엔티티를 생성 응답 DTO로 변환
+ */
+ public static NoteResponseDto.Create toCreateResponse(Note note) {
+ return new NoteResponseDto.Create(
+ note.getId(),
+ note.getTitle(),
+ note.getWorkspace().getId(),
+ note.getCreator().getName(),
+ note.getCreatedAt()
+ );
+ }
+
+ /**
+ * Note 엔티티를 상세 응답 DTO로 변환
+ */
+ public static NoteResponseDto.Detail toDetailResponse(Note note, List activeParticipants) {
+ return new NoteResponseDto.Detail(
+ note.getId(),
+ note.getTitle(),
+ note.getContent(),
+ note.getWorkspace().getId(),
+ note.getCreator().getId(),
+ note.getCreator().getName(),
+ note.getCreator().getProfileImage(),
+ note.getLastModifiedAt(),
+ note.getCreatedAt(),
+ (long) activeParticipants.size(),
+ activeParticipants.stream()
+ .map(NoteConverter::toParticipantInfo)
+ .toList()
+ );
+ }
+
+ /**
+ * Note 엔티티를 목록 아이템 DTO로 변환
+ */
+ public static NoteResponseDto.ListItem toListItemResponse(Note note, Long participantCount) {
+ return new NoteResponseDto.ListItem(
+ note.getId(),
+ note.getTitle(),
+ note.getCreator().getId(),
+ note.getCreator().getName(),
+ note.getCreator().getProfileImage(),
+ note.getLastModifiedAt(),
+ note.getCreatedAt(),
+ participantCount
+ );
+ }
+
+ /**
+ * NoteParticipant를 참여자 정보 DTO로 변환
+ */
+ public static NoteResponseDto.ParticipantInfo toParticipantInfo(NoteParticipant participant) {
+ return new NoteResponseDto.ParticipantInfo(
+ participant.getMember().getId(),
+ participant.getMember().getName(),
+ participant.getMember().getProfileImage(),
+ participant.getIsOnline(),
+ participant.getJoinedAt()
+ );
+ }
+
+ /**
+ * NoteParticipant 엔티티 생성
+ */
+ public static NoteParticipant toNoteParticipant(Note note, Member member) {
+ return NoteParticipant.builder()
+ .note(note)
+ .member(member)
+ .isOnline(true)
+ .build();
+ }
+
+ /**
+ * 노트 삭제 응답 DTO 생성
+ */
+ public static NoteResponseDto.Delete toDeleteResponse(Note note) {
+ return new NoteResponseDto.Delete(
+ "노트가 삭제되었습니다."
+ );
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/dto/CursorPosition.java b/src/main/java/com/project/syncly/domain/note/dto/CursorPosition.java
new file mode 100644
index 0000000..d12bf26
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/CursorPosition.java
@@ -0,0 +1,67 @@
+package com.project.syncly.domain.note.dto;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Builder;
+import lombok.Getter;
+
+/**
+ * 실시간 협업 노트의 커서 위치 정보
+ *
+ * position은 전체 텍스트에서 문자의 절대 위치 (0-based index)
+ * 예: "Hello\nWorld"에서 'W' 앞 → position=6
+ */
+@Getter
+@Builder
+public class CursorPosition {
+
+ /**
+ * 커서 위치 (0-based index)
+ * 전체 텍스트에서 문자의 절대 위치
+ */
+ private final int position;
+
+ /**
+ * 선택 영역 길이 (0이면 단순 커서)
+ * 예: 5글자 드래그 → range=5
+ */
+ private final int range;
+
+ /**
+ * WorkspaceMember ID
+ */
+ private final Long workspaceMemberId;
+
+ /**
+ * 워크스페이스 내 사용자 이름 (WorkspaceMember.name)
+ */
+ private final String userName;
+
+ /**
+ * 워크스페이스 내 프로필 이미지 (WorkspaceMember.profileImage)
+ */
+ private final String profileImage;
+
+ /**
+ * 사용자별 고유 색상 (hex 코드)
+ * 예: "#FF6B6B"
+ */
+ private final String color;
+
+ @JsonCreator
+ public CursorPosition(
+ @JsonProperty("position") int position,
+ @JsonProperty("range") int range,
+ @JsonProperty("workspaceMemberId") Long workspaceMemberId,
+ @JsonProperty("userName") String userName,
+ @JsonProperty("profileImage") String profileImage,
+ @JsonProperty("color") String color
+ ) {
+ this.position = position;
+ this.range = range;
+ this.workspaceMemberId = workspaceMemberId;
+ this.userName = userName;
+ this.profileImage = profileImage;
+ this.color = color;
+ }
+}
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
new file mode 100644
index 0000000..b229f5d
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/EditOperation.java
@@ -0,0 +1,211 @@
+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/NoteImageDto.java b/src/main/java/com/project/syncly/domain/note/dto/NoteImageDto.java
new file mode 100644
index 0000000..96d5ccd
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/NoteImageDto.java
@@ -0,0 +1,136 @@
+package com.project.syncly.domain.note.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+import java.time.LocalDateTime;
+
+/**
+ * 노트 이미지 업로드 관련 DTO 모음
+ */
+public class NoteImageDto {
+
+ /**
+ * Presigned URL 발급 요청 DTO
+ */
+ @Schema(description = "Presigned URL 발급 요청 DTO")
+ public record PresignedUrlRequest(
+ @Schema(description = "원본 파일명", example = "screenshot.png")
+ @NotBlank(message = "파일명은 필수입니다")
+ @Size(max = 255, message = "파일명은 255자를 초과할 수 없습니다")
+ String filename,
+
+ @Schema(description = "파일 Content Type (image/* 형식)", example = "image/png")
+ @NotBlank(message = "Content Type은 필수입니다")
+ @Pattern(regexp = "^image/.*", message = "이미지 파일만 업로드 가능합니다")
+ String contentType,
+
+ @Schema(description = "파일 크기 (바이트)", example = "1048576")
+ @NotNull(message = "파일 크기는 필수입니다")
+ Long fileSize
+ ) {
+ /**
+ * 파일 크기 검증 (최대 10MB)
+ */
+ public static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+
+ /**
+ * 파일명 정제 (특수문자 제거)
+ */
+ public String sanitizeFilename() {
+ if (filename == null) {
+ return "unknown";
+ }
+ // 위험한 문자 제거: 경로 구분자, null byte, 제어 문자 등
+ String sanitized = filename.replaceAll("[\\\\/:*?\"<>|\\x00-\\x1F]", "_");
+ // 연속된 점 제거 (경로 탐색 방지)
+ sanitized = sanitized.replaceAll("\\.{2,}", ".");
+ // 앞뒤 공백 및 점 제거
+ sanitized = sanitized.trim().replaceAll("^\\.+|\\.+$", "");
+
+ if (sanitized.isEmpty()) {
+ return "unknown";
+ }
+
+ return sanitized;
+ }
+
+ /**
+ * 파일 크기 검증
+ */
+ public void validateFileSize() {
+ if (fileSize > MAX_FILE_SIZE) {
+ throw new IllegalArgumentException(
+ String.format("파일 크기는 %dMB를 초과할 수 없습니다", MAX_FILE_SIZE / 1024 / 1024)
+ );
+ }
+ }
+ }
+
+ /**
+ * Presigned URL 발급 응답 DTO
+ */
+ @Schema(description = "Presigned URL 발급 응답 DTO")
+ public record PresignedUrlResponse(
+ @Schema(description = "업로드용 Presigned URL")
+ String uploadUrl,
+
+ @Schema(description = "이미지 ID (업로드 확인 시 사용)")
+ Long imageId,
+
+ @Schema(description = "S3 Object Key")
+ String objectKey,
+
+ @Schema(description = "Presigned URL 만료 시간")
+ LocalDateTime expiresAt
+ ) {}
+
+ /**
+ * 이미지 업로드 확인 요청 DTO
+ */
+ @Schema(description = "이미지 업로드 확인 요청 DTO")
+ public record ImageConfirmRequest(
+ @Schema(description = "이미지 ID", example = "123")
+ @NotNull(message = "이미지 ID는 필수입니다")
+ Long imageId
+ ) {}
+
+ /**
+ * 이미지 URL 응답 DTO
+ */
+ @Schema(description = "이미지 URL 응답 DTO")
+ public record ImageUrlResponse(
+ @Schema(description = "이미지 공개 URL (CloudFront 또는 S3 URL)")
+ String imageUrl,
+
+ @Schema(description = "마크다운 문법", example = "")
+ String markdownSyntax
+ ) {
+ /**
+ * 이미지 URL로부터 응답 생성
+ */
+ public static ImageUrlResponse from(String imageUrl, String originalFilename) {
+ String markdown = String.format("", originalFilename, imageUrl);
+ return new ImageUrlResponse(imageUrl, markdown);
+ }
+ }
+
+ /**
+ * 이미지 삭제 응답 DTO
+ */
+ @Schema(description = "이미지 삭제 응답 DTO")
+ public record ImageDeleteResponse(
+ @Schema(description = "삭제된 이미지 ID")
+ Long imageId,
+
+ @Schema(description = "성공 메시지")
+ String message
+ ) {
+ public static ImageDeleteResponse of(Long imageId) {
+ return new ImageDeleteResponse(imageId, "이미지가 성공적으로 삭제되었습니다");
+ }
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java b/src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java
new file mode 100644
index 0000000..1a9dcba
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java
@@ -0,0 +1,15 @@
+package com.project.syncly.domain.note.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public class NoteRequestDto {
+
+ @Schema(description = "노트 생성 요청 DTO")
+ public record Create(
+ @NotBlank(message = "노트 제목은 필수입니다.")
+ @Size(max = 200, message = "노트 제목은 최대 200자까지 입력 가능합니다.")
+ String title
+ ) {}
+}
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
new file mode 100644
index 0000000..62f1b03
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java
@@ -0,0 +1,91 @@
+package com.project.syncly.domain.note.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public class NoteResponseDto {
+
+ @Schema(description = "노트 생성 응답 DTO")
+ public record Create(
+ Long id,
+ String title,
+ Long workspaceId,
+ String creatorName,
+ LocalDateTime createdAt
+ ) {}
+
+ @Schema(description = "노트 상세 조회 응답 DTO")
+ public record Detail(
+ Long id,
+ String title,
+ String content,
+ Long workspaceId,
+ Long creatorId,
+ String creatorName,
+ String creatorProfileImage,
+ LocalDateTime lastModifiedAt,
+ LocalDateTime createdAt,
+ Long participantCount,
+ List activeParticipants
+ ) {}
+
+ @Schema(description = "노트 목록 조회 응답 DTO")
+ public record ListItem(
+ Long id,
+ String title,
+ Long creatorId,
+ String creatorName,
+ String creatorProfileImage,
+ LocalDateTime lastModifiedAt,
+ LocalDateTime createdAt,
+ Long participantCount
+ ) {}
+
+ @Schema(description = "노트 목록 응답 DTO")
+ public record NoteList(
+ List notes,
+ Long totalCount,
+ Integer currentPage,
+ Integer totalPages
+ ) {}
+
+ @Schema(description = "노트 삭제 응답 DTO")
+ public record Delete(
+ String message
+ ) {}
+
+ @Schema(description = "참여자 정보 DTO")
+ public record ParticipantInfo(
+ Long memberId,
+ String memberName,
+ String profileImage,
+ Boolean isOnline,
+ LocalDateTime joinedAt
+ ) {}
+
+ @Schema(description = "노트 저장 응답 DTO")
+ 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 failure(String message) {
+ return new SaveResponse(false, 0, 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
new file mode 100644
index 0000000..224f31f
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/NoteWebSocketDto.java
@@ -0,0 +1,547 @@
+package com.project.syncly.domain.note.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * WebSocket 메시지 전용 DTO 모음
+ */
+public class NoteWebSocketDto {
+
+ /**
+ * 노트 입장 시 입장한 사용자에게만 전송되는 응답 DTO
+ *
+ * 노트의 현재 상태를 모두 포함합니다.
+ */
+ @Schema(description = "노트 입장 응답 DTO")
+ public record EnterResponse(
+ @Schema(description = "노트 ID")
+ Long noteId,
+
+ @Schema(description = "노트 제목")
+ String title,
+
+ @Schema(description = "노트 현재 내용")
+ String content,
+
+ @Schema(description = "현재 문서 버전 (OT용)")
+ Integer revision,
+
+ @Schema(description = "현재 활성 사용자 목록 (workspaceMemberId 리스트)")
+ List activeUsers,
+
+ @Schema(description = "입장 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ /**
+ * 사용자가 노트에 입장했을 때 다른 참여자들에게 브로드캐스트되는 메시지
+ */
+ @Schema(description = "사용자 입장 알림 DTO")
+ public record UserJoinedMessage(
+ @Schema(description = "입장한 사용자의 WorkspaceMember ID")
+ Long workspaceMemberId,
+
+ @Schema(description = "입장한 사용자 이름 (WorkspaceMember.name)")
+ String userName,
+
+ @Schema(description = "입장한 사용자 프로필 이미지 (WorkspaceMember.profileImage)")
+ String profileImage,
+
+ @Schema(description = "현재 활성 사용자 수")
+ Integer activeUserCount,
+
+ @Schema(description = "입장 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ /**
+ * 사용자가 노트를 퇴장했을 때 다른 참여자들에게 브로드캐스트되는 메시지
+ */
+ @Schema(description = "사용자 퇴장 알림 DTO")
+ public record UserLeftMessage(
+ @Schema(description = "퇴장한 사용자의 WorkspaceMember ID")
+ Long workspaceMemberId,
+
+ @Schema(description = "퇴장한 사용자 이름 (WorkspaceMember.name)")
+ String userName,
+
+ @Schema(description = "현재 활성 사용자 수")
+ Integer activeUserCount,
+
+ @Schema(description = "퇴장 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ /**
+ * 퇴장 성공 응답 (퇴장한 사용자에게 전송)
+ */
+ @Schema(description = "노트 퇴장 응답 DTO")
+ public record LeaveResponse(
+ @Schema(description = "노트 ID")
+ Long noteId,
+
+ @Schema(description = "메시지")
+ String message,
+
+ @Schema(description = "퇴장 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ /**
+ * 사용자 정보 DTO (입장 시 활성 사용자 목록 전달용)
+ */
+ @Schema(description = "활성 사용자 정보 DTO")
+ public record ActiveUserInfo(
+ @Schema(description = "WorkspaceMember ID")
+ Long workspaceMemberId,
+
+ @Schema(description = "사용자 이름 (WorkspaceMember.name)")
+ String userName,
+
+ @Schema(description = "프로필 이미지 (WorkspaceMember.profileImage)")
+ String profileImage,
+
+ @Schema(description = "사용자 고유 색상 (hex)")
+ String color
+ ) {}
+
+ // ========== 실시간 편집 관련 DTO ==========
+
+ /**
+ * 편집 요청 DTO (클라이언트 → 서버)
+ */
+ @Schema(description = "편집 요청 DTO")
+ public record EditRequest(
+ @Schema(description = "편집 연산")
+ EditOperation operation
+ ) {}
+
+ /**
+ * 편집 브로드캐스트 메시지 (서버 → 모든 참여자)
+ *
+ * 한 사용자의 편집이 다른 참여자들에게 전파될 때 사용됩니다.
+ */
+ @Schema(description = "편집 브로드캐스트 메시지")
+ public record EditBroadcastMessage(
+ @Schema(description = "변환된 편집 연산")
+ EditOperation operation,
+
+ @Schema(description = "최신 문서 전체 내용 (10개 연산마다 전송)")
+ String content,
+
+ @Schema(description = "새 문서 버전 번호")
+ int revision,
+
+ @Schema(description = "편집한 사용자의 WorkspaceMember ID")
+ Long workspaceMemberId,
+
+ @Schema(description = "편집한 사용자 이름")
+ String userName,
+
+ @Schema(description = "편집 시각")
+ LocalDateTime timestamp,
+
+ @Schema(description = "전체 content 포함 여부 (동기화용)")
+ boolean includesFullContent
+ ) {}
+
+ /**
+ * 에러 메시지 DTO (서버 → 특정 사용자)
+ */
+ @Schema(description = "에러 메시지 DTO")
+ public record ErrorMessage(
+ @Schema(description = "에러 코드")
+ String code,
+
+ @Schema(description = "에러 메시지")
+ String message,
+
+ @Schema(description = "현재 문서 내용 (동기화용)")
+ String content,
+
+ @Schema(description = "현재 문서 버전 (동기화용)")
+ Integer revision,
+
+ @Schema(description = "에러 발생 시각")
+ LocalDateTime timestamp
+ ) {
+ /**
+ * 동기화 정보 없는 단순 에러 메시지 생성
+ */
+ public static ErrorMessage of(String code, String message) {
+ return new ErrorMessage(code, message, null, null, LocalDateTime.now());
+ }
+
+ /**
+ * 동기화 정보 포함 에러 메시지 생성
+ */
+ public static ErrorMessage withSync(String code, String message, String content, int revision) {
+ return new ErrorMessage(code, message, content, revision, LocalDateTime.now());
+ }
+ }
+
+ /**
+ * 편집 성공 응답 DTO (서버 → 편집 요청한 사용자)
+ */
+ @Schema(description = "편집 성공 응답 DTO")
+ public record EditResponse(
+ @Schema(description = "적용된 연산 (변환 후)")
+ EditOperation appliedOperation,
+
+ @Schema(description = "새 문서 버전")
+ int newRevision,
+
+ @Schema(description = "성공 메시지")
+ String message,
+
+ @Schema(description = "응답 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ // ========== 커서 위치 공유 관련 DTO ==========
+
+ /**
+ * 커서 업데이트 요청 DTO (클라이언트 → 서버)
+ */
+ @Schema(description = "커서 업데이트 요청 DTO")
+ public record CursorUpdateRequest(
+ @Schema(description = "커서 위치 (0-based index)")
+ int position,
+
+ @Schema(description = "선택 영역 길이 (0이면 단순 커서)")
+ int range
+ ) {}
+
+ /**
+ * 커서 브로드캐스트 메시지 (서버 → 다른 참여자들)
+ */
+ @Schema(description = "커서 브로드캐스트 메시지")
+ public record CursorBroadcastMessage(
+ @Schema(description = "WorkspaceMember ID")
+ Long workspaceMemberId,
+
+ @Schema(description = "사용자 이름")
+ String userName,
+
+ @Schema(description = "커서 위치 (0-based index)")
+ int position,
+
+ @Schema(description = "선택 영역 길이")
+ int range,
+
+ @Schema(description = "사용자 고유 색상 (hex)")
+ String color,
+
+ @Schema(description = "프로필 이미지")
+ String profileImage,
+
+ @Schema(description = "업데이트 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ /**
+ * 커서 삭제 메시지 (서버 → 다른 참여자들)
+ */
+ @Schema(description = "커서 삭제 메시지")
+ public record CursorRemovedMessage(
+ @Schema(description = "WorkspaceMember ID")
+ Long workspaceMemberId,
+
+ @Schema(description = "삭제 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ /**
+ * 전체 커서 정보 응답 (입장 시)
+ */
+ @Schema(description = "전체 커서 정보 응답")
+ public record AllCursorsResponse(
+ @Schema(description = "모든 활성 커서 (workspaceMemberId -> CursorPosition)")
+ Map cursors,
+
+ @Schema(description = "응답 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ /**
+ * 커서 조정 브로드캐스트 메시지 (편집 후 커서 위치 자동 조정)
+ */
+ @Schema(description = "커서 조정 브로드캐스트 메시지")
+ public record CursorsAdjustedMessage(
+ @Schema(description = "조정된 커서들 (workspaceMemberId -> CursorPosition)")
+ Map adjustedCursors,
+
+ @Schema(description = "조정 시각")
+ LocalDateTime timestamp
+ ) {}
+
+ // ========== 자동 저장 관련 DTO ==========
+
+ /**
+ * 자동 저장 완료 메시지 (서버 → 모든 참여자)
+ */
+ @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(), "자동 저장됨");
+ }
+ }
+
+ // ========== CRUD 기능 관련 DTO ==========
+
+ /**
+ * 노트 생성 요청 DTO (클라이언트 → 서버)
+ */
+ @Schema(description = "노트 생성 요청 DTO")
+ public record CreateRequest(
+ @Schema(description = "워크스페이스 ID")
+ Long workspaceId,
+
+ @Schema(description = "노트 제목")
+ String title
+ ) {}
+
+ /**
+ * 노트 생성 응답 DTO (서버 → 클라이언트)
+ */
+ @Schema(description = "노트 생성 응답 DTO")
+ public record CreateResponse(
+ @Schema(description = "생성된 노트 ID")
+ Long noteId,
+
+ @Schema(description = "노트 제목")
+ String title,
+
+ @Schema(description = "워크스페이스 ID")
+ Long workspaceId,
+
+ @Schema(description = "생성자 이름")
+ String creatorName,
+
+ @Schema(description = "생성자 프로필 이미지")
+ String creatorProfileImage,
+
+ @Schema(description = "생성 시각")
+ LocalDateTime createdAt
+ ) {}
+
+ /**
+ * 노트 목록 조회 요청 DTO (클라이언트 → 서버)
+ */
+ @Schema(description = "노트 목록 조회 요청 DTO")
+ public record ListRequest(
+ @Schema(description = "워크스페이스 ID")
+ Long workspaceId,
+
+ @Schema(description = "페이지 번호 (0-based)")
+ int page,
+
+ @Schema(description = "페이지 크기")
+ int size,
+
+ @Schema(description = "정렬 기준 (createdAt, lastModifiedAt 등)")
+ String sortBy,
+
+ @Schema(description = "정렬 방향 (asc, desc)")
+ String direction
+ ) {}
+
+ /**
+ * 노트 목록 조회 응답 DTO (서버 → 클라이언트)
+ */
+ @Schema(description = "노트 목록 조회 응답 DTO")
+ public record ListResponse(
+ @Schema(description = "노트 목록")
+ List notes,
+
+ @Schema(description = "전체 노트 개수")
+ int totalCount,
+
+ @Schema(description = "현재 페이지")
+ int currentPage,
+
+ @Schema(description = "전체 페이지 수")
+ int totalPages
+ ) {}
+
+ /**
+ * 노트 목록 항목 DTO
+ */
+ @Schema(description = "노트 목록 항목 DTO")
+ public record NoteListItem(
+ @Schema(description = "노트 ID")
+ Long noteId,
+
+ @Schema(description = "노트 제목")
+ String title,
+
+ @Schema(description = "생성자 이름")
+ String creatorName,
+
+ @Schema(description = "생성자 프로필 이미지")
+ String creatorProfileImage,
+
+ @Schema(description = "마지막 수정 시각")
+ LocalDateTime lastModifiedAt,
+
+ @Schema(description = "참여자 수")
+ int participantCount,
+
+ @Schema(description = "생성 시각")
+ LocalDateTime createdAt
+ ) {}
+
+ /**
+ * 노트 상세 조회 요청 DTO (클라이언트 → 서버)
+ */
+ @Schema(description = "노트 상세 조회 요청 DTO")
+ public record GetDetailRequest(
+ @Schema(description = "노트 ID")
+ Long noteId
+ ) {}
+
+ /**
+ * 노트 상세 조회 응답 DTO (서버 → 클라이언트)
+ */
+ @Schema(description = "노트 상세 조회 응답 DTO")
+ public record GetDetailResponse(
+ @Schema(description = "노트 ID")
+ Long noteId,
+
+ @Schema(description = "노트 제목")
+ String title,
+
+ @Schema(description = "노트 내용")
+ String content,
+
+ @Schema(description = "워크스페이스 ID")
+ Long workspaceId,
+
+ @Schema(description = "생성자 ID")
+ Long creatorId,
+
+ @Schema(description = "생성자 이름")
+ String creatorName,
+
+ @Schema(description = "생성자 프로필 이미지")
+ String creatorProfileImage,
+
+ @Schema(description = "현재 활성 참여자")
+ List activeParticipants,
+
+ @Schema(description = "마지막 수정 시각")
+ LocalDateTime lastModifiedAt,
+
+ @Schema(description = "생성 시각")
+ LocalDateTime createdAt,
+
+ @Schema(description = "현재 문서 버전 (OT용)")
+ Integer revision
+ ) {}
+
+ /**
+ * 노트 삭제 요청 DTO (클라이언트 → 서버)
+ */
+ @Schema(description = "노트 삭제 요청 DTO")
+ public record DeleteRequest(
+ @Schema(description = "노트 ID")
+ Long noteId
+ ) {}
+
+ /**
+ * 노트 삭제 응답 DTO (서버 → 클라이언트)
+ */
+ @Schema(description = "노트 삭제 응답 DTO")
+ public record DeleteResponse(
+ @Schema(description = "삭제 성공 여부")
+ boolean success,
+
+ @Schema(description = "응답 메시지")
+ String message,
+
+ @Schema(description = "삭제 시각")
+ LocalDateTime deletedAt
+ ) {}
+
+ /**
+ * 노트 수동 저장 응답 DTO (서버 → 모든 참여자)
+ */
+ @Schema(description = "노트 수동 저장 응답 DTO")
+ public record SaveResponse(
+ @Schema(description = "저장된 노트 ID")
+ Long noteId,
+
+ @Schema(description = "저장된 문서 버전")
+ Integer revision,
+
+ @Schema(description = "저장 시각")
+ LocalDateTime savedAt,
+
+ @Schema(description = "응답 메시지")
+ String message,
+
+ @Schema(description = "저장한 사용자 ID")
+ Long savedByWorkspaceMemberId,
+
+ @Schema(description = "저장한 사용자 이름")
+ String savedByUserName
+ ) {}
+
+ // ========== 실시간 목록 업데이트 관련 DTO ==========
+
+ /**
+ * 노트 생성 브로드캐스트 메시지 (서버 → 같은 워크스페이스의 모든 사용자)
+ *
+ * 노트가 생성되었을 때 해당 워크스페이스를 보고 있는 모든 사용자에게 실시간으로 전파됩니다.
+ */
+ @Schema(description = "노트 생성 브로드캐스트 메시지")
+ public record NoteCreatedMessage(
+ @Schema(description = "생성된 노트 ID")
+ Long noteId,
+
+ @Schema(description = "노트 제목")
+ String title,
+
+ @Schema(description = "워크스페이스 ID")
+ Long workspaceId,
+
+ @Schema(description = "생성자 이름")
+ String creatorName,
+
+ @Schema(description = "생성자 프로필 이미지")
+ String creatorProfileImage,
+
+ @Schema(description = "생성 시각")
+ LocalDateTime createdAt
+ ) {}
+
+ /**
+ * 노트 삭제 브로드캐스트 메시지 (서버 → 같은 워크스페이스의 모든 사용자)
+ *
+ *
노트가 삭제되었을 때 해당 워크스페이스를 보고 있는 모든 사용자에게 실시간으로 전파됩니다.
+ */
+ @Schema(description = "노트 삭제 브로드캐스트 메시지")
+ public record NoteDeletedMessage(
+ @Schema(description = "삭제된 노트 ID")
+ Long noteId,
+
+ @Schema(description = "워크스페이스 ID")
+ Long workspaceId,
+
+ @Schema(description = "삭제 시각")
+ LocalDateTime deletedAt
+ ) {}
+}
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
new file mode 100644
index 0000000..16691a4
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessage.java
@@ -0,0 +1,143 @@
+package com.project.syncly.domain.note.dto;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * WebSocket 메시지를 감싸는 제네릭 래퍼 클래스
+ *
+ *
실시간 협업 노트에서 주고받는 모든 WebSocket 메시지의 공통 형식을 정의합니다.
+ *
+ *
메시지 타입 (type):
+ *
+ * ENTER: 노트에 입장 (payload: null)
+ * LEAVE: 노트에서 퇴장 (payload: null)
+ * EDIT: 편집 연산 (payload: EditOperation)
+ * CURSOR: 커서 위치 변경 (payload: CursorPosition)
+ * 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
+ * );
+ * }
+ *
+ * @param 메시지 payload의 타입 (EditOperation, CursorPosition 등)
+ */
+@Getter
+@Builder
+public class WebSocketMessage {
+
+ /**
+ * 메시지 타입 (ENTER, LEAVE, EDIT, CURSOR, SAVE, ERROR)
+ */
+ private final WebSocketMessageType type;
+
+ /**
+ * 메시지 실제 데이터 (타입에 따라 다름)
+ */
+ private final T payload;
+
+ /**
+ * 메시지 발신자의 WorkspaceMember ID
+ */
+ private final Long workspaceMemberId;
+
+ /**
+ * 메시지 전송 시각
+ */
+ private final LocalDateTime timestamp;
+
+ @JsonCreator
+ public WebSocketMessage(
+ @JsonProperty("type") WebSocketMessageType type,
+ @JsonProperty("payload") T payload,
+ @JsonProperty("workspaceMemberId") Long workspaceMemberId,
+ @JsonProperty("timestamp") LocalDateTime timestamp
+ ) {
+ this.type = type;
+ this.payload = payload;
+ this.workspaceMemberId = workspaceMemberId;
+ this.timestamp = timestamp != null ? timestamp : LocalDateTime.now();
+ }
+
+ /**
+ * WebSocketMessage 생성 헬퍼 메서드
+ *
+ * @param type 메시지 타입
+ * @param payload 메시지 페이로드
+ * @param workspaceMemberId 발신자 WorkspaceMember ID
+ * @param payload 타입
+ * @return 생성된 WebSocketMessage
+ */
+ public static WebSocketMessage of(
+ WebSocketMessageType type,
+ T payload,
+ Long workspaceMemberId
+ ) {
+ return WebSocketMessage.builder()
+ .type(type)
+ .payload(payload)
+ .workspaceMemberId(workspaceMemberId)
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ /**
+ * payload 없는 간단한 메시지 생성 (ENTER, LEAVE 등)
+ *
+ * @param type 메시지 타입
+ * @param workspaceMemberId 발신자 WorkspaceMember ID
+ * @return 생성된 WebSocketMessage (payload = null)
+ */
+ public static WebSocketMessage ofEmpty(
+ WebSocketMessageType type,
+ Long workspaceMemberId
+ ) {
+ return WebSocketMessage.builder()
+ .type(type)
+ .payload(null)
+ .workspaceMemberId(workspaceMemberId)
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+
+ /**
+ * 에러 메시지 생성
+ *
+ * @param errorMessage 에러 메시지
+ * @param workspaceMemberId 발신자 WorkspaceMember ID
+ * @return ERROR 타입의 WebSocketMessage
+ */
+ public static WebSocketMessage error(String errorMessage, Long workspaceMemberId) {
+ return WebSocketMessage.builder()
+ .type(WebSocketMessageType.ERROR)
+ .payload(errorMessage)
+ .workspaceMemberId(workspaceMemberId)
+ .timestamp(LocalDateTime.now())
+ .build();
+ }
+}
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
new file mode 100644
index 0000000..d8a2dff
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/dto/WebSocketMessageType.java
@@ -0,0 +1,112 @@
+package com.project.syncly.domain.note.dto;
+
+/**
+ * WebSocket 메시지 타입을 정의하는 Enum
+ *
+ * 실시간 협업 노트에서 사용하는 모든 메시지 타입을 정의합니다.
+ */
+public enum WebSocketMessageType {
+ /**
+ * 노트 입장
+ * - 사용자가 노트에 처음 접속했을 때
+ * - payload: null
+ * - 서버 → 다른 참여자들에게 브로드캐스트
+ */
+ ENTER,
+
+ /**
+ * 노트 퇴장
+ * - 사용자가 노트를 나가거나 연결이 끊겼을 때
+ * - payload: null
+ * - 서버 → 다른 참여자들에게 브로드캐스트
+ */
+ LEAVE,
+
+ /**
+ * 편집 연산
+ * - 사용자가 텍스트를 삽입하거나 삭제했을 때
+ * - payload: EditOperation (insert/delete 정보)
+ * - 클라이언트 → 서버 → 다른 참여자들에게 브로드캐스트
+ */
+ EDIT,
+
+ /**
+ * 커서 위치 변경
+ * - 사용자의 커서 위치가 변경되었을 때
+ * - payload: CursorPosition (position, range 정보)
+ * - 클라이언트 → 서버 → 다른 참여자들에게 브로드캐스트
+ */
+ CURSOR,
+
+ /**
+ * 자동 저장 완료
+ * - 서버에서 Redis 데이터를 DB에 저장 완료했을 때
+ * - payload: SaveResult (저장 시각, 버전 정보)
+ * - 서버 → 모든 참여자들에게 브로드캐스트
+ */
+ SAVE,
+
+ /**
+ * 에러 발생
+ * - 작업 처리 중 에러가 발생했을 때
+ * - payload: ErrorDetails 또는 String (에러 메시지)
+ * - 서버 → 특정 사용자에게 전송 (유니캐스트)
+ */
+ ERROR,
+
+ /**
+ * 노트 생성
+ * - 사용자가 새로운 노트를 생성했을 때
+ * - payload: CreateResponse (noteId, title, creatorName, createdAt)
+ * - 클라이언트 → 서버 → 응답 전송 (유니캐스트)
+ */
+ CREATE,
+
+ /**
+ * 노트 목록 조회
+ * - 사용자가 노트 목록을 요청했을 때
+ * - payload: ListResponse (notes, totalCount, currentPage, totalPages)
+ * - 클라이언트 → 서버 → 응답 전송 (유니캐스트)
+ */
+ LIST,
+
+ /**
+ * 노트 상세 조회
+ * - 사용자가 특정 노트의 상세 정보를 요청했을 때
+ * - payload: GetDetailResponse (noteId, title, content, creator, activeParticipants)
+ * - 클라이언트 → 서버 → 응답 전송 (유니캐스트)
+ */
+ GET_DETAIL,
+
+ /**
+ * 노트 삭제
+ * - 사용자가 노트를 삭제했을 때
+ * - payload: DeleteResponse (success, message)
+ * - 클라이언트 → 서버 → 응답 전송 (유니캐스트)
+ */
+ DELETE,
+
+ /**
+ * 노트 수동 저장
+ * - 사용자가 수동으로 저장 버튼을 클릭했을 때
+ * - payload: SaveResponse (revision, savedAt, message)
+ * - 클라이언트 → 서버 → 모든 참여자에게 브로드캐스트
+ */
+ MANUAL_SAVE,
+
+ /**
+ * 노트 생성됨 (브로드캐스트)
+ * - 새로운 노트가 생성되었을 때
+ * - payload: NoteCreatedMessage (noteId, title, creatorName, createdAt)
+ * - 서버 → 같은 워크스페이스의 모든 사용자에게 브로드캐스트
+ */
+ NOTE_CREATED,
+
+ /**
+ * 노트 삭제됨 (브로드캐스트)
+ * - 노트가 삭제되었을 때
+ * - payload: NoteDeletedMessage (noteId, deletedAt)
+ * - 서버 → 같은 워크스페이스의 모든 사용자에게 브로드캐스트
+ */
+ NOTE_DELETED
+}
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
new file mode 100644
index 0000000..f2bae88
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/engine/OTEngine.java
@@ -0,0 +1,431 @@
+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을 반복 적용합니다.
+ *
+ *
예시:
+ *
+ * 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/entity/Note.java b/src/main/java/com/project/syncly/domain/note/entity/Note.java
new file mode 100644
index 0000000..5a91fad
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/entity/Note.java
@@ -0,0 +1,78 @@
+package com.project.syncly.domain.note.entity;
+
+import com.project.syncly.domain.member.entity.Member;
+import com.project.syncly.domain.workspace.entity.Workspace;
+import com.project.syncly.global.entity.BaseTimeDeletedEntity;
+import jakarta.persistence.*;
+import lombok.*;
+import org.hibernate.annotations.SQLDelete;
+import org.hibernate.annotations.Where;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "note")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+@SQLDelete(sql = "UPDATE note SET is_deleted = true, deleted_at = NOW() WHERE id = ?")
+@Where(clause = "is_deleted = false")
+public class Note extends BaseTimeDeletedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "workspace_id", nullable = false)
+ private Workspace workspace;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "creator_id", nullable = false)
+ private Member creator;
+
+ @Column(nullable = false, length = 200)
+ private String title;
+
+ @Column(columnDefinition = "LONGTEXT")
+ private String content;
+
+ @Column(name = "last_modified_at", nullable = false)
+ private LocalDateTime lastModifiedAt;
+
+ /**
+ * 노트 제목 업데이트
+ */
+ public void updateTitle(String title) {
+ this.title = title;
+ this.lastModifiedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 노트 내용 업데이트
+ */
+ public void updateContent(String content) {
+ this.content = content;
+ this.lastModifiedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 소프트 삭제 시 lastModifiedAt도 갱신
+ */
+ @Override
+ public void markAsDeleted() {
+ super.markAsDeleted();
+ this.lastModifiedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 생성 시 lastModifiedAt 초기화
+ */
+ @PrePersist
+ public void prePersist() {
+ if (this.lastModifiedAt == null) {
+ this.lastModifiedAt = LocalDateTime.now();
+ }
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/entity/NoteImage.java b/src/main/java/com/project/syncly/domain/note/entity/NoteImage.java
new file mode 100644
index 0000000..4f0d61e
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/entity/NoteImage.java
@@ -0,0 +1,89 @@
+package com.project.syncly.domain.note.entity;
+
+import com.project.syncly.domain.member.entity.Member;
+import com.project.syncly.global.entity.BaseCreatedEntity;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "note_image")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class NoteImage extends BaseCreatedEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "note_id", nullable = false)
+ private Note note;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "uploader_id", nullable = false)
+ private Member uploader;
+
+ @Column(name = "file_url", length = 500)
+ private String fileUrl;
+
+ @Column(name = "object_key", nullable = false, length = 500)
+ private String objectKey;
+
+ @Column(name = "original_filename", nullable = false, length = 255)
+ private String originalFilename;
+
+ @Column(name = "content_type", nullable = false, length = 100)
+ private String contentType;
+
+ @Column(name = "file_size")
+ private Long fileSize;
+
+ /**
+ * 업로드 상태: PENDING(대기), COMPLETED(완료), FAILED(실패)
+ */
+ @Column(name = "upload_status", nullable = false, length = 20)
+ @Enumerated(EnumType.STRING)
+ @Builder.Default
+ private UploadStatus uploadStatus = UploadStatus.PENDING;
+
+ /**
+ * Presigned URL 만료 시간
+ */
+ @Column(name = "expires_at")
+ private LocalDateTime expiresAt;
+
+ /**
+ * 파일 URL 업데이트 (업로드 완료 후)
+ */
+ public void updateFileUrl(String fileUrl) {
+ this.fileUrl = fileUrl;
+ }
+
+ /**
+ * 업로드 완료 상태로 변경
+ */
+ public void markAsCompleted(String fileUrl) {
+ this.fileUrl = fileUrl;
+ this.uploadStatus = UploadStatus.COMPLETED;
+ }
+
+ /**
+ * 업로드 실패 상태로 변경
+ */
+ public void markAsFailed() {
+ this.uploadStatus = UploadStatus.FAILED;
+ }
+
+ /**
+ * 업로드 상태 Enum
+ */
+ public enum UploadStatus {
+ PENDING, // 업로드 대기 중
+ COMPLETED, // 업로드 완료
+ FAILED // 업로드 실패
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/entity/NoteParticipant.java b/src/main/java/com/project/syncly/domain/note/entity/NoteParticipant.java
new file mode 100644
index 0000000..91d53e5
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/entity/NoteParticipant.java
@@ -0,0 +1,71 @@
+package com.project.syncly.domain.note.entity;
+
+import com.project.syncly.domain.member.entity.Member;
+import jakarta.persistence.*;
+import lombok.*;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(
+ name = "note_participant",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_note_member",
+ columnNames = {"note_id", "member_id"}
+ )
+ }
+)
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+@AllArgsConstructor
+@Builder
+public class NoteParticipant {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "note_id", nullable = false)
+ private Note note;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "member_id", nullable = false)
+ private Member member;
+
+ @Column(name = "joined_at", nullable = false)
+ private LocalDateTime joinedAt;
+
+ @Column(name = "is_online", nullable = false, columnDefinition = "BOOLEAN DEFAULT TRUE")
+ @Builder.Default
+ private Boolean isOnline = true;
+
+ /**
+ * 온라인 상태로 변경 (재입장 시)
+ */
+ public void setOnline() {
+ this.isOnline = true;
+ this.joinedAt = LocalDateTime.now();
+ }
+
+ /**
+ * 오프라인 상태로 변경 (퇴장 시)
+ */
+ public void setOffline() {
+ this.isOnline = false;
+ }
+
+ /**
+ * 생성 시 joinedAt 초기화
+ */
+ @PrePersist
+ public void prePersist() {
+ if (this.joinedAt == null) {
+ this.joinedAt = LocalDateTime.now();
+ }
+ if (this.isOnline == null) {
+ this.isOnline = true;
+ }
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/exception/NoteErrorCode.java b/src/main/java/com/project/syncly/domain/note/exception/NoteErrorCode.java
new file mode 100644
index 0000000..37e2a64
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/exception/NoteErrorCode.java
@@ -0,0 +1,61 @@
+package com.project.syncly.domain.note.exception;
+
+import com.project.syncly.global.apiPayload.code.BaseErrorCode;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+@AllArgsConstructor
+public enum NoteErrorCode implements BaseErrorCode {
+
+ // 노트 관련 에러
+ NOTE_NOT_FOUND(HttpStatus.NOT_FOUND, "Note404_0", "노트를 찾을 수 없습니다."),
+ NOTE_ALREADY_DELETED(HttpStatus.GONE, "Note410_0", "이미 삭제된 노트입니다."),
+
+ // 권한 관련 에러
+ NOTE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "Note403_0", "노트에 접근할 권한이 없습니다."),
+ NOT_NOTE_CREATOR(HttpStatus.FORBIDDEN, "Note403_1", "노트 작성자가 아닙니다."),
+
+ // 유효성 검증 에러
+ INVALID_NOTE_TITLE(HttpStatus.BAD_REQUEST, "Note400_0", "노트 제목이 유효하지 않습니다."),
+ EMPTY_NOTE_TITLE(HttpStatus.BAD_REQUEST, "Note400_1", "노트 제목은 비워둘 수 없습니다."),
+ NOTE_TITLE_TOO_LONG(HttpStatus.BAD_REQUEST, "Note400_2", "노트 제목은 최대 200자까지 입력 가능합니다."),
+ NOTE_CONTENT_TOO_LONG(HttpStatus.BAD_REQUEST, "Note400_3", "노트 내용이 너무 깁니다. (최대 1MB)"),
+
+ // 워크스페이스 관련 에러
+ WORKSPACE_NOT_FOUND(HttpStatus.NOT_FOUND, "Note404_1", "워크스페이스를 찾을 수 없습니다."),
+ NOT_WORKSPACE_MEMBER(HttpStatus.FORBIDDEN, "Note403_2", "워크스페이스의 멤버가 아닙니다."),
+
+ // 참여자 관련 에러
+ PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND, "Note404_2", "노트 참여자를 찾을 수 없습니다."),
+ ALREADY_PARTICIPANT(HttpStatus.CONFLICT, "Note409_0", "이미 노트에 참여 중입니다."),
+
+ // 이미지 관련 에러
+ IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "Note404_3", "이미지를 찾을 수 없습니다."),
+ INVALID_IMAGE_TYPE(HttpStatus.BAD_REQUEST, "Note400_4", "지원하지 않는 이미지 형식입니다."),
+ IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "Note400_5", "이미지 크기가 제한을 초과했습니다. (최대 10MB)"),
+ IMAGE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Note500_0", "이미지 업로드에 실패했습니다."),
+
+ // 동시 편집 관련 에러
+ CONCURRENT_EDIT_CONFLICT(HttpStatus.CONFLICT, "Note409_1", "동시 편집 충돌이 발생했습니다. 다시 시도해주세요."),
+ OT_TRANSFORM_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Note500_1", "편집 내용 병합에 실패했습니다."),
+ INVALID_OPERATION(HttpStatus.BAD_REQUEST, "Note400_6", "유효하지 않은 편집 연산입니다."),
+ REVISION_MISMATCH(HttpStatus.CONFLICT, "Note409_2", "문서 버전이 일치하지 않습니다. 새로고침 후 다시 시도해주세요."),
+
+ // WebSocket 연결 관련 에러
+ WEBSOCKET_CONNECTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Note500_2", "WebSocket 연결에 실패했습니다."),
+ WEBSOCKET_MESSAGE_PARSING_ERROR(HttpStatus.BAD_REQUEST, "Note400_7", "WebSocket 메시지를 처리할 수 없습니다."),
+
+ // Redis 연결 관련 에러
+ REDIS_CONNECTION_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "Note503_0", "Redis 서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요."),
+ REDIS_OPERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Note500_3", "Redis 작업에 실패했습니다."),
+
+ // Rate Limiting 에러
+ RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "Note429_0", "요청 횟수를 초과했습니다. 잠시 후 다시 시도해주세요."),
+ ;
+
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+}
diff --git a/src/main/java/com/project/syncly/domain/note/exception/NoteException.java b/src/main/java/com/project/syncly/domain/note/exception/NoteException.java
new file mode 100644
index 0000000..c73268c
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/exception/NoteException.java
@@ -0,0 +1,43 @@
+package com.project.syncly.domain.note.exception;
+
+import com.project.syncly.global.apiPayload.code.BaseErrorCode;
+import com.project.syncly.global.apiPayload.exception.CustomException;
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+
+@Getter
+public class NoteException extends CustomException {
+ private final String customMessage;
+
+ public NoteException(NoteErrorCode errorCode) {
+ super(errorCode);
+ this.customMessage = null;
+ }
+
+ /**
+ * 커스텀 메시지와 함께 예외 생성
+ *
+ * @param errorCode 에러 코드
+ * @param customMessage 추가 상세 메시지
+ */
+ public NoteException(NoteErrorCode errorCode, String customMessage) {
+ super(new CustomErrorCode(errorCode, customMessage));
+ this.customMessage = customMessage;
+ }
+
+ /**
+ * 커스텀 메시지를 포함한 에러 코드 래퍼
+ */
+ @Getter
+ private static class CustomErrorCode implements BaseErrorCode {
+ private final HttpStatus status;
+ private final String code;
+ private final String message;
+
+ public CustomErrorCode(NoteErrorCode baseErrorCode, String customMessage) {
+ this.status = baseErrorCode.getStatus();
+ this.code = baseErrorCode.getCode();
+ this.message = baseErrorCode.getMessage() + " - " + customMessage;
+ }
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/repository/NoteImageRepository.java b/src/main/java/com/project/syncly/domain/note/repository/NoteImageRepository.java
new file mode 100644
index 0000000..76e75c7
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/repository/NoteImageRepository.java
@@ -0,0 +1,57 @@
+package com.project.syncly.domain.note.repository;
+
+import com.project.syncly.domain.note.entity.NoteImage;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface NoteImageRepository extends JpaRepository {
+
+ /**
+ * 특정 노트의 이미지 목록 조회
+ */
+ @Query("SELECT ni FROM NoteImage ni WHERE ni.note.id = :noteId ORDER BY ni.createdAt DESC")
+ List findByNoteId(@Param("noteId") Long noteId);
+
+ /**
+ * 특정 노트의 이미지 개수 조회
+ */
+ @Query("SELECT COUNT(ni) FROM NoteImage ni WHERE ni.note.id = :noteId")
+ Long countByNoteId(@Param("noteId") Long noteId);
+
+ /**
+ * Object Key로 이미지 조회 (중복 업로드 체크용)
+ */
+ Optional findByObjectKey(String objectKey);
+
+ /**
+ * 특정 사용자가 업로드한 이미지 목록 조회
+ */
+ @Query("SELECT ni FROM NoteImage ni WHERE ni.uploader.id = :uploaderId ORDER BY ni.createdAt DESC")
+ List findByUploaderId(@Param("uploaderId") Long uploaderId);
+
+ /**
+ * 노트와 업로더로 이미지 조회
+ */
+ @Query("SELECT ni FROM NoteImage ni WHERE ni.note.id = :noteId AND ni.uploader.id = :uploaderId")
+ List findByNoteIdAndUploaderId(@Param("noteId") Long noteId, @Param("uploaderId") Long uploaderId);
+
+ /**
+ * 만료된 PENDING 상태의 이미지 조회 (스케줄러용)
+ */
+ @Query("SELECT ni FROM NoteImage ni WHERE ni.uploadStatus = 'PENDING' AND ni.expiresAt < :now")
+ List findExpiredPendingImages(@Param("now") LocalDateTime now);
+
+ /**
+ * 노트 ID와 이미지 ID로 조회
+ */
+ @Query("SELECT ni FROM NoteImage ni WHERE ni.id = :imageId AND ni.note.id = :noteId")
+ Optional findByIdAndNoteId(@Param("imageId") Long imageId, @Param("noteId") Long noteId);
+}
diff --git a/src/main/java/com/project/syncly/domain/note/repository/NoteParticipantRepository.java b/src/main/java/com/project/syncly/domain/note/repository/NoteParticipantRepository.java
new file mode 100644
index 0000000..3f62115
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/repository/NoteParticipantRepository.java
@@ -0,0 +1,84 @@
+package com.project.syncly.domain.note.repository;
+
+import com.project.syncly.domain.note.entity.NoteParticipant;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface NoteParticipantRepository extends JpaRepository {
+
+ /**
+ * 특정 노트의 모든 참여자 조회
+ */
+ @Query("SELECT np FROM NoteParticipant np WHERE np.note.id = :noteId")
+ List findByNoteId(@Param("noteId") Long noteId);
+
+ /**
+ * 특정 노트의 온라인 참여자만 조회
+ */
+ @Query("SELECT np FROM NoteParticipant np WHERE np.note.id = :noteId AND np.isOnline = true")
+ List findOnlineParticipantsByNoteId(@Param("noteId") Long noteId);
+
+ /**
+ * 노트 ID와 멤버 ID로 참여자 조회
+ */
+ @Query("SELECT np FROM NoteParticipant np WHERE np.note.id = :noteId AND np.member.id = :memberId")
+ Optional findByNoteIdAndMemberId(@Param("noteId") Long noteId, @Param("memberId") Long memberId);
+
+ /**
+ * 특정 멤버가 참여 중인 모든 노트의 참여자 정보 조회
+ */
+ @Query("SELECT np FROM NoteParticipant np WHERE np.member.id = :memberId")
+ List findByMemberId(@Param("memberId") Long memberId);
+
+ /**
+ * 특정 멤버가 온라인 상태인 노트 목록 조회
+ */
+ @Query("SELECT np FROM NoteParticipant np WHERE np.member.id = :memberId AND np.isOnline = true")
+ List findOnlineNotesByMemberId(@Param("memberId") Long memberId);
+
+ /**
+ * 특정 노트의 온라인 참여자 수 조회
+ */
+ @Query("SELECT COUNT(np) FROM NoteParticipant np WHERE np.note.id = :noteId AND np.isOnline = true")
+ Long countOnlineParticipantsByNoteId(@Param("noteId") Long noteId);
+
+ /**
+ * 특정 노트의 전체 참여자 수 조회
+ */
+ @Query("SELECT COUNT(np) FROM NoteParticipant np WHERE np.note.id = :noteId")
+ Long countByNoteId(@Param("noteId") Long noteId);
+
+ /**
+ * 노트 참여자의 온라인 상태 업데이트 (온라인으로)
+ */
+ @Modifying
+ @Query("UPDATE NoteParticipant np SET np.isOnline = true, np.joinedAt = CURRENT_TIMESTAMP WHERE np.note.id = :noteId AND np.member.id = :memberId")
+ int updateToOnline(@Param("noteId") Long noteId, @Param("memberId") Long memberId);
+
+ /**
+ * 노트 참여자의 온라인 상태 업데이트 (오프라인으로)
+ */
+ @Modifying
+ @Query("UPDATE NoteParticipant np SET np.isOnline = false WHERE np.note.id = :noteId AND np.member.id = :memberId")
+ int updateToOffline(@Param("noteId") Long noteId, @Param("memberId") Long memberId);
+
+ /**
+ * 특정 멤버의 모든 노트를 오프라인 상태로 변경 (연결 끊김 시)
+ */
+ @Modifying
+ @Query("UPDATE NoteParticipant np SET np.isOnline = false WHERE np.member.id = :memberId AND np.isOnline = true")
+ int updateAllToOfflineByMemberId(@Param("memberId") Long memberId);
+
+ /**
+ * 노트와 멤버 조합이 이미 존재하는지 확인
+ */
+ @Query("SELECT COUNT(np) > 0 FROM NoteParticipant np WHERE np.note.id = :noteId AND np.member.id = :memberId")
+ boolean existsByNoteIdAndMemberId(@Param("noteId") Long noteId, @Param("memberId") Long memberId);
+}
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
new file mode 100644
index 0000000..dd6f818
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java
@@ -0,0 +1,69 @@
+package com.project.syncly.domain.note.repository;
+
+import com.project.syncly.domain.note.entity.Note;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface NoteRepository extends JpaRepository {
+
+ /**
+ * 워크스페이스의 삭제되지 않은 노트 목록 조회 (페이징)
+ * EntityGraph를 사용하여 Creator를 eager loading
+ */
+ @EntityGraph(attributePaths = {"creator"})
+ @Query("SELECT n FROM Note n WHERE n.workspace.id = :workspaceId AND n.isDeleted = false")
+ Page findByWorkspaceId(@Param("workspaceId") Long workspaceId, Pageable pageable);
+
+ /**
+ * 워크스페이스의 삭제되지 않은 노트 목록 조회 (전체)
+ * EntityGraph를 사용하여 Creator를 eager loading
+ */
+ @EntityGraph(attributePaths = {"creator"})
+ @Query("SELECT n FROM Note n WHERE n.workspace.id = :workspaceId AND n.isDeleted = false")
+ List findAllByWorkspaceId(@Param("workspaceId") Long workspaceId);
+
+ /**
+ * 노트 ID로 조회 (삭제되지 않은 노트만)
+ * EntityGraph를 사용하여 Creator와 Workspace를 eager loading
+ */
+ @EntityGraph(attributePaths = {"creator", "workspace"})
+ @Query("SELECT n FROM Note n WHERE n.id = :noteId AND n.isDeleted = false")
+ Optional findByIdAndNotDeleted(@Param("noteId") Long noteId);
+
+ /**
+ * 워크스페이스와 노트 ID로 조회 (권한 검증용)
+ * EntityGraph를 사용하여 Creator를 eager loading
+ */
+ @EntityGraph(attributePaths = {"creator"})
+ @Query("SELECT n FROM Note n WHERE n.id = :noteId AND n.workspace.id = :workspaceId AND n.isDeleted = false")
+ Optional findByIdAndWorkspaceId(@Param("noteId") Long noteId, @Param("workspaceId") Long workspaceId);
+
+ /**
+ * 특정 멤버가 생성한 노트 목록 조회
+ */
+ @Query("SELECT n FROM Note n WHERE n.creator.id = :creatorId AND n.isDeleted = false")
+ List findByCreatorId(@Param("creatorId") Long creatorId);
+
+ /**
+ * 워크스페이스의 노트 개수 조회 (삭제된 것 제외)
+ */
+ @Query("SELECT COUNT(n) FROM Note n WHERE n.workspace.id = :workspaceId AND n.isDeleted = false")
+ Long countByWorkspaceId(@Param("workspaceId") Long workspaceId);
+
+ /**
+ * 제목으로 노트 검색 (워크스페이스 내)
+ * EntityGraph를 사용하여 Creator를 eager loading
+ */
+ @EntityGraph(attributePaths = {"creator"})
+ @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);
+}
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
new file mode 100644
index 0000000..8c7529e
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/scheduler/NoteAutoSaveScheduler.java
@@ -0,0 +1,475 @@
+package com.project.syncly.domain.note.scheduler;
+
+import com.project.syncly.domain.note.dto.NoteWebSocketDto;
+import com.project.syncly.domain.note.entity.Note;
+import com.project.syncly.domain.note.repository.NoteRepository;
+import com.project.syncly.domain.note.service.NoteRedisService;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import io.micrometer.core.instrument.Gauge;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.dao.OptimisticLockingFailureException;
+import org.springframework.data.redis.RedisConnectionFailureException;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * 노트 자동 저장 스케줄러
+ *
+ * 주기적으로 Redis의 dirty 플래그가 true인 노트들을 MySQL에 저장합니다.
+ *
+ *
실행 주기: 30초마다
+ *
+ *
처리 흐름:
+ *
+ * Redis에서 모든 dirty 노트 스캔 (SCAN 사용)
+ * 각 노트를 독립적인 트랜잭션으로 저장
+ * 저장 성공 시 dirty 플래그 false로 변경
+ * WebSocket으로 저장 완료 메시지 브로드캐스트
+ *
+ *
+ * 안정성 보장:
+ *
+ * 동시 실행 방지 (AtomicBoolean 플래그)
+ * 독립적인 트랜잭션 (한 노트 실패해도 다른 노트 저장 계속)
+ * Redis SCAN 사용 (KEYS 대신 - blocking 방지)
+ * 예외 처리 및 상세 로깅
+ *
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class NoteAutoSaveScheduler {
+
+ private final NoteRepository noteRepository;
+ private final NoteRedisService noteRedisService;
+ private final SimpMessagingTemplate messagingTemplate;
+ private final MeterRegistry meterRegistry;
+
+ /**
+ * 스케줄러 동시 실행 방지 플래그
+ */
+ private final AtomicBoolean isRunning = new AtomicBoolean(false);
+
+ /**
+ * 저장 성공/실패 카운터 (모니터링용)
+ */
+ private final AtomicInteger totalSavedCount = new AtomicInteger(0);
+ private final AtomicInteger totalFailedCount = new AtomicInteger(0);
+ private final AtomicInteger redisErrorCount = new AtomicInteger(0);
+
+ /**
+ * OptimisticLockException 재시도 설정
+ */
+ private static final int MAX_RETRY_ATTEMPTS = 3;
+ private static final long RETRY_DELAY_MS = 100; // 재시도 간격 (100ms)
+
+ /**
+ * 자동 저장 스케줄러 (30초마다 실행)
+ *
+ * 이전 실행이 완료되지 않았으면 현재 실행을 스킵합니다.
+ * Redis 오류 발생 시 로그 및 알람을 기록합니다.
+ */
+ @Scheduled(fixedDelay = 30000, initialDelay = 30000) // 30초마다, 최초 30초 후 시작
+ public void autoSaveNotes() {
+ // 동시 실행 방지
+ if (!isRunning.compareAndSet(false, true)) {
+ log.warn("이전 자동 저장이 아직 실행 중입니다. 현재 실행을 스킵합니다.");
+ return;
+ }
+
+ Timer.Sample sample = Timer.start(meterRegistry);
+ try {
+ log.debug("자동 저장 스케줄러 시작");
+
+ // Redis에서 모든 dirty 노트 ID 조회
+ Set dirtyNoteIds;
+ try {
+ dirtyNoteIds = noteRedisService.getAllDirtyNoteIds();
+ } catch (RedisConnectionFailureException e) {
+ log.error("Redis 연결 오류 발생: dirty 노트 조회 실패", e);
+ redisErrorCount.incrementAndGet();
+ meterRegistry.counter("note.autosave.redis.errors").increment();
+ alertRedisError(e);
+ return;
+ }
+
+ if (dirtyNoteIds.isEmpty()) {
+ log.debug("저장할 dirty 노트가 없습니다");
+ sample.stop(Timer.builder("note.autosave.duration")
+ .tag("status", "no_notes")
+ .register(meterRegistry));
+ return;
+ }
+
+ log.info("자동 저장 시작: {}개의 dirty 노트 발견", dirtyNoteIds.size());
+
+ int savedCount = 0;
+ int failedCount = 0;
+
+ // 각 노트를 독립적으로 저장
+ for (Long noteId : dirtyNoteIds) {
+ try {
+ boolean saved = saveNoteToDatabase(noteId);
+ if (saved) {
+ savedCount++;
+ } else {
+ failedCount++;
+ }
+ } catch (Exception e) {
+ log.error("노트 자동 저장 중 예외 발생: noteId={}", noteId, e);
+ failedCount++;
+ meterRegistry.counter("note.autosave.failures").increment();
+ }
+ }
+
+ // 통계 업데이트
+ totalSavedCount.addAndGet(savedCount);
+ totalFailedCount.addAndGet(failedCount);
+
+ // 메트릭 기록
+ meterRegistry.counter("note.autosave.success").increment(savedCount);
+ meterRegistry.counter("note.autosave.failures").increment(failedCount);
+ Gauge.builder("note.autosave.total.success", totalSavedCount::get)
+ .register(meterRegistry);
+ Gauge.builder("note.autosave.total.failures", totalFailedCount::get)
+ .register(meterRegistry);
+
+ log.info("자동 저장 완료: 성공={}, 실패={}, 총누적-성공={}, 총누적-실패={}",
+ savedCount, failedCount, totalSavedCount.get(), totalFailedCount.get());
+
+ sample.stop(Timer.builder("note.autosave.duration")
+ .tag("status", "completed")
+ .tag("saved", String.valueOf(savedCount))
+ .tag("failed", String.valueOf(failedCount))
+ .register(meterRegistry));
+
+ } catch (Exception e) {
+ log.error("자동 저장 스케줄러 실행 중 치명적 오류 발생", e);
+ meterRegistry.counter("note.autosave.critical.errors").increment();
+ sample.stop(Timer.builder("note.autosave.duration")
+ .tag("status", "error")
+ .register(meterRegistry));
+ } finally {
+ isRunning.set(false);
+ }
+ }
+
+ /**
+ * 단일 노트를 데이터베이스에 저장 (독립 트랜잭션)
+ *
+ * 각 노트는 독립적인 트랜잭션으로 처리되어,
+ * 한 노트의 저장 실패가 다른 노트에 영향을 주지 않습니다.
+ *
+ *
OptimisticLockException 발생 시 최대 3회까지 재시도합니다.
+ *
+ * @param noteId 저장할 노트 ID
+ * @return 저장 성공 여부
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public boolean saveNoteToDatabase(Long noteId) {
+ return saveNoteWithRetry(noteId, 0);
+ }
+
+ /**
+ * 재시도 로직을 포함한 노트 저장
+ *
+ * @param noteId 저장할 노트 ID
+ * @param retryCount 현재 재시도 횟수
+ * @return 저장 성공 여부
+ */
+ private boolean saveNoteWithRetry(Long noteId, int retryCount) {
+ try {
+ log.debug("노트 저장 시작: noteId={}, retryCount={}", noteId, retryCount);
+ Timer.Sample sampleSave = Timer.start(meterRegistry);
+
+ // 1. Redis에서 현재 상태 조회
+ String content;
+ try {
+ content = noteRedisService.getContent(noteId);
+ } catch (RedisConnectionFailureException 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);
+ noteRedisService.clearDirty(noteId);
+ sampleSave.stop(Timer.builder("note.autosave.save.time")
+ .tag("result", "no_content")
+ .register(meterRegistry));
+ return false;
+ }
+
+ int revision = noteRedisService.getRevision(noteId);
+
+ // 2. DB에서 Note 엔티티 조회
+ Note note = noteRepository.findById(noteId).orElse(null);
+ if (note == null) {
+ log.warn("DB에 노트가 존재하지 않습니다: noteId={}", noteId);
+ noteRedisService.clearDirty(noteId);
+ sampleSave.stop(Timer.builder("note.autosave.save.time")
+ .tag("result", "not_found")
+ .register(meterRegistry));
+ return false;
+ }
+
+ // 3. Content 업데이트
+ note.updateContent(content);
+
+ // 4. DB 저장
+ noteRepository.save(note);
+
+ // 5. Redis dirty 플래그 false로 변경
+ try {
+ noteRedisService.clearDirty(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);
+
+ // 6. WebSocket 브로드캐스트 (저장 완료 알림)
+ broadcastSaveCompleted(noteId, revision);
+
+ sampleSave.stop(Timer.builder("note.autosave.save.time")
+ .tag("result", "success")
+ .tag("retry_count", String.valueOf(retryCount))
+ .register(meterRegistry));
+
+ return true;
+
+ } catch (OptimisticLockingFailureException e) {
+ // OptimisticLockException 재시도 로직
+ log.warn("OptimisticLockException 발생: noteId={}, retryCount={}/{}",
+ noteId, retryCount, MAX_RETRY_ATTEMPTS, e);
+
+ if (retryCount < MAX_RETRY_ATTEMPTS) {
+ try {
+ Thread.sleep(RETRY_DELAY_MS * (retryCount + 1)); // 점진적 지연
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ log.error("재시도 대기 중 인터럽트됨: noteId={}", noteId);
+ }
+
+ meterRegistry.counter("note.autosave.retry.attempt")
+ .increment();
+
+ // 새로운 트랜잭션에서 재시도
+ try {
+ return performRetry(noteId, retryCount + 1);
+ } catch (Exception retryException) {
+ log.error("재시도 실패: noteId={}, retryCount={}", noteId, retryCount + 1, retryException);
+ return false;
+ }
+ } else {
+ log.error("최대 재시도 횟수 초과: noteId={}, maxRetries={}", noteId, MAX_RETRY_ATTEMPTS);
+ meterRegistry.counter("note.autosave.retry.failed").increment();
+ return false;
+ }
+
+ } catch (Exception e) {
+ log.error("노트 저장 실패: noteId={}, retryCount={}", noteId, retryCount, e);
+ meterRegistry.counter("note.autosave.save.errors").increment();
+ return false;
+ }
+ }
+
+ /**
+ * 새로운 트랜잭션에서 재시도 실행
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public boolean performRetry(Long noteId, int retryCount) {
+ return saveNoteWithRetry(noteId, retryCount);
+ }
+
+ /**
+ * WebSocket으로 저장 완료 메시지 브로드캐스트
+ */
+ private void broadcastSaveCompleted(Long noteId, int revision) {
+ try {
+ NoteWebSocketDto.SaveCompletedMessage message =
+ NoteWebSocketDto.SaveCompletedMessage.of(revision);
+
+ messagingTemplate.convertAndSend(
+ "/topic/notes/" + noteId + "/save",
+ message
+ );
+
+ log.debug("저장 완료 메시지 브로드캐스트: noteId={}, revision={}", noteId, revision);
+ } catch (Exception e) {
+ // WebSocket 브로드캐스트 실패는 치명적이지 않음
+ log.warn("저장 완료 메시지 브로드캐스트 실패: noteId={}", noteId, e);
+ }
+ }
+
+ /**
+ * 수동 저장 (API 호출용)
+ *
+ *
사용자가 명시적으로 저장 버튼을 클릭했을 때 호출됩니다.
+ * 자동 저장과 동일한 로직을 사용하되, 즉시 실행됩니다.
+ *
+ * @param noteId 저장할 노트 ID
+ * @return 저장 성공 여부
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public boolean saveNoteManually(Long noteId) {
+ log.info("수동 저장 요청: noteId={}", noteId);
+ Timer.Sample sample = Timer.start(meterRegistry);
+ try {
+ boolean result = saveNoteToDatabase(noteId);
+ sample.stop(Timer.builder("note.manual.save.time")
+ .tag("result", result ? "success" : "failure")
+ .register(meterRegistry));
+ return result;
+ } catch (Exception e) {
+ log.error("수동 저장 중 오류: noteId={}", noteId, e);
+ sample.stop(Timer.builder("note.manual.save.time")
+ .tag("result", "error")
+ .register(meterRegistry));
+ meterRegistry.counter("note.manual.save.errors").increment();
+ return false;
+ }
+ }
+
+ /**
+ * 애플리케이션 시작 시 모든 dirty 노트 즉시 저장
+ *
+ *
애플리케이션이 재시작되었을 때, Redis에 남아있는
+ * dirty 노트들을 즉시 DB에 저장하여 데이터 손실을 방지합니다.
+ */
+ @EventListener(ApplicationReadyEvent.class)
+ public void saveAllDirtyNotesOnStartup() {
+ log.info("애플리케이션 시작: 모든 dirty 노트 저장 시작");
+
+ try {
+ Set dirtyNoteIds;
+ try {
+ dirtyNoteIds = noteRedisService.getAllDirtyNoteIds();
+ } catch (RedisConnectionFailureException e) {
+ log.error("애플리케이션 시작 시 Redis 연결 오류", e);
+ redisErrorCount.incrementAndGet();
+ meterRegistry.counter("note.startup.redis.errors").increment();
+ alertRedisError(e);
+ return;
+ }
+
+ if (dirtyNoteIds.isEmpty()) {
+ log.info("저장할 dirty 노트가 없습니다");
+ return;
+ }
+
+ log.info("{}개의 dirty 노트 발견, 저장 시작", dirtyNoteIds.size());
+
+ int savedCount = 0;
+ int failedCount = 0;
+
+ for (Long noteId : dirtyNoteIds) {
+ try {
+ boolean saved = saveNoteToDatabase(noteId);
+ if (saved) {
+ savedCount++;
+ } else {
+ failedCount++;
+ }
+ } catch (Exception e) {
+ log.error("시작 시 노트 저장 실패: noteId={}", noteId, e);
+ failedCount++;
+ }
+ }
+
+ totalSavedCount.addAndGet(savedCount);
+ totalFailedCount.addAndGet(failedCount);
+
+ meterRegistry.counter("note.startup.saved").increment(savedCount);
+ meterRegistry.counter("note.startup.failed").increment(failedCount);
+
+ log.info("애플리케이션 시작 시 저장 완료: 성공={}, 실패={}, 총누적-성공={}, 총누적-실패={}",
+ savedCount, failedCount, totalSavedCount.get(), totalFailedCount.get());
+
+ } catch (Exception e) {
+ log.error("애플리케이션 시작 시 저장 중 오류 발생", e);
+ meterRegistry.counter("note.startup.critical.errors").increment();
+ }
+ }
+
+ /**
+ * Redis 연결 오류 알람
+ *
+ * Redis 연결에 실패했을 때 로그 및 알람을 전송합니다.
+ * 현재는 로그만 기록하지만, 향후 Slack, 이메일 등으로 확장 가능합니다.
+ *
+ * @param exception Redis 연결 예외
+ */
+ private void alertRedisError(Exception exception) {
+ String errorMessage = String.format(
+ "[CRITICAL] Redis 연결 오류 발생: %s | 시간: %s",
+ exception.getMessage(),
+ LocalDateTime.now()
+ );
+
+ log.error(errorMessage, exception);
+
+ // 향후 Slack, 이메일 등으로 확장 가능
+ // TODO: SlackNotificationService, EmailService 등을 주입받아 알람 전송
+ // slackNotificationService.sendAlert(errorMessage);
+ // emailService.sendAlert("admin@example.com", "Redis 연결 오류", errorMessage);
+ }
+
+ /**
+ * 저장 통계 조회 (모니터링용)
+ */
+ public String getStatistics() {
+ return String.format(
+ """
+ === 자동 저장 통계 ===
+ 총 저장 성공: %d
+ 총 저장 실패: %d
+ Redis 오류: %d
+ 성공률: %.2f%%
+ """,
+ totalSavedCount.get(),
+ totalFailedCount.get(),
+ redisErrorCount.get(),
+ calculateSuccessRate()
+ );
+ }
+
+ /**
+ * 성공률 계산
+ */
+ private double calculateSuccessRate() {
+ int total = totalSavedCount.get() + totalFailedCount.get();
+ if (total == 0) {
+ return 0.0;
+ }
+ return (totalSavedCount.get() * 100.0) / total;
+ }
+
+ /**
+ * 통계 초기화 (테스트용)
+ */
+ public void resetStatistics() {
+ totalSavedCount.set(0);
+ totalFailedCount.set(0);
+ redisErrorCount.set(0);
+ log.info("저장 통계 초기화됨");
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/scheduler/NoteImageCleanupScheduler.java b/src/main/java/com/project/syncly/domain/note/scheduler/NoteImageCleanupScheduler.java
new file mode 100644
index 0000000..452b86d
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/scheduler/NoteImageCleanupScheduler.java
@@ -0,0 +1,69 @@
+package com.project.syncly.domain.note.scheduler;
+
+import com.project.syncly.domain.note.service.NoteImageService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 노트 이미지 정리 스케줄러
+ *
+ *
만료된 PENDING 상태의 이미지를 주기적으로 정리합니다.
+ *
+ *
정리 대상:
+ *
+ * uploadStatus = PENDING
+ * expiresAt < 현재 시간
+ *
+ *
+ * 실행 주기: 10분마다 (cron: "0 *\/10 * * * *")
+ *
+ *
처리 내용:
+ *
+ * 만료된 PENDING 이미지 조회
+ * S3 객체 삭제
+ * DB 레코드 삭제
+ *
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class NoteImageCleanupScheduler {
+
+ private final NoteImageService noteImageService;
+
+ /**
+ * 만료된 PENDING 이미지 정리
+ *
+ * 10분마다 실행되어 다음 작업을 수행합니다:
+ *
+ * PENDING 상태이고 expiresAt이 지난 이미지 조회
+ * S3에서 객체 삭제
+ * DB에서 레코드 삭제
+ *
+ *
+ * 왜 필요한가?
+ * 클라이언트가 Presigned URL을 받았지만 실제로 업로드하지 않은 경우,
+ * DB에 PENDING 상태의 레코드가 남게 됩니다. 이를 주기적으로 정리하여
+ * 불필요한 데이터 누적을 방지합니다.
+ *
+ *
실행 시간: 매 시 0분, 10분, 20분, 30분, 40분, 50분
+ */
+ @Scheduled(cron = "0 */10 * * * *") // 10분마다 실행
+ public void cleanupExpiredImages() {
+ log.debug("만료된 노트 이미지 정리 스케줄러 시작");
+
+ try {
+ int deletedCount = noteImageService.cleanupExpiredImages();
+
+ if (deletedCount > 0) {
+ log.info("만료된 노트 이미지 정리 완료: {}개 삭제", deletedCount);
+ } else {
+ log.debug("정리할 만료 이미지 없음");
+ }
+ } catch (Exception e) {
+ log.error("만료된 노트 이미지 정리 중 오류 발생", e);
+ }
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/service/NoteImageService.java b/src/main/java/com/project/syncly/domain/note/service/NoteImageService.java
new file mode 100644
index 0000000..39e9ad2
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/service/NoteImageService.java
@@ -0,0 +1,277 @@
+package com.project.syncly.domain.note.service;
+
+import com.project.syncly.domain.member.entity.Member;
+import com.project.syncly.domain.member.repository.MemberRepository;
+import com.project.syncly.domain.note.dto.NoteImageDto;
+import com.project.syncly.domain.note.entity.Note;
+import com.project.syncly.domain.note.entity.NoteImage;
+import com.project.syncly.domain.note.exception.NoteErrorCode;
+import com.project.syncly.domain.note.exception.NoteException;
+import com.project.syncly.domain.note.repository.NoteImageRepository;
+import com.project.syncly.domain.note.repository.NoteRepository;
+import com.project.syncly.domain.s3.dto.S3RequestDTO;
+import com.project.syncly.domain.s3.dto.S3ResponseDTO;
+import com.project.syncly.domain.s3.enums.FileMimeType;
+import com.project.syncly.domain.s3.service.S3Service;
+import com.project.syncly.domain.s3.util.S3Util;
+import com.project.syncly.domain.workspaceMember.entity.WorkspaceMember;
+import com.project.syncly.domain.workspaceMember.repository.WorkspaceMemberRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 노트 이미지 업로드 서비스
+ *
+ *
S3 Presigned URL을 활용한 이미지 업로드 기능을 제공합니다.
+ *
+ *
업로드 플로우:
+ *
+ * 클라이언트: POST /presigned 요청 → uploadUrl, imageId 받음
+ * 클라이언트: uploadUrl로 PUT 요청 (파일 직접 업로드)
+ * 클라이언트: POST /images/{imageId}/confirm → imageUrl 받음
+ * 클라이언트: imageUrl을 마크다운에 삽입
+ * 클라이언트: WebSocket EDIT 메시지로 content 업데이트
+ *
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class NoteImageService {
+
+ private final NoteRepository noteRepository;
+ private final NoteImageRepository noteImageRepository;
+ private final MemberRepository memberRepository;
+ private final WorkspaceMemberRepository workspaceMemberRepository;
+ private final S3Service s3Service;
+ private final S3Util s3Util;
+
+ @Value("${aws.cloudfront.domain}")
+ private String cloudFrontDomain;
+
+ /**
+ * Presigned URL 유효 기간 (분)
+ */
+ private static final int PRESIGNED_URL_EXPIRY_MINUTES = 5;
+
+ /**
+ * 이미지 업로드용 Presigned URL 생성
+ *
+ * 검증 항목:
+ *
+ * 노트 존재 여부 및 워크스페이스 멤버 권한 확인
+ * 파일 타입 검증 (image/* 형식)
+ * 파일 크기 제한 (최대 10MB)
+ * 파일명 sanitize (특수문자 제거)
+ *
+ *
+ * @param noteId 노트 ID
+ * @param memberId 업로드하는 Member ID
+ * @param request Presigned URL 요청 (filename, contentType, fileSize)
+ * @return Presigned URL 응답 (uploadUrl, imageId, objectKey, expiresAt)
+ * @throws NoteException 노트를 찾을 수 없거나 권한이 없는 경우
+ * @throws NoteException 파일 타입이 이미지가 아니거나 크기가 초과된 경우
+ */
+ @Transactional
+ public NoteImageDto.PresignedUrlResponse generateUploadUrl(
+ Long noteId,
+ Long memberId,
+ NoteImageDto.PresignedUrlRequest request
+ ) {
+ log.info("Presigned URL 생성 시작: noteId={}, memberId={}, filename={}",
+ noteId, memberId, request.filename());
+
+ // 1. 노트 존재 및 권한 검증
+ Note note = noteRepository.findById(noteId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ Member member = memberRepository.findById(memberId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ // 워크스페이스 멤버 권한 확인
+ WorkspaceMember workspaceMember = workspaceMemberRepository
+ .findByWorkspaceIdAndMemberId(note.getWorkspace().getId(), memberId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER));
+
+ // 2. 파일 크기 검증
+ request.validateFileSize();
+
+ // 3. 파일명 sanitize
+ String sanitizedFilename = request.sanitizeFilename();
+
+ // 4. S3Service를 통해 Presigned URL 생성 (기존 인프라 재사용)
+ FileMimeType mimeType = FileMimeType.fromKey(request.contentType());
+ S3RequestDTO.NoteImageUploadPreSignedUrl s3Request =
+ new S3RequestDTO.NoteImageUploadPreSignedUrl(noteId, sanitizedFilename, mimeType);
+
+ S3ResponseDTO.PreSignedUrl s3Response = s3Service.generatePresignedPutUrl(memberId, s3Request);
+
+ // 5. NoteImage 엔티티 생성 (PENDING 상태)
+ LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(PRESIGNED_URL_EXPIRY_MINUTES);
+
+ NoteImage noteImage = NoteImage.builder()
+ .note(note)
+ .uploader(member)
+ .objectKey(s3Response.objectKey())
+ .originalFilename(sanitizedFilename)
+ .contentType(request.contentType())
+ .fileSize(request.fileSize())
+ .uploadStatus(NoteImage.UploadStatus.PENDING)
+ .expiresAt(expiresAt)
+ .build();
+
+ noteImage = noteImageRepository.save(noteImage);
+
+ log.info("Presigned URL 생성 완료: imageId={}, objectKey={}", noteImage.getId(), s3Response.objectKey());
+
+ return new NoteImageDto.PresignedUrlResponse(
+ s3Response.uploadUrl(),
+ noteImage.getId(),
+ s3Response.objectKey(),
+ expiresAt
+ );
+ }
+
+ /**
+ * 이미지 업로드 완료 확인 및 공개 URL 생성
+ *
+ * S3에 객체가 실제로 업로드되었는지 확인하고, COMPLETED 상태로 변경합니다.
+ * CloudFront URL 또는 S3 공개 URL을 생성하여 반환합니다.
+ *
+ * @param noteId 노트 ID
+ * @param imageId 이미지 ID
+ * @param memberId 업로드한 Member ID
+ * @return 이미지 URL 응답 (imageUrl, markdownSyntax)
+ * @throws NoteException 이미지를 찾을 수 없거나 업로드가 완료되지 않은 경우
+ */
+ @Transactional
+ public NoteImageDto.ImageUrlResponse confirmUpload(Long noteId, Long imageId, Long memberId) {
+ log.info("이미지 업로드 확인 시작: noteId={}, imageId={}, memberId={}", noteId, imageId, memberId);
+
+ // 1. NoteImage 조회
+ NoteImage noteImage = noteImageRepository.findByIdAndNoteId(imageId, noteId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.IMAGE_NOT_FOUND));
+
+ // 2. 업로더 확인
+ if (!noteImage.getUploader().getId().equals(memberId)) {
+ throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED);
+ }
+
+ // 3. 만료 시간 검증
+ if (noteImage.getExpiresAt() != null && LocalDateTime.now().isAfter(noteImage.getExpiresAt())) {
+ noteImage.markAsFailed();
+ noteImageRepository.save(noteImage);
+ throw new NoteException(NoteErrorCode.IMAGE_UPLOAD_FAILED, "Presigned URL이 만료되었습니다");
+ }
+
+ // 4. S3 객체 존재 여부 확인
+ if (!s3Util.objectExists(noteImage.getObjectKey())) {
+ log.warn("S3 객체가 존재하지 않음: objectKey={}", noteImage.getObjectKey());
+ noteImage.markAsFailed();
+ noteImageRepository.save(noteImage);
+ throw new NoteException(NoteErrorCode.IMAGE_UPLOAD_FAILED, "이미지 업로드가 완료되지 않았습니다");
+ }
+
+ // 5. CloudFront URL 생성
+ String imageUrl = String.format("https://%s/%s", cloudFrontDomain, noteImage.getObjectKey());
+
+ // 6. COMPLETED 상태로 변경
+ noteImage.markAsCompleted(imageUrl);
+ noteImageRepository.save(noteImage);
+
+ log.info("이미지 업로드 확인 완료: imageId={}, imageUrl={}", imageId, imageUrl);
+
+ return NoteImageDto.ImageUrlResponse.from(imageUrl, noteImage.getOriginalFilename());
+ }
+
+ /**
+ * 이미지 삭제
+ *
+ *
S3 객체와 DB 레코드를 모두 삭제합니다.
+ *
+ * @param noteId 노트 ID
+ * @param imageId 이미지 ID
+ * @param memberId 삭제 요청한 Member ID
+ * @return 삭제 응답
+ * @throws NoteException 이미지를 찾을 수 없거나 권한이 없는 경우
+ */
+ @Transactional
+ public NoteImageDto.ImageDeleteResponse deleteImage(Long noteId, Long imageId, Long memberId) {
+ log.info("이미지 삭제 시작: noteId={}, imageId={}, memberId={}", noteId, imageId, memberId);
+
+ // 1. NoteImage 조회
+ NoteImage noteImage = noteImageRepository.findByIdAndNoteId(imageId, noteId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.IMAGE_NOT_FOUND));
+
+ // 2. 권한 확인 (업로더 본인 또는 노트 작성자)
+ Note note = noteImage.getNote();
+ if (!noteImage.getUploader().getId().equals(memberId)
+ && !note.getCreator().getId().equals(memberId)) {
+ throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED);
+ }
+
+ // 3. S3 객체 삭제
+ try {
+ s3Util.delete(noteImage.getObjectKey());
+ log.debug("S3 객체 삭제 완료: objectKey={}", noteImage.getObjectKey());
+ } catch (Exception e) {
+ log.warn("S3 객체 삭제 실패 (계속 진행): objectKey={}, error={}",
+ noteImage.getObjectKey(), e.getMessage());
+ }
+
+ // 4. DB 레코드 삭제
+ noteImageRepository.delete(noteImage);
+
+ log.info("이미지 삭제 완료: imageId={}", imageId);
+
+ return NoteImageDto.ImageDeleteResponse.of(imageId);
+ }
+
+ /**
+ * 만료된 PENDING 이미지 정리 (스케줄러용)
+ *
+ *
PENDING 상태이고 expiresAt이 지난 이미지들을 S3와 DB에서 삭제합니다.
+ *
+ * @return 정리된 이미지 개수
+ */
+ @Transactional
+ public int cleanupExpiredImages() {
+ log.debug("만료된 PENDING 이미지 정리 시작");
+
+ LocalDateTime now = LocalDateTime.now();
+ List expiredImages = noteImageRepository.findExpiredPendingImages(now);
+
+ if (expiredImages.isEmpty()) {
+ log.debug("정리할 만료 이미지 없음");
+ return 0;
+ }
+
+ log.info("만료된 이미지 {}개 발견, 정리 시작", expiredImages.size());
+
+ int deletedCount = 0;
+ for (NoteImage image : expiredImages) {
+ try {
+ // S3 객체 삭제
+ s3Util.delete(image.getObjectKey());
+ log.debug("S3 객체 삭제: objectKey={}", image.getObjectKey());
+
+ // DB 레코드 삭제
+ noteImageRepository.delete(image);
+
+ deletedCount++;
+ } catch (Exception e) {
+ log.error("이미지 정리 실패: imageId={}, objectKey={}, error={}",
+ image.getId(), image.getObjectKey(), e.getMessage());
+ }
+ }
+
+ log.info("만료된 이미지 정리 완료: 총 {}개 중 {}개 삭제", expiredImages.size(), deletedCount);
+
+ return deletedCount;
+ }
+}
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
new file mode 100644
index 0000000..c5c6b4b
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/service/NoteRedisService.java
@@ -0,0 +1,820 @@
+package com.project.syncly.domain.note.service;
+
+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;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 노트의 실시간 협업 데이터를 Redis에서 관리하는 서비스
+ *
+ * Redis를 사용하는 이유:
+ *
+ * MySQL: 영구 저장용 (자동 저장 시 최종 상태만 저장)
+ * Redis: 실시간 작업용 (편집 중인 임시 데이터, 빠른 읽기/쓰기)
+ *
+ *
+ * 관리하는 데이터:
+ *
+ * content: 현재 편집 중인 노트 내용 (String)
+ * users: 접속 중인 사용자 목록 (Set)
+ * cursors: 각 사용자의 커서 위치 (Hash)
+ * dirty: 변경사항 있는지 플래그 (Boolean)
+ * revision: 문서 버전 번호 (Integer)
+ * operations: 편집 연산 히스토리 (List, 최근 100개)
+ *
+ *
+ * TTL (Time To Live):
+ *
+ * 모든 키는 24시간 후 자동 삭제
+ * 활동이 있을 때마다 TTL 갱신 (refreshTTL)
+ * 24시간 동안 아무도 편집 안 하면 자동 정리 → 메모리 절약
+ *
+ *
+ * @see CursorPosition
+ * @see EditOperation
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class NoteRedisService {
+
+ private final RedisStorage redisStorage;
+ private final RedisTemplate redisTemplate;
+ private final ObjectMapper redisObjectMapper;
+
+ // TTL 설정: 24시간 동안 활동 없으면 자동 삭제
+ private static final Duration NOTE_TTL = Duration.ofHours(24);
+
+ // Operation 히스토리 최대 개수: 메모리 절약을 위해 최근 100개만 유지
+ private static final int MAX_OPERATIONS_HISTORY = 100;
+
+ // ==================== Content 관리 ====================
+
+ /**
+ * 노트 내용 조회
+ *
+ * Redis Key: NOTE:CONTENT:{noteId}
+ *
+ *
사용 시나리오:
+ *
+ * // 사용자가 노트에 입장했을 때
+ * String content = redisService.getContent(noteId);
+ * if (content == null) {
+ * // Redis에 없으면 DB에서 조회 후 초기화
+ * Note note = noteRepository.findById(noteId);
+ * redisService.initializeNote(noteId, note.getContent());
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @return 노트 내용 (없으면 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;
+ }
+
+ /**
+ * 노트 내용 저장 및 dirty 플래그 자동 설정
+ *
+ * Redis Key: NOTE:CONTENT:{noteId}
+ *
+ *
중요: 이 메서드를 호출하면 자동으로 dirty 플래그가 true로 설정됩니다.
+ * dirty 플래그는 스케줄러가 "DB에 저장해야 할 노트"를 찾는 데 사용됩니다.
+ *
+ *
사용 시나리오:
+ *
+ * // 사용자가 편집했을 때
+ * String currentContent = redisService.getContent(noteId);
+ * String newContent = applyEdit(currentContent, operation);
+ * redisService.setContent(noteId, newContent); // dirty 자동 설정
+ *
+ * // 30초 후 스케줄러가 자동으로 DB 저장
+ *
+ *
+ * @param noteId 노트 ID
+ * @param content 저장할 내용
+ */
+ public void setContent(Long noteId, String content) {
+ String key = RedisKeyPrefix.NOTE_CONTENT.get(noteId);
+ redisStorage.setValueAsString(key, content, NOTE_TTL);
+ setDirty(noteId, true); // 변경사항 있음을 표시
+ log.debug("Set note content: noteId={}, length={}", noteId, content != null ? content.length() : 0);
+ }
+
+ // ==================== 사용자 관리 (Set) ====================
+
+ /**
+ * 접속 중인 사용자 목록 조회
+ *
+ * Redis Key: NOTE:USERS:{noteId}
+ *
Redis Type: Set (중복 없는 집합)
+ *
+ *
Set에 저장되는 값: workspaceMemberId (문자열)
+ *
예: {"123", "456", "789"}
+ *
+ *
사용 시나리오:
+ *
+ * // UI에 "3명이 함께 편집 중" 표시
+ * Set users = redisService.getActiveUsers(noteId);
+ * int count = users.size(); // 3
+ *
+ * // 각 사용자 정보 조회 (workspaceMemberId → WorkspaceMember)
+ * for (String wmId : users) {
+ * WorkspaceMember wm = workspaceMemberRepository.findById(Long.valueOf(wmId));
+ * // wm.getName(), wm.getProfileImage() 사용
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @return 접속 중인 사용자의 workspaceMemberId 집합
+ */
+ public Set getActiveUsers(Long noteId) {
+ String key = RedisKeyPrefix.NOTE_USERS.get(noteId);
+ Set users = redisStorage.getSetValues(key);
+ log.debug("Get active users: noteId={}, count={}", noteId, users != null ? users.size() : 0);
+ return users != null ? users : new HashSet<>();
+ }
+
+ /**
+ * 사용자 추가 (노트 입장 시)
+ *
+ * Redis Command: SADD NOTE:USERS:{noteId} {workspaceMemberId}
+ *
+ *
Set 자료구조 특징:
+ *
+ * 중복 자동 제거: 같은 사용자가 여러 번 추가해도 한 번만 저장
+ * O(1) 시간 복잡도: 매우 빠른 추가/삭제/조회
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // WebSocket ENTER 메시지 처리
+ * {@literal @}MessageMapping("/note.{noteId}.enter")
+ * public void handleEnter(Long noteId, Principal principal) {
+ * Long workspaceMemberId = getWorkspaceMemberId(principal);
+ * redisService.addUser(noteId, workspaceMemberId);
+ *
+ * Set activeUsers = redisService.getActiveUsers(noteId);
+ * broadcast("user joined", activeUsers);
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @param workspaceMemberId WorkspaceMember ID (이메일 대신 사용)
+ */
+ public void addUser(Long noteId, Long workspaceMemberId) {
+ String key = RedisKeyPrefix.NOTE_USERS.get(noteId);
+ redisStorage.addToSet(key, String.valueOf(workspaceMemberId));
+ refreshTTL(key);
+ log.info("User added to note: noteId={}, workspaceMemberId={}", noteId, workspaceMemberId);
+ }
+
+ /**
+ * 사용자 제거 (노트 퇴장 시)
+ *
+ * Redis Command: SREM NOTE:USERS:{noteId} {workspaceMemberId}
+ *
+ *
사용 시나리오:
+ *
+ * // WebSocket LEAVE 메시지 처리
+ * {@literal @}MessageMapping("/note.{noteId}.leave")
+ * public void handleLeave(Long noteId, Principal principal) {
+ * Long workspaceMemberId = getWorkspaceMemberId(principal);
+ * redisService.removeUser(noteId, workspaceMemberId);
+ *
+ * Set activeUsers = redisService.getActiveUsers(noteId);
+ * broadcast("user left", activeUsers);
+ * }
+ *
+ * // WebSocket 연결 끊김 시 자동 처리
+ * {@literal @}EventListener
+ * public void onDisconnect(SessionDisconnectEvent event) {
+ * // 해당 사용자를 모든 참여 중인 노트에서 제거
+ * redisService.removeUser(noteId, workspaceMemberId);
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @param workspaceMemberId WorkspaceMember ID
+ */
+ public void removeUser(Long noteId, Long workspaceMemberId) {
+ String key = RedisKeyPrefix.NOTE_USERS.get(noteId);
+ redisStorage.removeFromSet(key, String.valueOf(workspaceMemberId));
+ log.info("User removed from note: noteId={}, workspaceMemberId={}", noteId, workspaceMemberId);
+ }
+
+ // ==================== 커서 관리 (Hash) ====================
+
+ /**
+ * 모든 사용자의 커서 정보 조회
+ *
+ * Redis Key: NOTE:CURSORS:{noteId}
+ *
Redis Type: Hash (key-value 쌍의 집합)
+ *
+ *
Hash 구조:
+ *
+ * NOTE:CURSORS:123 = {
+ * "456": '{"position":10,"range":0,"userName":"홍길동","profileImage":"...","color":"#FF6B6B"}',
+ * "789": '{"position":25,"range":5,"userName":"김철수","profileImage":"...","color":"#4ECDC4"}'
+ * }
+ *
+ *
+ * Hash를 사용하는 이유:
+ *
+ * 사용자별로 개별 업데이트 가능 (한 사용자 커서만 업데이트해도 다른 사용자 영향 없음)
+ * HGETALL 명령으로 모든 커서를 한 번에 조회 가능
+ * HDEL 명령으로 특정 사용자 커서만 삭제 가능
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // 노트 입장 시 모든 사용자의 커서 위치 가져오기
+ * Map allCursors = redisService.getAllCursors(noteId);
+ *
+ * // UI에 다른 사용자들의 커서 표시
+ * for (CursorPosition cursor : allCursors.values()) {
+ * editor.showRemoteCursor(
+ * cursor.getPosition(),
+ * cursor.getUserName(),
+ * cursor.getColor()
+ * );
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @return Map
+ */
+ public Map getAllCursors(Long noteId) {
+ String key = RedisKeyPrefix.NOTE_CURSORS.get(noteId);
+ Map hashEntries = redisStorage.getHash(key);
+
+ if (hashEntries == null || hashEntries.isEmpty()) {
+ return new HashMap<>();
+ }
+
+ // Hash에서 가져온 JSON 문자열을 CursorPosition 객체로 변환
+ Map cursors = new HashMap<>();
+ for (Map.Entry entry : hashEntries.entrySet()) {
+ try {
+ String json = entry.getValue().toString();
+ CursorPosition cursor = redisObjectMapper.readValue(json, CursorPosition.class);
+ cursors.put(entry.getKey(), cursor);
+ } catch (JsonProcessingException e) {
+ log.error("Failed to deserialize cursor position: workspaceMemberId={}", entry.getKey(), e);
+ }
+ }
+
+ log.debug("Get all cursors: noteId={}, count={}", noteId, cursors.size());
+ return cursors;
+ }
+
+ /**
+ * 커서 위치 저장
+ *
+ * Redis Command: HSET NOTE:CURSORS:{noteId} {workspaceMemberId} {JSON}
+ *
+ *
JSON 직렬화:
+ *
+ * CursorPosition 객체 → JSON 문자열로 변환
+ * Jackson ObjectMapper 사용 (LocalDateTime 자동 처리)
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // WebSocket CURSOR 메시지 처리
+ * {@literal @}MessageMapping("/note.{noteId}.cursor")
+ * public void handleCursor(Long noteId, CursorUpdateRequest request) {
+ * Long workspaceMemberId = getCurrentWorkspaceMemberId();
+ * WorkspaceMember wm = workspaceMemberRepository.findById(workspaceMemberId);
+ *
+ * CursorPosition cursor = CursorPosition.builder()
+ * .position(request.position()) // 클라이언트가 보낸 위치
+ * .range(request.range()) // 선택 영역 길이
+ * .workspaceMemberId(wm.getId())
+ * .userName(wm.getName()) // WorkspaceMember에서 이름
+ * .profileImage(wm.getProfileImage())
+ * .color(assignColor(wm.getId()))
+ * .build();
+ *
+ * redisService.setCursor(noteId, workspaceMemberId, cursor);
+ *
+ * // 다른 사용자들에게 브로드캐스트
+ * messagingTemplate.convertAndSend("/topic/note." + noteId, cursor);
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @param workspaceMemberId WorkspaceMember ID
+ * @param cursor 커서 위치 정보 (position, range, userName, profileImage, color 포함)
+ */
+ public void setCursor(Long noteId, Long workspaceMemberId, CursorPosition cursor) {
+ String key = RedisKeyPrefix.NOTE_CURSORS.get(noteId);
+ try {
+ String json = redisObjectMapper.writeValueAsString(cursor);
+ redisStorage.updateHashField(key, String.valueOf(workspaceMemberId), json);
+ refreshTTL(key);
+ log.debug("Set cursor: noteId={}, workspaceMemberId={}, position={}", noteId, workspaceMemberId, cursor.getPosition());
+ } catch (JsonProcessingException e) {
+ log.error("Failed to serialize cursor position", e);
+ }
+ }
+
+ /**
+ * 커서 정보 삭제 (퇴장 시)
+ *
+ * Redis Command: HDEL NOTE:CURSORS:{noteId} {workspaceMemberId}
+ *
+ *
사용 시나리오:
+ *
+ * // 사용자 퇴장 시 커서도 함께 제거
+ * {@literal @}MessageMapping("/note.{noteId}.leave")
+ * public void handleLeave(Long noteId, Principal principal) {
+ * Long workspaceMemberId = getWorkspaceMemberId(principal);
+ *
+ * redisService.removeUser(noteId, workspaceMemberId);
+ * redisService.removeCursor(noteId, workspaceMemberId); // 커서도 제거
+ *
+ * // 다른 사용자 화면에서 커서 사라짐
+ * broadcast("cursor removed", workspaceMemberId);
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @param workspaceMemberId WorkspaceMember ID
+ */
+ public void removeCursor(Long noteId, Long workspaceMemberId) {
+ String key = RedisKeyPrefix.NOTE_CURSORS.get(noteId);
+ redisTemplate.opsForHash().delete(key, String.valueOf(workspaceMemberId));
+ log.debug("Remove cursor: noteId={}, workspaceMemberId={}", noteId, workspaceMemberId);
+ }
+
+ // ==================== Dirty 플래그 (자동 저장용) ====================
+
+ /**
+ * dirty 플래그 확인
+ *
+ * Redis Key: NOTE:DIRTY:{noteId}
+ *
Redis Value: "true" 또는 "false" (문자열)
+ *
+ *
dirty 플래그란?
+ *
+ * true: Redis에 변경사항이 있어서 DB에 저장 필요
+ * false: 변경사항 없거나 이미 DB에 저장됨
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // 스케줄러(10단계)가 30초마다 실행
+ * {@literal @}Scheduled(fixedDelay = 30000)
+ * public void autoSave() {
+ * Set dirtyNoteIds = redisService.getAllDirtyNoteIds();
+ *
+ * for (Long noteId : dirtyNoteIds) {
+ * if (redisService.isDirty(noteId)) {
+ * String content = redisService.getContent(noteId);
+ * noteRepository.updateContent(noteId, content); // DB 저장
+ * redisService.clearDirty(noteId); // 플래그 초기화
+ * }
+ * }
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @return true: 변경사항 있음, false: 변경사항 없음
+ */
+ public boolean isDirty(Long noteId) {
+ String key = RedisKeyPrefix.NOTE_DIRTY.get(noteId);
+ String value = redisStorage.getValueAsString(key);
+ return "true".equals(value);
+ }
+
+ /**
+ * dirty 플래그 설정
+ *
+ * 주의: 이 메서드는 보통 직접 호출하지 않습니다.
+ * setContent() 메서드가 자동으로 호출합니다.
+ *
+ * @param noteId 노트 ID
+ * @param dirty true: 변경사항 있음, false: 변경사항 없음
+ */
+ public void setDirty(Long noteId, boolean dirty) {
+ String key = RedisKeyPrefix.NOTE_DIRTY.get(noteId);
+ redisStorage.setValueAsString(key, String.valueOf(dirty), NOTE_TTL);
+ log.debug("Set dirty flag: noteId={}, dirty={}", noteId, dirty);
+ }
+
+ /**
+ * dirty 플래그 초기화 (DB 저장 완료 후)
+ *
+ *
사용 시나리오:
+ *
+ * // 스케줄러가 DB 저장 후 호출
+ * String content = redisService.getContent(noteId);
+ * noteRepository.updateContent(noteId, content); // DB 저장
+ * redisService.clearDirty(noteId); // 저장 완료 표시
+ *
+ *
+ * @param noteId 노트 ID
+ */
+ 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용) ====================
+
+ /**
+ * operations 리스트에 추가 (최근 100개만 유지)
+ *
+ * Redis Key: NOTE:OPERATIONS:{noteId}
+ *
Redis Type: List (순서가 있는 리스트)
+ *
+ *
List 구조:
+ *
+ * NOTE:OPERATIONS:123 = [
+ * '{"type":"insert","position":5,"revision":5,...}', // 가장 오래된 연산
+ * '{"type":"delete","position":10,"revision":6,...}',
+ * ...
+ * '{"type":"insert","position":20,"revision":104,...}' // 가장 최근 연산
+ * ]
+ *
+ *
+ * 최근 100개만 유지하는 이유:
+ *
+ * 메모리 절약: 무한정 쌓이면 메모리 부족
+ * 충분한 히스토리: 대부분의 충돌은 최근 몇 개 연산으로 해결
+ * 오래된 연산은 이미 DB에 저장되어 복구 가능
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // 편집 연산 적용 후 히스토리에 추가
+ * EditOperation operation = EditOperation.insert(5, "Hello", 42, workspaceMemberId);
+ * redisService.addOperation(noteId, operation);
+ *
+ * // 나중에 다른 사용자가 충돌 해결 시 사용
+ * List history = redisService.getOperations(noteId, 40);
+ *
+ *
+ * @param noteId 노트 ID
+ * @param operation 추가할 편집 연산
+ */
+ 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);
+ }
+ }
+
+ /**
+ * 특정 revision 이후의 operations 조회
+ *
+ * OT Transform에서 사용:
+ *
+ * 클라이언트가 revision 5를 기준으로 편집했는데
+ * 서버는 이미 revision 8이면
+ * revision 6, 7, 8 연산을 가져와서 클라이언트 연산과 transform
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // 클라이언트가 보낸 연산
+ * EditOperation clientOp = ...; // revision = 5
+ * int serverRevision = redisService.getRevision(noteId); // 8
+ *
+ * if (clientOp.getRevision() < serverRevision) {
+ * // revision 6, 7, 8 연산 가져오기
+ * List missedOps = redisService.getOperations(noteId, 6);
+ *
+ * // Transform (6단계에서 구현)
+ * for (EditOperation missedOp : missedOps) {
+ * clientOp = OTEngine.transform(clientOp, missedOp);
+ * }
+ *
+ * // 변환된 연산 적용
+ * String content = OTEngine.apply(currentContent, clientOp);
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @param fromRevision 이 revision 이후의 연산만 조회 (inclusive)
+ * @return 필터링된 연산 리스트 (시간순 정렬)
+ */
+ 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());
+ }
+
+ // ==================== 초기화 및 삭제 ====================
+
+ /**
+ * 노트 최초 진입 시 Redis 초기화
+ *
+ * 초기화하는 데이터:
+ *
+ * content: DB에서 가져온 내용
+ * revision: 0으로 시작
+ * dirty: false (변경사항 없음)
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // 첫 사용자가 노트 입장 시
+ * {@literal @}MessageMapping("/note.{noteId}.enter")
+ * public void handleEnter(Long noteId) {
+ * String content = redisService.getContent(noteId);
+ *
+ * if (content == null) {
+ * // Redis에 없으면 DB에서 로드 후 초기화
+ * Note note = noteRepository.findById(noteId).orElseThrow();
+ * redisService.initializeNote(noteId, note.getContent());
+ * }
+ *
+ * // 이후 사용자들은 Redis에서 바로 조회
+ * }
+ *
+ *
+ * @param noteId 노트 ID
+ * @param content 초기 내용 (DB에서 가져온 값, null이면 빈 문자열)
+ */
+ public void initializeNote(Long noteId, String content) {
+ 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);
+
+ // dirty 플래그 초기화 (변경사항 없음)
+ clearDirty(noteId);
+
+ log.info("Note initialized successfully: noteId={}", noteId);
+ }
+
+ /**
+ * 노트 관련 모든 Redis 데이터 삭제
+ *
+ * 삭제되는 데이터:
+ *
+ * content: 노트 내용
+ * users: 접속 중인 사용자
+ * cursors: 커서 위치
+ * dirty: dirty 플래그
+ * revision: 버전 번호
+ * operations: 연산 히스토리
+ *
+ *
+ * 사용 시나리오:
+ *
+ * // 노트 삭제 시
+ * {@literal @}Transactional
+ * public void deleteNote(Long noteId) {
+ * noteRepository.deleteById(noteId); // DB 삭제
+ * redisService.deleteNoteData(noteId); // Redis 정리
+ * }
+ *
+ * // 또는 마지막 사용자가 퇴장하고 24시간 후 TTL로 자동 삭제
+ *
+ *
+ * @param noteId 노트 ID
+ */
+ 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_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);
+ }
+
+ // ==================== 유틸리티 ====================
+
+ /**
+ * TTL 갱신 (활동 있을 때마다)
+ *
+ * TTL (Time To Live):
+ *
+ * Redis 키가 자동으로 삭제되는 시간
+ * 이 메서드를 호출하면 24시간으로 리셋
+ * 활동이 없으면 24시간 후 자동 삭제 → 메모리 절약
+ *
+ *
+ * 예시:
+ *
+ * 10:00 - 노트 생성, TTL = 24시간 (다음날 10:00에 삭제 예정)
+ * 14:00 - 사용자가 편집, refreshTTL 호출 → TTL 리셋 (다음날 14:00에 삭제 예정)
+ * 16:00 - 또 편집, refreshTTL 호출 → TTL 리셋 (다음날 16:00에 삭제 예정)
+ * ...
+ * 24시간 동안 아무도 편집 안 함 → 자동 삭제
+ *
+ *
+ * @param key Redis 키
+ */
+ private void refreshTTL(String key) {
+ redisTemplate.expire(key, NOTE_TTL);
+ }
+
+ /**
+ * 모든 dirty 노트 ID 조회 (스케줄러용)
+ *
+ * 사용 시나리오:
+ *
+ * // 스케줄러가 30초마다 실행
+ * {@literal @}Scheduled(fixedDelay = 30000)
+ * public void autoSave() {
+ * Set dirtyNoteIds = redisService.getAllDirtyNoteIds();
+ * log.info("Found {} dirty notes to save", dirtyNoteIds.size());
+ *
+ * for (Long noteId : dirtyNoteIds) {
+ * try {
+ * String content = redisService.getContent(noteId);
+ * noteRepository.updateContent(noteId, content);
+ * redisService.clearDirty(noteId);
+ * log.info("Auto-saved note: {}", noteId);
+ * } catch (Exception e) {
+ * log.error("Failed to save note: {}", noteId, e);
+ * }
+ * }
+ * }
+ *
+ *
+ * @return dirty 플래그가 true인 노트 ID 집합
+ */
+ public Set getAllDirtyNoteIds() {
+ // Redis SCAN 명령 사용 (KEYS 대신 - non-blocking)
+ Set dirtyNoteIds = new HashSet<>();
+ String pattern = RedisKeyPrefix.NOTE_DIRTY.get("*");
+
+ try {
+ redisTemplate.execute((org.springframework.data.redis.core.RedisCallback) connection -> {
+ org.springframework.data.redis.core.Cursor cursor = connection.scan(
+ org.springframework.data.redis.core.ScanOptions.scanOptions()
+ .match(pattern)
+ .count(100) // 한 번에 100개씩 스캔
+ .build()
+ );
+
+ while (cursor.hasNext()) {
+ String key = new String(cursor.next());
+ String value = redisStorage.getValueAsString(key);
+
+ if ("true".equals(value)) {
+ // "NOTE:DIRTY:123" → 123 추출
+ String noteIdStr = key.substring(RedisKeyPrefix.NOTE_DIRTY.get().length());
+ try {
+ dirtyNoteIds.add(Long.parseLong(noteIdStr));
+ } catch (NumberFormatException e) {
+ log.error("Invalid noteId in dirty key: {}", key);
+ }
+ }
+ }
+
+ cursor.close();
+ return null;
+ });
+ } catch (Exception e) {
+ log.error("Failed to scan dirty note IDs", e);
+ }
+
+ log.debug("Found {} dirty notes", dirtyNoteIds.size());
+ return dirtyNoteIds;
+ }
+}
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
new file mode 100644
index 0000000..ccede8c
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/service/NoteService.java
@@ -0,0 +1,38 @@
+package com.project.syncly.domain.note.service;
+
+import com.project.syncly.domain.note.dto.NoteRequestDto;
+import com.project.syncly.domain.note.dto.NoteResponseDto;
+import org.springframework.data.domain.Pageable;
+
+public interface NoteService {
+
+ /**
+ * 노트 생성
+ */
+ NoteResponseDto.Create createNote(Long workspaceId, NoteRequestDto.Create requestDto, Long memberId);
+
+ /**
+ * 워크스페이스의 노트 목록 조회 (페이징)
+ */
+ NoteResponseDto.NoteList getNoteList(Long workspaceId, Long memberId, Pageable pageable);
+
+ /**
+ * 노트 상세 조회
+ */
+ NoteResponseDto.Detail getNoteDetail(Long workspaceId, Long noteId, Long memberId);
+
+ /**
+ * 노트 삭제 (소프트 삭제)
+ */
+ NoteResponseDto.Delete deleteNote(Long workspaceId, Long noteId, Long memberId);
+
+ /**
+ * 워크스페이스 멤버 권한 확인
+ */
+ 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
new file mode 100644
index 0000000..b18c694
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java
@@ -0,0 +1,212 @@
+package com.project.syncly.domain.note.service;
+
+import com.project.syncly.domain.member.entity.Member;
+import com.project.syncly.domain.member.repository.MemberRepository;
+import com.project.syncly.domain.note.converter.NoteConverter;
+import com.project.syncly.domain.note.dto.NoteRequestDto;
+import com.project.syncly.domain.note.dto.NoteResponseDto;
+import com.project.syncly.domain.note.entity.Note;
+import com.project.syncly.domain.note.entity.NoteParticipant;
+import com.project.syncly.domain.note.exception.NoteErrorCode;
+import com.project.syncly.domain.note.exception.NoteException;
+import com.project.syncly.domain.note.repository.NoteParticipantRepository;
+import com.project.syncly.domain.note.repository.NoteRepository;
+import com.project.syncly.domain.workspace.entity.Workspace;
+import com.project.syncly.domain.workspace.exception.WorkspaceErrorCode;
+import com.project.syncly.domain.workspace.exception.WorkspaceException;
+import com.project.syncly.domain.workspace.repository.WorkspaceRepository;
+import com.project.syncly.domain.workspaceMember.repository.WorkspaceMemberRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.messaging.simp.SimpMessagingTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class NoteServiceImpl implements NoteService {
+
+ private final NoteRepository noteRepository;
+ private final NoteParticipantRepository noteParticipantRepository;
+ private final WorkspaceRepository workspaceRepository;
+ private final WorkspaceMemberRepository workspaceMemberRepository;
+ private final MemberRepository memberRepository;
+ private final NoteRedisService noteRedisService;
+ private final SimpMessagingTemplate messagingTemplate;
+
+ @Override
+ @Transactional
+ public NoteResponseDto.Create createNote(Long workspaceId, NoteRequestDto.Create requestDto, Long memberId) {
+ log.info("Creating note in workspace {} by member {}", workspaceId, memberId);
+
+ // 워크스페이스 멤버십 검증
+ validateWorkspaceMembership(workspaceId, memberId);
+
+ // 워크스페이스 조회
+ Workspace workspace = workspaceRepository.findById(workspaceId)
+ .orElseThrow(() -> new WorkspaceException(WorkspaceErrorCode.WORKSPACE_NOT_FOUND));
+
+ // 멤버 조회
+ Member creator = memberRepository.findById(memberId)
+ .orElseThrow(() -> new WorkspaceException(WorkspaceErrorCode.MEMBER_NOT_FOUND));
+
+ // 노트 생성
+ Note note = NoteConverter.toNote(requestDto, workspace, creator);
+ Note savedNote = noteRepository.save(note);
+
+ log.info("Note created successfully: noteId={}", savedNote.getId());
+
+ // WebSocket을 통해 모든 워크스페이스 멤버에게 새 노트 생성 알림
+ broadcastNoteCreation(savedNote, workspace.getId());
+
+ return NoteConverter.toCreateResponse(savedNote);
+ }
+
+ @Override
+ public NoteResponseDto.NoteList getNoteList(Long workspaceId, Long memberId, Pageable pageable) {
+ log.info("Getting note list for workspace {} by member {}", workspaceId, memberId);
+
+ // 워크스페이스 멤버십 검증
+ validateWorkspaceMembership(workspaceId, memberId);
+
+ // 노트 목록 조회
+ Page notePage = noteRepository.findByWorkspaceId(workspaceId, pageable);
+
+ // 각 노트의 참여자 수 조회
+ List noteItems = notePage.getContent().stream()
+ .map(note -> {
+ Long participantCount = noteParticipantRepository.countByNoteId(note.getId());
+ return NoteConverter.toListItemResponse(note, participantCount);
+ })
+ .toList();
+
+ return new NoteResponseDto.NoteList(
+ noteItems,
+ notePage.getTotalElements(),
+ notePage.getNumber(),
+ notePage.getTotalPages()
+ );
+ }
+
+ @Override
+ public NoteResponseDto.Detail getNoteDetail(Long workspaceId, Long noteId, Long memberId) {
+ log.info("Getting note detail: workspaceId={}, noteId={}, memberId={}", workspaceId, noteId, memberId);
+
+ // 워크스페이스 멤버십 검증
+ validateWorkspaceMembership(workspaceId, memberId);
+
+ // 노트 조회 및 워크스페이스 일치 확인
+ Note note = noteRepository.findByIdAndWorkspaceId(noteId, workspaceId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ // 활성 참여자 목록 조회
+ List activeParticipants = noteParticipantRepository.findOnlineParticipantsByNoteId(noteId);
+
+ return NoteConverter.toDetailResponse(note, activeParticipants);
+ }
+
+ @Override
+ @Transactional
+ public NoteResponseDto.Delete deleteNote(Long workspaceId, Long noteId, Long memberId) {
+ log.info("Deleting note: workspaceId={}, noteId={}, memberId={}", workspaceId, noteId, memberId);
+
+ // 워크스페이스 멤버십 검증
+ validateWorkspaceMembership(workspaceId, memberId);
+
+ // 노트 조회 및 워크스페이스 일치 확인
+ Note note = noteRepository.findByIdAndWorkspaceId(noteId, workspaceId)
+ .orElseThrow(() -> new NoteException(NoteErrorCode.NOTE_NOT_FOUND));
+
+ // 소프트 삭제
+ note.markAsDeleted();
+ noteRepository.save(note);
+
+ log.info("Note deleted successfully: noteId={}", noteId);
+
+ // WebSocket을 통해 모든 워크스페이스 멤버에게 노트 삭제 알림
+ broadcastNoteDeletion(noteId, workspaceId);
+
+ return NoteConverter.toDeleteResponse(note);
+ }
+
+ @Override
+ public void validateWorkspaceMember(Long workspaceId, Long memberId) {
+ validateWorkspaceMembership(workspaceId, memberId);
+ }
+
+ @Override
+ public int getRevisionFromRedis(Long noteId) {
+ return noteRedisService.getRevision(noteId);
+ }
+
+ /**
+ * WebSocket을 통해 노트 삭제를 모든 워크스페이스 멤버에게 브로드캐스트합니다.
+ */
+ private void broadcastNoteDeletion(Long noteId, Long workspaceId) {
+ try {
+ Map message = new HashMap<>();
+ message.put("type", "NOTE_DELETED");
+
+ Map payload = new HashMap<>();
+ payload.put("noteId", noteId);
+ payload.put("workspaceId", workspaceId);
+
+ message.put("payload", payload);
+
+ String destination = "/topic/workspace/" + workspaceId + "/notes/list";
+ messagingTemplate.convertAndSend(destination, message);
+
+ log.info("Note deletion broadcasted: noteId={}, destination={}", noteId, destination);
+ } catch (Exception e) {
+ log.error("Failed to broadcast note deletion: noteId={}", noteId, e);
+ // 브로드캐스트 실패는 note 삭제에 영향을 주지 않음
+ }
+ }
+
+ /**
+ * WebSocket을 통해 새로운 노트 생성을 모든 워크스페이스 멤버에게 브로드캐스트합니다.
+ */
+ private void broadcastNoteCreation(Note savedNote, Long workspaceId) {
+ try {
+ Map message = new HashMap<>();
+ message.put("type", "NOTE_CREATED");
+
+ Map payload = new HashMap<>();
+ payload.put("noteId", savedNote.getId());
+ payload.put("title", savedNote.getTitle());
+ payload.put("creatorName", savedNote.getCreator().getName());
+ payload.put("creatorProfileImage", savedNote.getCreator().getProfileImage());
+ payload.put("createdAt", savedNote.getCreatedAt());
+ payload.put("workspaceId", workspaceId);
+ payload.put("participantCount", 0L);
+
+ message.put("payload", payload);
+
+ String destination = "/topic/workspace/" + workspaceId + "/notes/list";
+ messagingTemplate.convertAndSend(destination, message);
+
+ log.info("Note creation broadcasted: noteId={}, destination={}", savedNote.getId(), destination);
+ } catch (Exception e) {
+ log.error("Failed to broadcast note creation: noteId={}", savedNote.getId(), e);
+ // 브로드캐스트 실패는 note 생성에 영향을 주지 않음
+ }
+ }
+
+ /**
+ * 워크스페이스 멤버십 검증
+ */
+ private void validateWorkspaceMembership(Long workspaceId, Long memberId) {
+ if (!workspaceMemberRepository.existsByWorkspaceIdAndMemberId(workspaceId, memberId)) {
+ throw new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER);
+ }
+ }
+}
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
new file mode 100644
index 0000000..aabf5fa
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/service/OTService.java
@@ -0,0 +1,394 @@
+package com.project.syncly.domain.note.service;
+
+import com.project.syncly.domain.note.dto.CursorPosition;
+import com.project.syncly.domain.note.dto.EditOperation;
+import com.project.syncly.domain.note.engine.OTEngine;
+import com.project.syncly.domain.note.exception.NoteErrorCode;
+import com.project.syncly.domain.note.exception.NoteException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * OT(Operational Transformation) 서비스
+ *
+ * OT 엔진과 Redis를 통합하여 실시간 협업 편집을 처리합니다.
+ *
+ *
주요 기능:
+ *
+ * 편집 연산 검증
+ * 연산 변환 (OT 알고리즘 적용)
+ * Redis에 연산 적용 및 저장
+ *
+ *
+ * 처리 흐름:
+ *
+ * 클라이언트로부터 EditOperation 수신
+ * 연산 유효성 검증 (validateOperation)
+ * 현재 revision 확인
+ * op.revision < currentRevision이면 히스토리를 통해 변환 (transformAgainstHistory)
+ * 변환된 연산을 문서에 적용 (applyOperation)
+ * Redis 업데이트:
+ *
+ * content 업데이트
+ * operations 히스토리에 추가
+ * revision 증가
+ * dirty 플래그 설정
+ *
+ *
+ * 새 content와 revision 반환
+ *
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class OTService {
+
+ private final NoteRedisService noteRedisService;
+
+ /**
+ * 편집 연산을 처리합니다.
+ *
+ * 이 메서드는 다음 단계를 수행합니다:
+ *
+ * Redis에서 현재 content, revision, operations 조회
+ * 연산 유효성 검증
+ * op.revision이 구버전이면 OT 변환 수행
+ * 변환된 연산을 content에 적용
+ * Redis 업데이트
+ *
+ *
+ * 사용 예시:
+ *
{@code
+ * EditOperation op = EditOperation.insert(10, "Hello", 5, 123L);
+ * ProcessEditResult result = otService.processEdit(noteId, op);
+ * // result.content: 새 문서 내용
+ * // result.revision: 새 버전 번호
+ * }
+ *
+ * @param noteId 노트 ID
+ * @param operation 처리할 편집 연산
+ * @return 처리 결과 (새 content, 새 revision)
+ * @throws NoteException 연산이 유효하지 않거나 적용에 실패한 경우
+ */
+ public ProcessEditResult processEdit(Long noteId, EditOperation operation) {
+ log.debug("편집 연산 처리 시작: noteId={}, operation={}", noteId, operation);
+
+ // 1. Redis에서 현재 상태 조회
+ String currentContent = noteRedisService.getContent(noteId);
+ if (currentContent == null) {
+ throw new NoteException(NoteErrorCode.NOTE_NOT_FOUND);
+ }
+
+ int currentRevision = noteRedisService.getRevision(noteId);
+
+ // 2. 연산 유효성 검증
+ validateOperation(operation, currentContent);
+
+ // 3. 연산 변환 (필요한 경우)
+ EditOperation transformedOp = operation;
+
+ if (operation.getRevision() < currentRevision) {
+ log.debug("구버전 연산 감지: op.revision={}, current={}, 변환 필요",
+ operation.getRevision(), currentRevision);
+
+ // operation.revision 이후의 모든 히스토리 조회
+ List history = noteRedisService.getOperations(noteId, operation.getRevision());
+
+ log.debug("히스토리 조회 완료: {} 개의 연산", history.size());
+
+ // 히스토리의 각 연산 로깅
+ for (int i = 0; i < history.size(); i++) {
+ EditOperation histOp = history.get(i);
+ log.debug(" [{}] {}", i + 1, histOp);
+ }
+
+ // OT 변환 수행
+ transformedOp = OTEngine.transformAgainstHistory(operation, history);
+
+ log.debug("변환 완료: original={}, transformed={}", operation, transformedOp);
+
+ // ⚠️ 변환 후 다시 검증하지 않음!
+ // 변환된 operation은 현재 content에 대해 유효하지 않을 수 있지만,
+ // OT 변환 알고리즘이 유효성을 보장하므로 검증 불필요.
+ // 실제 적용 시 DELETE 범위 조정(line 124)으로 안전성 확보.
+ }
+
+ // 4. 연산을 content에 적용
+ String newContent;
+ try {
+ // DELETE 연산의 경우 범위 초과를 자동으로 조정
+ EditOperation adjustedOp = transformedOp;
+ if (transformedOp.isDelete()) {
+ int deleteStart = transformedOp.getPosition();
+ int deleteEnd = transformedOp.getEndPosition();
+ int contentLength = currentContent.length();
+
+ // DELETE 범위가 content를 초과하는 경우 조정
+ if (deleteEnd > contentLength) {
+ log.warn("DELETE 범위 조정: pos={}, len={}, content.length={} → len={}",
+ deleteStart, transformedOp.getLength(), contentLength,
+ Math.max(0, contentLength - deleteStart));
+
+ // 조정된 길이로 새 연산 생성
+ int adjustedLength = Math.max(0, contentLength - deleteStart);
+ adjustedOp = transformedOp.withLength(adjustedLength);
+ }
+ }
+
+ newContent = OTEngine.applyOperation(currentContent, adjustedOp);
+ } catch (IllegalArgumentException e) {
+ log.error("연산 적용 실패: operation={}, content.length={}, error={}",
+ transformedOp, currentContent.length(), e.getMessage());
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION);
+ }
+
+ // 5. Redis 업데이트
+ // 5-1. Content 업데이트 (자동으로 dirty 플래그 설정됨)
+ noteRedisService.setContent(noteId, newContent);
+
+ // 5-2. Revision 증가
+ int newRevision = noteRedisService.incrementRevision(noteId);
+
+ // 5-3. 변환된 연산을 히스토리에 추가 (새 revision으로)
+ EditOperation historyOp = transformedOp.isNoOp() ? transformedOp :
+ EditOperation.builder()
+ .type(transformedOp.getType())
+ .position(transformedOp.getPosition())
+ .length(transformedOp.getLength())
+ .content(transformedOp.getContent())
+ .revision(newRevision) // 새 revision 할당
+ .workspaceMemberId(transformedOp.getWorkspaceMemberId())
+ .timestamp(transformedOp.getTimestamp())
+ .build();
+
+ noteRedisService.addOperation(noteId, historyOp);
+
+ log.info("편집 연산 처리 완료: noteId={}, revision={}, contentLength={}",
+ noteId, newRevision, newContent.length());
+
+ return new ProcessEditResult(newContent, newRevision, transformedOp);
+ }
+
+ /**
+ * 편집 연산의 유효성을 검증합니다.
+ *
+ * 검증 항목:
+ *
+ * position이 0 이상이고 content 길이 이하인지
+ * DELETE의 경우 position + length가 content 길이 이하인지
+ * length가 음수가 아닌지
+ * INSERT의 경우 content가 null이 아닌지
+ *
+ *
+ * @param operation 검증할 연산
+ * @param content 현재 문서 내용
+ * @throws NoteException 연산이 유효하지 않은 경우
+ */
+ public void validateOperation(EditOperation operation, String content) {
+ if (content == null) {
+ content = "";
+ }
+
+ int position = operation.getPosition();
+ int length = operation.getLength();
+ int contentLength = content.length();
+
+ // 1. position 범위 검증
+ if (position < 0) {
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION,
+ String.format("position은 0 이상이어야 합니다: position=%d", position));
+ }
+
+ if (operation.isInsert()) {
+ // INSERT: position은 0 ~ contentLength (끝에 추가 가능)
+ if (position > contentLength) {
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION,
+ String.format("INSERT position 범위 초과: position=%d, contentLength=%d",
+ position, contentLength));
+ }
+
+ if (operation.getContent() == null) {
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION,
+ "INSERT 연산은 content가 필요합니다");
+ }
+ } else if (operation.isDelete()) {
+ // 2. length 검증 (DELETE인 경우)
+ if (length < 0) {
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION,
+ String.format("length는 0 이상이어야 합니다: length=%d", length));
+ }
+
+ // DELETE: position은 0 ~ contentLength-1
+ if (position > contentLength) {
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION,
+ String.format("DELETE position 범위 초과: position=%d, contentLength=%d",
+ position, contentLength));
+ }
+
+ // 3. DELETE 범위 검증
+ if (position + length > contentLength) {
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION,
+ String.format("DELETE 범위 초과: position=%d, length=%d, contentLength=%d",
+ position, length, contentLength));
+ }
+ } else {
+ throw new NoteException(NoteErrorCode.INVALID_OPERATION,
+ "알 수 없는 연산 타입: " + operation.getType());
+ }
+
+ log.debug("연산 유효성 검증 통과: {}", operation);
+ }
+
+ /**
+ * 편집 연산 후 모든 커서 위치를 조정합니다.
+ *
+ * 편집 연산(INSERT/DELETE)이 적용되면 다른 사용자들의 커서 위치도
+ * 그에 맞춰 조정되어야 합니다.
+ *
+ *
조정 규칙:
+ *
+ * INSERT: operation.position <= cursor.position이면 cursor.position += operation.length
+ * DELETE: operation.position < cursor.position이면 cursor.position -= operation.length
+ * DELETE: cursor가 삭제 범위 내에 있으면 cursor.position = operation.position
+ *
+ *
+ * 사용 예시:
+ *
{@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/util/UserColorGenerator.java b/src/main/java/com/project/syncly/domain/note/util/UserColorGenerator.java
new file mode 100644
index 0000000..b183650
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/util/UserColorGenerator.java
@@ -0,0 +1,64 @@
+package com.project.syncly.domain.note.util;
+
+/**
+ * 사용자별 고유 색상을 생성하는 유틸리티 클래스
+ *
+ * 실시간 협업 노트에서 각 사용자의 커서 및 선택 영역을 구분하기 위한 색상을 생성합니다.
+ */
+public class UserColorGenerator {
+
+ /**
+ * 미리 정의된 색상 팔레트 (16진수 색상 코드)
+ *
+ *
가독성이 좋고 서로 구분하기 쉬운 색상들로 구성
+ */
+ private static final String[] COLOR_PALETTE = {
+ "#FF6B6B", // Red
+ "#4ECDC4", // Teal
+ "#45B7D1", // Sky Blue
+ "#FFA07A", // Light Salmon
+ "#98D8C8", // Mint
+ "#F7DC6F", // Yellow
+ "#BB8FCE", // Purple
+ "#85C1E2", // Light Blue
+ "#F8B739", // Orange
+ "#52B788", // Green
+ "#EF476F", // Pink
+ "#06FFA5", // Cyan
+ "#FFD97D", // Peach
+ "#AAB7B8", // Gray
+ "#FF9FF3", // Magenta
+ "#54A0FF", // Blue
+ "#48DBFB", // Aqua
+ "#FF9F43", // Tangerine
+ "#00D2D3", // Turquoise
+ "#B53471" // Rose
+ };
+
+ /**
+ * WorkspaceMember ID를 기반으로 고유한 색상을 생성합니다.
+ *
+ *
동일한 ID는 항상 동일한 색상을 반환하며,
+ * 색상 팔레트 내에서 순환합니다.
+ *
+ * @param workspaceMemberId WorkspaceMember ID
+ * @return 16진수 색상 코드 (예: "#FF6B6B")
+ */
+ public static String generateColor(Long workspaceMemberId) {
+ if (workspaceMemberId == null) {
+ return COLOR_PALETTE[0]; // 기본 색상
+ }
+
+ int index = (int) (workspaceMemberId % COLOR_PALETTE.length);
+ return COLOR_PALETTE[index];
+ }
+
+ /**
+ * 색상 팔레트의 크기를 반환합니다.
+ *
+ * @return 사용 가능한 색상 개수
+ */
+ public static int getColorCount() {
+ return COLOR_PALETTE.length;
+ }
+}
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
new file mode 100644
index 0000000..9ba0bb1
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/validator/EditOperationValidator.java
@@ -0,0 +1,224 @@
+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, "편집자 정보가 유효하지 않습니다");
+ }
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/validator/ImageValidator.java b/src/main/java/com/project/syncly/domain/note/validator/ImageValidator.java
new file mode 100644
index 0000000..fb7aa51
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/validator/ImageValidator.java
@@ -0,0 +1,234 @@
+package com.project.syncly.domain.note.validator;
+
+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;
+
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * 이미지 파일 유효성 검증 컴포넌트
+ *
+ *
S3에 업로드되는 이미지의 타입, 크기, 파일명 등을 검증합니다.
+ */
+@Slf4j
+@Component
+public class ImageValidator {
+
+ // 허용되는 이미지 확장자
+ private static final Set ALLOWED_EXTENSIONS = new HashSet<>(Set.of(
+ "jpg", "jpeg", "png", "gif", "webp"
+ ));
+
+ // 허용되는 MIME 타입
+ private static final Set ALLOWED_MIME_TYPES = new HashSet<>(Set.of(
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/x-png" // 구형 브라우저 호환성
+ ));
+
+ // 파일 크기 제한 (10MB)
+ private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
+
+ // 파일명 최대 길이
+ private static final int MAX_FILENAME_LENGTH = 255;
+
+ // 파일명 유효성 패턴 (XSS, 경로 탐색 방지)
+ private static final Pattern VALID_FILENAME_PATTERN = Pattern.compile(
+ "^[a-zA-Z0-9._-]+$"
+ );
+
+ /**
+ * 이미지 파일 전체 검증
+ *
+ * 다음을 확인합니다:
+ *
+ * 파일명이 유효한가?
+ * MIME 타입이 이미지 형식인가?
+ * 파일 크기가 제한을 초과하지 않는가?
+ * 확장자가 허용 목록에 있는가?
+ *
+ *
+ * @param filename 파일명
+ * @param contentType MIME 타입 (예: "image/jpeg")
+ * @param fileSize 파일 크기 (바이트)
+ * @throws NoteException 이미지가 유효하지 않음
+ */
+ public void validateImageFile(String filename, String contentType, long fileSize) {
+ validateFilename(filename);
+ validateContentType(contentType);
+ validateFileSize(fileSize);
+ validateExtension(filename);
+
+ log.debug("이미지 파일 검증 성공: filename={}, contentType={}, fileSize={}",
+ filename, contentType, fileSize);
+ }
+
+ /**
+ * 파일명 유효성 검증
+ *
+ * 다음을 확인합니다:
+ *
+ * 파일명이 null/empty가 아닌가?
+ * 파일명 길이가 255자 이하인가?
+ * 파일명에 XSS/경로 탐색 위험 문자가 없는가?
+ *
+ *
+ * @param filename 검증할 파일명
+ * @throws NoteException 파일명이 유효하지 않음
+ */
+ private void validateFilename(String filename) {
+ // null/empty 체크
+ if (filename == null || filename.isBlank()) {
+ log.warn("빈 파일명으로 이미지 업로드 시도");
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE, "파일명이 비어있습니다");
+ }
+
+ String trimmedFilename = filename.trim();
+
+ // 길이 체크
+ if (trimmedFilename.length() > MAX_FILENAME_LENGTH) {
+ log.warn("파일명이 너무 깨미: length={}", trimmedFilename.length());
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ String.format("파일명은 %d자 이하여야 합니다", MAX_FILENAME_LENGTH));
+ }
+
+ // 위험 문자 체크 (경로 탐색, XSS 방지)
+ if (trimmedFilename.contains("..") || trimmedFilename.contains("/") ||
+ trimmedFilename.contains("\\") || trimmedFilename.contains(":")) {
+ log.warn("파일명에 위험 문자 포함: filename={}", trimmedFilename);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ "파일명에 유효하지 않은 문자가 포함되어 있습니다");
+ }
+
+ // 패턴 검증
+ if (!VALID_FILENAME_PATTERN.matcher(trimmedFilename).matches()) {
+ log.warn("파일명이 패턴을 위반: filename={}", trimmedFilename);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ "파일명은 영문, 숫자, 점(.), 하이픈(-), 언더스코어(_)만 포함 가능합니다");
+ }
+ }
+
+ /**
+ * Content-Type (MIME 타입) 검증
+ *
+ * 다음을 확인합니다:
+ *
+ * MIME 타입이 null/empty가 아닌가?
+ * MIME 타입이 "image/"로 시작하는가?
+ * MIME 타입이 허용 목록에 있는가?
+ *
+ *
+ * @param contentType 검증할 MIME 타입
+ * @throws NoteException MIME 타입이 이미지가 아님
+ */
+ private void validateContentType(String contentType) {
+ // null/empty 체크
+ if (contentType == null || contentType.isBlank()) {
+ log.warn("null/empty Content-Type");
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ "Content-Type이 지정되지 않았습니다");
+ }
+
+ String lowerContentType = contentType.toLowerCase().trim();
+
+ // 기본 "image/" 형식 체크
+ if (!lowerContentType.startsWith("image/")) {
+ log.warn("이미지가 아닌 파일: contentType={}", contentType);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ String.format("지원하지 않는 파일 형식입니다: %s", contentType));
+ }
+
+ // 허용 목록 체크
+ if (!ALLOWED_MIME_TYPES.contains(lowerContentType)) {
+ log.warn("허용되지 않는 이미지 형식: contentType={}", contentType);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ String.format("지원하는 이미지 형식: %s", String.join(", ", ALLOWED_MIME_TYPES)));
+ }
+ }
+
+ /**
+ * 파일 크기 검증
+ *
+ * 파일 크기가 10MB를 초과하지 않는지 확인합니다.
+ *
+ * @param fileSize 파일 크기 (바이트)
+ * @throws NoteException 파일이 너무 큼
+ */
+ private void validateFileSize(long fileSize) {
+ if (fileSize <= 0) {
+ log.warn("잘못된 파일 크기: fileSize={}", fileSize);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ "파일 크기가 0보다 커야 합니다");
+ }
+
+ if (fileSize > MAX_FILE_SIZE) {
+ log.warn("파일이 너무 큼: fileSize={} (max={})", fileSize, MAX_FILE_SIZE);
+ throw new NoteException(NoteErrorCode.IMAGE_SIZE_EXCEEDED,
+ String.format("파일 크기는 %dMB 이하여야 합니다", MAX_FILE_SIZE / (1024 * 1024)));
+ }
+ }
+
+ /**
+ * 파일 확장자 검증
+ *
+ *
파일명의 확장자가 허용 목록에 있는지 확인합니다.
+ *
+ * @param filename 검증할 파일명
+ * @throws NoteException 확장자가 허용되지 않음
+ */
+ private void validateExtension(String filename) {
+ if (!filename.contains(".")) {
+ log.warn("확장자가 없는 파일: filename={}", filename);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ "파일에 확장자가 없습니다");
+ }
+
+ String extension = filename.substring(filename.lastIndexOf(".") + 1)
+ .toLowerCase();
+
+ if (!ALLOWED_EXTENSIONS.contains(extension)) {
+ log.warn("허용되지 않는 확장자: extension={}", extension);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ String.format("지원하는 확장자: %s", String.join(", ", ALLOWED_EXTENSIONS)));
+ }
+ }
+
+ /**
+ * 이미지 크기 범위 검증 (선택)
+ *
+ *
이미지가 너무 작지 않은지 확인합니다.
+ * 예: 1x1 픽셀 이미지 스팸 방지
+ *
+ * @param fileSize 파일 크기 (바이트)
+ * @throws NoteException 파일이 너무 작음
+ */
+ public void validateMinimumFileSize(long fileSize) {
+ long MIN_FILE_SIZE = 100; // 최소 100 바이트
+
+ if (fileSize < MIN_FILE_SIZE) {
+ log.warn("파일이 너무 작음: fileSize={}", fileSize);
+ throw new NoteException(NoteErrorCode.INVALID_IMAGE_TYPE,
+ String.format("파일 크기는 최소 %d 바이트 이상이어야 합니다", MIN_FILE_SIZE));
+ }
+ }
+
+ /**
+ * 허용되는 확장자 목록 반환 (클라이언트 안내용)
+ */
+ public Set getAllowedExtensions() {
+ return new HashSet<>(ALLOWED_EXTENSIONS);
+ }
+
+ /**
+ * 최대 파일 크기 반환 (클라이언트 안내용)
+ */
+ public long getMaxFileSizeMB() {
+ return MAX_FILE_SIZE / (1024 * 1024);
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/note/validator/NoteValidator.java b/src/main/java/com/project/syncly/domain/note/validator/NoteValidator.java
new file mode 100644
index 0000000..2441a3a
--- /dev/null
+++ b/src/main/java/com/project/syncly/domain/note/validator/NoteValidator.java
@@ -0,0 +1,189 @@
+package com.project.syncly.domain.note.validator;
+
+import com.project.syncly.domain.note.entity.Note;
+import com.project.syncly.domain.note.exception.NoteErrorCode;
+import com.project.syncly.domain.note.exception.NoteException;
+import com.project.syncly.domain.note.repository.NoteRepository;
+import com.project.syncly.domain.workspaceMember.repository.WorkspaceMemberRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.regex.Pattern;
+
+/**
+ * 노트 엔티티 유효성 검증 컴포넌트
+ *
+ * 노트의 접근 권한, 제목, 내용 등을 검증합니다.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class NoteValidator {
+
+ private final NoteRepository noteRepository;
+ private final WorkspaceMemberRepository workspaceMemberRepository;
+
+ // 제목 검증 규칙
+ private static final int MIN_TITLE_LENGTH = 1;
+ private static final int MAX_TITLE_LENGTH = 200;
+ private static final Pattern XSS_PATTERN = Pattern.compile("[<>\"'%;()&+]");
+
+ // 내용 검증 규칙
+ private static final long MAX_CONTENT_SIZE = 1024 * 1024; // 1MB
+
+ /**
+ * 노트 접근 권한 검증
+ *
+ *
다음을 확인합니다:
+ *
+ * 노트가 존재하는가?
+ * 사용자가 워크스페이스 멤버인가?
+ * 노트가 삭제되지 않았는가?
+ *
+ *
+ * @param noteId 노트 ID
+ * @param workspaceId 워크스페이스 ID
+ * @param memberId 멤버 ID
+ * @return 유효한 노트 엔티티
+ * @throws NoteException 접근 권한이 없거나 노트를 찾을 수 없음
+ */
+ public Note validateNoteAccess(Long noteId, Long workspaceId, Long memberId) {
+ // 1. 워크스페이스 멤버 확인
+ if (!workspaceMemberRepository.existsByWorkspaceIdAndMemberId(workspaceId, memberId)) {
+ log.warn("워크스페이스 멤버 아님: workspaceId={}, memberId={}", workspaceId, memberId);
+ throw new NoteException(NoteErrorCode.NOT_WORKSPACE_MEMBER);
+ }
+
+ // 2. 노트 존재 확인
+ Note note = noteRepository.findByIdAndWorkspaceId(noteId, workspaceId)
+ .orElseThrow(() -> {
+ log.warn("노트를 찾을 수 없음: noteId={}, workspaceId={}", noteId, workspaceId);
+ return new NoteException(NoteErrorCode.NOTE_NOT_FOUND);
+ });
+
+ // 3. 삭제된 노트 확인
+ if (note.getIsDeleted()) {
+ log.warn("삭제된 노트에 접근 시도: noteId={}", noteId);
+ throw new NoteException(NoteErrorCode.NOTE_ALREADY_DELETED);
+ }
+
+ return note;
+ }
+
+ /**
+ * 노트 제목 유효성 검증
+ *
+ * 다음을 확인합니다:
+ *
+ * 제목이 비어있지 않은가?
+ * 제목 길이가 1~200자인가?
+ * XSS 공격 특수문자가 없는가?
+ *
+ *
+ * @param title 검증할 제목
+ * @throws NoteException 제목이 유효하지 않음
+ */
+ public void validateTitle(String title) {
+ // null/empty 체크
+ if (title == null || title.isBlank()) {
+ log.warn("빈 제목으로 노트 생성 시도");
+ throw new NoteException(NoteErrorCode.EMPTY_NOTE_TITLE);
+ }
+
+ String trimmedTitle = title.trim();
+
+ // 길이 체크
+ if (trimmedTitle.length() < MIN_TITLE_LENGTH) {
+ log.warn("너무 짧은 제목: length={}", trimmedTitle.length());
+ throw new NoteException(NoteErrorCode.EMPTY_NOTE_TITLE);
+ }
+
+ if (trimmedTitle.length() > MAX_TITLE_LENGTH) {
+ log.warn("너무 긴 제목: length={}", trimmedTitle.length());
+ throw new NoteException(NoteErrorCode.NOTE_TITLE_TOO_LONG);
+ }
+
+ // XSS 공격 특수문자 체크
+ if (XSS_PATTERN.matcher(trimmedTitle).find()) {
+ log.warn("제목에 XSS 위험 문자 포함: title={}", trimmedTitle);
+ throw new NoteException(NoteErrorCode.INVALID_NOTE_TITLE, "제목에 특수문자가 포함되어 있습니다");
+ }
+ }
+
+ /**
+ * 노트 내용 유효성 검증
+ *
+ * 다음을 확인합니다:
+ *
+ * 내용 크기가 1MB 이하인가? (NULL 허용)
+ *
+ *
+ * @param content 검증할 내용 (null 허용)
+ * @throws NoteException 내용이 너무 큼
+ */
+ public void validateContent(String content) {
+ // null은 허용 (빈 노트 가능)
+ if (content == null) {
+ return;
+ }
+
+ // 크기 체크
+ byte[] contentBytes = content.getBytes();
+ if (contentBytes.length > MAX_CONTENT_SIZE) {
+ log.warn("노트 내용이 너무 큼: size={}bytes (max={}bytes)",
+ contentBytes.length, MAX_CONTENT_SIZE);
+ throw new NoteException(NoteErrorCode.NOTE_CONTENT_TOO_LONG);
+ }
+ }
+
+ /**
+ * 노트 생성 권한 검증 (작성자 확인)
+ *
+ * 노트를 삭제하거나 수정하려는 사용자가 작성자인지 확인합니다.
+ *
+ * @param note 검증할 노트
+ * @param requesterId 요청 사용자 ID
+ * @throws NoteException 요청 사용자가 작성자가 아님
+ */
+ public void validateNoteCreator(Note note, Long requesterId) {
+ if (!note.getCreator().getId().equals(requesterId)) {
+ log.warn("노트 작성자가 아닌 사용자가 수정 시도: noteId={}, requesterId={}, creatorId={}",
+ note.getId(), requesterId, note.getCreator().getId());
+ throw new NoteException(NoteErrorCode.NOT_NOTE_CREATOR);
+ }
+ }
+
+ /**
+ * 노트 업데이트 데이터 검증
+ *
+ *
제목, 내용 모두 유효성 검증합니다.
+ *
+ * @param title 새 제목 (null이면 검증 생략)
+ * @param content 새 내용 (null이면 검증 생략)
+ */
+ public void validateNoteUpdate(String title, String content) {
+ if (title != null) {
+ validateTitle(title);
+ }
+
+ if (content != null) {
+ validateContent(content);
+ }
+ }
+
+ /**
+ * 노트 상태 검증
+ *
+ *
노트가 유효한 상태인지 확인합니다.
+ *
+ * @param note 검증할 노트
+ * @throws NoteException 노트가 삭제된 상태
+ */
+ public void validateNoteNotDeleted(Note note) {
+ if (note.getIsDeleted()) {
+ log.warn("삭제된 노트 접근 시도: noteId={}", note.getId());
+ throw new NoteException(NoteErrorCode.NOTE_ALREADY_DELETED);
+ }
+ }
+}
diff --git a/src/main/java/com/project/syncly/domain/s3/controller/S3Controller.java b/src/main/java/com/project/syncly/domain/s3/controller/S3Controller.java
index 18ae492..06d157a 100644
--- a/src/main/java/com/project/syncly/domain/s3/controller/S3Controller.java
+++ b/src/main/java/com/project/syncly/domain/s3/controller/S3Controller.java
@@ -38,6 +38,14 @@ public ResponseEntity> getDrivePresig
HttpStatus.OK, s3Service.generatePresignedPutUrl(memberId, request)));
}
+ @PostMapping("/presigned-url/note-image")
+ public ResponseEntity> getNoteImagePresignedUrl(
+ @RequestBody @Valid S3RequestDTO.NoteImageUploadPreSignedUrl request,
+ @MemberIdInfo Long memberId) {
+ return ResponseEntity.ok(CustomResponse.success(
+ HttpStatus.OK, s3Service.generatePresignedPutUrl(memberId, request)));
+ }
+
// 이미지 조회용 CloudFront Signed Cookie 방식
@PostMapping("/view-cookie")
public ResponseEntity issueSignedCookieForView(
diff --git a/src/main/java/com/project/syncly/domain/s3/dto/S3RequestDTO.java b/src/main/java/com/project/syncly/domain/s3/dto/S3RequestDTO.java
index 1afff5a..14fe650 100644
--- a/src/main/java/com/project/syncly/domain/s3/dto/S3RequestDTO.java
+++ b/src/main/java/com/project/syncly/domain/s3/dto/S3RequestDTO.java
@@ -24,6 +24,12 @@ public record DriveFileUploadPreSignedUrl (
FileMimeType mimeType
) implements UploadPreSignedUrl {}
+ @ValidMimeMatch
+ public record NoteImageUploadPreSignedUrl (
+ Long noteId,
+ @ValidFileName String fileName,
+ FileMimeType mimeType
+ ) implements UploadPreSignedUrl {}
public record UpdateFile(
@NotBlank String fileName,
diff --git a/src/main/java/com/project/syncly/domain/s3/service/S3ServiceImpl.java b/src/main/java/com/project/syncly/domain/s3/service/S3ServiceImpl.java
index 4ecfa4f..0841d0f 100644
--- a/src/main/java/com/project/syncly/domain/s3/service/S3ServiceImpl.java
+++ b/src/main/java/com/project/syncly/domain/s3/service/S3ServiceImpl.java
@@ -27,7 +27,18 @@ public class S3ServiceImpl implements S3Service {
@Override
public S3ResponseDTO.PreSignedUrl generatePresignedPutUrl(Long memberId, S3RequestDTO.UploadPreSignedUrl request) {
String extension = request.mimeType().getExtension();
- String objectKey = "uploads/" + UUID.randomUUID() + "." + extension;
+
+ // NoteImage인 경우 notes/{noteId} 경로 사용
+ String objectKey;
+ if (request instanceof S3RequestDTO.NoteImageUploadPreSignedUrl noteRequest) {
+ objectKey = String.format("notes/%d/%s.%s",
+ noteRequest.noteId(),
+ UUID.randomUUID(),
+ extension);
+ } else {
+ objectKey = "uploads/" + UUID.randomUUID() + "." + extension;
+ }
+
String redisKey = RedisKeyPrefix.S3_AUTH_OBJECT_KEY.get(memberId.toString() + ':' + request.fileName() + ':' + objectKey);
String url = s3Util.createPresignedUrl(objectKey, request.mimeType());
diff --git a/src/main/java/com/project/syncly/domain/s3/util/S3Util.java b/src/main/java/com/project/syncly/domain/s3/util/S3Util.java
index cecb1e8..9bd10dd 100644
--- a/src/main/java/com/project/syncly/domain/s3/util/S3Util.java
+++ b/src/main/java/com/project/syncly/domain/s3/util/S3Util.java
@@ -92,5 +92,22 @@ public void delete(String objectKey) {
}
}
+ /**
+ * S3 객체 존재 여부 확인 (headObject)
+ *
+ * @param objectKey S3 object key
+ * @return 객체가 존재하면 true, 아니면 false
+ */
+ public boolean objectExists(String objectKey) {
+ try {
+ s3Client.headObject(builder -> builder
+ .bucket(bucket)
+ .key(objectKey)
+ .build());
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
}
diff --git a/src/main/java/com/project/syncly/global/apiPayload/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/project/syncly/global/apiPayload/exception/handler/GlobalExceptionHandler.java
index 83683f0..e44828c 100644
--- a/src/main/java/com/project/syncly/global/apiPayload/exception/handler/GlobalExceptionHandler.java
+++ b/src/main/java/com/project/syncly/global/apiPayload/exception/handler/GlobalExceptionHandler.java
@@ -1,18 +1,25 @@
package com.project.syncly.global.apiPayload.exception.handler;
+import com.project.syncly.domain.note.exception.NoteErrorCode;
+import com.project.syncly.domain.note.exception.NoteException;
import com.project.syncly.global.apiPayload.CustomResponse;
import com.project.syncly.global.apiPayload.code.BaseErrorCode;
import com.project.syncly.global.apiPayload.code.GeneralErrorCode;
import com.project.syncly.global.apiPayload.exception.CustomException;
import com.project.syncly.global.jwt.exception.JwtException;
+import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.OptimisticLockingFailureException;
+import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.NoHandlerFoundException;
+import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@@ -67,15 +74,119 @@ public ResponseEntity> handleCustomException(CustomExceptio
.body(ex.getCode().getErrorResponse());
}
+ /**
+ * NoteException 처리
+ *
+ * 노트 도메인에서 발생하는 비즈니스 예외를 처리합니다.
+ */
+ @ExceptionHandler(NoteException.class)
+ public ResponseEntity>> handleNoteException(
+ NoteException ex,
+ HttpServletRequest request) {
+ log.warn("[ NoteException ]: {}", ex.getCode().getMessage());
+
+ Map details = new HashMap<>();
+ details.put("code", ex.getCode().getCode());
+ details.put("timestamp", LocalDateTime.now());
+ details.put("path", request.getRequestURI());
+ if (ex.getCustomMessage() != null) {
+ details.put("detail", ex.getCustomMessage());
+ }
+
+ return ResponseEntity.status(ex.getCode().getStatus())
+ .body(CustomResponse.failure(
+ ex.getCode().getCode(),
+ ex.getCode().getMessage(),
+ details
+ ));
+ }
+
+ /**
+ * Redis 연결 오류 처리
+ *
+ * Redis 서버 연결 실패 시 503 Service Unavailable 반환
+ */
+ @ExceptionHandler(RedisConnectionFailureException.class)
+ public ResponseEntity>> handleRedisConnectionFailure(
+ RedisConnectionFailureException ex,
+ HttpServletRequest request) {
+ log.error("[ Redis Connection Error ]: {}", ex.getMessage());
+
+ Map details = new HashMap<>();
+ details.put("timestamp", LocalDateTime.now());
+ details.put("path", request.getRequestURI());
+ details.put("error", "Redis 서버에 연결할 수 없습니다");
+
+ BaseErrorCode errorCode = NoteErrorCode.REDIS_CONNECTION_FAILED;
+ return ResponseEntity.status(errorCode.getStatus())
+ .body(CustomResponse.failure(
+ errorCode.getCode(),
+ errorCode.getMessage(),
+ details
+ ));
+ }
+
+ /**
+ * OptimisticLockingFailureException 처리
+ *
+ * 동시 편집 충돌 시 409 Conflict 반환
+ */
+ @ExceptionHandler(OptimisticLockingFailureException.class)
+ public ResponseEntity>> handleOptimisticLockingFailure(
+ OptimisticLockingFailureException ex,
+ HttpServletRequest request) {
+ log.warn("[ OptimisticLocking Failure ]: {}", ex.getMessage());
+
+ Map details = new HashMap<>();
+ details.put("timestamp", LocalDateTime.now());
+ details.put("path", request.getRequestURI());
+ details.put("action", "RELOAD"); // 클라이언트는 페이지 새로고침 필요
+
+ BaseErrorCode errorCode = NoteErrorCode.CONCURRENT_EDIT_CONFLICT;
+ return ResponseEntity.status(errorCode.getStatus())
+ .body(CustomResponse.failure(
+ errorCode.getCode(),
+ errorCode.getMessage(),
+ details
+ ));
+ }
+
+ /**
+ * 404 Not Found 처리
+ */
+ @ExceptionHandler(NoHandlerFoundException.class)
+ public ResponseEntity>> handleNoHandlerFound(
+ NoHandlerFoundException ex,
+ HttpServletRequest request) {
+ log.warn("[ 404 Not Found ]: path={}", ex.getRequestURL());
+
+ Map details = new HashMap<>();
+ details.put("timestamp", LocalDateTime.now());
+ details.put("path", ex.getRequestURL());
+ details.put("message", "요청한 리소스를 찾을 수 없습니다");
+
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(CustomResponse.failure(
+ "404",
+ "요청한 리소스를 찾을 수 없습니다",
+ details
+ ));
+ }
+
// 그 외의 정의되지 않은 모든 예외 처리
@ExceptionHandler({Exception.class})
- public ResponseEntity> handleAllException(Exception ex) {
- log.error("[WARNING] Internal Server Error : {} ", ex.getMessage());
+ public ResponseEntity> handleAllException(
+ Exception ex,
+ HttpServletRequest request) {
+ log.error("[WARNING] Internal Server Error: path={}", request.getRequestURI(), ex);
+
BaseErrorCode errorCode = GeneralErrorCode.INTERNAL_SERVER_ERROR_500;
CustomResponse errorResponse = CustomResponse.failure(
errorCode.getCode(),
errorCode.getMessage(),
- ex.getMessage()//실제 예외의 메시지를 전달하면, 실제로는 위험할 수 있다.(DB 커넥션 URL, 테이블/컬럼명 노출 등) 프론트 연결 끝나고 빼면 될 듯?
+ // TODO: 프로덕션에서는 민감한 정보 제거
+ // 개발 환경에서만 실제 예외 메시지 전달
+ ex.getMessage()
);
return ResponseEntity
.status(errorCode.getStatus())
diff --git a/src/main/java/com/project/syncly/global/apiPayload/exception/handler/WebSocketExceptionHandler.java b/src/main/java/com/project/syncly/global/apiPayload/exception/handler/WebSocketExceptionHandler.java
index b66235a..163e24b 100644
--- a/src/main/java/com/project/syncly/global/apiPayload/exception/handler/WebSocketExceptionHandler.java
+++ b/src/main/java/com/project/syncly/global/apiPayload/exception/handler/WebSocketExceptionHandler.java
@@ -1,15 +1,19 @@
package com.project.syncly.global.apiPayload.exception.handler;
+import com.project.syncly.domain.note.exception.NoteErrorCode;
+import com.project.syncly.domain.note.exception.NoteException;
import com.project.syncly.global.apiPayload.CustomResponse;
import com.project.syncly.global.apiPayload.code.GeneralErrorCode;
import com.project.syncly.global.apiPayload.exception.CustomException;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.RedisConnectionFailureException;
import org.springframework.messaging.handler.annotation.MessageExceptionHandler;
import org.springframework.messaging.simp.SimpMessagingTemplate;
-import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import java.security.Principal;
+import java.time.LocalDateTime;
+import java.util.HashMap;
import java.util.Map;
@Slf4j
@@ -22,15 +26,88 @@ public WebSocketExceptionHandler(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
+ /**
+ * NoteException 처리
+ */
+ @MessageExceptionHandler(NoteException.class)
+ public void handleNoteException(NoteException ex, Principal principal) {
+ log.warn("[ WebSocket NoteException ]: {}", ex.getCode().getMessage());
+
+ if (principal == null) {
+ log.warn("Principal이 null입니다");
+ return;
+ }
+
+ Map errorDetails = new HashMap<>();
+ errorDetails.put("code", ex.getCode().getCode());
+ errorDetails.put("message", ex.getCode().getMessage());
+ errorDetails.put("timestamp", LocalDateTime.now());
+
+ // 클라이언트의 대응 액션 지정
+ String action = determineClientAction(ex.getCode());
+ errorDetails.put("action", action);
+
+ if (ex.getCustomMessage() != null) {
+ errorDetails.put("detail", ex.getCustomMessage());
+ }
+
+ messagingTemplate.convertAndSendToUser(
+ principal.getName(),
+ "/queue/errors",
+ CustomResponse.failure(
+ ex.getCode().getCode(),
+ ex.getCode().getMessage(),
+ errorDetails
+ )
+ );
+ }
+
+ /**
+ * Redis 연결 오류 처리
+ */
+ @MessageExceptionHandler(RedisConnectionFailureException.class)
+ public void handleRedisConnectionError(RedisConnectionFailureException ex, Principal principal) {
+ log.error("[ WebSocket Redis Error ]: {}", ex.getMessage());
+
+ if (principal == null) {
+ log.warn("Principal이 null입니다");
+ return;
+ }
+
+ Map errorDetails = new HashMap<>();
+ errorDetails.put("code", NoteErrorCode.REDIS_CONNECTION_FAILED.getCode());
+ errorDetails.put("message", NoteErrorCode.REDIS_CONNECTION_FAILED.getMessage());
+ errorDetails.put("timestamp", LocalDateTime.now());
+ errorDetails.put("action", "RETRY"); // 클라이언트는 재시도 가능
+
+ messagingTemplate.convertAndSendToUser(
+ principal.getName(),
+ "/queue/errors",
+ CustomResponse.failure(
+ NoteErrorCode.REDIS_CONNECTION_FAILED.getCode(),
+ NoteErrorCode.REDIS_CONNECTION_FAILED.getMessage(),
+ errorDetails
+ )
+ );
+ }
+
+ /**
+ * CustomException 처리
+ */
@MessageExceptionHandler(CustomException.class)
public void handleCustomException(CustomException ex, Principal principal) {
log.warn("[ WebSocket CustomException ]: {}", ex.getCode().getMessage());
- Map errorResult = Map.of(
- "action", "ERROR",
- "details", ex.getCode().getMessage()
- );
+ if (principal == null) {
+ log.warn("Principal이 null입니다");
+ return;
+ }
+ Map errorResult = new HashMap<>();
+ errorResult.put("code", ex.getCode().getCode());
+ errorResult.put("action", "ERROR");
+ errorResult.put("details", ex.getCode().getMessage());
+ errorResult.put("timestamp", LocalDateTime.now());
messagingTemplate.convertAndSendToUser(
principal.getName(),
@@ -43,15 +120,23 @@ public void handleCustomException(CustomException ex, Principal principal) {
);
}
-
+ /**
+ * 모든 예외 처리
+ */
@MessageExceptionHandler(Exception.class)
public void handleAllOtherExceptions(Exception ex, Principal principal) {
- log.error("[ WebSocket Unexpected Error ]: {}", ex.getMessage());
+ log.error("[ WebSocket Unexpected Error ]: {}", ex.getMessage(), ex);
- Map errorResult = Map.of(
- "action", "ERROR",
- "details", "알 수 없는 서버 에러가 발생했습니다."
- );
+ if (principal == null) {
+ log.warn("Principal이 null입니다");
+ return;
+ }
+
+ Map errorResult = new HashMap<>();
+ errorResult.put("code", GeneralErrorCode.INTERNAL_SERVER_ERROR_500.getCode());
+ errorResult.put("action", "ERROR");
+ errorResult.put("details", "알 수 없는 서버 에러가 발생했습니다");
+ errorResult.put("timestamp", LocalDateTime.now());
messagingTemplate.convertAndSendToUser(
principal.getName(),
@@ -64,5 +149,41 @@ public void handleAllOtherExceptions(Exception ex, Principal principal) {
);
}
+ /**
+ * 에러 코드에 따라 클라이언트가 취해야 할 액션 결정
+ *
+ * 클라이언트는 이 액션에 따라 다음과 같이 대응합니다:
+ *
+ * RELOAD: 페이지 새로고침 필요 (revision 불일치, 동시 편집 충돌)
+ * RETRY: 작업 재시도 가능 (일시적 오류)
+ * RECONNECT: WebSocket 재연결 필요 (연결 오류)
+ * IGNORE: 무시 가능한 에러
+ *
+ *
+ * @param errorCode 에러 코드
+ * @return 클라이언트 액션
+ */
+ private String determineClientAction(com.project.syncly.global.apiPayload.code.BaseErrorCode errorCode) {
+ String code = errorCode.getCode();
+
+ // Revision 불일치 또는 동시 편집 충돌 → 페이지 새로고침
+ if (code.contains("409") || code.equals("Note409_2")) {
+ return "RELOAD";
+ }
+
+ // Redis 오류 → 재시도
+ if (code.contains("503") || code.equals("Note500_3")) {
+ return "RETRY";
+ }
+
+ // 권한 오류 → 무시 또는 로그인 페이지
+ if (code.contains("403")) {
+ return "REDIRECT_LOGIN";
+ }
+
+ // 기타 오류
+ return "ERROR";
+ }
+
}
diff --git a/src/main/java/com/project/syncly/global/config/SecurityConfig.java b/src/main/java/com/project/syncly/global/config/SecurityConfig.java
index b058aea..a4e2207 100644
--- a/src/main/java/com/project/syncly/global/config/SecurityConfig.java
+++ b/src/main/java/com/project/syncly/global/config/SecurityConfig.java
@@ -44,8 +44,11 @@ public class SecurityConfig {
"/api/member/register",
//livekit
"/api/livekit/webhook",
+ //WebSocket endpoints
"/ws-stomp",
"/ws-stomp/**",
+ "/ws/note",
+ "/ws/note/**",
"/api/workspaces/notifications",
"/api/workspaces/notifications/**",
//비밀번호
@@ -138,7 +141,7 @@ SecurityFilterChain fallbackChain(HttpSecurity http) throws Exception {
//Websocket handshake시 filter chain을 지나지 않고 무시하도록 설정(해당 설정이 없으면 403에러 발생)
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
- return web -> web.ignoring().requestMatchers("/ws-stomp");
+ return web -> web.ignoring().requestMatchers("/ws-stomp", "/ws/note");
}
}
\ No newline at end of file
diff --git a/src/main/java/com/project/syncly/global/config/WebSocketConfig.java b/src/main/java/com/project/syncly/global/config/WebSocketConfig.java
index 0d83962..2501d20 100644
--- a/src/main/java/com/project/syncly/global/config/WebSocketConfig.java
+++ b/src/main/java/com/project/syncly/global/config/WebSocketConfig.java
@@ -31,9 +31,15 @@ public void configureMessageBroker(MessageBrokerRegistry registry) {
@Override //클라이언트가 웹소켓에 연결할 때 사용할 엔드포인트를 등록
public void registerStompEndpoints(StompEndpointRegistry registry) {
- registry.addEndpoint("/ws-stomp") //클라이언트가 웹소켓 서버에 최초로 접속할 때 연결할 주소
+ // 기존 일반 WebSocket 엔드포인트 (LiveKit, 일반 알림 등)
+ registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("*"); // CORS 허용 설정, 실 서비스 시에는 도메인을 제한
//.withSockJS(); // SockJS를 사용하여 연결을 시도
+
+ // 노트 실시간 협업용 WebSocket 엔드포인트
+ registry.addEndpoint("/ws/note")
+ .setAllowedOriginPatterns("*"); // CORS 허용 설정
+ //.withSockJS(); // SockJS 지원 (WebSocket을 지원하지 않는 브라우저 대응)
}
@Override //토큰을 가진 유저와 웹소켓을 연결할 것이므로, 토큰을 검증하는 로직이 필요
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 48f3c48..7a4ba49 100644
--- a/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java
+++ b/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java
@@ -1,5 +1,6 @@
package com.project.syncly.global.event;
+import com.project.syncly.domain.note.service.NoteRedisService;
import com.project.syncly.global.redis.enums.RedisKeyPrefix;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -13,6 +14,15 @@
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
import java.security.Principal;
+/**
+ * WebSocket 연결/해제 이벤트를 처리하는 리스너
+ *
+ * 주요 기능:
+ *
+ * SessionConnectedEvent: WebSocket 연결 시 사용자 정보를 Redis에 저장
+ * SessionDisconnectEvent: WebSocket 해제 시 사용자 정보 및 노트 참여 정보 정리
+ *
+ */
@Component
@RequiredArgsConstructor
@Slf4j
@@ -20,9 +30,17 @@ public class WebSocketEventListener {
private final RedisTemplate redisTemplate;
private final ApplicationContext applicationContext;
+ private final NoteRedisService noteRedisService;
+ /**
+ * WebSocket 연결 이벤트 처리
+ *
+ * 사용자가 WebSocket에 연결하면 Redis에 세션 정보를 저장합니다.
+ *
+ * @param event SessionConnectedEvent
+ */
@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
@@ -34,24 +52,64 @@ public void handleWebSocketConnectListener(SessionConnectedEvent event) {
redisTemplate.opsForSet().add(RedisKeyPrefix.WS_ONLINE_USERS.get(), userId);
redisTemplate.opsForHash().put(RedisKeyPrefix.WS_SESSIONS.get(), sessionId, userId);
- log.info("User connected: {}", userId);
+ log.info("WebSocket 연결: sessionId={}, userId={}", sessionId, userId);
}
}
+ /**
+ * WebSocket 연결 해제 이벤트 처리
+ *
+ *
사용자가 WebSocket 연결을 끊으면:
+ *
+ * 일반 WebSocket 세션 정보 삭제
+ * 노트 WebSocket 세션인 경우 노트 참여 정보 정리 (Redis에서 사용자 제거, 커서 삭제)
+ *
+ *
+ * @param event SessionDisconnectEvent
+ */
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
if (!((AbstractApplicationContext) applicationContext).isActive()) {
- log.warn("ApplicationContext 종료로 인한 redis 삭제 생략 {}", event.getSessionId());
+ log.warn("ApplicationContext 종료로 인한 redis 삭제 생략: sessionId={}", event.getSessionId());
return;
}
String sessionId = event.getSessionId();
+ // 1. 일반 WebSocket 세션 정리
String userId = (String) redisTemplate.opsForHash().get(RedisKeyPrefix.WS_SESSIONS.get(), sessionId);
if (userId != null) {
redisTemplate.opsForSet().remove(RedisKeyPrefix.WS_ONLINE_USERS.get(), userId);
redisTemplate.opsForHash().delete(RedisKeyPrefix.WS_SESSIONS.get(), sessionId);
- log.info("User disconnected: {}", userId);
+ log.info("WebSocket 연결 해제: sessionId={}, userId={}", sessionId, userId);
+ }
+
+ // 2. 노트 WebSocket 세션 정리 (있는 경우)
+ String noteSessionData = (String) redisTemplate.opsForHash()
+ .get(RedisKeyPrefix.WS_NOTE_SESSIONS.get(), sessionId);
+
+ if (noteSessionData != null) {
+ try {
+ // noteSessionData 형식: "noteId:workspaceMemberId"
+ String[] parts = noteSessionData.split(":");
+ if (parts.length == 2) {
+ Long noteId = Long.parseLong(parts[0]);
+ Long workspaceMemberId = Long.parseLong(parts[1]);
+
+ // Redis에서 노트 참여 정보 제거
+ noteRedisService.removeUser(noteId, workspaceMemberId);
+ noteRedisService.removeCursor(noteId, workspaceMemberId);
+
+ // 노트 세션 매핑 삭제
+ redisTemplate.opsForHash().delete(RedisKeyPrefix.WS_NOTE_SESSIONS.get(), sessionId);
+
+ log.info("노트 WebSocket 세션 정리: sessionId={}, noteId={}, workspaceMemberId={}",
+ sessionId, noteId, workspaceMemberId);
+ }
+ } catch (Exception e) {
+ log.error("노트 세션 정리 중 오류 발생: sessionId={}, noteSessionData={}, error={}",
+ sessionId, noteSessionData, e.getMessage());
+ }
}
}
}
\ No newline at end of file
diff --git a/src/main/java/com/project/syncly/global/handler/StompHandler.java b/src/main/java/com/project/syncly/global/handler/StompHandler.java
index 3af3dc2..67ab9a7 100644
--- a/src/main/java/com/project/syncly/global/handler/StompHandler.java
+++ b/src/main/java/com/project/syncly/global/handler/StompHandler.java
@@ -1,32 +1,90 @@
package com.project.syncly.global.handler;
+import com.project.syncly.domain.note.exception.NoteErrorCode;
+import com.project.syncly.domain.note.exception.NoteException;
+import com.project.syncly.domain.note.repository.NoteRepository;
+import com.project.syncly.domain.workspaceMember.repository.WorkspaceMemberRepository;
import com.project.syncly.global.jwt.JwtProvider;
+import com.project.syncly.global.jwt.PrincipalDetails;
import com.project.syncly.global.jwt.exception.JwtErrorCode;
import com.project.syncly.global.jwt.exception.JwtException;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * STOMP WebSocket 메시지를 가로채서 JWT 인증 및 권한 검증을 수행하는 인터셉터
+ *
+ * 주요 기능:
+ *
+ * CONNECT: JWT 토큰 검증 및 인증 정보 설정
+ * SUBSCRIBE/SEND: 노트 접근 권한 검증 (워크스페이스 멤버십 확인)
+ *
+ */
@RequiredArgsConstructor
@Component
+@Slf4j
public class StompHandler implements ChannelInterceptor {
private final JwtProvider jwtProvider;
+ private final NoteRepository noteRepository;
+ private final WorkspaceMemberRepository workspaceMemberRepository;
+
+ // 노트 관련 destination 패턴: /app/notes/{noteId}/... 또는 /topic/notes/{noteId}/...
+ private static final Pattern NOTE_DESTINATION_PATTERN = Pattern.compile("^/(app|topic|queue)/notes/(\\d+)/");
- //WebSocket 서버에서 ChannelInterceptor를 사용해 STOMP CONNECT 요청을 가로채고 헤더를 확인
+ /**
+ * WebSocket 메시지 전송 전 인터셉트
+ *
+ * @param message 전송할 메시지
+ * @param channel 메시지 채널
+ * @return 처리된 메시지 (null 반환 시 메시지 전송 중단)
+ */
@Override
public Message> preSend(Message> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
- if (StompCommand.CONNECT.equals(accessor.getCommand())) {
+ try {
+ // CONNECT: JWT 토큰 검증 및 인증 정보 설정
+ if (StompCommand.CONNECT.equals(accessor.getCommand())) {
+ handleConnect(accessor);
+ }
+ // SUBSCRIBE, SEND: 노트 접근 권한 검증
+ else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand()) ||
+ StompCommand.SEND.equals(accessor.getCommand())) {
+ handleNoteAccessAuthorization(accessor);
+ }
+ } catch (Exception ex) {
+ log.error("StompHandler 예외 발생: command={}, message={}",
+ accessor.getCommand(), ex.getMessage(), ex);
+ throw ex;
+ }
+
+ return message;
+ }
+
+ /**
+ * CONNECT 명령 처리: JWT 토큰 검증
+ *
+ * @param accessor STOMP 헤더 접근자
+ * @throws JwtException 토큰이 유효하지 않은 경우
+ */
+ private void handleConnect(StompHeaderAccessor accessor) {
+ try {
String rawToken = accessor.getFirstNativeHeader("Authorization");
if (rawToken == null || !rawToken.startsWith("Bearer ")) {
+ log.warn("CONNECT 요청 중 토큰이 없거나 형식이 잘못됨");
throw new JwtException(JwtErrorCode.INVALID_TOKEN);
}
@@ -34,13 +92,106 @@ public Message> preSend(Message> message, MessageChannel channel) {
String token = rawToken.substring(7);
if (!jwtProvider.isValidToken(token)) {
+ log.warn("CONNECT 요청 중 토큰이 유효하지 않음");
throw new JwtException(JwtErrorCode.INVALID_TOKEN);
}
Authentication authentication = jwtProvider.getAuthentication(token);
+ log.info("CONNECT JWT 파싱 성공: authType={}, principalType={}, authName={}",
+ authentication.getClass().getSimpleName(),
+ authentication.getPrincipal().getClass().getSimpleName(),
+ authentication.getName());
+
accessor.setUser(authentication);
+ log.info("CONNECT accessor에 user 설정 완료: getName={}", accessor.getUser().getName());
+
+ log.info("WebSocket CONNECT 성공: user={}", authentication.getName());
+ } catch (JwtException ex) {
+ log.error("CONNECT JWT 예외: {}", ex.getMessage());
+ throw ex;
+ } catch (Exception ex) {
+ log.error("CONNECT 예외: {}", ex.getMessage(), ex);
+ throw ex;
}
+ }
- return message;
+ /**
+ * SUBSCRIBE/SEND 명령 처리: 노트 접근 권한 검증
+ *
+ * destination이 /app/notes/{noteId}/... 또는 /topic/notes/{noteId}/... 형식인 경우
+ * 해당 노트가 속한 워크스페이스의 멤버인지 확인합니다.
+ *
+ * @param accessor STOMP 헤더 접근자
+ * @throws NoteException 노트가 존재하지 않거나 접근 권한이 없는 경우
+ */
+ private void handleNoteAccessAuthorization(StompHeaderAccessor accessor) {
+ try {
+ String destination = accessor.getDestination();
+ if (destination == null) {
+ log.debug("SUBSCRIBE/SEND destination이 null, 통과");
+ return;
+ }
+
+ log.info("SUBSCRIBE/SEND 처리: destination={}", destination);
+
+ // 노트 관련 destination인지 확인
+ Matcher matcher = NOTE_DESTINATION_PATTERN.matcher(destination);
+ if (!matcher.find()) {
+ log.debug("노트 관련 아닌 destination, 통과: {}", destination);
+ return; // 노트 관련 아닌 destination은 통과
+ }
+
+ Long noteId = Long.parseLong(matcher.group(2));
+
+ // PrincipalDetails에서 직접 member ID를 가져오기
+ // accessor.getUser().getName()은 이메일을 반환할 수 있으므로 PrincipalDetails에서 직접 접근
+ java.security.Principal userPrincipal = accessor.getUser();
+ if (!(userPrincipal instanceof UsernamePasswordAuthenticationToken)) {
+ log.error("인증 타입이 잘못됨: {}", userPrincipal.getClass().getSimpleName());
+ throw new IllegalArgumentException("Invalid authentication type");
+ }
+
+ UsernamePasswordAuthenticationToken auth = (UsernamePasswordAuthenticationToken) userPrincipal;
+ Object principal = auth.getPrincipal();
+ if (!(principal instanceof PrincipalDetails)) {
+ log.error("Principal 타입이 잘못됨: {}", principal.getClass().getSimpleName());
+ throw new IllegalArgumentException("Invalid principal type");
+ }
+
+ Long memberId = ((PrincipalDetails) principal).getMember().getId();
+ log.info("PrincipalDetails에서 memberId 추출: {}", memberId);
+
+ log.info("노트 접근 권한 검증 시작: noteId={}, memberId={}", noteId, memberId);
+
+ // 1. 노트가 존재하는지 확인 (workspace와 creator를 eager loading)
+ var note = noteRepository.findByIdAndNotDeleted(noteId)
+ .orElseThrow(() -> {
+ log.error("노트를 찾을 수 없음: noteId={}", noteId);
+ return new NoteException(NoteErrorCode.NOTE_NOT_FOUND);
+ });
+
+ log.info("노트 조회 성공: noteId={}, workspaceId={}", noteId, note.getWorkspace().getId());
+
+ Long workspaceId = note.getWorkspace().getId();
+
+ // 2. 사용자가 해당 워크스페이스의 멤버인지 확인
+ boolean isMember = workspaceMemberRepository
+ .existsByWorkspaceIdAndMemberId(workspaceId, memberId);
+
+ if (!isMember) {
+ log.warn("WebSocket 접근 거부: noteId={}, memberId={}, workspaceId={}",
+ noteId, memberId, workspaceId);
+ throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED);
+ }
+
+ log.info("WebSocket 접근 허가: noteId={}, memberId={}, destination={}",
+ noteId, memberId, destination);
+ } catch (NoteException ex) {
+ log.error("SUBSCRIBE/SEND NoteException: {}", ex.getMessage());
+ throw ex;
+ } catch (Exception ex) {
+ log.error("SUBSCRIBE/SEND 예외: {}", ex.getMessage(), ex);
+ throw ex;
+ }
}
}
\ No newline at end of file
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 1ebf716..52476d2 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
@@ -20,6 +20,7 @@ public enum RedisKeyPrefix {
//WebSocket 관련 키
WS_SESSIONS("WS:SESSIONS:"),
WS_ONLINE_USERS("WS:ONLINE_USERS"),
+ WS_NOTE_SESSIONS("WS:NOTE_SESSIONS:"), // sessionId -> noteId:workspaceMemberId
// Refresh Whitelist
REFRESH_CURRENT("refresh:current:%s:%s"),
@@ -28,6 +29,14 @@ public enum RedisKeyPrefix {
//profile
MEMBER_PROFILE("PROFILE_CACHE:"),
+
+ // Note 실시간 협업 관련 키
+ NOTE_CONTENT("NOTE:CONTENT:"), // note:{noteId}:content
+ 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
;
private final String prefix;
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 1428a44..6332abd 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -61,11 +61,52 @@ redis:
cache:
member-ttl-seconds: 259200 # 3일
+# ==================== Actuator & 모니터링 ====================
+# 자동 저장 스케줄러 모니터링을 위한 메트릭 수집 설정
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,metrics,prometheus # Prometheus 메트릭 노출
+ base-path: /actuator
+ endpoint:
+ health:
+ show-details: always
+ metrics:
+ enabled: true
+ metrics:
+ enable:
+ jvm: true
+ process: true
+ system: true
+ logback: true
+ export:
+ simple:
+ enabled: true
+ prometheus:
+ enabled: true
+ distribution:
+ percentiles-histogram:
+ note.autosave.duration: true
+ note.autosave.save.time: true
+ note.manual.save.time: true
+ tags:
+ application: ${spring.application.name}
+
logging:
discord:
webhook-url: ${DISCORD_ERROR_WEBHOOK_URL:}
config: classpath:logback-spring.xml
+# ==================== 자동 저장 스케줄러 설정 ====================
+# 30초마다 실행되는 자동 저장 스케줄러의 타이밍을 조정하려면 여기서 수정
+# 단위: milliseconds
+scheduler:
+ note:
+ auto-save:
+ fixed-delay: 30000 # 30초마다 실행
+ initial-delay: 30000 # 애플리케이션 시작 후 30초 후 첫 실행
+
diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml
index 903cebe..509cb7b 100644
--- a/src/main/resources/logback-spring.xml
+++ b/src/main/resources/logback-spring.xml
@@ -24,6 +24,11 @@
+
+
+
+
+