최원종의 개발 블로그

V7-2 댓글 작성하기 기능 추가 본문

Spring boot 입문

V7-2 댓글 작성하기 기능 추가

chl6698 2026. 5. 20. 17:45


reply_tb 생성(SQL)

create table reply_tb(
	  id int auto_increment primary key, 
    comment varchar(500) not null, 
    created_at timestamp default current_timestamp, 
    user_id int not null,
    board_id int not null, 
    foreign key(user_id) references user_tb(id), 
    foreign key(board_id) references board_tb(id) 
);

Reply 엔티티 ( 1단계 작업)

Reply 코드 요약

댓글 도메인(Reply) 신규 구축 : 테이블 reply_tb와 매핑되는 새로운 엔티티를 추가하여 블로그의 핵심 기능 확장

@Column 제약 조건 설정 : 댓글 내용(comment) 필드에
글자 수 제한(500자) 및 필수값(nullable = false) 옵션을 주어 데이터 무결성 확보

다대일(@ManyToOne) 단방향 이중 매핑 : 하나의 댓글이 '어떤 사용자'가 '어떤 게시글'에 썼는지
추적할 수 있도록 두 개의 외래키(FK) 관계를 정석대로 연결

성능 최적화 지연 로딩(LAZY) 적용 : User와 Board를 조회할 때 불필요한 즉시 로딩을 방지하여
성능 낭비를 원천 차단

Reply에서 알아야 할 개념

Reply ➔ User (N : 1): 한 명의 유저는 여러 개의 댓글을 달 수 있지만,
하나의 댓글은 오직 한 명의 유저만 작성할 수 있다.

Reply ➔ Board (N : 1): 하나의 게시글에는 수많은 댓글이 달릴 수 있지만,
특정 댓글 하나가 여러 게시글에 동시에 존재할 수는 없다.

이 두 개의 외래키(user_id, board_id)가 reply_tb에 물리적으로 생성되면서
중심을 단단히 잡아주는 구조가 완성.

Reply 코드

package com.tenco.blog.reply;

import com.tenco.blog.board.Board;
import com.tenco.blog.user.User;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

@Data
@NoArgsConstructor
@Entity
@Table(name = "reply_tb")
public class Reply {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(length = 500 , nullable = false)
    private String comment;

    @CreationTimestamp // pc --> db 자동 주입
    private Timestamp createdAt;

    // Reply -> User 연관관계 설정 (FK -> 자바에서 표현하는 개념)
    // 1 : N, N : 1 , M:N <- 중 선택
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    // 하나의 게시글에는 여러개의 댓글이 작성될 수 있다.  1 : N
    // N : 1
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private Board board;

}

Reply 댓글 소유자 확인 로직 추가 코드 ( 2단계 작업)

package com.tenco.blog.reply;

import com.tenco.blog._core.errors.Exception404;
import com.tenco.blog.board.Board;
import com.tenco.blog.user.User;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.sql.Timestamp;

@Data
@NoArgsConstructor
@Entity
@Table(name = "reply_tb")
public class Reply {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(length = 500 , nullable = false)
    private String comment;

    @CreationTimestamp // pc --> db 자동 주입
    private Timestamp createdAt;

    // Reply -> User 연관관계 설정 (FK -> 자바에서 표현하는 개념)
    // 1 : N, N : 1 , M:N <- 중 선택
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    // 하나의 게시글에는 여러개의 댓글이 작성될 수 있다.  1 : N
    // N : 1
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "board_id")
    private Board board;

    @Builder
    public Reply(String comment, User user, Board board) {
        this.comment = comment;
        this.user = user;
        this.board = board;
    }

    /**
     * 댓글 소유자 확인 로직 (세션정보, DB 작성된 user_id)
     * @return
     */
    public boolean isOwner(Integer userId) {
        if(this.user == null || userId == null) {
            throw new Exception404("잘못된 요청입니다.");
        }
        // 본인이 작성한 댓글이 아님
        if(this.user.getId() != userId) {
            return  false;
        }
        return true;
    }
}

WebMvcConfig 코드 ( reply 인증검사 공통 처리)

package com.tenco.blog._core.config;

import com.tenco.blog._core.interceptor.LoginInterceptor;
import com.tenco.blog._core.interceptor.SessionInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

// 자바 코드로 스프링 부트 설정 파일을 다둘 수 있다.

