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)
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(선택, 편의용)
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로 두면 “서비스 계층에서 필요한 데이터를 다 로딩”하는 습관이 생겨서, 나중에 성능/설계가 더 깔끔해집니다.
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);
}
}
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);
}
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;
}
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);
}
}
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";
}
}
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>
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>
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-auto를 validate 또는 none으로 두고, 스키마 변경은 마이그레이션 도구(Flyway/Liquibase)로 관리합니다.
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);
}
}
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
'it' 카테고리의 다른 글
| OSI 7계층 실무 사례: 장애 원인 10분 안에 좁히는 사고방식 (0) | 2026.02.10 |
|---|---|
| 자바 17/21 마이그레이션 가이드 (실무 체크리스트 + 트러블슈팅) (0) | 2026.02.09 |
| Spring Boot 3 최신 특징 (2026 기준으로 “지금” 꼭 알아야 할 변화들) (0) | 2026.02.08 |
| Express 보안 미들웨어: 운영에서 바로 쓰는 필수 조합 (0) | 2026.02.07 |
| 엑셀 Copilot으로 복잡한 수식 1초 만에 만드는 법 (실무 프롬프트 템플릿 포함) (0) | 2026.02.06 |
| 라즈베리파이 5로 만드는 나만의 개인 클라우드 (Nextcloud 구축) (0) | 2026.02.06 |
| Node.js 가비아 호스팅 배포 시 발생하는 흔한 오류와 해결법 (0) | 2026.02.06 |
| AutoGPT 설치 방법 및 실무 활용 시나리오 (2026 최신판) (0) | 2026.02.06 |