[스프링부트 시리즈27] 글쓴이 항목 추가하기

Featured image

글쓴이 항목 추가하기


질문 또는 답변을 작성할 때 사용자 정보도 DB에 함께 저장해 보자. 이때 질문 또는 답변을 작성한 사용자는 반드시 로그인되어 있어야 한다. 그래야 누가 작성한 글인지 알 수 있고, 수정 및 삭제도 가능하기 때문이다. 이와 더불어 게시판의 질문 목록과 답변 상세 페이지에는 누가 글을 작성했는지 알려 주는 ‘글쓴이’ 항목도 추가해 보자.

엔티티에 속성 추가하기


먼저 기존에 만든 Question(질문)과 Answer(답변) 엔티티에 글쓴이에 해당하는 author 속성을 추가해 보자.

질문 엔티티에 속성 추가하기

질문 테이블에 글쓴이를 저장하려면 먼저 Question 엔티티에 author 속성을 추가해야 한다. 다음과 같이 Question.java를 수정해 보자.

[파일명:/question/Question.java]

(... 생략 ...)
import jakarta.persistence.ManyToOne;
import com.mysite.sbb.user.SiteUser;
(... 생략 ...)
public class Question {
    (... 생략 ...)

    @ManyToOne
    private SiteUser author;
}

author 속성에는 @ManyToOne 애너테이션을 적용했는데, 이는 사용자 한 명이 질문을 여러 개 작성할 수 있기 때문이다.

답변 엔티티에 속성 추가하기

Question 엔티티와 같은 방법으로 Answer 엔티티에도 author 속성을 추가해 보자.

[파일명:/answer/Answer.java]

(... 생략 ...)
import com.mysite.sbb.user.SiteUser;
(... 생략 ...)
public class Answer {
    (... 생략 ...)

    @ManyToOne
    private SiteUser author;
}

테이블 확인하기

앞선 실습에서 Question, Answer 엔티티를 변경했으므로, H2 콘솔에 접속하여 Question, Answer 테이블을 확인해 보자. question, answer 테이블에 author_id 열이 생성된 것을 확인할 수 있다. 이 열에는 글쓴이의 ID 값이 저장된다. Author

글쓴이 저장하기

이제 Question, Answer 엔티티에 author 속성이 추가되었으므로 질문과 답변 데이터를 저장할 때 author(글쓴이)도 함께 저장할 수 있다. 우리가 실습에서 익혔듯 새로운 데이터를 저장하려면 서버와 DB를 관리하는 컨트롤러와 서비스(또는 리포지터리)에도 관련 내용을 업데이트해야 한다. 기존 파일들을 수정하면서 이번에는 글쓴이 데이터를 어떻게 저장하는지 알아보자.

답변 컨트롤러와 서비스 업데이트하기

1) 답변을 저장할 때, 사용자 정보도 저장할 수 있도록 먼저 AnswerController를 수정해 보자.

[파일명:/answer/AnswerController.java]

(... 생략 ...)
import java.security.Principal;
(... 생략 ...)
public class AnswerController {

    (... 생략 ...)

    @PostMapping("/create/{id}")
    public String createAnswer(Model model, @PathVariable("id") Integer id, 
            @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
        (... 생략 ...)
    }
}

현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다. 여기서는 일단 이와 같이 createAnswer 메서드에 Principal 객체를 매개변수로 지정하는 작업까지만 해두고 답변 서비스를 수정해 보자.

[파일명:/user/UserService.java]

(... 생략 ...)
import java.util.Optional;
import com.mysite.sbb.DataNotFoundException;
(... 생략 ...)
@Service
public class UserService {

    (... 생략 ...)

    public SiteUser getUser(String username) {
        Optional<SiteUser> siteUser = this.userRepository.findByusername(username);
        if (siteUser.isPresent()) {
            return siteUser.get();
        } else {
            throw new DataNotFoundException("siteuser not found");
        }
    }
}

getUser 메서드는 userRepositoryfindByUsername 메서드를 사용하여 쉽게 만들 수 있다. 사용자명에 해당하는 데이터가 없을 경우에는 DataNoFoundException이 발생하도록 했다.

3) 답변 내용을 저장할 때 글쓴이 데이터도 저장할 수 있도록 AnswerService를 수정해 보자.

[파일명:/answer/AnswerService.java]

(... 생략 ...)
import com.mysite.sbb.user.SiteUser;
(... 생략 ...)
public class AnswerService {

    (... 생략 ...)

    public Answer create(Question question, String content, SiteUser author) {
        Answer answer = new Answer();
        answer.setContent(content);
        answer.setCreateDate(LocalDateTime.now());
        answer.setQuestion(question);
        answer.setAuthor(author);
        this.answerRepository.save(answer);
        return answer;
    }
}

