[카테캠 BE] week7 - 스프링(세션, 예외 처리, WEBDataBinder 등)
이번 주로 스프링 강의는 마지막이네요. 선택강의로 스프링 심화랑 스프링 부트도 존재하니까 나중에 봐야겠어요.
다음주부터는 MySQL을 다룬다고 해요.
학습 일지 시작!
쿠키(Cookie)
http 통신은 stateless
이다. 그렇기 때문에 서버가 클라이언트를 식별하기 위해선 추가 기능이 필요하다. 쿠키란 http 통신에서 클라이언트를 식별하는 기술이다.
이름=값
쌍으로 구성된 작은 정보- 서버에서 생성 후 클라이언트로 전송, 브라우저에 저장(유효기간 이후 자동 삭제)
- 서버에 요청 시
domain
,path
가 일치(하위 경로 포함)하는 경우에만 자동 전송된다.
- 서버에 요청 시
- ASCII 문자만 가능
- 서버에서 응답헤더에 쿠키를 추가하여 전송한다.
Cookie cookie = new Cookie("id", "asdf"); // 쿠키 생성 cookie.setMaxAge(60*60*24); // 유효기간 설정(초) 코드는 24시간;하루 response.addCookie(cookie); // 응답에 쿠키 추가
- 응답헤더 내용
HTTP/1.1 200 Set-Cookie: id=asdf; Max-Age=86400; Expires=Tue, 16-Nov-2021 11:12:15 GMT // 응답 헤더, 쿠키, 상대 시간, 절대 시간
- 응답헤더 내용
- 브라우저에 저장된다.
- 클라이언트의 다음 요청부터 쿠키가 따라가게 된다.
쿠키의 변경과 삭제
기존 쿠키와 같은 이름의 쿠키를 생성하고 변경할 내용을 새로 set
하여 전송한다.
Cookie cookie = new Cookie("id", ""); // 변경할 쿠키와 같은 이름 쿠키 생성
// cookie.setMaxAge(0); // 쿠키 삭제(유효기간을 0으로 설정)
cookie.setValue(URLEncoder.encode("안혜준"));
cookie.setDomain("www.jagaldol.dev");
cookie.setPath("/app");
response.addCookie(cookie); // 응답에 쿠키 추가
- 삭제는 유효기간을 0으로 설정하여 이루어진다.
쿠키 읽기
Cookie[] cookies = reqeust.getCookies();
for(Cookie cookie:cookies) {
String name = cookie.getName();
String value = cookie.getValue();
}
Session(세션)
클라이언트를 식별하기 위해 접속하는 클라이언트(브라우저)에게 식별자(세션ID;쿠키)를 부여하고, 서버에서 세션id에 따라 세션 저장소를 만들어 보관한다.
- 브라우저마다 개별 저장소(session 객체)를 서버에서 제공
a collection of related HTTP transactions made by one browser to one server.
세션 메서드
메서드 | 설명 |
---|---|
String getId() | 세션의 ID를 반환 |
long getLastAccessedTime() | 세션 내에서 최근 요청을 받은 시간을 반환 |
boolean isNew() | 새로 생성된 세션인지? 확인. request.getSession()호출 후 사용 |
void invalidate() | 세션 객체를 제거(저장된 객체도 지워진다.) |
void setMaxInactiveInterval(int interval) | 지정된 시간(초) 후에 세션을 종료(예약) |
int getMaxInactiveInterval() | 예약된 세션 종료 시간을 반환 |
속성 관련 메서드는 pageContext
, request
, session
, application
들과 전부 동일하다.
속성 관련 메서드 | 설명 |
---|---|
void setAttribute(String name, Object value) | 지정된 값(value)을 지정된 속성 이름(name)으로 저장 |
Object getAttribute(String name) | 지정된 이름(name)으로 저장된 속성의 값을 반환 |
void removeAttribute(String name) | 지정된 이름(name)의 속성을 삭제 |
Enumeration getAttributeNames() | 기본 객체에 저장된 모든 속성의 이름을 반환 |
세션 사용
- 세션 id를 클라이언트에게 부여(쿠키 전송; Set-Cookie)
- 클라이언트의 이후 요청부터 세션id 정보가 요청 헤더에 담겨진다.
- 서버에서 일치하는 세션 탐색
- 세션 저장소를 사용가능
HttpSession session = request.getSession(); // 요청의 세션 정보 확인
session.setAttribute("id", "asdf"); // 해당 세션 저장소 사용 가능
- 저장소 생성 및 삭제는 세션 정보에 따라 자동으로 관리된다.
세션 종료
- 수동 종료
HttpSession session = request.getSession(); session.invalidate();
- 자동 종료 - web.xml
<session-config> <session-timeout>30</session-timeout> </session-config>
- 자동으로 세션이 종료 되지 않으면 보안 상 위험이 존재한다.
로그인과 로그아웃
- 로그인 성공 시 세션을 만들어 준다.
- 현재는 하드코딩 되어있지만 나중에는 db와 연결하여 매칭 검색을 수행한다.
- 아이디 기억하기를 누르면 유효기간이 없는 쿠키를 브라우저에게 넘겨줘 기억하도록 한다.
- 로그아웃 시 세션을 파기하고 홈화면으로
redirect
시킨다.
@PostMapping("/login")
public String login(String id, String pwd, String toURL, boolean rememberId,
HttpServletRequest request, HttpServletResponse response) throws Exception {
if (!loginCheck(id, pwd)) {
String msg = URLEncoder.encode("id 또는 pwd가 일치하지 않습니다.", "utf-8");
return "redirect:/login/login?msg="+msg;
}
// 세션 생성
HttpSession session = request.getSession();
session.setAttribute("id", id);
// 아이디 기억하기
if (rememberId) {
Cookie cookie = new Cookie("id", id);
response.addCookie(cookie);
} else {
Cookie cookie = new Cookie("id", id);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
toURL = toURL == null || toURL.equals("") ? "/" : toURL;
return "redirect:" + toURL();
}
@GetMapping("/logout")
public String logout(HttpServletRequest request) {
HttpSession session = request.getSession();
session.invalidate();
return "redirect:/";
}
private boolean loginCheck(String id, String pwd) {
return "asdf".equals(id) && "1234".equals(pwd);
}
로그인 후 원래 가고자 했던 페이지로 이동 시키기
- Http request에서 from과 to의 정보를 얻는 법
- from:
request.getHeader(”refer”)
- to:
request.getRequestURL()
- 필터를 사용해 모든 요청에 대해 확인을 해보자
// 3. 후처리 작업 HttpServletRequest req = (HttpServletRequest)request; String referer = req.getHeader("referer"); String method = req.getMethod(); String requestURI = req.getRequestURI(); System.out.print("["+referer+"] ->"+method+"["+requestURI+"]"); System.out.println(" 소요시간="+(System.currentTimeMillis()-startTime)+"ms");
- 모든 요청에 대해 필터를 걸어서 전부 확인이 가능하다.
[http://localhost:8080/ch2/] ->GET[/ch2/] 소요시간=39ms [http://localhost:8080/ch2/] ->GET[/ch2/board/list] 소요시간=3ms [http://localhost:8080/ch2/] ->GET[/ch2/login/login] 소요시간=5ms
- 필터 전체 코드
// 필터를 적용할 요청의 패턴 지정 - 모든 요청에 필터를 적용. @WebFilter(urlPatterns="/*") public class PerformanceFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { // 초기화 작업 } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 1. 전처리 작업 long startTime = System.currentTimeMillis(); // 2. 서블릿 또는 다음 필터를 호출 chain.doFilter(request, response); // 3. 후처리 작업 HttpServletRequest req = (HttpServletRequest)request; String referer = req.getHeader("referer"); String method = req.getMethod(); String requestURI = req.getRequestURI(); System.out.print("["+referer+"] ->"+method+"["+requestURI+"]"); System.out.println(" 소요시간="+(System.currentTimeMillis()-startTime)+"ms"); } @Override public void destroy() { // 정리 작업 } }
- 모든 요청에 대해 필터를 걸어서 전부 확인이 가능하다.
- from:
- 로그인 화면으로의
redirect
에서 url 전달if (!loginCheck(request)) return "redirect:/login/login?toURL="+request.getRequestURL();
- toURL 파라미터로
request.getRequestURL()
을 준다.
- toURL 파라미터로
- 로그인 화면에서 POST 요청에 포함 시키기 위한 숨겨진 input태그 작성
<input type="text" name="id" value="${cookie.id.value}" placeholder="이메일 입력" autofocus> <input type="password" name="pwd" placeholder="비밀번호"> <input type="hidden" name="toURL" value="${param.toURL}">
- loginController에서의 redirect 수정
@PostMapping("/login") public String login(String id, String pwd, String toURL, boolean rememberId, HttpServletRequest request, HttpServletResponse response) { ... toURL = toURL == null || toURL.equals("") ? "/" : toURL; return "redirect:" + toURL; }
- 새로운 파라미터인 toURL을 작성
- 값에 따라 redirect 위치 수정
session=false
세션 시간을 줄여야 서버 부담이 줄어든다. 세션이 필요없는 페이지(로그인 화면, 홈화면) 등에는 session=false
를 하자.
- jsp 파일 상단에 코드 추가
<%@ page session="false" %>
session=false
여도 기존 session이 생성되어있을 때 session을 버리지는 않는다. 단지 새로 생성을 하지 않을 뿐이다.
예외처리
각 controller
내부에 ExcpetionHandler
를 넣거나 전체 패키지에 적용을 위해 ControllerAdvice
를 사용한다.
// @ControllerAdvice // 모든 패캐지의 컨트롤러에 적용
@ControllerAdvice("com.fastcampus.ch2") // 지정된 패키지의 컨트롤러들에게 적용
public class GlobalCatcher {
@ExceptionHandler(Exception.class)
public String catcher(Exception ex, Model m) {
m.addAttribute("ex", ex);
return "error";
}
@ExceptionHandler({NullPointerException.class, FileNotFoundException.class})
public String catcher2(Exception ex, Model m) {
m.addAttribute("ex", ex);
return "error";
}
}
해당하는 Exception 발생 시 catcher로 넘어와 catcher가 실행된다.
- jsp 페이지에서
<%@ page isErrorPage='true' %>
를 사용하면 기본 객체excpetion
을 사용 가능하여model
로exception
을 넘겨주지 않아도 된다.<%@ page contentType="text/html;charset=utf-8" isErrorPage="true"%> <%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> <html> <head> <title>error.jsp</title> </head> <body> <h1>예외가 발생했습니다.</h1> 발생한 예외 : ${pageContext.exception}<br> 예외 메시지 : ${pageContext.exception.message}<br> <ol> <c:forEach items="${pageContext.exception.stackTrace}" var="i"> <li>${i.toString()}</li> </c:forEach> </ol> </body> </html>
❗주의❗
isErrorPage="true"
를 적으면 응답이 전부 500(내부 서버 오류)로 돌아간다. @ResponseStatus
로 설정한 것이 덮어 쓰여지니까 주의할 것!
@ResponseStatus
응답 메시지의 상태 코드를 변경할 때 사용
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
@ExceptionHandler(Exception.class)
public String catcher(Exception ex, Model m) {
m.addAttribute("ex", ex);
return "error";
}
요청 처리 성공으로 200의 상태코드가 가버리면 맞지 않다. 임의로 상태코드를 부여해주어야 한다.
톰캣의 기본 error page 변경하기
기본 error page는 서버의 에러 코드를 전부 다 전달하기 때문에 보안 상 위험이 생길 수도 있고 사용자에게 친화적이지 않다. 이를 web.xml
을 수정하여 기본 페이지를 만들 수 있다.
- 기본 error page
web.xml
에 코드 추가<error-page> <error-code>400</error-code> <location>/error400.jsp</location> </error-page> <error-page> <error-code>500</error-code> <location>/error500.jsp</location> </error-page>
error400.jsp
와error500.jsp
는 webapp 폴더 밑에 만들어 추가한다.
이렇게 하면, ExceptionHandler
로 catch
하지 않아도 원하는 상태코드에 맞게 원하는 에러 페이지로 보여 줄 수 있다.
ExceptionHandler
를 사용한 것은try-catch문
과 같기 때문에web.xml
로 설정한 페이지로 리다이렉션 되지않는다. 기본 에러 페이지는Exception
으로 에러가 발생했을 때만 이어진다.
SimpleMappingExceptionResolver
단순히 Exception
에 대해 에러 페이지를 mapping할 때는, SimpleMappingExceptionResolver
를 사용해 servlet-context.xml
에 추가해둘 수 있다.
<beans:bean class="org.springframework.web.servlet.handler.SimpleMappingExceptionResolver">
<beans:property name="defaultErrorView" value="error"/>
<beans:property name="exceptionMappings">
<beans:props>
<beans:prop key="com.fastcampus.ch2.MyException">error400</beans:prop>
</beans:props>
</beans:property>
<beans:property name="statusCodes">
<beans:props>
<beans:prop key="error400">400</beans:prop>
</beans:props>
</beans:property>
</beans:bean>
예외처리 기본 전략
Controller
에서 Exception 발생DispatcherServlet
으로 Exception 전달(throws)handlerExceptionResolvers
를 확인하여 해결 시도- 기본 전략 3가지를 순차적으로 시도
ExceptionHandlerExceptionResolver
- @ExceptionHandler 가 붙은 handler
2.
ResponseStatusExceptionResolver
- Exception에 붙어 있는 @RespnoseStatus 확인
- web.xml에서 에러코드에 해당하는 게 존재하는 지 확인하여 view전달 할 수 있
3.
DefaultExceptionResolver
- 스프링에서 기본으로 정의돼 있음
- 기본 서버 에러는 500
- 상황을 판단하여 400번대와 500번대로 적절히 바꿔준다.
- 기본 전략 3가지를 순차적으로 시도
DispatcherServlet 파헤치기
- 요청이 들어오면 HandlerMapping에게 요청 url을 전달한다.
- HandlerMapping
- key-value(url-method)를 보관하여 매핑을 해준다.
- HandlerAdapter
- 매핑된 정보를 토대로 Adapter를 거쳐 Controller 호출
- 느슨한 연결(변경에 유리)
- HandlerAdapter는 여러개 가능하다.
- 이걸 통해서
Controller
뿐만 아니라Servlet
도 호출가능하다.
- ViewResolver
- 전달받은 뷰 정보(return 값)을 resolver를 사용해 실제 값 얻는다.
- 기본
InternalResourceViewResolver
<beans:bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <beans:property name="prefix" value="/WEB-INF/views/" /> <beans:property name="suffix" value=".jsp" /> </beans:bean>
- 실제 뷰가 무엇인지 말해준다.
- jstlVIEW
- View 인터페이스
- 역시나 느슨한 연결
- 뷰를 사용자에게 응답해준다.
소스 분석
DispatcherServlet.class
는 spring-webmvc-5.0.7.RELEASE.jar
에 포함되어 있다.
- 소스파일 위치 -
org/springframework/web/servlet/DispatcherServlet.java
- 기본 전략 -
org/springframework/web/servlet/DispatcherServlet.properties
-
주요 메서드들
메서드 설명 void initStrategies(ApplicationContext context) 기본 전략을 초기화 void doService(~) doDispatch() 호출 void doDispatch(~) 실제 요청을 처리 void processDispatchResult(~,
HandlerExecutionChain mappedHandler)예외가 발생했는지 확인하고, 발생하지 않았으면 render()를 호출 void render(ModelAndView mv, ~) 응답결과를 생성해서 전송 HttpServletRequest request, HttpServletResponse response
는 ~로 생략함
WebDataBinder
타입변환과 데이터 검증을 수행한다.
String
으로 받은 데이터를타입 변환
- 데이터의 세부적인 검증 (e.g. day은 31 이하 여야 한다.)
- 각각의 결과와 에러를
BindingResult
에 보관 - 컨트롤러의 메서드에 매개변수로
BindingReuslt
를 받을 수 있다.- (주의)바인딩할 객체 바로 뒤에 와야 한다.
@PostMapping("/register/save") public String save(User user, BindingResult result) { System.out.println("result="+result); ... }
BindingResult
를 적으면 바인딩 과정에서 에러 발생 시 서버 에러를 내보내는 대신, 오류가 발생한 객체는null
로 처리되고 에러 내용이 result에 담겨 처리된다. 정상 리턴이 가능하다!
타입 변환
- Controller 내부에
@InitBinder
를 만들어 타입 변환을 설정해준다.@InitBinder public void toDate(WebDataBinder binder) { SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd"); binder.registerCustomEditor(Date.class, new CustomDateEditor(df, false)); binder.registerCustomEditor(String[].class, "hobby", new StringArrayPropertyEditor("#")); }
- 날짜 입력을
yyy-MM-dd
형식의 문자열로 받아 Date 객체로 변환 - 특정 이름(”hobby”)를
#
으로 구분해서 string배열로 변환
- 날짜 입력을
- 바인딩할 필드 앞에
Formatter
를 붙인다.public class User { ... @DateTimeFormat(pattern="yyyy-MM-dd") private Date birth; private String[] hobby; ... }
- 타입변환 종류
- PropertyEditor
- 양뱡향 타입 변환(String ↔ 타입)
- 특정 타입이나 이름의 필드에 적용 가능
- Converter
- 단방향 타입 변환(타입A → 타입B)
- PropertyEditor의 단점을 개선(stateful → stateless)
- ConversionService에 등록해야한다.
- Formatter
- 양방향 타입 변환
- 바인딩할 필드에 적용 -
@DateTimeFormat
- 인터페이스
- PropertyEditor
아니면 단순히 개별적인 setter를 수정할 수도 있다!
public void setBirth(String birth) {
String[] split_birth = birth.split("-");
System.out.println(Arrays.toString(split_birth));
this.birth = new Date(Integer.parseInt(split_birth[0]) - 1900,
Integer.parseInt(split_birth[1]) - 1, Integer.parseInt(split_birth[2]));
}
- String으로 받은 parameter를 수정하여 저장할 수 있다.
- (참고)Date는 기본 년도가 1900으로 되어있어 1900을 빼야 한다고 한다.
- 자바의 Date 객체는 Deprecated돼 권장되지 않는다.
- 대신 Calendar라는 객체가 있다고 한다.
데이터 검증
- Validator
- 객체를 검증하기 위한 인터페이스
public class UserValidator implements Validator { @Override public boolean supports(Class<?> clazz) { return User.class.equals(clazz); } @Override public void validate(Object target, Errors errors) { User user = (User)target; String id = user.getId(); // if(id==null || "".equals(id.trim())) { // errors.rejectValue("id", "required"); // } ValidationUtils.rejectIfEmptyOrWhitespace(errors, "id", "required"); ValidationUtils.rejectIfEmptyOrWhitespace(errors, "pwd", "required"); if(id==null || id.length() < 5 || id.length() > 12) { errors.rejectValue("id", "invalidLength"); } } }
- supports
- 어떤 객체를 검증하기 위해 지원할지 여부를 정한다.
- 여기서는 User 클래스인 객체를 검증하도록 설정하였다.
- validate
- target으로 객체를 받는다.(supports에서 확인하였기에 instanceof 필요X)
- validation으로 틀린 내용은 errors에 집어넣는다.
errors.reject(String errorCode)
errors.reject(String field, String errorCode)
등 존재
- 메서드에서 수동 사용
@PostMapping("/register/save") public String save(User user, BindingResult result) { // 수동 검증 - validator 직접 생성 및 validate()를 통한 검증 UserValidator userValidator = new UserValidator(); userValidator.validate(user, result); // User 객체를 검증한 결과 에러가 있으면, registerForm을 이용해 에러 출력 if (result.hasErrors()) { return "registerForm"; } return "registerInfo"; }
- 메서드 내에서 validator 객체를 만들어
validate
를 수행한다. 에러는result
에 담긴다.
- 메서드 내에서 validator 객체를 만들어
- WebDataBinder로 자동 사용
@InitBinder public void dataBinder(WebDataBinder binder) { binder.setValidator(new UserValidator()); // 로컬 validator로 등록 // binder.addValidators(new UserValidator()); // 글로벌 validator에 더해서 사용 }
- 타입변환처럼 Controller내의
@InitBinder
에 작성한다.
@PostMapping("/register/save") public String save(@Valid User user, BindingResult result) { ... }
- 객체 앞에
@Valid
를 붙이면 자동으로 등록된validator
를 수행한다. - 글로벌 validator
- 동일하게 validator 클래스를 만든 후 servlet-context.xml에 추가한다.
<annotation-driven validator="globalValidator"/> <beans:bean id="globalValidator" class="com.fastcampus.ch2.GlobalValidator"/>
- 동일하게 validator 클래스를 만든 후 servlet-context.xml에 추가한다.
- 타입변환처럼 Controller내의
✏️여담
생각보다 내용이 방대하네요. 핵심만 적을려고 했는데 배운내용 최대한 나중에 쓰기 좋게 정리한다는게 너무 길어졌어요…!! 어쩔 수 없죠. 그만큼 많이 배웠다는 소리니깐요.
포스팅을 여러개로 쪼갤까 생각도 했는데 주차별로 포스팅하는게 좋아서 그냥 통째로 했거든요. 그런데 이제와서 생각해보면 week7(1), week7(2)로 나누는것도 괜찮았을 거 같네요🤣 이미 다 써버렸으니까 이대로 포스팅!
댓글남기기