it ·

Spring Boot 3.x + JPA로 게시판 만들기 (가장 쉬운 입문 가이드)

반응형
Spring Boot 3.x + JPA 게시판 만들기 입문 가이드 대표 이미지
Spring Boot 3.x + JPA로 가장 쉬운 게시판 입문 가이드

Spring Boot 3.x + JPA로 게시판 만들기 (가장 쉬운 입문 가이드)

“웹 백엔드 뭐부터 만들지?” 고민될 때, 게시판만큼 좋은 연습 주제는 없습니다. CRUD(등록/조회/수정/삭제), 페이징, 검색, 검증, 예외 처리, 화면 렌더링(또는 API)까지 실무에서 자주 쓰는 흐름을 한 번에 묶어서 연습할 수 있기 때문입니다.

코딩 화면 이미지
[이미지] 개발 환경/코딩 화면

이 글은 “정말 처음”인 분을 기준으로, Spring Boot 3.x + Spring Data JPA로 가장 단순한 게시판을 완성하는 흐름을 단계별로 안내합니다. 화면은 Thymeleaf 기반 MVC로 설명하되, 마지막에 REST API로 바꾸는 팁도 같이 제공합니다.

완성 목표

  • 게시글 작성/목록/상세/수정/삭제
  • 목록 페이징 + 간단 검색(제목)
  • DTO + Validation으로 입력 검증
  • 예외 처리(404/검증 오류) 기본 뼈대
  • H2(개발) → MySQL(운영) 전환 포인트
아키텍처 스케치 이미지
[이미지] 목표 기능/구조 스케치

준비물

  • JDK 17 이상(권장: 17)
  • IntelliJ IDEA(Community도 가능) 또는 VS Code
  • Spring Initializr로 프로젝트 생성

기술 스택(이 글 기준)

  • Spring Boot 3.x
  • Spring Data JPA
  • H2 Database(개발용)
  • Thymeleaf(템플릿 엔진, 선택)
  • Validation(jakarta.validation)
개발 도구 이미지
[이미지] JDK/IDE/툴 준비

1) 프로젝트 생성 (Spring Initializr)

Spring Initializr에서 다음 옵션으로 시작하면 가장 무난합니다.

  • Project: Gradle - Groovy(또는 Maven)
  • Language: Java
  • Spring Boot: 3.x
  • Packaging: Jar
  • Java: 17

의존성 선택

  • Spring Web
  • Spring Data JPA
  • Thymeleaf(화면 만들 경우)
  • Validation
  • H2 Database
  • Lombok(선택, 편의용)
초기 프로젝트 설정 이미지
[이미지] Initializr 설정/의존성 선택

2) 설정 파일(application.yml)

개발 단계에서는 H2를 쓰고, JPA가 생성하는 SQL을 확인할 수 있게 로그를 켭니다.

spring:
  datasource:
    url: jdbc:h2:mem:boarddb;MODE=MYSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true
      path: /h2-console

  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
    open-in-view: false

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

✅ 팁: open-in-view: false로 두면 “서비스 계층에서 필요한 데이터를 다 로딩”하는 습관이 생겨서, 나중에 성능/설계가 더 깔끔해집니다.

설정 파일 이미지
[이미지] application.yml 설정

3) 도메인(Entity) 만들기

게시판의 핵심은 “게시글(Post)”입니다. 가장 단순한 컬럼부터 시작합니다: 제목, 내용, 작성자, 생성/수정 시간.

BaseTimeEntity (생성/수정 시간 자동 처리)

package com.example.board.domain;

import jakarta.persistence.*;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

그리고 메인 애플리케이션에 JPA Auditing을 켭니다.

package com.example.board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
public class BoardApplication {
    public static void main(String[] args) {
        SpringApplication.run(BoardApplication.class, args);
    }
}
엔티티 설계 이미지
[이미지] Entity 설계(시간 컬럼 포함)

Post 엔티티

package com.example.board.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseTimeEntity {

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

    @Column(nullable = false, length = 100)
    private String title;

    @Lob
    @Column(nullable = false)
    private String content;

    @Column(nullable = false, length = 30)
    private String author;

    public Post(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

4) Repository 만들기

Spring Data JPA는 인터페이스만 만들어도 CRUD가 거의 끝납니다.

PostRepository

package com.example.board.repository;

import com.example.board.domain.Post;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PostRepository extends JpaRepository<Post, Long> {
    Page<Post> findByTitleContainingIgnoreCase(String keyword, Pageable pageable);
}
리포지토리 코드 이미지
[이미지] Repository로 CRUD 기반 만들기

5) DTO + Validation (입력 검증)

