Spring/개인 공부

4. CRUD 게시판 프로젝트 - 네비바 구현, 작성자명 출력, 게시글 작성폼 유효성 검사

코딩하는냥이 2025. 7. 20. 00:36
반응형

이번 글에서는 아무래도 보수를 좀 해야할 것 같습니다.

저번 글이 좀 하다가 끊긴 부분이 많았던 느낌이 있던 만큼 부족한 부분을 채우고 가려고 합니다.

물론 저번 글 마지막에서 말씀드렸던 유효성 검사도 포함해서 진행할 생각입니다.


1. Navbar (네이바) 구현!

일단 저번에 로그인을 하는 것에서 끝냈잖아요?

하지만 아직 로그인이 성공한건지, 실패한건지 알 수 없습니다.

그래서 일단 고정적인 편의 기능을 위해서 네비바(상단바) 구현을 해보고자 합니다.

 

navbar.html 위치

 

사실 저번 글을 적고 있을 때, 저는 이미 만들었었습니다.

하지만 만들어도 연결을 하지 않으면 화면에 나오지 않는법!

 

navbar.html

<nav th:fragment="navbarFrgment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">자유게시판</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
            data-bs-target="#navbarSupportedContent"
            aria-controls="navbarSupportedContent" aria-expanded="false"
            aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
                <li class="nav-item">
                    <a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/empl/login}">로그인</a>
                    <a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/empl/logout}" >로그아웃</a>
                </li>
                <li class="nav-item"><a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/empl/create}">회원가입</a></li>
            </ul>
        </div>
    </div>
</nav>

 

먼저 navbar.html의 코드입니다.

이 또한 기본적으로 제공하는 코드가 있기 때문에, 부트 스트랩의 네비바 탭을 보시면 코드가 적힌 게 있습니다.

그 곳에 있는 코드를 그냥 복사해서 넣어도 네비바의 형태가 바로 나오게 됩니다.

그렇기 때문에 기본적으로 제공되는 것을 제외하고 코드를 설명해드리겠습니다.

 

th:fragment="navbarFrgment"

먼저 navbar.html 에 들어간 Fragment(조각) 이라는 이름으로,

해당 위치를 정의하고 치환(Replace) 해서 랜더링할 준비를 하는 곳 입니다.

오타로 navbarFragment 인데, navbarFrgment라고 적은 것을 볼 수 있습니다.

여기서 확인할 수 있듯이, 저 안에 적는 것은 본인이 원하는 이름으로 적어도 괜찮습니다.

 

약간 저는 자바에서 메서드를 호출하는 느낌으로 생각하고 있는 편입니다.

 

href="/"

이것은 다시 잊을까 싶어서 다시 복습으로 짚는 느낌입니다만,

저희가 MainController에서 주소가 "/"만 남으면 "/board/list"로 리다이렉트하게 걸어두어서

/ 만 남아있으면 MainController.me -> BoardController.getListBoard 로 이동해서 게시글 목록 화면을 띄웁니다.

 

sec:authorize="isAnonymous()"/"isAuthenticated()"

Spring Security(스프링 시큐리티)에서 제공하는 Thymeleaf(타임리프)용 코드입니다.

특정 조건에 따라서 해당 코드가 적힌 HTML 코드(태그)를 보여줄지 혹은 숨길지 결정합니다.

 

◆ isAnonymous → 로그아웃 상태일 때, 출력

익명(Anonymous) 사용자라는 의미로, 로그인을 하지 않았을 때 TRUE가 되는 특징이 있습니다.

 

◆  isAuthenticated → 로그인 상태일 때, 출력

인증된(Authenticated) 사용자라는 의미로, 로그인을 했을 때 TRUE가 되는 특징이 있습니다.

 

그 외의 조건으로는..

◆ hasRole('ROLE_ADMIN') → ADMIN 권한이 있을 때 TRUE (관리자 메뉴라던가)

◆ hasAuthority('ROLE_USER') → USER 권한이 있을 때 TRUE (Role ≠ Authority 라고 함. 나중에 추후 검색)

◆ isRememberMe() → 자동 로그인(Remember me) 상태일 때 TRUE

◆ isFullAuthenticated() → 진?짜 로그인 상태일 때 TRUE (자동 로그인 제외)

 

layout.html

