본문 바로가기

Dev.BackEnd/Spring Boot

#SLiPP Spring boot, JPA 강의 - 반복주기 5

이 포스팅은 다음 강의를 바탕으로 작성되었습니다.

>> SLiPP 자바 웹 애플리케이션 개발 >>

반복주기 5

학습 내용


1. 회원과 질문 간의 관계 매핑 및 리팩토링
2. 날짜 추가하기
3. 질문 상세보기 기능 / 질문 수정 및 삭제 기능 구현 - PutMapping, DeleteMapping
4. 답변 추가 및 답변 목록 기능 구현
5. Refactoring을 통한 중복 코드 제거


회원과 질문 간의 관계 매핑
JPA에서도 데이터베이스의 개념인 릴레이션 간의 관계를 설정할 수 있다. SQL 문을 사용했을 때와 다른 점은 테이블과 테이블의 관계를 객체 상에서 설정해주는 것 뿐이다. Question 객체와 User 객체가 관계를 맺도록 한다고 했을 때 User 객체와 Question 객체는 어떠한 관계인가를 따져봐야 한다. 한 명의 User는 여러 Question을 작성할 수 있지만 한 Question은 한 명의 작성자만이 존재한다. 그렇기 때문에 1:N의 관계가 된다. 이것을 JPA 상에서는 Question 객체 안에 User 객체를 두는 것으로 표현한다. 데이터베이스 설계 단계에서 테이블 간의 1:N의 관계를 설정해줄 때를 생각해보자. N인 부분에 1인 id 값을 넣어두지 않았던가! 그것과 마찬가지 이치이다.

Question 객체에 User객체를 멤버 변수로 정의하고 @ManyToOne이라는 애노테이션을 통해 설정을 해주면, User 엔티티의 기본키가 들어가게 된다. Question 객체의 멤버로 들어가게 되고 데이터베이스에는 user의 primary key 값으로 필드가 설정된다. h2 database console에서 확인해보면 필드가 추가된 것을 확인할 수 있다.


추가적으로 제약조건의 이름을 설정해줄 수 있다.
@ManyToOne
@JoinColumn(foreignKey @ForeignKey(name "fk_question_writer"))
private User writer;

console 창에서도 관계 매핑이 제대로 설정되었는지를 query 문을 통해 확인할 수 있다.

반대로 할 수도 있다. @OneToMany라는 애노테이션을 사용하여 User 객체에 Question 객체를 포함시킬 수도 있다. 하지만 이 경우, 데이터베이스 상에서 user 객체에 여러 개의 question 객체를 생성해야하고, 데이터베이스의 원자성이 깨지게 된다. 또 설계에 따라 다를 수 있지만, User에서는 다른 객체와의 관계를 피하는 것이 좋다.




날짜 포함하기!
LocalDateTime
LocalDateTime 은 자바에서 시간을 나타내기 위해 java 8부터 추가된 타입이다.
private LocalDateTime createDate;
일반 멤버 변수 처럼 @Entity 애노테이션이 설정된 클래스에 정의하면 데이터베이스가 필드가 생성된다.

이제 어떠한 형식으로 LocalDateTime이 데이터베이스에 추가되었는지 확인해보자. 생성해준 createDate 필드에 알 수 없는 글자들로 레코드가 가득 차 있다. 이 알 수 없는 글자들이 현재 시간을 나타내는 것임은 분명한 듯하다. 이대로 게시글에 게시한 날짜를 표시할 수는 없으니 formatting을 해주자. 이 createDate 를 사람들이 알아볼 수 있는 형식으로 format해주는 것이다.
mustache에서도 사용할 수 있도록 get메서드를 통해서 만들자. ( mustache template engine은 get 메소드를 따로 생성하지 않아도 자동으로 coding convention을 통해 멤버 변수에 대해 접근하기 위해 get 메소드 형식으로 접근한다. )

cf> 이번에 알게 된 사실인데, 카멜 표기법으로 변수명을 작성하게 되면. h2-console에서 데이터베이스 필드명을 스네이크 표기법으로 자동 설정해준다! import.sql 을 통해 테스트 데이터를 삽입할 때 참고하면 좋을 듯 하다.

public String getFormattedCreateDate() {
    if (createDate == null) {
        return "";
    }
    return createDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"));
}

.ofPattern 메소드에 String 값을 통해 일정 Format을 전달할 수 있다. y는 year, M은 month, d는 day 의 약자로 표현식을 정의할 수 있다. 알 수 없는 메소드들의 향연이지만, 구글링을 통하거나 docs를 통해 사용법을 쉽게 알아볼 수 있다.

