
이 글은 2026년 05월 11일 작성된 글입니다.
오늘은 Thymeleaf 기반 폼 처리 개선, 게시글 목록과 상세 리다이렉트,
그리고 Spring Security와 회원가입 기능까지 정리했다.
기존에는 컨트롤러에서 HTML을 직접 만들거나 문자열을 조립하는 방식에 가까웠지만,
이제는 Thymeleaf 템플릿 파일을 사용해서 화면을 분리했다.
<form action="/posts/write" method="post">
<input type="text" name="title">
<textarea name="content"></textarea>
<button type="submit">등록</button>
</form>
Thymeleaf에서 값을 출력할 때는 th:text와 th:utext를 사용할 수 있다.
| 문법 | 설명 |
|---|---|
th:text | HTML 특수문자를 이스케이프해서 출력 |
th:utext | HTML 태그를 해석해서 출력 |
<span th:text="${user.name}"></span>
<span th:utext="${htmlContent}"></span>
일반 텍스트는 th:text를 사용하는 것이 안전하다.
th:utext는 HTML을 그대로 렌더링하므로 신뢰할 수 있는 데이터에만 사용해야 한다.
글 작성 페이지에 처음 들어올 때도 폼 객체가 필요하다.
@GetMapping("/posts/write")
public String showWrite(WriteForm form) {
return "post/write";
}
Thymeleaf에서 form.title 같은 값을 사용하려면
처음 화면을 보여줄 때도 form 객체가 존재해야 한다.
Thymeleaf에서는 null 안전 접근을 위해 ?.를 사용할 수 있다.
<input th:value="${writeForm?.title}">
| 표현식 | 특징 |
|---|---|
writeForm?.title | writeForm이 null이어도 에러를 막는다. |
writeForm.title | writeForm이 null이면 에러가 발생할 수 있다. |
초기 GET 요청에서 form 객체가 없을 수 있다면 ?.를 쓰면 안전하다.
하지만 form 객체를 항상 모델에 넣는 구조라면 직접 접근해도 된다.
폼 객체 이름을 명확하게 지정하기 위해 @ModelAttribute("form")을 사용했다.
public String write(@ModelAttribute("form") WriteForm form) {
return "post/write";
}
이렇게 하면 Thymeleaf에서 writeForm 대신 form이라는 이름으로 접근할 수 있다.
<input th:value="${form.title}">
처음에는 작성 처리 URL을 따로 두었다.
POST /posts/doWrite
하지만 POST 자체에 이미 생성이라는 의미가 있기 때문에
다음처럼 정리하는 것이 더 자연스럽다.
GET /posts/write
POST /posts/write
같은 URL이라도 HTTP 메서드가 다르면 스프링은 서로 다른 액션으로 구분할 수 있다.
/posts/write는 작성 폼을 출력한다./posts/write는 작성 처리를 담당한다.폼 관련 중복을 줄이기 위해 th:object와 th:field를 사용했다.
<form th:object="${form}" method="post">
<input th:field="*{title}">
<textarea th:field="*{content}"></textarea>
</form>
th:object를 사용하면 내부에서 form. 접두어를 생략할 수 있다.
th:field는 다음 속성들을 자동으로 처리해준다.
Validation 실패 시 Thymeleaf에서 에러 메시지를 출력했다.
<div th:if="${#fields.hasAnyErrors()}">
<div th:each="err : ${#fields.allErrors()}" th:text="${err}"></div>
</div>
서버에서 검증한 결과를 사용자에게 다시 보여줄 수 있다.
게시글 등록이 끝나면 목록이 아니라
생성된 게시글의 상세 페이지로 이동하도록 처리했다.
return "redirect:/posts/" + post.getId();
등록 결과를 바로 확인할 수 있어서 사용자 흐름이 더 자연스럽다.
HTTP 응답 코드 기준으로 보면 리다이렉트는 3xx 응답에 해당한다.
게시글 목록 페이지를 구현했다.
<tr th:each="post : ${posts}">
<td th:text="${post.id}"></td>
<td>
<a th:href="@{|/posts/${post.id}|}" th:text="${post.title}"></a>
</td>
</tr>
목록에서 상세 페이지로 이동할 수 있도록 링크도 함께 연결했다.
회원 기능을 위해 Member 도메인을 추가했다.
게시글 기능과 마찬가지로
회원 기능도 도메인 단위로 분리해서 관리한다.
회원 기능과 인증 처리를 위해 Spring Security를 추가했다.
implementation("org.springframework.boot:spring-boot-starter-security")
Security를 추가하면 기본적으로 인증/인가 기능이 적용된다.
Security 설정을 직접 구성하기 위해 SecurityConfig를 만들었다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
}
기본 보안 설정을 프로젝트에 맞게 수정하기 위한 단계이다.
Spring Security가 적용되면 h2-console 접근도 막힐 수 있다.
그래서 개발 중에는 h2-console에 접근할 수 있도록 SecurityConfig에서 허용했다.
회원가입 기능을 구현하고 테스트 회원 3명을 생성했다.
회원가입에서는 다음 값을 받는다.
회원가입 입력 화면을 만들었다.
<form th:object="${form}" method="post">
<input th:field="*{username}">
<input th:field="*{password}" type="password">
<input th:field="*{nickname}">
<button type="submit">회원가입</button>
</form>
회원가입 폼에도 validation을 적용했다.
@NotBlank
private String username;
@NotBlank
private String password;
빈 값이나 잘못된 값이 들어오지 않도록 서버에서 검증했다.
단순히 빈 값만 확인하는 것이 아니라
회원가입 처리 과정에서 필요한 데이터 검증도 추가했다.
예를 들면 다음과 같은 검증이 필요하다.
회원가입 검증 로직을 정리하여
컨트롤러가 너무 복잡해지지 않도록 개선했다.
검증과 저장 로직을 적절히 분리하면
회원 기능이 커져도 유지보수하기 쉬워진다.
th:object와 th:field를 사용하면 폼 코드를 훨씬 간결하게 작성할 수 있다.#fields를 함께 사용하면 사용자에게 에러 메시지를 자연스럽게 보여줄 수 있다.