diff --git a/build.gradle b/build.gradle index 7683947..9dfe6fa 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,8 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // web socket + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { diff --git a/src/main/java/org/sopt/assignment/article/controller/ArticleController.java b/src/main/java/org/sopt/assignment/article/controller/ArticleController.java index 72948c0..e501efa 100644 --- a/src/main/java/org/sopt/assignment/article/controller/ArticleController.java +++ b/src/main/java/org/sopt/assignment/article/controller/ArticleController.java @@ -3,7 +3,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.sopt.assignment.article.domain.ESearchType; -import org.sopt.assignment.article.domain.ETag; import org.sopt.assignment.article.dto.command.SaveArticleCommandDto; import org.sopt.assignment.article.dto.request.SaveArticleRequestDto; import org.sopt.assignment.article.dto.response.ArticleResponseDto; diff --git a/src/main/java/org/sopt/assignment/article/domain/Article.java b/src/main/java/org/sopt/assignment/article/domain/Article.java index fee106e..436a772 100644 --- a/src/main/java/org/sopt/assignment/article/domain/Article.java +++ b/src/main/java/org/sopt/assignment/article/domain/Article.java @@ -14,8 +14,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Table(name = "articles", indexes = { - @Index(name = "idx_member_id", columnList = "member_id"), - @Index(name = "idx_title", columnList = "title"), + @Index(name = "idx_member_created", columnList = "member_id, created_at"), + @Index(name = "idx_created_at", columnList = "created_at") }) public class Article extends BaseTimeEntity { diff --git a/src/main/java/org/sopt/assignment/article/domain/ETag.java b/src/main/java/org/sopt/assignment/article/domain/ETag.java index 7e8c07e..5d34d2e 100644 --- a/src/main/java/org/sopt/assignment/article/domain/ETag.java +++ b/src/main/java/org/sopt/assignment/article/domain/ETag.java @@ -1,7 +1,6 @@ package org.sopt.assignment.article.domain; import lombok.Getter; -import lombok.RequiredArgsConstructor; @Getter public enum ETag { diff --git a/src/main/java/org/sopt/assignment/article/service/ArticleService.java b/src/main/java/org/sopt/assignment/article/service/ArticleService.java index 2a58c95..c5573a9 100644 --- a/src/main/java/org/sopt/assignment/article/service/ArticleService.java +++ b/src/main/java/org/sopt/assignment/article/service/ArticleService.java @@ -63,6 +63,18 @@ public GetListArticleResponseDto searchArticle(ESearchType searchType, String ke return GetListArticleResponseDto.of(articlePage.map(GetListArticleResponse::from)); } + @Transactional(readOnly = true) + public Article get(Long articleId){ + return articleRepository.findById(articleId) + .orElseThrow(()-> BaseException.type(ArticleErrorCode.NOT_FOUND_ARTICLE)); + } + + @Transactional(readOnly = true) + public void validateArticleExists(Long articleId){ + if(!articleRepository.existsById(articleId)) + throw BaseException.type(ArticleErrorCode.NOT_FOUND_ARTICLE); + } + private void validateDuplicateTitle(String title){ if(articleRepository.existsByTitle(title)){ throw BaseException.type(ArticleErrorCode.ALREADY_USED_ARTICLE_TITLE); diff --git a/src/main/java/org/sopt/assignment/chat/controller/ChatController.java b/src/main/java/org/sopt/assignment/chat/controller/ChatController.java new file mode 100644 index 0000000..592187c --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/controller/ChatController.java @@ -0,0 +1,33 @@ +package org.sopt.assignment.chat.controller; + +import lombok.RequiredArgsConstructor; +import org.sopt.assignment.chat.domain.ChatMessage; +import org.sopt.assignment.chat.domain.ConnectedUser; +import org.sopt.assignment.chat.service.ChatService; +import org.sopt.assignment.chat.service.ChatSessionManager; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +public class ChatController { + private final ChatService chatService; + private final ChatSessionManager chatSessionManager; + + @GetMapping("/messages") + public List getMessages( + @RequestParam(defaultValue = "50") int count + ) { + return chatService.getRecentMessages(count); + } + + @GetMapping("/users") + public List getConnectedUsers() { + return chatSessionManager.getConnectedUsers(); + } +} diff --git a/src/main/java/org/sopt/assignment/chat/domain/ChatMessage.java b/src/main/java/org/sopt/assignment/chat/domain/ChatMessage.java new file mode 100644 index 0000000..f2aeeb4 --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/domain/ChatMessage.java @@ -0,0 +1,19 @@ +package org.sopt.assignment.chat.domain; + +import java.time.LocalDateTime; + +public record ChatMessage( + EMessageType type, + + String sender, + + String content, + + LocalDateTime timestamp + +) { + + public static ChatMessage of(EMessageType type, String sender, String content) { + return new ChatMessage(type, sender, content, LocalDateTime.now()); + } +} diff --git a/src/main/java/org/sopt/assignment/chat/domain/ConnectedUser.java b/src/main/java/org/sopt/assignment/chat/domain/ConnectedUser.java new file mode 100644 index 0000000..47b20b3 --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/domain/ConnectedUser.java @@ -0,0 +1,13 @@ +package org.sopt.assignment.chat.domain; + +public record ConnectedUser( + Long userId, + + String userName, + + String sessionId +) { + public static ConnectedUser of(Long userId, String userName, String sessionId) { + return new ConnectedUser(userId, userName, sessionId); + } +} diff --git a/src/main/java/org/sopt/assignment/chat/domain/EMessageType.java b/src/main/java/org/sopt/assignment/chat/domain/EMessageType.java new file mode 100644 index 0000000..161bf6c --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/domain/EMessageType.java @@ -0,0 +1,9 @@ +package org.sopt.assignment.chat.domain; + +public enum EMessageType { + CHAT, + ENTER, + LEAVE, + TYPING_START, + TYPING_END +} diff --git a/src/main/java/org/sopt/assignment/chat/exception/ChatErrorCode.java b/src/main/java/org/sopt/assignment/chat/exception/ChatErrorCode.java new file mode 100644 index 0000000..f9f41be --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/exception/ChatErrorCode.java @@ -0,0 +1,18 @@ +package org.sopt.assignment.chat.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.sopt.assignment.global.exception.ErrorCode; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +@Getter +public enum ChatErrorCode implements ErrorCode { + + FAILED_SAVE_MESSAGE(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT_001", "메시지 저장에 실패했습니다."),; + + private final HttpStatus status; + private final String errorCode; + private final String message; + +} diff --git a/src/main/java/org/sopt/assignment/chat/handler/ChatWebSocketHandler.java b/src/main/java/org/sopt/assignment/chat/handler/ChatWebSocketHandler.java new file mode 100644 index 0000000..881a022 --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/handler/ChatWebSocketHandler.java @@ -0,0 +1,92 @@ +package org.sopt.assignment.chat.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.assignment.chat.domain.ChatMessage; +import org.sopt.assignment.chat.service.ChatService; +import org.sopt.assignment.chat.service.ChatSessionManager; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.io.IOException; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatWebSocketHandler extends TextWebSocketHandler { + + private final Set sessions = new CopyOnWriteArraySet<>(); + private final ObjectMapper objectMapper; + private final ChatService chatService; + private final ChatSessionManager chatSessionManager; + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + sessions.add(session); + + Long memberId = getUserId(session); + log.info("새로운 연결: {}, 현재 접속자: {}", session.getId(), sessions.size()); + + chatSessionManager.addUser(memberId, session.getId()); + + ChatMessage enterMessage = chatService.createEnterMessage(memberId); + broadcast(enterMessage); + } + + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + String payload = message.getPayload(); + Long memberId = getUserId(session); + log.info("받은 메시지: {} from {}", payload, session.getId()); + + try { + ChatMessage chatMessage = chatService.processMessage(payload, memberId); + broadcast(chatMessage); + } catch (Exception e) { + log.error("메시지 처리 실패: sessionId = {}", session.getId(), e); + } + } + + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + sessions.remove(session); + log.info("연결 종료: {}, 현재 접속자: {}", session.getId(), sessions.size()); + Long memberId = getUserId(session); + chatSessionManager.removeUserBySessionId(session.getId()); + ChatMessage leaveMessage = chatService.createLeaveMessage(memberId); + broadcast(leaveMessage); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { + log.error("에러 발생: {}", session.getId(), exception); + if (!session.isOpen()) { + log.warn("세션이 실제로 닫혔음, 제거 처리: sessionId={}", session.getId()); + sessions.remove(session); + chatSessionManager.removeUserBySessionId(session.getId()); + } + } + + private void broadcast(ChatMessage message) { + sessions.forEach(session -> { + try{ + String json = objectMapper.writeValueAsString(message); + session.sendMessage(new TextMessage(json)); + } catch (IOException e){ + log.error("메시지 전송 실패: {}", session.getId(), e); + } + }); + } + + private Long getUserId(WebSocketSession session) { + return (Long) session.getAttributes().get("userId"); + } +} diff --git a/src/main/java/org/sopt/assignment/chat/service/ChatService.java b/src/main/java/org/sopt/assignment/chat/service/ChatService.java new file mode 100644 index 0000000..948e514 --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/service/ChatService.java @@ -0,0 +1,111 @@ +package org.sopt.assignment.chat.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.assignment.chat.domain.EMessageType; +import org.sopt.assignment.chat.domain.ChatMessage; +import org.sopt.assignment.chat.exception.ChatErrorCode; +import org.sopt.assignment.global.constants.Constants; +import org.sopt.assignment.global.exception.BaseException; +import org.sopt.assignment.member.service.MemberService; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +import static org.sopt.assignment.global.constants.Constants.CHAT_MESSAGES_KEY; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatService { + + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + private final MemberService memberService; + + public ChatMessage processMessage(String payload, Long memberId){ + try{ + ChatMessage message = objectMapper.readValue(payload, ChatMessage.class); + + String memberName = memberService.getMemberById(memberId).getName(); + + if(message.type() == EMessageType.TYPING_START || message.type() == EMessageType.TYPING_END){ + return ChatMessage.of(message.type(), memberName, ""); + } + + ChatMessage chatMessage = ChatMessage.of(message.type(), memberName, message.content()); + + if(message.type() == EMessageType.CHAT){ + saveMessage(chatMessage); + } + + return chatMessage; + } catch (JsonProcessingException e){ + log.error("메시지 저장 실패", e); + throw BaseException.type(ChatErrorCode.FAILED_SAVE_MESSAGE); + } + + } + + + public ChatMessage createEnterMessage(Long memberId) { + String memberName = memberService.getMemberById(memberId).getName(); + + return ChatMessage.of( + EMessageType.ENTER, + memberName, + memberName + "님이 입장했습니다." + ); + } + + // 퇴장 메시지 생성 + public ChatMessage createLeaveMessage(Long memberId) { + String memberName = memberService.getMemberById(memberId).getName(); + + return ChatMessage.of( + EMessageType.LEAVE, + memberName, + memberName + "님이 퇴장했습니다." + ); + } + + public void saveMessage(ChatMessage message) { + try { + String json = objectMapper.writeValueAsString(message); + + redisTemplate.opsForList().leftPush(CHAT_MESSAGES_KEY, json); + + redisTemplate.opsForList().trim(CHAT_MESSAGES_KEY, 0, Constants.MAX_MESSAGE_COUNT - 1); + + log.info("Redis 저장: {}", message.content()); + } catch (JsonProcessingException e){ + log.error("메시지 저장 실패", e); + } + } + + public List getRecentMessages(int count) { + List messages = redisTemplate.opsForList().range( + CHAT_MESSAGES_KEY, + 0, + count - 1 + ); + + List result = new ArrayList<>(); + if (messages != null) { + for (String json : messages) { + try { + result.add(objectMapper.readValue(json, ChatMessage.class)); + } catch (JsonProcessingException e) { + log.error("메시지 파싱 실패: {}", json, e); + } + } + } + + return result; + } + +} diff --git a/src/main/java/org/sopt/assignment/chat/service/ChatSessionManager.java b/src/main/java/org/sopt/assignment/chat/service/ChatSessionManager.java new file mode 100644 index 0000000..e8269d1 --- /dev/null +++ b/src/main/java/org/sopt/assignment/chat/service/ChatSessionManager.java @@ -0,0 +1,73 @@ +package org.sopt.assignment.chat.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.assignment.chat.domain.ConnectedUser; +import org.sopt.assignment.member.service.MemberService; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static org.sopt.assignment.global.constants.Constants.CONNECTED_USERS_KEY; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatSessionManager { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final MemberService memberService; + private final Map sessionUserMap = new ConcurrentHashMap<>(); + + + public void addUser(Long userId, String sessionId){ + String userName = memberService.getMemberById(userId).getName(); + + try{ + sessionUserMap.put(sessionId, userId); + ConnectedUser user = ConnectedUser.of(userId, userName, sessionId); + String json = objectMapper.writeValueAsString(user); + + redisTemplate.opsForHash().put(CONNECTED_USERS_KEY, userId.toString(), json); + + log.info("접속자 추가: userId = {}, userName = {}, 현재 접속자 = {}", userId, userName, getConnectedUserCount()); + } catch (JsonProcessingException e){ + log.error("사용자 추가 실패", e); + } + } + + public void removeUserBySessionId(String sessionId){ + Long userId = sessionUserMap.get(sessionId); + if(userId != null){ + redisTemplate.opsForHash().delete(CONNECTED_USERS_KEY, userId.toString()); + sessionUserMap.remove(sessionId); + + log.info("접속자 제거: userId = {}, sessionId = {}", userId, sessionId); + } + } + + public List getConnectedUsers(){ + Map entries = redisTemplate.opsForHash().entries(CONNECTED_USERS_KEY); + List users = new ArrayList<>(); + + for (Object value : entries.values()) { + try{ + users.add(objectMapper.readValue((String) value, ConnectedUser.class)); + } catch (JsonProcessingException e){ + log.error("사용자 파싱 실패", e); + } + } + return users; + } + + public long getConnectedUserCount() { + return redisTemplate.opsForHash().size(CONNECTED_USERS_KEY); + } +} diff --git a/src/main/java/org/sopt/assignment/comment/controller/CommentController.java b/src/main/java/org/sopt/assignment/comment/controller/CommentController.java new file mode 100644 index 0000000..0a320de --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/controller/CommentController.java @@ -0,0 +1,90 @@ +package org.sopt.assignment.comment.controller; + +import lombok.RequiredArgsConstructor; +import org.sopt.assignment.comment.dto.command.CreateCommentCommandDto; +import org.sopt.assignment.comment.dto.command.UpdateCommentCommandDto; +import org.sopt.assignment.comment.dto.request.CreateCommentRequestDto; +import org.sopt.assignment.comment.dto.request.UpdateCommentRequestDto; +import org.sopt.assignment.comment.dto.response.GetCommentResponseDto; +import org.sopt.assignment.comment.service.CommentService; +import org.sopt.assignment.global.annotation.LoginUser; +import org.sopt.assignment.global.dto.PageBaseDto; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/articles/{articleId}/comments") +public class CommentController { + + private final CommentService commentService; + + @PostMapping + public void createComment(@PathVariable Long articleId, + @RequestBody CreateCommentRequestDto request, + @LoginUser Long memberId) { + commentService.createComment(CreateCommentCommandDto.of(request, memberId, articleId)); + } + + @GetMapping("/v1") + public PageBaseDto getCommentV1(@PathVariable Long articleId, + @RequestParam(defaultValue = "0") int page){ + + Pageable pageable = PageRequest.of(page , 10); + + return commentService.getCommentsV1(articleId, pageable); + } + + + @GetMapping("/v2") + public PageBaseDto getCommentV2(@PathVariable Long articleId, + @RequestParam(defaultValue = "0") int page){ + + Pageable pageable = PageRequest.of(page , 10); + + return commentService.getCommentsV2(articleId, pageable); + } + + @GetMapping("/v3") + public PageBaseDto getCommentV3(@PathVariable Long articleId, + @RequestParam(defaultValue = "0") int page){ + + Pageable pageable = PageRequest.of(page , 10); + + return commentService.getCommentsV3(articleId, pageable); + } + + @GetMapping("/v4") + public PageBaseDto getCommentV4(@PathVariable Long articleId, + @RequestParam(defaultValue = "0") int page){ + + Pageable pageable = PageRequest.of(page , 10); + + return commentService.getCommentsV4(articleId, pageable); + } + + @GetMapping("/v5") + public PageBaseDto getCommentV5(@PathVariable Long articleId, + @RequestParam(defaultValue = "0") int page){ + + Pageable pageable = PageRequest.of(page , 10); + + return commentService.getCommentsV5(articleId, pageable); + } + + @PatchMapping("/{commentId}") + public void updateComment(@PathVariable Long articleId, + @PathVariable Long commentId, + @RequestBody UpdateCommentRequestDto request, + @LoginUser Long memberId){ + commentService.updateComment(UpdateCommentCommandDto.of(articleId, commentId, request, memberId)); + } + + @DeleteMapping("/{commentId}") + public void deleteComment(@PathVariable Long articleId, + @PathVariable Long commentId, + @LoginUser Long memberId){ + commentService.deleteComment(articleId, commentId, memberId); + } +} diff --git a/src/main/java/org/sopt/assignment/comment/domain/Comment.java b/src/main/java/org/sopt/assignment/comment/domain/Comment.java new file mode 100644 index 0000000..2fc578f --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/domain/Comment.java @@ -0,0 +1,72 @@ +package org.sopt.assignment.comment.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.sopt.assignment.article.domain.Article; +import org.sopt.assignment.global.base.BaseTimeEntity; +import org.sopt.assignment.member.domain.Member; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Table(name = "comments") +public class Comment extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content", length = 300, nullable = false) + private String content; + + @Column(name = "isUpdate", nullable = false) + private boolean isUpdate = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "article_id", + foreignKey = @ForeignKey( + name = "fk_comment_article", + foreignKeyDefinition = "FOREIGN KEY (article_id) REFERENCES articles(id) ON DELETE CASCADE" + ) + )private Article article; + + @Builder + private Comment(final String content, + final Member member, + final Article article) { + this.content = content; + this.member = member; + this.article = article; + this.isUpdate = false; + } + + public static Comment create(final String content, + final Member member, + final Article article) { + return Comment.builder() + .content(content) + .member(member) + .article(article) + .build(); + } + + public boolean belongsToArticle(Long articleId) { + return this.article.getId().equals(articleId); + } + + public boolean isOwnedBy(Long memberId) { + return this.member.getId().equals(memberId); + } + + public void update(final String content){ + this.content = content; + this.isUpdate = true; + } +} diff --git a/src/main/java/org/sopt/assignment/comment/dto/command/CreateCommentCommandDto.java b/src/main/java/org/sopt/assignment/comment/dto/command/CreateCommentCommandDto.java new file mode 100644 index 0000000..24ad62b --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/dto/command/CreateCommentCommandDto.java @@ -0,0 +1,15 @@ +package org.sopt.assignment.comment.dto.command; + +import org.sopt.assignment.comment.dto.request.CreateCommentRequestDto; + +public record CreateCommentCommandDto( + String comment, + + Long articleId, + + Long memberId +) { + public static CreateCommentCommandDto of(CreateCommentRequestDto requestDto, Long articleId, Long memberId) { + return new CreateCommentCommandDto(requestDto.comment(), articleId, memberId); + } +} diff --git a/src/main/java/org/sopt/assignment/comment/dto/command/UpdateCommentCommandDto.java b/src/main/java/org/sopt/assignment/comment/dto/command/UpdateCommentCommandDto.java new file mode 100644 index 0000000..b0f6693 --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/dto/command/UpdateCommentCommandDto.java @@ -0,0 +1,20 @@ +package org.sopt.assignment.comment.dto.command; + +import org.sopt.assignment.comment.dto.request.UpdateCommentRequestDto; + +public record UpdateCommentCommandDto( + Long articleId, + + Long commentId, + + String content, + + Long memberId +) { + public static UpdateCommentCommandDto of(Long articleId, + Long commentId, + UpdateCommentRequestDto request, + Long memberId) { + return new UpdateCommentCommandDto(articleId, commentId, request.comment(), memberId); + } +} diff --git a/src/main/java/org/sopt/assignment/comment/dto/query/CommentQueryDto.java b/src/main/java/org/sopt/assignment/comment/dto/query/CommentQueryDto.java new file mode 100644 index 0000000..06c712a --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/dto/query/CommentQueryDto.java @@ -0,0 +1,17 @@ +package org.sopt.assignment.comment.dto.query; + +import java.time.LocalDateTime; + +public record CommentQueryDto( + Long commentId, + + String name, + + LocalDateTime createdAt, + + String content, + + boolean isUpdate + +) { +} diff --git a/src/main/java/org/sopt/assignment/comment/dto/request/CreateCommentRequestDto.java b/src/main/java/org/sopt/assignment/comment/dto/request/CreateCommentRequestDto.java new file mode 100644 index 0000000..6b2e26b --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/dto/request/CreateCommentRequestDto.java @@ -0,0 +1,11 @@ +package org.sopt.assignment.comment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record CreateCommentRequestDto( + @NotBlank + @Schema(example = "약간 이해가 안되는 부분이 있어요") + String comment +) { +} diff --git a/src/main/java/org/sopt/assignment/comment/dto/request/UpdateCommentRequestDto.java b/src/main/java/org/sopt/assignment/comment/dto/request/UpdateCommentRequestDto.java new file mode 100644 index 0000000..0a362b8 --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/dto/request/UpdateCommentRequestDto.java @@ -0,0 +1,11 @@ +package org.sopt.assignment.comment.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record UpdateCommentRequestDto( + @NotBlank + @Schema(example = "흠 흥미로운데요") + String comment +) { +} diff --git a/src/main/java/org/sopt/assignment/comment/dto/response/GetCommentResponseDto.java b/src/main/java/org/sopt/assignment/comment/dto/response/GetCommentResponseDto.java new file mode 100644 index 0000000..ecf3313 --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/dto/response/GetCommentResponseDto.java @@ -0,0 +1,60 @@ +package org.sopt.assignment.comment.dto.response; + +import org.sopt.assignment.comment.domain.Comment; +import org.sopt.assignment.comment.dto.query.CommentQueryDto; +import org.sopt.assignment.comment.repository.CommentSummary; + +import java.sql.Timestamp; +import java.time.LocalDateTime; + +public record GetCommentResponseDto( + Long commentId, + + String name, + + LocalDateTime createdAt, + + String content, + + boolean isUpdate +) { + public static GetCommentResponseDto from(Comment comment) { + return new GetCommentResponseDto( + comment.getId(), + comment.getMember().getName(), + comment.getCreatedAt(), + comment.getContent(), + comment.isUpdate() + ); + } + + public static GetCommentResponseDto from(CommentQueryDto commentQueryDto) { + return new GetCommentResponseDto( + commentQueryDto.commentId(), + commentQueryDto.name(), + commentQueryDto.createdAt(), + commentQueryDto.content(), + commentQueryDto.isUpdate() + ); + } + + public static GetCommentResponseDto from(CommentSummary commentSummary) { + return new GetCommentResponseDto( + commentSummary.getId(), + commentSummary.getMemberName(), + commentSummary.getCreatedAt(), + commentSummary.getContent(), + commentSummary.getIsUpdate() + ); + } + + public static GetCommentResponseDto from(Object[] result){ + return new GetCommentResponseDto( + (Long) result[0], + (String) result[1], + ((Timestamp) result[2]).toLocalDateTime(), + (String) result[3], + (boolean) result[4] + ); + } +} diff --git a/src/main/java/org/sopt/assignment/comment/exception/CommentErrorCode.java b/src/main/java/org/sopt/assignment/comment/exception/CommentErrorCode.java new file mode 100644 index 0000000..a47118f --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/exception/CommentErrorCode.java @@ -0,0 +1,21 @@ +package org.sopt.assignment.comment.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.sopt.assignment.global.exception.ErrorCode; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CommentErrorCode implements ErrorCode { + + NOT_FOUND_COMMENT(HttpStatus.NOT_FOUND, "COMMENT_001", "해당 댓글을 찾을 수 없습니다."), + NOT_MATCH_ARTICLE(HttpStatus.NOT_FOUND, "COMMENT_002", "해당 댓글은 아티클과 일치하지 않습니다."), + NOT_MATCH_MEMBER(HttpStatus.FORBIDDEN, "COMMENT_003", "댓글은 작성자만 수정 가능합니다."); + + + + private final HttpStatus status; + private final String errorCode; + private final String message; +} diff --git a/src/main/java/org/sopt/assignment/comment/repository/CommentRepository.java b/src/main/java/org/sopt/assignment/comment/repository/CommentRepository.java new file mode 100644 index 0000000..7900bfb --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/repository/CommentRepository.java @@ -0,0 +1,52 @@ +package org.sopt.assignment.comment.repository; + +import org.sopt.assignment.comment.domain.Comment; +import org.sopt.assignment.comment.dto.query.CommentQueryDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CommentRepository extends JpaRepository { + @Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.article.id = :articleId") + Page findByArticleId(Long articleId, Pageable pageable); + + Page findCommentByArticleId(Long articleId, Pageable pageable); + + @Query(""" + SELECT new org.sopt.assignment.comment.dto.query.CommentQueryDto( + c.id, + c.member.name, + c.createdAt, + c.content, + c.isUpdate + ) + FROM Comment c + WHERE c.article.id = :articleId + """) + Page findCommentDtoByArticleId( + @Param("articleId") Long articleId, + Pageable pageable + ); + + @Query("SELECT c.id as id, c.content as content, c.member.name as memberName, c.createdAt as createdAt, c.isUpdate as isUpdate " + + "FROM Comment c WHERE c.article.id = :articleId") + Page findCommentSummariesByArticleId(@Param("articleId") Long articleId, Pageable pageable); + + @Query(value = """ + SELECT + c.id, + m.name, + c.created_at, + c.content, + c.is_update + FROM comments c + JOIN members m ON c.member_id = m.id + WHERE c.article_id = :articleId + """, nativeQuery = true) + Page findCommentsByArticleIdNative( + @Param("articleId") Long articleId, + Pageable pageable + ); +} diff --git a/src/main/java/org/sopt/assignment/comment/repository/CommentSummary.java b/src/main/java/org/sopt/assignment/comment/repository/CommentSummary.java new file mode 100644 index 0000000..fb59000 --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/repository/CommentSummary.java @@ -0,0 +1,12 @@ +package org.sopt.assignment.comment.repository; + +import java.time.LocalDateTime; + +public interface CommentSummary { + Long getId(); + String getContent(); + String getMemberName(); + LocalDateTime getCreatedAt(); + boolean getIsUpdate(); +} + diff --git a/src/main/java/org/sopt/assignment/comment/service/CommentService.java b/src/main/java/org/sopt/assignment/comment/service/CommentService.java new file mode 100644 index 0000000..d1b3953 --- /dev/null +++ b/src/main/java/org/sopt/assignment/comment/service/CommentService.java @@ -0,0 +1,117 @@ +package org.sopt.assignment.comment.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.assignment.article.domain.Article; +import org.sopt.assignment.article.service.ArticleService; +import org.sopt.assignment.comment.domain.Comment; +import org.sopt.assignment.comment.dto.command.CreateCommentCommandDto; +import org.sopt.assignment.comment.dto.command.UpdateCommentCommandDto; +import org.sopt.assignment.comment.dto.response.GetCommentResponseDto; +import org.sopt.assignment.comment.exception.CommentErrorCode; +import org.sopt.assignment.comment.repository.CommentRepository; +import org.sopt.assignment.global.dto.PageBaseDto; +import org.sopt.assignment.global.exception.BaseException; +import org.sopt.assignment.member.domain.Member; +import org.sopt.assignment.member.service.MemberService; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CommentService { + private final CommentRepository commentRepository; + private final ArticleService articleService; + private final MemberService memberService; + + @Transactional + public void createComment(CreateCommentCommandDto command){ + + Member member = memberService.getMemberById(command.memberId()); + + Article article = articleService.get(command.articleId()); + + commentRepository.save(Comment.create(command.comment(), member, article)); + } + + @Transactional(readOnly = true) + public PageBaseDto getCommentsV1(Long articleId, Pageable pageable) { + + articleService.validateArticleExists(articleId); + + return PageBaseDto.from(commentRepository + .findByArticleId(articleId, pageable).map(GetCommentResponseDto::from)); + } + + + @Transactional(readOnly = true) + public PageBaseDto getCommentsV2(Long articleId, Pageable pageable) { + + articleService.validateArticleExists(articleId); + + return PageBaseDto.from(commentRepository + .findCommentDtoByArticleId(articleId, pageable) + .map(GetCommentResponseDto::from)); + } + + + @Transactional(readOnly = true) + public PageBaseDto getCommentsV3(Long articleId, Pageable pageable) { + + articleService.validateArticleExists(articleId); + + return PageBaseDto.from(commentRepository + .findCommentSummariesByArticleId(articleId, pageable) + .map(GetCommentResponseDto::from)); + } + + + @Transactional(readOnly = true) + public PageBaseDto getCommentsV4(Long articleId, Pageable pageable) { + + articleService.validateArticleExists(articleId); + + return PageBaseDto.from(commentRepository + .findCommentsByArticleIdNative(articleId, pageable) + .map(GetCommentResponseDto::from)); + } + + @Transactional(readOnly = true) + public PageBaseDto getCommentsV5(Long articleId, Pageable pageable) { + + articleService.validateArticleExists(articleId); + + return PageBaseDto.from(commentRepository + .findCommentByArticleId(articleId, pageable).map(GetCommentResponseDto::from)); + } + + @Transactional + public void updateComment(UpdateCommentCommandDto command){ + + Comment comment = getMyComment(command.commentId(), command.articleId(), command.memberId()); + + comment.update(command.content()); + } + + @Transactional + public void deleteComment(Long articleId, Long commentId, Long memberId) { + Comment comment = getMyComment(commentId, articleId, memberId); + + commentRepository.delete(comment); + } + + private Comment getMyComment(Long commentId, Long articleId, Long memberId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> BaseException.type(CommentErrorCode.NOT_FOUND_COMMENT)); + + if(!comment.belongsToArticle(articleId)) { + throw BaseException.type(CommentErrorCode.NOT_MATCH_ARTICLE); + } + + if(!comment.isOwnedBy(memberId)) { + throw BaseException.type(CommentErrorCode.NOT_MATCH_MEMBER); + } + + return comment; + } +} diff --git a/src/main/java/org/sopt/assignment/global/config/WebSocketConfig.java b/src/main/java/org/sopt/assignment/global/config/WebSocketConfig.java new file mode 100644 index 0000000..7611da7 --- /dev/null +++ b/src/main/java/org/sopt/assignment/global/config/WebSocketConfig.java @@ -0,0 +1,74 @@ +package org.sopt.assignment.global.config; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.sopt.assignment.chat.handler.ChatWebSocketHandler; +import org.sopt.assignment.global.security.info.JwtUserInfo; +import org.sopt.assignment.global.security.util.JwtUtil; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketConfigurer { + + private final ChatWebSocketHandler chatWebSocketHandler; + private final JwtUtil jwtUtil; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(chatWebSocketHandler, "/ws/chat") + .addInterceptors(new JwtHandshakeInterceptor(jwtUtil)) + .setAllowedOrigins("*"); + } + + @RequiredArgsConstructor + private static class JwtHandshakeInterceptor implements HandshakeInterceptor { + + private final JwtUtil jwtUtil; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes) throws Exception { + if(request instanceof ServletServerHttpRequest){ + ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; + + String token = servletRequest.getServletRequest().getParameter("token"); + + if(token != null){ + try{ + Claims claims = jwtUtil.validateToken(token); + JwtUserInfo jwtUserInfo = JwtUserInfo.from(claims); + + attributes.put("userId", jwtUserInfo.userId()); + attributes.put("token", token); + + return true; + } catch (Exception e){ + return false; + } + } + + } + + return false; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { + + } + } +} diff --git a/src/main/java/org/sopt/assignment/global/constants/Constants.java b/src/main/java/org/sopt/assignment/global/constants/Constants.java index 40c2740..72bc420 100644 --- a/src/main/java/org/sopt/assignment/global/constants/Constants.java +++ b/src/main/java/org/sopt/assignment/global/constants/Constants.java @@ -9,8 +9,9 @@ public class Constants { public static final String BEARER = "Bearer "; public static final String CLAIM_USER_ID = "userId"; public static final String CLAIM_USER_ROLE = "role"; - public static String ACCESS_COOKIE_NAME = "access_token"; - public static String REFRESH_COOKIE_NAME = "refresh_token"; + public static final String CHAT_MESSAGES_KEY = "chat:messages"; + public static final int MAX_MESSAGE_COUNT = 100; + public static final String CONNECTED_USERS_KEY = "chat:connected_users"; public static List NO_NEED_AUTH = List.of( "/swagger", @@ -22,6 +23,8 @@ public class Constants { "/api/health", "/api/health-check", "/api/v1/login", - "/api/v1/members" + "/api/v1/members", + "/chat.html", + "/ws/chat" ); } diff --git a/src/main/java/org/sopt/assignment/global/security/filter/JwtExceptionFilter.java b/src/main/java/org/sopt/assignment/global/security/filter/JwtExceptionFilter.java index 97b2f38..382a4d7 100644 --- a/src/main/java/org/sopt/assignment/global/security/filter/JwtExceptionFilter.java +++ b/src/main/java/org/sopt/assignment/global/security/filter/JwtExceptionFilter.java @@ -25,35 +25,35 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } catch (SecurityException e) { log.error("FilterException throw SecurityException Exception : {}", e.getMessage()); - request.setAttribute("exception", CommonErrorCode.INVALID_USER); + request.setAttribute("errorCode", CommonErrorCode.INVALID_USER); filterChain.doFilter(request, response); } catch (MalformedJwtException e) { log.error("FilterException throw MalformedJwtException Exception : {}", e.getMessage()); - request.setAttribute("exception", CommonErrorCode.TOKEN_MALFORMED_ERROR); + request.setAttribute("errorCode", CommonErrorCode.TOKEN_MALFORMED_ERROR); filterChain.doFilter(request, response); } catch (IllegalArgumentException e) { log.error("FilterException throw IllegalArgumentException Exception : {}", e.getMessage()); - request.setAttribute("exception", CommonErrorCode.TOKEN_TYPE_ERROR); + request.setAttribute("errorCode", CommonErrorCode.TOKEN_TYPE_ERROR); filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { log.error("FilterException throw ExpiredJwtException Exception : {}", e.getMessage()); - request.setAttribute("exception", CommonErrorCode.EXPIRED_TOKEN_ERROR); + request.setAttribute("errorCode", CommonErrorCode.EXPIRED_TOKEN_ERROR); filterChain.doFilter(request, response); } catch (UnsupportedJwtException e) { log.error("FilterException throw UnsupportedJwtException Exception : {}", e.getMessage()); - request.setAttribute("exception", CommonErrorCode.TOKEN_UNSUPPORTED_ERROR); + request.setAttribute("errorCode", CommonErrorCode.TOKEN_UNSUPPORTED_ERROR); filterChain.doFilter(request, response); } catch (JwtException e) { log.error("FilterException throw JwtException Exception : {}", e.getMessage()); - request.setAttribute("exception", CommonErrorCode.TOKEN_UNKNOWN_ERROR); + request.setAttribute("errorCode", CommonErrorCode.TOKEN_UNKNOWN_ERROR); filterChain.doFilter(request, response); } catch (BaseException e) { log.error("FilterException throw BaseException Exception : {}", e.getMessage()); - request.setAttribute("exception", e.getErrorCode()); + request.setAttribute("errorCode", e.getErrorCode()); filterChain.doFilter(request, response); } catch (Exception e) { log.error("FilterException throw Exception Exception : {}", e.getMessage()); - request.setAttribute("exception", CommonErrorCode.INTERNAL_SERVER_ERROR); + request.setAttribute("errorCode", CommonErrorCode.INTERNAL_SERVER_ERROR); filterChain.doFilter(request, response); } } diff --git a/src/main/resources/static/chat.html b/src/main/resources/static/chat.html new file mode 100644 index 0000000..7cad0df --- /dev/null +++ b/src/main/resources/static/chat.html @@ -0,0 +1,548 @@ + + + + + 실시간 채팅 + + + +
+
+

