스프링 시큐리티란?

인증, 인가 그리고 보편적인 공격들을 방어해주는 웹 보안 관련 스프링 하위 프레임워크이다.

 

작동 방식

스프링 시큐리티는 Servlet Filter기반으로 작동한다.

그리고 SecurityFilterChain 라는 이름의 스프링 빈으로 Security 설정을 관리한다.

 

그러나 Spring Bean은 스프링 컨테이너(Application Context)에서 관리되는데

Servlet Filter는 서블릿 컨테이너(톰캣)에서 관리하므로 서로의 생성주기가 다르다.

즉 스프링 빈으로 설정한 내용을 Servlet Filter에서 사용할 수 없다.

 

그래서 스프링 시큐리티는

서블릿 필터에서 서블릿 필터를 구현한 스프링 빈에게 요청을 위임하는 DelegatingFilterProxy를 사용해서 실제로 동작한다.

 

스프링 시큐리티는 SecurityFilterChain의 설정에 맞춰 필터들을 구현한 후

서블릿 필터체인 진행 중 FIlterChainProxy(DelegatingFilterProxy)가 호출되면

SecurityFilterChain이 만들어낸 스프링 시큐리티의 필터 체인을 호출해 보안 로직을 수행한다. 

 

여러개의 SecurityFilterChain을 만들어 보안 설정을 여러개 만들고 동시에 사용할 수 있으며

과거(스프링부트 3.0.0 이전)에는 WebSecurityConfigurerAdapter를 상속하는 Config class를 만들어 설정했지만

최근에는 직접 SecurityFilterChain을 빈으로 등록하며 설정한다.

 

  1. 사용자 요청
  2. ServletFilter 차례대로 동작
  3. DelegatingFilterProxy 동작
  4. FilterChainProxy 동작(요청에 맞는 SecurityFilterChain 선택)
  5. SecurityFilterChain에서 자신이 가진 각각의 필터를 차례대로 수행하며 보안 처리 수행
  6. 다음 로직 수행.

 

정확하게 어떻게 구현되었는지 코드 레벨로 뜯어보는 것은 너무 깊게 들어가는 것 같고,

  1. Spring Security는 서블릿 필터 기반으로 동작한다.
  2. 설정 파일에 SecurityFilterChain이라는 빈을 등록해 설정한다. (여러개 설정 가능)
  3. WebSecurityConfigureAdapter를 이용해 SecurityFilterChain을 생성한다. (과거의 설정 방식)

정도만 알고 넘어가면 될 것 같다.

 

SecurityFilterChain가 만들어내는 Security 필터들

SecurityFilterChain은 사용자의 설정에 따라 Security 필터들을 만들어낸다.

SecurityContextPersistenceFilter SecurityContext가 있는지 확인 (인증 전 요청인지 인증 후 요청인지 확인)하는 필터
LogoutFilter 로그아웃 요청인지 확인한 후 로그아웃 요청을 처리하는 필터
UsernamePasswordAuthenticationFilter 전달받은 Username, password를 이용해 인증 객체를 만들고 인증을 진행하는 필터
ConcurrentSessionFilter 현재 사용자의 세션 만료 여부를 체크하는 필터
RememberMeAuthenticationFilter 세션이 만료되거나 무효화 되었을 시에 동작하는 필터
AnonymousAuthenticationFilter 인증 시도나 권한 없이 특정 자원에 접근 시도시에 동작하는 필터
SessionManagementFilter 세션정보의 등록, 조회, 삭제 등을 관리하는 필터
ExceptionTranslationFilter 필터체인 동작중 발생하는 예외를 처리하는 필터
FilterSecurityInterceptor 인증이 완료된 유저의 인가 로직을 처리하는 필터

Username/password를 사용해 로그인 하고, session을 사용해 로그인을 유지하는 방식으로 설정한다면

다음과 같은 필터들이 만들어진다. 

 

이외에도 수많은 필터들이 존재하며 인증/인가 전략을 어떻게 세우느냐,

SecurityFilterChain을 어떻게 설정하느냐에 따라 만들어지는 필터들이 달라진다.

 

SpringSecurity의 핵심 요소 - Authentication, SecurityContext

1. Authenticaion이란?

스프링 시큐리티에서 사용하는 인증 인터페이스

인증 방법(username/password, oauth 등등) 에 따라 실제로 구현되고 사용되는 객체가 다르지만

이번 글에선 편의상 모두 Authentication 객체라고 부르겠다.

 

인증 전, 인증 후에 모두 사용된다.

 

인증 전 : 인증 요청시에 들어온 사용자의 정보를 담아 인증 검증을 위해 사용된다.

인증 후 : 최종 인증 결과를 담아 SecurityContext에 저장되어 전역적으로 사용 가능해진다.

  • principal: 사용자 아이디 (인증 전) /  UserDetails 객체를 저장 (인증 후)
  • credentials: 사용자 비밀번호 저장 (인증 전) / 값 없음 (인증 후)
  • authorities:  값 없음 (인증 전) / 인증된 사용자의 권한 목록 저장 (인증 후)
  • details: 인증 부가 정보
  • Authenticated: false (인증 전) / true (인증 후)

이러한 정보들을 담고 있는데 뒤의 인증 로직에서 어떤 값들이 들어가 어떻게 사용되는지

예시와 함께 다시 보면 이해가 더 쉬울 것 같다.

2. SecurityContext란?

Authentication 객체를 담고 있는 저장소로 필요할때 Authentcation 객체를 꺼내 쓸 수 있도록 제공되는 클래스이다.

ex) 사용자 인증 완료 후, 사용자 정보를 화면에 보여주고 싶음 -> 컨트롤러에서 인증한 사용자의 정보가 필요함

 

기본적으론 ThreadLocal에 저장되어 다른 Thread로부터 안전하며,

로직 아무곳에서나 참조가 가능하도록 설계되었다.

 

또 인증이 완료되고 응답을 반환했다면, HttpSession에 저장되어,

동일한 유저에게 다시 요청이 왔을때 재사용할 수 있다.

 

SecurityContextHolder는 SecurityContext 객체를 감싸는 wrapper 클래스이며,

SecurityContext를 어떻게 저장하는지 설정할 수 있다. (스레드 당 할당, 자식 스레드와 공유, 전체 공유 등등)

 

AuthenticationFilter - 인증 로직

AuthenticationFIlter은 인증을 담당하는 필터 인터페이스이다.

 

SecurityFilterChain 설정에 따라 구현되는 필터가 달라지는데

username/password를 사용해 로그인 하는 설정의 경우엔

UsernamePasswordAuthenticationFilter로 구현된다.

 

1. AuthenticationFilter는 사용자 요청으로부터 인증에 필요한 정보를 가지고 Authentication 객체를 만들어 AuthenticationManager에게 전달한다.

 

