Spring/게시판 만들기 프로젝트

[Spring Boot] #4_2 게시판 만들기

sujin7837 2020. 12. 22. 18:01
반응형

학습 목표

-객체 간의 관계 설정(@OneToMany, @ManyToOne 등)

 

학습 목차

4-1. 회원과 질문 간의 관계 매핑 및 리팩토링

4-2. 질문 상세보기 기능 구현

4-3. 질문 수정/삭제 기능 구현

4-4. 수정/삭제 기능에 대한 보안 처리 및 LocalDateTime 설정

4-5. 답변 추가 및 답변 목록 기능 구현

4-6. QuestionController 중복 제거 리팩토링

4-7. 원격 서버에 소스 코드 배포

 

 

//QuestionController.java

@Controller	
@RequestMapping("/questions")	
public class QuestionController {
	@Autowired	
    private QuestionRepository questionRepository;

	@GetMapping("/form")
    public String form(HttpSession session) {	
    	if(HttpSessionUtils.isLoginUser(session)) {
        	return "/users/loginForm";
        }
    	return "/qna/form";
    }
    
    @PostMapping("")
    public String create(String title, String contents, HttpSession session) {	
    	if(!HttpSessionUtils.isLoginUser(session)) {
        	return "/users/loginForm";
        }
        User sessionUser=HttpSessoinUtils.getUserFromSession(session);
        Question newQuestion=new Question(sessionUser, title, contents);		
        questionRepository.save(newQuestion);	
    	return "redirect:/";
    }
    
    @GetMapping("/{id}")
    public String show(@PathVariable Long id, Model model) {	
    	model.addAttribute("question", questionRepository.findById(id).get());
    	return "/qna/show";
    }
    
    @GetMapping("/{id}/form")
    public String updateForm(@PathVariable Long id, Model model, HttpSession session) {
    	if(!HttpSessionUtils.isLoginUser(session)) {	//1.
        	return "/users/loginForm";
        }
        User loginUser=HttpSessionUtils.getUserFromSession(session);	//2.
        Question question=questionRepository.findById(id).get();
        if(!question.isSameWriter(loginUser)) {
        	return "/users/loginForm";
        }
        
    	model.addAttribute("question", questionRepository.findById(id).get());
    	return "/qna/updateForm";
    }
    
    @PutMapping("/{id}")	
    public String update(@PathVariable Long id, String title, String contents, HttpSession session) {
    	if(!HttpSessionUtils.isLoginUser(session)) {	//1.
        	return "/users/loginForm";
        }
        User loginUser=HttpSessionUtils.getUserFromSession(session);	//2.
    	Question question=questionRepository.findById(id).get();
        if(!question.isSameWriter(loginUser)) {
        	return "/users/loginForm";
        }
        
        question.update(title, contents);
        questionRepository.save(question);
    	return String.format("redirect:/questions/%d", id);
    }
    
    @DeleteMapping("/{id}")	
    public String delete(@PathVariable Long id, HttpSession session) {	
    	if(!HttpSessionUtils.isLoginUser(session)) {	//1.
        	return "/users/loginForm";
        }
        User loginUser=HttpSessionUtils.getUserFromSession(session);	//2.
    	Question question=questionRepository.findById(id).get();
        if(!question.isSameWriter(loginUser)) {
        	return "/users/loginForm";
        }
    
    	questionRepository.delete(id);
        return "redirect:/";
    }
}

1. 사용자가 로그인을 했는지 확인합니다.

 

2. 사용자가 질문의 작성자와 일치하는지 확인합니다.

 

자신의 정보만 수정이 가능해야 하므로, 위의 1., 2.번 방식을 이용해서 질문 수정 및 삭제에 대한 보안 설정을 해줍니다.

 

//Question.java

@Entity
public class Question {	
	@Id
    @GeneratedValue
    private Long id;
    
    @ManyToOne	
    @JoinColumn(foreignKey=@ForeignKey(name="fk_question_writer"))	
    private User writer;	
    private String title;
    @Lob	//2.
    private String contents;
    private LocalDateTime createDate;	
    
    public Question() {}	
    