<!-- HTML <body> 부분에 추가 -->
<body>
    <!-- 네비바 -->
    <nav th:replace="~{navbar :: ${navbarFrgment}}"></nav>

    <!-- Bootstrap fragment -->
    <th:block layout:fragment="content"></th:block>
	
    <!-- Bootstrap JS -->
    <script type="text/javascript" th:src="@{/bootstrap.min.js}"></script>
</body>

 

th:replace="~{navbar :: ${navbarFrgment}}"

저 곳에 오타가 있었지만 잘 가져왔네요. 보통 복붙으로 가져오기 때문에..

navbar.html 에서 정의한 navbarFrgment를 가져온다는 의미입니다.

제가 아까 말씀드린 것처럼 메서드를 가져오는 느낌으로 가져와서 그걸 <nav> 태그로 출력한 것입니다.
저희 layout.html 은 이미 각 다른 html에서 가져다가 쓰고있기 때문에 body 안에 넣으면,

layout을 쓰고있는 페이지는 다 적용되는 것을 볼 수 있습니다.

 

(좌) 로그인 전 / (우) 로그인 후

 

이러면 로그인이 잘 작동하고 있는 것을 볼 수 있습니다.

그럼 일단 로그인이 잘 작동하는 것을 확인했으니, 보이는 순서대로 기능을 좀 고치려고 합니다.


2. 작성자 출력하기

작성일자가 너무 길게 나오는 게시판

 

게시판을 보면 작성일자가 시간초의 소수점 6자리까지 출력되는 것을 볼 수 있습니다.

하지만 보통의 게시판들은 이만큼 정확하게 시간을 적어주지 않습니다.

그래서 이 부분을 바꾸고, 작성자 이름도 나오게 하려고 합니다.

 

일단 먼저 비교적 간단한 작성일자를 정상적으로 출력되게 하는 것부터 진행해보겠습니다.

 

board_list.html

<!-- 게시판 목록 중, 날씨를 출력하는 부분 / 기존 코드 -->
<td th:text="${board.createDate}"></td>

<!-- 날씨를 출력하는 부분 / 바뀐 코드 -->
<td th:text="${#temporals.format(board.createDate, 'yyyy-MM-dd')}"></td>

 

${#temporals.format(board.createDate, 'yyyy-MM-dd')}

temporals는 Tymeleaf 내장 유틸리티 객체 중 하나로, 이것을 통해서 날짜/시간 포맷을 진행합니다.

출력을 연-월-일로 출력을 할 건데, 그것을 board 객체의 필드인 createDate에 적용할 것입니다.

출력 방식을 정하는 것은 보통 일반적으로 사용되는 형식을 따르기 때문에 다르게 출력하는 것도 어렵지 않을겁니다.

 

그러면 로그인 구현이 끝났으니, 게시 목록에서 작성자의 이름이 나오는 작업을 하려고합니다.

하지만 저희는 기본적으로 테이블에 작성자 칸을 만들어두지 않았는데요?

그러면 저희가 이제 만들면 되죠!

 

Board.java

@Getter
@Setter
@Entity
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
	
    @Column(length = 200)
    private String subject;
	
    @Column(columnDefinition = "TEXT")
    private String content;
    private LocalDateTime createDate;
	
    @OneToMany(mappedBy = "board", cascade = CascadeType.REMOVE)
    private List<Comment> commentList;
	
    // 새로 추가된 부분
    @ManyToOne
    private Employee employee;
}

 

작성자 정보를 받을 칸을 만들어주고, 저장을 해주면 H2 DB에 변화가 생기는 것을 알 수 있을겁니다.

EMPLOYEE_ID is null

 

"null이 있으니까, 이대로 작성자를 출력시키면 그 칸에 null이 출력되겠네요?"

 

한번 정말로 그런지 확인해볼까요?

 

board_list.html

<!-- 기존 게시 목록 출력부 -->
<thead>
    <tr>
        <th>번호</th>
        <th>제목</th>
        
        <!-- 새로 추가된 부분 -->
        <th>작성자</th>
        <!-- 새로 추가된 부분 -->
        
        <th>작성일자</th>
    </tr>
</thead>
<tbody>
    <tr th:each="board : ${paging}">
        <td th:text="${board.id}"></td>
        <td><a th:text="${board.subject}" th:href="@{|/board/detail/${board.id}|}"></a></td>
        <td th:text="${board.employee.nickname}"></td>
        
        <!-- 새로 추가된 부분 -->
        <td th:text="${board.employee.nickname}"></td>
        <!-- 새로 추가된 부분 -->
        
        <td th:text="${#temporals.format(board.createDate, 'yyyy-MM-dd')}"></td>
    </tr>