UsernamePasswordAuthenticationFilter는 사용자 요청에서

  • pricipal : username(로그인 아이디, 이메일 등)
  • credentials : 비밀번호

다음과 같은 값을 넣어 Authentication의 객체를 생성한 후 AuthenticationManager에게 전달한다.

 

 

2. AuthenticationManager는 받은 Authentication 객체를 사용해 인증을 진행한다.

 

AuthenticationManager 역시 인터페이스로 주로 ProviderManager로 구현한다.

 

ProviderManager는 Authentication 인증 객체를 받은 후 여러개의 AuthenticationProvider 중에서

해당 인증객체를 사용해 인증을 진행할 수 있는 AuthenticationProvider를 찾아 인증 처리를 위임한다. 

(username/password 인증 방식의 경우 DaoAuthenticationProvider에 넘긴다)

 

2-1. DaoAuthenticationProvider의 인증 처리 로직

 

DaoAuthenticationProvider는 UserDetailsService 객체와 PasswordEncoder 객체를 사용해 인증을 진행한다.

AuthenticationManager가 받아온 Authentication 객체엔 username(사용자 아이디, 이메일)과 password가 저장되어 있다.

 

  1. UserDetails 객체에선 Authenticaion 객체에 저장된 username의 값을 이용해 유저가 존재하는지 여부를 판단한다.
  2. 유저가 존재하지 않을 경우 UsernameNotFoundException을 발생시키고, 유저가 존재할 시 UserDetails 객체에 유저 정보(비밀번호, 사용자 권한 목록 등)을 담아 반환한다.
  3. PasswordEncoder를 사용해 Authentcaion 객체에 저장된 비밀번호와 UserDetails 객체의 비밀번호를 비교한다.
  4. 일치할 경우 인증이 성공했으므로 Authentcaion에 성공한 유저 정보 (UserDetails) 객체를 담아 반환한다.

 

3. AuthenticationManager는 인증 성공시 Authentication 객체를 SpringContext에 저장한다

 

최종적으로 만들어진 Authentication 객체는 SecurityContextHolder에 저장되어 필터가 종료된 이후에도 컨트롤러 로직 등에서 전역적으로 사용할 수 있도록 한다. (인증에 성공한 유저 정보를 컨트롤러에서 사용하도록)

 

또한 컨트롤러 로직이 모두 종료되고 요청 처리가 끝난 후 최종적으로 응답하기 전에

SecurityContextHolder의 SecurityContext 정보를 httpSession에 저장해놓은 후 응답하고,

SecurityContextHolder는 비워버린다.

 

 

FilterSecurityIntercepter - 인가 로직

1. SecurityContextPersistenceFilter에서 인증 전인지, 인증 후인지 판단한다.

세션을 이용하도록 SecurityFilterChain을 설정했다면

httpSession을 사용해 SecurityContext가 저장되어있는지 유무를 확인한다.

 

SecurityContext가 httpSession에 저장되어 있다면

세션에서 SecurityContext를 꺼내 SecurityContextHolder에 집어넣는다.

 

2. FilterSecurityIntercepter에서 인증 여부를 확인한다.

FilterSecurityIntercepter는 SecurityContext가 인증 객체(Authentication)를 가지고 있는지 확인한다.

 

3. 인증된 요청일 경우 사용자가 요청한 자원에 필요한 권한 정보를 조회한다.

인증 객체가 있는 경우 SecurityMetadataSource가 사용자가 요청한 정보의 권한 정보를 전달해준다.

요청한 정보에 접근 권한 설정이 없는 경우 바로 접근 허용한다.

 

4. AccessDecisionManager에게 권한 정보와, 사용자 정보를 보내 접근 승인 판단을 위임한다.

AccessDecisionManager에게 권한 정보 위임 후 접근 승인 판단을 위임한다.

AccessDecisionManager는 받은 권한 정보와 사용자 권한 정보를 이용해 사용자의 자원 접근을 허용/거부한다.

 

AccessDecisionManager는 AccessDecisionVoter 객체들을 사용해 승인, 거부 여부를 받은 후 최종적인 접근 결정을 내린다.

 

 

https://catsbi.oopy.io/f9b0d83c-4775-47da-9c81-2261851fe0d0

 

스프링 시큐리티 주요 아키텍처 이해

목차

catsbi.oopy.io

 

다음 글에선 세션과 username/password 방식을 이용해서 로그인 했던 기존 프로젝트에

spring security를 적용하는 방법에 대해 써보겠다.

어드민 페이지와 api에 session을 이용한 로그인 처리, Valid를 이용한 검증을 구현했고,

이젠 api와 어드민 모두 예외처리를 구현하려고 한다.

@RestController
@RequiredArgsConstructor
public class LoginController{
    
    private final UserService userService;
    
    @PostMapping("/login")
    public ResponseEntity<LoginResponse> loginUser(@Valid @RequestBody LoginRequest request,
                                                  HttpSession session){
    
        User loginUser = userService.signIn(request.getAccountId(), request.getPassword());
            
        session.setAttribute(SessionConst.LOGIN_USER, loginUser);
            
        LoginResponse response = new LoginResponse(loginUser);

        return ResponseEntity.ok(response);
    }
}

/// UserService
public User signIn(String accountId, String password){
    return userRepository.findByAccountId(accountId).filter(u -> u.getPassword().equals(password))
        .orElseThrow(NoSuchUserException::new);
}

api는 로그인 과정에서 @Valid를 이용해 입력 값을 검증한다.

이때 id나 비밀번호에 공백이 들어오는 등 검증 오류가 발생한 경우, MethodArgumentNotValidException을 발생시킨다.

그리고 유저 로그인 중에 일치하는 유저가 없을 경우, 직접 만든 NoSuchUserException을 발생시킨다.

 

이러한 예외 처리를 왜 하는 것이며, 어떻게 진행되고,

어떻게 커스터마이징 하는 것이 좋은지 공부하며 예외처리를 구현했다.

 

예외 처리와 오류 페이지 - 서블릿 예외처리

순수 서블릿은 예외처리를 어떻게 할까?

  1. Exception : 종료시 throws 있으면 상위 메소드로 계속 전달됨
    1. 자바 메인 메소드 실행 - main 쓰레드 실행됨
      1. 실행 도중 예외 잡지 못하고 main을 넘어서 예외가 던저짐?
      2. 예외 정보를 남기고 해당 쓰레드 종료
    2. 웹 어플리케이션 → 하나의 쓰레드 x, 사용자 요청별로 서블릿 컨테이너가 쓰레드 할당
      1. try catch 없이 서블릿 밖으로 예외 던져지면?
      2. 톰캣같은 WAS 서버까지 예외가 전달됨
  2. response.sendError : 호출한다고 에러 발생 x, 서블릿 컨테이너에 오류 발생 전달
    1. http 상태 코드와 오류 메세지도 전달 가능
    2. 컨트롤러에서 sendError → 인터셉터, 서블릿, 필터 → WAS로 예외 없이 전달
    3. 서블릿 컨테이너에선 응답 전에 response에 sendError 호출 확인
    4. sendError로 설정한 오류 코드에 맞춰 오류 페이지 보여줌

 