    public Question(String writer, String title, String contents) {
    	super();
        this.writer=writer;
        this.title=title;
        this.contents=contents;
        this.createDate=LocalDateTime.now();	
    }
    
    public String getFormattedCreateDate() {	
    	if(createDate==null) {
        	return "";
        }
        return createDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss"));
    }
    
    public void update(String title, String contents) {	
    	this.title=title;
        this.contents=contents;
    }
    
    public boolean isSameWriter(User loginUser) {	//1.
    	return this.writer.equals(loginUser);
    }
    
    //3.
    @Override	
    public int hashCode() {
    	final int prime=31;
        int result=1;
        result=prime * result + ((id==null) ? 0: id.hashCode());
        return result;
    }
    
    @Override
    public boolean equals(Object obj) {
    	if(this==obj)
        	return true;
        if(obj==null)
        	return false;
        if(getClass() != obj.getClass())
        	return false;
        Question other=(Question) obj;
        if(id==null) {
        	if(other.id!=null)
            	return false;
        } else if(!id.equals(other.id))
        	return false;
        return true;   
    }
    
}

1. 로그인한 사용자가 글쓴이와 일치하는지 확인하는 메소드를 만들어줍니다.

 

2. @Lob: 데이터베이스는 255자까지 저장이 가능한데, 그 이상의 내용을 관리해야 하는 경우에 훨씬 많은 양의 글자를 저장할 수 있도록 해줍니다.

 

3. generate hashCode() and equals() 기능을 이용해서 메소드를 만들어주어야  Question.java의 isSameWriter 메소드가 정상적으로 작동합니다.

id만 같으면 두 개의 인스턴스가 같다고 구현했습니다.

//User.java

@Entity	
public class User {
	@Id	
    @GeneratedValue	
    private Long id;
    
    @Column(nullable=false, length=20)	
	private String userId;
    
    private String password;
    private String name;
    private String email;
    
    public boolean matchId(Long newId) {	
    	if(newId==null) {
        	return false;
        }
        return newId.equals(id);
    }
    
    public void setUserId(String userId) {
    	this.userId=userId;
    }
    
    public String getUserId() {	
    	return userId;
    }
    
    public boolean matchPassword(String newPassword) {	
    	if(newPassword==null) {
        	return false;
        }
        return newPassword.equals(password);
    }
    
    public void setPassword(String password) {
    	this.password=password;
    }
    
    public void setName(String name) {
    	this.name=name;
    }
    
    public void setEmail(String email) {
    	this.email=email;
    }
    
    public void update(User newUser) {	
    	this.password=newUser.password;
        this.name=newUser.name;
        this.email=newUser.email;
    }
    
    //1.
    @Override	
    public int hashCode() {
    	final int prime=31;
        int result=1;
        result=prime * result + ((id==null) ? 0: id.hashCode());
        return result;
    }
    
    @Override
    public boolean equals(Object obj) {
    	if(this==obj)
        	return true;
        if(obj==null)
        	return false;
        if(getClass() != obj.getClass())
        	return false;
        Question other=(Question) obj;
        if(id==null) {
        	if(other.id!=null)
            	return false;
        } else if(!id.equals(other.id))
        	return false;
        return true;   
    }

    
    @Override
    public String toString() {	
    	return "User [userId=" + userId + ", password=" + password + ", name=" + name, "email=" + email +"]";
    }
}

 

1. generate hashCode() and equals() 기능을 이용해서 메소드를 만들어주어야  Question.java의 isSameWriter 메소드가 정상적으로 작동합니다.

id만 같으면 두 개의 인스턴스가 같다고 구현했습니다.

//import.sql

INSERT INTO USER (ID, USER_ID, PASSWORD, NAME, EMAIL) VALUES (1, 'may', 'test', '메이', 'may@slipp.net');
INSERT INTO USER (ID, USER_ID, PASSWORD, NAME, EMAIL) VALUES (2, 'april', 'test', '에이프릴', 'april@slipp.net');

INSERT INTO QUESTION (id, writer_id, title, contents, create_date) VALUES (1, 1, '질문 있어요', '스프링에서 LocalDateTime과 Timestamp는 어떤 기능인가요?', CURRENT_TIMESTAMP());
INSERT INTO QUESTION (id, writer_id, title, contents, create_date) VALUES (2, 2, '에이프릴이 쓴 글', 'April은 4월이다.', CURRENT_TIMESTAMP());
//LocalDateTimeConverter.java