</tbody>

 

일단 설명하기에 앞서, 이렇게 작성할 경우 500 에러가 발생합니다.

에러의 메인 내용은

 

"board.employee.username을 평가하는 중 예외가 발생했습니다(템플릿: board_list - 16번째 줄, 9번째 열)."

 

이 내용은 받아온 값이 null일 경우에 출력되는 내용인데요?

저희가 직접 H2 DB에 접근해서 null인 값에 임의의 값을 넣어주고,

앞으로 작성하는 글에 null이 나오지 않게 하는 방법도 있습니다만,

저희는 직접 타임리프에서 제공하는 if문을 통해 무시하는 방식을 택하겠습니다.

 

<!-- if문을 통해서 employee 값이 null이 아닐때에만 출력하게 조건 설정 -->
<td><div th:if="${board.employee != null}" th:text="${board.employee.nickname}"></div></td>

 

그냥 저희가 쓰던 if문을 그대로 사용한 것입니다.

 

"div 태그는 왜 들어간 것인가요?"

 

이게 저희가 td 태그에 if문을 걸 경우, 반복을 통해서 출력하는 td 태그 하나가 나오지 않으면 값이 부족해서 칸이 밀립니다.

작성자가 null일 경우 작성일자가 앞으로 밀릴 수 있는 것이죠.

그래서 저는 td 태그는 출력하면서 뒤의 if문은 div 태그에 던졌다고 봐주시면 좋을 것 같습니다.

 

null 값이라 잘 보이지는 않아도 작성자 칸이 잘 뜨는 것을 확인할 수 있다.

 

그러면 이제 작성자 출력도 준비되었겠다.

저희는 글을 작성하는 부분에 대해서 설정을 바꿔주어야합니다.

 

BoardService.java

public void createBoard(String subject, String content, Employee empl) {
    Board board = new Board();
    board.setSubject(subject);
    board.setContent(content);
    board.setCreateDate(LocalDateTime.now());
    board.setEmployee(empl);
		
    boardRepository.save(board);
}

 

먼저 간단하게 Employee를 받고, 그것을 DB에 저장하기 전에 board에 넣어주기만 하면 됩니다.

여기를 수정을 하면, Service를 사용하고 있는 Controller에서 빨간줄이 그어지는 것을 확인하실 수 있습니다.

 

메서드에 들어간 변수가 부족해서 에러가 뜨는 모습

지금 확인하니 contet라고 적혀있네요. content로 슬쩍 바꿔주고..

여기에서 로그인 정보를 가져오기 위해서는 좀 여러가지 준비가 필요해서

바로 Controller 를 수정하지 않고 다른 곳으로 가보려고 합니다.

 

EmployeeService.java

// 새로 추가한 메서드
public Employee get(Integer id) {
    return this.employeeRepository.findByUsername(username).get();
}

 

해당 메서드를 간단하게 만들어줍니다.

※ 어차피 간단하게 하고 넘어갈거라 get으로 놔두었는데, 자주 겹치는 이름으로 메서드를 만들면 오류를 발생시킬 수 있습니다.

 

BoardController.java

@PostMapping("/create")
public String createBoard(@RequestParam("subject") String subject, @RequestParam("content") String content, Principal principal) {
    this.boardService.createBoard(subject, content, this.employeeService.get(principal.getName()));
    return "redirect:/";
}

 

그리고 post mapping이 되어있는 게시글 생성쪽을 수정해줍니다.

 

Principal principal

Spring Security에서 로그인한 사용자 정보를 담는 객체입니다.

principal.getName()을 통해서 사용자 정보의 username를 호출합니다.

저희가 Service에서 로그인할 때, username을 통해 Employee를 호출하는 레포지트리를 준비해두었습니다.

그래서 저희는 서비스에서 저희의 용도에 맞게 레포지트리에 있는 메서드를 호출하여 employee를 호출하게 작성하고,

그것을 controller에서 board service의 생성 기능으로 넘어가기 전에 이 값을 넣었습니다.

 

이로서 저희가 게시글을 제작하면, 이제 작성자도 같이 뜨는 것을 확인할 수 있습니다.

 

회원가입할 때 적었던 닉네임이 뜨는 것을 확인할 수 있다.


3. 게시글 작성 유효성 검사하기

하지만 여기서 2가지의 편의성 문제가 있습니다.