create 메서드에 SiteUser 객체를 추가로 전달받아 작성자도 함께 저장하도록 수정했다.

4) 다시 AnswerController.java로 돌아가 다음과 같이 수정하여 createAnswer 메서드를 완성해 보자.

[파일명:/answer/AnswerController.java] ```java (… 생략 …) import com.mysite.sbb.user.SiteUser; import com.mysite.sbb.user.UserService; (… 생략 …) public class AnswerController {

private final QuestionService questionService;
private final AnswerService answerService;
private final UserService userService;

@PostMapping("/create/{id}")
public String createAnswer(Model model, @PathVariable("id") Integer id, 
        @Valid AnswerForm answerForm, BindingResult bindingResult, Principal principal) {
    Question question = this.questionService.getQuestion(id);
    SiteUser siteUser = this.userService.getUser(principal.getName());
    if (bindingResult.hasErrors()) {
        model.addAttribute("question", question);
        return "question_detail";
    }
    this.answerService.create(question, answerForm.getContent(), siteUser);
    return String.format("redirect:/question/detail/%s", id);
} } ```

principal 객체를 통해 사용자명을 얻은 후, 사용자명을 통해 SiteUser 객체를 얻어 답변을 등록할 때 사용했다.

질문 컨트롤러와 서비스 업데이트하기 질문 컨트롤러와 서비스도 앞선 방법과 동일하게 수정하면 되므로 빠르게 작성해 보자.

1) 먼저 글쓴이 데이터를 저장하기 위해 QuestionService를 다음과 같이 수정해 보자.

[파일명:/question/QuestionService.java]

(... 생략 ...)
import com.mysite.sbb.user.SiteUser;
(... 생략 ...)
public class QuestionService {

    (... 생략 ...)

    public void create(String subject, String content, SiteUser user) {
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(LocalDateTime.now());
        q.setAuthor(user);
        this.questionRepository.save(q);
    }
}

create 메서드에 SiteUser를 추가하여 Question 데이터를 생성하도록 수정했다.

2) 이어서 QuestionController도 다음과 같이 수정해 보자.

[파일명:/question/QuestionController.java]

(... 생략 ...)
import java.security.Principal;
import com.mysite.sbb.user.SiteUser;
import com.mysite.sbb.user.UserService;
(... 생략 ...)
public class QuestionController {

    private final QuestionService questionService;
    private final UserService userService;

    (... 생략 ...)

    @PostMapping("/create")
    public String questionCreate(@Valid QuestionForm questionForm, 
            BindingResult bindingResult, Principal principal) {
        if (bindingResult.hasErrors()) {
            return "question_form";
        }
        SiteUser siteUser = this.userService.getUser(principal.getName());
        this.questionService.create(questionForm.getSubject(), questionForm.getContent(), siteUser);
        return "redirect:/question/list";
    }
}

Principal 객체를 통해 사용자명을 구한 후, SiteUser를 조회하여 질문 저장 시 함께 저장할 수 있도록 했다.

3) 다시 로컬 서버를 시작하고 로그인한 다음, 질문과 답변 등록을 테스트해 보자.

SbbApplicationTests.java 오류를 해결해 보자 QuestionService의 create 메서드 매개변수로 SiteUser가 추가되었으므로 이전에 작성한 테스트 파일에서 오류가 발생한다. 테스트 파일의 오류를 임시로 해결하기 위해 다음과 같이 수정해 보자.

package com.mysite.sbb;

(... 생략 ...)

    @Test
    void testJpa() {
        for (int i = 1; i <= 300; i++) {
            String subject = String.format("테스트 데이터입니다:[%03d]", i);
            String content = "내용무";
            this.questionService.create(subject, content, null);
        }
    }
}

로그인 페이지로 이동시키기


1) 로그아웃 상태에서 질문 또는 답변을 등록해 보자. 그럼 다음과 같은 500 오류(서버 오류)가 발생한다. 500

이는 principal 객체가 널(null)이라서 발생한 오류이다. principal 객체는 로그인을 해야만 생성되는 객체인데 현재는 로그아웃 상태이므로 principal 객체에 값이 없어 오류가 발생하는 것이다.

2) 이 문제를 해결하려면 principal 객체를 사용하는 메서드에 @PreAuthorize("isAuthenticated()") 애너테이션을 사용해야 한다. @PreAuthorize("isAuthenticated()") 애너테이션이 붙은 메서드는 로그인한 경우에만 실행된다. 즉, 이 애너테이션을 메서드에 붙이면 해당 메서드는 로그인한 사용자만 호출할 수 있다. @PreAuthorize("isAuthenticated()") 애너테이션이 적용된 메서드가 로그아웃 상태에서 호출되면 로그인 페이지로 강제 이동된다. 먼저, QuestionController부터 다음과 같이 수정해 보자.