서블릿 예외 처리 - 오류 화면 작동 원리

  1. WAS는 Exception이 전달되거나 sendError 호출 기록이 있으면 오류 페이지 정보를 확인한다.
  2. ErrorPage(RuntimeException.class “/error-page/500”); 처럼 오류 페이지가 존재하면 “/error-page/500”으로 다시 요청을 보냄(HTTP 요청이 아닌 내부적인 호출)
  3. 이때 필터, 서블릿, 인터셉터, 컨트롤러가 전부 다 다시 호출됨
  4. 웹 브라우저는 이 과정을 모름 → 오직 서버 내부에서만 추가적인 호출을 함
  5. 또 단순한 요청이 아닌 request attribute에 오류 정보를 추가해서 넘겨줌 (exceptionType, error_message, request_uri, servletname, 등등)
  6. 이때 에러 경로만 처리하는 컨트롤러를 내부에 만들고, ModelAndView를 반환하게 하면 상황에 따른 오류 페이지를 제공할 수 있음

 

스프링 부트 오류 페이지

  1. 스프링 부트는 /error를 기본 경로로 기본 오류 페이지 설정
  2. 서블릿 밖으로 에러 던져짐 or respose.sendError 시에 기본 오류 페이지 호출
  3. 스프링 부트는 BasicErrorController를 자동으로 등록함 → /error 매핑해서 처리하는 컨트롤러
  4. 개발자는 오류 페이지만 등록하면 됨 (처리 순서)
    • 뷰 템플릿 (/resources/templates/error/500.html)
    • 정적 리소스(/resources/static/error/500.html)
    • 적용 대상 없을 시 기본 뷰 (/resources/templates/error.html)
    • 4xx.html 식으로 통합해서도 사용 가능 (구체적인게 더 우선순위 높음)
  5.  BasicErrorContorller는 다음과 같은 정보 기본 제공
    • timestamp : 시간
    • status : 상태 코드 400,500
    • error : 어떤 에러인지
    • exception : 어떤 예외 클래스인지
    • trace : 예외 trace? - 어디서 발생했는지 경로 쭉 훑음
    • message : 오류 메세지
    • errors : ?
    • path : 클라이언트 요청 경로
  6.  근데 고객이 오류 정보를 아는 것은 보안상 안좋음
    1. application.properties에서 오류 정보에 포함할지 안할지 선택 가능
    2. 사용자는 간단한 오류 페이지만, 오류 로그는 서버에 남기자

API 예외 처리

api 예외는 어떻게 처리해야할까?

api는 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 보내야 한다.

그러나 아무런 설정을 하지 않은 상태에서 오류가 발생하면 body에 오류 페이지 html이 전송된다.

오류 페이지 컨트롤러도 json을 보내도록 수정해야한다.

 

HandlerExceptionResolver

  1. 스프링 mvc는 핸들러 밖으로 예외 던져지면 예외 해결 & 동작 지원
  2. 동작 방식 변경을 원함 (런타임 에러에서 500이 아닌 404 Http Status를 제공하고 싶다.)
  3. 컨트롤러에서 예외 있으면 인터셉터의 posthandle이 호출안됨
  4. 그 자리에서 ExceptionResolver가 호출, 예외 해결 시도
  5. 해결하면 정상 응답 나감

스프링이 제공하는 exceptionResolver

  1. ExceptionHandlerExceptionResolver : @ExceptionHandler 처리
  2. ResponseStatusExceptionResolver : Http 상태 코드 지정 @ResponseStatus
  3. DefaultHandlerExceptionResolver : 스프링 내부 기본 예외 처리

@ExceptionHandler

  1. 컨트롤러에 메소드에 @ExceptionHandler 어노테이션 달고 에러 파라미터로 받기
  2. 컨트롤러 안에서 해당 예외 터질시 Resolver 호출
  3. 에러를 정상 흐름으로 바꿔서 전송해줌
  4. @ResponseStatus 컨트롤러 메소드에 붙이면 상태 코드도 바꾸기 가능
  5. 이러면 예외시에도 서블릿 컨테이너 다시 호출 안함, 정상 흐름으로 리턴
  6. 그냥 Exception을 처리하도록 하면 → 마지막까지 처리 안된 나머지 예외 공통 처리
  7. 지정한 예외와 그 자식클래스까지 잡을 수 있음
  8. 스프링 컨트롤러처럼 여러 파라미터 넣어서 사용 가능

@ControllerAdvice

@Slf4j
@RestControllerAdvice
public class ControllerAdvice{
    
    @ExceptionHandler(NoSuchUserException.class)
    public ResponseEntity<ErrorResponse> invalidUserHandler(NoSuchUserException e){
        log.info("NoSuchUserException");
        
        ErrorResponse response = new ErrorResponse("404", e.getMessage());
        
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(response);
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class) // spring Valid
    public ResponseEntity<ErrorResponse> ValidateErrorHandler(MethodArgumentNotValidException e){
        //에러가 여러개면 첫번째 에러만 반환하도록
        String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
        
        ErrorResponse response = new ErrorResponse("404", message);
        
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(response);
    }
}

exceptionHandler를 컨트롤러에 사용하면 정상 코드, 예외 코드 처리가 하나의 컨트롤러에 들어가 있음

@RestControllerAdvice를 사용해서 분리하면 됨

대상 지정 안하면 모든 컨트롤러에 적용됨

 

 

 

인증과 인가?

인증 : 사용자의 신원을 검증하는 프로세스, ID와 PW를 이용해 로그인 하는 것이 인증이라고 할 수 있다.

인가 : 인증된 사용자가 어떤 자원에 접근 권한을 가지고 있는지 확인하는 절차

 

지난 강의에서 세션을 이용해 인증 기능을 구현했지만, 사용자가 url만 안다면 로그인 없이 모든 화면과 기능을 사용할 수 있었다.

이번 강의에선 필터와 스프링 인터셉터를 사용해 인가 기능을 구현했다.

 

필터와 인터셉터

웹에서 공통 관심사항을 해결하기 위한 기능들.

AOP도 가능하지만 더 편리한 기능들을 제공함.

 

필터

  • 서블릿이 제공
  • 적용할 url 범위 지정 가능
  • 제한 기능 가능 (적절하지 않은 요청이라고 판단되면 서블릿, 컨트롤러 호출 안함)
  • 필터 체인 (여러개의 필터 연속해서 적용) 구성 가능
  • 필터 인터페이스를 구현하고 등록하면 스프링이 싱글톤으로 관리해줌

