From d0640de474f176c53b06251fefec85d312f7041a Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sat, 25 Oct 2025 02:06:05 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20Actuator=20=EB=B0=8F=20Micrometer?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.gradle b/build.gradle index 982217f..28eda03 100644 --- a/build.gradle +++ b/build.gradle @@ -86,6 +86,10 @@ dependencies { // error discord webhook implementation "com.github.napstr:logback-discord-appender:1.0.0" + // Actuator & Micrometer (모니터링 메트릭용) + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-core' + } tasks.named('test') { From 94c7f07f00d3d670c26805b66e7302e1b0c449f6 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sat, 25 Oct 2025 02:06:29 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/s3/controller/S3Controller.java | 8 ++++++++ .../syncly/domain/s3/dto/S3RequestDTO.java | 6 ++++++ .../syncly/domain/s3/service/S3ServiceImpl.java | 13 ++++++++++++- .../project/syncly/domain/s3/util/S3Util.java | 17 +++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) 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; + } + } } From 75eef22fb136c540bfbe3d2278e3fc48e6cb2bfb Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sat, 25 Oct 2025 02:07:12 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20WebSocket=20=EB=85=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=B3=B4=EC=95=88=20=EB=B0=8F=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/project/syncly/global/config/SecurityConfig.java | 5 ++++- .../com/project/syncly/global/config/WebSocketConfig.java | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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 //토큰을 가진 유저와 웹소켓을 연결할 것이므로, 토큰을 검증하는 로직이 필요 From 69ecd157ff50e905b3034b6751170d1387060156 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sat, 25 Oct 2025 02:07:54 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=8F=AC=EA=B4=84?= =?UTF-8?q?=EC=A0=81=EC=9D=B8=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/GlobalExceptionHandler.java | 117 +++++++++++++- .../handler/WebSocketExceptionHandler.java | 143 ++++++++++++++++-- 2 files changed, 246 insertions(+), 14 deletions(-) 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"; + } + } From 1acb10b633e5679a16f227de534eec522f8c2ab8 Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sat, 25 Oct 2025 02:08:28 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20websocket=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/event/WebSocketEventListener.java | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java b/src/main/java/com/project/syncly/global/event/WebSocketEventListener.java index 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 연결을 끊으면: + *

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

+ + + +
+

온라인 사용자

+
    +
    + + + +
    + + +``` + +--- + +## 6. 체크리스트 + +### 필수 구현 항목 + +- [ ] WebSocket 클라이언트 라이브러리 설치 (@stomp/stompjs) +- [ ] JWT 토큰 포함 WebSocket 연결 구현 +- [ ] Operation 메시지 발행/구독 구현 +- [ ] 에러 처리 구현 (action별 대응) +- [ ] UI에 실시간 변경사항 반영 +- [ ] 이미지 업로드 (Presigned URL) 구현 +- [ ] 동시 편집 충돌 처리 (page reload) +- [ ] 연결 상태 표시 (온/오프라인) + +### 권장 사항 + +- [ ] 자동 저장 로직 (30초마다 수동 저장) +- [ ] Operation 큐잉 (오프라인 모드 지원) +- [ ] Cursor 위치 공유 (다른 사용자 커서 표시) +- [ ] 사용자 활동 상태 (typing indicator) +- [ ] 변경 이력 (undo/redo) + +--- + +## 7. 문제 해결 + +### Q: WebSocket 연결이 실패합니다 +**A:** +- JWT 토큰이 유효한지 확인하세요 +- Authorization 헤더 형식: `Bearer {token}` +- 브라우저 콘솔에서 에러 메시지 확인 + +### Q: "NOTE_ACCESS_DENIED" 에러가 발생합니다 +**A:** +- 해당 노트가 속한 워크스페이스의 멤버인지 확인하세요 +- 워크스페이스에 초대되어 있는지 확인하세요 + +### Q: 다른 사용자의 변경사항이 보이지 않습니다 +**A:** +- `/topic/notes/{noteId}/edit` 구독이 제대로 되었는지 확인 +- 네트워크 탭에서 메시지 수신 여부 확인 +- 서버 로그에서 broadcast 여부 확인 + +### Q: 이미지 업로드가 실패합니다 +**A:** +- MIME 타입이 지원되는 형식인지 확인 +- CORS 설정 확인 +- S3 버킷 권한 확인 + +--- + +## 8. API 명세 + +### 이미지 업로드 Presigned URL +``` +POST /api/notes/{noteId}/images/presigned-url +Content-Type: application/json +Authorization: Bearer {token} + +요청: +{ + "noteId": 123, + "fileName": "screenshot.png", + "mimeType": "image/png" +} + +응답: +{ + "code": "200", + "message": "성공", + "data": { + "presignedUrl": "https://s3.amazonaws.com/bucket/notes/123/uuid.png?..." + } +} +``` + +--- + +## 📞 문제 발생 시 + +> 백엔드 팀에 다음 정보와 함께 이슈 생성하세요: +> - 에러 메시지 +> - 브라우저 콘솔 로그 +> - 서버 로그 (stacktrace) +> - 재현 방법 (Step by step) + +--- + +**마지막 업데이트**: 2025-10-25 +**백엔드 브랜치**: feat/#125 From 379f6135378264574e6b6add1da913ba50bcc41c Mon Sep 17 00:00:00 2001 From: 1026hz <1026hzz@gmail.com> Date: Sat, 25 Oct 2025 02:18:17 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EB=85=B8=ED=8A=B8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/note/annotation/NoteAccess.java | 80 ++ .../domain/note/aspect/NoteAccessAspect.java | 208 +++ .../note/controller/NoteController.java | 194 +++ .../controller/NoteWebSocketController.java | 1188 +++++++++++++++++ .../domain/note/converter/NoteConverter.java | 108 ++ .../domain/note/dto/CursorPosition.java | 67 + .../syncly/domain/note/dto/EditOperation.java | 211 +++ .../syncly/domain/note/dto/NoteImageDto.java | 136 ++ .../domain/note/dto/NoteRequestDto.java | 15 + .../domain/note/dto/NoteResponseDto.java | 91 ++ .../domain/note/dto/NoteWebSocketDto.java | 547 ++++++++ .../domain/note/dto/WebSocketMessage.java | 143 ++ .../domain/note/dto/WebSocketMessageType.java | 112 ++ .../syncly/domain/note/engine/OTEngine.java | 431 ++++++ .../syncly/domain/note/entity/Note.java | 78 ++ .../syncly/domain/note/entity/NoteImage.java | 89 ++ .../domain/note/entity/NoteParticipant.java | 71 + .../domain/note/exception/NoteErrorCode.java | 61 + .../domain/note/exception/NoteException.java | 43 + .../note/repository/NoteImageRepository.java | 57 + .../repository/NoteParticipantRepository.java | 84 ++ .../note/repository/NoteRepository.java | 69 + .../note/scheduler/NoteAutoSaveScheduler.java | 475 +++++++ .../scheduler/NoteImageCleanupScheduler.java | 69 + .../domain/note/service/NoteImageService.java | 277 ++++ .../domain/note/service/NoteRedisService.java | 820 ++++++++++++ .../domain/note/service/NoteService.java | 38 + .../domain/note/service/NoteServiceImpl.java | 212 +++ .../syncly/domain/note/service/OTService.java | 394 ++++++ .../domain/note/util/UserColorGenerator.java | 64 + .../validator/EditOperationValidator.java | 224 ++++ .../domain/note/validator/ImageValidator.java | 234 ++++ .../domain/note/validator/NoteValidator.java | 189 +++ 33 files changed, 7079 insertions(+) create mode 100644 src/main/java/com/project/syncly/domain/note/annotation/NoteAccess.java create mode 100644 src/main/java/com/project/syncly/domain/note/aspect/NoteAccessAspect.java create mode 100644 src/main/java/com/project/syncly/domain/note/controller/NoteController.java create mode 100644 src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java create mode 100644 src/main/java/com/project/syncly/domain/note/converter/NoteConverter.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/CursorPosition.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/EditOperation.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/NoteImageDto.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/NoteRequestDto.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/NoteResponseDto.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/NoteWebSocketDto.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/WebSocketMessage.java create mode 100644 src/main/java/com/project/syncly/domain/note/dto/WebSocketMessageType.java create mode 100644 src/main/java/com/project/syncly/domain/note/engine/OTEngine.java create mode 100644 src/main/java/com/project/syncly/domain/note/entity/Note.java create mode 100644 src/main/java/com/project/syncly/domain/note/entity/NoteImage.java create mode 100644 src/main/java/com/project/syncly/domain/note/entity/NoteParticipant.java create mode 100644 src/main/java/com/project/syncly/domain/note/exception/NoteErrorCode.java create mode 100644 src/main/java/com/project/syncly/domain/note/exception/NoteException.java create mode 100644 src/main/java/com/project/syncly/domain/note/repository/NoteImageRepository.java create mode 100644 src/main/java/com/project/syncly/domain/note/repository/NoteParticipantRepository.java create mode 100644 src/main/java/com/project/syncly/domain/note/repository/NoteRepository.java create mode 100644 src/main/java/com/project/syncly/domain/note/scheduler/NoteAutoSaveScheduler.java create mode 100644 src/main/java/com/project/syncly/domain/note/scheduler/NoteImageCleanupScheduler.java create mode 100644 src/main/java/com/project/syncly/domain/note/service/NoteImageService.java create mode 100644 src/main/java/com/project/syncly/domain/note/service/NoteRedisService.java create mode 100644 src/main/java/com/project/syncly/domain/note/service/NoteService.java create mode 100644 src/main/java/com/project/syncly/domain/note/service/NoteServiceImpl.java create mode 100644 src/main/java/com/project/syncly/domain/note/service/OTService.java create mode 100644 src/main/java/com/project/syncly/domain/note/util/UserColorGenerator.java create mode 100644 src/main/java/com/project/syncly/domain/note/validator/EditOperationValidator.java create mode 100644 src/main/java/com/project/syncly/domain/note/validator/ImageValidator.java create mode 100644 src/main/java/com/project/syncly/domain/note/validator/NoteValidator.java diff --git a/src/main/java/com/project/syncly/domain/note/annotation/NoteAccess.java b/src/main/java/com/project/syncly/domain/note/annotation/NoteAccess.java new file mode 100644 index 0000000..5dd57c8 --- /dev/null +++ b/src/main/java/com/project/syncly/domain/note/annotation/NoteAccess.java @@ -0,0 +1,80 @@ +package com.project.syncly.domain.note.annotation; + +import java.lang.annotation.*; + +/** + * 노트 접근 권한 검증 어노테이션 + * + *

    컨트롤러 메서드에 이 어노테이션을 붙이면, + * NoteAccessAspect가 메서드 실행 전에 권한을 검증합니다. + * + *

    사용 예: + *

    + * {@literal @}PostMapping("/{noteId}/edit")
    + * {@literal @}NoteAccess(level = AccessLevel.WRITE)
    + * public void editNote({@literal @}PathVariable Long noteId, ...) {
    + *     // 권한이 확인된 후 실행됨
    + * }
    + * 
    + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface NoteAccess { + + /** + * 필요한 접근 권한 레벨 + * + *

    READ: 노트 조회만 가능 + *

    WRITE: 노트 편집 가능 + *

    DELETE: 노트 삭제 가능 + */ + AccessLevel level() default AccessLevel.READ; + + /** + * 노트 ID 파라미터 이름 + * + *

    기본값은 "noteId"입니다. + * 다른 이름이면 여기서 지정할 수 있습니다. + * + *

    예: @NoteAccess(paramName = "id") + */ + String paramName() default "noteId"; + + /** + * 워크스페이스 ID 파라미터 이름 + * + *

    기본값은 "workspaceId"입니다. + */ + String workspaceParamName() default "workspaceId"; + + /** + * 접근 권한 레벨 열거형 + */ + enum AccessLevel { + /** + * 읽기: 노트 조회만 가능 + */ + READ("노트 조회"), + + /** + * 쓰기: 노트 편집 가능 + */ + WRITE("노트 편집"), + + /** + * 삭제: 노트 삭제 가능 (작성자만) + */ + DELETE("노트 삭제"); + + private final String description; + + AccessLevel(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + } +} diff --git a/src/main/java/com/project/syncly/domain/note/aspect/NoteAccessAspect.java b/src/main/java/com/project/syncly/domain/note/aspect/NoteAccessAspect.java new file mode 100644 index 0000000..1a69f99 --- /dev/null +++ b/src/main/java/com/project/syncly/domain/note/aspect/NoteAccessAspect.java @@ -0,0 +1,208 @@ +package com.project.syncly.domain.note.aspect; + +import com.project.syncly.domain.note.annotation.NoteAccess; +import com.project.syncly.domain.note.annotation.NoteAccess.AccessLevel; +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.note.validator.NoteValidator; +import com.project.syncly.global.jwt.PrincipalDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +/** + * 노트 접근 권한 검증 AOP + * + *

    {@link NoteAccess} 어노테이션이 붙은 메서드 실행 전에 + * 요청한 사용자의 권한을 검증합니다. + * + *

    검증 항목: + *

      + *
    • 사용자 인증 확인
    • + *
    • 워크스페이스 멤버십 확인
    • + *
    • 노트 존재 여부 확인
    • + *
    • 노트 삭제 여부 확인
    • + *
    • DELETE 권한 필요 시 작성자 확인
    • + *
    + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class NoteAccessAspect { + + private final NoteRepository noteRepository; + private final NoteValidator noteValidator; + + /** + * @NoteAccess 어노테이션이 붙은 메서드 실행 전 권한 검증 + * + * @param joinPoint 프록시 정보 + * @param noteAccess 어노테이션 정보 + * @return 메서드 실행 결과 + * @throws Throwable 메서드 실행 중 발생한 예외 + */ + @Around("@annotation(noteAccess)") + public Object validateNoteAccess(ProceedingJoinPoint joinPoint, NoteAccess noteAccess) throws Throwable { + log.debug("NoteAccess 권한 검증 시작: level={}", noteAccess.level()); + + try { + // 1. 현재 사용자 정보 조회 + Long memberId = getCurrentMemberId(); + + // 2. 메서드 파라미터에서 noteId, workspaceId 추출 + Long noteId = extractNoteId(joinPoint, noteAccess.paramName()); + Long workspaceId = extractWorkspaceId(joinPoint, noteAccess.workspaceParamName()); + + log.debug("권한 검증: memberId={}, noteId={}, workspaceId={}, accessLevel={}", + memberId, noteId, workspaceId, noteAccess.level()); + + // 3. 기본 접근 권한 검증 (노트 존재, 워크스페이스 멤버십 등) + Note note = noteValidator.validateNoteAccess(noteId, workspaceId, memberId); + + // 4. 접근 레벨별 추가 권한 검증 + validateAccessLevel(note, memberId, noteAccess.level()); + + log.debug("권한 검증 성공: noteId={}, memberId={}, accessLevel={}", + noteId, memberId, noteAccess.level()); + + // 5. 메서드 실행 + return joinPoint.proceed(); + + } catch (NoteException e) { + log.warn("노트 접근 권한 검증 실패: {}", e.getCode().getMessage()); + throw e; + } catch (Exception e) { + log.error("권한 검증 중 예외 발생", e); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED); + } + } + + /** + * 현재 인증된 사용자의 memberId 조회 + * + * @return 사용자의 memberId + * @throws NoteException 인증되지 않은 사용자 + */ + private Long getCurrentMemberId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + log.warn("인증되지 않은 사용자가 접근 시도"); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED); + } + + try { + PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); + return Long.valueOf(principalDetails.getName()); + } catch (Exception e) { + log.error("사용자 정보 추출 실패", e); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED); + } + } + + /** + * 메서드 파라미터에서 noteId 추출 + * + * @param joinPoint 프록시 정보 + * @param paramName 파라미터 이름 + * @return 추출된 noteId + * @throws NoteException noteId를 찾을 수 없음 + */ + private Long extractNoteId(ProceedingJoinPoint joinPoint, String paramName) { + return extractLongParameter(joinPoint, paramName, "noteId"); + } + + /** + * 메서드 파라미터에서 workspaceId 추출 + * + * @param joinPoint 프록시 정보 + * @param paramName 파라미터 이름 + * @return 추출된 workspaceId + * @throws NoteException workspaceId를 찾을 수 없음 + */ + private Long extractWorkspaceId(ProceedingJoinPoint joinPoint, String paramName) { + return extractLongParameter(joinPoint, paramName, "workspaceId"); + } + + /** + * 메서드 파라미터에서 Long 타입 파라미터 추출 + * + * @param joinPoint 프록시 정보 + * @param paramName 파라미터 이름 + * @param paramType 파라미터 용도 (로깅용) + * @return 추출된 파라미터 값 + * @throws NoteException 파라미터를 찾을 수 없음 + */ + private Long extractLongParameter(ProceedingJoinPoint joinPoint, String paramName, String paramType) { + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + Method method = methodSignature.getMethod(); + Parameter[] parameters = method.getParameters(); + Object[] args = joinPoint.getArgs(); + + for (int i = 0; i < parameters.length; i++) { + if (parameters[i].getName().equals(paramName)) { + Object value = args[i]; + if (value instanceof Long) { + return (Long) value; + } else if (value instanceof String) { + try { + return Long.parseLong((String) value); + } catch (NumberFormatException e) { + log.error("{} 파라미터 파싱 실패: value={}", paramType, value); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED); + } + } else if (value instanceof Integer) { + return ((Integer) value).longValue(); + } + } + } + + log.error("{} 파라미터를 찾을 수 없음: paramName={}", paramType, paramName); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED, + String.format("%s 파라미터를 찾을 수 없습니다", paramType)); + } + + /** + * 접근 레벨별 추가 권한 검증 + * + * @param note 대상 노트 + * @param memberId 요청 사용자 ID + * @param accessLevel 필요한 접근 레벨 + * @throws NoteException 권한 없음 + */ + private void validateAccessLevel(Note note, Long memberId, AccessLevel accessLevel) { + switch (accessLevel) { + case READ: + // 모든 워크스페이스 멤버는 읽기 가능 + log.debug("READ 권한 검증 완료: 모든 멤버 접근 가능"); + break; + + case WRITE: + // WRITE 권한은 기존 READ 권한 검증으로 충분 + log.debug("WRITE 권한 검증 완료: 워크스페이스 멤버 편집 가능"); + break; + + case DELETE: + // DELETE 권한은 작성자만 가능 + noteValidator.validateNoteCreator(note, memberId); + log.debug("DELETE 권한 검증 완료: 작성자만 삭제 가능"); + break; + + default: + log.error("알 수 없는 접근 레벨: {}", accessLevel); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED); + } + } +} diff --git a/src/main/java/com/project/syncly/domain/note/controller/NoteController.java b/src/main/java/com/project/syncly/domain/note/controller/NoteController.java new file mode 100644 index 0000000..76d117e --- /dev/null +++ b/src/main/java/com/project/syncly/domain/note/controller/NoteController.java @@ -0,0 +1,194 @@ +package com.project.syncly.domain.note.controller; + +import com.project.syncly.domain.note.dto.NoteRequestDto; +import com.project.syncly.domain.note.dto.NoteResponseDto; +import com.project.syncly.domain.note.scheduler.NoteAutoSaveScheduler; +import com.project.syncly.domain.note.service.NoteService; +import com.project.syncly.global.apiPayload.CustomResponse; +import com.project.syncly.global.jwt.PrincipalDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/workspaces/{workspaceId}/notes") +@Tag(name = "Note API", description = "실시간 협업 노트 API") +public class NoteController { + + private final NoteService noteService; + private final NoteAutoSaveScheduler autoSaveScheduler; + + @PostMapping + @Operation( + summary = "노트 생성", + description = "워크스페이스에 새로운 노트를 생성합니다. 생성자는 자동으로 현재 인증된 사용자로 설정됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "노트 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (제목 누락 등)"), + @ApiResponse(responseCode = "403", description = "워크스페이스 멤버가 아님"), + @ApiResponse(responseCode = "404", description = "워크스페이스를 찾을 수 없음") + }) + public ResponseEntity> createNote( + @Parameter(description = "워크스페이스 ID") @PathVariable Long workspaceId, + @Valid @RequestBody NoteRequestDto.Create requestDto, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + Long memberId = Long.valueOf(userDetails.getName()); + + NoteResponseDto.Create response = noteService.createNote(workspaceId, requestDto, memberId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(CustomResponse.success(HttpStatus.CREATED, response)); + } + + @GetMapping + @Operation( + summary = "노트 목록 조회", + description = """ + 워크스페이스의 노트 목록을 조회합니다. + + **페이징 옵션:** + - page: 페이지 번호 (1부터 시작) + - size: 페이지당 항목 수 + - sort: 정렬 기준 (예: lastModifiedAt,desc) + + **응답 데이터:** + - 노트 목록과 페이징 정보 포함 + - 각 노트의 작성자 정보, 참여자 수 포함 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "노트 목록 조회 성공"), + @ApiResponse(responseCode = "403", description = "워크스페이스 멤버가 아님"), + @ApiResponse(responseCode = "404", description = "워크스페이스를 찾을 수 없음") + }) + public ResponseEntity> getNoteList( + @Parameter(description = "워크스페이스 ID") @PathVariable Long workspaceId, + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지당 항목 수") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "정렬 기준") @RequestParam(defaultValue = "lastModifiedAt") String sortBy, + @Parameter(description = "정렬 방향") @RequestParam(defaultValue = "desc") String direction, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + Long memberId = Long.valueOf(userDetails.getName()); + + Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy); + Pageable pageable = PageRequest.of(page, size, sort); + + NoteResponseDto.NoteList response = noteService.getNoteList(workspaceId, memberId, pageable); + + return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, response)); + } + + @GetMapping("/{noteId}") + @Operation( + summary = "노트 상세 조회", + description = """ + 노트의 상세 정보를 조회합니다. + + **응답 데이터:** + - 노트 내용, 작성자 정보 + - 현재 접속 중인 참여자 목록 + - 마지막 수정 시간 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "노트 상세 조회 성공"), + @ApiResponse(responseCode = "403", description = "워크스페이스 멤버가 아님"), + @ApiResponse(responseCode = "404", description = "노트를 찾을 수 없음") + }) + public ResponseEntity> getNoteDetail( + @Parameter(description = "워크스페이스 ID") @PathVariable Long workspaceId, + @Parameter(description = "노트 ID") @PathVariable Long noteId, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + Long memberId = Long.valueOf(userDetails.getName()); + + NoteResponseDto.Detail response = noteService.getNoteDetail(workspaceId, noteId, memberId); + + return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, response)); + } + + @DeleteMapping("/{noteId}") + @Operation( + summary = "노트 삭제", + description = "노트를 소프트 삭제합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "노트 삭제 성공"), + @ApiResponse(responseCode = "403", description = "워크스페이스 멤버가 아님"), + @ApiResponse(responseCode = "404", description = "노트를 찾을 수 없음") + }) + public ResponseEntity> deleteNote( + @Parameter(description = "워크스페이스 ID") @PathVariable Long workspaceId, + @Parameter(description = "노트 ID") @PathVariable Long noteId, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + Long memberId = Long.valueOf(userDetails.getName()); + + NoteResponseDto.Delete response = noteService.deleteNote(workspaceId, noteId, memberId); + + return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, response)); + } + + @PostMapping("/{noteId}/save") + @Operation( + summary = "노트 수동 저장", + description = """ + 사용자가 명시적으로 저장 버튼을 클릭했을 때 호출합니다. + + **처리 흐름:** + 1. Redis에서 현재 content와 revision 조회 + 2. DB의 Note 엔티티 업데이트 + 3. Redis dirty 플래그 false로 변경 + 4. WebSocket으로 저장 완료 메시지 브로드캐스트 + + **주의사항:** + - 자동 저장과 동일한 로직 사용 + - 독립적인 트랜잭션으로 처리 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "저장 성공"), + @ApiResponse(responseCode = "400", description = "Redis에 데이터가 없음"), + @ApiResponse(responseCode = "403", description = "워크스페이스 멤버가 아님"), + @ApiResponse(responseCode = "404", description = "노트를 찾을 수 없음") + }) + public ResponseEntity> saveNote( + @Parameter(description = "워크스페이스 ID") @PathVariable Long workspaceId, + @Parameter(description = "노트 ID") @PathVariable Long noteId, + @AuthenticationPrincipal PrincipalDetails userDetails + ) { + Long memberId = Long.valueOf(userDetails.getName()); + + // 권한 확인 (워크스페이스 멤버 여부만 확인) + noteService.validateWorkspaceMember(workspaceId, memberId); + + // 수동 저장 실행 + boolean saved = autoSaveScheduler.saveNoteManually(noteId); + + NoteResponseDto.SaveResponse response; + if (saved) { + // 저장 성공 시 revision 조회 + int revision = noteService.getRevisionFromRedis(noteId); + response = NoteResponseDto.SaveResponse.success(revision, java.time.LocalDateTime.now()); + } else { + response = NoteResponseDto.SaveResponse.failure("저장에 실패했습니다"); + } + + return ResponseEntity.ok(CustomResponse.success(HttpStatus.OK, response)); + } +} diff --git a/src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java b/src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java new file mode 100644 index 0000000..fd736ce --- /dev/null +++ b/src/main/java/com/project/syncly/domain/note/controller/NoteWebSocketController.java @@ -0,0 +1,1188 @@ +package com.project.syncly.domain.note.controller; + +import com.project.syncly.domain.note.dto.CursorPosition; +import com.project.syncly.domain.note.dto.EditOperation; +import com.project.syncly.domain.note.dto.NoteWebSocketDto; +import com.project.syncly.domain.note.dto.WebSocketMessage; +import com.project.syncly.domain.note.dto.WebSocketMessageType; +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.note.service.NoteRedisService; +import com.project.syncly.domain.note.service.OTService; +import com.project.syncly.domain.workspaceMember.entity.WorkspaceMember; +import com.project.syncly.domain.workspaceMember.repository.WorkspaceMemberRepository; +import com.project.syncly.global.jwt.PrincipalDetails; +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.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 실시간 협업 노트 WebSocket 메시지 핸들러 + * + *

    노트 입장/퇴장/편집 이벤트를 처리하고 참여자들에게 알림을 브로드캐스트합니다. + * + *

    메시지 라우팅: + *

      + *
    • 클라이언트 → /app/notes/{noteId}/enter - 노트 입장
    • + *
    • 클라이언트 → /app/notes/{noteId}/leave - 노트 퇴장
    • + *
    • 클라이언트 → /app/notes/{noteId}/edit - 편집 연산
    • + *
    • 서버 → /topic/notes/{noteId}/participants - 참여자 변경 (브로드캐스트)
    • + *
    • 서버 → /topic/notes/{noteId}/edits - 편집 연산 (브로드캐스트)
    • + *
    • 서버 → /queue/notes/{noteId}/enter - 입장 응답 (유니캐스트)
    • + *
    • 서버 → /queue/errors - 에러 메시지 (유니캐스트)
    • + *
    + */ +@Controller +@RequiredArgsConstructor +@Slf4j +public class NoteWebSocketController { + + private final SimpMessagingTemplate messagingTemplate; + private final NoteRedisService noteRedisService; + private final OTService otService; + private final NoteRepository noteRepository; + private final NoteParticipantRepository noteParticipantRepository; + private final WorkspaceMemberRepository workspaceMemberRepository; + private final RedisTemplate redisTemplate; + + /** + * 전체 content를 브로드캐스트하는 주기 (연산 개수 기준) + * 10개 연산마다 한 번씩 전체 content를 전송하여 클라이언트 동기화 + */ + private static final int FULL_CONTENT_BROADCAST_INTERVAL = 10; + + /** + * 편집 재시도 최대 횟수 + */ + private static final int MAX_RETRY_COUNT = 3; + + /** + * 노트 입장 핸들러 + * + *

    사용자가 노트에 입장하면: + *

      + *
    1. NoteParticipant 엔티티 조회 또는 생성 (isOnline=true 설정)
    2. + *
    3. Redis에 사용자 추가 (NOTE:USERS:{noteId})
    4. + *
    5. WebSocket 세션 매핑 저장 (WS:NOTE_SESSIONS:{sessionId} → noteId:workspaceMemberId)
    6. + *
    7. Redis에서 노트 내용, 버전, 활성 사용자 조회
    8. + *
    9. 입장한 사용자에게 EnterResponse 전송 (유니캐스트)
    10. + *
    11. 다른 참여자들에게 UserJoinedMessage 브로드캐스트
    12. + *
    + * + * @param noteId 노트 ID + * @param principal 인증된 사용자 정보 (memberId) + * @param headerAccessor WebSocket 세션 정보 + */ + @MessageMapping("/notes/{noteId}/enter") + public void handleEnter( + @DestinationVariable Long noteId, + Principal principal, + SimpMessageHeaderAccessor headerAccessor + ) { + Long memberId = extractMemberId(principal); + String sessionId = headerAccessor.getSessionId(); + + log.info("노트 입장 요청: noteId={}, memberId={}, sessionId={}", noteId, memberId, sessionId); + + // 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(); + String userName = workspaceMember.getName(); + String profileImage = workspaceMember.getProfileImage(); + + // 3. NoteParticipant 조회 또는 생성 (재시도 로직 포함) + NoteParticipant participant = null; + for (int attempt = 0; attempt < 3; attempt++) { + try { + participant = getOrCreateParticipantInTransaction(noteId, memberId, note, workspaceMember); + break; + } catch (Exception e) { + if (attempt == 2) { + log.error("참여자 생성 최종 실패: noteId={}, memberId={}", noteId, memberId, e); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED); + } + log.warn("참여자 생성 실패 ({}회 재시도): noteId={}, memberId={}, error={}", + attempt + 1, noteId, memberId, e.getMessage()); + try { + Thread.sleep(100L * (attempt + 1)); // exponential backoff + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new NoteException(NoteErrorCode.NOTE_ACCESS_DENIED); + } + } + } + + // 5. Redis에 사용자 추가 + noteRedisService.addUser(noteId, workspaceMemberId); + + // 6. WebSocket 세션 매핑 저장 (disconnect 시 자동 정리용) + String sessionData = noteId + ":" + workspaceMemberId; + redisTemplate.opsForHash().put( + RedisKeyPrefix.WS_NOTE_SESSIONS.get(), + sessionId, + sessionData + ); + + // 7. Redis에서 노트 데이터 조회 (없으면 DB에서 초기화) + String content = noteRedisService.getContent(noteId); + if (content == null) { + // Redis에 없으면 DB에서 로드하여 초기화 + noteRedisService.initializeNote(noteId, note.getContent()); + content = note.getContent(); + } + + Integer revision = noteRedisService.getRevision(noteId); + List activeUserIds = noteRedisService.getActiveUsers(noteId).stream() + .map(Long::valueOf) + .collect(Collectors.toList()); + + // 8. 입장한 사용자에게 EnterResponse 전송 (유니캐스트) + NoteWebSocketDto.EnterResponse enterResponse = new NoteWebSocketDto.EnterResponse( + noteId, + note.getTitle(), + content, + revision, + activeUserIds, + LocalDateTime.now() + ); + + WebSocketMessage enterMessage = WebSocketMessage.of( + WebSocketMessageType.ENTER, + enterResponse, + workspaceMemberId + ); + + messagingTemplate.convertAndSendToUser( + principal.getName(), + "/queue/notes/" + noteId + "/enter", + enterMessage + ); + + // 9. 다른 참여자들에게 UserJoinedMessage 브로드캐스트 + NoteWebSocketDto.UserJoinedMessage joinedMessage = new NoteWebSocketDto.UserJoinedMessage( + workspaceMemberId, + userName, + profileImage, + activeUserIds.size(), + LocalDateTime.now() + ); + + WebSocketMessage broadcastMessage = WebSocketMessage.of( + WebSocketMessageType.ENTER, + joinedMessage, + workspaceMemberId + ); + + messagingTemplate.convertAndSend( + "/topic/notes/" + noteId + "/participants", + broadcastMessage + ); + + log.info("노트 입장 완료: noteId={}, workspaceMemberId={}, activeUsers={}", + noteId, workspaceMemberId, activeUserIds.size()); + } + + /** + * 노트 퇴장 핸들러 + * + *

    사용자가 노트에서 퇴장하면: + *

      + *
    1. NoteParticipant의 isOnline 상태를 false로 변경
    2. + *
    3. Redis에서 사용자 제거 (NOTE:USERS:{noteId})
    4. + *
    5. WebSocket 세션 매핑 삭제
    6. + *
    7. 퇴장한 사용자에게 LeaveResponse 전송 (유니캐스트)
    8. + *
    9. 다른 참여자들에게 UserLeftMessage 브로드캐스트
    10. + *
    + * + * @param noteId 노트 ID + * @param principal 인증된 사용자 정보 (memberId) + * @param headerAccessor WebSocket 세션 정보 + */ + @MessageMapping("/notes/{noteId}/leave") + @Transactional + public void handleLeave( + @DestinationVariable Long noteId, + Principal principal, + SimpMessageHeaderAccessor headerAccessor + ) { + Long memberId = extractMemberId(principal); + String sessionId = headerAccessor.getSessionId(); + + log.info("노트 퇴장 요청: noteId={}, memberId={}, sessionId={}", noteId, memberId, sessionId); + + // 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(); + String userName = workspaceMember.getName(); + + // 3. NoteParticipant 오프라인 상태로 변경 + noteParticipantRepository.updateToOffline(noteId, memberId); + + // 4. Redis에서 사용자 제거 + noteRedisService.removeUser(noteId, workspaceMemberId); + + // 5. WebSocket 세션 매핑 삭제 + redisTemplate.opsForHash().delete( + RedisKeyPrefix.WS_NOTE_SESSIONS.get(), + sessionId + ); + + // 6. 현재 활성 사용자 수 조회 + int activeUserCount = noteRedisService.getActiveUsers(noteId).size(); + + // 7. 퇴장한 사용자에게 LeaveResponse 전송 (유니캐스트) + NoteWebSocketDto.LeaveResponse leaveResponse = new NoteWebSocketDto.LeaveResponse( + noteId, + "노트에서 퇴장했습니다.", + LocalDateTime.now() + ); + + WebSocketMessage leaveMessage = WebSocketMessage.of( + WebSocketMessageType.LEAVE, + leaveResponse, + workspaceMemberId + ); + + messagingTemplate.convertAndSendToUser( + principal.getName(), + "/queue/notes/" + noteId + "/leave", + leaveMessage + ); + + // 8. 다른 참여자들에게 UserLeftMessage 브로드캐스트 + NoteWebSocketDto.UserLeftMessage leftMessage = new NoteWebSocketDto.UserLeftMessage( + workspaceMemberId, + userName, + activeUserCount, + LocalDateTime.now() + ); + + WebSocketMessage broadcastMessage = WebSocketMessage.of( + WebSocketMessageType.LEAVE, + leftMessage, + workspaceMemberId + ); + + messagingTemplate.convertAndSend( + "/topic/notes/" + noteId + "/participants", + broadcastMessage + ); + + log.info("노트 퇴장 완료: noteId={}, workspaceMemberId={}, remainingUsers={}", + noteId, workspaceMemberId, activeUserCount); + } + + /** + * 노트 편집 핸들러 + * + *

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

    처리 흐름: + *

      + *
    1. 사용자 권한 검증 (해당 노트의 active user인지)
    2. + *
    3. OTService.processEdit() 호출 (transform + 적용 + Redis 저장)
    4. + *
    5. 성공 시 모든 참여자에게 EditBroadcastMessage 전송
    6. + *
    7. 10개 연산마다 전체 content 포함 (동기화)
    8. + *
    9. 실패 시 재시도 (최대 3회)
    10. + *
    + * + * @param noteId 노트 ID + * @param request 편집 요청 (EditOperation 포함) + * @param principal 인증된 사용자 정보 (memberId) + */ + @MessageMapping("/notes/{noteId}/edit") + public void handleEdit( + @DestinationVariable Long noteId, + @Payload NoteWebSocketDto.EditRequest request, + Principal principal + ) { + Long memberId = extractMemberId(principal); + EditOperation operation = request.operation(); + + log.debug("편집 요청 수신: noteId={}, memberId={}, operation={}", + noteId, memberId, operation); + + 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(); + String userName = workspaceMember.getName(); + + // 3. 활성 사용자 검증 (노트에 입장한 상태인지) + if (!noteRedisService.getActiveUsers(noteId).contains(String.valueOf(workspaceMemberId))) { + log.warn("비활성 사용자의 편집 시도: noteId={}, workspaceMemberId={}", noteId, workspaceMemberId); + sendErrorToUser(principal.getName(), noteId, + NoteErrorCode.NOTE_ACCESS_DENIED.getCode(), + "노트에 먼저 입장해주세요."); + return; + } + + // 4. 편집 처리 (재시도 로직 포함) + OTService.ProcessEditResult result = processEditWithRetry(noteId, operation, MAX_RETRY_COUNT); + + log.info("편집 성공: noteId={}, workspaceMemberId={}, type={}, position={}, " + + "originalRevision={}, newRevision={}, contentLength={}", + noteId, workspaceMemberId, operation.getType(), operation.getPosition(), + operation.getRevision(), result.revision(), result.content().length()); + + // 5. 전체 content 포함 여부 결정 (10개 연산마다) + boolean includeFullContent = (result.revision() % FULL_CONTENT_BROADCAST_INTERVAL == 0); + String contentToSend = includeFullContent ? result.content() : null; + + // 6. 모든 참여자에게 EditBroadcastMessage 브로드캐스트 + NoteWebSocketDto.EditBroadcastMessage broadcastMessage = + new NoteWebSocketDto.EditBroadcastMessage( + result.appliedOperation(), + contentToSend, + result.revision(), + workspaceMemberId, + userName, + LocalDateTime.now(), + includeFullContent + ); + + WebSocketMessage 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에 저장하고 다른 참여자들에게 브로드캐스트합니다. + * + *

    처리 흐름: + *

      + *
    1. 사용자 권한 검증 (활성 사용자인지)
    2. + *
    3. CursorPosition 객체 생성 (위치, 범위, 사용자 정보, 색상)
    4. + *
    5. Redis에 커서 저장 (TTL 10분)
    6. + *
    7. 본인을 제외한 다른 참여자들에게 브로드캐스트
    8. + *
    + * + *

    성능 최적화: + *

      + *
    • 클라이언트는 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 = "![image](https://...)") + String markdownSyntax + ) { + /** + * 이미지 URL로부터 응답 생성 + */ + public static ImageUrlResponse from(String imageUrl, String originalFilename) { + String markdown = String.format("![%s](%s)", 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초마다 + * + *

    처리 흐름: + *

      + *
    1. Redis에서 모든 dirty 노트 스캔 (SCAN 사용)
    2. + *
    3. 각 노트를 독립적인 트랜잭션으로 저장
    4. + *
    5. 저장 성공 시 dirty 플래그 false로 변경
    6. + *
    7. WebSocket으로 저장 완료 메시지 브로드캐스트
    8. + *
    + * + *

    안정성 보장: + *

      + *
    • 동시 실행 방지 (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 * * * *") + * + *

    처리 내용: + *

      + *
    1. 만료된 PENDING 이미지 조회
    2. + *
    3. S3 객체 삭제
    4. + *
    5. DB 레코드 삭제
    6. + *
    + */ +@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을 활용한 이미지 업로드 기능을 제공합니다. + * + *

    업로드 플로우: + *

      + *
    1. 클라이언트: POST /presigned 요청 → uploadUrl, imageId 받음
    2. + *
    3. 클라이언트: uploadUrl로 PUT 요청 (파일 직접 업로드)
    4. + *
    5. 클라이언트: POST /images/{imageId}/confirm → imageUrl 받음
    6. + *
    7. 클라이언트: imageUrl을 마크다운에 삽입
    8. + *
    9. 클라이언트: WebSocket EDIT 메시지로 content 업데이트
    10. + *
    + */ +@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에 연산 적용 및 저장
    • + *
    + * + *

    처리 흐름: + *

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

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

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

    사용 예시: + *

    {@code
    +     * EditOperation op = EditOperation.insert(10, "Hello", 5, 123L);
    +     * ProcessEditResult result = otService.processEdit(noteId, op);
    +     * // result.content: 새 문서 내용
    +     * // result.revision: 새 버전 번호
    +     * }
    + * + * @param noteId 노트 ID + * @param operation 처리할 편집 연산 + * @return 처리 결과 (새 content, 새 revision) + * @throws NoteException 연산이 유효하지 않거나 적용에 실패한 경우 + */ + public ProcessEditResult processEdit(Long noteId, EditOperation operation) { + log.debug("편집 연산 처리 시작: noteId={}, operation={}", noteId, operation); + + // 1. Redis에서 현재 상태 조회 + String currentContent = noteRedisService.getContent(noteId); + if (currentContent == null) { + throw new NoteException(NoteErrorCode.NOTE_NOT_FOUND); + } + + int currentRevision = noteRedisService.getRevision(noteId); + + // 2. 연산 유효성 검증 + validateOperation(operation, currentContent); + + // 3. 연산 변환 (필요한 경우) + EditOperation transformedOp = operation; + + if (operation.getRevision() < currentRevision) { + log.debug("구버전 연산 감지: op.revision={}, current={}, 변환 필요", + operation.getRevision(), currentRevision); + + // operation.revision 이후의 모든 히스토리 조회 + List history = noteRedisService.getOperations(noteId, operation.getRevision()); + + log.debug("히스토리 조회 완료: {} 개의 연산", history.size()); + + // 히스토리의 각 연산 로깅 + for (int i = 0; i < history.size(); i++) { + EditOperation histOp = history.get(i); + log.debug(" [{}] {}", i + 1, histOp); + } + + // OT 변환 수행 + transformedOp = OTEngine.transformAgainstHistory(operation, history); + + log.debug("변환 완료: original={}, transformed={}", operation, transformedOp); + + // ⚠️ 변환 후 다시 검증하지 않음! + // 변환된 operation은 현재 content에 대해 유효하지 않을 수 있지만, + // OT 변환 알고리즘이 유효성을 보장하므로 검증 불필요. + // 실제 적용 시 DELETE 범위 조정(line 124)으로 안전성 확보. + } + + // 4. 연산을 content에 적용 + String newContent; + try { + // DELETE 연산의 경우 범위 초과를 자동으로 조정 + EditOperation adjustedOp = transformedOp; + if (transformedOp.isDelete()) { + int deleteStart = transformedOp.getPosition(); + int deleteEnd = transformedOp.getEndPosition(); + int contentLength = currentContent.length(); + + // DELETE 범위가 content를 초과하는 경우 조정 + if (deleteEnd > contentLength) { + log.warn("DELETE 범위 조정: pos={}, len={}, content.length={} → len={}", + deleteStart, transformedOp.getLength(), contentLength, + Math.max(0, contentLength - deleteStart)); + + // 조정된 길이로 새 연산 생성 + int adjustedLength = Math.max(0, contentLength - deleteStart); + adjustedOp = transformedOp.withLength(adjustedLength); + } + } + + newContent = OTEngine.applyOperation(currentContent, adjustedOp); + } catch (IllegalArgumentException e) { + log.error("연산 적용 실패: operation={}, content.length={}, error={}", + transformedOp, currentContent.length(), e.getMessage()); + throw new NoteException(NoteErrorCode.INVALID_OPERATION); + } + + // 5. Redis 업데이트 + // 5-1. Content 업데이트 (자동으로 dirty 플래그 설정됨) + noteRedisService.setContent(noteId, newContent); + + // 5-2. Revision 증가 + int newRevision = noteRedisService.incrementRevision(noteId); + + // 5-3. 변환된 연산을 히스토리에 추가 (새 revision으로) + EditOperation historyOp = transformedOp.isNoOp() ? transformedOp : + EditOperation.builder() + .type(transformedOp.getType()) + .position(transformedOp.getPosition()) + .length(transformedOp.getLength()) + .content(transformedOp.getContent()) + .revision(newRevision) // 새 revision 할당 + .workspaceMemberId(transformedOp.getWorkspaceMemberId()) + .timestamp(transformedOp.getTimestamp()) + .build(); + + noteRedisService.addOperation(noteId, historyOp); + + log.info("편집 연산 처리 완료: noteId={}, revision={}, contentLength={}", + noteId, newRevision, newContent.length()); + + return new ProcessEditResult(newContent, newRevision, transformedOp); + } + + /** + * 편집 연산의 유효성을 검증합니다. + * + *

    검증 항목: + *

      + *
    • 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); + } + } +}