[파일명:/question/QuestionController.java]

package com.mysite.sbb.question;

(... 생략 ...)
import org.springframework.security.access.prepost.PreAuthorize;
(... 생략 ...)
public class QuestionController {

    (... 생략 ...)

    @PreAuthorize("isAuthenticated()")
    @GetMapping("/create")
    public String questionCreate(QuestionForm questionForm) {
        return "question_form";
    }

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/create")
    public String questionCreate(@Valid QuestionForm questionForm, 
            BindingResult bindingResult, Principal principal) {
        (... 생략 ...)
    }
}

로그인이 필요한 메서드(질문 등록과 관련된 메서드)들에 @PreAuthorize("isAuthenticated()") 애너테이션을 적용했다.

3) 마찬가지로 AnswerController도 다음과 같이 수정하자.

[파일명:/answer/AnswerController.java]

(... 생략 ...)
import org.springframework.security.access.prepost.PreAuthorize;
(... 생략 ...)
public class AnswerController {

    (... 생략 ...)

    @PreAuthorize("isAuthenticated()")
    @PostMapping("/create/{id}")
    public String createAnswer(Model model, @PathVariable("id") Integer id, @Valid AnswerForm answerForm,
            BindingResult bindingResult, Principal principal) {
        (... 생략 ...)
    }
}

4) 마지막으로 @PreAuthorize 애너테이션이 동작할 수 있도록 스프링 시큐리티의 설정도 수정해야 한다. SecurityConfig를 다음과 같이 수정해 보자.

[파일명:SecurityConfig.java]

(... 생략 ...)
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
(... 생략 ...)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
    (... 생략 ...)
}

SecurityConfig에 적용한 @EnableMethodSecurity 애너테이션의 prePostEnabled = trueQuestionControllerAnswerController에서 로그인 여부를 판별할 때 사용한 @PreAuthorize 애너테이션을 사용하기 위해 반드시 필요한 설정이다.

5) 이렇게 수정한 후 로그아웃 상태에서 질문 또는 답변을 등록하면 자동으로 로그인 화면으로 이동하는 것을 확인할 수 있을 것이다.

로그아웃 상태에서 [질문 등록] 버튼을 누르면 로그인 페이지로 이동한다. 로그인을 완료하면 이전에 요청한 질문 등록 페이지가 등장한다. 이는 로그인 후에 원래 가려고 했던 페이지로 리다이렉트시키는 스프링 시큐리티의 기능 덕분에 가능한 것이다.

답변 작성 막아 두기


현재 질문 등록 페이지에서는 사용자가 로그아웃 상태라면 아예 글을 작성할 수 없다. 하지만 답변 등록 페이지에서는 로그아웃 상태에서도 글은 작성할 수 있어서 답변을 작성한 후 [답변 등록] 버튼을 눌러야만 로그인 화면으로 이동된다. 이렇게 되면 애써 사용자가 작성한 답변이 사라지는 문제가 있다. 이 문제를 해결하려면 사용자가 로그아웃 상태인 경우 아예 답변 작성을 못하게 막는 것이 좋은 방법일 것이다.

1) 로그아웃 상태에서 답변을 작성하지 못하도록 question_detail.html 파일을 다음과 같이 수정해 보자.

[파일명:/templates/question_detail.html]

<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
    (... 생략 ...)
    <!-- 답변 작성 -->
    <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
        <div th:replace="~{form_errors :: formErrorsFragment}"></div>
        <textarea sec:authorize="isAnonymous()" disabled th:field="*{content}" class="form-control" rows="10"></textarea>
        <textarea sec:authorize="isAuthenticated()" th:field="*{content}" class="form-control" rows="10"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
</div>
</html>

로그인 상태가 아닌 경우 textarea 태그에 disabled 속성을 적용하여 사용자가 화면에서 아예 입력하지 못하게 만들었다. 여기서 sec:authorize="isAnonymous()", sec:authorize="isAuthenticated()"는 현재 사용자의 로그인 상태를 체크하는 속성으로, sec:authorize="isAnonymous()"는 현재 로그아웃 상태임을 의미하고, sec:authorize="isAuthenticated()"는 현재 로그인 상태임을 의미한다.

2) 다음은 로그아웃 상태에서 disabled가 적용된 화면이다. 이와 같이 로그아웃 상태에서는 사용자가 답변을 등록할 수 없도록 답변 등록 칸이 회색으로 표시되고, 키보드를 눌러도 아무런 내용이 입력되지 않는다. 로그인