인터셉터

  •  스프링 mvc에서 제공
  • 컨트롤러 호출 직전에 호출됨, 서블릿 url과 다르게 매우 정밀하게 제어 가능
  • 제한 기능 가능 (적절하지 않은 요청이라고 판단되면 컨트롤러 호출 하지 않을 수 있음)
  • 체인 기능도 제공
  • 핸들러와 모델,뷰 정보까지 받을 수 있음

 

호출 순서

Http request → WAS → 필터 → 디스패처 서블릿 → 스프링 인터셉터 → 컨트롤러

 

인가 구현 - 필터

@Slf4j
public class LoginCheckFilter implements Filter{
    
    private static final String[] whiteList = {"/", "/admin/users/create-form", "/admin/login", "/css/*"};
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException{
  
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        
        try{
            //사용자 인증 시작
            if(isLoginCheckPath(requestURI)){ //경로 체크
                HttpSession session = httpRequest.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_USER) == null){ //세션에 없음
                    log.info("위임 전 사용자 요청");
                    httpResponse.sendRedirect("/admin/login?redirectURL=" + requestURI);
                    return; // 미인증 사용자 요청 -> 다음 적용 x
                }
            }
            chain.doFilter(request, response);
        } catch (Exception e){
            throw e; // 예외 로깅 가능, 근데 톰캣까지 보내줘야함
        } finally {
        }
    }
    
    //화이트 리스트 안의 url이면 체크 안함
    private boolean isLoginCheckPath(String requestURI){
        return !PatternMatchUtils.simpleMatch(whiteList, requestURI);
    }
}
//설정 파일에 스프링 빈으로 필터 등록
    @Bean
    public FilterRegistrationBean logFilter(){
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<> ();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        filterRegistrationBean.addUrlPatterns("/*");
        
        return filterRegistrationBean;
    }

Filter 인터페이스를 구현한 후 Configuration 에 수동으로 FilterRegistrationBean을 등록하면 된다.

구현해야할 메소드는 3가지가 있는데

  1. init - 필터가 시작될 때
  2. doFilter - 필터의 기능
  3. destroy - 필터가 소멸될 때

모든 메소드는 default로 만들어져서 사용할 메소드만 구현하면 된다.

doFilter만 이용해서 인가를 구현했다. 

 

whiteList (로그인되지 않아도 접근 허용할 url)를 만들어 둔 후

HttpServletRequest에서 session 값을 받아와서 회원정보가 확인되면 doFilter(다음 필터, 컨트롤러 호출)를 호출,

확인되지 않을 시 Response에 redirect를 지정한 뒤 바로 doFilter를 호출하지 않고 끝내 컨트롤러를 호출하지 않도록 했다.

 

인가 구현 - 인터셉터

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor{
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
        
        String requestURI = request.getRequestURI();
            
        HttpSession session = request.getSession(false);
        
        if (session == null || session.getAttribute(SessionConst.LOGIN_USER) == null){ //세션에 없음
            log.info("위임 전 사용자 요청");
            response.sendRedirect("/admin/login?redirectURL=" + requestURI);
            return false;
        }
        return true;
    }
}
@Configuration
public class AppConfig implements WebMvcConfigurer{
    @Override
    public void addInterceptors(InterceptorRegistry registry){
        registry.addInterceptor(new LoginCheckInterceptor())
            .order(1)
            .addPathPatterns("/**")
            .excludePathPatterns("/", "/admin/users/create-users", "/admin/login", "/css/**", "/*.ico","/error");
    }
	...

인터셉터 인터페이스를 구현한 후 Configuration에서 addInterceptors를 구현하면 된다.

인터셉터가 구현해야할 메소드도 3가지가 있다.

  1. preHandle - 컨트롤러 호출 전
  2. postHandle - 호출 후 ( 에러시 호출 안됨)
  3. afterCompletion - 요청 완료 후 - 뷰 호출할때 (에러 여부와 상관없이 항상 호출)

인터셉터 역시 모든 메소드가 default로 만들어져서 사용할 메소드만 구현하면 된다.

preHandle만 이용해서 인가를 구현했다. 

 

HttpServletRequest에서 session 값을 받아와서 회원정보가 확인되면 true를 

확인되지 않을 시 Response에 redirect를 지정한 뒤 false를 호출했다.

스프링 인터셉터는 preHandle에서 false를 반환하면 컨트롤러를 호출하지 않고 끝내버린다.

 

화이트리스트 기능은 설정 파일에서 addPathPatterns, excludePathPatterns를 이용해 필터보다 상세하게 구현이 가능하다.

 

필터 vs 스프링 인터셉터

확실히 구현 코드만 봐도 특별한 이유가 없다면 스프링 인터셉터를 사용해야할 것 같다.

  • 세밀하고 상세한 url 경로 설정 가능 및 경로 설정을 설정파일에서 따로 함(관심사의 분리)
  • 구현의 편리함 (HttpServletRequest를 변환할 필요 없음)
  • 더 많은 확장 기능 구현 ( 핸들러와 모델,뷰 정보까지 받을 수 있음 )

만약 컨트롤러에서 진행되는 로직이 아닌 스프링과 무관하게 전역적으로 처리되는 로직이라면

필터로직이 인터셉터보다 먼저 진행되므로 필터를 사용하는 것도 괜찮은 것 같다.

 

또 인터셉터 대신 AOP를 사용하면 되지 않나 싶지만

  • 컨트롤러는 타입, 실행 메소드, 파라미터, 리턴값이 일정하지 않다.
  • AOP에선 HttpServletRequest를 받아오기 힘들다.

라는 이유때문에 컨트롤러의 호출 과정에 적용되는 경우는 인터셉터가 더 나은 것 같다.

 

번외 ) 세션에서 회원 정보 받아오는 ArgumentResolver 어노테이션으로 구현하기

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login{
}

@Slf4j
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver{
    
    @Override
    public boolean supportsParameter(MethodParameter parameter){
        log.info("suppoersParameter");
        
        boolean hasLogin = parameter.hasParameterAnnotation(Login.class); //로그인 어노테이션이 파라미터에 있는가
        boolean hasUserType = User.class.isAssignableFrom(parameter.getParameterType()); //유저를 파라미터로 받는가
        
        return hasLogin && hasUserType;
    }
    
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        
        log.info("resolveArgument");
        
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        
        HttpSession session = request.getSession(false);
        if (session == null){
            return null;
        }
        return session.getAttribute(SessionConst.LOGIN_USER);
        
    }
}
@Configuration
public class AppConfig implements WebMvcConfigurer{  
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginUserArgumentResolver());
    }
  1. Argument Resolver 활용
    1. @Login라는 어노테이션 직접 만들기
    2. Login 이라는 어노테이션 만들어서 로그인 됐는지 확인
    3. argumentResolver를 직접 만들어서 등록함
    4. Target : 사용 범위
    5. Retention : 어노테이션 정보를 남길 기간 - 거의 런타임만 씀

