본 포스팅은 Slipp - Spring-Boot, JPA로 질문/답변 게시판 구현 과정을 참조하여 작성한 내용입니다. 개인적 학습 내용을 복습하기 위한 내용이기 때문에 내용상 오류가 있을 수 있습니다. 소스코드의 자세한 내용은 https://github.com/walbatrossw/boot-qna 를 참조해주세요.
4. slipp 반복주기 4
로그인 기능 구현, 쿠키와 세션에 대한 이해
로그인 사용자에 대한 접근 제한
4-1) 로그인 기능 구현
UserController : 로그인 관련 메서드 추가
로그인 화면 매핑
@GetMapping("/loginForm") public String loginForm() { return "/user/login"; }
로그인 처리
@PostMapping("/login") public String login(String userId, String password, HttpSession session) { User user = userRepository.findByUserId(userId); if ( user == null ) { System.out.println("login failure"); return "redirect:/users/loginForm"; } if ( !password.equals(user.getPassword()) ) { System.out.println("login failure"); return "redirect:/users/loginForm"; } session.setAttribute("user", user); System.out.println("login success"); return "redirect:/"; }
UserRepository : userId 로 하나의 회원을 조회하는 메서드 작성
@Repository public interface UserRepository extends JpaRepository<User, Long>{ // User 타입의 userId로 조회 User findByUserId(String userId); }
4-2) 로그인 상태에 따른 메뉴 처리 및 로그아웃
로그인 상태에서 메뉴 처리
mustache 템플릿 엔진 if else 문으로 처리하기
session 에 user 가 없으면 로그인, 회원가입 메뉴
session 에 user 가 있으면 로그아웃, 개인정보수정 메뉴
<div class="collapse navbar-collapse" id="navbar-collapse2"> <ul class="nav navbar-nav navbar-right"> <li class="active"><a href="/">Posts</a></li> {{^user}} <li><a href="/users/loginForm" role="button">로그인</a></li> <li><a href="/users/form" role="button">회원가입</a></li> {{/user}} {{#user}} <li><a href="/users/logout" role="button">로그아웃</a></li> <li><a href="/users/update" role="button">개인정보수정</a></li> {{/user}} </ul> </div>
참고 URL : https://mustache.github.io/mustache.5.html, Inverted Sections 를 참고
로그인시 메뉴 처리가 되지않는 문제 발생
application.properties
에 mustache 템플릿 session 관련 설정하기spring.mustache.expose-session-attributes=true
참고 URL : https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html, MUSTACHE TEMPLATES 를 참고
로그아웃 처리
UserController : logout() 메서드 추가, 세션에 담긴 user 를 제거
@GetMapping("/logout") public String logout(HttpSession session) { session.removeAttribute("user"); System.out.println("logout success"); return "redirect:/"; }
4-3) 로그인 사용자에 한해 자신의 정보를 수정하도록 처리
로그인 -> 자신의 정보 수정 페이지로 이동
navigation.html
상단 메뉴바의 개인정보수정a
태그 수정<li><a href="/users/{{id}}/form" role="button">개인정보수정</a></li>
500 에러 발생 : Cannot expose session attribute ‘user’ because of an existing model object of the same name
원인 : session 의 key 값이 user 이고, model 의 key 값도 user 라서 충돌이 발생
해결 : session 의 key 값을 sessionUser 로 변경
navigation.html
{{^sessionUser}} <li><a href="/users/loginForm" role="button">로그인</a></li> <li><a href="/users/form" role="button">회원가입</a></li> {{/sessionUser}} {{#sessionUser}} <li><a href="/users/logout" role="button">로그아웃</a></li> <li><a href="/users/update" role="button">개인정보수정</a></li> {{/sessionUser}}
UserController
@PostMapping("/login") public String login(String userId, String password, HttpSession session) { User user = userRepository.findByUserId(userId); if ( user == null ) { return "redirect:/users/loginForm"; } if ( !password.equals(user.getPassword()) ) { System.out.println("login failure"); return "redirect:/users/loginForm"; } // key값을 sessionUser로 변경 session.setAttribute("sessionUser", user); System.out.println("login success"); return "redirect:/"; } @GetMapping("/logout") public String logout(HttpSession session) { // key값을 sessionUser로 변경 session.removeAttribute("sessionUser"); System.out.println("logout success"); return "redirect:/"; }
개인정보 수정화면 및 수정처리
본인의 개인정보만 접근이 가능하도록 구현
// 회원 정보수정 화면 @GetMapping("/{id}/form") public String updateForm(@PathVariable Long id, Model model, HttpSession session) { // session 에서 값을 꺼내면 Object 타입으로 리턴하게 되므로 User 타입이 아닌 Object 타입으로 변수선언 Object tempUser = session.getAttribute("sessionUser"); // session 이 null 이면 로그인페이지로 리다이렉트 if ( tempUser == null ) { return "redirect:/users/loginForm"; } // session 에 저장된 id와 일치하지 않는 회원정보 수정화면으로 접근 금지 User sessionUser = (User)tempUser; if ( !id.equals(sessionUser.getId()) ) { throw new IllegalStateException("Can't modify other's information"); } // session 에 저장된 자신의 정보만 조회할 수 있도록 처리 User user = userRepository.findOne(sessionUser.getId()); model.addAttribute("user", user); return "/user/updateForm"; }
// 회원 정보수정 처리 @PutMapping("/{id}") public String update(@PathVariable Long id, User updatedUser, HttpSession session) { // session 에서 값을 꺼내면 Object 타입으로 리턴하게 되므로 User 타입이 아닌 Object 타입으로 변수선언 Object tempUser = session.getAttribute("sessionUser"); // session 이 null 이면 로그인페이지로 리다이렉트 if ( tempUser == null ) { return "redirect:/users/loginForm"; } // session 에 저장된 id와 일치하지 않는 회원정보 수정처리 금지 User sessionUser = (User)tempUser; if ( !id.equals(sessionUser.getId()) ) { throw new IllegalStateException("Can't modify other's information"); } User user = userRepository.findOne(sessionUser.getId()); // 기존의 아이디 정보를 조회 user.update(updatedUser); // 아이디의 정보 변경 userRepository.save(user); // 변경된 정보를 저장 return "redirect:/users/list"; }
4-4) 중복 제거 및 읽기 좋은 코드를 위한 리팩토링
UserController 중복 코드 제거
session 처리를 담당하는 Util 클래스 작성 : HttpSessionUtils (com.doubles.qna.web)
public class HttpSessionUtils { // session 의 key 값을 상수로 변경 public static final String USER_SESSION_KEY = "sessionUser"; // session 에 로그인된 유저의 존재 여부 판별하는 메서드 public static boolean isLoginUser(HttpSession session) { // session 에서 값을 꺼내면 Object 타입으로 리턴하게 되므로 User 타입이 아닌 Object 타입으로 변수선언 Object sessionUser = session.getAttribute(USER_SESSION_KEY); // session 값이 null 이면 false 리턴 if ( sessionUser == null ) { return false; } return true; } // session 에 저장된 값을 가져오는 메서드 public static User getUserFromSession(HttpSession session) { if ( !isLoginUser(session) ) { return null; } return (User)session.getAttribute(USER_SESSION_KEY); } }
UserController 수정
로그인 처리
@PostMapping("/login") public String login(String userId, String password, HttpSession session) { User user = userRepository.findByUserId(userId); if ( user == null ) { System.out.println("login failure"); return "redirect:/users/loginForm"; } // 로그인 시 password 값과 조회하려는 password 값 비교 <-- 변경 if ( !user.matchPassword(password) ) { System.out.println("login failure"); return "redirect:/users/loginForm"; } session.setAttribute(HttpSessionUtils.USER_SESSION_KEY, user); // <-- 변경 System.out.println("login success"); return "redirect:/"; }
로그아웃 처리
@GetMapping("/logout") public String logout(HttpSession session) { // 세션에 담기 user 를 제거 <-- 변경 session.removeAttribute(HttpSessionUtils.USER_SESSION_KEY); System.out.println("logout success"); return "redirect:/"; }
회원 정보수정 화면
@GetMapping("/{id}/form") public String updateForm(@PathVariable Long id, Model model, HttpSession session) { // session 값이 null 이면 로그인 페이지로 redirect <-- 변경 if ( !HttpSessionUtils.isLoginUser(session) ) { return "redirect:/users/loginForm"; } // session 에 저장된 값을 sessionUser 에 복사 <-- 변경 User sessionUser = HttpSessionUtils.getUserFromSession(session); // session 에 저장된 id 값과 조회하려는 id값 비교 <-- 변경 if ( !sessionUser.matchId(id) ) { throw new IllegalStateException("Can't modify other's information"); } // session 에 저장된 자신의 정보만 조회할 수 있도록 처리 User user = userRepository.findOne(id); model.addAttribute("user", user); return "/user/updateForm"; }
회원 정보수정 처리
@PutMapping("/{id}") public String update(@PathVariable Long id, User updatedUser, HttpSession session) { // session 값이 null 이면 로그인 페이지로 redirect if ( !HttpSessionUtils.isLoginUser(session) ) { return "redirect:/users/loginForm"; } // session 에 저장된 값을 sessionUser 에 복사 <-- 변경 User sessionUser = HttpSessionUtils.getUserFromSession(session); // session 에 저장된 id 값과 조회하려는 id값 비교 <-- 변경 if ( !sessionUser.matchId(id) ) { throw new IllegalStateException("Can't modify other's information"); } User user = userRepository.findOne(id); // 기존의 아이디 정보를 조회 user.update(updatedUser); // 아이디의 정보 변경 userRepository.save(user); // 변경된 정보를 저장 return "redirect:/users/list"; }
User 클래스 수정 : id, password get() 메서드 제거하고 아래의 메서드로 변경
User 가 가지고 있는 변수 값을 get() 메서드로 값을 꺼내서 Controller 에서 비교하는 것보다는 객체 클래스에서 직접 값을 비교하고 결과값을 리턴하도록 만드는 것이 좋다. 객체지향적으로 코드를 작성하려면 private 으로 캡슐화 되어 있는 변수의 값을 외부에 노출시키는 것은 바람직 하지 않기 때문이다.
아이디 일치확인 메서드
public boolean matchId(Long newId) { if ( newId == null ) { return false; } return newId.equals(id); }
비밀번호 일치확인 매서드
public boolean matchPassword(String newPassword) { if ( newPassword == null ) { return false; } return newPassword.equals(password); }
JPA Console 창에 SQL Query 보기 설정
application.properties
에 아래와 같이 설정spring.jpa.show-sql=true spring.jpa.database-platform=org.hibernate.dialect.H2Dialect spring.jpa.properties.hibernate.format_sql=true
4-5) 질문하기, 질문 목록 기능 구현
질문하기
QuestionController 작성 : (com.doubles.qna.web)
@Controller @RequestMapping("/questions") public class QuestionController { @Autowired private QuestionRepository questionRepository; // 게시글 작성 화면 @GetMapping("/form") public String question(HttpSession session) { // 로그인되어 있지 않으면 로그인 페이지로 if ( !HttpSessionUtils.isLoginUser(session)) { return "redirect:/users/loginForm"; } return "/qna/form"; } // 게시글 작성 처리 @PostMapping public String create(String title, String contents, HttpSession session) { // 로그인되어 있지 않으면 로그인 페이지로 if ( !HttpSessionUtils.isLoginUser(session) ) { return "redirect:/users/loginForm"; } // 현재 로그인되어 있는 회원의 정보를 sessionUser 에 복사 User sessionUser = HttpSessionUtils.getUserFromSession(session); Question newQuestion = new Question(sessionUser.getUserId(), title, contents); questionRepository.save(newQuestion); return "redirect:/"; } }
Question 클래스 작성 : (com.doubles.qna.domain)
@Entity public class Question { @Id @GeneratedValue private Long id; private String writer; private String title; private String contents; public Question() { } public Question(String writer, String title, String contents) { this.writer = writer; this.title = title; this.contents = contents; } }
QuestionRepository 인터페이스 작성 : (com.doubles.qna.domain)
@Repository public interface QuestionRepository extends JpaRepository<Question, Long>{ }
form.html : 질문하기 작성 화면 (resources/template/qna)
<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"> <form name="question" method="post" action="/questions"> <div class="form-group"> <label for="title">제목</label> <input type="text" class="form-control" id="title" name="title" placeholder="제목"/> </div> <div class="form-group"> <label for="contents">내용</label> <textarea name="contents" id="contents" rows="5" class="form-control"></textarea> </div> <button type="submit" class="btn btn-success clearfix pull-right">질문하기</button> <div class="clearfix"/> </form> </div> </div> </div>
질문 목록
HomeController : home() 메서드 수정
@Controller public class HomeController { @Autowired private QuestionRepository questionRepository; @GetMapping("/") public String home(Model model) { model.addAttribute("questions", questionRepository.findAll()); return "index"; } }
index.html 수정 : 글작성 시간 및 댓글 갯수는 추후 구현 예정
{{#questions}} <li> <div class="wrap"> <div class="main"> <strong class="subject"> <a href="../static/qna/show.html">{{title}}</a> </strong> <div class="auth-info"> <i class="icon-add-comment"></i> <span class="time">2016-01-15 18:47</span> <a href="../static/user/profile.html" class="author">{{contents}}</a> </div> <div class="reply" title="댓글"> <i class="icon-reply"></i> <span class="point">8</span> </div> </div> </div> </li> {{/questions}}
4-6) 원격 서버에 소스코드 배포
war 파일로 배포
pom.xml
설정war packaging 으로 변경
<groupId>com.doubles.qna</groupId> <artifactId>boot-qna</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>war</packaging>
내장형 tomcat 을 사용하지 않고 별도의 tomcat 을 사용하기 위해 tomcat 의존성 추가
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>
BootQnaWebInitializer
클래스 작성 : 외장 tomcat 을 사용하려면 초기화를 위한 클래스를 따로 작성해줘야 한다.public class BootQnaWebInitializer extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(BootQnaApplication.class); } }
SpringBootServletInitializer
클래스를 상속configure()
메서드를 오버라이딩- 기존에 초기화 작업를 담당했던
BootQnaApplication
클래스를 등록 설정하여 초기화 작업을 수행할 수 있도록 해준다.
- 기존에 초기화 작업를 담당했던
배포 서버에 tomcat 설치
tar.gz 파일을 링크주소 복사 ex) http://apache.tt.co.kr/tomcat/tomcat-8/v8.5.15/bin/apache-tomcat-8.5.15.tar.gz
배포 서버로 tomcat 다운로드
$ wget http://apache.tt.co.kr/tomcat/tomcat-8/v8.5.15/bin/apache-tomcat-8.5.15.tar.gz
다운로드 받은 tomcat 압축풀기
$ tar -xvf apache-tomcat-8.5.15.tar.gz
tomcat 폴더의 실제 물리적 경로를 tomcat 심볼릭 링크로 설정
$ ln -s apache-tomcat-8.5.15 tomcat
tomcat 서버 구동하기 : tomcat/bin 으로 이동하여 startup.sh 파일을 실행
$ ./startup.sh
tomcat 서버 정지하기 : tomcat/bin 으로 이동하여 shutdown.sh 파일을 실행
tomcat 서버로 접속하기 : tomcat 서버의 경우 default 로 8080포트로 되어 있다. 주소:8080 으로 접속하여 고양이 그림이 있는 웹페이지가 뜨면 성공
github 저장소에서 지금까지 구현한 프로젝트를 pull 하고 build 하기
$ git pull $ ./mvnw clean package
tomcat 서버에 springboot 프로젝트 배포하기
tomcat 이 설치된 디렉토리에서
webapps/ROOT
에 기존의 내용을 삭제$ rm -rf ROOT/
빌드한 프로젝트를 ROOT 디렉토리로 이동한 뒤 ROOT 디렉토리로 이름을 변경
$ mv [해당프로젝트명] ~/tomcat/webapps $ mv [해당프로젝트명]/ ROOT
tomcat 서버 구동
tomcat 서버 로그 확인하기 :
tomcat/logs
로 이동하여catalina.out
실행$ tail -500f catalina.out
프로젝트 기능 구현을 한 뒤에 항상 실 서버나 테스트 서버에 배포 작업을 하자. 그렇지 않고 프로젝트를 완성한 뒤 배포하는 시점에 문제가 발생하게 되면 문제를 해결하는데 상당한 시간과 노력이 필요할 수 있다.
war 배포 설정 이후 로컬에서 오류발생
SpringBoot 프로젝트를 로컬에서 실행시 오류가 IntelliJ 에서 계속 발생, STS 에서는 이상없음.
원인 :
pom.xml
에서 tomcat 설정 scope 에서 문제가 발생해결 :
pom.xml
에서 scope 설정 제거, default 설정(compiled)참고 URL : http://krespo.net/166