@Converter(autoApply=true)
public class LocalDateTimeConverter implements AttributeConverter<LocalDateTime, Timestamp> {
	@Override
    public Timestamp convertToDatebaseColumn(LocalDateTime localDateTime) {
    	return localDateTime != null ? Timestamp.valueOf(localDateTime) : null; 
    }
    
    @Override
    public LocalDateTime convertToEntityAttribute(Timestamp timestamp) {
    	return timestamp != null ? timestamp.toLocalDateTime() : null;
    }
}

LocalDateTime과 Timestamp를 이용해서 현재 시간을 기준으로 데이터베이스에 데이터를 추가할 수 있습니다.

 

//Answer.java

@Entity
public class Answer {
	@Id
    @GeneratedValue
    private Long id;
    
    @ManyToOne	//1.
    @JoinColumn(foreignKey=@ForeignKey(name="fk_answer_writer"))	//2.
    private User writer;
    
    @ManyToOne	//5.
    @JoinColumn(foreignKey=@ForeignKey(name="fk_answer_to_question"))	//6.
    private Question question;
    
    @Lob	//3.
    private String contents;
    
    private LocalDateTime createDate;
    
    public Answer() {
    }
    
    public Answer(User writer, String contents) {
    	this.writer=writer;
        this.contents=contents;
    }
    
    public String getFormattedCreateDate() {	
    	if(createDate==null) {
        	return "";
        }
        return createDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss"));
    }
    
    @Override	
    public int hashCode() {
    	final int prime=31;
        int result=1;
        result=prime * result + ((id==null) ? 0: id.hashCode());
        return result;
    }
    
    //4.
    @Override
    public boolean equals(Object obj) {
    	if(this==obj)
        	return true;
        if(obj==null)
        	return false;
        if(getClass() != obj.getClass())
        	return false;
        Question other=(Question) obj;
        if(id==null) {
        	if(other.id!=null)
            	return false;
        } else if(!id.equals(other.id))
        	return false;
        return true;   
    }
    
    @Override
    public String toString() {
    	return "Answer [id="+id+", writer="+writer+", contents="+contents+", createDate="+createDate+"]";
    }
}

1. @ManyToOne: 답변과 사용자 사이에는 다 대 일 관계가 있습니다. 

 

2. writer를 fk_answer_writer라는 이름의 외래키로 만들어줍니다.

 

3. 답변 내용은 255자 이상의 많은 내용을 포함하게 될 수 있으므로 @Lob을 추가해줍니다.

 

4. hashCode() and equals()와 toString()을 generate 해줍니다.

 

5. @ManyToOne: 답변과 질문 사이에는 다 대 일 관계가 있습니다.

 

6. question을 fk_answer_to_question이라는 이름의 외래키로 만들어줍니다.

 

//interface
//AnswerRepository.java

public interface AnswerRepository extends JpaRepository<Answer, Long> {
	
}

Answer에 대한 데이터베이스 처리를 담당할 Repository를 만들어줍니다.

 

//AnswerController.java

@Controller
@RequestMapping("/questions/{questionId}/answers")	//1.
public class AnswerController {
	@Autowired	//2.
	private AnswerRepository answerRepository;

	@PostMapping("")
    public String create(@PathVariable Long questionId, String contents, HttpSession session) {
    	if(!HttpSessionUtils.isLoginUser(session)) {
        	return "/users/loginForm";
        }
        
        User loginUser=HttpSessionUtils.getUserFromSession(session);
        Answer answer=new Answer(loginUser, contents); 
        answerRepository.save(answer);
        return String.format("redirect:/questions/%d", questionId);
    }
}

1. answer는 항상 question에 종속적인 관계입니다. 따라서 question의 questionId에 종속되는 answer임을 명시하는 형태로 설계해줍니다.

 

2. @Autowired를 이용해야 AnswerRepository를 가져올 수 있습니다.

<!--show.html-->