어드민 사이트에 로그인 기능을 구현해봤다.

로그인 기능을 구현하는 방법은 여러가지가 있는데 크게 세션, JWT, Oauth 방식들이 있다.

 

이번 강의에선 가장 기초적이고 구현하기 쉬운 세션 방식으로 로그인을 구현하는 방법을 다뤘다.

 

세션 방식이란?

로그인 시 서버에서 Map과 같은 형식으로 클라이언트에 세션id 값과 로그인 유저 정보 값을 저장한 후

세션 id 값만 http header 쿠키에 담아 클라이언트에 전달하는 방식.

 

클라이언트는 받은 쿠키를 요청이 있을때마다 지속적으로 전달하고,

서버는 쿠키에서 전달된 id 값을 세션에서 확인해 로그인을 유지하는 방식이다.

 

세션이 수행하는 일은 크게 3가지이다.

1. 세션 생성 (로그인 정보를 저장하고 id 값을 쿠키에 담아 반환)

2. 세션 조회 (받아온 쿠기의 id값이 세션에 있는지 확인)

3. 세션 삭제 (로그아웃시 세션에서 해당 정보 삭제)

1. Session 직접 구현하기

@Component
public class SessionManager{
    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
    
    public void createSession(Object value, HttpServletResponse response){
        //세션 id 생성하고 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);
        
        Cookie cookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        cookie.setPath("/");
        response.addCookie(cookie);
    }
    
    //세션 조회
    public Object getSession(HttpServletRequest request){
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        
        if (sessionCookie == null){
            return null;
        }
        return sessionStore.get(sessionCookie.getValue());
    }
    
    private Cookie findCookie(HttpServletRequest request, String cookieName){
        Cookie[] cookies = request.getCookies();
        
        if (cookies == null){
            return null;
        }
        return Arrays.stream(cookies)
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }
    
    //세션 만료
    public void expire(HttpServletRequest request){
        Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
        
        if (cookie != null){
            sessionStore.remove(cookie.getValue());
        }
    }
}

 

UUID와 ConcurrentHashMap을 이용해서 세션을 직접 구현했다.

 

세션에서 주의할 점은

  1. 세션에서 쿠키로 전달되는 값은 클라이언트가 임의로 변경할 수 있다.
  2. 클라이언트에 저장되는 쿠키는 다른 사용자에게 탈취당할 수 있다.

그러므로 쿠키 내용으로 들어가는 값(클라이언트에게 전달하는 값)은 항상

비연속적이고, 예측 불가능하며, 주요 정보가 들어가선 안된다.

 

또한 세션의 유효기간을 짧게 설정하여(보통 30분)

쿠키가 탈취되더라도 다른 곳에서 사용되는 것을 최소화하고 메모리 과부화를 막아야한다.

 

직접 구현할때는 UUID를 사용했다.

그러나 실제로 이렇게 구현하는 것은 귀찮고 번거로우니까 HttpSession기능을 사용하자.

 

2. HttpSession 사용

@Controller
@RequiredArgsConstructor
public class AdminController{
    
    // private final SessionManager sessionManager;
    private final UserService userService;
    
    @GetMapping("/") //홈 화면
    public String newHome(@SessionAttribute(name=SessionConst.LOGIN_USER, required=false) User findUser,
                          Model model){
        
        if (findUser == null){
            return "admin/home";
        }
        
        model.addAttribute("userName", findUser.getName());
        return "admin/menu";
    }
    
    @GetMapping("/admin/login") //로그인 폼
    public String loginForm(Model model) {
        model.addAttribute("signInRequest", new SignInRequest());
        return "admin/login";
    }
    
    @PostMapping("/admin/login") //로그인 로직
    public String login(@Valid @ModelAttribute SignInRequest signInRequest,
                       BindingResult bindingResult,
                       HttpServletRequest request){
        
        if(bindingResult.hasErrors()){ // 검증 실패
            return "admin/login";
        }
        else { //검증 성공
            try{
                User loginUser = userService.signIn(signInRequest.getAccountId(), signInRequest.getPassword());
        
                //HttpSession 사용 로그인 정보 저장
                HttpSession session = request.getSession();
                session.setAttribute(SessionConst.LOGIN_USER, loginUser);
                
                return "redirect:/";    
            }
            catch(NoSuchUserException e){ //아이디나 비밀번호 틀림
                bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
                return "admin/login"; 
            }
        }
    }
    
    @PostMapping("/admin/logout")
    public String logout(HttpServletRequest request){
        //세션에서 삭제
        HttpSession session = request.getSession(false);
        
        if (session != null){
            session.invalidate();
        }
        
        return "redirect:/";
    }
}

HttpSession은 서블릿이 제공하는 session 기능이다.

기본 쿠키 이름은 JSESSIONID이다.

 

HttpServletRequest.getSession(create=true)

  • HttpSession 객체를 가져온다.
  • create 옵션을 지정할 수 있는데 default는 true이다.
  • create 옵션이 true이면 세션이 존재하지 않을시 생성하며, false이면 null을 반환한다.

HttpSession.setSessionAttribute(name, value)

  • 세션에 데이터를 저장한다.
  • name값은 나중에 세션에서 정보를 꺼내올 때 사용된다.

@SessionAttribute(name=..., required=true) Object value

  • 쿠키 값을 이용해 세션에서 데이터를 조회해온다.
  • HttpServletRequest.getSession().getSessionAttribute()와 예외처리 로직을 편하게 수행하는 어노테이션이다.

기본 저장기간은 마지막 사용으로부터 30분이며

application.yml 파일을 수정해 세션의 저장기간을 바꿀 수 있다.

지난번에 공부했던 Validator와 @Validated를 이용한 검증 방법은 구현과 사용 과정에서 공통적이고 반복적인 일들이 많았다.

그리고 사실 대부분의 검증 로직은 null 체크, 빈 문자열 체크 등 제한적이고 일반적인 로직이 대부분이다.

 

java는 어노테이션을 이용한 Bean Validation 기능을 제공하며, 스프링과 통합해서 사용하기 굉장히 편하다.

Bean Validation은 특별한 구현체를 통칭하는 것이 아닌 여러 어노테이션과 인터페이스의 모음을 이야기하며

구현체는 여러 종류가 있는데 주로 Hibernate Validator를 사용한다.(Spring boot 이용시에도 기본으로 제공한다.)

 

Bean Validation 사용 방법

@Getter 
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CreateUserRequest{
    @NotBlank
    private String accountId;
    
    @NotBlank
    private String password;
    
    @NotBlank
    private String name;
}

