본 포스팅은 Slipp - Spring-Boot, JPA로 질문/답변 게시판 구현 과정을 참조하여 작성한 내용입니다. 개인적 학습 내용을 복습하기 위한 내용이기 때문에 내용상 오류가 있을 수 있습니다. 소스코드의 자세한 내용은 https://github.com/walbatrossw/boot-qna 를 참조해주세요.
5. slipp 반복주기 5
- 객체 간의 관계 설정(@OneToMany, @ManyToOne 등)
5-1) 회원과 질문 간의 관계 매핑 및 리팩토링
회원과 질문 간의 관계
회원 입장에서의 관계 : 한명의 회원과 다수의 질문이 존재할 수 있다. OneToMany
- User 가 Question 에 대한 목록을 가질수 있도록 관계를 맺을 수 있다.
질문 입장에서의 관계 : 다수의 질문과 한명의 회원이 존재할 수 있다. ManyToOne
- Question 이 User 객체를 가질 수 있도록 관계를 맺을 수 있다.
Question 클래스 리팩토링 : 회원과 질문간의 관계 설정 및 생성일 추가
- Question 객체에서 User 객체를 필드 변수로 선언하고
@ManyToOne
애너태이션을 통해 N:1의 관계로 설정하게 되면 User 객체의 기본키가 Question 객체의 foreign key 로 설정된다. @JoinColumn
: 제약조건의 이름을 지정@Entity public class Question { @Id @GeneratedValue private Long id; @ManyToOne @JoinColumn(foreignKey = @ForeignKey(name = "fk_question_writer")) private User writer; // private String writer; private String title; private String contents; // 날짜와 시간을 나타내기 위해 java8 부터 추가된 타입 private LocalDateTime createDate; public Question() { } public Question(User writer, String title, String contents) { this.writer = writer; this.title = title; this.contents = contents; // 현재시간 생성 할당 : 현재 시간이 생성되었지만 알수 없는 글자들로 표현되기 때문에 formatting 을 해줘야한다. this.createDate = LocalDateTime.now(); } // 시간 포맷변경 메서드 public String getFormattedCreateDate() { if (createDate == null) { return ""; } return createDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss")); } }
- Question 객체에서 User 객체를 필드 변수로 선언하고
index.html 에서 질문이 생성된 날짜와 시간 출력
mustache 템플릿은 기본적으로 get() 메서드를 지원하기 때문에 Question 클래스에서 작성한
getFormattedCreateDate()
메서드에서 get 을 제거하고 대문자 F를 소문자 f로 변경하여 {{}}에 넣어주면 날짜가 화면에 출력된다.<span class="time">{{formattedCreateDate}}</span>
5-2) 질문 상세보기 기능 구현
QuestionController : 질문 상세 보기 매핑
@GetMapping("/{id}") public String show(@PathVariable Long id, Model model) { model.addAttribute("question", questionRepository.findOne(id)); return "/qna/show"; }
질문 상세 보기 페이지 작성 : template/qna/show.html
{{#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> </div> {{/question}}
5-3) 질문 수정, 삭제 기능 구현
표준 HTML 에서는 POST, GET 요청만 처리 할 수 있기 때문에 PUT, DELETE 요청을 처리하기 위해서는 아래와 같은 코드를 작성해주어야 한다.
<input type="hidden" name="_method" value="put"> <input type="hidden" name="_method" value="delete">
질문 수정 기능
QuestionController : 질문 수정 화면, 수정처리 매핑
// 질문 수정 화면 @GetMapping("/{id}/form") public String updateForm(@PathVariable Long id, Model model, HttpSession session) { model.addAttribute("question", questionRepository.findOne(id)); return "/qna/updateForm"; } // 질문 수정 처리 @PutMapping("/{id}") public String update(@PathVariable Long id, String title, String contents, HttpSession session) { Question question = questionRepository.findOne(id); question.update(title, contents); questionRepository.save(question); return String.format("redirect:/questions/%d", id); }
show.html : 질문 수정 처리 페이지로 이동할 수 있도록 URL 세팅
<a class="link-modify-article" href="/questions/{{id}}/form">수정</a>
updateForm.html : 질문 수정 처리
<div class="container" id="main"> <div class="col-md-12 col-sm-12 col-lg-10 col-lg-offset-1"> <div class="panel panel-default content-main"> {{#question}} <form name="question" method="post" action="/questions/{{id}}"> <input type="hidden" name="_method" value="put"> <div class="form-group"> <label for="title">제목</label> <input type="text" class="form-control" id="title" name="title" value="{{title}}" placeholder="제목"/> </div> <div class="form-group"> <label for="contents">내용</label> <textarea name="contents" id="contents" rows="5" class="form-control">{{contents}}</textarea> </div> <button type="submit" class="btn btn-success clearfix pull-right">질문 수정</button> <div class="clearfix"/> </form> {{/question}} </div> </div> </div>
질문 삭제 기능
QuestionController : 삭제처리 매핑
// 질문 삭제 처리 @DeleteMapping("/{id}") public String delete(@PathVariable Long id) { questionRepository.delete(id); return "redirect:/"; }
show.html : 질문 삭제 처리할 수 있도록 URL 세팅
<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>
5-4) 수정/삭제 기능에 대한 보안 처리 및 LocalDateTime 설정
수정/삭제 기능 보안처리
QuestionController 클래스 보안처리
updateForm()
메서드 : 수정화면 매핑@GetMapping("/{id}/form") public String updateForm(@PathVariable Long id, Model model, HttpSession session) { // 로그인 여부 체크 if ( !HttpSessionUtils.isLoginUser(session) ) { return "redirect:/users/loginForm"; } // 로그인한 유저와 질문작성자 비교 User loginUser = HttpSessionUtils.getUserFromSession(session); Question question = questionRepository.findOne(id); if ( !question.isSameWriter(loginUser) ) { return "redirect:/users/loginForm"; } model.addAttribute("question", question); return "/qna/updateForm"; }
udpdate()
메서드 : 수정처리 매핑@PutMapping("/{id}") public String update(@PathVariable Long id, String title, String contents, HttpSession session) { // 로그인 여부 체크 if ( !HttpSessionUtils.isLoginUser(session) ) { return "redirect:/users/loginForm"; } // 로그인한 유저와 질문작성자 비교 User loginUser = HttpSessionUtils.getUserFromSession(session); Question question = questionRepository.findOne(id); if ( !question.isSameWriter(loginUser) ) { return "redirect:/users/loginForm"; } question.update(title, contents); questionRepository.save(question); return String.format("redirect:/questions/%d", id); }
String.format()
메서드- 수정처리가 완료되고 나서 수정된 질문페이지로 이동하기 위해서는 id 값이 필요하다.
리턴값이 String 인데 특정 변수의 값을 String 에 포함시키기 위해서는 아래와 같이 작성하면 된다.
String.format("redirect:/questions/%d", id);
delete()
메서드 : 삭제처리 매핑@DeleteMapping("/{id}") public String delete(@PathVariable Long id, HttpSession session) { // 로그인 여부 체크 if ( !HttpSessionUtils.isLoginUser(session) ) { return "redirect:/users/loginForm"; } // 로그인한 유저와 질문작성자 비교 User loginUser = HttpSessionUtils.getUserFromSession(session); Question question = questionRepository.findOne(id); if ( !question.isSameWriter(loginUser) ) { return "redirect:/users/loginForm"; } questionRepository.delete(id); return "redirect:/"; }
Question 클래스
질문 작성자와 로그인유저가 같은지 비교하는 메서드
public boolean isSameWriter(User loginUser) { return this.writer.equals(loginUser); }
equals()
메서드 오버라이딩- 위의 코드를 작성하고 테스트 해보면 계속 false 가 리턴 되어 로그인페이지로 리다이렉트가 된다.
- 그 이유는
equals()
메서드는 참조변수에 저장된 주소 값이 같은지만를 판단하는 때문이다. equals()
메서드로 writer 인스턴스와 loginUser 인스턴스가 가지고 있는 id 값을 비교하는 방법은equals()
메서드를 오버라이딩하여 객체에 저장된 내용을 비교하도록 변경해줘야 한다.// equals() 메서드 오버라이딩 @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return id != null ? id.equals(user.id) : user.id == null; } // hashCode() 메서드 오버라이딩 @Override public int hashCode() { return id != null ? id.hashCode() : 0; }
LocalDateTime 설정
DateTimeConverter 클래스 작성 : LocalDateTime 타입을 Timestamp 타입으로 변환 시켜주는 역할 수행
@Converter(autoApply = true) public class DateTimeConverter implements AttributeConverter<LocalDateTime, Timestamp> { @Override public Timestamp convertToDatabaseColumn(LocalDateTime localDateTime) { return localDateTime != null ? Timestamp.valueOf(localDateTime) : null; } @Override public LocalDateTime convertToEntityAttribute(Timestamp timestamp) { return timestamp != null ? timestamp.toLocalDateTime() : null; } }
5-5) 답변 추가 및 답변 목록 기능 구현
Answer 클래스 작성
@Entity public class Answer { @Id @GeneratedValue private Long id; @ManyToOne @JoinColumn(foreignKey = @ForeignKey(name = "fk_answer_writer")) private User writer; @Lob // 255자가 넘는 String 타입일 경우 @Lob 애노테이션 추가 private String contents; private LocalDateTime createDate; @ManyToOne @JoinColumn(foreignKey = @ForeignKey(name = "fk_answer_question")) private Question question; // 기본 생성자 public Answer() { } // 생성자 public Answer(User writer, Question question, String contents) { this.writer = writer; this.contents = contents; this.question = question; this.createDate = LocalDateTime.now(); } // 시간 포맷변경 메서드 public String getFormattedCreateDate() { if (createDate == null) { return ""; } return createDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss")); } // equals() 메서드 오버라이딩 @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Answer answer = (Answer) o; return id != null ? id.equals(answer.id) : answer.id == null; } // hashCode() 메서드 오버라이딩 @Override public int hashCode() { return id != null ? id.hashCode() : 0; } // toString() 메서드 @Override public String toString() { return "Answer{" + "id=" + id + ", writer=" + writer + ", contents='" + contents + '\'' + ", createDate=" + createDate + '}'; } }
AnswerRepository 클래스 작성
@Repository public interface AnswerRepository extends JpaRepository<Answer, Long>{ }
AnswerController 클래스 작성
@Controller @RequestMapping("/questions/{questionId}/answers") public class AnswerController { @Autowired private AnswerRepository answerRepository; @Autowired private QuestionRepository questionRepository; // 답변 하기 @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.findOne(questionId); Answer answer = new Answer(loginUser, question, contents); answerRepository.save(answer); return String.format("redirect:/questions/%d", questionId); } }
Question 클래스 : 질문과 답변 간의 관계 매핑 - 1:N, 하나의 질문에 다수의 답변이 존재가 가능하다.
@OneToMany
: 1:N 관계mapppedBy
: 관계를 설정할 해당 필드명을 입력@OrderBy
: 정렬, 속성(ASC, DESC)@OneToMany(mappedBy = "question") @OrderBy("id ASC") // 오른차순 정렬 private List<Answer> answers;
show.html : 답변 목록 및 입력 코드 작성
{{#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"> {{contents}} </div> <div class="article-util"> <ul class="article-util-list"> <li> <a class="link-modify-article" href="" >수정</a> </li> <li> <form class="delete-answer-form" action="" method="POST"> <input type="hidden" name="_method" value="DELETE"> <button type="submit" class="delete-answer-button">삭제</button> </form> </li> </ul> </div> </article> {{/answers}} <form class="submit-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>
5-6) QuestionController 중복 제거 리팩토링
중복코드 제거 방법 1 : Exception 을 이용
QuestionController
권한 체크 메서드 작성 : 수정화면, 수정처리, 삭제시 중복되는 코드를 메서드화하고, 예외처리
// 권한체크 메서드 private boolean hasPermission(HttpSession session, Question question) { // 로그인 여부 체크 if ( !HttpSessionUtils.isLoginUser(session) ) { throw new IllegalStateException("로그인이 필요합니다."); } // 본인여부 체크 User loginUser = HttpSessionUtils.getUserFromSession(session); if ( !question.isSameWriter(loginUser) ) { throw new IllegalStateException("자신이 쓴 글만 수정, 삭제가 가능합니다."); } return true; }
수정 화면
@GetMapping("/{id}/form") public String updateForm(@PathVariable Long id, Model model, HttpSession session) { try { // 현재 질문 조회 Question question = questionRepository.findOne(id); // 권한 체크 hasPermission(session, question); // 질문 수정화면으로 이동 model.addAttribute("question", question); return "/qna/updateForm"; } catch (IllegalStateException e) { // 에러 메시지 전달 model.addAttribute("errorMsg", e.getMessage()); // 로그인 페이지로 이동 return "/user/login"; } }
수정 처리
@PutMapping("/{id}") public String update(@PathVariable Long id, String title, String contents, Model model, HttpSession session) { try { // 현재 질문 조회 Question question = questionRepository.findOne(id); // 권한 체크 hasPermission(session, question); // 업데이트 처리 question.update(title, contents); questionRepository.save(question); return String.format("redirect:/questions/%d", id); } catch (IllegalStateException e) { // 에러 메시지 전달 model.addAttribute("errorMsg", e.getMessage()); // 로그인 페이지로 이동 return "/user/login"; } }
삭제 처리
@DeleteMapping("/{id}") public String delete(@PathVariable Long id, HttpSession session, Model model) { try { // 현재 질문 조회 Question question = questionRepository.findOne(id); // 권한 체크 hasPermission(session, question); // 삭제 처리 questionRepository.delete(id); return "redirect:/"; } catch (IllegalStateException e) { // 에러 메시지 전달 model.addAttribute("errorMsg", e.getMessage()); // 로그인 페이지로 이동 return "/user/login"; } }
login.html : 로그인
form
태그 상단에 에러메시지 출력할div
태그 작성{{#errorMsg}} <div class="alert alert-danger" role="alert">{{this}}</div> {{/errorMsg}}
중복코드 제거방법 2 Result 클래스를 작성
Result 클래스
public class Result { // 권한 유효성 판단 private boolean valid; // 에러메시지 private String errorMsg; private Result(boolean valid, String errorMsg) { this.valid = valid; this.errorMsg = errorMsg; } public boolean isValid() { return valid; } public String getErrorMsg() { return errorMsg; } public static Result ok() { return new Result(true, null); } public static Result fail(String errorMsg) { return new Result(false, errorMsg); } }
QuestionController 클래스
권한 체크 메서드
private Result valid(HttpSession session, Question question) { // 로그인 여부 체크 if ( !HttpSessionUtils.isLoginUser(session) ) { return Result.fail("로그인이 필요합니다."); } // 본인여부 체크 User loginUser = HttpSessionUtils.getUserFromSession(session); if ( !question.isSameWriter(loginUser) ) { return Result.fail("자신이 쓴 글만 수정, 삭제가 가능합니다."); } return Result.ok(); }
질문 수정 화면
@GetMapping("/{id}/form") public String updateForm(@PathVariable Long id, Model model, HttpSession session) { // 현재 질문 조회 Question question = questionRepository.findOne(id); Result result = valid(session, question); if ( !result.isValid() ) { // 에러 메시지 저장 model.addAttribute("errorMsg", result.getErrorMsg()); // 로그인 페이지로 이동 return "/user/login"; } // 질문 수정화면으로 이동 model.addAttribute("question", question); return "/qna/updateForm"; }
질문 수정 처리
@PutMapping("/{id}") public String update(@PathVariable Long id, String title, String contents, Model model, HttpSession session) { // 현재 질문 조회 Question question = questionRepository.findOne(id); Result result = valid(session, question); if ( !result.isValid() ) { // 에러 메시지 저장 model.addAttribute("errorMsg", result.getErrorMsg()); // 로그인 페이지로 이동 return "/user/login"; } // 업데이트 처리 question.update(title, contents); questionRepository.save(question); return String.format("redirect:/questions/%d", id); }
질문 삭제 처리
@DeleteMapping("/{id}") public String delete(@PathVariable Long id, HttpSession session, Model model) { // 현재 질문 조회 Question question = questionRepository.findOne(id); Result result = valid(session, question); if ( !result.isValid() ) { // 에러 메시지 저장 model.addAttribute("errorMsg", result.getErrorMsg()); // 로그인 페이지로 이동 return "/user/login"; } // 삭제 처리 questionRepository.delete(id); return "redirect:/"; }
5-7) 원격 서버에 소스코드 배포