이렇게 하고 questions 로 반복되어 mustache에 뿌려지는 데이터를 살펴보자. template 파일에도 마찬가지로 get을 빼고 formattedCreateDate로 {{}} 안을 채워주자. mustache는 기본적으로 getter method를 제공한다! 그렇기 때문에 get 메소드를 설정하면 convention에 맞춰서 멤버명만으로 템플릿 엔진을 통해 값을 표시할 수 있다. 우리가 원하는 형식대로 날짜 값이 표시되었다.

! Spring boot에서 java 8의 localdatetime을 활용하기
localdatetime을 timestamp 로 convert 시켜준다. 이것은 스택오버플로우느님께서 알려주신 코드이니 믿고 사용해보도록하자. 간단하게 코드의 의미만 파악하고 넘어가자. 다음과 같이 LocalDateTimeConverter 클래스를 만들어준다.

package com.jbee;

import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
import java.sql.Timestamp;
import java.time.LocalDateTime;

@Converter(autoApply true)
public class LocalDateTimeConverter implements AttributeConverter<LocalDateTimeTimestamp>{

    @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;
    }
}




질문 상세보기 기능 구현 & 수정 및 삭제 기능 구현
수정 기능을 구현하기 위해선, 몇 가지 확인 작업이 필요하다. 우선, 로그인이 되었는지를 판단하고, 로그인이 되어있지 않으면 로그인 폼으로 보낸다. 그리고 수정하고자 하는 글이, 로그인 된 사용자에 의해 작성된 글인지를 먼저 판단해야 한다. 이전 과정에서 만들어둔 HttpSessionUtils 를 활용하자. 그리고 question 객체에게 메시지를 전달하여 현재 로그인 된 사용자가 해당 게시물의 작성자와 일치하는지를 확인한다.

@GetMapping("/{id}/form")
public String updateForm(@PathVariable Long idModel modelHttpSession session) {
    if (!HttpSessionUtils.isLoginUser(session)) {
        return "user/login";
    }

    User sessionedUser = HttpSessionUtils.getUserFromSession(session);
    Question question = questionRepository.findOne(id);
    if (!question.isSameWriter(sessionedUser)) {
        return "user/login";
    }
    model.addAttribute("question"question);
    return "qna/updateForm";
}

public boolean isSameWriter(User sessionedUser) {
    return this.writer.equals(sessionedUser);//instance는 다르지만 갖고있는 hashcode 값이 같으면 true
}

그런데 이 과정에서 한 가지 주의할 점이 있다. 주석에 표시해둔 것처럼 현재 equals를 override할 필요가 있다. 그 이유는 다음 포스팅을 통해 학습하면 되겠다!
위 코드에서는 writer가 User class로부터 생성된 인스턴스이므로 User 클래스에서 equals 메소드와 hashcode 메소드를 override해주자.
@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;

}

@Override
public int hashCode() {
    return id != null id.hashCode() : 0;
}

override code는 intelli J 에서 자동으로 생성해주는 코드를 사용하였다. 이제 updateForm에서 변경된 사항을 다시 저장해주자.
회원정보 수정의 원리와 같다. ( question class에 update 메소드를 추가해주자 )

@PutMapping("/{id}")
public String update(@PathVariable Long idString titleString contentsHttpSession session) {
    if (!HttpSessionUtils.isLoginUser(session)) {
        return "users/login";
    }

    Question question = questionRepository.findOne(id);
    question.update(titlecontents);
    questionRepository.save(question);

    return String.format("redirect:/questions/%d"id);
}

return 값으로 못보던 녀석이 등장했다.
String format!
Controller에 url을 Mapping 해줄 때 특정 메소드를 사용한다. 이 메소드의 return 값을 통해, template Engine에 연결하는 원리이다. 이 return 값은 String이다. 그런데, 이 return 값에 특정 변수를 포함시켜야 한다면? 이 때, String.format 메소드를 사용한다.
한 가지 예를 들어보자. 특정한 게시글에 대한 수정을 완료하고나서는, 완료된 게시글을 보여줘야 한다. 게시글은 게시글만의 id 값이 포함된 url로 접근했을 것이다. 그렇다면 PostMapping이나 PutMapping을 통해서, 특정 id 값이 전달됬을 것이다. 그리고 그 페이지로 redirect 시켜줘야 하는데, 이 때 id 값이 필요하게 된다. 이를 해결하기 위해서 위와 같은 방법을 사용한다. C언어에서 printf를 하는 방식처럼, %d라는 것을 통해, 변수값을 전달한다. id 데이터 타입이 int 이기 때문에 %d를 사용하였고, 만약 String 변수라면 %s를 쓰면 된다.