이렇게 dto 혹은 엔티티에 @NotBlank, @NotNull 등의 어노테이션을 달아주기만 하면 간편하게 사용할 수 있다.

    //유저 생성
    @PostMapping("/admin/users/create-users")
    public String createUser(@Valid CreateUserRequest createUserRequest, 
                             BindingResult bindingResult){
        
        if(bindingResult.hasErrors()){ // 검증 실패
            return "admin/users/users-create-form";
        }
        else { //검증 성공
            User user = createUserRequest.toUser();
            Long userId = userService.register(user);

            return "redirect:/admin/users/read-users";    
        }
    }

컨트롤러에선 ModelAttribute앞에 @Valid 혹은 @Validated 어노테이션을 달아주기만 하면 된다.

 

Bean Validation의 작동 방식

  1. springboot-stater-validation 라이브러리를 등록한다.
  2. 스프링이 LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
  3. @Valid, @Validated를 컨트롤러에 달아주면 어노테이션을 기반으로 검증한다.
  4. 검증 오류시 FieldError, ObjectError 생성해서 BindingResult에 담아준다.
  5. 검증 순서
    1. ModelAttribute로 각각 필드에 타입 변환 시도
    2. 성공하면 다음, 실패하면 “typeMismatch”로 FieldError 추가
    3. Validator 적용(실패한 필드는 적용 x)
    4. 어노테이션 따라서 검증
  6. 에러 코드 출력 방식
    1. 에러 코드 메세지는 bindingResult.rejectValue(”어노테이션 이름”)과 동일하게 등록된다.
    2. 어노테이션 이름를 기반으로 MessageCodeResolver가 메세지 코드 배열을 만들어준다.
    3. @...(message="...") 를 사용하면 defaultMessage를 설정해줄 수 있다.
    4. 1,2,3이 전부 없으면 라이브러리 제공 기본값 사용해 제공한다.

 

오브젝트 오류의 경우 @ScriptAssert()를 사용해서 처리할 수 있지만 제약이 많아서 실무에서 잘 사용하지 않는다.

강의에선 그냥 자바 코드를 이용해서 직접 검증한 후 bindingResult에 담는 것을 추천했다.

 

@Valid와 @Validated의 차이점?

@Valid

  • 자바 표준 스펙
  • ArgumentResolver를 통해 진행된다.
  • 컨트롤러에서만 사용 가능하다

@Validated

  • 스프링 프레임워크에서 지원
  • AOP기반으로 검증을 지원한다.
  • 스프링 빈으로 등록된 모든 클래스에서 활용할 수 있다.
  • 유효성 검증 그룹 기능을 지정할 수 있다.

 

Bean Validation - Http Message Converter

    @PostMapping("/users")
    public ResponseEntity<Object> registerUser(@Valid @RequestBody CreateUserRequest request, 
    						BindingResult bindingResult){
        
        if(bindingResult.hasErrors()){ // 검증 실패
            return ResponseEntity.badRequest().body(bindingResult.getAllErrors());
        }
        else{ // 검증 성공
            User user = request.toUser();
        
            Long userId = userService.register(user);
        
            return ResponseEntity.ok(userId);    
        }
    }

form 형식이 아닌 Request Body로 들어오는 데이터 또한 Bean Validation을 적용할수 있다.

@RequestBody 어노테이션이 붙은 객체 앞에 @Valid 또는 @Validated를 사용해 검증하면 된다.

 

그러나 object type error 등과 같이 객체 바인딩 과정에서 오류가 발생하면

ModelAttribute와 다르게 컨트롤러 호출이 안된다.

 

ModelAttribute : 필드 단위로 세밀하게 적용 → 타입 안맞아도 나머지 필드 처리 가능

→ bindingResult 달면 컨트롤러 로직 호출 가능

 

RequestBody : HttpMessageConverter단계에서 JSON 객체 변경 못하면 나머지 필드 다 호출 불가능

→ 컨트롤러, Validator 다 사용 불가능

 

 

내가 정말 궁금해 하던 부분이 나왔다.

아니 사실 이거랑 api 예외처리 공부하려고 mvc 강의를 듣기 시작했다.

 

예전에 장고로 칵테일 레시피 사이드 프로젝트를 할 때

재료 양을 ml로 받는 요청에 문자를 넣어도 db에 그대로 저장되는 참사가 있었다.

 

Validation(유효성 검사) 이란?

사용자(Client)가 서버에 보내는 요청에 담긴 데이터가 유효한지(양식이 정확한지, 따로 합의해놓은 기준을 만족하는지 등) 확인하는 과정을 말한다.

 

보통 프론트와 백에서 둘 다 검증을 진행한다.

프론트에서 검증을 하면 서버에서 검증을 위해 동작하는 네트워크 비용과 시간을 아낄 수 있다. 

그러나 프론트의 검증은 사용자가 고의로 변경해서 우회할 수 있다.

DB의 데이터를 안전하고 정확하게 관리하기 위해선 백엔드에서 검증은 필수이다.

 

당장 프론트 개발자로 취업할건 아니니까 css 깨지는건 그냥 넘어가자

유저 서비스에서 회원가입을 하기 위해선 이름, 아이디, 비밀번호를 폼으로 입력받는다.

지금까지 개발한 어플리케이션 로직은 서비스단에서 아이디가 중복인지는 체크하지만

이름, 비밀번호에 null값, 빈 문자열이 들어와도 그대로 저장했다.

 

이름, 아이디, 비밀번호에 공백을 입력하고 form을 넘기면 오른쪽 그림처럼 오류 화면을 띄우는 것이 오늘의 목표다.

 

BindingResult란?

Validation(검증) 오류를 보관하는 객체이다.

 

스프링에서 폼에서 넘어온 데이터를 ModelAttribute를 통해 바인딩해서 받아오면

바인딩 과정에서 오류(int 타입에 문자가 넘어오는 등)가 발생할 경우

컨트롤러를 거치지 않고 그냥 400 오류화면을 보내버린다. 

    //유저 생성
    @PostMapping("/admin/users/create-users")
    public String createUser(@ModelAttribute("request") CreateUserRequest request, 
    				BindingResult bindingResult,
                    		Model model){
        ...
    }

그러나 컨트롤러의 파라미터로 BindingResult가 있을 경우

스프링은 BindingResult에 오류를 담아서 컨트롤러를 호출한다.

 

BindingResult에 저장되는 에러는 FieldError와 ObjectError가 있다.

 

1. FieldError : 검증하려는 객체의 특정 필드에 에러가 있는 경우 사용한다. ( int 타입 필드에 문자가 들어옴, null이 허용되지 않는 필드에 null이 들어옴 등등) 