화면에 글쓴이 나타내기


이제 질문 목록과 질문 상세 화면에 글쓴이를 표시해 보자. 앞서 Question 엔티티와 Answer 엔티티에 auther 속성을 추가했다. 이를 이용하여 질문 목록, 질문 상세 화면에 글쓴이를 표시해 보자.

질문 목록에 글쓴이 표시하기 1) 질문 목록 템플릿인 question_list.html에 글쓴이를 추가해 보자. 그 전에 다음과 같이 테이블 헤더를 수정해 보자.

[파일명:/templates/question_list.html]

(... 생략 ...)
<tr class="text-center">
    <th>번호</th>
    <th style="width:50%">제목</th>
    <th>글쓴이</th>
    <th>작성일시</th>
</tr>
(... 생략 ...)

<th>글쓴이</th>를 추가했다. 그리고 각 th 요소들(번호, 제목, 글쓴이, 작성 일시)을 가운데 정렬하도록 tr 태그에 text-center 클래스를 추가하고, <th>제목</th>에서는 너비가 전체에서 50%를 차지하도록 style="width:50%"로 작성하여 너비를 지정했다.

2) ‘글쓴이’가 화면에 보이도록 틀을 마련했으니 글쓴이가 표시되도록 이어서 for 문에도 다음과 같이 추가해 보자.

[파일명:/templates/question_list.html]

(... 생략 ...)
<tr class="text-center" th:each="question, loop : ${paging}">
    <td th:text="${paging.getTotalElements - (paging.number * paging.size) - loop.index}"></td>
    <td class="text-start">
        <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
        <span class="text-danger small ms-2" th:if="${#lists.size(question.answerList) > 0}"
            th:text="${#lists.size(question.answerList)}">
        </span>
    </td>
    <td><span th:if="${question.author != null}" th:text="${question.author.username}"></span></td>
    <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
</tr>
(... 생략 ...)

<td> ... </td> 요소를 삽입하여 질문의 글쓴이를 표시했다. 글쓴이 정보 없이 저장된 기존의 질문들은 author 속성에 해당하는 데이터가 없으므로(author 속성의 값으로 null을 가지고 있으므로) author 속성의 값이 null이 아닌 경우만 글쓴이를 표시하도록 했다. 그리고 여기서도 표시되는 항목을 모두 가운데 정렬하도록 tr 요소에 text-center 클래스를 추가하고, 제목 항목의 값들만 왼쪽 정렬하도록 text-start 클래스를 추가했다.

3) 다시 질문 목록 화면으로 돌아가면 오른쪽과 같이 글쓴이 항목이 추가된 것을 확인할 수가 있다. 글쓴이

질문 상세에 글쓴이 표시하기

1) 질문 상세 템플릿인 question_detail.html을 수정하여 질문 상세 화면에서도 글쓴이 항목 이 노출되도록 만들어 보자.

[파일명: /templates/question_detail.html]

(... 생략 ...)
<!-- 질문 -->
<h2 class="border-bottom py-2" th:text="${question.subject}"></h2>
<div class="card my-3">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${question.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
                <div class="mb-2">
                    <span th:if="${question.author != null}" th:text="${question.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
    </div>
</div>
(... 생략 ...)

글쓴이와 작성 일시가 나란히 보이도록 수정했다.

2) 그다음 답변 부분에도 글쓴이 항목이 노출되도록 다음과 같이 내용을 추가하자.

[파일명: /sbb/src/main/resources/templates/question_detail.html]

(... 생략 ...)
<!-- 답변 반복 시작 -->
<div class="card my-3" th:each="answer : ${question.answerList}">
    <div class="card-body">
        <div class="card-text" style="white-space: pre-line;" th:text="${answer.content}"></div>
        <div class="d-flex justify-content-end">
            <div class="badge bg-light text-dark p-2 text-start">
                <div class="mb-2">
                    <span th:if="${answer.author != null}" th:text="${answer.author.username}"></span>
                </div>
                <div th:text="${#temporals.format(answer.createDate, 'yyyy-MM-dd HH:mm')}"></div>
            </div>
        </div>
    </div>
</div>
<!-- 답변 반복 끝  -->
(... 생략 ...)

마찬가지로 글쓴이와 작성 일시가 나란히 보이도록 수정했다.

3) 로컬 서버를 재시작하여 오른쪽과 같이 질문 상세 화면에서 답변을 입력한 후, 답변의 글쓴이가 노출되는지 확인해 보자. 일시 이와 같이 질문을 등록하는 글쓴이와 답변을 등록한 글쓴이 모두 작성 일시와 함께 노출된 것을 확인할 수 있다.