지금까지 사용자 입력 값을 검증하기 위한 여러 검증 방법과 로그인을 유지하기 위한 방법으로 쿠키, 세션에 대해 알아보았다.

 

쿠키, 세션을 사용하여 로그인을 한 사용자한테만 메인 화면을 보여주도록 설계를 했는데 만약에 로그인을 하지 않은 사용자가 직접 URL로 메인 화면을 호출을 하면 어떻게 될까?

각각의 컨트롤러에서 전부 로그인 여부를 체크하는 것이 아니라면 로그인을 하지 않아도 URL로 접근이 가능할 것이다.

 

이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 것공통 관심사라고 한다. 이러한 공통 관심사는 스프링의 AOP로 해결할 수도 있지만 웹과 관련된 공통 관심사는 서블릿의 필터 또는 스프링의 인터셉터를 사용하는 것이 좋다.

 

서블릿 필터

 

서블릿 필터는 WAS에서 서블릿(DispatcherServlet)을 호출하기 전에 작동하며, 특정 URL 요청에 대해 적절하지 않은 요청의 경우 서블릿으로 넘어가지 않도록 필터 역할을 한다.

필터는 체인 기능을 통해 여러 개의 필터를 추가할 수 있으며 다음의 메서드를 오버라이드해서 필터 인터페이스를 구현한다.

 

  • init() : 필터 초기화 메서드서블릿 컨테이너가 생성될 때 호출
  • doFilter(): 요청이 올 때 마다 호출되는 메서드로, 필터의 로직을 담당
  • destroy(): 필터 종료 메서드서블릿 컨테이너가 종료될 때 호출

 

public class LogFilter implements Filter {
	@Override
	public void init(FilterConfig filterConfig) throws ServletException {
    	...
    	}
    
	@Override
	public void doFilter(ServletRequest request, ServletResponse response,
    	FilterChain chain) throws IOException, ServletException {
        
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            String requestURI = httpRequest.getRequestURI();

            String uuid = UUID.randomUUID().toString();
            try {
            	// 다음 필터를 호출하고, 다음 필터가 없을 경우 서블릿을 호출하는 필수 로직
                chain.doFilter(request, response);
            } catch (Exception e) {
                throw e;
            } finally {
                ...
            }
    	}
        
    	@Override
    	public void destroy() {
    	    ...
    	}
}

 

이렇게 만든 필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용하는 경우 FilterRegistrationBean을 사용해서 등록하면 된다.

 

@Configuration
public class WebConfig {
	@Bean
	public FilterRegistrationBean logFilter() {
    	FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
		
        // 등록할 필터 지정
        filterRegistrationBean.setFilter(new LogFilter());
        // 필터 체인 우선 순위, 1이면 먼저 동작
        filterRegistrationBean.setOrder(1);
        // 필터를 적용할 URL 패턴
        filterRegistrationBean.addUrlPatterns("/*");
        
        return filterRegistrationBean;
    }
}

 

모든 URL에 대해 필터를 적용한다해도 홈, 회원가입, 로그인 화면, css 같은 리소스에는 접근할 수 있어야 하는데 예외적인 경로를 따로 배열로 만들어서 제외시킬 수 있다.

 

public class LoginCheckFilter implements Filter {

	private static final String[] whitelist = {"/", "/login", "/logout","/css/*"};
    
    @Override
    public void doFilter(...) {
    	try {
        	if (isLoginCheckPath(requestURI)) {
                    HttpSession session = httpRequest.getSession(false);
                
            		if (session == null || session.getAttribute("loginMember") == null) {
            			httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
                    		return;
                	}
                }
           // 세션이 없을 경우 리턴, 아니면 다음 필터로
           chain.doFilter(request, response);
        } catch (Exception e) {
        	throw e;
        } finally {
			...       
        }
    }
    