답변기능 구현하기
질문에 대한 답변을 댓글 형식으로 달아주는 기능을 추가해보자.
답변을 나타내는 객체 Answer class, JPA 메소드를 사용하기 위한 AnswerRepository Interface, 그리고 요청에 대응할 AnswerController를 생성해준다.

Answer.java
이 Answer 객체는 조금 복잡하다. 두 객체와 연관이 있기 때문이다. 특정한 질문에 속해있으며, 어떠한 사용자가 작성하는 것이기 때문에 멤버 변수를 설정할 때, 이 두 가지를 포함해야 한다. 질문과 답변은 1:N의 관계이므로 Answer 객체에 question 객체를 멤버 변수로 추가해주면 된다.  작성자와 답변 또한 1:N 관계이므로 Answer 객체에 user 객체를 멤버 변수로 추가해주자. 그리고 나서 question 객체에 user 객체를 멤버변수로 추가할 때와 마찬가지로 @ManyToOne 이라는 애노테이션을 추가하고 @JoinColumn 애노테이션을 통해 foreign key 를 설정해준다.

Answer도 하나의 객체이므로 id 값을 갖고 있어야 하며, 내용을 담고 있을 contents 변수, 작성 시점을 담고 있을 createDate 변수를 추가적으로 작성해준다. 그리고 이 추가되는 내용을 인스턴스를 통해 데이터베이스에 추가해줘야 하므로, 생성자를 설정해준다. (createDate 는 자동생성되므로 생성자로 넘어가는 인자에 추가해주지 않는다. 단, 템플릿 엔진에서 Answer 객체로의 접근을 통해 createDate를 표현해야 하므로, 생성자에게 속해있도록 해야 한다.) 생성자를 정의할 때, default 생성자를 생성해줘야 한다는 것을 잊지 말자. 넘어가는 인자로는 writer 객체, question 객체, contents 변수가 되겠다.

public Answer(User writerString contentsQuestion question) {
    this.writer = writer;
    this.createDate = LocalDateTime.now();
    this.contents = contents;
    this.question = question;
}

Controller를 설정할 때, 의미 있는 url을 설정해줄 필요가 있다. ResfFul 한 url을 설계해야하는 것이다. 답변은 질문에 속해있다. ( 1:N의 관계이다. ) 객체를 설정해줄 때도 그렇기 때문에 애노테이션을 통해서 이를 명시해줬다. url도 이에 맞게 설정해주자.
@Controller
@RequestMapping("/questions/{questionId}/answers")
public class AnswerController {

}

Post 방식을 통해 데이터가 날아올 것이기 때문에, PostMapping으로 받을 준비를 하자. 어떠한 값이 넘어올 것인가?
어떠한 질문에 대한 답변인가에 대한 정보로 question의 id 값이 넘어올 것이고, 해당 답변의 내용, contents 가 넘어올 것이고, 작성자 즉 현재 로그인된 상태인 유저의 정보가 넘어올 것이다. 이 세 가지 경우를 인자로 추가하여 받을 준비를 하면 된다.
@PostMapping("")
public String create(@PathVariable Long questionIdString contentsHttpSession session) {

}

로그인이 되었는지 안되었는지 판단하자. 로그인 되어 있지 않다면 로그인을 먼저 하라고 login form으로 redirect 시켜준다.
if (!HttpSessionUtils.isLoginUser(session)) {
    return "/user/login";
}

session으로부터 로그인된 사용자 객체를 받아오고, url을 통해 넘어온 questionId 값을 통해, question 객체를 받아온 다음, 데이터베이스에 저장할 Answer 객체를 생성하여, 인자로 넘겨준다. 물론 contents도 함께 넘겨준다. 그리고 이 answer 객체를 데이터베이스에 저장해주기 위해 answerRepository의 save 메소드를 사용한다. question 객체를 받을 때와 answer 객체를 저장하기 위해 repository interface가 필요하기 때문에, @Autowired 애노테이션을 통해 해당 repository를 가져와야 한다는 것을 잊지 말자!

이제 어디로 보내줘야 할까? 답변을 달고 바로 답변을 확인하는 것이 UX적으로 괜찮아보인다. redirect로 질문 상세 보기 페이지로 보내주자.
1
2
3
4
@Autowired
private QuestionRepository questionRepository;
@Autowired
private AnswerRepository answerRepository;
cs

// 메소드 안으로 들어가야할 부분
User sessionedUser = HttpSessionUtils.getUserFromSession(session);
Question question = questionRepository.findOne(questionId);
Answer answer = new Answer(sessionedUsercontentsquestion);
answerRepository.save(answer);
return String.format("redirect:/questions/%d"questionId);