엔티티를 컨트롤러에서 직접 받는 방법도 있지만, 초보 단계부터 DTO를 쓰는 습관을 추천합니다. 이유는 간단합니다: 화면/요청 형식이 바뀌어도 엔티티가 덜 흔들립니다.

요청 DTO

package com.example.board.web.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class PostCreateRequest {

    @NotBlank
    @Size(max = 100)
    private String title;

    @NotBlank
    private String content;

    @NotBlank
    @Size(max = 30)
    private String author;
}
package com.example.board.web.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class PostUpdateRequest {

    @NotBlank
    @Size(max = 100)
    private String title;

    @NotBlank
    private String content;
}
검증과 DTO 이미지
[이미지] DTO/Validation으로 안전한 입력 처리

6) Service 계층 (비즈니스 로직)

서비스는 “저장/조회/수정/삭제”의 중심입니다. 트랜잭션 범위를 서비스에서 잡아두면 컨트롤러가 얇아지고, 테스트도 쉬워집니다.

PostService

package com.example.board.service;

import com.example.board.domain.Post;
import com.example.board.repository.PostRepository;
import com.example.board.web.dto.PostCreateRequest;
import com.example.board.web.dto.PostUpdateRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {

    private final PostRepository postRepository;

    @Transactional
    public Long create(PostCreateRequest req) {
        Post post = new Post(req.getTitle(), req.getContent(), req.getAuthor());
        return postRepository.save(post).getId();
    }

    public Post getOrThrow(Long id) {
        return postRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다. id=" + id));
    }

    public Page<Post> list(String keyword, Pageable pageable) {
        if (keyword == null || keyword.isBlank()) {
            return postRepository.findAll(pageable);
        }
        return postRepository.findByTitleContainingIgnoreCase(keyword, pageable);
    }

    @Transactional
    public void update(Long id, PostUpdateRequest req) {
        Post post = getOrThrow(id);
        post.update(req.getTitle(), req.getContent());
    }

    @Transactional
    public void delete(Long id) {
        Post post = getOrThrow(id);
        postRepository.delete(post);
    }
}
서비스 계층 이미지
[이미지] Service/Transaction 흐름

7) Controller (MVC + Thymeleaf)

이제 화면과 연결합니다. URL은 직관적인 REST 스타일로 잡아두면 나중에 API로 바꿀 때도 편합니다.

PostController

package com.example.board.web;

import com.example.board.domain.Post;
import com.example.board.service.PostService;
import com.example.board.web.dto.PostCreateRequest;
import com.example.board.web.dto.PostUpdateRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
@RequiredArgsConstructor
@RequestMapping("/posts")
public class PostController {

    private final PostService postService;

    @GetMapping
    public String list(
            @RequestParam(required = false) String keyword,
            @PageableDefault(size = 10) Pageable pageable,
            Model model
    ) {
        Page<Post> page = postService.list(keyword, pageable);
        model.addAttribute("page", page);
        model.addAttribute("keyword", keyword);
        return "posts/list";
    }

    @GetMapping("/{id}")
    public String detail(@PathVariable Long id, Model model) {
        model.addAttribute("post", postService.getOrThrow(id));
        return "posts/detail";
    }

    @GetMapping("/new")
    public String createForm(Model model) {
        model.addAttribute("form", new PostCreateRequest());
        return "posts/new";
    }

    @PostMapping
    public String create(
            @Valid @ModelAttribute("form") PostCreateRequest form,
            BindingResult bindingResult,
            RedirectAttributes ra
    ) {
        if (bindingResult.hasErrors()) {
            return "posts/new";
        }
        Long id = postService.create(form);
        ra.addFlashAttribute("message", "게시글이 등록되었습니다.");
        return "redirect:/posts/" + id;
    }

    @GetMapping("/{id}/edit")
    public String editForm(@PathVariable Long id, Model model) {
        Post post = postService.getOrThrow(id);
        PostUpdateRequest form = new PostUpdateRequest();
        form.setTitle(post.getTitle());
        form.setContent(post.getContent());

        model.addAttribute("postId", id);
        model.addAttribute("form", form);
        return "posts/edit";
    }

    @PostMapping("/{id}/edit")
    public String edit(
            @PathVariable Long id,
            @Valid @ModelAttribute("form") PostUpdateRequest form,
            BindingResult bindingResult,
            RedirectAttributes ra
    ) {
        if (bindingResult.hasErrors()) {
            return "posts/edit";
        }
        postService.update(id, form);
        ra.addFlashAttribute("message", "게시글이 수정되었습니다.");
        return "redirect:/posts/" + id;
    }