    // whitelist의 경로일 경우 인증 체크 X
    private boolean isLoginCheckPath(String requestURI) {
    	return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
	}

 

 

[참고] 인프런 김영한님 강의를 공부한 내용입니다.

로그인 상태를 유지하는 방법

 

1.  쿠키

HTTP 응답 시 쿠키에 데이터를 담아서 브라우저에 전달하면 브라우저의 쿠키 저장소에 쿠키가 저장이 되어 이후 요청을 보낼 때 쿠키를 같이 전송한다.

쿠키를 생성할 때는 일정 시간동안 유지되는 영속 쿠키, 브라우저 종료시까지 유지되는 세션 쿠키(만료 날짜 생략)가 있다. 또한 @CookieValue 애노테이션을 사용하면 편리하게 쿠키를 조회할 수 있다.

 

쿠키의 문제점

클라이언트가 쿠키 값을 임의로 변경할 수 있기 때문에 보안에 취약하다.

쿠키에는 임의의 예측 불가능한 값의 토큰을 생성해서 노출하고 서버에서는 해당 토큰을 매핑해서 중요한 데이터를 관리하는 방법이 있다.

이렇게 서버에 중요한 데이터를 보관하고 연결을 유지하는 방법을 세션이라 한다.

 

2.  세션

세션을 생성하고 sessionId로 쿠키를 생성해서 클라이언트에 전달하면, 클라이언트는 쿠키를 보관하면서 이후 요청 시 쿠키를 통해 세션 저장소에 저장된 값을 조회할 수 있고 세션이 만료되면 연결이 끊어지게 된다.

서블릿이 제공하는 HttpSession은 JSESSIONID 라는 이름의 쿠키를 생성하고 값은 추정 불가능한 랜덤 값을 가진다.

getSession(boolean create) 의 경우 기본 값이 true 옵션이라 로그인 확인을 하려면 false로 바꾸는 것이 좋다.

//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
//getSession(boolean create)
// true : 세션이 없으면 새로 생성해서 반환, false : 세션이 없으면 null 반환
HttpSession session = request.getSession(default: true);

//세션에 로그인 회원 정보 보관
session.setAttribute("loginMember", loginMember);

 

스프링이 지원하는 @SessionAttribute를 사용하면 직접 생성할 필요 없이 다음과 같이 편하게 사용할 수 있다.

 

@GetMapping("/login")
public String homeLogin (
	@SessionAttribute("loginMember", required = false) Member loginMember, Model model) {
	...
}

 

세션은 session.invalidate() 를 호출하면 삭제가 되는데 HTTP는 비연결성이기 때문에 로그아웃을 누르지 않고 브라우저를 종료할 경우 세션 데이터가 삭제되지 않고 남아있게 된다.

세션기본적으로 메모리에 생성되기 때문에 필요한 경우에 생성했다 삭제하는 것이 좋은데 서블릿의 HttpSession은 클라이언트에서 서버에 마지막으로 요청한 시간을 기준으로 일정 시간동안 세션을 유지하는 방법을 사용한다. (기본 설정 30분, 만료 시 WAS가 내부에서 해당 세션을 제거)

특정 세션 단위로 시간을 설정하거나 스프링부트를 통해 application.properties로 글로벌 설정을 할 수 있다.

 

// 특정 세션에 적용
session.setMaxInactiveInterval(1800);

// application.properties
server.servlet.session.timeout=1800

 

 

 

[참고] 인프런 김영한님 강의를 공부한 내용입니다.

Validation

 

기존 클라이언트에서 전송한 데이터 오류 발생 시 문제점

 

뷰 템플릿에서 중복으로 처리할 것이 많고 숫자 타입에 문자가 입력되는 등의 타입 오류 시 컨트롤러에 진입하기 전에 예외 발생한다.

타입 오류 발생 시에 이전에 입력한 값을 보여주기 힘들다.

 

 

BindingResult

 

스프링이 제공하는 인터페이스(Error 인터페이스 상속)로 @ModelAttribute에 데이터 바인딩 오류가 발생해도 컨트롤러가 호출된다.

addError() 메서드의 파라미터로 오류 정보를 담은 FiledError(), ObjectError()를 생성한다.

FiledError, ObjectError의 생성자는 errorCode, arguments를 가지는데 이는 오류 메시지를 생성할 때 사용된다.

 

@PostMapping("/add")
public String createItem(@ModelAttribute Item item, BindingResult bindingResult) {
    if (!StringUtils.hasText(item.getItemName())) {
                bindingResult.addError(new FieldError("item", "itemName",
        item.getItemName(), false, new String[]{"required.item.itemName"}, null,
        null));
}

errors.properties 파일을 별도로 생성 (메시지 방식과 동일함)

<!-- errors.properties -->
required.item.itemName=상품명을 입력하세요.

 

rejectValue() 를 사용하면 FieldError를 직접 생성하지 않고 오류를 관리할 수 있다.

내부적으로 MessageCodeResolver가 동작하면서 errorCode를 보고 errors.properties에서 오류 메시지를 찾는데

객체, 필드명, errorCode를 조합해서 구체적인 것에서 덜 구체적인 순으로 조회를 한다.

 

//void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
if (!StringUtils.hasText(item.getItemName())) {
	/**
    *	우선 순위
    *	1. required.item.itemName
    *	2. required.itemName
    *	3. required.java.lang.String
    *	4. required
    */
	bindingResult.rejectValue("itemName", "required");
}

 

타임리프

 

타임리프는 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능 제공한다.

  • #fields : BindingResult 가 제공하는 검증 오류에 접근할 수 있다. (th:if="${#fields.hasGlobalErrors()}")
  • th:errors : 해당 필드에 오류가 있는 경우에만 태그를 출력한다. (th:if 편의 버전)
  • th:errorclass : th:field 에서 지정한 필드에 오류가 있는 경우에만 class 정보를 추가한다.
  • th:filed : 평상시 model 객체의 값을 사용, 오류 발생 시 FieldError에 보관한 값(rejectedValue)을 사용

 


 

Validator

BindingResult의 rejectValue()로 편해지긴 했지만 컨트롤러에서 작성하는 검증 로직이 매우 복잡하다.

Validator를 사용해서 검증 로직을 별도의 클래스로 분리 가능한데, Validator를 사용해서 컨트롤러에서 검증 로직을 분리시켜도 검증 코드를 하나하나 작성하는 건 변하지 않는다.

Bean Validation을 사용하면 검증 로직을 애노테이션으로 깔끔하게 해결 가능하다.

 

Bean Validation

Bean Validation 2.0(JSR-380) 표준 기술로, 실무에서는 보통 하이버네이트 validator(구현체)를 사용한다.

  • build.gradle 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'

 

'spring-boot-starter-validation' 라이브러리를 추가하면 스프링 부트는 자동으로 Bean Validator를 스프링에 통합해준다.

필드 위에 검증 애노테이션을 입력만 하면 Bean Validation이 오류 발생 시 FeildError를 자동 생성한다.

스프링 부트는 Bean Validation을 글로벌 Validator로 사용하기 때문에 @ModelAttribute 앞에 @Validated만 추가하면 적용이 된다.

 

@Data
public class Item {
    private Long id;
    
    @NotBlank
    private String itemName;
    
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
}
@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult
bindingResult) {
	...
}

 

먼저 @ModelAttribute 각각의 필드에 타입 변환이 되면 Bean Validation이 적용되고 타입 변환에 실패할 경우 해당 필드는 typeMismatch(errorCode)로 FiledError가 생성된다. (나머지 필드는 Bean Validation 검증 시작)

 

Bean Validation은 검증 오류 발생 시 애노테이션명을 errorCode로 사용해서 FieldError 생성하기 때문에 errors.properties에 해당 애노테이션명(errorCode)로 오류 메시지를 추가해서 활용 가능하다.

 

NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank

Range.item.price
Range.price
Range.java.lang.Integer
Range

처음 바인딩 되는 {0}은 필드명, {1}, {2}.. 는 애노테이션마다 다르다. (@Range의 경우 가격 범위 바인딩)

//Bean Validation 추가

NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용

 

ObjectError의 경우는 bean Validation 적용이 복잡해서 bindingResult.reject() 로 직접 생성해주는 것이 좋다. 

Bean Validation은 각각의 경우에 따라 검증 방식을 나눌 수 있는 groups 기능을 제공하지만 별도의 폼 객체(saveForm, updateForm.. )를 만들어서 사용하는 것이 좋다.

 

 

HttpMessageConverter(@RequestBody)에도 적용이 가능한데 FieldError 객체를 JSON으로 변환해서 전달한다. (필요한 데이터만 뽑아서 별도의 API 스펙으로 객체를 만들어서 반환)

@ModelAttribute는 @Valiated 적용 시 각각의 필드에 세밀하게 적용이 되어 특정 필드에 타입 오류가 발생해도 나머지 필드는 정상 처리가 된다.

반면, HttpMessageConverter전체 객체 단위로 적용이 되기 때문에 하나라도 문제가 생기면 예외가 발생하고 컨트롤러도 호출되지 않는다.

스프링 메시지