// @Component
@Configuration // IoC 대상 - 하나 이상의 IoC 처리를 하고 싶을 때 사용 한다.
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired // DI 처리
    private LoginInterceptor loginInterceptor;
    @Autowired // DI 처리
    private SessionInterceptor sessionInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // 화면에 SessionUser 정보를 내려줄 사용 됨. 
        registry.addInterceptor(sessionInterceptor)
                .addPathPatterns("/**"); // 모든 URL 요청서 동작 함


        // 인증 처리 인터셉터 동작 함
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/board/**", "/user/**", "/reply/**")
                .excludePathPatterns(
                    // 로그인 관련(인증이 필요 없는 페이지)
                    "/login-form",  // 로그인 화면 요청 시
                    "/join-form",   // 회원 가입 화면 요청 시
                    "/logout", // 로그아웃

                    // 게시글 조회 관련 (인증 없이도 볼 수 있는 페이지)
                    "/board/list",  // 게시글 목록 화면 요청 시
                    "/"          ,  // 메인 페이지
                    "/index"          ,  // 메인 페이지
                    "/board/{id:\\d+}", // 게시글 상세보기( 숫자 ID만 허용)

                    // 정적 리소스 (CSS, JS, 이미지 등)
                    "/css/**",          // CSS 파일 제외
                    "/js/**",           // JS 파일 제외
                    "/images/**",      //  이미지 파일 제외
                    "/favicon.ico",    // 파비콘 제외

                    // H2 데이터베시스 콘솔( 개발 환경용)
                    "/h2-console/**"    // H2 콘솔 접근
                );
    }
}

board/detail.mustache ( 댓글 등록 폼 hidden 태그에 board pk 추가)

 

        <!-- 댓글 등록 폼 -->
        <div class="card-body">
            <!--
            댓글 등록 폼: POST 방식으로 댓글 저장
            향후 댓글 기능 구현시 사용할 예정
            현재는 정적 HTML로만 구성
            -->
            <form action="/reply/save" method="post">
                <input type="hidden" name="boardId" value="{{board.id}}">
                <textarea class="form-control" rows="2" name="comment" placeholder="댓글을 입력하세요"></textarea>
                <div class="d-flex justify-content-end">
                    <button type="submit" class="btn btn-outline-primary mt-1">댓글등록</button>
                </div>
            </form>

ReplyController (댓글 등록 기능)

코드 요약

도메인 제어기 구축 : 댓글(Reply) 도메인의 생성 및 처리를 전담하는
ReplyController를 새로 추가하여 아키텍처 응집도 향상

동적 리다이렉트(Redirect) 구현 : 댓글 작성 완료 후
saveDTO.getBoardId()를 활용해 원래 읽던 게시글 상세 화면으로 자연스럽게 복귀 처리

유효성 검사 1차 방어 : 로직 진입 직후 DTO의 validate()를 즉시 실행하여 
빈 댓글이나 비정상 요청을 서비스 레이어 전 단계에서 컷

세션 연동 및 가벼운 컨트롤러 유지 : 복잡한 인가 처리나 비즈니스 로직은 
ReplyService.댓글작성()으로 위임하고, 컨트롤러는 흐름 제어에만 집중

ReplyController 코드

package com.tenco.blog.reply;

import com.tenco.blog.user.User;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;

@Controller // IoC
@RequiredArgsConstructor // DI 처리
public class ReplyController {

    private final ReplyService replyService;

    // 댓글 등록 기능 요청
    @PostMapping("/reply/save")
    public String saveProc(ReplyRequest.SaveDTO saveDTO, HttpSession session) {
        // 1. 인증검사 --> LoginInterceptor 처리
        User sessionUser = (User) session.getAttribute("sessionUser");
        // 2. 유효성 검사
        saveDTO.validate();
        replyService.댓글작성(saveDTO, sessionUser.getId());
        // 해당 게시글에 댓글 작성후 리다이렉션 처리 (해당 게시글로)
        return "redirect:/board/" + saveDTO.getBoardId();
    }
    // 댓글 삭제 기능 요청
    // @PostMapping("/reply/delete")
}

ReplyService 

Service 계층에서는 여러 Repository를 조합해서 비즈니스 규칙을 완성한다
즉, 서비스 계층이 필요한 이유 중 하나이다.

 

코드 요약

서비스 계층의 본질 구현 : 하나의 서비스에서 Reply, Board, User 총 3개의 리포지토리를
유기적으로 조합하여 복잡한 비즈니스 규칙 완성

선제적 데이터 검증 (Defensive Code) : 댓글을 달기 전, 대상 게시글과 작성자가 
실제로 DB에 존재하는지 findById와 orElseThrow로 철저히 상호 검증

도메인 객체 간의 연관관계 바인딩 : 조회해온 영속 객체(userEntity, boardEntity)를
saveDTO.toEntity()의 인자로 전달해 정석적인 FK 관계 맵핑 완료

@Transactional 어노테이션 전술적 활용 : 데이터 삽입(INSERT)이 일어나는 메서드 특성에 맞게
쓰기 트랜잭션을 명시하여 데이터 무결성 보장

ReplyService 코드

package com.tenco.blog.reply;

import com.tenco.blog._core.errors.Exception404;
import com.tenco.blog.board.Board;
import com.tenco.blog.board.BoardRepository;
import com.tenco.blog.user.User;
import com.tenco.blog.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// Service 계층에서는 여러 Repository를 조합해서 비즈니스 규칙을 완성한다
// 즉, 서비스 계층이 필요한 이유 중 하나이다.
@Service // IoC
@RequiredArgsConstructor // DI 처리
public class ReplyService {

    private final ReplyRepository replyRepository;
    private final BoardRepository boardRepository;
    private final UserRepository userRepository;

    @Transactional
    public Reply 댓글작성(ReplyRequest.SaveDTO saveDTO, Integer id) {
        // 게시글 조회
        Board boardEntity = boardRepository.findById(saveDTO.getBoardId()).orElseThrow(
                () -> new Exception404("해당 게시글을 찾을 수 없습니다"));

        // id 로 사용자 조회
        User userEntity = userRepository.findById(id).orElseThrow(
                () -> new Exception404("사용자를 찾을 수 없습니다")
        );
        Reply reply = saveDTO.toEntity(userEntity, boardEntity);
        replyRepository.save(reply);
        return reply;
    }
}

ReplyRepository 코드

package com.tenco.blog.reply;

import org.springframework.data.jpa.repository.JpaRepository;

// @Repository - 부모의 클래스에 정의 되어 있음()
public interface ReplyRepository extends JpaRepository<Reply, Integer> {
    // 기본적인 CRUD 자동 완성 및 추가 편의 기능 자동 생성
}