    @PostMapping("/{id}/delete")
    public String delete(@PathVariable Long id, RedirectAttributes ra) {
        postService.delete(id);
        ra.addFlashAttribute("message", "게시글이 삭제되었습니다.");
        return "redirect:/posts";
    }
}
MVC 컨트롤러 이미지
[이미지] Controller 라우팅 설계

8) Thymeleaf 템플릿 (최소 구성)

아래는 “최소 동작”을 목표로 한 간단한 템플릿 예시입니다. (실전에서는 레이아웃 분리, 공통 헤더/푸터, CSS 등을 추가하세요.)

posts/list.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Posts</title></head>
<body>
  <h1>게시판</h1>

  <form th:action="@{/posts}" method="get">
    <input type="text" name="keyword" th:value="${keyword}" placeholder="제목 검색">
    <button type="submit">검색</button>
    <a th:href="@{/posts/new}">글쓰기</a>
  </form>

  <div th:if="${message}" th:text="${message}"></div>

  <ul>
    <li th:each="post : ${page.content}">
      <a th:href="@{/posts/{id}(id=${post.id})}" th:text="${post.title}"></a>
      <small th:text="${post.author}"></small>
    </li>
  </ul>

  <div>
    <span th:text="${page.number + 1}"></span> / <span th:text="${page.totalPages}"></span>

    <div>
      <a th:if="${page.hasPrevious()}"
         th:href="@{/posts(keyword=${keyword}, page=${page.number - 1})}">이전</a>

      <a th:if="${page.hasNext()}"
         th:href="@{/posts(keyword=${keyword}, page=${page.number + 1})}">다음</a>
    </div>
  </div>
</body>
</html>
Thymeleaf 템플릿 이미지
[이미지] 목록 화면(검색/페이징) 템플릿

posts/detail.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Detail</title></head>
<body>
  <h1 th:text="${post.title}"></h1>

  <p>
    작성자: <span th:text="${post.author}"></span>
    / 생성: <span th:text="${post.createdAt}"></span>
  </p>

  <pre th:text="${post.content}"></pre>

  <div>
    <a th:href="@{/posts/{id}/edit(id=${post.id})}">수정</a>
    <form th:action="@{/posts/{id}/delete(id=${post.id})}" method="post" style="display:inline">
      <button type="submit">삭제</button>
    </form>
    <a th:href="@{/posts}">목록</a>
  </div>
</body>
</html>

posts/new.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>New</title></head>
<body>
  <h1>글쓰기</h1>

  <form th:action="@{/posts}" th:object="${form}" method="post">
    <div>
      <input th:field="*{title}" placeholder="제목">
      <div th:errors="*{title}"></div>
    </div>

    <div>
      <textarea th:field="*{content}" placeholder="내용"></textarea>
      <div th:errors="*{content}"></div>
    </div>

    <div>
      <input th:field="*{author}" placeholder="작성자">
      <div th:errors="*{author}"></div>
    </div>

    <button type="submit">등록</button>
    <a th:href="@{/posts}">취소</a>
  </form>
</body>
</html>

posts/edit.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Edit</title></head>
<body>
  <h1>수정</h1>

  <form th:action="@{/posts/{id}/edit(id=${postId})}" th:object="${form}" method="post">
    <div>
      <input th:field="*{title}" placeholder="제목">
      <div th:errors="*{title}"></div>
    </div>

    <div>
      <textarea th:field="*{content}" placeholder="내용"></textarea>
      <div th:errors="*{content}"></div>
    </div>

    <button type="submit">저장</button>
    <a th:href="@{/posts/{id}(id=${postId})}">취소</a>
  </form>
</body>
</html>
게시판 화면 구성 이미지
[이미지] 글쓰기/수정/상세 화면 구성

9) 예외 처리(초보용으로 가장 쉬운 방식)

지금은 IllegalArgumentException으로 던지고 있지만, 사용자 경험을 위해 “에러 페이지/메시지”를 통일하는 게 좋습니다. 초보 단계에서는 아래처럼 전역 예외 처리로 시작하면 충분합니다.

GlobalExceptionHandler

package com.example.board.web;

import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(IllegalArgumentException.class)
    public String handleIllegalArgument(IllegalArgumentException e, Model model) {
        model.addAttribute("errorMessage", e.getMessage());
        return "error/400";
    }
}

error/400.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><meta charset="UTF-8"><title>Error</title></head>
<body>
  <h1>요청 처리 중 문제가 발생했습니다</h1>
  <p th:text="${errorMessage}"></p>
  <a th:href="@{/posts}">목록으로</a>