  • 메시지를 한 곳에서 모아서 관리 (한 곳만 수정하면 전체가 수정)
  • 스프링이 제공하는 MessageSource를 스프링 빈으로 등록
  • (MessageSource는 인터페이스, 구현체인 ResourceBundleMessageSource 생성)
  • /resources/messages.properties, messages_en.properties ..

 

스프링 부트

  • 스프링 부트는 MessageSource 를 스프링 빈으로 등록하지 않아도 messages 라는 이름으로 기본 등록
  • messages.properties 에서 파라미터 바인딩 {0}, {1} ..

 

 

//application.properties
spring.messages.basename=messages,config.i18n.messages

(한글 깨짐 방지, IDE에서 *.properties 파일의 인코딩을 UTF-8로 설정)

 

타임리프의 메시지 표현식

  • 타임리프는 #{...} 를 사용하면 messages.properties에 입력해놓은 메시지를 불러올 수 있다.
<th th:text="#{label.item.itemName(${item.itemName})}">상품명</th>

<!-- messages.properties -->
<!-- label.item.itemName=name {0} -->
<!-- {0} 에 ${item.itemName}이 바인딩 -->

 

LocaleResolver

  • 스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver 라는 인터페이스를 제공
  • 스프링 부트는 기본으로 Accept-Language를 활용하는 AcceptHeaderLocaleResolver를 사용한다.
  • 만약 Locale 선택 방식을 변경하려면 LocaleResolver 의 구현체를 변경
  • 쿠키나 세션 기반의 Locale 선택 (ex. 고객이 직접 Locale 을 선택) 등

[참고] 인프런 김영한님 강의를 공부한 내용입니다.

 

 

+ Recent posts