
핵심 기능

src/main/java/com/onandhome/
├── review/
│ ├── entity/
│ │ ├── Review.java # 리뷰 엔티티
│ │ └── ReviewReply.java # 리뷰 댓글 엔티티
│ ├── ReviewReplyRepository.java # JPA Repository
│ └── ReviewReplyService.java # 비즈니스 로직 + 알림
├── notification/
│ └── NotificationService.java # 알림 저장
└── config/
└── WebSocketConfig.java # WebSocket 설정
@Entity
@Getter
@Setter
@Table(name = "review_reply")
public class ReviewReply {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 부모 리뷰 (N:1)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "review_id", nullable = false)
private Review review;
// 댓글 내용
@Column(nullable = false, length = 1000)
private String content;
// 작성자 정보
@Column(name = "user_id")
private Long userId; // 회원 ID (선택)
@Column(name = "author")
private String author; // 작성자 별칭
@Column(name = "username", nullable = false)
private String username; // 로그인 계정명
// 시간 정보
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PreUpdate
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
1. FetchType.LAZY
2. userId
3. username

@Repository
public interface ReviewReplyRepository
extends JpaRepository<ReviewReply, Long> {
// 리뷰 ID로 댓글 목록 조회
List<ReviewReply> findByReviewId(Long reviewId);
}
@Service
@RequiredArgsConstructor
@Transactional
public class ReviewReplyService {
private final ReviewReplyRepository reviewReplyRepository;
private final ReviewRepository reviewRepository;
private final NotificationService notificationService;
private final SimpMessagingTemplate messagingTemplate;
/**
* 댓글 목록 조회
*/
@Transactional(readOnly = true)
public List<ReviewReplyDTO> findByReviewId(Long reviewId) {
return reviewReplyRepository.findByReviewId(reviewId)
.stream()
.map(ReviewReplyDTO::fromEntity)
.collect(Collectors.toList());
}
/**
* 댓글 생성 + 실시간 알림
*/
public void createReply(Long reviewId, String content,
String author, String username, Long userId) {
// 1. 리뷰 조회
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new IllegalArgumentException(
"리뷰를 찾을 수 없습니다."));
// 2. 댓글 생성
ReviewReply reply = new ReviewReply();
reply.setReview(review);
reply.setContent(content);
reply.setAuthor(author);
reply.setUsername(username);
// 관리자는 userId null
if (userId != null && userId > 0) {
reply.setUserId(userId);
} else {
reply.setUserId(null);
}
reply.setCreatedAt(LocalDateTime.now());
reply.setUpdatedAt(LocalDateTime.now());
// 3. DB 저장
reviewReplyRepository.save(reply);
// 4. 리뷰 작성자에게 알림 전송
try {
if (review.getUser() != null) {
String reviewAuthorId = review.getUser().getUserId();
// 자기 자신이 댓글 단 경우 제외
boolean isSelfReply = username.equals(reviewAuthorId);
if (!isSelfReply) {
// DB 알림 저장
notificationService.createNotification(
reviewAuthorId,
"리뷰 댓글 등록",
"작성하신 리뷰에 댓글이 등록되었습니다.",
"REVIEW_REPLY",
reviewId,
null
);
// WebSocket 실시간 알림
Map<String, Object> notification = new HashMap<>();
notification.put("type", "REVIEW_REPLY");
notification.put("reviewId", reviewId);
notification.put("title", "리뷰 댓글 등록");
notification.put("message",
"작성하신 리뷰에 댓글이 등록되었습니다.");
notification.put("timestamp",
LocalDateTime.now().toString());
messagingTemplate.convertAndSendToUser(
reviewAuthorId,
"/queue/notifications",
notification
);
}
}
} catch (Exception e) {
// 알림 실패해도 댓글 등록은 성공
log.error("알림 전송 실패: {}", e.getMessage());
}
}
/**
* 댓글 수정
*/
public void updateReply(Long replyId, String content) {
ReviewReply reply = reviewReplyRepository.findById(replyId)
.orElseThrow(() -> new IllegalArgumentException(
"댓글을 찾을 수 없습니다."));
reply.setContent(content);
reply.setUpdatedAt(LocalDateTime.now());
reviewReplyRepository.save(reply);
}
/**
* 댓글 삭제
*/
public void deleteReply(Long replyId) {
ReviewReply reply = reviewReplyRepository.findById(replyId)
.orElseThrow(() -> new IllegalArgumentException(
"댓글을 찾을 수 없습니다."));
reviewReplyRepository.delete(reply);
}
}
1. createReply()
2. 알림 전송 대상
review.getUser())/user/{userId}/queue/notifications3. 관리자 vs 사용자
userId = nulluserId 저장@RestController
@RequestMapping("/api/reviews")
@RequiredArgsConstructor
public class ReviewRestController {
private final ReviewReplyService reviewReplyService;
/**
* 댓글 작성
* POST /api/reviews/{reviewId}/reply
*/
@PostMapping("/{reviewId}/reply")
public ResponseEntity<Map<String, Object>> createReviewReply(
@PathVariable Long reviewId,
@RequestBody Map<String, String> request,
@RequestHeader(value = "Authorization", required = false) String authHeader,
HttpSession session) {
Map<String, Object> response = new HashMap<>();
try {
String content = request.get("content");
if (content == null || content.trim().isEmpty()) {
response.put("success", false);
response.put("message", "댓글 내용을 입력해주세요.");
return ResponseEntity.badRequest().body(response);
}
// 사용자 정보 추출 (JWT 또는 세션)
String userId = getCurrentUserId(authHeader, session);
if (userId == null) {
response.put("success", false);
response.put("message", "로그인이 필요합니다.");
return ResponseEntity.status(401).body(response);
}
// 댓글 생성
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
reviewReplyService.createReply(
reviewId,
content,
user.getUsername(),
userId,
user.getId()
);
response.put("success", true);
response.put("message", "댓글이 등록되었습니다.");
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
/**
* 댓글 수정
* PUT /api/reviews/reply/{replyId}
*/
@PutMapping("/reply/{replyId}")
public ResponseEntity<Map<String, Object>> updateReviewReply(
@PathVariable Long replyId,
@RequestBody Map<String, String> request) {
Map<String, Object> response = new HashMap<>();
try {
String content = request.get("content");
if (content == null || content.trim().isEmpty()) {
response.put("success", false);
response.put("message", "댓글 내용을 입력해주세요.");
return ResponseEntity.badRequest().body(response);
}
reviewReplyService.updateReply(replyId, content);
response.put("success", true);
response.put("message", "댓글이 수정되었습니다.");
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
/**
* 댓글 삭제
* DELETE /api/reviews/reply/{replyId}
*/
@DeleteMapping("/reply/{replyId}")
public ResponseEntity<Map<String, Object>> deleteReviewReply(
@PathVariable Long replyId) {
Map<String, Object> response = new HashMap<>();
try {
reviewReplyService.deleteReply(replyId);
response.put("success", true);
response.put("message", "댓글이 삭제되었습니다.");
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("success", false);
response.put("message", e.getMessage());
return ResponseEntity.status(500).body(response);
}
}
}

POST http://localhost:8080/api/reviews/1/reply
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Content-Type: application/json
{
"content": "좋은 리뷰 감사합니다!"
}
응답:
{
"success": true,
"message": "댓글이 등록되었습니다."
}
PUT http://localhost:8080/api/reviews/reply/1
Content-Type: application/json
{
"content": "수정된 댓글 내용입니다."
}
응답:
{
"success": true,
"message": "댓글이 수정되었습니다."
}
DELETE http://localhost:8080/api/reviews/reply/1
응답:
{
"success": true,
"message": "댓글이 삭제되었습니다."
}
// SockJS + STOMP 연결
const socket = new SockJS('http://localhost:8080/ws');
const stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
console.log('WebSocket 연결 성공');
// 개인 알림 구독
stompClient.subscribe('/user/queue/notifications', (message) => {
const notification = JSON.parse(message.body);
if (notification.type === 'REVIEW_REPLY') {
// 댓글 알림 표시
showNotification(
notification.title,
notification.message
);
}
});
});
{
"type": "REVIEW_REPLY",
"reviewId": 1,
"title": "리뷰 댓글 등록",
"message": "작성하신 리뷰에 댓글이 등록되었습니다.",
"timestamp": "2025-01-15T14:30:00"
}