<div class="container" id="main">
    <div class="col-md-12 col-sm-12 col-lg-12">
    	{{#question}}	
        <div class="panel panel-default">
          <header class="qna-header">
              <h2 class="qna-title">{{title}}</h2>
          </header>
          <div class="content-main">
              <article class="article">
                  <div class="article-header">
                      <div class="article-header-thumb">
                          <img src="https://graph.facebook.com/v2.3/100000059371774/picture" class="article-author-thumb" alt="">
                      </div>
                      <div class="article-header-text">
                          <a href="/users/92/kimmunsu" class="article-author-name">{{writer.userId}}</a>	
                          <a href="/questions/413" class="article-header-time" title="퍼머링크">
                              {{formattedCreateDate}}	
                              <i class="icon-link"></i>
                          </a>
                      </div>
                  </div>
                  <div class="article-doc">
                      {{contents}}	
                  </div>
                  <div class="article-util">
                      <ul class="article-util-list">
                          <li>
                              <a class="link-modify-article" href="/questions/{{id}}/form">수정</a>	
                          </li>
                          <li>
                              <form class="form-delete" action="/questions/{{id}}" method="POST">	
                                  <input type="hidden" name="_method" value="delete">	
                                  <button class="link-delete-article" type="submit">삭제</button>
                              </form>
                          </li>
                          <li>
                              <a class="link-modify-article" href="/">목록</a>
                          </li>
                      </ul>
                  </div>
              </article>

              <div class="qna-comment">
                  <div class="qna-comment-slipp">
                      <p class="qna-comment-count"><strong>2</strong>개의 의견</p>
                      <div class="qna-comment-slipp-articles">
						  {{#answers}}
                          <article class="article" id="answer-1405">
                              <div class="article-header">
                                  <div class="article-header-thumb">
                                      <img src="https://graph.facebook.com/v2.3/1324855987/picture" class="article-author-thumb" alt="">
                                  </div>
                                  <div class="article-header-text">
                                      <a href="/users/1/자바지기" class="article-author-name">{{writer.userId}}</a>
                                      <a href="#answer-1434" class="article-header-time" title="퍼머링크">
                                          {{formattedCreateDate}}
                                      </a>
                                  </div>
                              </div>
                              <div class="article-doc comment-doc">
                                  <p>{{contents}}</p>
                              </div>
                              <div class="article-util">
                                  <ul class="article-util-list">
                                      <li>
                                          <a class="link-modify-article" href="/questions/413/answers/1405/form">수정</a>
                                      </li>
                                      <li>
										  <a class="link-delete-article" href="/api/questions/{{question.id}}/answers/{{id}}">삭제</a>
									</li>
                                  </ul>
                              </div>
                          </article>
                          {{/answers}}
                          <form class="answer-write" method="post" action="/questions/{{id}}/answers">	<!--1.-->
                              <div class="form-group" style="padding:14px;">
                               <textarea class="form-control" placeholder="Update your status" name="contents"></textarea>	<!--1.-->
                              </div>
                              <input type="submit" class="btn btn-success pull-right" value="답변하기"/>	<!--2.-->
                              <div class="clearfix" />
                          </form>
                      </div>
                  </div>
              </div>
          </div>
        </div>
        {{/question}}
    </div>
</div>

1. 답변을 post 방식으로 action 안의 url에 매핑합니다.

답변 영역의 name을 contents로 합니다.

 

2. 답변하기는 form이므로 button이 아니라 input으로 바꿔줍니다.

 

//Answer.java

@Entity
public class Answer {
	@Id
    @GeneratedValue
    private Long id;
    
    @ManyToOne	
    @JoinColumn(foreignKey=@ForeignKey(name="fk_answer_writer"))	
    private User writer;
    
    @ManyToOne	
    @JoinColumn(foreignKey=@ForeignKey(name="fk_answer_to_question"))	
    private Question question;
    
    @Lob	
    private String contents;
    
    private LocalDateTime createDate;
    
    public Answer() {
    }
    
    public Answer(User writer, Question question, String contents) {	//1.
    	this.writer=writer;
        this.question=question;
        this.contents=contents;
        this.createDate=LocalDateTime.now();
    }
    
    public String getFormattedCreateDate() {	
    	if(createDate==null) {
        	return "";
        }
        return createDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss"));
    }
    
    @Override	
    public int hashCode() {
    	final int prime=31;
        int result=1;
        result=prime * result + ((id==null) ? 0: id.hashCode());
        return result;
    }
    
    @Override
    public boolean equals(Object obj) {
    	if(this==obj)
        	return true;
        if(obj==null)
        	return false;
        if(getClass() != obj.getClass())
        	return false;
        Question other=(Question) obj;
        if(id==null) {
        	if(other.id!=null)
            	return false;
        } else if(!id.equals(other.id))
        	return false;
        return true;   
    }
    
    @Override
    public String toString() {
    	return "Answer [id="+id+", writer="+writer+", contents="+contents+", createDate="+createDate+"]";
    }
}

1. Answer 인스턴스에 question을 추가하고, LocalDateTime.now()를 통해 현재 시간을 생성합니다.

 

//AnswerController

@Controller
@RequestMapping("/questions/{questionId}/answers")	
public class AnswerController {
	@Autowired	//1.
    private QuestionRepository questionRepository;

	@Autowired	
	private AnswerRepository answerRepository;

	@PostMapping("")
    public String create(@PathVariable Long questionId, String contents, HttpSession session) {
    	if(!HttpSessionUtils.isLoginUser(session)) {
        	return "/users/loginForm";
        }
        
        User loginUser=HttpSessionUtils.getUserFromSession(session);
        Question question=questionRepository.findById(id).get();	//2.
        Answer answer=new Answer(loginUser, question, contents); 	//2.
        answerRepository.save(answer);
        return String.format("redirect:/questions/%d", questionId);
    }
}

1. @Autowired를 이용해서 questionRepository를 가져옵니다.

 

2. 객체 간의 매핑이 있기 때문에 객체로부터 id를 꺼내온 후 answer에 전달해줍니다.

 

//QuestionController.java

@Controller	
@RequestMapping("/questions")	
public class QuestionController {
	@Autowired	
    private QuestionRepository questionRepository;

	@GetMapping("/form")
    public String form(HttpSession session) {	
    	if(HttpSessionUtils.isLoginUser(session)) {
        	return "/users/loginForm";
        }
    	return "/qna/form";
    }
    
    @PostMapping("")
    public String create(String title, String contents, HttpSession session) {	
    	if(!HttpSessionUtils.isLoginUser(session)) {
        	return "/users/loginForm";
        }
        User sessionUser=HttpSessoinUtils.getUserFromSession(session);
        Question newQuestion=new Question(sessionUser, title, contents);		
        questionRepository.save(newQuestion);	
    	return "redirect:/";
    }
    
    @GetMapping("/{id}")
    public String show(@PathVariable Long id, Model model) {	
    	model.addAttribute("question", questionRepository.findById(id).get());
    	return "/qna/show";
    }
    
    @GetMapping("/{id}/form")
    public String updateForm(@PathVariable Long id, Model model, HttpSession session) {
    	if(!HttpSessionUtils.isLoginUser(session)) {	
        	return "/users/loginForm";
        }
        User loginUser=HttpSessionUtils.getUserFromSession(session);	
        Question question=questionRepository.findById(id).get();
        if(!question.isSameWriter(loginUser)) {
        	return "/users/loginForm";
        }
        
    	model.addAttribute("question", questionRepository.findById(id).get());
    	return "/qna/updateForm";
    }
    
    @PutMapping("/{id}")	
    public String update(@PathVariable Long id, String title, String contents, HttpSession session) {
    	if(!HttpSessionUtils.isLoginUser(session)) {	
        	return "/users/loginForm";
        }
        User loginUser=HttpSessionUtils.getUserFromSession(session);	
    	Question question=questionRepository.findById(id).get();
        if(!question.isSameWriter(loginUser)) {
        	return "/users/loginForm";
        }
        
        question.update(title, contents);
        questionRepository.save(question);
    	return String.format("redirect:/questions/%d", id);
    }
    
    @DeleteMapping("/{id}")	
    public String delete(@PathVariable Long id, HttpSession session) {	
    	if(!HttpSessionUtils.isLoginUser(session)) {	
        	return "/users/loginForm";
        }
        User loginUser=HttpSessionUtils.getUserFromSession(session);	
    	Question question=questionRepository.findById(id).get();
        if(!question.isSameWriter(loginUser)) {
        	return "/users/loginForm";
        }
    
    	questionRepository.delete(id);
        return "redirect:/";
    }
}
//Question.java

@Entity
public class Question {	
	@Id
    @GeneratedValue
    private Long id;
    
    @ManyToOne	
    @JoinColumn(foreignKey=@ForeignKey(name="fk_question_writer"))	
    private User writer;	
    private String title;
    @Lob	
    private String contents;
    private LocalDateTime createDate;	
    
    @OneToMany(mappedBy="question")	//1.
    @OrderBy("id ASC")	//2.
    private List<Answer> answers;
    
    public Question() {}	
    
    public Question(String writer, String title, String contents) {
    	super();
        this.writer=writer;
        this.title=title;
        this.contents=contents;
        this.createDate=LocalDateTime.now();	
    }
    
    public String getFormattedCreateDate() {	
    	if(createDate==null) {
        	return "";
        }
        return createDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss"));
    }
    
    public void update(String title, String contents) {	
    	this.title=title;
        this.contents=contents;
    }
    
    public boolean isSameWriter(User loginUser) {	
    	return this.writer.equals(loginUser);
    }
    
    @Override	
    public int hashCode() {
    	final int prime=31;
        int result=1;
        result=prime * result + ((id==null) ? 0: id.hashCode());
        return result;
    }
    
    @Override
    public boolean equals(Object obj) {
    	if(this==obj)
        	return true;
        if(obj==null)
        	return false;
        if(getClass() != obj.getClass())
        	return false;
        Question other=(Question) obj;
        if(id==null) {
        	if(other.id!=null)
            	return false;
        } else if(!id.equals(other.id))
        	return false;
        return true;   
    }
    
}

1. 질문과 답변은 일 대 다의 관계를 가지고 있습니다.(질문은 여러개이기 때문에 리스트로 만들어줍니다.)

mappedBy의 값에는 Question 클래스에서 ManyToOne 관계를 갖는 값인 "question"을 넣어줍니다.

 

2. 답변 목록은 답변 id(answer id)를 기준으로 오름차순 정렬됩니다.

 

질문에 달려있는 답변의 목록을 가져와서 출력해주는 기능이 필요합니다. 이를 구현해주기 위해 질문 상세보기에서 현재 질문 id에 달려있는 답변 목록을 가져와서 전달하는 방식을 이용합니다.

 

<!--show.html-->

<div class="qna-comment-slipp-articles">
{{#answers}}	<!--1.-->
    <article class="article" id="answer-1405">
        <div class="article-header">
            <div class="article-header-thumb">
                <img src="https://graph.facebook.com/v2.3/1324855987/picture" class="article-author-thumb" alt="">
            </div>
            <div class="article-header-text">
                <a href="/users/1/자바지기" class="article-author-name">{{writer.userId}}</a>	<!--1.-->
                <a href="#answer-1434" class="article-header-time" title="퍼머링크">
                {{formattedCreateDate}}	<!--1.-->
                </a>
            </div>
        </div>
        <div class="article-doc comment-doc">
            <p>{{contents}}</p>	<!--1.-->
        </div>
        <div class="article-util">
            <ul class="article-util-list">
            <li>
                <a class="link-modify-article" href="/questions/413/answers/1405/form">수정</a>
            </li>
            <li>
			    <a class="link-delete-article" href="/api/questions/{{question.id}}/answers/{{id}}">삭제</a>
	        </li>
            </ul>
        </div>
    </article>
{{/answers}}
    <form class="answer-write" method="post" action="/questions/{{id}}/answers">	
        <div class="form-group" style="padding:14px;">
        <textarea class="form-control" placeholder="Update your status" name="contents"></textarea>	
        </div>
        <input type="submit" class="btn btn-success pull-right" value="답변하기"/>	
        <div class="clearfix" />
    </form>
</div>

1. mustache로 answers 관련 동적 데이터를 모두 바꿔줍니다.

반응형