Skip to content

Commit 8319e4b

Browse files
authored
Merge pull request #53 from DMU-DebugVisual/fix/comment-notification
fix: 알림 조회 API Jackson 직렬화 오류 수정 (#52)
2 parents cbd8ec4 + 48f5f23 commit 8319e4b

File tree

3 files changed

+184
-65
lines changed

3 files changed

+184
-65
lines changed
Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,82 @@
11
package com.dmu.debug_visual.community.controller;
22

3-
import com.dmu.debug_visual.community.entity.Notification;
3+
import com.dmu.debug_visual.community.dto.NotificationResponse;
44
import com.dmu.debug_visual.community.service.NotificationService;
55
import com.dmu.debug_visual.security.CustomUserDetails;
66
import com.dmu.debug_visual.user.User;
77
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.Parameter;
9+
import io.swagger.v3.oas.annotations.media.Content;
10+
import io.swagger.v3.oas.annotations.media.Schema;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
13+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
814
import io.swagger.v3.oas.annotations.tags.Tag;
915
import lombok.RequiredArgsConstructor;
16+
import org.springframework.http.ResponseEntity; // ResponseEntity 사용
1017
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1118
import org.springframework.web.bind.annotation.*;
1219

1320
import java.util.List;
1421

22+
/**
23+
* 사용자 알림 관련 API 요청을 처리하는 컨트롤러 클래스.
24+
*/
1525
@RestController
1626
@RequestMapping("/api/notifications")
1727
@RequiredArgsConstructor
18-
@Tag(name = "알림 API", description = "사용자에게 전달되는 알림 API")
28+
@Tag(name = "알림 API", description = "사용자 알림 조회 및 읽음 처리 API") // 태그 이름에 이모지 추가
29+
@SecurityRequirement(name = "bearerAuth") // 모든 API에 JWT 인증 필요 명시
1930
public class NotificationController {
2031

2132
private final NotificationService notificationService;
2233

34+
/**
35+
* 현재 로그인한 사용자의 모든 알림 목록을 조회합니다.
36+
*
37+
* @param userDetails 현재 로그인한 사용자의 정보 (JWT 토큰에서 추출)
38+
* @return 알림 DTO 리스트 (최신순 정렬)
39+
*/
2340
@GetMapping
24-
@Operation(summary = "내 알림 목록 조회")
25-
public List<Notification> getMyNotifications(@AuthenticationPrincipal CustomUserDetails userDetails) {
41+
@Operation(summary = "내 알림 목록 조회", description = "현재 로그인된 사용자의 모든 알림을 최신순으로 조회합니다.")
42+
@ApiResponses(value = {
43+
@ApiResponse(responseCode = "200", description = "조회 성공",
44+
content = @Content(mediaType = "application/json",
45+
schema = @Schema(implementation = NotificationResponse.class))),
46+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", content = @Content),
47+
@ApiResponse(responseCode = "403", description = "접근 권한 없음", content = @Content)
48+
})
49+
public ResponseEntity<List<NotificationResponse>> getMyNotifications(
50+
@Parameter(hidden = true) // Swagger UI에서 파라미터 숨김 처리
51+
@AuthenticationPrincipal CustomUserDetails userDetails) {
52+
2653
User user = userDetails.getUser();
27-
return notificationService.getUserNotifications(user);
54+
List<NotificationResponse> notifications = notificationService.getUserNotifications(user);
55+
return ResponseEntity.ok(notifications); // ResponseEntity로 감싸서 반환
2856
}
2957

58+
/**
59+
* 특정 알림을 읽음 상태로 변경합니다.
60+
*
61+
* @param id 읽음 처리할 알림의 ID
62+
* @param userDetails 현재 로그인한 사용자의 정보 (JWT 토큰에서 추출)
63+
*/
3064
@PutMapping("/{id}/read")
31-
@Operation(summary = "알림 읽음 처리")
32-
public void markAsRead(@PathVariable Long id, @AuthenticationPrincipal CustomUserDetails userDetails) {
65+
@Operation(summary = "알림 읽음 처리", description = "특정 알림 ID를 받아 해당 알림을 읽음 상태로 변경합니다.")
66+
@ApiResponses(value = {
67+
@ApiResponse(responseCode = "200", description = "읽음 처리 성공", content = @Content),
68+
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자", content = @Content),
69+
@ApiResponse(responseCode = "403", description = "접근 권한 없음 (본인 알림 아님)", content = @Content),
70+
@ApiResponse(responseCode = "404", description = "해당 ID의 알림 없음", content = @Content)
71+
})
72+
public ResponseEntity<Void> markAsRead( // 반환 타입 void 대신 ResponseEntity<Void> 사용
73+
@Parameter(description = "읽음 처리할 알림 ID", required = true, example = "1")
74+
@PathVariable Long id,
75+
@Parameter(hidden = true) // Swagger UI에서 파라미터 숨김 처리
76+
@AuthenticationPrincipal CustomUserDetails userDetails) {
77+
3378
User user = userDetails.getUser();
3479
notificationService.markAsRead(id, user);
80+
return ResponseEntity.ok().build(); // 성공 시 200 OK만 반환
3581
}
36-
37-
}
82+
}
Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package com.dmu.debug_visual.community.service;
22

3-
import com.dmu.debug_visual.community.dto.*;
4-
import com.dmu.debug_visual.community.entity.*;
5-
import com.dmu.debug_visual.community.repository.*;
3+
import com.dmu.debug_visual.community.dto.CommentRequestDTO;
4+
import com.dmu.debug_visual.community.dto.CommentResponseDTO;
5+
import com.dmu.debug_visual.community.entity.Comment;
6+
import com.dmu.debug_visual.community.entity.Post;
7+
import com.dmu.debug_visual.community.repository.CommentRepository;
8+
import com.dmu.debug_visual.community.repository.PostRepository;
69
import com.dmu.debug_visual.user.User;
710
import lombok.RequiredArgsConstructor;
811
import org.springframework.stereotype.Service;
@@ -11,6 +14,9 @@
1114
import java.util.List;
1215
import java.util.stream.Collectors;
1316

17+
/**
18+
* 게시글 댓글 관련 비즈니스 로직을 처리하는 서비스 클래스.
19+
*/
1420
@Service
1521
@RequiredArgsConstructor
1622
public class CommentService {
@@ -19,89 +25,122 @@ public class CommentService {
1925
private final PostRepository postRepository;
2026
private final NotificationService notificationService;
2127

22-
// ★ [수정] 싱글톤 서비스에서 상태를 가지는 필드는 스레드 충돌을 일으키므로 삭제
23-
// Comment parent = null;
24-
28+
/**
29+
* 새로운 댓글 또는 대댓글을 생성하고 저장합니다.
30+
* 댓글 생성 시 게시글 작성자에게, 대댓글 생성 시 부모 댓글 작성자에게 알림을 전송합니다.
31+
*
32+
* @param dto 댓글 생성 요청 정보 (postId, content, parentId)
33+
* @param user 댓글 작성자
34+
* @return 생성된 댓글의 ID
35+
*/
2536
@Transactional
2637
public Long createComment(CommentRequestDTO dto, User user) {
27-
// ★ [수정] postRepository.findById() -> findByIdWithWriter()로 변경
38+
// Fetch Join을 사용하여 Post 조회 시 writer 정보도 함께 로드
2839
Post post = postRepository.findByIdWithWriter(dto.getPostId())
29-
.orElseThrow(() -> new RuntimeException("게시글 없음"));
40+
.orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다. ID: " + dto.getPostId()));
3041

3142
Comment.CommentBuilder builder = Comment.builder()
3243
.post(post)
3344
.writer(user)
3445
.content(dto.getContent());
3546

36-
// 메서드 내의 지역 변수로 사용하는 것이 올바른 방법입니다.
37-
Comment parent = null;
38-
47+
// 대댓글 처리
3948
if (dto.getParentId() != null && dto.getParentId() != 0) {
40-
// ★ [수정] commentRepository.findById() -> findByIdWithWriter()로 변경
41-
parent = commentRepository.findByIdWithWriter(dto.getParentId())
42-
.orElseThrow(() -> new RuntimeException("상위 댓글 없음"));
49+
// Fetch Join을 사용하여 부모 댓글 조회 시 writer 정보도 함께 로드
50+
Comment parent = commentRepository.findByIdWithWriter(dto.getParentId())
51+
.orElseThrow(() -> new RuntimeException("상위 댓글을 찾을 수 없습니다. ID: " + dto.getParentId()));
4352

53+
// 대댓글의 대댓글은 허용하지 않음
4454
if (parent.getParent() != null) {
4555
throw new IllegalArgumentException("대댓글에는 답글을 달 수 없습니다.");
4656
}
4757

4858
builder.parent(parent);
4959

60+
// 대댓글 작성 시, 부모 댓글 작성자와 현재 작성자가 다르면 알림 전송
5061
if (!user.getUserNum().equals(parent.getWriter().getUserNum())) {
51-
notificationService.notify(
52-
parent.getWriter(),
53-
user.getName() + "님이 댓글에 답글을 남겼습니다.",
54-
post.getId()
55-
);
62+
String message = user.getName() + "님이 회원님의 댓글에 답글을 남겼습니다.";
63+
notificationService.notify(parent.getWriter(), message, post.getId());
5664
}
5765
}
5866

67+
// 댓글 저장
68+
Comment savedComment = commentRepository.save(builder.build());
69+
70+
// 댓글 작성 시, 게시글 작성자와 현재 작성자가 다르면 알림 전송
71+
// (대댓글인 경우에도 게시글 작성자에게 알림 전송)
5972
if (!user.getUserNum().equals(post.getWriter().getUserNum())) {
60-
notificationService.notify(
61-
post.getWriter(),
62-
user.getName() + "님이 게시글에 댓글을 남겼습니다.",
63-
post.getId()
64-
);
73+
String message = user.getName() + "님이 회원님의 게시글에 댓글을 남겼습니다.";
74+
notificationService.notify(post.getWriter(), message, post.getId());
6575
}
6676

67-
return commentRepository.save(builder.build()).getId();
77+
return savedComment.getId();
6878
}
6979

70-
80+
/**
81+
* 특정 게시글의 댓글 목록을 조회합니다 (대댓글 포함).
82+
*
83+
* @param postId 댓글 목록을 조회할 게시글 ID
84+
* @return 댓글 DTO 리스트 (계층 구조)
85+
*/
86+
@Transactional(readOnly = true) // 읽기 전용 트랜잭션 명시
7187
public List<CommentResponseDTO> getComments(Long postId) {
88+
// 게시글 존재 여부 확인 (writer 정보는 필요 없으므로 기본 findById 사용)
7289
Post post = postRepository.findById(postId)
73-
.orElseThrow(() -> new RuntimeException("게시글 없음"));
90+
.orElseThrow(() -> new RuntimeException("게시글을 찾을 수 없습니다. ID: " + postId));
7491

75-
// ★ [수정] N+1 문제를 일부 해결하기 위해 writer를 함께 조회하는 메서드 사용
92+
// 최상위 댓글 목록 조회 (writer 정보 포함)
7693
List<Comment> rootComments = commentRepository.findByPostAndParentIsNullWithWriter(post);
7794

7895
return rootComments.stream()
79-
.map(this::mapToDTO)
96+
.map(this::mapToDTO) // DTO 변환 메소드 재귀 호출
8097
.collect(Collectors.toList());
8198
}
8299

100+
/**
101+
* 특정 댓글을 논리적으로 삭제 처리합니다.
102+
*
103+
* @param commentId 삭제할 댓글 ID
104+
* @param user 현재 로그인한 사용자 (권한 확인용)
105+
*/
106+
@Transactional // 데이터 변경이 있으므로 트랜잭션 명시
107+
public void deleteComment(Long commentId, User user) {
108+
// 댓글 조회 (writer 정보는 권한 확인에 필요 없으므로 기본 findById 사용)
109+
Comment comment = commentRepository.findById(commentId)
110+
.orElseThrow(() -> new RuntimeException("댓글을 찾을 수 없습니다. ID: " + commentId));
111+
112+
// 댓글 작성자와 현재 사용자가 일치하는지 확인
113+
if (!comment.getWriter().getUserNum().equals(user.getUserNum())) {
114+
throw new RuntimeException("댓글 삭제 권한이 없습니다. (댓글 ID: " + commentId + ")");
115+
}
116+
117+
// 논리 삭제 처리
118+
comment.setDeleted(true);
119+
// @Transactional 환경에서는 명시적인 save 호출 없이 더티 체킹으로 업데이트 가능
120+
// commentRepository.save(comment);
121+
}
122+
123+
// --- Private Helper Methods ---
124+
125+
/**
126+
* Comment 엔티티를 CommentResponseDTO로 변환합니다 (재귀 호출 지원).
127+
*
128+
* @param comment 변환할 Comment 엔티티
129+
* @return 변환된 CommentResponseDTO
130+
*/
83131
private CommentResponseDTO mapToDTO(Comment comment) {
132+
// 재귀 호출 시 children의 writer 로딩으로 N+1 발생 가능성 있음
133+
// 성능 최적화가 필요하다면 EntityGraph 또는 별도 조회 쿼리 고려
84134
return CommentResponseDTO.builder()
85135
.id(comment.getId())
136+
// writer 필드는 getComments에서 Fetch Join 되었으므로 getName() 호출 가능
86137
.writer(comment.getWriter().getName())
87138
.content(comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent())
88139
.createdAt(comment.getCreatedAt())
89-
// 참고: 이 부분(children)은 여전히 N+1 문제가 발생할 수 있습니다.
90140
.replies(comment.getChildren().stream()
141+
.filter(child -> !child.isDeleted()) // 논리 삭제된 대댓글은 제외 (선택사항)
91142
.map(this::mapToDTO)
92143
.collect(Collectors.toList()))
93144
.build();
94145
}
95-
96-
public void deleteComment(Long commentId, User user) {
97-
Comment comment = commentRepository.findById(commentId)
98-
.orElseThrow(() -> new RuntimeException("댓글 없음"));
99-
100-
if (!comment.getWriter().getUserNum().equals(user.getUserNum())) {
101-
throw new RuntimeException("댓글 삭제 권한 없음");
102-
}
103-
104-
comment.setDeleted(true);
105-
commentRepository.save(comment);
106-
}
107146
}
Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dmu.debug_visual.community.service;
22

3+
import com.dmu.debug_visual.community.dto.NotificationResponse;
34
import com.dmu.debug_visual.community.entity.Notification;
45
import com.dmu.debug_visual.community.repository.NotificationRepository;
56
import com.dmu.debug_visual.user.User;
@@ -8,27 +9,33 @@
89
import org.springframework.transaction.annotation.Transactional;
910

1011
import java.util.List;
12+
import java.util.stream.Collectors;
1113

14+
/**
15+
* 사용자 알림 관련 비즈니스 로직을 처리하는 서비스 클래스.
16+
*/
1217
@Service
1318
@RequiredArgsConstructor
1419
public class NotificationService {
1520

1621
private final NotificationRepository notificationRepository;
1722

23+
// --- Public Methods ---
24+
1825
/**
19-
* 일반 알림을 생성합니다. (게시물 ID가 없는 경우)
26+
* 일반 알림(게시물 ID 없음, 예: 좋아요)을 생성하고 저장합니다.
27+
*
2028
* @param receiver 알림을 받을 사용자
2129
* @param message 알림 내용
2230
*/
2331
@Transactional
2432
public void notify(User receiver, String message) {
25-
// postId가 없는 경우, null로 저장합니다.
26-
// 좋아요 기능 등에서 이 메소드를 호출할 수 있습니다.
27-
createAndSaveNotification(receiver, message, null, Notification.NotificationType.LIKE); // 기본 타입을 LIKE 등으로 설정
33+
createAndSaveNotification(receiver, message, null, Notification.NotificationType.LIKE);
2834
}
2935

3036
/**
31-
* 게시물과 관련된 알림을 생성합니다. (댓글 등)
37+
* 게시물 관련 알림(예: 댓글)을 생성하고 저장합니다.
38+
*
3239
* @param receiver 알림을 받을 사용자
3340
* @param message 알림 내용
3441
* @param postId 관련된 게시물의 ID
@@ -38,30 +45,58 @@ public void notify(User receiver, String message, Long postId) {
3845
createAndSaveNotification(receiver, message, postId, Notification.NotificationType.COMMENT);
3946
}
4047

41-
public List<Notification> getUserNotifications(User user) {
42-
return notificationRepository.findByReceiverOrderByCreatedAtDesc(user);
48+
/**
49+
* 특정 사용자의 모든 알림 목록을 DTO 리스트 형태로 조회합니다.
50+
*
51+
* @param user 알림 목록을 조회할 사용자
52+
* @return 알림 DTO 리스트 (최신순 정렬)
53+
*/
54+
public List<NotificationResponse> getUserNotifications(User user) {
55+
List<Notification> notifications = notificationRepository.findByReceiverOrderByCreatedAtDesc(user);
56+
return notifications.stream()
57+
.map(NotificationResponse::fromEntity)
58+
.collect(Collectors.toList());
4359
}
4460

61+
/**
62+
* 특정 알림을 읽음 상태로 변경합니다.
63+
*
64+
* @param notificationId 읽음 처리할 알림의 ID
65+
* @param user 현재 로그인한 사용자 (권한 확인용)
66+
*/
67+
@Transactional
4568
public void markAsRead(Long notificationId, User user) {
4669
Notification notification = notificationRepository.findById(notificationId)
47-
.orElseThrow(() -> new RuntimeException("알림 없음"));
70+
.orElseThrow(() -> new RuntimeException("알림 없음 ID: " + notificationId));
71+
72+
// 알림 수신자와 현재 사용자가 일치하는지 확인
4873
if (!notification.getReceiver().getUserNum().equals(user.getUserNum())) {
49-
throw new RuntimeException("권한 없음");
74+
throw new RuntimeException("알림 읽기 권한 없음 (알림 ID: " + notificationId + ")");
5075
}
51-
notification.setRead(true);
52-
notificationRepository.save(notification);
76+
77+
notification.markAsRead();
78+
// @Transactional 환경에서는 명시적인 save 호출 없이 더티 체킹으로 업데이트 가능
79+
// notificationRepository.save(notification);
5380
}
5481

82+
// --- Private Helper Methods ---
83+
5584
/**
56-
* Notification 엔티티를 생성하고 저장하는 private 헬퍼 메소드
85+
* Notification 엔티티를 생성하고 데이터베이스에 저장합니다.
86+
*
87+
* @param receiver 알림을 받을 사용자
88+
* @param message 알림 내용
89+
* @param postId 관련된 게시물의 ID (nullable)
90+
* @param type 알림 유형 (COMMENT, LIKE 등)
5791
*/
5892
private void createAndSaveNotification(User receiver, String message, Long postId, Notification.NotificationType type) {
5993
Notification notification = Notification.builder()
6094
.receiver(receiver)
6195
.message(message)
6296
.notificationType(type)
63-
.postId(postId) // postId가 null이 아니면 저장, null이면 null로 저장
97+
.postId(postId)
98+
// isRead는 @Builder.Default로 false가 기본값이므로 생략 가능
6499
.build();
65100
notificationRepository.save(notification);
66101
}
67-
}
102+
}

0 commit comments

Comments
 (0)