template 파일에 적용을 시키다보니, 한 질문에 답변이 여러 개일 수 있다. 때문에 question에 저장되는 answer가 list로 저장되야 한다. 우리는 이전에 사용자 목록을 불러올 때, 이 부분을 해결하기 위해서, repository에 새로운 메소드를 생성해주었다.
public interface UserRepository extends JpaRepository<UserLong>{
    User findByUserId(String userId);
}

이번엔 다른 방법으로 해보자.
Question 객체에서 answer에 대한 List 를 멤버 변수로 갖고 있는 것이다. 두 객체가 1:N이라는 관계에 놓여있기 때문에 가능하다. 단, 몇 가지 조치를 취해줘야 한다. Answer 객체에서 Question 객체에 대한 정보를 갖고 있기 위해 @ManyToOne 애노테이션을 설정해줬다면 Question 객체에서 Answer 객체에 대한 정보를 갖고 있기 위해 @OneToMany 애노테이션을 설정해주는 것이다. Question 클래스에 answers를 값으로 하는 list 변수를 추가해주고 @OneToMany에서 mappedBy=“”를 통해서 해결한다. 이것은 연결되어 있는 필드의 이름으로 설정한다. 그리고 추가적으로 Answer를 정렬할 방법을 설정하기 위해, @OrderBy 애노테이션을 추가해주자.
@OneToMany(mappedBy "question")
@OrderBy("createDate ASC")
private List<Answer> answers;



Refactoring을 통한 중복제거
1. Exception을 사용하자
에러메시지를 사용자에게 전달해보자. 
private void hasPermission(HttpSession sessionQuestion question) {
    if (!HttpSessionUtils.isLoginUser(session)) {
        throw new IllegalStateException("로그인이 필요합니다.");
    }
    User sessionedUser = HttpSessionUtils.getUserFromSession(session);
    if (!question.isSameWriter(sessionedUser)) {
        throw new IllegalStateException("자신의 글만 수정, 삭제가 가능합니다.");
    }
}

@PutMapping("/{id}")
public String update(@PathVariable Long idString titleString contentsModel modelHttpSession session) {
    try {
        Question question = questionRepository.findOne(id);
        hasPermission(sessionquestion);
        question.update(titlecontents);
        questionRepository.save(question);
        return String.format("redirect:/questions/%d"id);
   catch (IllegalStateException e) {
        model.addAttribute("errorMessage"e.getMessage());
        return "user/login";
    }
}

2. 새로운 클래스를 생성하여 사용하기.
public class Result {
    private boolean valid;

    private String errorMessage;

    public Result(boolean validString errorMessage) {
        this.valid = valid;
        this.errorMessage = errorMessage;
    }

    public static Result ok(){
        return new Result(true, null);
    }

    public static Result fail(String errorMessage){
        return new Result(false, errorMessage);
    }
}


@PutMapping("/{id}")
public String update(@PathVariable Long idString titleString contentsModel modelHttpSession session) {
    Question question = questionRepository.findOne(id);
    Result result = valid(sessionquestion);
    if(!result.isValid()){
        model.addAttribute("errorMessage"result.getMessage());
        return "user/login";
    }
    question.update(titlecontents);
    questionRepository.save(question);
    return String.format("redirect:/questions/%d"id);
}



배포할 때 쓰는 명령어들
cd spring_project
git pull
./mvnw clean package
tomcat/bin/shutdown.sh
cd target/
mv my-project-1.0 ~/tomcat/webapps/ROOT
tomcat/bin/startup.sh

오늘의 꿀팁!
@Lob을 추가해주자.
255자가 넘는 긴 텍스트의 String 타입인 경우에는 애노테이션을 추가해줘야 한다.
ex>
1
2
@Lob
private String content
cs


사족
cf> 강의에 나온 변수명들을 그대로 따라하지말고, 자신만의 변수명으로 코드를 따라 작성하자. 물론 강의해주시는 교수님의 변수명이 가독성이 좋고 의미론적으로 더 훌륭하겠지만 이렇게 하는 이유는, 변수명까지 똑같게 강의를 따라하다보면, 따라하는데 정신이 팔릴 수 있다. 변수명을 다르게 해두면, 교수님 코드가 어떤 목적으로 작성되있는지를 먼저 파악하게 되고 이해한 다음 내 코드로 옮겨 적게 되서 효과가 더 좋다. 그리고 이번 강의가 어떠한 내용을 다루는지 알 수 있기 때문에 혼자서 한번 그 기능들을 구현하기 위해 시도를 해보고 강의를 듣는 것이 더 좋다고 생각한다.



반복주기 5. End

항상 좋은 강의 감사합니다!