첫번째는 글 작성할 때의 유효성 검사가 필요하다는 것.

글을 작성하는데 제목과 내용을 비워둔 상태로 작성을 눌러도 글이 작성되는 것을 볼 수 있습니다.

그렇게 작성이 되면 저희가 만들어놓은 하이퍼링크가 작동하지 않아, 해당 글의 상세보기 페이지를 들어갈 수 없죠?

 

그리고 두번째는 최근에 적은 글은 아래로 내려가는 것입니다.

대부분의 게시글은 글을 적을 때 유저 편의성을 위해서 맨 위로 출력시키는 것이 보통입니다.

그래서 저희는 이 두가지도 고치고 넘어가겠습니다.

 

새로 생겨난 BoardForm

 

BoardForm.java

package com.mysite.sbb.board;

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

@Getter
@Setter
public class BoardForm {
	@NotEmpty(message = "제목이 비어있습니다.")
	@Size(max = 200, message = "200자를 넘을 수 없습니다.")
	private String subject;
	
	@NotEmpty(message = "내용이 비어있습니다.")
	private String content;
}

 

이것으로 Spring Boot에서 유효성 검사를 해 줄 DTO 클래스를 완성한 것입니다.

 

@NotEmpty(message = ...)

유효성 검사 (Validation) 어노테이션으로 해당 필드가 null이 아닐 때에만 TRUE를 반환합니다.

 

@Size(max = ..., message = ...)

문자열 길이를 제한할 수 있습니다.

FALSE일 경우 메세지를 출력할 수 있고, 조건에 맞아야지만 TRUE를 반환합니다.

 

이렇게 수정했으면, Controller로 넘어갑니다.

 

BoardController.java

// 수정된 부분
@GetMapping("/create")
public String createBoard(BoardForm boardForm) {
    return "board_form";
}

@PostMapping("/create")
public String createBoard(@Valid BoardForm boardForm, BindingResult bindingResult, Principal principal) {
    if (bindingResult.hasErrors()) 
        return "board_form";
    this.boardService.createBoard(boardForm.getSubject(), boardForm.getContent(), this.employeeService.get(principal.getName()));
    return "redirect:/";
}

 

BoardForm boardForm

Get 부분에서는 저희가 유효성 검사를 하기 위해 필드를 받으려고 호출해둡니다.

Post 부분에서는 작성된 필드를 받아서 Service(DB 등록)로 보내려고 호출합니다.

 

BindingResult bindingResult

BindingResult는 Spring MVC(및 Boot)에서 폼 데이터 검증 결과를 담는 결과 객체입니다.

@Valid와 바로 이어서 매개변수로 선언해야 유효성 검증 결과로 자동으로 담깁니다.

이것으로 bindingResult에 에러같은 내용 등, 결과가 담기게 되는 것입니다.

 

if(bindingResult.hasErrors())

이 조건식은 유효성 검사 결과에서 에러가 하나라도 있으면 TRUE를 반환하여 실행문을 실행합니다.

지금은 return "board_form"이 되어있기 때문에 작성 폼으로 다시 돌아옵니다.

 

여기까지 작성한 것 만으로도 글 작성 폼으로 직접 들어가서 글을 작성해보시면

유효성 검사가 작동한다는 것을 확인하실 수 있습니다.

 

하지만 제목이나 내용 둘 중 하나만 작성하고 작성하기를 눌러버릴 경우,

작성 폼으로 다시 돌아오면서 제목과 내용이 다 증발해버리는 것을 볼 수 있습니다.

 

이런 경우를 막기 위해서 HTML 코드도 수정해 줄 것입니다.

 

board_form.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <form th:action="@{/board/create}" method="post" th:object="${boardForm}">
        <h2>글 작성하기</h2>
        <div class="alert alert-danger align-items-center"
            th:if="${#fields.hasAnyErrors()}">
            <div th:each="err:${#fields.allErrors()}" th:text="|⚠ ${err}|"></div>
        </div>
        <div class="container">
            <div class="input-group my-3">
                <input type="text" class="form-control" placeholder="제목"
                    th:field="*{subject}">
            </div>
            <div class="input-group my-3">
                <textarea class="form-control" rows="10" placeholder="내용"
                    th:field="*{content}"></textarea>
            </div>
        </div>
        <div class="row justify-content-between">
            <a type="submit" class="col-2 btn btn-outline-danger" th:href="@{/}">돌아가기</a>
            <input type="submit" class="col-2 btn btn-primary" value="글 등록">
        </div>
    </form>