new FieldError(”objectName”, “field”, “rejectedValue" , bindingFailure(boolean) , codes,  arguments, "default message")

로 직접 만들 수 있다.

 

2. ObjectError : 특정 필드에서 나오는 에러가 아닌 여러 필드가 복합적으로 작용한 오류 (상품 개수 * 가격 >= 10000원 이상이어야함 등)

new ObjectError(”objectName”, codes, arguments, "default message")로 직접 만들 수 있다.

 

rejectedValue : 오류가 발생한 사용자에게 돌려줄 값

bindingFailure : 타입 에러 등 Binding 과정에서 에러가 발생했는지 여부

codes : error 메세지를 spring messages 기능을 통해 제공하는 파라미터, 문자열 배열을 입력받는다.

arguments : messages 기능에 들어갈 파라미터

//유저 생성
@PostMapping("/admin/users/create-users")
public String createUser(@ModelAttribute("request") CreateUserRequest request, 
			BindingResult bindingResult,
			Model model){
	if (!StringUtils.hasText(request.getAccountId())){
		bindingResult.rejectValue("accountId", null, null, "아이디는 필수 값입니다.");
        
	}

	if (!StringUtils.hasText(request.getAccountId())){
		bindingResult.rejectValue("accountId", "required", null, null); //messages 기능 사용
	}
    
	if(bindingResult.hasErrors()){ // 검증 실패
		return "admin/users/users-create-form";
	}
    ...
}

이때 BindingResult는 반드시 검증하려는 객체의 바로 뒤에 위치해야한다.

BindingResult는 바로 앞의 파라미터에 위치하는 객체를 target으로 지정해놓고,

Error 객체를 직접 만들고, model에 자동으로 반환해주는 등 검증에 필요한 편의 기능들을 지원한다.

 

다음과 같이 bingingResult.rejectValue(), reject()를 통해 FieldError, ObjectError를 쉽게 생성할 수 있다.

rejectValue에는 FieldError와 다르게 objectName을 적을 필요가 없으며 (target인 객체의 이름으로 저장된다)

messages기능을 위한 codes에 문자열 배열이 아닌 문자열을 직접 입력받는다.

 

축약된 codes는 MessageCodesResolver가 문자열 배열로 전환해주며

타임리프에서 문자열 배열을 읽어 우선순위에 따라 message를 연결해준다.

 

그리고 오류가 난 경우 입력 폼으로 다시 되돌아가서 어느 필드에서 오류가 발생했는지를 알리는데

ModelAttribute를 쓰면 들어온 입력 값이 담긴 객체가 그대로 model에 추가되서 다시 입력폼으로 전달할 수 있다.

그리고 BindingResult에 들어온 Error 객체 또한 그대로 model에 자동으로 추가된다.

 

타임리프에서 th:errors="*{fieldName}"을 사용하면 그 필드에 에러가 있을 시에만 태그가 보이게 설정할 수 있다.

(bindingErrors에서 그 필드를 대상으로 FieldErrors를 만들어 전달했을때.)

 

그리고 예외처리 로직이 커졌을 경우 컨트롤러에 남겨두는 것 보다는

Validator 클래스를 만들어 분리하는 것이 좋다.

 

//UserValidator
@Component
public class UserValidator implements Validator{
    
    @Override
    public boolean supports(Class<?> clazz){
        return CreateUserRequest.class.isAssignableFrom(clazz);
    }
    
    @Override
    public void validate(Object target, Errors errors){
        CreateUserRequest request = (CreateUserRequest) target;
        
                // === 검증 로직 ===
        if (!StringUtils.hasText(request.getAccountId())){
            errors.rejectValue("accountId", "required", null, null);
        }
        if (!StringUtils.hasText(request.getName())){
            errors.rejectValue("name", "required", null, null);
        }
        if (!StringUtils.hasText(request.getPassword())){
            errors.rejectValue("password", "required", null, null);
        }
    }
}
//Controller
@Controller
@RequiredArgsConstructor
public class AdminUserController{

    private final UserValidator userValidator;
    
    @InitBinder
    public void init(WebDataBinder dataBinder){
        dataBinder.addValidators(userValidator);
    }
    
    @PostMapping("/admin/users/create-users")
    public String createUser(@Validated @ModelAttribute("request") CreateUserRequest request, BindingResult bindingResult, Model model){
        
        if(bindingResult.hasErrors()){ // 검증 실패
            return "admin/users/users-create-form";
        }
        else { //검증 성공
            User user = request.toUser();
            Long userId = userService.register(user);
            //redirect 해야함
            return "redirect:/admin/users/read-users";    
        }
    }
    ...
 }

컨트롤러에선 WebDataBinder을 이용해 userValidator를 컨트롤러에서 사용하도록 등록할 수 있고

@Validated 어노테이션을 달아 검증 로직을 자동으로 적용할 수 있다.

 

 

 

 

 

 

 

 

 

 

갑자기 화면에 나오는 메세지를 한 번에 변경해야 하는 상황이 오면 어떻게 될까?

페이지가 100개, 1000개라면 모두 하나씩 변경해야하는데 생각만 해도 어지럽다.

 

타임리프 메세지 기능을 이용해 화면에 나오는 메세지들을 한 파일에 모아놓고 관리할 수 있다.

 

스프링 메세지 소스 설정

  1. 스프링에서 메세지 기능을 이요하려면 MessageSource를 스프링 빈으로 등록해야 한다.
  2. 하지만 스프링부트가 자동으로 등록해주므로 우리는 설정만 관리하면 된다.
  3. application.yml 에서 메세지 소스 파일 설정이 가능하다.
//aplication.yml

spring:
  messages:
    basename: message //기본 설정
    //basename: project/config/message

웹에 메세지 기능 사용하기

//messages.properties

admin=어드민
service.user=[유저 서비스]
service.schedule=[스케줄 서비스]
service.workspace=[워크스페이스 서비스]
service.devLog=[개발일지 서비스]
<div class="header">
    <h1><a href="/" th:text="#{admin}">Admin</a></h1>
    <p><a href="/admin/devLogs" th:text="#{service.devLog}">[개발일지 서비스]</a></p>  
</div>

다음과 같이 messages.properties를 만들어 설정해놓은 후

html 파일에서 th:text="#{...}"를 이용해 사용할 수 있다.

 

//messages_en.properties

admin=Admin
service.user=[User Service]
service.schedule=[Schedule Service]
service.workspace=[Workspace Service]
service.devLog=[DevLog Service]

messages_en.properties 등의 언어에 따른 파일을 분리해서 만들면

스프링이 LocaleResolver가 자동으로 국제화를 적용해준다.

 

따로 설정하지 않을 시 기본으로 Accept_Language 헤더를 읽어 적용해준다.

타임리프 기본 사용법

텍스트 출력

  1. html 콘텐츠에 내용 출력 → th:text=”${data}”
  2. 컨텐츠 안에서 직접 출력 = [[${data}]]

Espace 기본 제공

  1. html은 <, > 등등의 특수문자 기반으로 화면 생성
  2. html 엔티티 : <, >를 문자로 표현하는 방법 = &lt, &gt
  3. th:text, [[${data}]] 는 기본적으로 이스케이프 제공
  4. th:utext, [(${data})] → 이스케이프 제공 x

기본 객체 제공

  1. ${#request}
  2. ${#response}
  3. ${#session}
  4. ${#servletContext}
  5. ${#locale}

연산 기능 제공

  1. html 안에서 연산을 사용할 수 있다.
  2. th:text=”1+3” → 4가 화면에 나옴
    1. &gt, gt,, ge, ≥, ==, +, -, %, *, ≠ 다 사용 가능
    2. th:text=”(10 % 2 == 0)? ‘짝수’ : ‘홀수’ “ (조건식 사용 가능)
  3. Elvis 연산자
    1. 데이터 있는지 없는지에 따라 다른 값이 나오도록 할 수 있다.
    2. th:text=”${data}? ‘데이터가 없습니다’ ”
    3. 조건식의 축약 버전
  4. No- Operation
    1. th:text=”${data}? _ ”
    2. 데이터 없으면 타임리프가 렌더링을 안한다.

속성 값 설정

  1. th:* 사용해서 html 태그 내의 속성 값 설정
  2. 기존 속성 있으면 대체, 없으면 새로 만듦 - 기본 값 만들어놓고 디버깅 가능
    1. th:attrappend : 속성 뒤에 값 추가
    2. th:attrperpend: 속성 앞에 값 추가
    3. th:classappend: 클래스 뒤에 값 추가
  3. checked 처리
    1. html checked → true,false 상관없이 그냥 체크 표시 나옴
    2. th:checked=”false”면 checked 지워버림

반복

  1. 반복 상태 유지
    1. th:each 사용하면 됨
    2. List, Map, 배열, Iterable 등등 다 가능
    3. “user, userStat : ${userList}”
    4. userStat에 현재 반복 상태 알려줌, 생략 가능 → +Stat으로 들어감
      1. userStat.index : 0 부터 시작하는 인덱스
      2. count: 1 부터 시작함
      3. size: 전체 사이즈
      4. event, odd : 홀수, 짝수 여부
      5. first, last: 처음, 마지막 여부
      6. current 현재 객체

템플릿 조각과 레이아웃

<div th:fragment="users_header (title)" class="title">
    <div class="header">
        <h1><a href="/">Admin</a></h1>
        <p><a href="/admin/users">[유저 서비스]</a></p>
    </div>
    <p th:text="${title}"></p>
</div>
<!DOCTYPE HTML>
<html xmlns: th=”http://www.thymeleaf.org”>
<head>
    <meta charset="utf-8">
    <title>BlazingDevs Calender!</title>
    <link th:href="@{/css/menu.css}" rel="stylesheet" />
</head>
<body>
    <div th:replace="~{template/fragment/header :: users_header ('유저관리')}" class="title">
        <h1>유저 관리</h1>
    </div>
    ...
</body>
</html>

th:fragment를 사용하면 템플릿을 조각처럼 만들어놓고 사용할 수 있다.

네비게이션 바와 같은 여러 템플릿에서 공유하는 부분을 만들어 놓고 사용하면 편할 것 같다.

 

html 태그에 fragment를 사용하여 기본 레이아웃을 만들어 놓고 사용할 수도 있다.

 

스프링 통합 기능

th:object, th:field를 사용하면 템플릿에 객체를 넘겨주고 form 태그를 편리하게 사용할 수 있다.

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CreateScheduleRequest{
    private String workspace;
    private String name;
    private List<String> users;
}
    @GetMapping("/admin/schedules/create-schedules")
    public String createScheduleForm(Model model){
        
        List<Workspace> workspaces = workspaceService.findAll()
        List<User> users = userService.findAll();
        
        List<String> workspaceList = workspaces.stream()
            .map(w -> w.getName())
            .collect(toList());
        
        List<String> userList = users.stream()
            .map(u -> u.getAccountId())
            .collect(toList());
        
        model.addAttribute("workspaceList", workspaceList);
        model.addAttribute("userList", userList);
        model.addAttribute("schedule", new CreateScheduleRequest());
        
        return "admin/schedules/schedules-create-form";
    }

스케줄 생성 폼에 생성에 필요한 내용을 담고 있는 CreateScheduleRequest 객체를 넘겨줬다.

<body>
    <div th:replace="~{template/fragment/header :: schedules_header ('스케줄생성')}" class="title">
        <h1>스케줄 생성</h1>
    </div>

    <div class="container">
        <form role="form" action="/admin/schedules/create-schedules" method="post" th:object="${schedule}">
            <div class="form-group">
                <label>이름</label>
                <input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"/>
            </div>
            <div class="form-group">
                <label>워크스페이스</label>
                <select th:field="*{workspace}">
                    <option th:each="workspace: ${workspaceList}" th:value="${workspace}"
                            th:text="${workspace}"></option>
                </select>
            </div>
            <div class="form-group">
                <label>유저</label>
                <details>
                    <summary> 유저 보기 </summary>
                    <ul>
                        <li th:each="user : ${userList}">
                            <input th:field="*{users}" type="checkbox" th:text="${user}" th:value="${user}" />
                        </li>
                    </ul>
                </details>
            </div>
            <button type="submit" class="btn">생성</button>
        </form>
    </div>
</body>

th:object="${schedule}" 를 사용하면 form에서 model로 받아온 객체를 사용할 수 있다.

th:field="*{name}"를 사용하면 가져온 객체(schedule)의 name 필드를 사용할 수 있다.

th:field를 사용하면

  1. 태그에 name, id 를 자동으로 만들어준다.
  2. field 안에 값이 있을 경우 value도 만들어준다.
  3. checkbox 타입에선 value와 field의 값을 비교하여 checked 속성과 히든 태그를 만들어준다.

validation에서도 사용할 수 있다. 고 하는데 강의를 더 들어봐야 할 것 같다

 

@ModelAttribute NoArgs Setter 이슈

어드민 페이지를 만들면서 파라미터로는 값을 잘 전달하는데

ModelAttribute에서 변환하는 과정에서 객체 프로퍼티에 null값이 전달되는 일이 있었다.

 

ModelAttribute에서 파라미터로 넘어온 값을 이용해서 객체를 생성할때

  1. 파라미터 없는 기본 생성자(NoArgsConstructer 등)로 객체 생성 후 Setter로 초기화
  2. 파라미터 존재하는 생성자로 생성하면서 초기화

순으로 진행하기 때문에

 

파라미터 없는 생성자가 있을 경우 Setter를 달아줘야한다.

 

 

 

 

+ Recent posts