💬 실시간 채팅

+
+ 상태: 연결 안 됨 +
+
+ +
+

🔐 JWT 토큰

+
+ + + + +
+
+
+ +
+
+
+ +
+ + +
+
+ + + + \ No newline at end of file diff --git a/src/test/java/org/sopt/assignment/comment/CommentPerformanceTest.java b/src/test/java/org/sopt/assignment/comment/CommentPerformanceTest.java new file mode 100644 index 0000000..792cc59 --- /dev/null +++ b/src/test/java/org/sopt/assignment/comment/CommentPerformanceTest.java @@ -0,0 +1,158 @@ +package org.sopt.assignment.comment; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.sopt.assignment.article.domain.Article; +import org.sopt.assignment.article.domain.ETag; +import org.sopt.assignment.comment.domain.Comment; +import org.sopt.assignment.comment.dto.response.GetCommentResponseDto; +import org.sopt.assignment.comment.repository.CommentRepository; +import org.sopt.assignment.comment.service.CommentService; +import org.sopt.assignment.global.dto.PageBaseDto; +import org.sopt.assignment.member.domain.EGender; +import org.sopt.assignment.member.domain.Member; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@SpringBootTest +@Transactional +public class CommentPerformanceTest { + + + + @Autowired + private EntityManager em; + + private Long articleId; + + @BeforeEach + void setUp() { + // Member 100명 생성 + List members = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + Member member = Member.create( + "name" + i, + "email" + i + "@naver.com", + LocalDate.parse("2000-04-15"), + EGender.MALE, + "password" + i + ); + em.persist(member); + members.add(member); + } + + // Article 1개 생성 (아무 Member나 사용) + Article article = Article.create( + "어노테이션에 대해", + "어려워요", + ETag.SPRING, + members.get(0) + ); + em.persist(article); + articleId = article.getId(); + + // 댓글 1000개 생성 (각 Member마다 10개씩) + for (int i = 0; i < 10000; i++) { + Member member = members.get(i / 10); // 0-9: Member 0, 10-19: Member 1, ... + Comment comment = Comment.create("테스트 댓글 " + i, member, article); + em.persist(comment); + + // 100개마다 flush (메모리 절약) + if (i % 100 == 0) { + em.flush(); + em.clear(); + } + } + + em.flush(); + em.clear(); + } + + @Autowired + private CommentService commentService; + + @Test + @DisplayName("Fetch Join 만") + void V1_성능_측정() { + Pageable pageable = PageRequest.of(0, 1000); + + long startTime = System.currentTimeMillis(); + + PageBaseDto result = + commentService.getCommentsV1(1L, pageable); + + long endTime = System.currentTimeMillis(); + + System.out.println("V1 실행 시간: " + (endTime - startTime) + "ms"); + } + + @Test + @DisplayName("DTO Projection") + void V2_성능_측정() { + Pageable pageable = PageRequest.of(0, 1000); + + long startTime = System.currentTimeMillis(); + + PageBaseDto result = + commentService.getCommentsV2(1L, pageable); + + long endTime = System.currentTimeMillis(); + + System.out.println("V2 실행 시간: " + (endTime - startTime) + "ms"); + } + + @Test + @DisplayName("Interface projection") + void V3_성능_측정() { + Pageable pageable = PageRequest.of(0, 1000); + + long startTime = System.currentTimeMillis(); + + PageBaseDto result = + commentService.getCommentsV3(1L, pageable); + + long endTime = System.currentTimeMillis(); + + System.out.println("V3 실행 시간: " + (endTime - startTime) + "ms"); + } + + @Test + @DisplayName("Native Query") + void V4_성능_측정() { + Pageable pageable = PageRequest.of(0, 1000); + + long startTime = System.currentTimeMillis(); + + PageBaseDto result = + commentService.getCommentsV4(1L, pageable); + + long endTime = System.currentTimeMillis(); + + System.out.println("V4 실행 시간: " + (endTime - startTime) + "ms"); + } + + @Test + @DisplayName("N+1 문제") + void V5_성능_측정() { + Pageable pageable = PageRequest.of(0, 1000); + + long startTime = System.currentTimeMillis(); + + PageBaseDto result = + commentService.getCommentsV5(1L, pageable); + + long endTime = System.currentTimeMillis(); + + System.out.println("V5 실행 시간: " + (endTime - startTime) + "ms"); + } + +}