</div>
</html>

 

th:object="${boardForm}"

Thymeleaf에서 폼과 관련된 데이터를 가져오는 속성입니다.

이 코드는 폼 전체에 사용할 기준 객체를 지정합니다.

이것을 적음으로서 form 태그 안에서 th:field="*{...}" 형태로

각 필드와 BoardForm의 프로퍼티를 매핑할 수 있게 해줍니다.

 

<form th:object="${boardForm}">
    <input th:field="*{subject}" ... />
    <textarea th:field="*{content}" ...></textarea>
</form>

 

아래에 있는 필드와 자동으로 연결해주는 것입니다.

 

th:if="${#fields.hasAnyErrors()}"

Thymeleaf에서 제공하는 if문을 사용해서, 폼에 에러가 있을 때만 에러 메세지 박스를 화면에 보여줍니다.

 

#fields : 폼 바인딩에 사용되는 내장 객체로, 현재 폼에 대한 유효성 검사 결과를 조회할 수 있게 해주는 코드입니다.

hasAnyErrors : 폼 전체에서 어던 필드든 에러가 하나라도 있다면 TRUE를 반환합니다.

 

th:field="*{...}"

Thymeleaf에서 폼 필드와 객체의 프로퍼티를 연결할 때 사용하는 속성입니다.

위에서 th:object="${boardForm}"으로 기준 객체가 설정되어 있기 때문에,

subject → BoardForm.subject / content → BoardForm.content로 연결됩니다.

 

또한, 폼을 다시 보여줄 때 (유효성 검사에서 오류를 발견할 경우)

사용자가 입력한 값이 자동으로 해당 필드에 유지되고,

유효성 검사 결과도 연동됩니다.

 

※ 다만, input type이 password 같은 특수 개체인 경우 유지되지 않을 수 있습니다.

 

이렇게 전부 작성하고 오류가 뜨지 않는다면, 이것으로 끝입니다!!!

하지만 추후 계획을 위해서, 개발 편의를 위해서 조금 수정하고 넘어가겠습니다.

이 작업으로 보이는 것은 바뀌는 게 없습니다.

 

새롭게 생긴 form_errors.html

 

form_errors.html

<div th:fragment="errorDangerFragment" class="alert alert-danger align-items-center"
    th:if="${#fields.hasAnyErrors()}">
    <div th:each="err:${#fields.allErrors()}" th:text="|⚠ ${err}|"></div>
</div>

 

저희가 작성했던 위험 메세지 띄우는 부분에 th:fragment를 넣어서 다른 곳에 보관해줍니다.

 

board_form.html

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    <form th:action="@{/board/create}" method="post" th:object="${boardForm}">
        <h2>글 작성하기</h2>
        <!-- 수정된 부분 -->
        <div th:replace="~{form_errors :: ${errorDangerFragment}}"></div>
        <div class="container">
            <div class="input-group my-3">
                <input type="text" class="form-control" placeholder="제목"
                    th:field="*{subject}">
            </div>
            <div class="input-group my-3">
                <textarea class="form-control" rows="10" placeholder="내용"
                    th:field="*{content}"></textarea>
            </div>
        </div>
        <div class="row justify-content-between">
            <a type="submit" class="col-2 btn btn-outline-danger" th:href="@{/}">돌아가기</a>
            <input type="submit" class="col-2 btn btn-primary" value="글 등록">
        </div>
    </form>
</div>
</html>

 

그리고 저희가 layout.html에서 navbar.html을 불러왔던 것처럼

<div> 태그에서 error 상자를 가지고있는 fragment를 replace 해줍니다.

그러면 코드는 확실히 짧아졌는데, 나오는 값은 똑같죠?

 

이런 경고창의 경우에는 다른 곳에서도 계속 쓰일 것이기 때문에,

따로 넣어두고 필요할 때마다 꺼내주는 것이 좋습니다.

 

이번 글에서는 여기까지만 진행하겠습니다.

이정도 진행하면서 설명을 잘 읽으셨다면, 슬슬 코드가 익숙해지실겁니다!

이해하기 힘든 편도 아니라서 코딩함에 있어서 많은 도움이 되었으면 좋겠습니다.

 

다음 글부터는 상세 폼에 대한 내용이나 로그아웃에 대해서 적겠습니다.

까먹고 있었는데 방금 글을 적으면서 로그아웃 누르니까 에러뜨더라구요 ^^7