Spring Boot - 댓글 기능 구현

송진우·2026년 1월 19일
post-thumbnail

핵심 기능

  • 리뷰에 댓글 작성
  • 댓글 목록 조회
  • 댓글 수정/삭제
  • 실시간 알림 (WebSocket)

댓글 전체 흐름


📁 프로젝트 구조

src/main/java/com/onandhome/
├── review/
│   ├── entity/
│   │   ├── Review.java              # 리뷰 엔티티
│   │   └── ReviewReply.java         # 리뷰 댓글 엔티티
│   ├── ReviewReplyRepository.java   # JPA Repository
│   └── ReviewReplyService.java      # 비즈니스 로직 + 알림
├── notification/
│   └── NotificationService.java     # 알림 저장
└── config/
    └── WebSocketConfig.java         # WebSocket 설정

1. Entity

ReviewReply.java

@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

  • 관리자는 null, 일반 사용자는 ID 저장
  • 관리자 댓글과 사용자 댓글 구분

3. username

  • 알림 전송 시 작성자 구분용
  • 자기 자신 제외 로직에 사용

ERD 구조


2. Repository

ReviewReplyRepository.java

@Repository
public interface ReviewReplyRepository 
        extends JpaRepository<ReviewReply, Long> {
    
    // 리뷰 ID로 댓글 목록 조회
    List<ReviewReply> findByReviewId(Long reviewId);
}

3. Service

ReviewReplyService.java

@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()

  • DB 저장 + 알림 전송 동시 수행
  • 자기 자신 댓글은 알림 제외

2. 알림 전송 대상

  • 리뷰 작성자 (review.getUser())
  • WebSocket 경로: /user/{userId}/queue/notifications

3. 관리자 vs 사용자

  • 관리자: userId = null
  • 사용자: userId 저장

4. REST API

ReviewRestController.java

@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);
        }
    }
}

실제 구현 화면


5. API 테스트

댓글 작성

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": "댓글이 삭제되었습니다."
}

6. WebSocket 실시간 알림

프론트엔드 연결

// 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"
}

0개의 댓글