</body>
</html>
예외 처리 이미지
[이미지] 전역 예외 처리로 에러 UX 통일

10) 운영 DB(MySQL)로 바꾸는 포인트

개발이 끝나면 H2 대신 MySQL을 붙이면 됩니다. 가장 쉬운 방법은 profile을 나누는 것입니다.

application-dev.yml

spring:
  datasource:
    url: jdbc:h2:mem:boarddb;MODE=MYSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa

application-prod.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/boarddb?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: root
    password: your_password
  jpa:
    hibernate:
      ddl-auto: validate

✅ 운영에서는 보통 ddl-autovalidate 또는 none으로 두고, 스키마 변경은 마이그레이션 도구(Flyway/Liquibase)로 관리합니다.

데이터베이스 전환 이미지
[이미지] H2 → MySQL 전환 포인트

11) REST API로 바꾸고 싶다면 (선택)

“화면” 대신 “API”로 만들고 싶다면 컨트롤러를 @RestController로 바꾸고, 응답 DTO를 만들어 반환하면 됩니다. 아래는 아주 단순한 예시입니다.

응답 DTO

package com.example.board.api.dto;

import com.example.board.domain.Post;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class PostResponse {
    private final Long id;
    private final String title;
    private final String content;
    private final String author;
    private final LocalDateTime createdAt;

    public PostResponse(Post post) {
        this.id = post.getId();
        this.title = post.getTitle();
        this.content = post.getContent();
        this.author = post.getAuthor();
        this.createdAt = post.getCreatedAt();
    }
}

API Controller

package com.example.board.api;

import com.example.board.api.dto.PostResponse;
import com.example.board.domain.Post;
import com.example.board.service.PostService;
import com.example.board.web.dto.PostCreateRequest;
import com.example.board.web.dto.PostUpdateRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/posts")
public class PostApiController {

    private final PostService postService;

    @PostMapping
    public Long create(@Valid @RequestBody PostCreateRequest req) {
        return postService.create(req);
    }

    @GetMapping("/{id}")
    public PostResponse detail(@PathVariable Long id) {
        Post post = postService.getOrThrow(id);
        return new PostResponse(post);
    }

    @GetMapping
    public Page<PostResponse> list(
            @RequestParam(required = false) String keyword,
            Pageable pageable
    ) {
        return postService.list(keyword, pageable).map(PostResponse::new);
    }

    @PutMapping("/{id}")
    public void update(@PathVariable Long id, @Valid @RequestBody PostUpdateRequest req) {
        postService.update(id, req);
    }

    @DeleteMapping("/{id}")
    public void delete(@PathVariable Long id) {
        postService.delete(id);
    }
}
REST API 이미지
[이미지] MVC → REST API 전환

12) 초보가 자주 막히는 포인트 (체크리스트)

1) 404가 뜬다

  • 컨트롤러 매핑 경로(@RequestMapping("/posts"))와 실제 URL이 같은지 확인
  • 템플릿 파일 경로가 resources/templates/ 아래인지 확인

2) 저장은 되는데 목록에서 에러가 난다

  • DTO Validation 에러가 화면에서 처리되는지 확인
  • open-in-view를 끈 경우, 지연 로딩 연관관계를 화면에서 직접 접근하면 에러가 날 수 있음

3) 한글이 깨진다

  • DB charset(UTF-8) 확인
  • MySQL 연결 문자열에 characterEncoding=UTF-8 포함
디버깅 이미지
[이미지] 초보 디버깅 체크리스트

마무리

여기까지 따라오면 “게시판 1개”가 돌아갑니다. 이 상태에서 다음 확장을 추천합니다.

  • 댓글(Comment) 엔티티 추가 + 연관관계 매핑
  • 회원(User) + 로그인(Spring Security) 도입
  • 검색 확장(내용/작성자, 복합 조건)
  • 파일 업로드(이미지 첨부)
  • Flyway로 DB 마이그레이션 관리
  • 테스트 코드(RepositoryTest / ServiceTest) 추가
프로젝트 확장 이미지
[이미지] 다음 확장(댓글/로그인/검색/업로드)

Meta Description

Spring Boot 3.x와 Spring Data JPA로 게시판 CRUD를 가장 쉽게 구현하는 입문 가이드. 엔티티/리포지토리/서비스/컨트롤러, Validation, 페이징/검색, H2→MySQL 전환까지 한 번에 정리했습니다.

관련 키워드 태그 10개

#SpringBoot3 #JPA #SpringDataJPA #게시판만들기 #CRUD #Thymeleaf #H2Database #MySQL #페이징 #Validation

반응형