반응형

공통 관심 사항

1. 공통 관심 사항이란

많은 로직에서 공통으로 관심이 있는 부분을 공통 관심사(cross-cutting concerns)라 함. 

• 로그인을 하지 않은 사용자는 접근할 수 있는 페이지가 제한적이며 로그인이 필요한 페이지 접근이 허용되서는 안됨.

• 로그인이 필요한 모든 컨트롤러 로직에 로그인 여부를 확인하는 코드를 작성하기엔 비효율적
• 여러 로직에서 공통으로 로그인에 관심을 가지고 있는데, 이러한 공통 관심사는 스프링에서 AOP로 처리할 수 있음

• 웹에 관련된 공통 관심사는 스프링 AOP 보다는 서블릿 필터, 스프링 인터셉터에서 처리하는게 좋음.

• 웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL 정보가 필요한데 서블릿 필터나, 스프링 인터셉터는 HttpServletRequest를 제공.

 

 

서블릿필터 VS 스프링 인터셉터

※ 서블릿 필터와 스프링 인터셉터는 모두 웹과 관련된 공통 관심사를 처리하는데 사용되는 기술
필터는 서블릿에서 제공하고 인터셉터는 스프링 MVC가 제공하는 기술인데, 적용되는 순서와 범위, 사용방법이 다름 

 

1. 필터와 인터셉터의 흐름 비교

• 필터를 적용하면 필터가 호출된 이후 서블릿이 호출됨
(여기서 서블릿은 스프링의 경우 디스패처 서블릿을 의미한다고 생각하면 된다.)
• 인터셉터를 적용하면 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출됨  
• 필터는 서블릿 호출전에 인터셉터는 서블릿 호출 이후 호출되기에 인터셉터는 서블릿에서 예외가 발생한다면 호출되지 않음

 

 

2. 필터와 인터셉터의 제한 비교

• 필터와 인터셉터는 각각 요청이 적절하지 않을경우 자신의 상태에서 종료할 수 있음
• 필터는 서블릿까지 가지 못하지만, 스프링 인터셉터는 서블릿까진 통과 후 제한됨 

 

 

3. 필터와 인터셉터 체인 비교

 

• 필터 및 인터셉터는 둘 다 추가로 적용할 수 있다. 
ex) 로그를 남기는 필터(혹은 인터셉터)를 적용 후 그 다음 로그인 여부를 체크하는 필터(혹은 인터셉터)를 만들어 적용할 수 있음


※ 호출시점의 차이를 빼면 별 차이가 없어보이지만, 스프링 인터셉터는 좀 더 편하고 정교하며 다양한 기능을 제공함.

 

 

서블릿 필터

1. 필터 인터페이스

public interface Filter {
    public default void init(FilterConfig filterConfig) throws ServletException {}

    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException;

    public default void destroy() {}
}

 필터 인터페이스를 구현한 뒤 등록하면 서블릿 컨테이너가 필터를 등록  후 싱글톤 객체고 생성 및 관리
- init(): 필터 초기화 메서드로 서블릿 컨테이너가 생성될 때 호출됨
- doFilter(): 고객의 요청이 올 때마다 해당 메서드가 호출됨
- destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출됨

※ init, destroy 메서드는 default 메서드 이기에 따로 구현하지 않아도 됨.

 

 

2. 필터 구현 예제 1

2.1 로그 필터 기능 소스

package hello.login.web.filter;

import java.io.IOException;
import java.util.UUID;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LogFilter implements Filter {
	public void init(FilterConfig filterConfig) throws ServletException {
		log.info("log filter init");
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		log.info("log filter doFilter");
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		String requestURI = httpRequest.getRequestURI();
		
		String uuid = UUID.randomUUID().toString();
		try {
			log.info("REQUEST [{}] [{}]", uuid, requestURI);
			// 서블릿과 컨트롤러를 호출
			chain.doFilter(httpRequest, response);
		} catch(Exception e) {
			throw e;
		} finally {
			log.info("RESPONSE[{}] [{}]", uuid, requestURI);
		}		
	}

	public void destroy() {
		log.info("log filter destroy");
	}
}

 public class LogFilter implements Filter

 - Filter 인터페이스를 구현하며 init, doFilter, destroy 메서드를 재정의


 doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
- HTTP 요청이 오면 doFilter가 호출됨
- ServletRequest request는 HTTP 요청이 아닌 경우도 고려해서 만든 인터페이스.

- HTTP를 사용하면 HttpServletRequest로 다운캐스팅한 뒤 사용하면 됨


 UUID.randomUUID().toString()
- HTTP 요청을 구분하기 위해 요청당 임의의 uuid를 만든다. UUID로 만드는 값이 중복될 일은 거의 없다. 

 

chain.doFilter(request, response);
- 가장 중요.

 - 다음 필터가 있으면 다음 필터를 호출하고 필터가 없으면 서블릿을 호출

- 만약 이 로직을 호출하지 않으면 다음단계로 진행되지 않음.

 

 

2.2 필터 등록

@Configuration
public class WebConfig implements WebMvcConfigurer{
	@Bean
	public FilterRegistrationBean logFilter() {
		FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
		filterRegistrationBean.setFilter(new LogFilter());
		filterRegistrationBean.setOrder(1);
		filterRegistrationBean.addUrlPatterns("/*");
		
		return filterRegistrationBean;
	}
}

 setFilter(new LogFilter)
 - 등록 할 필터를 지정
 setOrder(1)
 - 필터는 체인으로 동작하기에 순서가 필요하다. 순서가 낮을수록 먼저 동작
 addUrlPatterns("/*")
 - 필터를 적용할 URL 패턴을 지정하며, 하나 이상의 패턴을 지정 할 수도 있음.

 

 

3. 필터 구현 예제 2

3.1 로그인 인증 체크 필터 기능 소스

package hello.login.web.filter;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.util.PatternMatchUtils;

import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LoginCheckFilter implements Filter {
	private static final String[] whitelist = {"/", "/members/add", "/login", "/logout", "/css/*" };

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
		HttpServletRequest httpRequest = (HttpServletRequest)request;
		String requestURI = httpRequest.getRequestURI();
		
		HttpServletResponse httpResponse = (HttpServletResponse)response;
		
		try {
			log.info("인증 체크 필터 시작{}", requestURI);
			
			if(isLoginCheckPath(requestURI)) {
				log.info("인증 체크 로직 실행 {}",requestURI);
				HttpSession session = httpRequest.getSession(false);
				if(session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
					log.info("미인증 사용자 요청 {}",requestURI);
					
					// 로그인으로 리다이렉트, 로그인 완료 후 로그인전 페이지로 보내기위해 파라미터 담기
					httpResponse.sendRedirect("/login?redirectURL="+requestURI);
					return;
				}
			}
			chain.doFilter(httpRequest, httpResponse);
		} catch (Exception e) {
			throw e; // 예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
		} finally {
			log.info("인증 체크 필터 종료 {}",requestURI);
		}
		
	}
	
	//화이트 리스트의 경우 인증 체크 X
	private boolean isLoginCheckPath(String requestURI) {
		// 화이트리스트와 requesURI가 패턴이 일치 하는지
		return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
	}
}

 private static final String[] whitelist = {"/", "/members/add", ...};
 - 정적 리소스(css)와 로그인, 로그아웃의 경우 로그인을 하지 않아도 접근이 가능해야 함.

 - 화이트리스트를 제외한 나머지 경로에는 인증 체크 로직을 적용 해 줌

 

 isLoginCheckPath(String requestURI)
 - 매개변수로 전달받은 requestURI가 화이트리스트와 일치하는지 검사

 - PatternMatchUtils라는 정적 헬퍼 클래스를 이용하여 쉽게 경로 검사가 가능

 

 httpResponse.sendRedirect("/login?redirectURL=" + requestURI);
 - 로그인을 안했는데 로그인이 필요한 페이지에 접근시 로그인 페이지로 이동
 - redirectURL의 queryString : 내가 등록한 상품 목록 페이지에 접근하려는 상황에서 로그인이 안되어 있어 로그인 페이지로 이동했다고 가정할 때 로그인을 하면 다시 상품목록 페이지로 이동시켜주면 사용자 입장에선 편리함.

- 컨트롤러에서 redirectURL 관련 처리를 해줘야함

 

 return;
 - 필터를 더 진행하지 않음

 - redirect를 사용했기에 redirect가 응답으로 적용되고 요청이 끝남

 

 

3.2 필터 등록

@Bean
public FilterRegistrationBean loginCheckFilter() {
    FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
    filterRegistrationBean.setFilter(new LoginCheckFilter());
    filterRegistrationBean.setOrder(2);
    filterRegistrationBean.addUrlPatterns("/*");

    return filterRegistrationBean;
}

 addUrlPatterns("/*")

 - 허용 URL은 /*으로 전부 허용을 해 줌

 - 필터 내부에 화이트리스트가 있기 때문에 검사가 불필요한 경로는 검사를 하지 않음.

 

 

3.3 로그인 컨트롤러 (redirectURL처리)

@PostMapping("login")
public String loginV4(@Valid @ModelAttribute LoginForm form,
			BindingResult bindingResult,
			@RequestParam(defaultValue = "/") String redirectURL,
			HttpServletResponse response,
			HttpServletRequest request) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    //세션 매니저를 통해 세션 생성및 회원정보 보관
    //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
    HttpSession session = request.getSession();
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);

    if (redirectURL != null) {
        return "redirect:" + redirectURL;
    }

    return "redirect:/";
}

 로그인이 성공했을 경우 redirectURL이라는 @RequestParam이 있으면 로그인 하기 전 그 페이지로 되돌아가기위해 사용

 

 

스프링 인터셉터

1. 인터셉터 인터페이스

public interface HandlerInterceptor {
	default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {

		return true;
	}

	default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			@Nullable ModelAndView modelAndView) throws Exception {
	}

	default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
			@Nullable Exception ex) throws Exception {
	}
}

 doFilter 하나로 로직을 수행하는 서블릿 필터와는 다르게 인터셉터는 다음과 같이 3가지 단계로 세분화 되어있음.
 - 컨트롤러 호출 전(preHandler)
 - 컨트롤러 호출 후(postHandle)
 - 요청 완료 이후(afterCompletion)

 

 

2. 인터셉터 흐름

2.1 인터셉터 메서드의 흐름

 (1) preHandler: 컨트롤러 호출 전에 호출되며 반환 타입은 Boolean, 반환 값이 false이면 그 뒤는 진행하지 않음  
 (4) postHandler: 컨트롤러 호출 후 호출되며 정확히는 핸들러 어댑터 호출 후 호출. 
 (6) afterCompletion: 뷰가 렌더링 된 후에 호출

 

 

2.2 인터셉터 예외 발생 흐름

 preHandle : 컨트롤러 호출 전에 호출

 postHandler : 컨트롤러에서 예외가 발생하면 postHandler은 호출되지 않음

 afterCompletion : 항상 호출(try-catch의 finally처럼) 이전에 발생한 예외가 있을 경우 이를 파라미터로 받아서 어떤 예외가 발생했는지 확인할 수 있음

 

※ 예외가 발생하면 postHandler()같은 경우 호출되지 않기 때문에 예외 처리가 필요하다면 afterCompletion()을 사용해야 함

afterCompletion은 Exception ex를 매개변수로 받고 있으며 Nullable하기에 notNull인 경우 해당 처리를 수행하면 됨 

 

 

3. 인터셉터 구현 예제

3.1 로그 인터셉터 기능 소스

@Slf4j
public class LoginInterceptor implements HandlerInterceptor {

    public static final String LOG_ID = "logId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        request.setAttribute(LOG_ID, uuid);

        //@RequestMapping: HandlerMethod가 넘어온다.
        //정적 리소스: ResourcehttpRequesthandler가 넘어온다.
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
        }

        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("pohstHandler [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        String uuid = (String) request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}][{}]", uuid, requestURI, handler);

        if (ex != null) {
            log.error("afterCompletion error:", ex);
        }
    }
}

 public class LoginInterceptor implements HandlerInterceptor

 - 인터셉터 구현은 HandlerInterceptor를 구현

 

 reqeust.setAttribute(LOG_ID, uuid)
 - 스프링 인터셉터는 호출 시점이 분리되어 있기에 각각의 메서드가 호출되는 시점에 변수들의 값 유지가 되지 않음

 - preHandler에서 지정한 값을 postHandler이나 afterCompletion에서 사용하려면 멤버변수를 사용하면 안되고 request 인스턴스에 담아두어서 사용해야함  

※ 인터셉터는 싱글톤 처럼 사용되기 때문에 멤버변수에 값을 저장하면 안됨
 - 위 코드에서 request에 담은 LOG_ID(uuid)는 afterCompletion에서 getAttribute로 찾아 사용


 HandlerMethod hm = (HandlerMethod) handler;
 - 스프링에서는 일반적으로 @Controller, @RequestMapping을 활용해 핸들러 매핑을 사용하는데, 이 경우 스프링 인터셉터의 Object handler 매개변수에는 핸들러 정보로 HandlerMethod가 넘어옴

 

 

3.2 인터셉터 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error");
    }

    @Bean
    public LoginInterceptor loginInterceptor() {
        return new LoginInterceptor();
    }
}

WebMvcConfigurer 인터페이스를 구현하여 addInterceptor 메서드를 재정의해서 인터셉터 등록이 가능

 addInterceptor: 인터셉터를 등록
 order(1): 인터셉터의 호출 순서를 지정하며 낮을 수록 먼저 호출
 addPathPatterns("/**"): 인터셉터를 적용할 URL 패턴을 지정
 excludePathPatterns("/css/**", "/*.ico", "/error") :인터셉터에서 제외할 패턴을 지정

 

 

※ PathPattern 공식 문서

? 한 문자 일치
* 경로(/) 안에서 0개 이상의 문자 일치
** 경로 끝까지 0개 이상의 경로(/) 일치
{spring} 경로(/)와 일치하고 spring이라는 변수로 캡처
{spring:[a-z]+} matches the regexp [a-z]+ as a path variable named "spring"
{spring:[a-z]+} regexp [a-z]+ 와 일치하고, "spring" 경로 변수로 캡처
{*spring} 경로가 끝날 때 까지 0개 이상의 경로(/)와 일치하고 spring이라는 변수로 캡처


/pages/t?st.html — matches /pages/test.html, /pages/tXst.html but not /pages/toast.html

/resources/*.png — matches all .png files in the resources directory

/resources/** — matches all files underneath the /resources/ path, including /resources/image.png and /resources/css/spring.css

/resources/{*path} — matches all files underneath the /resources/ path and captures their relative path in a variable named "path"; /resources/image.png will match with "path" → "/image.png", and /resources/css/spring.css will match with "path" → "/css/spring.css"

/resources/{filename:\\w+}.dat will match /resources/spring.dat and assign the value "spring" to the filename variable

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/util/pattern/PathPattern.html

 

 

 

ArgumentResolver 활용

1. session 정보를 담는 에노테이션 구현 예시

1.1 @Login 에노테이션 생성

package hello.login.web.argumentresolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

// 파라미터 타입
@Target(ElementType.PARAMETER)
// 동작할때까지 남아있도록
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {

}

 @Target(ElementType.PARAMETER) : 파라미터에만 붙힐 수 있는 애노테이션
@Retention(RetentionPolicy.RUNTIME) : 리플렉션 등을 활용할 수 있도록 런타임까지 애노테이션 정보가 남아있도록 해줌

 

1.2 ArgumentResolver 구현

package hello.login.web.argumentresolver;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import hello.login.domain.member.Member;
import hello.login.web.SessionConst;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver{
	@Override
	public boolean supportsParameter(MethodParameter parameter) {
		log.info("supportsPararmeter 실행");
		
		// Login에노테이션이 파라미터에 있는지
		boolean hasLoginAnnotation =  parameter.hasParameterAnnotation(Login.class);
		// 파라미터의 타입이 Member 클래스의 타입인지
		boolean hasMemberType = Member.class.isAssignableFrom(parameter.getParameterType());
		
		return hasLoginAnnotation && hasMemberType;
	}

	@Override
	public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
				NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
		log.info("resolverArgument 실행");
		HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
		HttpSession session = request.getSession(false);
		if(session == null) {
			return null;
		}
		
		return session.getAttribute(SessionConst.LOGIN_MEMBER);
	}
}

 supportsParameter()
 - 컨트롤러 호출시 각 매개변수들은 ArgumentResolver에 의해 매핑이 됨

 - 많은 ArgumentResolver가 각각 대응할 수 있는 객체는 제한되어있음

 - 이를 책임사슬 패턴을 이용해 처리

 - 각각의 ArgumentResolver는 이 메서드(supportsParameter())를 이용해 매핑가능여부를 Boolean 타입으로 반환함. 
 - 여기선 @Login 애노테이션이 붙어있고 Member객체인 경우 지원이 가능하다고 로직을 구현


 resolverArgument()
 - 실제로 컨트롤러에 필요한 파라미터 정보를 생성해주는 메서드

 - 여기서는 세션에서 로그인 회원 정보인 member 객체를 찾아 반환해줌

 

1.3 ArgumentResolver 등록

@Configuration
public class WebConfig implements WebMvcConfigurer {
	@Override
	public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
		resolvers.add(new LoginMemberArgumentResolver());
	}

	// ...
}

 WebMvcConfigurer 인터페이스를 구현하여 addArgumentResolvers메서드를 재정의해서 ArgumentResolver 등록이 가능

 

1.4 controller에서 ArgumentResolver 사용 

기존 소스

public String homeLoginV3Spring(
        @SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)Member loginMember,
        HttpServletRequest request, Model model) {

    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

매번 속성들(name, required)를 작성해주는건 번거롭고, 해당 애노테이션을 통해 해당 객체에 대한 명시성이 부족

 

ArgumentResolver 적용 소스

@GetMapping("/")
public String homeLoginV3ArgumentResolver(@Login Member loginMember, Model model) {
	//세션에 회원 데이터가 없으면 home
	if (loginMember == null) {
		return "home";
	}

	//세션이 유지되면 로그인으로 이동
	model.addAttribute("member", loginMember);
	return "loginHome";
}

※ 애노테이션을 활용하면 더 명시적이고 간결하게 회원정보를 찾아서 매핑해줄 수 있음
※ 회원 객체(Member)의 구조가 바뀐다면 Resolver쪽만 수정해주면 됨

※ 직접 객체 매핑 로직을 구현하면 코드가 너무 길고 차후 후임자나 다른 개발자가 보기도 쉽지 않음
※ @SessionAttribute 애노테이션을 활용하면 좀 나아지지만 목적자체가 로그인 정보를 찾는다는 특정 목적의 애노테이션이 아니기 때문에 이 역시 다른 개발자가 볼 때 단번에 이해하기는 힘들고, name이 명확하지 않으면 세션의 이 정보를 어째서 조회하는지에 대해 한번에 이해하기 어려움
※ ArgumentResolver를 이용해 애노테이션으로 요청 매핑 핸들러 어댑터를 구현해주면 하나의 특정 애노테이션으로 가독성도 좋고 편리하게 회원 정보를 조회할 수 있음

반응형
반응형

로그인 previous

※ 웹/앱 서비스에서 로그인 기능은 필수 기능

로그인 기능에는 고려할 부분이 많음

 - 아이디와 비밀번호가 맞는지 인증 기능

 - 서버에서 로그인을 처리하는 로직의 위치 파악

 - 로그인 후 로그인 페이지 리다이렉트 이전 페이지로 다시 복귀

 - 한 번 로그인 한 뒤에는 로그인 상태가 유지

 

※로그인 상태를 유지하는 방법
로그인 상태는 쿠키 혹은 세션으로 관리를 하며 여기에 해당 키의 유효시간 관리를 통해 일정시간만 유지되도록 할 수 있음

 

※ 스프링에서는 스프링 시큐리티라는 프레임워크로 로그인, 계층화, 리멤버미까지 다양한 기능을 제공하지만, 결국 이러한 스프링 시큐리티도 쿠키, 세션을 통해 관리하는 것이고 여러 리졸버를 이용함.
쿠키와 세션을 통해 로그인을 처리하는 과정과 이를 처리하기 위해 필터와 인터셉터를 학습. 

 

쿠키를 사용한 로그인 처리

1. 쿠키 동작 방식

• 서버에서 로그인 성공 시 사용자 정보를 쿠키에 담아 브라우저로 전달하면 브라우저는 해당 쿠키를 저장해둠  
• 해당 사이트에 접속할 때마다 지속해서 해당하는 쿠키를 사용

※ request의 헤더에 cookie가 담겨있어 모든 request에 쿠키 정보가 자동으로 포함됨

 

2. 쿠키의 종류

※ 사용자는 상황에따라 쿠키의 생명주기를 설정해 사용할 수 있음
 - 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
 - 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

 

3. 서버에서 쿠키 생성하기 - version 1

※ java.servlet.http에는 Cookie라는 클래스를 제공해주는데 이 클래스를 이용해 클라이언트에 응답 할 쿠키정보를 쉽게 핸들링할 수 있음

3.1 로그인 수행 controller 쿠키 생성 하기 예제

@PostMapping("login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    //쿠키에 시간 정보를 주지 않으면 세션 쿠키가 된다. (브라우저 종료시 모두 종료)
    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);

    return "redirect:/";
}

• new Cookie("memberId", String.valueOf(loginMember.getId()));
 - Cookie 라는 클래스 생성자로 key / value를 인자로 넘겨주어 생성
• response.addCookie(idCookie); 
 - 생성된 쿠키(idCookie)를 서버 응답 객체(HttpServletResponse)에 addCookie를 이용해 쿠키에 담아줌

 - 웹 브라우저에서는 Set-Cookie 프로퍼티에 쿠키정보가 담겨져 반환됨

 

 

3.2 루트 페이지 controller 쿠키 조회 하기 예제

@GetMapping("/")
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
    if (memberId == null) {
        return "home";
    }

    Member loginMember = memberRepository.findById(memberId);
    if (loginMember == null) {
        return "home";
    }

    model.addAttribute("member", loginMember);
    return "loginHome";
}

• @CookieValue(name = "memberId", required = false) Long memberId
 - 쿠키를 편하게 조회할 수 있도록 도와주는 애노테이션

 - 전송된 쿠키정보중 key가 memberId인 쿠키값을 찾아 memberId 변수에 할당해줌
 - required가 false이기에 쿠키정보가 없는 비회원도 해당페이지에 접근 가능

 

 

4. 서버에서 쿠키 삭제하기(로그 아웃)

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    expiredCookie(response, "memberId");
    return "redirect:/";
}

private void expiredCookie(HttpServletResponse response, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}

• 로그아웃 기능은 쿠키를 삭제하는게 아니라 종료 날짜를 0으로 줘서 바로 만료시킴으로써 삭제할 수 있음

 - 응답 쿠키의 정보를 보면 Max-Age=0으로 되어있어 해당 쿠키는 즉시 종료된다.

 

 

5. version 1의 보안 문제

• 쿠키 값을 임의대로 변경할 수 있음 

 - 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 됨
 - 실제 웹브라우저 개발자모드 → Application → Cookie: memberId=1을 memberId=2로 변경(다른 사용자의 이름이 보임)
• 쿠키에 보관된 정보(memberId) 를 타인이 훔쳐갈 수 있음
• 한 번 도용된 쿠키정보는 계속 악용될 수 있음

 

6. 대안

• 쿠키에 중요한 값을 바로 노출하지 않고 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)으로 대체하고, 서버에서
토큰과 사용자 id를 매핑해서 보관

ex) 브라우저에서 cookiename : randomkey, 서버에서 randomkey : 사용자 id 

• 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예측 불가능하게 생성

• 해커가 토큰을  시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분)유지

• 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 됨.

 

 

세션을 사용한 로그인 처리

1. 세션 동작 방식

• 중요 정보는 서버의 세션 저장소에 key/value로 저장한 뒤 브라우저에서는 key값만 쿠키로 저장 해둠.

쿠키에 저장하고 있는 sessionId를 전달하면 서버의 세션저장소에서는 해당sessionId를 key로 가지고 있는 value값을 조회해서 로그인 여부와 중요 정보를 확인함

• 개선된 점

 - 회원과 관련된 정보는 클라이언트에서 가지고 있지 않음
 - 추정 불가능한 세션 아이디만 쿠키를 통해 주고받기에 보안에서 많이 안전해짐

 추가 개선할점

 - 세션아이디가 저장된 쿠키의 만료시간을 짧게 유지한다면, 해커가 해당 키를 도용한다 하더라도 금새 갱신되며 사용하지 못하게 되어 보안적으로 좀 더 안전해질 수 있음

 

2. 세션 관리 기능

※ 직접 세션을 만들기 위해서는 다음과 같이 크게 3가지 기능을 제공해야 함
 세션 생성
 - 세션 키는 중복이 안되며 추정 불가능한 랜덤 값이어야 함
 - 세션 키에 매칭될 값(사용자 정보)가 있어야 함
 - 이렇게 생성된 세션 키를 응답 쿠키에 저장해 클라이언트에 전달
 세션 조회
 - 클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 저장된 값을 조회할 수 있어야 함
 세션 만료
 - 클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 보관한 세션 엔트리를 제거해야 함

 

 

3. 직접 세션 구현

3.1 세션 관리 클래스 소스

package hello.login.web.session;

import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;

@Component
public class SessionManager {
	public static final String SESSION_COOKIE_NAME = "mySessionId";
	// 동시에 여러 스레드 접근 시 ConcurrentHashMap을 사용
	private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
	
	/* 세션생성
	 * sessionId생성 (임의의 추정 불가능한 랜덤값)
	 * 세션 저장소에 sessionId와 보관할 값 저장
	 * sessionId로 응답 쿠키를 생성해서 클래이언트에 전달
	 */
	public void createSession(Object value , HttpServletResponse response) {
		// sessionId생성하고, 값을 세션에 저장
		String sessionId = UUID.randomUUID().toString();
		
		sessionStore.put(sessionId, value);
		
		Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
		response.addCookie(mySessionCookie);
	}

	// 세션 조회
	public Object getSession(HttpServletRequest request) {
		Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
		if(sessionCookie == null) {
			return null;
		}
		
		return sessionStore.get(sessionCookie.getValue());
	}
	
	// 세션 만료
	public void expire(HttpServletRequest request) {
		Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
		if(sessionCookie != null) {
			sessionStore.remove(sessionCookie.getValue());
		}
		
	}
	
	// request의 쿠키를 가져와서 session과 관련된 "mySessionId"이라는 이름의 쿠키를 조회(session의 key가 들어있음)
	public Cookie findCookie(HttpServletRequest request, String cookieName) {
		/*
		Cookie[] cookies = request.getCookies();
		
		if(cookies == null) {
			return null;
		}
		
		for (Cookie cookie : cookies) {
			if(cookie.getName().equals(SESSION_COOKIE_NAME)) {
				return sessionStore.get(cookie.getValue());
			}
		}
		
		return null;
		*/
		Cookie[] cookies = request.getCookies();
		if(cookies == null) {
			return null;
		}
		// cookies 배열을 스트림으로 바꿔주고
		// 필터적용(loop로 cookies를 cookie 변수에 담아서 cookie.getName().equals(cookieName)로직을 하나씩 돌려줌)
		// findFirst() : 첫번째로 나온 cookies의 cookie객체
		// findAny() : 순서와 상관없이 빨리 나온 cookies의 cookie 객체(병렬처리에서 순서와 상관없이 찾을때)
		// 없으면 null
		return Arrays.stream(cookies)
				.filter(cookie -> cookie.getName().equals(cookieName))
				.findAny()
				.orElse(null);
	}
}

 @Component : 스프링 빈으로 자동 등록
 ConcurrentHashMap : HashMap은 동시 요청에 안전하지 않음 동시 요청에 안전한 ConcurrentHashMap를 사용

 

 

4. 직접 만든 세션 적용

4.1 로그인 컨트롤러 (세션 생성)

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
	private final LoginService loginService;
	private final SessionManager sessionManager;
    
	@PostMapping("/login")
	public String loginV2(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,HttpServletResponse response) {
		if(bindingResult.hasErrors()) {
			return "login/loginForm";
		}
		
		Member loginMember= loginService.login(form.getLoginId(), form.getPassword());
		
		if(loginMember ==null) {
			bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
			return "login/loginForm";
		}
		
		// 로그인 성공처리
		// 세션 관리자를 통해 세션을 생성하고 회원 데이터 보관
		sessionManager.createSession(loginMember, response);
		
		return "redirect:/";
	}
}

 로그인 성공시 직접 만든 sessionManager객체의 createSession 메서드 호출

 - sessionId(임의의 렌덤값) 생성

 - 세션 저장소에 sessionId와 보관할 값 저장

 - sessionId로 응답 쿠키를 생성해서 클라이언트에 전달

 

 

4.2 로그아웃 컨트롤러 (세션 만료)

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
	private final LoginService loginService;
	private final SessionManager sessionManager;	
    
	@PostMapping("/logout")
	public String logoutV2(HttpServletResponse response,HttpServletRequest request) {
		sessionManager.expire(request);
		return "redirect:/";
	}
}

 로그아웃 시 직접 만든 sessionManager객체의 expire메서드 호출

 - 요청 헤더의 쿠키를 찾아 sessionId로 세션 저장소에서 삭제

 

 

4.3 홈 컨트롤러 (세션 조회)

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
	private final MemberRepository memberRepository;
	private final SessionManager sessionManager;

	@GetMapping("/")
	public String homeLoginV2(HttpServletRequest request, Model model){
		// 세션 관리자에 저장된 회원 정보 조회
		Member member = (Member) sessionManager.getSession(request);
		
		// 로그인 성공
		if(member == null) {
			return "home";
		}
		
		model.addAttribute("member", member);
		
		return "loginHome";
	}
}

 

 

※ 서블릿에서는 세션매니저 역할(HttpSession)객체를 제공하고 있음

 - 우리가 만들었던 SessionManager의 역할을 하는 객체를 서블릿에서는 HttpServlet 클래스를 통해 제공하고 있음

 - HttpSession을 이용하면 우리는 세션 생성, 조회, 삭제를 편하게 사용할 수 있고 추적 불가능한 키를 가진 쿠키를 생성할 수 있음

 - 이때 쿠키 이름은 JSESSIONID이며 HttpOnly이기에 클라이언트에서 조작할 수 없음

 

 

5. 서블릿 HttpSession을 이용한 로그인 처리

5.1 세션 조회용 상수 클래스 생성

public interface SessionConst {
	String LOGIN_MEMBER = "loginMember";
}

 

5.2 로그인 컨트롤러 (세션 생성)

@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,HttpServletRequest request) {
	if(bindingResult.hasErrors()) {
		return "login/loginForm";
	}
	
	Member loginMember= loginService.login(form.getLoginId(), form.getPassword());
	
	if(loginMember ==null) {
		bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
		return "login/loginForm";
	}
	
	// 로그인 성공 처리
	// 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
	// 서블릿을 통해 HttpSession을 생성하면 쿠키 이름을 JSESSIONID으로, 값은 추정 불가능한 랜덤 값으로 저장한다.
	HttpSession session = request.getSession();
	
	// 세션에 로그인 회원 정보 보관
	// 세션에 "loginMember"이름으로 loginMember 객체가 저장됨
	session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
	
	return "redirect:/";
}

 request.getSession()
 - getSession 메서드는 세션을 생성 혹은 조회하는 메서드


 public HttpSession getSession(boolean create); //default true
  - create : true일 경우
    : 세션이 있으면 기존 세션을 반환
    : 세션이 없으면 새로운 세션을 생성해 반환
  - create :false일 경우
   : 세션이 있으면 기존 세션을 반환
   : 세션이 없으면 새로운 세션을 생성하지 않고 null을 반환
※ 추가적으로 인수를 전달하지 않을 경우 기본 값으로 true

 

 

 

5.3 로그아웃 컨트롤러 (세션 만료)

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
	// 세션이 있으면 세션 반환, 세션이 없으면 새로운 세션을 생성하지 않고 null 반환 default가 true
	HttpSession session = request.getSession(false);
	
	if(session != null) {
		// session 삭제
		session.invalidate();
	}
	
	return "redirect:/";
}

 session.invalidate();
  - 세션을 제거하는 메서드다. 

 

 

5.4 홈 컨트롤러 (세션 조회)

@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model){
	// 세션 관리자에 저장된 회원 정보 조회
	HttpSession session = request.getSession(false);
	if(session == null) {
		return "home";
	}
	
	Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
	
	// 세션에 회원 데이터가 없으면 home으로 이동
	if(loginMember == null) {
		return "home";
	}
	
	// 세션이 유지되면 로그인으로 이동
	model.addAttribute("member", loginMember);
	
	return "loginHome";
}

 HttpSession session = request.getSession(false);

 - 기존에 있는 세션 조회

 

 Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);

- "loginMember"의 key로 저장된 세션 정보 조회하여 회원 정보 저장

 

※ @SessionAttribute 어노테이션으로 위의 로직을 간편화하여 사용할 수 있음

 

 

5.5 홈 컨트롤러 (세션 조회) - @SessionAttribute 어노테이션 활용

@GetMapping("/")
public String homeLoginV3Spring(
			// @SessionAttribute어노테이션은 세션을 생성하지 않음 찾을때만 씀
			@SessionAttribute(name=SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model){

	// 세션에 회원 데이터가 없으면 home으로 이동
	if(loginMember == null) {
		return "home";
	}
	
	// 세션이 유지되면 로그인으로 이동
	model.addAttribute("member", loginMember);
		
	return "loginHome";
}

@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
 - 이전에 사용한 @CookieValue와 비슷

 - 클라이언트로부터 전달받은 내용의 세션중에서 key가 일치하는게 있는지 찾음.

 - required가 false이니 만약 못찾으면 null이 할당

※ 소스가 간결해짐

 

 

5.6 HttpSession객체에서 제공하는 정보

package hello.login.web.session;

import java.util.Date;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
public class SessionInfoController {
	@GetMapping("/session-info")
	public String sessionInfo(HttpServletRequest request) {
		HttpSession session = request.getSession(false);
		if(session ==null) {
			return "세션이 없습니다.";
		}
		
		// 세션에 있는 attribute를 꺼내서 확인
		session.getAttributeNames().asIterator()
		.forEachRemaining(name -> log.info("session name={}, value= {}", name, session.getAttribute(name)));
		
		log.info("sessionId={}", session.getId());
		log.info("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
		log.info("getCreationTime={}",new Date(session.getCreationTime()));
		log.info("getLastAccessedTime={}", new Date(session.getLastAccessedTime()));
		log.info("isNew={}", session.isNew());
		
		return "세션 출력";
	}
}

 sessionId : 세션 아이디(JSESSIONID)의 값(ex:754BE5D4DD969894D958AC278370D06E)
 maxInactiveInterval : 세션의 유효 시간(ex: 1800초, (30분))
 creationTime: 세션 생성일시
 lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접속한 시간.
(클라이언트에서 서버로 sessionId(JSESSIONID)를 요청한 경우 갱신됨.)
 isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId(JSESSIONID)를 요청해서 조회된 세션인지 여부

 

 

5.7 세션 타임아웃 설정

 대부분의 사용자는 직접 명시적으로 로그아웃 버튼을 누르지 않음

HTTP는 비연결성(ConnectionLess)이기에 서버에선 클라이언트가 웹 브라우저를 종료했는지를 알 수 없음

※ 세션을 언제 삭제해야할지 판단하기 어려움  

 

• 세션의 타임아웃을 설정 되어야하는 이유

 - 세션을 무한정 유지되도록 한다면, 여러가지 문제가 발생할 수 있음

 - JSESSIONID를 탈취당한 경우 시간이 흘러도 해당 쿠키로 악용될 수 있음

 - 세션은 기본적으로 메모리에 생성되는데 메모리의 크기가 무한하지 않아 사용하지 않는 세션이 관리되지 않으면 성능 저하 발생 및 OutOfMemoryException이 발생 할 수있음

 

 세션의 종료 시점
 - 타임아웃이 너무 빠르면 로그인 유지가 무관하게 계속 로그인을 해야함.

 - 기본적으로는 세션 생성 시점으로부터 30분 정도

 - 사용자가 가장 최근 요청한 시간을 기준으로 30분 정도를 유지
※ HttpSession은 기본적으로 이 방식을 사용

 

세션의 타임아웃 설정 방법

※ 스프링 부트에서는 application.properties에 글로벌 설정을 해 줄 수 있음

session.setMaxInactiveInterval(1800);//1800초

- 1800초(30분)으로 설정을 해두면 LastAccessTime 이후 timeout 시간이 지나면 WAS 내부에서 해당 세션을 삭제

 

※ TrackingModes

 최초 로그인시 브라우저 URL 입력창이 다음과 같은 형식이됨.
http://localhost:8080/;jsessionid=F5511518B921DF6209l.......
 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해 세션을 유지하는 방법임.

 이를 없애기 위해서는 스프링 설정 파일(application.properties)에 다음과 같은 설정을 추가해주면 됨.

server.servlet.session.tracking-modes=cookie
반응형
반응형

Bean validation 소개

1. Bean Validation이란?

 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준
 검증 애노테이션과 여러 인터페이스의 모음
 Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator임

 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없음


2. 하이버네이트 Validator 관련 링크

 공식 사이트: http://hibernate.org/validator/
 공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
 검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

 

 

Bean validation 사용하기

1. 의존성 추가 및 에노테이션 확인

 Bean Validation 기능은 라이브러리를 추가해서 사용해야 함
build.gradle에 추가

implementation 'org.springframework.boot:spring-boot-starter-validation'

※ spring-boot-starter-validation 의존관계를 추가하면 라이브러리가 추가 됨

 Jakarta Bean Validation추가 확인

- jakarta.validation-api : Bean Validation 인터페이스

- hibernate-validator : 구현체

 

 

 

 

 

 

 

 

 

 

 

2. Item -Bean Validation 에노테이션 적용 예제

package hello.itemservice.domain.item;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

import lombok.Data;

@Data
public class Item {
	private Long id;

	// 빈값 + 공백만 있는경우를 허용하지 않음
	@NotBlank
	private String itemName;

	// null을 허용하지 않음
	@NotNull
	@Range(min = 1000, max = 1000000)
	private Integer price;

	@NotNull
	@Max(9999) // 수정 요구 사항 추가
	private Integer quantity;

	public Item() {
	}

	public Item(String itemName, Integer price, Integer quantity) {
		this.itemName = itemName;
		this.price = price;
		this.quantity = quantity;
	}
}

 @NotBlank : 빈 값 + 공백만 있는 경우를 허용하지 않음
 @NotNull: null을 허용하지 않음
 @Max(최대값): 최대값 초과를 허용하지 않음
 @Range(min, max): 범위 안의 값이여야 함 

 


※ javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스
※ org.hibernate.validator로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증
기능

※ 실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용해도 됨

 

 

3. 테스트코드로 동작 확인

@Test
void beanValidation() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    Validator validator = factory.getValidator();

    Item item = new Item();
    item.setItemName(" ");
    item.setPrice(0);
    item.setQuantity(10000);


    Set<ConstraintViolation<Item>> validate = validator.validate(item);
    for (ConstraintViolation<Item> violation : validate) {
        System.out.println("violation = " + violation);
        System.out.println("violation.getMessage() = " + violation.getMessage());
    }

}

※ 임의로 validator를 꺼내서 테스트 진행 (아이템의 모든 필드의 유효성을 어긴 테스트 코드)

violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName,
rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.message=공백일 수 없습니다
        
violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, 
rootBeanClass=class hello.itemservice.domain.item.Item, 
messageTemplate='{javax.validation.constraints.Max.message}'}
violation.message=9999 이하여야 합니다
        
violation={interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price,
rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000에서 1000000 사이여야 합니다

※ 스프링 부트는 자동으로 글로벌 Validator로 등록함
 - spring-boot-starter-validation 라이브러리를 넣으면 스프링 부트가 자동으로 Bean Validator를 인지하고 스프링에 통합함

 - LocalValidatorFactoryBean이 글로벌 Validator로 등록되며 @Valid , @Validated 만 적용하면 됨.

 - 위에서 사용해봤던 @NotNull과 같은 애노테이션 검증을 수행.

 - 검증 오류 발생시 FieldError, ObjectError를 생성해 BindingResult에 담아줌. 

 - 임의로 글로벌 Validator를 등록해주면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않기에 위의 검증 애노테이션들이 동작하지 않음.

 

 

필드 검증하기(FieldError)

1. 필드 검증 요구 사항

 이름은 공백이여선 안됨
 가격은 빈 값이면 안되고, 1000원 이상 100만원 이하여야 함
 수량은 빈 값이면 안되고, 9999개까지만 가능

 

 

2. 검증 요구사항 에노테이션 적용

@Data
public class Item {
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1_000_000)
    private Integer price;

    @NotNull
    @Max(9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

3. 컨트롤러에 적용

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
			BindingResult bindingResult, 
			RedirectAttributes redirectAttributes,
			Model model) {
		...
}

 @Validated 검증 애노테이션을 붙혀주고 검증결과를 담기위해 BindingResult 클래스를 바로 다음 위치에 매개변수 선언

스프링에서는 자동으로 필드에 적용된 검증 애노테이션을 수행

 

4. 검증 순서

① @ModelAttribute 각각의 필드에 타입 변환 시도
 - 성공하면 다음 필드 진행
 - 실패하면 typeMismatch로 FieldError 추가 
② Validator 적용


 Bean Validation 적용 

 - 각각의 필드에 바인딩이 성공한 필드만 Bean Validation이 적용

 - BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않음
 - 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있음

※ price 에 문자 "A" 입력 → "A"를 숫자 타입 변환 시도 실패  typeMismatch FieldError 추가   price 필드는 BeanValidation 적용 안됨

 

5. 검증 메세지 수정

 Bean Validation을 사용하면서 따로 messages.properties를 설정해주거나 작성해준적이 없는데도, 메세지가 출력됨. 

 해당 라이브러리에서 지정한 기본 메세지인데, 만약 이를 임의로 바꾸고 싶다면 MessageCodeResolver의 메세지 코드를 보면 됨.
 메세지 설정에서 MessageCodeResolver는 다음과 같이 각각의 애노테이션에 대한 메세지코드가 생성됨


@NotBlank
 - NotBlank.item.itemName
 - NotBlank.itemName
 - NotBlank.java.lang.String
 - NotBlank


@Range
 - Range.item.price
 - Range.price
 - Range.java.lang.Integer
 - Range


errors.properties에 메세지 등록

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

 - {0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다름

 

 BeanValidation 메시지 찾는 순서
① 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
② 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
③ 라이브러리가 제공하는 기본 값 사용 → "공백일 수 없습니다."

 

※ 애노테이션의 message 사용 예

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

 

 

객체 검증 하기 ObjectError

1. 객체 검증

 하나의 필드에 붙힐 수 없는 이런 로직상의 검증

ex) 가격과 수량의 합은 10000원 이상이어야 한다.

 

2. @ScriptAssert

2.1 사용 예제

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
	//...
}

 정상 수행이 되고 다음과 같은 순서로 메세지 코드도 찾음
 - ScriptAssert.item
 - ScriptAssert

 

※ 이 방식은 다음과 같은 이유로 실무에서 잘 사용되지 않음
 애노테이션의 기능자체가 강하지 않아 제약이 많고 복잡
 실무에선 검증 기능이 해당 객체의 범위를 벗어나는 경우도 있는데 이 경우 대응이 어려움
 제약조건이 많아질수록 코드가 길어지는데 속성에 로직을 넣기엔 가독성이 너무 떨어지게 됨.

 

3. 직접 코드로 구현하기

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
	// ... //
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
		}
	}
	// ... //
}

※ 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장

 

 

Bean validation의 한계

1. 상황에 따라 달라지는 검증 조건

 지금까지 예제로 다뤄 본 코드는 상품 등록(POST)에 대한 부분이였고, 검증까지 무사히 완료함
상품 수정(Fetch or Put)의 검증이 등록일 경우와 달라질 수 있음

상품 등록시 전송될 내용과 수정시 전송될 내용도 상이할 확률이 높음
ex) 상품 등록시에는 아직 등록이 되지 않았기에 아이디(id)가 존재하지 않지만, 수정시에는 이미 등록된 상품을 수정하는 것이기에 id가 null이여서는 안됨(NotNull)

상품 등록시에는 수량을 1~9999개까지만 허용했지만, 등록후에는 그 외의 값으로 수정을 해도 제약이 없도록 할 수도 있음

 등록과 수정의 상이한 제약조건은 지금 기존에 작성된 상품 엔티티에서는 적용이 불가능
 이처럼 상황에 따라 달라지는 검증 조건을 스프링에서는 다음과 같이 두 가지 방법으로 이를 해결할 수 있음
 - Bean Validation의 groups 기능을 사용하기
 - 전송 객체 분리하기(ItemSaveForm, ItemUpdateForm)

groups는 한계가 명확하기에 전송 객체 분리가 일반적으로 옳은 선택지

 

2. Bean Validation - groups를 사용해 검증 분리

2.1 사용법

등록과 수정 각각의 group을 인터페이스로 만들어서 groups 라는 속성을 사용하면 됨

 

2.2 예제

등록 인터페이스 생성

package hello.itemservice.domain.item;

public interface SaveCheck {}

 

수정 인터페이스 생성

package hello.itemservice.domain.item;

public interface UpdateCheck {}

 

Item 엔티티에 groups 적용

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1000, max = 1_000_000)
    private Integer price;

    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, groups = SaveCheck.class)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

groups는 다수의 그룹도 설정할 수 있으며 필요에따라 맞는 그룹을 선택해 검증할 수 있음

 

controller에서 필요한 검증 groups 선택

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		//...
}

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
		//...
}

 - addItem에서는 상품 저장이기에 @Validated 애노테이션에 속성으로 SaveCheck.class 사용
 - editV2에서는 상품 갱신이기에 @Validated 애노테이션 속성으로 UpdateCheck.class 사용.
 - Item 객체에서는 각각 @Validated 애노테이션에 작성된 인터페이스가 선언된 검증만 수행 

 

※ 이 방식은 사실 잘 사용되지 않음
- 해당 애노테이션자체가 문제가 있는것은 아니고 등록,수정시 전달되는 내용이 상품 도메인 객체(Item)과 딱 일치하지 않음.
ex) 회원 가입을 한다고 할 땐 회원 정보에 더해 약관정보같은 추가 정보가 있을 수 있고 아직 등록하지 않기에 존재하지 않는 정보들도 있을 수 있음 

- 이런 엔티티를 사용자에게 노출시키는 것은 보안상으로도 문제가 많음

- 노출시켜도 되는 필드를 모아 View 객체를 만들어 이를 통해 데이터를 주고받고는 함.

 

※ @Valid 검증 애노테이션은 groups라는 속성이 없기 때문에 해당 기능을 사용할 수 없음

 

 

3. Bean Validation - 등록과 수정의 Form전송 객체 분리로 검증 분리

3.1 객체 분리의 장점

 각각에 상황에맞는 전용 폼 객체를 따로 만들어서 상황에 맞는 검증을 하고, 전송 객체이기에 사용자에게 노출해도 상관이 없는 객체가 됨.

 이렇게 구현 할 경우 도메인 객체로 한번 더 변환을 해서 등록이든 수정이든 해야한다는 추가 과정이 생기지만, 이 과정을 줄이고자 엔티티를 그대로 사용하는 것보다 장점이 더 큼 

 

3.2 예제

 등록 form 전송 객체 생성

@Data
public class ItemSaveForm {
    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1_000_000)
    private Integer price;

    @NotNull
    @Max(9999)
    private Integer quantity;
}

 

 수정 form 전송 객체 생성

@Data
public class ItemUpdateForm {
    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1_000_000)
    private Integer price;

    //수정일 경우 제약은 사라진다.
    private Integer quantity;
}

 

controller에 적용

@PostMapping("/add")
public String addItemV2(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		// ... 생략 ... //

		// 성공 로직
		Item item = new Item();
		item.setItemName(form.getItemName());
		item.setPrice(form.getPrice());
		item.setQuantity(form.getQuantity());
        
		// ... 생략 ... //
}

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
		//...
}

 - @ModelAttribute에 추가되는 value 속성
: 이전과 다르게 컨트롤러에서 @ModelAttribute에 item 이라는 value 속성을 작성해줌. 

만약 이를 작성해주지 않으면 규칙에 따라 MVC Model에는 itemSaveForm라는 이름으로 담기게 됨.

그렇게되면 기존에 뷰 템플릿에서 th:object 이름을 item으로 선언해줬는데 이를 itemSaveForm으로 수정해줘야 함.

 

- Form 객체의 도메인 객체 변환 작업 

: 폼 객체를 기반으로 Item 객체를 생성 및 수정해야 하기 때문에 변환 과정이 작성되야하는데, 폼 객체와 도메인 객체간의 커플링을 최소한으로 할 수 있도록 설계에 주의.
보통 폼 객체와 같은 DTO 에서 도메인을 의존하는것은 괜찮지만 반대의 경우는 괜찮지 않음.
의존의 방향은 변경이 많은곳에서 변경이 적은곳으로 향하는게 바람직.

 

 

Bean validation - HTTP 메세지 컨버터

※ Form 데이터 전송이 아닌 ajax, fetch, axios 등등 프론트 영역에서 API JSON을 요청하는경우에도 @Valid, @Validated는 HttpMessageConvert(@RequestBody)에서도 사용할 수 있음.

 

※ @ModelAttribute, @RequestBody
 - @ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)을 다룰 때 사용
 - @RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용

 

 

1. 예제

1.1 컨트롤러 소스

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
    @PostMapping("/add")
    public Object addItem(@Validated @RequestBody ItemSaveForm form, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");
        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}

 

1.2 postman 테스트 요청 및 응답 정보

※ API의 경우 다음과 같은 3가지 경우가 발생할 수 있다. 

 성공 요청: 성공
 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함


 성공 요청: 성공 - 예시

 - 요청 정보

POST http://localhost:8080/validation/api/items/add
Content-Type: application/json

{"itemName":"hello", "price" : 1000 , "quantity": 100}

 - 응답 데이터

{
    "itemName": "hello",
    "price": 1000,
    "quantity": 10
}


실패 요청: JSON을 객체로 생성하는 것 자체가 실패함 - 예시

 - 요청 정보

POST http://localhost:8080/validation/api/items/add
Content-Type: application/json

{"itemName":"hello", "price": "A", "quantity": 10}

 - 응답 데이터

{
 "timestamp": "2021-04-20T00:00:00.000+00:00",
 "status": 400,
 "error": "Bad Request",
 "message": "",
 "path": "/validation/api/items/add"
}

HttpMessageConverter에서 요청 JSON을 객체로 생성하는 것 자체가 실패하는 경우 문제

지정한 객체(ex: Item)로 만들지 못하기 때문에 컨트롤러 호출이 되지 않기 때문에 Validator도 실행되지 않음

 


검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함 - 예시

 - 요청 정보

POST http://localhost:8080/validation/api/items/add
Content-Type: application/json

{"itemName":"hello", "price": 1000, "quantity": 10000}

 - 응답 데이터

[
    {
        "codes": [
            "Max.itemSaveForm.quantity",
            "Max.quantity",
            "Max.java.lang.Integer",
            "Max"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.quantity",
                    "quantity"
                ],
                "arguments": null,
                "defaultMessage": "quantity",
                "code": "quantity"
            },
            9999
        ],
        "defaultMessage": "9999 이하여야 합니다",
        "objectName": "itemSaveForm",
        "field": "quantity",
        "rejectedValue": 100000,
        "bindingFailure": false,
        "code": "Max"
    }
]

 

 

※ @ModelAttribute vs @RequestBody
 폼 전송방식으로 할 때 @ModelAttribute를 사용할 때는 타입이 불일치해도 발생하지 않는 문제가 @RequestBody를 사용할때는 발생하는 것일까?
 - HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용되기에 특정 필드가 타입이 맞지 않더라도 나머지 필드를 정상 처리할 수 있음.
 - 하지만, HttpMessageConverter는 @ModelAttribute과는 다르게 필드 단위가 아닌 객체 전체 단위로 적용되기 때문에 메세지 컨버팅이 성공해서 객체가 만들어진 다음에나 검증 애노테이션(@Valid, @Validated)이 적용됨

 

※ HttpMessageConverter 단계에서 실패하면 예외가 발생. 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룸

반응형
반응형

검증 요구사항

1. 요구사항

• 타입 검증
    ◦ 가격, 수량에 문자가 들어가면 검증 오류 처리
• 필드 검증
    ◦ 상품명: 필수, 공백X
    ◦ 가격: 1000원 이상, 1백만원 이하
    ◦ 수량: 최대 9999
• 특정 필드의 범위를 넘어서는 검증
    ◦ 가격 * 수량의 합은 10,000원 이상

 

2. 검증 로직의 필요성

• 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것 

정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있음

 

• 참고: 클라이언트 검증, 서버 검증
 - 클라이언트 검증은 조작할 수 있으므로 보안에 취약함
 - 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해짐
 - 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
 - API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함

 

 

검증 과정

1. 상품 저장 성공

ⓞ 사용자가 상품 등록페이지에 접근한다(HTTP GET /add)
① 사용자가 상품정보를 입력 후 서버로 전송한다(HTTP POST /add)
② 상품이 성공적으로 등록된 후 Location 정보로 상품정보 상세경로를 Redirect로 응답함
③ 클라이언트에서는 응답받은 정보에 있는 Location정보로 Redirect하여 신규 상세 페이지로 이동함

 

 

2. 상품 저장 실패

ⓞ 사용자가 상품 등록페이지에 접근한다(HTTP GET /add)
① 사용자가 상품정보를 입력 후 서버로 전송한다(HTTP POST /add)
② 상품의 유효성 검증이 실패하며 검증 오류 결과가 포함된 정보를 담아 다시 상품 등록 페이지로 이동

 

※검증에서 실패하는 대표적인 경우
 - Null
 - TypeMissMatch
 - 비즈니스 요구사항에 맞지 않음
    ex) 상품의 가격은 1000원 이상이여야 하는데 500원으로 작성)

 

 

다양한 검증 방식

1. Map사용 (검증 직접 처리)

※ 서버에서 전달받은 데이터를 직접 검증하여 Map에 담아 RedirectAttributes에 담아 보내는 방법

1.1 상품 추가 controller 소스

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
	//검증 오류 결과를 담음 
	Map<String, String> errors = new HashMap<>();

	//검증 로직
	if(item.getItemName() == null){
		errors.put("itemName", "상품 이름은 필수입니다.");
	}
	//... 기타 검증 로직

	//검증 실패시 다시 입력 폼으로 이동해야 한다.
	if (!errors.isEmpty()) {
		log.info("errors = {}", errors);
		model.addAttribute("errors", errors);
		return "validation/v1/addForm";
	}

	//검증 성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

• 검증에 실패하면 errors라는 Map에 에러 내용을 담아서 model에 담아 타임리프로 반환
• @ModelAttribute 애노테이션이 붙은 Item 객체는 에러가 발생하여 다시 페이지 이동 시 그대로 다시 담겨져 전송되며 타임리프에서 이를 사용할 수 있음
• RedirectAttributes는 uri과 파라미터에 보존할 데이터를 Redirect 할 수 있음.

 

1.2 상품추가 thyemleaf 소스

•  글로벌 오류 메세지

<div th:if="${errors?.containsKey('globalError')}">
	<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>

※ Safe Navigation Operator
 - 여기에서 errors 가 null 이라면  errors.containsKey() 를 호출하는 순간 NullPointerException 이 발생
 - errors?.은 errors가 null 일때 NullPointerException이 발생하는 대신, null 을 반환하는 문법
 - th:if 에서 null 은 실패로 처리되므로 오류 메시지가 출력되지 않음

 

• 오류 메세지 적용

<div>
	<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
	<input type="text" id="itemName" th:field="*{itemName}"
		th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
		class="form-control" placeholder="이름을 입력하세요">
	<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
 		상품명 오류
 	</div>
 </div>

※ input의 필드 오류 처리

 - th:class를 사용하여 해당 필드에 오류가 있으면 기존에 있던 form-control 클래스에 field-error 클래스를 추가하여 반환하고 오류가 없으면 기존에 있던 form-control 클래스만 바인딩

 

※ input의 필드 오류 처리 개선

<input type="text" id="itemName" th:field="*{itemName}"
	th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" 
	class="form-control" placeholder="이름을 입력하세요">

 - classappend를 사용해서 해당 필드에 오류가 있으면 field-error 라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조

 - 만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않음

 

1.3 문제점

 타입이 안맞는 경우(ex: Integer 타입 변수에 String 타입 값을 바인딩 하려는 경우) 컨트롤러까지 가지도 못하고  400 (Bad Request) 에러가 발생하며 오류 페이지를 띄움
잘못된 타입의 값 전달시에도 오류페이지를 보여주지 않고 잘못된 부분을 사용자에게 알려야함.  
해결책 : BindingResult 클래스를 이용해 타입이 잘못된 내용에도 오류 페이지를 내보내지 않도록 할 수 있음

 

 

2. BindingResult를 이용하여 검증 1

2.1 상품 추가 controller 소스

@PostMapping("/add")
// BindingResult가 Map<String, String> errors역할을 해줌
// BindingResult는 model에 자동으로 담아줌
// BindingResult의 위치는 @ModelAttribute Item item뒤에 와야함
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		
	// 검증 로직
	if (!StringUtils.hasText(item.getItemName())) {
		// 이전소스
		//Map<String, String> errors = new HashMap<>();
		//errors.put("itemName", "상품 이름은 필수 입니다.");

		// modelAttribute에 담길 Object명, 필드명, 메시지
		bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수 입니다."));
	}
	// 다른 로직
	// ... 생략 ...
	
	// 특정 필드가 아닌 복합 룰 검증
	if(item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if(resultPrice < 10000) {
			// 이전소스
			//errors.put("globalError", "가격 * 수량은 10,000원 이상이어야 합니다. 현재값 = "+resultPrice);
			bindingResult.addError(new ObjectError("item", "가격 * 수량은 10,000원 이상이어야 합니다. 현재값 = "+resultPrice));
		}
	}
	
	// 검증에 실패하면 다시 입력 폼으로
	if(bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		// 이전소스
		//model.addAttribute("errors",errors);
		return "validation/v2/addForm";
	}
	
	// 성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

 컨트롤러의 매핑 메서드에서 BindingResult를 매개변수로 받음으로써 타입 불일치에 대한 대응 가능
BindingResult 매개변수는 반드시 전송받을 객체(ex: @ModelAttribute Item item) 다음에 위치해야 함 

 bindingResult의 addError 메서드를 이용해 에러내용을 담을 수 있음
 - @ModelAttribute 필드(ex: name, price, quantity, ...)에러인 경우 FieldError객체를 이용해 담으면 됨.


 필드 에러 (FieldError) 생성자 요약

public FieldError(String objectName, String field, String defaultMessage) {}

 - objectName: @ModelAttribute 이름
 - field: 오류가 발생한 필드 이름
 - defaultMessage: 기본 오류 메세지

 

※ 글로벌 오류인 경우 ObjectError객체를 이용해 담으면 된다. 
ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage) {}

 - objectName : @ModelAttribute의 이름

 - defaultMessage : 오류 기본 메시지

 

 

2.2 상품추가 thyemleaf 소스

<form action="item.html" th:action th:object="${item}" method="post">
	<div th:if="${#fields.hasGlobalErrors()}">
		<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">
			글로벌 오류 메시지
		</p> 
	</div>
	<div>
		<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
		<input type="text" id="itemName" th:field="*{itemName}" 
			th:errorclass="field-error" class="form-control"
			placeholder="이름을 입력하세요">
		<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
	</div>

${#fields}: BindingResult가 제공하는 검증 오류에 접근이 가능하다.
 th:errors: 해당 필드에 오류가 있는 경우 태그를 출력 (th:if 편의 버전)
 th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class정보를 추가

 

※ BindingResult를 사용할 경우 클라이언트에서 타입이 잘못된 내용이 전송되더라도 BindingResult에 그 오류내용(FieldError)을 담아서 컨트롤러를 정상 호출함

※  BindingResult의 내용은 자동으로 Model에 담겨지기 때문에 타임리프에서도 자연스럽게 사용 가능

 

2.3 문제점

 사용자가 잘못 입력해서 전송한 데이터가 남아있지 않음 (사용자 입력 값을 유지할 수 없음)

 사용자가 잘못 입력한 내용이 뭔지 잊을수도있고, 혹은 에러내용을 봐도 에러 내용이 자세하지 않으면 내가 어디가 어떻게 잘못 입력했는지 파악하기 힘들어짐  
 매번 에러 메세지를 하드코딩으로 입력해야하는것도 쉽지 않고 코드 중복이 심함
해결책 : 위에서 사용한 FieldError에 오버로딩 된 생성자가 존재함 

 

 

3. BindingResult를 이용하여 검증 2 (사용자가 입력한 값을 유지하는 방법)

3.1 FieldError에 오버로딩 된 생성자 분석

public FieldError(String objectName,               // 오류가 발생한 객체 이름
                  String field,                    // 오류 필드
                  @Nullable Object rejectedValue,  // 사용자가 입력한 값(거절된 값)
                  boolean bindingFailure,          // 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 
                  @Nullable String[] codes,        // 메세지 코드
                  @Nullable Object[] arguments,    // 메세지에서 사용하는 인자
                  @Nullable String defaultMessage) // 기본 오류 메세지.

//사용 예
new FieldError( "item", 
	        "itemName", 
	        item.getItemName(),	// 사용자가 입력 한 값 (거절된 값)
	        false,
	        null,
	        null,
	        "상품 이름은 필수입니다.")

 

3.2 controller 소스

@PostMapping("/add")
// BindingResult의 fieldError에 입력한 값 다시 넣어주는 오버로딩된 메서드 활용
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
		
	// 검증 로직
	if (!StringUtils.hasText(item.getItemName())) {
		// feildError의 파라미터 : 오류가 발생한 객체이름, 오류 필드, 사용자가 입력한값(거절된값), 타입오류 같은 바인딩 실패인지 검증실패인지 구분값, 메시지코드, 메시지에서 사용하는 인자, 기본 오류 메시지
		bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수 입니다."));
	}

	// 다른 로직
	// ... 생략 ...
		
	// 검증에 실패하면 다시 입력 폼으로
	if(bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		return "validation/v2/addForm";
	}
	
	// 성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

 bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수 입니다."));

 - 사용자가 입력한 값 (거절된 값을 3번째 인자로 전달)

 

 

3. BindingResult를 이용하여 검증 3 (errors.proerties로 에러 메세지 관리)

3.1 FieldError에 오버로딩 된 생성자 분석

public FieldError(String objectName,               // 오류가 발생한 객체 이름
                  String field,                    // 오류 필드
                  @Nullable Object rejectedValue,  // 사용자가 입력한 값(거절된 값)
                  boolean bindingFailure,          // 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값 
                  @Nullable String[] codes,        // 메세지 코드
                  @Nullable Object[] arguments,    // 메세지에서 사용하는 인자
                  @Nullable String defaultMessage) // 기본 오류 메세지.

//사용 예
new FieldError( "item", 
	        "price", 
	        item.getPrice(),	
	        false,
	        new String[]{"range.item.price"},    // 메세지 코드
	        new Object[]{1000, 1000000},         // 메세지에서 사용하는 인자
	        null)

 

3.2 메세지 코드 사용

스프링 부트 메세지 설정 추가 (application.properties) 파일

spring.messages.basename=messages,errors

 

resources/errors.properties 파일 추가

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

3.3 controller 소스

@PostMapping("/add")
// error.properties 기능 사용
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
	// 다른 로직
	// ... 생략 ...
		
	// 검증 로직
	if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
		// new Object[]{1000, 100000}는 properties 입력한 값의 {0} {1}의 치환 인자 값
		bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
	}
		
	// 다른 로직
	// ... 생략 ...

	// 검증에 실패하면 다시 입력 폼으로
	if(bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		return "validation/v2/addForm";
	}
	
	// 성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[] "range.item.price"}, new Object[]{1000, 1000000}, null));

 - 사용할 메세지 코드 (거절된 값을 5번째 인자로 전달)

 - 사용할 메세지 코드에 넘길 인자 (거절된 값을 6번째 인자로 전달)

 

3.4 문제점

 소스 작성이 너무 번거롭고 에러하나 담는데 넣어야 할 속성도 너무 많음
 messages의 이름도 range.item.price을 매번 다 적는것도 번거로움
해결책 : BindingResult에서는 rejectValue(), reject() 메서드를 통해 FieldError, ObjectError을 직접 생성하지 않아도 되도록 해줌

 

 

4. BindingResult를 이용하여 검증 4 (rejectValue(), reject() 메서드)

4.1 사용 예시

//before
bindingResult.addError(new FieldError("item", "itemName",item.getItemName(), false, new String[]{"required.item.itemName"}, null, null))
bindingResult.addError(new FieldError("item", "price", item.getPrice(),false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null))

//after
bindingResult.rejectValue("itemName", "required");
bindingResult.rejectValue("price", "range", new Object[]{1000, 1_000_000}, null);



// before
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[] {10000, resultPrice}, null));

// after
bindingResult.reject("totalPriceMin", new Object[] {10000, resultPrice}, null);

 

4.2 rejectValue 메서드의 매개 변수

void rejectValue(@Nullable String field,         // 오류 필드명
		String errorCode,                // MessageResolver를 위한 오류 코드
		@Nullable Object[] errorArgs,    // 오류 메세지에서 {0}을 치환하기 위한 값
		@Nullable String defaultMessage);// 오류 메세지를 못찾을 경우 기본 메세지

 

4.3 reject 메서드 매개 변수

void reject(String errorCode,                    // MessageResolver를 위한 오류 코드
		@Nullable Object[] errorArgs,    // 오류 메세지에서 {0}을 치환하기 위한 값
		@Nullable String defaultMessage);// 오류 메세지를 못찾을 경우 기본 메세지

※ FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했으나 rejectValue()를 사용하고 부터는 오류 코드를 range 로 간단하게 입력함.

 

 field와 errorCode 매개변수를 가지고 errors.properties에서 메세지를 찾아낸다는 것인데, rejectValue()와 rejct()는 내부에서 MessageCodesResolver를 통해서 찾아냄.

 

 

MessageCodesResolver

1. MessageCodesResolver 인터페이스 분석

 스프링에서 제공하는 마커 인터페이스인 MessageCodesResolver는 다음과 같은 메서드가 정의되어 있음

public interface MessageCodesResolver {
	String[] resolveMessageCodes(String errorCode, String objectName);
	String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType);
}

※ 이 인터페이스의 기본 구현체로 DefaultMessageCodesResolver를 제공하는데 이를 이용해서 각종 메세지에 대한 대처가 쉽게 가능함.


2. MessageCodesResolver의 동작

 메세지 혹은 예외메세지는 특정 필드에 맞는 메세지가 있을수도 있지만 범용성이 높은 메세지도 있을 수 있음

 예를들어 required.item.itemName=상품 이름은 필수 입니다. 라고 디테일하게 에러 메세지를 작성할 수 있지만,

required=필수 값입니다. 라고 범용적인 메세지를 작성할수도 있음.

 범용성의 수준에따라 단계를 만들어두면 MessageCodesResolver는 범용성이 낮은순서에서 높은순서로 차례대로 찾으면서 처음 매칭되는 결과를 가져옴

 

 메세지 예시

#level 1
required.item.itemName: 상품 이름은 필수입니다.

#level 2
required: 필수 값 입니다.

 - MessageCodesResolver는 디테일한순서부터 차례대로 찾음

 - 만약 level1이 작성되어있지 않다면 level2의 required값을 찾아서 담음

 - 이렇게 작성하면 오류메세지에 대한 대응이 한결 편해짐

 

2. DefaultMessageCodesResolver의 기본 매세지 생성 규칙

※ 객체 오류와 필드 오류를 범용성 순으로 찾음.
2.1 객체 오류

객체 오류의 경우 다음 순서로 2가지 생성 
1.: code + "." + object name 
2.: code

예) 오류 코드: required, object name: item 
1.: required.item
2.: required


2.2 필드 오류
※ 필드 오류의 경우 다음 순서로4가지 메시지 코드 생성

1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int 
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"

※ 구체적인 것에서 덜 구체적인 순으로 찾음

 

2.3 예시

 reject("totalPriceMin") ObjectError  발생 시
다음 2가지 오류 코드를 자동으로 생성

new String[]{"totalPriceMin.item", "totalPriceMin"}을 내부에서 만들어 메세지를 찾음
 - totalPriceMin.item
 - totalPriceMin

 

 rejectValue("itemName", "required") FieldError 발생 시 
다음 4가지 오류 코드를 자동으로 생성

new String[]{"required.item.itemName", "required.itemName", "required.java.lang.String", "required"} 를 내부에서 만들어 메세지를 찾음
 - required.item.itemName
 - required.itemName
 - required.java.lang.String
 - required


 오류 메시지 출력
 - 타임리프 화면을 렌더링 할 때 th:errors가 실행

 - 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾음

 - 없으면 디폴트 메시지 출력

 

 

3. MessageCodesResolver 사용해보기(테스트)

3.1 객체 오류 조회 해보는 테스트

@Test
void messageCodesResolverObject() {
	DefaultMessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
	String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
	for (String msg : messageCodes) {
		System.out.println(msg);
	}
}

 - required.item
 - required

 

 

3.2 필드 오류 조회 해보는 테스트

@Test
void messageCodesResolverField() {
	DefaultMessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
	String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
	for (String msg : messageCodes) {
		System.out.println(msg);
	}
}

 - required.item.itemName
 - required.itemName
 - required.java.lang.String
 - required

 

 

ValidationUtils

※ 유효성 검증을 더 편하게 작성할 수 있는 객체

//before
if (!StringUtils.hasText(item.getItemName())) { 
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다."); 
}

//after
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

 너무 복잡한 검증은 힘들고 위 코드처럼 단순한 Empty나 공백처리같은 기능만 제공
 내부적으로 rejectValue를 호출하고 위에서 우리가 소개한 여러 방식들을 사용해서 에러를 담음
(MessageCodesResolver, new FiledError(), ...)

 

 

 

스프링에서 제공하는 기본 오류 메세지

※ 직접 정의한 오류 코드는 rejectValue()를 직접 호출해서 담아주지만, 스프링이 직접 검증 오류에 추가한 경우도 있음 (주로 타입 정보 불일치)

스프링에서 직접 검증 오류에 추가를 한 것으로 BindingResult에 FieldError가 다음과 같은 메세지코드가 생성되어 추가됨.

codes[
typeMismatch.item.price, 
typeMismatch.price, 
typeMismatch.java.lang.Integer, 
typeMismatch
]

※ 스프링은 타입 오류가 발생하면 자동으로 위 오류 코드들을 사용하게 됨

 errors.properties에는 해당 내용으로 정의한 메세지가 없기 때문에 스프링에서 정의한 기본 메세지가 출력됨

※ 하지만, 기본 메세지는 너무 장황하고 길어서 개발자가아닌 사용자에게 노출해서는 안됨
 errors.properties에 다음과 같이 메세지를 추가

typeMismatch.java.lang.Integer=숫자를 입력해주세요. 
typeMismatch=타입 오류입니다.



Validator 분리

※ 검증 로직은 중복이 많고, 매번 필요할때마다 작성하는것은 비효율적이지만 중요도가 높은만큼 생략할수도 없음

※ 이런 검증 로직을 별도의 클래스로 분리해서 모듈화하면 재사용성이 높아지고 가독성또한 높아질 수 있음.

 

1. 분리 예제 1

1.1 validator 인터페이스 분석

public interface Validator {
	boolean supports(Class<?> clazz);
	void validate(Object target, Errors errors);
}

 인터페이스는 책임 사슬 패턴에서 주로보이는 메서드인 supports와 실제 검증을 수행하는 validate메서드를 정의하고있음.

Validator 인터페이스를 구현하면서 Item에 대한 검증로직을 구현

 

1.2 ItemValidator 객체 생성

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
        // isAssignableFrom을 사용하면, class와 자식 클래스까지 검증함
        // item == clazz
        // item == subItem
    }

    @Override
    public void validate(Object target, Errors errors) {
        Item item = (Item) target;

        ValidationUtils.rejectIfEmpty(errors, "itemName", "required");

        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }
        //복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }
    }
}

 Item.class.isAssignableFrom(clazz): 해당 Validator 구현체는 Item 클래스에 대한 검증을 수행할 수 있음을 의미
 Errors errors : 매개변수타입인 Errors는 BindingResult클래스의 부모 타입이기 때문에 공변성이 성립함


※ itemValidator는 Component라 Component Scan으로 등록되었기 때문에 Dependency Injection을 받아서 컨트롤러에서 사용가능

 

1.3 controller에서 itemValidator사용 법

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

        ...

    @PostMapping("/add")
    public String addItemV5(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        itemValidator.validate(item, bindingResult);				

        //검증 실패시 다시 입력 폼으로 이동해야 한다.
        if (bindingResult.hasErrors()) {
            log.info("errors = {}", bindingResult);
            return "validation/v2/addForm";
        }
        
        ...
        
    }
}

※ 컨트롤러에 있던 검증 로직이 itemValidator.validate()메서드 호출로 검증이 가능

 

 

2. 분리 예제 2 - 에노테이션

※ 스프링에서는 Validator 인터페이스를 구현해서 검증로직을 만들면 추가적으로 애너테이션을 사용하여 검증을 수행할수도 있음

 WebDataBinder를 이용하는 것인데 이 클래스는 스프링의 파라미터 바인딩의 역할 및 검증 기능도 내부에 포함하는 클래스

 객체에 내가 만든 검증기를 추가(add)하면 자동으로 검증기 적용이 가능

 

2.1 한 컨트롤러에만 적용 - 예제1

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

	private final ItemRepository itemRepository;
	private final ItemValidator itemValidator;

	// 컨트롤러가 호출 될때마다 항상 실행됨
	@InitBinder
	// item 객체에 파라미터 바인딩 해주고, 검증기를 가지고 검증을 해줌
	// Spring MVC가 내부에서 검증기를 적용
	public void init(WebDataBinder dataBinder) {
		dataBinder.addValidators(itemValidator);
	}
    
	...
    
	@PostMapping("/add")
	// @Validated : item에 대해서 자동으로 검증기가 수행이됨 
	//자동 검증
	public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

	//검증 실패시 다시 입력 폼으로 이동해야 한다.
	if (bindingResult.hasErrors()) {
		log.info("errors = {}", bindingResult);
		return "validation/v2/addForm";
	}
	...
}
    
}

 WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있음

 @InitBinder → 해당 컨트롤러에만 영향을 줌

@Validated 어노테이션으로  item에 대해서 자동으로 검증기가 수행됨

 

2.2 글로벌 설정 방법

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Override
	public Validator getValidator() {
		return new ItemValidator();
	}
}

 

2.3 @Validated, @Valid
 @Validated : org.springframework.validation.annotation.Validated가 스프링 전용 검증 애너테이션
 @Valid : javax.validation.@Valid는 자바 표준 검증 애너테이션

 둘 다 역할은 동일하지만, @Valid는 build.gradle에 다음과 같은 의존성을 추가해줘야 함

implementation 'org.springframework.boot:spring-boot-starter-validation'
반응형
반응형

메시지, 국제화 소개

1. 메세지

• 상품명이라는 단어를 모두 상품이름으로 고쳐야할 경우 여러 화면에 있는 상품명을 찾아가면서 수동으로 모두 변경해야 함

• HTML 파일에 메시지가 하드코딩 되어 있기 때문인데 이런 다양한 메시지를 한 곳에서 관리하도록 하는 기능을 메시지 기능이라 함.

 

• 적용 방법

 - messages.properties메시지 관리용 파일 생성 후 해당 데이터를 key 값으로 불러서 사용

ex) messages.properties메시지

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

ex) 적용

addForm.html
editForm.html
<label for="itemName" th:text="#{item.itemName}"></label>

 

 

2. 국제화

• 메시지에서 설명한 메시지 파일(messages.properties)을 각 나라별로 별도로 관리하면 서비스를 국제화 할 수 있음

ex) messages_en.properties메시지

item=Item
item.id=Item ID
item.itemName=Item Name
item.price=price
item.quantity=quantity

ex) messages_ko.properties메시지

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

• 영어를 사용하는 사람이면 messages_en.properties를 사용하고, 한국어를 사용하는 사람이면 messages_ko.properties를 사용하게 개발하면 사이트를 국제화 할 수 있음

• 한국어로 표현할지 영어에 표현할지 판단하는 방법은 HTTP accept-language 해더 값을 사용하거나 사용자가 직접 언어를 선택하도록 하고, 쿠키 등을 사용해서 처리하면 됨

• 메시지와 국제화 기능을 직접 구현할 수도 있겠지만, 스프링은 기본적인 메시지와 국제화 기능을 모두 제공함.

타임리프도 스프링이 제공하는 메시지와 국제화 기능을 편리하게 통합해서 제공함

 

 

스프링 메시지 소스 설정

1. 메세지 관리 빈 직접 등록

@Bean
public MessageSource messageSource() {
	ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
	messageSource.setBasenames("messages", "errors");
	messageSource.setDefaultEncoding("utf-8");
	return messageSource;
}

 MessageSource를 스프링 빈으로 등록하면 되는데, MessageSource는 인터페이스임

 따라서 구현체인 ResourceBundleMessageSource를 스프링 빈으로 등록하면 됨.

 

messageSource.setBasenames("messages", "errors")

 - basenames : 설정 파일의 이름을 지정

 - 여러 파일을 한번에 지정 가능(messages , errors)

 - messages로 지정하면 messages.properties 파일을 읽어서 사용

 -

추가로 국제화 기능을 적용하려면 messages_en.properties , messages_ko.properties와 같이 파일명 마지막에 언어 정보를 주면됨

 - 만약 찾을 수 있는 국제화 파일이 없으면 messages.properties (언어정보가 없는 파일명)를 기본으로 사용.
 - 파일의 위치는 /resources/messages.properties에 두면 됨.


 defaultEncoding : 인코딩 정보 지정 utf-8 사용

 

2. 스프링 부트 메세지 소스 설정

2.1 MessageSource 설정

 스프링 부트를 사용하면 스프링 부트가 application.properties를 읽어 MessageSource를 자동으로 스프링 빈으로 등록

 

2.2 application.properties 설정

 spring.messages.basename=messages,errors

 - 위의 basenames설정과 같음

 

※ 스프링 부트 메시지 소스 기본 값

 - spring.messages.basename=messages

※ MessageSource를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages 라는 이름으로 기본 등록

※ messages_en.properties , messages_ko.properties , messages.properties 파일만 등록하면 자동으로 인식.

 

 

3. 메세지 파일 만들기

 /resources/messages.properties 파일 생성

hello=안녕
hello.name=안녕 {0}

 

 /resources/messages_en.properties 파일 생성

hello=hello
hello.name=hello {0}

 

 

스프링 메세지 소스 사용

1. MessageSource 인터페이스

public interface MessageSource {
	String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
	String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

 코드를 포함한 일부 파라미터로 메시지를 읽어오는 기능을 제공

 

 

2. 등록한 메세지 프로퍼티스 테스트 소스

2.1 단순한 메세지 조회 테스트 소스

@Test
void helloMessage() {
	String result = ms.getMessage("hello", null, null);
	assertThat(result).isEqualTo("안녕");
}

 ms.getMessage("hello", null, null)
 - code: hello
 - args: null
 - locale: null

locale 정보가 없으면 Locale.getDefault()을 호출해서 시스템의 기본 로케일을 사용하여 메시지 파일을 조회

locale 정보에 맞는 messages_xx.properties가 없다면 기본 messages.properties조회

application.properties에서 basename으로 messages를 지정 했으므로 messages.properties 파일에서 데이터 조회

 

 

2.2 메시지가 없는 경우, 기본 메세지 테스트 소스

@Test
void notFoundMessageCode() {
	assertThatThrownBy(() -> ms.getMessage("no_code", null, null))
	.isInstanceOf(NoSuchMessageException.class);
}

@Test
void notFoundMessageCodeDefaultMessage() {
	String result = ms.getMessage("no_code", null, "기본 메시지", null);
	assertThat(result).isEqualTo("기본 메시지");
}

 메시지 코드가 없는 경우에는 NoSuchMessageException이 발생

 메시지코드가 없어도 기본 메시지( defaultMessage )를 사용하면 기본 메시지가 반환됨

 

 

2.3 매개변수를 사용 하는 메세지 테스트 소드

@Test
void argumentMessage() {
	String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
	assertThat(result).isEqualTo("안녕 Spring");
}

hello.name=안녕 {0} → Spring 단어를 매개변수로 전달  안녕 Spring

 

 

3. 국제화 파일 선택

3.1 국제화 파일 선택 하는 메세지 테스트 소스 1

@Test
void defaultLang() {
	assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");
	assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");
}

 ms.getMessage("hello", null, null) : locale 정보가 없으므로 messages를 사용
 ms.getMessage("hello", null, Locale.KOREA) : locale 정보가 있지만, message_ko가 없으므로 messages 를 사용

 

 

3.2 국제화 파일 선택 하는 메세지 테스트 소스 2

@Test
void enLang() {
	assertThat(ms.getMessage("hello", null,
	Locale.ENGLISH)).isEqualTo("hello");
}

 ms.getMessage("hello", null, Locale.ENGLISH) : locale 정보가 Locale.ENGLISH이므로 messages_en을 찾아서 사용

 

 

웹 애플리케이션에 메세지 적용하기

1. 메세지 등록

 messages.properties에 메세지 추가

label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량

page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정

button.save=저장
button.cancel=취소

 

2. 타임리프에 메세지 적용

타임리프의 메세지 표현식 : #{ ... }

2.1 페이지 이름에 적용

<h2 th:text="#{page.addItem}">상품 등록</h2>

 

2.2 레이블에 적용

<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<label for="price" th:text="#{label.item.price}">가격</label>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>

 

2.3 버튼에 적용

<button type="submit" th:text="#{button.save}">저장</button>
<button type="button" th:text="#{button.cancel}">취소</button>

 

2.4 파라미터에 사용하는 방법

<p th:text="#{hello.name(${item.itemName})}"></p>

 

 

웹 어플리케이션에 국제화 적용하기

1. messages_en.properties에 메세지 추가

label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity

page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update

button.save=Save
button.cancel=Cancel

 

2. 웹으로 확인하기

 웹 브라우저의 언어 설정 값을 변경하면서 국제화 적용을 확인가능
크롬 브라우저 설정 언어를 검색하고, 우선 순위를 변경하면 됨

 웹 브라우저의 언어 설정 값을 변경하면 요청시 Accept-Language 의 값이 변경
 Accept-Language 는 클라이언트가 서버에 기대하는 언어 정보를 담아서 요청하는 HTTP 요청 헤더임

 

3. 스프링의 국제화 메시지 선택

 메시지 기능은 Locale 정보를 알아야 언어를 선택할 수 있음
스프링도 Locale 정보를 알아야 언어를 선택할 수 있는데, 스프링은 언어 선택시 기본으로 Accept-Language 헤더의 값을 사용함.

 

4. LocaleResolver

 스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver라는 인터페이스를 제공함.
 스프링 부트는 기본으로 Accept-Language를 활용하는 AcceptHeaderLocaleResolver를 사용

 

 LocaleResolver 인터페이스

public interface LocaleResolver {
	Locale resolveLocale(HttpServletRequest request);
	void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);
}


 LocaleResolver 변경
 - Locale 선택 방식을 변경하려면 LocaleResolver의 구현체를 변경해서 쿠키나 세션 기반의 Locale 선택 기능을 사용할 수 있음

 - 고객이 직접 Locale을 선택하도록 변경가능

반응형
반응형

타임리프 스프링 통합

1. 메뉴얼

기본 메뉴얼: https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html
스프링 통합 메뉴얼: https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html

 

2. 스프링 통합으로 추가되는 기능들

• 스프링의 SpringEL 문법 통합
• ${@myBean.doSomething()} 처럼 스프링 빈 호출 지원
• 편리한 폼 관리를 위한 추가 속성
  ◦ th:object (기능 강화, 폼 커맨드 객체 선택)
  ◦ th:field , th:errors , th:errorclass

• 폼 컴포넌트 기능
  ◦ checkbox, radio button, List 등을 편리하게 사용할 수 있는 기능 지원
• 스프링의 메시지, 국제화 기능의 편리한 통합
• 스프링의 검증, 오류 처리 통합
• 스프링의 변환 서비스 통합(ConversionService)

 

3. 설정 방법

• 타임리프 템플릿 엔진을 스프링 빈에 등록하고, 타임리프용 뷰 리졸버를 스프링 빈으로 등록하는 방법 메뉴얼
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#the-springstandarddialect
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#views-and-viewresolvers

 

• 스프링 부트는 이런 부분을 build.gradle에 다음 한줄을 넣어주면 모두 자동화 해줌

• Gradle은 타임리프와 관련된 라이브러리를 다운로드 받고, 스프링 부트는 앞서 설명한 타임리프와 관련된 설정용
스프링 빈을 자동으로 등록해줌

※ build.gradle 파일

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

• 타임리프 관련 설정 변경 메뉴얼(application.properties에 설정추가)

 - 스프링 부트가 제공하는 타임리프 설정메뉴얼 (thymeleaf 라고 검색)

https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-applicationproperties.html#common-application-properties-templating

 

 

입력 및 수정 폼 처리

1. 설명

• th:object : 커맨드 객체를 지정
• *{...} : 선택 변수 식이라고 한다. th:object 에서 선택한 객체에 접근
• th:field : HTML 태그의 id , name , value 속성을 자동으로 처리해줌
 - 렌더링 전
<input type="text" th:field="*{itemName}" />
 - 렌더링 후
<input type="text" id="itemName" name="itemName" th:value="*{itemName}" />

 

 

2. 등록 폼 예제

2.1 controller 소스

@GetMapping("/add")
public String addForm(Model model) {
	model.addAttribute("item", new Item());
	return "form/addForm";
}

2.2 Item 객체

@Setter @Getter
public class Item {
	private Long id;
	private String itemName;
	private Integer price;
	private Integer quantity;

	public Item(String itemName, Integer price, Integer quantity) {
		this.itemName = itemName;
		this.price = price;
		this.quantity = quantity;
	}
}

 

2.3 thyemleaf 소스

<form action="item.html" th:action th:object="${item}" method="post">
	<div>
		<label for="itemName">상품명</label>
		<input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
	</div>
	<div>
		<label for="price">가격</label>
		<input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
	</div>
	<div>
		<label for="quantity">수량</label>
		<input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
	</div>

• th:object="${item}"

 - <form> 에서 사용할 객체를 지정한다. 선택 변수 식( *{...} )을 적용할 수 있음

 

• th:field="*{itemName}"
 - *{itemName} 는 선택 변수 식을 사용했는데, ${item.itemName} 과 같음.

 - th:object 로 item 을 선택했기 때문에 선택 변수 식을 적용할 수 있음
 - th:field 는 id , name , value 속성을 모두 자동으로 만들어줌

    ◦ id : th:field 에서 지정한 변수 이름과 같음 id="itemName"
    ◦ name : th:field 에서 지정한 변수 이름과 같음 name="itemName"
    ◦ value : th:field 에서 지정한 변수의 값을 사용함 value=""

 

※  예제에서 id 속성을 제거해도 th:field 가 자동으로 만들어줌
 - 렌더링 전
<input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
 - 렌더링 후
<input type="text" id="itemName" class="form-control" placeholder="이름을 입력하세요" name="itemName" value="">

 

 

3. 수정 폼 예제

3.1 controller 소스

@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
	Item item = itemRepository.findById(itemId);
	model.addAttribute("item", item);
	return "form/editForm";
}

 

3.2 thyemleaf 소스

<form action="item.html" th:action th:object="${item}" method="post">
	<div>
		<label for="id">상품 ID</label>
		<input type="text" id="id" th:field="*{id}" class="form-control" readonly>
	</div>
	<div>
		<label for="itemName">상품명</label>
		<input type="text" id="itemName" th:field="*{itemName}" class="formcontrol">
	</div>
	<div>
		<label for="price">가격</label>
		<input type="text" id="price" th:field="*{price}" class="form-control">
	</div>
	<div>
		<label for="quantity">수량</label>
		<input type="text" id="quantity" th:field="*{quantity}" class="formcontrol">
	</div>

수정 폼의 경우도 입력폼과 마찬가지로 id , name , value 를 모두 신경써야 했는데, 많은 부분이 th:field로 자동으로 처리됨

수정 폼 변화

 - 렌더링 전
<input type="text" id="itemName" th:field="*{itemName}" class="form-control">
 - 렌더링 후
<input type="text" id="itemName" class="form-control" name="itemName" value="itemA">

 

 

요구사항 추가

1. 요구사항 목록

판매 여부 등록 지역 상품 종류 배송 방식
판매 오픈 여부
(체크 박스)
• 서울, 부산, 제주
(다중 체크박스로 ) 
도서, 식품, 기타
(라디오버튼)
빠른배송, 일반배송, 느린배송
(셀렉트 박스, 드롭다운)

 

2. 요구사항 예시 이미지

 

3. 객체 생성

3.1 상품 종류(ItemType) enum객체 생성

package hello.itemservice.domain.item;

public enum ItemType {
	BOOK("도서"), FOOD("음식"), ETC("기타");
	
	private final String description;

	ItemType(String description) {
		this.description = description;
	}

	public String getDescription() {
		return description;
	}	
}

 

3.2 배송 방식(DeliveryCode)객체 생성

package hello.itemservice.domain.item;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * FAST : 빠른배송
 * NORMAL : 일반 배송
 * SLOW : 느린 배송
 */

@Data
@AllArgsConstructor
public class DeliveryCode {
	private String code;
	private String displayName;
}

 

3.3 상품(Item)객체에 필드 추가

package hello.itemservice.domain.item;

import java.util.List;

import lombok.Data;

@Data
public class Item {
	private Long id;
	private String itemName;
	private Integer price;
	private Integer quantity;

	private Boolean open;		// 판매 여부
	private List<String> regions;	// 등록 지역
	private ItemType itemType;	// 상품 종류
	private String deliveryCode;	// 배송 방식

	public Item() {
	}

	public Item(String itemName, Integer price, Integer quantity) {
		this.itemName = itemName;
		this.price = price;
		this.quantity = quantity;
	}
}

 

 

체크박스 - 단일 1 (html 구현)

1. 일반적인 체크박스의 데이터 확인 예제

1.1 html 소스

<!-- single checkbox -->
<div>판매 여부</div>
<div>
	<div class="form-check">
		<input type="checkbox" id="open" name="open" class="form-check-input">
		<label for="open" class="form-check-label">판매 오픈</label>
	</div>
</div>

 

1.2 controller 소스

@PostMapping("/add")
public String addItem(Item item, RedirectAttributes redirectAttributes) {
	log.info("item.open={}", item.getOpen());
	...
}

 form에서 controller로 데이터가 넘어와서 어떻게 바인딩 되는지 log확인

 - 체크박스를 체크 한 후 데이터 확인

item.open=true

 

 - 체크박스를 체크하지 않은 후 데이터 확인

item.open=null

 

 체크 박스를 체크하면 HTML Form에서 open=on 이라는 값이 넘어감

스프링은 on 이라는 문자를 true 타입으로 자동 변환해줌

 

 

2. 체크 박스를 선택하지 않을 때 이슈

2.1 이슈 상황

 HTML에서 체크 박스를 선택하지 않고 폼을 전송하면 open이라는 필드 자체가 서버로 전송되지 않음.

 수정의 경우 상황에 따라서 이 방식이 문제가 될 수 있음

 사용자가 의도적으로 체크되어 있던 값을 체크를 해제해도 저장 시 아무 값도 넘어가지 않기 때문에, 

서버 구현에 따라서 값이 오지 않은 것으로 판단해서 값을 변경하지 않음

 

2.2 해결

 스프링 MVC는 히든 필드를 하나 만들어서 _open 처럼 기존 체크 박스 이름 앞에 언더스코어( _ )를 붙여서 전송하면 체크를 해제했다고 인식할 수 있음

 히든 필드는 항상 전송되어 체크를 해제한 경우 open 필드는 전송되지 않고 _open 만 전송되는데 이를 통해 스프링 MVC는 체크를 해제했다고 판단함.

 

 

3. 히든필드 예제

<!-- single checkbox -->
<div>판매 여부</div>
<div>
	<div class="form-check">
		<input type="checkbox" id="open" name="open" class="form-check-input">
		<input type="hidden" name="_open" value="on">  <!-- 히든 필드 추가 -->
		<label for="open" class="form-check-label">판매 오픈</label>
	</div>
</div>

 체크 박스 체크 후 form 데이터 전송

 - 로그 : item.open=true
 - form에서 전송된 데이터 : open=on&_open=on

 - 체크 박스를 체크하면 스프링 MVC가 open 에 값이 있는 것을 확인하고 사용함

 - 이때 _open은 무시

 

 체크 박스 미체크 후 form 데이터 전송

 - 로그 : item.open=false
 - form에서 전송된 데이터 : _open=on
- 체크 박스를 체크하지 않으면 스프링 MVC가 _open만 있는 것을 확인하고, open 의 값이 체크되지
않았다고 인식

 

체크박스 - 단일 2 (Thyemleaf로 구현)

1. 타임리프로 체크박스의 데이터 확인 예제

※ thyemleaf 소스

<!-- single checkbox -->
<div>판매 여부</div>
<div>
	<div class="form-check">
		<input type="checkbox" id="open" th:field="*{open}" class="form-checkinput">
		<label for="open" class="form-check-label">판매 오픈</label>
	</div>
</div>

※ HTML 렌더링 결과

<!-- single checkbox -->
<div>판매 여부</div>
<div>
	<div class="form-check">
		<input type="checkbox" id="open" class="form-check-input" name="open" value="true">
		<input type="hidden" name="_open" value="on"/>
		<label for="open" class="form-check-label">판매 오픈</label>
	</div>
</div>

 타임리프를 사용하면 체크 박스의 히든 필드와 관련된 부분도 함께 해결해줌

 HTML 생성 결과를 보면 히든 필드 부분이 자동으로 생성되어 있음

 

 

2. 상품 상세 페이지 적용

※ thyemleaf 소스

<!-- single checkbox -->
<div>판매 여부</div>
<div>
	<div class="form-check">
		<input type="checkbox" id="open" th:field="${item.open}" class="formcheck-input" disabled>
		<label for="open" class="form-check-label">판매 오픈</label>
	</div>
</div>

※ HTML 렌더링 결과

<!-- single checkbox -->
<div class="form-check">
	<input type="checkbox" id="open" class="form-check-input" disabled name="open" value="true" checked="checked">
	<label for="open" class="form-check-label">판매 오픈</label>
</div>

 타임리프의 checked="checked"

 - 체크 박스에서 판매 여부를 체크해서 저장하면, 조회시에 checked 속성이 자동으로 추가 되어 있음

 - 타임리프의 th:field를 사용하면 값이 true 인 경우 체크를 자동으로 처리해줌

 

 상품 상세 페이지에는 checkbox에 disabled 처리가 되어있어서 hidden 필드를 자동으로 생성해주지 않음

 

 

3. 상품 수정 페이지 적용

상품 상세 페이지와 동일 하지만 렌더링 결과에 hidden 필드를 자동으로 생성해줌.

 

 

체크 박스 - 멀티

1. controller에 체크 박스 관련 데이터 로직 추가

@ModelAttribute("regions")
public Map<String, String> regions() {
	Map<String, String> regions = new LinkedHashMap<>();
	regions.put("SEOUL", "서울");
	regions.put("BUSAN", "부산");
	regions.put("JEJU", "제주");
	return regions;
}

 @ModelAttribute의 특별한 사용법

 - 등록 폼, 상세화면, 수정 폼에서 모두 서울, 부산, 제주라는 체크 박스를 반복해서 보여주어야 함

 - 각각의 컨트롤러에서 model.addAttribute(...)를 사용해서 데이터를 반복해서 넣어주어야 함

 - @ModelAttribute 는 이렇게 컨트롤러에 있는 별도의 메서드에 적용할 수 있음

 - 해당 컨트롤러를 요청할 때 regions 에서 반환한 값이 모든 메서드에 자동으로 모델(model)에 담김

 

 

2. 상품 추가 폼 thyemleaf

<!-- multi checkbox -->
<div>
	<div>등록 지역</div>
	<div th:each="region : ${regions}" class="form-check form-check-inline">
		<input type="checkbox" th:field="*{regions}" th:value="${region.key}" class="form-check-input">
		<label th:for="${#ids.prev('regions')}" th:text="${region.value}" class="form-check-label">서울</label>
	</div>
</div>

 th:for="${#ids.prev('regions')}"

 - 멀티 체크박스는 th:for를 통해 같은 이름의 여러 체크박스를 만들 수 있음

 - 반복해서 HTML 태그를 생성할 때, 생성된 HTML 태그 속성에서 name 은 같아도 되지만, id 는 모두 달라야 함

 - 타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 1 , 2 , 3 숫자를 뒤에 붙여줌

 

 each로 체크박스가 반복 생성된 렌더링 결과(id 뒤에 숫자가 추가)

<input type="checkbox" value="SEOUL" class="form-check-input" id="regions1" name="regions">
<input type="checkbox" value="BUSAN" class="form-check-input" id="regions2" name="regions">
<input type="checkbox" value="JEJU" class="form-check-input" id="regions3" name="regions">

※ HTML의 id가 타임리프에 의해 동적으로 만들어지기 때문에 <label for="id 값"> 으로 label의 대상이 되는 id 값을 임의로 지정할수 없으나, 타임리프는 ids.prev(...) , ids.next(...)을 제공해서 동적으로 생성되는 id 값을 사용할 수 있도록 함.

 

3. 멀티 체크 박스 form 전송 시 controller 처리

 서울, 부산 선택 후 form 데이터 전송 시 

 - 로그: item.regions=[SEOUL, BUSAN]
 - form에서 전송된 데이터 : regions=SEOUL&_regions=on&regions=BUSAN&_regions=on&_regions=on

 지역 선택 없이 form 데이터 전송 시

 - 로그: item.regions=[]
 - form에서 전송된 데이터 : _regions=on&_regions=on&_regions=on

※ _regions는 앞선 checkbox 예제와 같이 웹 브라우저에서 체크를 하나도 하지 않았을 때, 클라이언트가 서버에 아무런 데이터를 보내지 않는 것을 방지함.

 

 

라디오 버튼

1. controller에 라디오 버튼 관련 데이터 로직 추가

@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
	return ItemType.values();
}

 itemTypes를 등록 폼, 조회, 수정 폼에서 모두 사용하므로 @ModelAttribute사용

 ItemType.values() 를 사용하면 해당 ENUM의 모든 정보를 배열로 반환함 예) [BOOK, FOOD, ETC]

 

2. 상품 추가 폼 thyemleaf

<!-- radio button -->
<div>
	<div>상품 종류</div>
	<div th:each="type : ${itemTypes}" class="form-check form-check-inline">
		<input type="radio" th:field="*{itemType}" th:value="${type.name()}" class="form-check-input">
		<label th:for="${#ids.prev('itemType')}" th:text="${type.description}" class="form-check-label">
			BOOK
		</label>
	</div>
</div>

 

3. 라디오 버튼 form 전송 시 controller 처리

 음식 선택 후 form 데이터 전송 시 

 - 로그: item.itemType=FOOD

 라디오버튼 선택 없이 form 데이터 전송 시

 - 로그: item.itemType=null

※ 라디오 버튼은 이미 선택이 되어 있다면, 수정 시에도 항상 하나를 반드시 선택하도록 되어 있으므로 체크 박스와 달리 별도의 히든 필드를 사용할 필요가 없음.

※ 체크 박스와 마찬가지로 라디오 버튼에서 상품 종류를 체크해서 저장하면, 조회시에 checked 속성이 자동으로 추가 되어 있음

※ 타임리프의 th:field를 사용하면 값이 true인 경우 체크를 자동으로 처리해줌

 

 

4. 타임리프에서 ENUM 직접 사용하기

소스

</div th:each="type : ${t(hello.itemservice.domain.item.itemtype).values()}">

 - 스프링EL 문법으로 ENUM을 직접 사용할 수 있음 

 - ENUM에 values() 를 호출하면 해당 ENUM의 모든 정보가 배열로 반환됨
 - 이렇게 사용하면 ENUM의 패키지 위치가 변경되거나 할때 자바 컴파일러가 타임리프까지 컴파일 오류를 잡을 수 없으므로 추천하지는 않음

 

 

셀렉트 박스

1. controller에 셀렉트 박스 관련 데이터 로직 추가

@ModelAttribute("deliveryCodes")
public List<DeliveryCode> deliveryCodes() {
	List<DeliveryCode> deliveryCodes = new ArrayList<>();
	deliveryCodes.add(new DeliveryCode("FAST", "빠른 배송"));
	deliveryCodes.add(new DeliveryCode("NORMAL", "일반 배송"));
	deliveryCodes.add(new DeliveryCode("SLOW", "느린 배송"));
	return deliveryCodes;
}

 DeliveryCode를 등록 폼, 조회, 수정 폼에서 모두 사용하므로 @ModelAttribute  사용

 

 

2. 상품 추가 폼 thyemleaf

<!-- SELECT -->
<div>
	<div>배송 방식</div>
	<select th:field="*{deliveryCode}" class="form-select">
		<option value="">==배송 방식 선택==</option>
		<option th:each="deliveryCode : ${deliveryCodes}" th:value="${deliveryCode.code}" th:text="${deliveryCode.displayName}">FAST</option>
	</select>
</div>

 렌더링 결과

<!-- SELECT -->
<div>
	<div>배송 방식</div>
	<select class="form-select" id="deliveryCode" name="deliveryCode">
		<option value="">==배송 방식 선택==</option>
		<option value="FAST">빠른 배송</option>
		<option value="NORMAL">일반 배송</option>
		<option value="SLOW">느린 배송</option>
	</select>
</div>

※ 체크 박스와 마찬가지로 셀렉트 박스에서 배송 방식을 체크해서 저장하면, 조회시에 selected="selected" 속성이 자동으로 추가 되어 있음

※ 타임리프의 th:field를 사용하면 값이 있는 경우 선택을 자동으로 처리해줌

반응형
반응형

Thyemleaf 소개

※ 공식 사이트: https://www.thymeleaf.org/

 공식 메뉴얼 - 기본 기능: https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html

 공식 메뉴얼 - 스프링 통합: https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html

 

1. 타임리프 특징

1.1 서버 사이드 HTML 렌더링 (SSR)

• 타임리프는 백엔드 서버에서 반환된 model을 가지고 HTML을 동적으로 렌더링 하는 용도로 사용

• 사용법은 SSR이 다 비슷하기에 학습하기에도 어렵지 않고, 페이지가 어느정도 정적이고 빠른 생산성이 필요한 경우 백엔드 개발자가 개발해야하는 일이 생기는데 이 경우 타임리프는 좋은 선택지임.

 

1.2 네츄럴 템플릿

• 타임리프는 순수 HTML을 최대한 유지하는 특징이 있음.
• 타임리프로 작성된 파일은 해당 파일을 그대로 웹 브라우저에서 열어도 정상적인 HTML 결과를 확인할 수 있음.

(이 경우 동적으로 결과가 렌더링 되지는 않음)

• 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있음.
• JSP를 포함한 다른 뷰 템플릿들은 소스코드와 HTML이 섞여서 웹 브라우저에서 정상적인 HTML 결과를 확인할 수 없음. 

(무조건 서버를 통해서 JSP가 렌더링 되고 HTML 응답 결과를 받아야 화면을 확인 가능.)

• 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿 (natural templates)이라 함.

 

1.3 스프링 통합 지원

• 타임리프는 스프링과 자연스럽게 통합되고, 스프링의 다양한 기능을 편리하게 사용할 수 있게 지원함.

 

2. 타임리프의 기본 기능

2.1 타임리프 사용 선언
• <html xmlns:th="http://www.thymeleaf.org">


2.2 타임리프의 기본 표현식

• 간단한 표현:
     ◦ 변수 표현식: ${...}
     ◦ 선택 변수 표현식: *{...}
     ◦ 메시지 표현식: #{...}
     ◦ 링크 URL 표현식: @{...}
     ◦ 조각 표현식: ~{...}

• 리터럴
     ◦ 텍스트: 'one text', 'Another one!',…
     ◦ 숫자: 0, 34, 3.0, 12.3,…
     ◦ 불린: true, false
     ◦ 널: null
     ◦  리터럴 토큰: one, sometext, main,…

• 문자 연산:
     ◦ 문자 합치기: +
     ◦ 리터럴 대체: |The name is ${name}|

• 산술 연산:
     ◦ Binary operators: +, -, *, /, %
     ◦  Minus sign (unary operator): -

• 불린 연산:
     ◦ Binary operators: and, or
     ◦ Boolean negation (unary operator): !, not

• 비교와 동등:
     ◦ 비교: >, <, >=, <= (gt, lt, ge, le)
     ◦ 동등 연산: ==, != (eq, ne)

• 조건 연산:
     ◦ If-then: (if) ? (then)
     ◦ If-then-else: (if) ? (then) : (else)
     ◦ Default: (value) ?: (defaultvalue)

• 특별한 토큰:
     ◦ No-Operation: _

※ 참고 사이트: https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#standardexpression-syntax

 

 

텍스트 - text, utext

※ 타임리프는 기본적으로 HTML 태그의 속성에 기능을 정의해서 동작함

1. th:text, [[...]] 예제 코드

1.1 controller 소스

@GetMapping("text-basic")
public String textBasic(Model model) {
	model.addAttribute("data","Hello Spring");
	return "basic/text-basic";
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>
<body>
	<h1>컨텐츠에 데이터 출력하기</h1>
	<ul>
		<li>th:text 사용 <span th:text="${data}"></span></li>
		<li>컨텐츠 안에서 직접 출력하기 = [[${data}]]</li>
	</ul>	
</body>
</html>

• HTML의 콘텐츠(content)에 데이터를 출력할 때는 th:text를 사용하면 됨

• HTML 태그의 속성이 아니라 HTML 콘텐츠 영역안에서 직접 데이터를 출력하고 싶으면 [[...]] 를 사용하면 됨

 

 

2. Escape와 Unescape 예제

2.1 controller 소스

@GetMapping("text-unescaped")
public String textUnescaped(Model model) {
	model.addAttribute("data","Hello <b>Spring</b>");
	return "basic/text-unescaped";
}

• model에 <b>태그를 담아 Spring!이라는 단어가 진하게 나오도록 구현

 

2.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>
<body>
	<h1>text vs utext</h1>
	<ul>
		<li>th:text 사용 <span th:text="${data}"></span></li>
		<li>th:utext 사용 <span th:utext="${data}"></span></li>
	</ul>
	
	<h1><span th:inline="none">[[...]] vs [(...)]</span></h1>
	<ul>
		<li><span th:inline="none">[[...]] = </span>[[${data}]]</li>
		<li><span th:inline="none">{(...)] = </span>[(${data})]</li>
	</ul>
</body>
</html>

기존의 th:text 방식과 [[...]] 방식의 출력결과

 - Hello &lt;b&gt;Spring!&lt;/b&gt;

 HTML 엔티티

 - 웹 브라우저는 <를 HTML 태그의 시작으로 인식함.

 - <를 태그의 시작이 아니라 <라는 문자로 표현할 수 있는 방법이 필요한데, 특정 HTML관련 문자가 들어오면 escape하여 HTML 엔티티라는 것으로 치환해서 브라우저에서 HTML 마크업으로 표현하는 것이아닌 문자로 표현함

 - HTML에서 사용하는 특수 문자를 HTML 엔티티로 변경하는 것을 이스케이프(escape)라 함

 - 타임리프가 제공하는 th:text , [[...]] 는 기본적으로 이스케이스(escape)를 제공함.

• 타임리프에서 escape를 하지 않는 방법(Unescape)

 - th:text → th:utext

 - [[...]]  [(...)]

th:utext 방식과 [(...)] 방식의 출력결과

 - Hello Spring!

※ 주의!

 - 실제 서비스를 개발하다 보면 escape를 사용하지 않아서 HTML이 정상 렌더링 되지 않는 수 많은 문제가 발생함

 - escape를 기본으로 하고, 꼭 필요한 때만 unescape를 사용해야함.

 

 

변수 - SpringEL

1. 예제 

1.1 controller 소스

@GetMapping("variable")
public String variable(Model model) {
	User userA = new User("userA", 10);
	User userB = new User("userB", 20);
	
	List<User> list = new ArrayList<>();
	list.add(userA);
	list.add(userB);
	
	Map<String, User> map = new HashMap<>();
	map.put("userA", userA);
	map.put("userB", userB);
	
	model.addAttribute("user", userA);
	model.addAttribute("users", list);
	model.addAttribute("userMap", map);
	
	return "basic/variable";
}


@Data
static class User{
	private String username;
	private int age;
	
	public User(String username, int age) {
		this.username = username;
		this.age = age;
	}	
}

• model에 여러 데이터를 담아 view 호출

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>SpringEL 표현식</h1>
	<ul>Object
		<li>${user.username} = <span th:text="${user.username}"></span></li>
		<li>${user['username']} = <span th:text="${user['username']}"></span></li>
		<li>${user.getUsername()} = <span th:text="${user.getUsername()}"></span></li>
	</ul>
	<ul>List
		<li>${users[0].username} = <span th:text="${users[0].username}"></span></li>
		<li>${users[0]['username']} = <span th:text="${users[0]['username']}"></span></li>
		<li>${users[0].getUsername()} = <span th:text="${users[0].getUsername()}"></span></li>
	</ul>
	<ul>Map
		<li>${userMap['userA'].username} = <span th:text="${userMap['userA'].username}"></span></li>
		<li>${userMap['userA']['username']} = <span th:text="${userMap['userA']['username']}"></span></li>
		<li>${userMap['userA'].getUsername()} = <span th:text="${userMap['userA'].getUsername()}"></span></li>
	</ul>	
	
	<h1>지역 변수 - (th:with)</h1>
	<div th:with="first=${users[0]}">
		<p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
	</div>
</body>
</html>

• 변수 표현식 : ${...}

 

 Object 표현
 - ${user.username} : user의 username을 프로퍼티로 접근 → user.getUsername()
 - ${user['username']} : 위와 같음 → user.getUsername()
 - ${user.getUsername()} : user의 getUsername() 을 직접 호출

 

• List 표현

 - ${users[0].username} : List에서 첫 번째 회원을 찾고 username 프로퍼티 접근  ${list.get(0).getUsername()}
 - ${users[0]['username']} : 위와 같음
 - ${users[0].getUsername()} : List에서 첫 번째 회원을 찾고 메서드 직접 호출

 

Map 표현

 - ${userMap['userA'].username : Map에서 userA를 찾고, username 프로퍼티 접근 → map.get("userA").getUsername()
 - ${userMap['userA']['username'] : 위와 같음
 - ${userMap['userA'].getUsername() : Map에서 userA를 찾고 메서드 직접 호출

 

지역변수 선언

<div th:with="first=${users[0]}">
	<p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>

 - th:with를 선언하여 지역변수 생성, 지역변수는 선언한 태그안에서만 사용가능

 

 

기본 객체들

1. 타임리프가 제공하는 기본 객체들

 ${#request} - 스프링 부트 3.0부터 제공하지 않는다.

 ${#response} - 스프링 부트 3.0부터 제공하지 않는다.

 ${#session} - 스프링 부트 3.0부터 제공하지 않는다.

 ${#servletContext} - 스프링 부트 3.0부터 제공하지 않는다.

 ${#locale}

 

2. 스프링부트 3.0 미만 예제

2.1 controller 소스

@GetMapping("basic-objects")
public String basicObejct(HttpSession session) {
	session.setAttribute("sessionData", "Hello Session");
	return "basic/basic-objects";
}


@Component("helloBean")
static class HelloBean{
	public String hello(String data) {
		return "hello "+data;
	}
}

 

2.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>식 기본 객체 (Expression Basic Objects)</h1>
	<ul>
		<li>request = <span th:text="${#request}"></span></li>
		<li>response = <span th:text="${#response}"></span></li>
		<li>session = <span th:text="${#session}"></span></li>
		<li>servletContext = <span th:text="${#servletContext}"></span></li>
		<li>locale = <span th:text="${#locale}"></span></li>
	</ul>
	<h1>편의 객체</h1>
	<ul>
		<li>Request Parameter = <span th:text="${param.paramData}"></span></li>
		<li>session = <span th:text="${session.sessionData}"></span></li>
		<li>spring bean = <span th:text="${@helloBean.hello('Spring!')}"></span></ li>
	</ul>
</body>
</html>

${#request}는 HttpServletRequest 객체가 그대로 제공됨

 데이터를 조회하려면 request.getParameter("data") 처럼 불편하게 접근해야 함.

※ 이런 점을 해결하기 위해 편의 객체도 제공

편의 객체

 - HTTP 요청 파라미터 접근: param

   ex) ${param.paramData}

 - HTTP 세션 접근: session

   ex) ${session.sessionData}

 - 스프링 빈 접근: @

   ex) ${@helloBean.hello('Spring!')}

 

 

3. 스프링부트 3.0 이상 예제 (model에 객체들을 담아서 변수 표현식 ${...}으로 사용)

3.1 controller 소스

@GetMapping("/basic-objects")
public String basicObjects(Model model, HttpServletRequest request, HttpServletResponse response, HttpSession session) {
	session.setAttribute("sessionData", "Hello Session");
	model.addAttribute("request", request);
	model.addAttribute("response", response);
	model.addAttribute("servletContext", request.getServletContext());
	return "basic/basic-objects";
}


@Component("helloBean")
	static class HelloBean {
		public String hello(String data) {
		return "Hello " + data;
	}
}

 

3.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>식 기본 객체 (Expression Basic Objects)</h1>
	<ul>
		<li>request = <span th:text="${request}"></span></li>
		<li>response = <span th:text="${response}"></span></li>
		<li>session = <span th:text="${session}"></span></li>
		<li>servletContext = <span th:text="${servletContext}"></span></li>
		<li>locale = <span th:text="${#locale}"></span></li>
	</ul>
	<h1>편의 객체</h1>
	<ul>
		<li>Request Parameter = <span th:text="${param.paramData}"></span></li>
		<li>session = <span th:text="${session.sessionData}"></span></li>
		<li>spring bean = <span th:text="${@helloBean.hello('Spring!')}"></span></ li>
	</ul>
</body>

</html>

 

유틸리티 객체와 날짜

1. 타임리프가 제공하는 유틸리티 객체들

 ${#message} : 메시지, 국제화 처리

 ${#uris} : URI 이스케이프 지원

 ${#dates} : java.util.Date 서식 지원

 ${#calendars} : java.util.Calendar 서식 지원

 ${#temporals} : 자바8 날짜 서식 지원

 ${#numbers} : 숫자 서식 지원

 ${#strings} : 문자 관련 편의 기능

 ${#objects} : 객체 관련 기능 제공

 ${#bools} : boolean 관련 기능 제공

 ${#arrays} : 배열 관련 기능 제공

 ${#lists} , ${#sets} , ${#maps} : 컬렉션 관련 기능 제공

 ${#ids} : 아이디 처리 관련 기능 제공, 뒤에서 설명

※ 타임리프 유틸리티 객체: https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#expression-utilityobjects

※ 유틸리티 객체 예시 https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#appendix-b-expressionutility-objects

 

2. ${#temporals} 객체 예제

2.1 controller 소스

@GetMapping("date")
public String date(Model model) {
	model.addAttribute("localDateTime", LocalDateTime.now());
	return "basic/date";
}

 

2.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>LocalDateTime</h1>
	<ul>
		<li>default = <span th:text="${localDateTime}"></span></li>
		<li>yyyy-MM-dd HH:mm:ss = <span th:text="${#temporals.format(localDateTime,'yyyy-MM-dd HH:mm:ss')}"></span></li>
	</ul>
	<h1>LocalDateTime - Utils</h1>
	<ul>
		<li>${#temporals.day(localDateTime)} = <span th:text="${#temporals.day(localDateTime)}"></span></li>
		<li>${#temporals.month(localDateTime)} = <span th:text="${#temporals.month(localDateTime)}"></span></li>
		<li>${#temporals.monthName(localDateTime)} = <span th:text="${#temporals.monthName(localDateTime)}"></span></li>
		<li>${#temporals.monthNameShort(localDateTime)} = <span th:text="${#temporals.monthNameShort(localDateTime)}"></span></li>
		<li>${#temporals.year(localDateTime)} = <span th:text="${#temporals.year(localDateTime)}"></span></li>
		<li>${#temporals.dayOfWeek(localDateTime)} = <span th:text="${#temporals.dayOfWeek(localDateTime)}"></span></li>
		<li>${#temporals.dayOfWeekName(localDateTime)} = <span th:text="${#temporals.dayOfWeekName(localDateTime)}"></span></li>
		<li>${#temporals.dayOfWeekNameShort(localDateTime)} = <span th:text="${#temporals.dayOfWeekNameShort(localDateTime)}"></span></li>
		<li>${#temporals.hour(localDateTime)} = <span th:text="${#temporals.hour(localDateTime)}"></span></li>
		<li>${#temporals.minute(localDateTime)} = <span th:text="${#temporals.minute(localDateTime)}"></span></li>
		<li>${#temporals.second(localDateTime)} = <span th:text="${#temporals.second(localDateTime)}"></span></li>
		<li>${#temporals.nanosecond(localDateTime)} = <span th:text="${#temporals.nanosecond(localDateTime)}"></span></li>
	</ul>
</body>

</html>

 대략 이런 것이 있다 알아두고, 필요할 때 찾아서 사용하면 됨.

 

3. Java8의 날짜

 타임리프에서 자바8 날짜인 LocalDate , LocalDateTime , Instant를 사용하려면 추가 라이브러리가 필요 함

 스프링 부트 타임리프를 사용하면 해당 라이브러리가 자동으로 추가되고 통합됨.
※ 타임리프 자바8 날짜 지원 라이브러리
 - thymeleaf-extras-java8time

 자바8 날짜용 유틸리티 객체 : ${#temporals}
 사용 예시

<span th:text="${#temporals.format(localDateTime, 'yyyy-MM-dd HH:mm:ss')}"></span>

 

 

URL 링크

1. 예제

1.1 controller 소스

@GetMapping("link")
public String link(Model model) {
	model.addAttribute("param1", "data1");
	model.addAttribute("param2", "data2");
	return "basic/link";
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>URL 링크</h1>
	<ul>
		<li><a th:href="@{/hello}">basic url</a></li>
		<li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">hello query param</a></li>
		<li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>
		<li><a th:href="@{/hello/{param1}(param1=${param1}, param2=${param2})}">path variable + query parameter</a></li>
	</ul>
</body>

</html>

 단순한 URL 예시

@{/hello}  /hello

 

 쿼리 파라미터

 - @{/hello(param1=${param1}, param2=${param2})} → /hello?param1=data1&param2=data2

 - () 에 있는 부분은 쿼리 파라미터로 처리

 

 경로 변수

 - @{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}  /hello/data1/data2

 - URL 경로상에 변수가 있으면 () 부분은 경로 변수로 처리

 

 경로 변수 + 쿼리 파라미터

 - @{/hello/{param1}(param1=${param1}, param2=${param2})} → /hello/data1?param2=data2

 - 경로 변수와 쿼리 파라미터를 함께 사용할 수 있음

 

※ 상대경로, 절대경로, 프로토콜 기준을 표현할 수 도 있음

/hello : 절대 경로

hello : 상대 경로

참고: https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#link-urls

 

 

리터럴

1. 리터럴이란

 리터럴은 소스 코드상에 고정된 값을 말하는 용어

String a = "Hello"
int a = 10 * 20

 "Hello" 는 문자 리터럴

 10 , 20 는 숫자 리터럴

 

2. 타임리프 리터럴의 종류

 문자: 'hello'

 숫자: 10

 불린: true , false

 null: null

 

3. 리터럴 예제

3.1 controller 소스

@GetMapping("literal")
public String literal(Model model) {
	model.addAttribute("data","Spring!");
	return "basic/literal";
}

 

3.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>리터럴</h1>
	<ul>
		<!--주의! 다음 주석을 풀면 예외가 발생함-->
		<!-- <li>"hello world!" = <span th:text="hello world!"></span></li>-->
		<li>'hello' + ' world!' = <span th:text="'hello' + ' world!'"></span></li>
		<li>'hello world!' = <span th:text="'hello world!'"></span></li>
		<li>'hello ' + ${data} = <span th:text="'hello ' + ${data}"></span></li>
		<li>리터럴 대체 |hello ${data}| = <span th:text="|hello ${data}|"></span></li>
	</ul>
</body>

</html>

타임리프에서 문자 리터럴은 항상 '(작은 따옴표로 감싸야함 (없으면 오류 발생)

 공백 없이 쭉 이어진다면 하나의 의미있는 토큰으로 인지해서 다음과 같이 작은 따옴표를 생략할 수 있음.

※ 룰: A-Z , a-z , 0-9 , [] , . , - , _

 리터럴 대체(Literal substitutions)

 - 마지막의 리터럴 대체 문법을 사용하면 마치 템플릿을 사용하는 것 처럼 편리하게 사용 가능

 

 

연산

1. 예제

1.1 controller 소스

@GetMapping("/operation")
public String operation(Model model) {
	model.addAttribute("nullData",null);
	model.addAttribute("data","Spring!");
	return "basic/operation";
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<ul>
		<li>산술 연산
			<ul>
				<li>10 + 2 = <span th:text="10 + 2"></span></li>
				<li>10 % 2 == 0 = <span th:text="10 % 2 == 0"></span></li>
			</ul>
		</li>
		<li>비교 연산
			<ul>
				<li>1 > 10 = <span th:text="1 &gt; 10"></span></li>
				<li>1 gt 10 = <span th:text="1 gt 10"></span></li>
				<li>1 >= 10 = <span th:text="1 >= 10"></span></li>
				<li>1 ge 10 = <span th:text="1 ge 10"></span></li>
				<li>1 == 10 = <span th:text="1 == 10"></span></li>
				<li>1 != 10 = <span th:text="1 != 10"></span></li>
			</ul>
		</li>
		<li>조건식
			<ul>
				<li>(10 % 2 == 0)? '짝수':'홀수' = <span th:text="(10 % 2 == 0)?'짝수':'홀수'"></span></li>
			</ul>
		</li>
		<li>Elvis 연산자
			<ul>
				<li>${data}?: '데이터가 없습니다.' = <span th:text="${data}?: '데이터가 없습니다.'"></span></li>
				<li>${nullData}?: '데이터가 없습니다.' = <span th:text="${nullData}?:'데이터가 없습니다.'"></span></li>
			</ul>
		</li>
		<li>No-Operation
			<ul>
				<li>${data}?: _ = <span th:text="${data}?: _">데이터가 없습니다.</ span>
				</li>
				<li>${nullData}?: _ = <span th:text="${nullData}?: _">데이터가 없습니다.</span></li>
			</ul>
		</li>
	</ul>
</body>

</html>

 비교연산: HTML 엔티티를 사용가능

※ > (gt), < (lt), >= (ge), <= (le), ! (not), == (eq), != (neq, ne)

 조건식: 자바의 조건식과 유사함

 Elvis 연산자: 조건식의 편의 버전

 No-Operation: _ 인 경우 마치 타임리프가 실행되지 않는 것 처럼 동작함. 

  ※ 이것을 잘 사용하면 HTML 의 내용 그대로 활용할 수 있음.

    마지막 예를 보면 데이터가 없습니다. 부분이 그대로 출력됨.

 

 

속성값 설정

1. 타임리프 태그 속성

 타임리프는 주로 HTML 태그에 th:* 속성을 지정하는 방식으로 동작함.

 th:* 로 속성을 적용하면 기존 속성을 대체함

 기존 속성이 없으면 새로 만듦

 

2. 예제

2.1 controller 소스

@GetMapping("/attribute")
public String attribute(Model model) {
	return "basic/attribute";
}

 

2.2 thyemleaf 소스

<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>속성 설정</h1>
	<input type="text" name="mock" th:name="userA" />
	<h1>속성 추가</h1>
	- th:attrappend = <input type="text" class="text" th:attrappend="class=' large'" /><br />
	- th:attrprepend = <input type="text" class="text" th:attrprepend="class='large '" /><br />
	- th:classappend = <input type="text" class="text" th:classappend="large" /><br />
	<h1>checked 처리</h1>
	- checked o <input type="checkbox" name="active" th:checked="true" /><br />
	- checked x <input type="checkbox" name="active" th:checked="false" /><br />
	- checked=false <input type="checkbox" name="active" checked="false" /><br />
</body>

</html>

 속성 설정

 - th:* 속성을 지정하면 타임리프는 기존 속성을 th:* 로 지정한 속성으로 대체함.

 - 기존 속성이 없다면 새로 만듦

ex) 타임리프 소스 : <input type="text" name="mock" th:name="userA" />

타임리프 렌더링 이후 : <input type="text" name="userA" />

 

 속성 추가

 - th:attrappend : 속성 값의 뒤에 값을 추가 (띄어쓰기 주의)

 - th:attrprepend : 속성 값의 앞에 값을 추가 (띄어쓰기 주의)

 - th:classappend : class 속성에 자연스럽게 추가 (띄어쓰기 자체 추가)

 

 checked 처리

 - checked 속성 특징

<input type="checkbox" name="active" checked="false" />

※ checked 속성이 있기 때문에 "false"라고 하더라도 checked 처리가 되어버림

※ HTML에서 checked 속성은 checked 속성의 값과 상관없이 checked 라는 속성만 있어도 체크가 됨

 - 타임리프는 th:checked 값이 false 인 경우 checked 속성 자체를 제거

타임리프 소스 : <input type="checkbox" name="active" th:checked="false" />

타임리프 렌더링 후: <input type="checkbox" name="active" />

 

 

반복

1. 예제

1.1 controller 소스

@GetMapping("/each")
public String each(Model model) {
	addUser(model);
	return "basic/each";
}


public void addUser(Model model) {
	List<User> list = new ArrayList<>();
	list.add(new User("userA", 10));
	list.add(new User("userB", 30));
	list.add(new User("userC", 20));
	list.add(new User("userD", 33));
	model.addAttribute("users",list);
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>기본 테이블</h1>
	<table border="1">
		<tr>
			<th>username</th>
			<th>age</th>
		</tr>
		<tr th:each="user : ${users}">
			<td th:text="${user.username}">username</td>
			<td th:text="${user.age}">0</td>
		</tr>
	</table>
	<h1>반복 상태 유지</h1>
	<table border="1">
		<tr>
			<th>count</th>
			<th>username</th>
			<th>age</th>
			<th>etc</th>
		</tr>
		<tr th:each="user, userStat : ${users}">
			<td th:text="${userStat.count}">username</td>
			<td th:text="${user.username}">username</td>
			<td th:text="${user.age}">0</td>
			<td>
				index = <span th:text="${userStat.index}"></span>
				count = <span th:text="${userStat.count}"></span>
				size = <span th:text="${userStat.size}"></span>
				even? = <span th:text="${userStat.even}"></span>
				odd? = <span th:text="${userStat.odd}"></span>
				first? = <span th:text="${userStat.first}"></span>
				last? = <span th:text="${userStat.last}"></span>
				current = <span th:text="${userStat.current}"></span>
			</td>
		</tr>
	</table>
</body>

</html>

 반복 기능
ex) <tr th:each="user : ${users}">
 - 반복시 오른쪽 컬렉션( ${users} )의 값을 하나씩 꺼내 왼쪽 변수( user )에 담아서 태그를 반복 실행
 - th:each는 List 뿐만 아니라 배열, java.util.Iterable, java.util.Enumeration을 구현한 모든 객체를 반복에 사용할 수 있음.

 - Map도 사용할 수 있는데 이 경우 변수에 담기는 값은 Map.Entry임

 

 반복 상태 유지
ex) <tr th:each="user, userStat : ${users}">
 - 반복의 두번째 파라미터를 설정해서 반복의 상태를 확인 할 수 있음
 - 두번째 파라미터는 생략 가능한데, 생략하면 지정한 변수명 user + Stat가 됨
 - 여기서는 user + Stat = userStat 이므로 생략 가능

 

 반복 상태 유지 기능
- index : 0부터 시작하는 값
- count : 1부터 시작하는 값
- size : 전체 사이즈
- even , odd : 홀수, 짝수 여부( boolean )
- first , last :처음, 마지막 여부( boolean )
- current : 현재 객체

 

 

조건부 평가

1. 예제

1.1 controller 소스

@GetMapping("/condition")
public String condition(Model model) {
	addUser(model);
	return "basic/condition";
}


public void addUser(Model model) {
	List<User> list = new ArrayList<>();
	list.add(new User("userA", 10));
	list.add(new User("userB", 30));
	list.add(new User("userC", 20));
	list.add(new User("userD", 33));
	model.addAttribute("users",list);
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>if, unless</h1>
	<table border="1">
		<tr>
			<th>count</th>
			<th>username</th>
			<th>age</th>
		</tr>
		<tr th:each="user, userStat : ${users}">
			<td th:text="${userStat.count}">1</td>
			<td th:text="${user.username}">username</td>
			<td>
				<span th:text="${user.age}">0</span>
				<span th:text="'미성년자'" th:if="${user.age lt 20}"></span>
				<span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
			</td>
		</tr>
	</table>
	<h1>switch</h1>
	<table border="1">
		<tr>
			<th>count</th>
			<th>username</th>
			<th>age</th>
		</tr>
		<tr th:each="user, userStat : ${users}">
			<td th:text="${userStat.count}">1</td>
			<td th:text="${user.username}">username</td>
			<td th:switch="${user.age}">
				<span th:case="10">10살</span>
				<span th:case="20">20살</span>
				<span th:case="*">기타</span>
			</td>
		</tr>
	</table>
</body>

</html>

 if, unless
 - 타임리프는 해당 조건이 맞지 않으면 태그 자체를 렌더링하지 않음
 - 만약 다음 조건이 false 인 경우 <span>...<span> 부분 자체가 렌더링 되지 않고 사라짐
<span th:text="'미성년자'" th:if="${user.age lt 20}"></span>

 

 switch
 - * 은 만족하는 조건이 없을 때 사용하는 디폴트 조건

 

 

주석

1. 예제

1.1 controller 소스

@GetMapping("/comments")
public String comment(Model model) {
	model.addAttribute("data","Spring!");
	return "basic/comments";
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>예시</h1>
	<span th:text="${data}">html data</span>
	
	<h1>1. 표준 HTML 주석</h1>
	<!--
	<span th:text="${data}">html data</span>
	-->
	
	<h1>2. 타임리프 파서 주석</h1>
	<!--/* [[${data}]] */-->
	
	<!--/*-->
	<span th:text="${data}">html data</span>
	<!--*/-->
	
	<h1>3. 타임리프 프로토타입 주석</h1>
	<!--/*/
	<span th:text="${data}">html data</span>
	/*/-->
	
</body>

</html>

 표준 HTML 주석 (<!-- ... -->)
 - 자바스크립트의 표준 HTML 주석은 타임리프가 렌더링 하지 않고, 그대로 남겨둠


 타임리프 파서 주석(<!--/* ... */-->    ,    <!--/*--> ... <!--*/-->)
 - 타임리프 파서 주석은 타임리프의 진짜 주석이다. 렌더링에서 주석 부분을 제거함


 타임리프 프로토타입 주석(<!--/*/ ... /*/-->)
 - HTML 파일을 웹 브라우저에서 그대로 열어보면 HTML 주석이기 때문에 이 부분이 웹 브라우저가 렌더링하지 않음
 - 타임리프 렌더링을 거치면 이 부분이 정상 렌더링 됨.
 - 쉽게 이야기해서 HTML 파일을 그대로 열어보면 주석처리가 되지만, 타임리프를 렌더링 한 경우에만 보이는 기능

 

 

블록

1. 예제

1.1 controller 소스

@GetMapping("/block")
public String block(Model model) {
	addUser(model);
	return "basic/block";
}


public void addUser(Model model) {
	List<User> list = new ArrayList<>();
	list.add(new User("userA", 10));
	list.add(new User("userB", 30));
	list.add(new User("userC", 20));
	list.add(new User("userD", 33));
	model.addAttribute("users",list);
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<th:block th:each="user : ${users}">
		<div>
			사용자 이름1 <span th:text="${user.username}"></span>
			사용자 나이1 <span th:text="${user.age}"></span>
		</div>
		<div>
			요약 <span th:text="${user.username} + ' / ' + ${user.age}"></span>
		</div>
	</th:block>
</body>

</html>

 <th:block> 은 HTML 태그가 아닌 타임리프의 유일한 자체 태그

 타임리프의 특성상 HTML 태그안에 속성으로 기능을 정의해서 사용하는데, 위와 같이 2군데의 div태그에서 model에 담긴 ${users}리스트를 사용할 경우 사용이 애매함

이럴경우 <th:block>을 사용하여 해결 가능

 

 

자바스크립트 인라인

1. 예제

1.1 controller 소스

@GetMapping("/javascript")
public String javascript(Model model) {
	model.addAttribute("user", new User("UserA", 10));
	addUser(model);
	return "basic/javascript";
}

 

1.2 thyemleaf 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<!-- 자바스크립트 인라인 사용 전 -->
	<script>
		var username = [[${user.username}]];
		var age = [[${user.age}]];
		//자바스크립트 내추럴 템플릿
		var username2 = /*[[${user.username}]]*/ "test username";
		//객체
		var user = [[${user}]];
	</script>
	<!-- 자바스크립트 인라인 사용 후 -->
	<script th:inline="javascript">
		var username = [[${user.username}]];
		var age = [[${user.age}]];
		//자바스크립트 내추럴 템플릿
		var username2 = /*[[${user.username}]]*/ "test username";
		//객체
		var user = [[${user}]];
	</script>

	<!-- 자바스크립트 인라인 each -->
	<script th:inline="javascript">
		[# th: each = "user, stat : ${users}"]
		var user[[${stat.count}]] = [[${user}]];
		[/]
	</script>
</body>

</html>

  자바스크립트 인라인 적용 

<script th:inline="javascript">...</script>

 

 텍스트 렌더링
 - var username = [[${user.username}]];

인라인 사용 전 결과 소스 var username = userA;
인라인 사용 후 결과 소스 var username = "userA";

 - 인라인 사용 전 렌더링 결과를 보면 userA라는 변수 이름이 그대로 남아있음.

 - 개발자가 기대한 것은 다음과 같은 "userA"라는 문자일 것
 ※ 인라인 사용을 안할 경우 userA라는 변수명으로 사용되어서 자바스크립트 오류가 발생

 ※ 숫자 age의 경우에는 " 가 필요 없기 때문에 정상 렌더링 됨
 ※ 인라인을 사용 할경우 렌더링 결과를 보면 문자 타입인 경우 자동으로 " 를 포함해줌

 ※ 추가로 자바스크립트에서 문제가 될 수 있는 문자가 포함되어 있으면 이스케이프 처리도 해줌. ex) " → \"

 

자바스크립트 내추럴 템플릿

 - var username2 = /*[[${user.username}]]*/ "test username";

※ 의도 : 타임리프 없이 실행할 경우 "test username" 적용, 타임리프 렌더링 시 [[${user.username}]] 적용

인라인 사용 전 결과 소스 var username2 = /*userA*/ "test username";
인라인 사용 후 결과 소스 var username2 = "userA";

 - 인라인 사용 전 결과를 보면 정말 순수하게 그대로 해석 함.

 - 따라서 내추럴 템플릿 기능이 동작하지 않고, 심지어 렌더링 내용이 주석처리 되어 버림
 - 인라인 사용 후 결과를 보면 주석 부분이 제거되고, 기대한 "userA"가 정확하게 적용

 

 객체

 ※ 타임리프의 자바스크립트 인라인 기능을 사용하면 객체를 JSON으로 자동으로 변환해줌
 - var user = [[${user}]];

인라인 사용 전 결과 소스 var user = BasicController.User(username=userA, age=10);
인라인 사용 후 결과 소스 var user = {"username":"userA","age":10};

- 인라인 사용 전 소스는 객체의 toString()이 호출된 값
- 인라인 사용 후 소스는 객체를 JSON으로 자동으로 변환해줌

 

 자바스크립트 인라인 each 소스

<!-- 자바스크립트 인라인 each -->
<script th:inline="javascript">
	[# th:each="user, stat : ${users}"]
	var user[[${stat.count}]] = [[${user}]];
	[/]
</script>

 자바스크립트 인라인 each 결과

<script>
	var user1 = {"username":"userA","age":10};
	var user2 = {"username":"userB","age":20};
	var user3 = {"username":"userC","age":30};
</script>

 

 

템플릿 조각

1. 설명 

 웹 페이지를 개발할 때는 상단 영역이나 하단 영역, 좌측 카테고리 등등 여러 페이지에서 함께 사용하는 영역들과 같은 공통 영역이 많이 있음.

 타임리프는 공통영역 처리를 위해 템플릿 조각과 레이아웃 기능을 지원.

 

2. 예제

2.1 controller 소스

@GetMapping("/fragment")
public String template() {
	return "template/fragment/fragmentMain";
}

 

2.2 thyemleaf 소스

 footer로 사용될 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<body>
	<footer th:fragment="copy">
		푸터 자리 입니다.
	</footer>
	<footer th:fragment="copyParam (param1, param2)">
		<p>파라미터 자리 입니다.</p>
		<p th:text="${param1}"></p>
		<p th:text="${param2}"></p>
	</footer>
</body>

</html>

메인 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>

<body>
	<h1>부분 포함</h1>
	<h2>부분 포함 insert</h2>
	<div th:insert="~{template/fragment/footer :: copy}"></div>
	<h2>부분 포함 replace</h2>
	<div th:replace="~{template/fragment/footer :: copy}"></div>
	<h2>부분 포함 단순 표현식</h2>
	<div th:replace="template/fragment/footer :: copy"></div>
	<h1>파라미터 사용</h1>
	<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>
</body>

</html>

• 부분 포함 insert

<div th:insert="~{template/fragment/footer :: copy}"></div>

- th:insert를 사용하면 현재 태그(div) 내부에 추가함.

- 결과 소스

<h2>부분 포함 insert</h2>
<div>
<footer>
푸터 자리 입니다.
</footer>
</div>

 

• 부분 포함 replace

<div th:replace="~{template/fragment/footer :: copy}"></div>

 - th:replace를 사용하면 현재 태그(div)를 대체함.

 - 결과 소스

<h2>부분 포함 replace</h2>
<footer>
푸터 자리 입니다.
</footer>

 

• 부분 포함 단순 표현식

<div th:replace="template/fragment/footer :: copy"></div>

 - ~{ ... }를 사용하는 것이 원칙이지만 템플릿 조각을 사용하는 코드가 단순하면 이 부분을 생략할 수 있음

 - 결과 소스는 위와 같음

 

 

• 파라미터 사용

<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>

 - template/fragment/footer소스 

<footer th:fragment="copyParam (param1, param2)">
	<p>파라미터 자리 입니다.</p>
	<p th:text="${param1}"></p>
	<p th:text="${param2}"></p>
</footer>

 - 결과 소스

<h1>파라미터 사용</h1>
<footer>
	<p>파라미터 자리 입니다.</p>
	<p>데이터1</p>
	<p>데이터2</p>
</footer>

 

 

템플릿 레이아웃1

1. 설명

 코드 조각을 레이아웃에 넘겨서 사용하는 방법.
<head>에 공통으로 사용하는 css, javascript 같은 정보들을 한 곳에 모아두고 공통으로 사용하지만 각 페이지마다 필요한 정보를 더 추가해서 사용하고 싶을때 사용

 

2. 예제

2.1 controller 소스

@GetMapping("layout")
public String layout() {
	return "template/layout/layoutMain";
}

 

2.2 thyemleaf 소스

• 레이아웃 소스

<html xmlns:th="http://www.thymeleaf.org">

<head th:fragment="common_header(title,links)">
	
	<title th:replace="${title}">레이아웃 타이틀</title>
	
	<!-- 공통 -->
	<link rel="stylesheet" type="text/css" media="all" th:href="@{/css/awesomeapp.css}">
	<link rel="shortcut icon" th:href="@{/images/favicon.ico}">
	<script type="text/javascript" th:src="@{/sh/scripts/codebase.js}"></script>
	
	<!-- 추가 -->
	<th:block th:replace="${links}" />
	
</head>

 

• controller에서 호출하는 소스

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">

<head th:replace="template/layout/base :: common_header(~{::title},~{::link})">
	<title>메인 타이틀</title>
	<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
	<link rel="stylesheet" th:href="@{/themes/smoothness/jquery-ui.css}">
</head>

<body>
	메인 컨텐츠
</body>

</html>

controller에서 호출하는 소스의 common_header(~{::title},~{::link}) 이 부분이 핵심

 - th:replace로 head태그는 레이아웃 소스의  common_header(~{::title},~{::link}) th:fragment로 대체가 됨
 - ::title은 현재 페이지의 title 태그들을 레이아웃 소스로 전달
 - ::link는 현재 페이지의 link 태그들을 레이아웃 소스로 전달

 

 결과 소스

<!DOCTYPE html>
<html>
<head>
	<title>메인 타이틀</title>
	<!-- 공통 -->
	<link rel="stylesheet" type="text/css" media="all" href="/css/awesomeapp.css">
	<link rel="shortcut icon" href="/images/favicon.ico">
	<script type="text/javascript" src="/sh/scripts/codebase.js"></script>

	<!-- 추가 -->
	<link rel="stylesheet" href="/css/bootstrap.min.css">
	<link rel="stylesheet" href="/themes/smoothness/jquery-ui.css">
</head>
<body>
	메인 컨텐츠
</body>
</html>

 메인 타이틀이 controller에서 호출한 title 소스로 교체됨
 공통 부분은 그대로 유지되고, 추가 부분에 전달한 <link> 들이 포함됨.

 

 

템플릿 레이아웃 2

1. 예제

1.1 controller 소스

@GetMapping("/layoutExtend")
public String layoutExtend() {
	return "template/layoutExtend/layoutExtendMain";
}

 

1.2 thyemleaf 소스

• 레이아웃 소스

<!DOCTYPE html>
<html th:fragment="layout (title, content)" xmlns:th="http://www.thymeleaf.org">

<head>
	<title th:replace="${title}">레이아웃 타이틀</title>
</head>

<body>
	<h1>레이아웃 H1</h1>
	
	<div th:replace="${content}">
		<p>레이아웃 컨텐츠</p>
	</div>
	
	<footer>
		레이아웃 푸터
	</footer>
</body>

</html>

 

• controller에서 호출하는 소스

<!DOCTYPE html>
<html th:replace="~{template/layoutExtend/layoutFile :: layout(~{::title},~{::section})}"
	xmlns:th="http://www.thymeleaf.org">

<head>
	<title>메인 페이지 타이틀</title>
</head>

<body>
	<section>
		<p>메인 페이지 컨텐츠</p>
		<div>메인 페이지 포함 내용</div>
	</section>
</body>

</html>

위의 <header>태그에만 적용한게 아니라 <html> 전체에 적용 (원리는 똑같음)

 

 결과소스

<!DOCTYPE html>
<html>
<head>
	<title>메인 페이지 타이틀</title>
</head>
<body>
	<h1>레이아웃 H1</h1>
	<section>
		<p>메인 페이지 컨텐츠</p>
		<div>메인 페이지 포함 내용</div>
	</section>
	<footer>
		레이아웃 푸터
	</footer>
</body>
</html>
반응형
반응형

요구사항 분석

1. 모델 및 기능 명세

상품 도메인 모델 상품 관리 기능
상품 아이디
상품 명
가격
수량
상품 목록
상품 상세
상품 등록
상품 수정

 

2. 서비스 화면

 


3. 서비스 제공 흐름

※ 이렇게 요구사항과 도메인, 화면이 어느정도 정리되면 웹 퍼블리셔, 백엔드 개발자가 업무를 나눠 진행함
 디자이너: 요구사항에 맞도록 디자인 후 디자인 결과물을 웹 퍼블리셔에게 전달.
 웹 퍼블리셔: 디자이너에게 받은 디자인을 기반으로 HTML, CSS를 만들어 개발자에게 전달
 백엔드 개발자: HTML화면을 받기전까지 시스템 설계및 핵심 비즈니스 모델을 개발한다. 이후 HTML을 전달받으면 뷰 템플릿으로 변환 후 화면을 그리고 제어

 

 

상품 도메인 개발

1. 상품 도메인

1.1 필요한 상품 도메인 필드

 상품 아이디
 상품 명
 가격
 수량

 

1.2 상품 도메인 필드 코드

package hello.itemservice.domain.item;

import lombok.Data;

@Data
public class Item {
	private Long id;
	private String itemName;
	private Integer price;
	private Integer quantity;

	public Item(String itemName, Integer price, Integer quantity) {
		this.itemName = itemName;
		this.price = price;
		this.quantity = quantity;
	}
}

 

2. 상품 저장소

2.1 필요한 상품 저장소 기능

 상품 목록
 상품 상세
 상품 등록
 상품 수정

 

2.2 상품 저장소 코드

package hello.itemservice.domain.item;

import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Repository
public class ItemRepository {
	// concurrenthashmap으로 생성해야 thread-safe하기 때문에 실제로는 그냥 HashMap을 안씀
	private static final Map<Long, Item> store = new HashMap<>();
	private static long sequence = 0L;

	public Item save(Item item) {
		item.setId(++sequence);

		store.put(item.getId(), item);
		return item;
	}

	public Item findById(Long id) {
		return store.get(id);
	}

	public List<Item> findAll() {
		return new ArrayList<>(store.values());
	}

	public void update(Long itemId, Item updateParam) {
		Item findItem = findById(itemId);
		findItem.setItemName(updateParam.getItemName());
		findItem.setPrice(updateParam.getPrice());
		findItem.setQuantity(updateParam.getQuantity());
	}

	public void clearStore() {
		store.clear();
		sequence = 0L;
	}
}

 기본적인 상품 저장, 조회, 목록조회, 수정 기능을 추가
 개발환경에서 리포지토리내에 store 콜렉션을 초기화 해주기 위해 clearStore를 구현
아이디는 전역변수로 선언된 sequance를 활용해 할당해줌 (멀티스레드 환경에서 이슈가 생길 수 있음)

저장소는 아이디와 상품도메인 Item으로 HashMap 사용(멀티스레드 환경에서 이슈가 생길 수 있음)

 

 

상품 목록 구현

1. 상품 목록 controller 구현

package hello.itemservice.web.basic;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.annotation.PostConstruct;
import java.util.List;

@Controller
@RequestMapping("/basic/items")
// 롬복(Lombok)에서 제공하는 애노테이션으로 final이 붙은 멤버 변수만 사용해 생성자를 자동으로 만들어줌 
// 생성자를 통해 해당 멤버변수를 자동 주입해줌
@RequiredArgsConstructor
public class BasicItemController {
	private final ItemRepository itemRepository;

	@GetMapping
	public String items(Model model) {
		List<Item> items = itemRepository.findAll();
		model.addAttribute("items", items);

		return "/basic/items";
	}
	// 컨트롤러만 구현하고 테스트를 하면 노출할 상품이 없기 때문에 프로젝트 로드시 
	// 해당 빈의 의존관계가 모두 주입된 후 초기화 용도로 호출
	// 첨부된 메소드 init()을 수행해 2개의 아이템을 미리 추가
	@PostConstruct
	public void init() {
		itemRepository.save(new Item("itemA", 10000, 10));
		itemRepository.save(new Item("itemB", 20000, 20));
	}
}

 @PostConstructor

 - 컨트롤러만 구현하고 테스트를 하면 노출할 상품이 없기 때문에 프로젝트 로드시 해당 빈의 의존관계가 모두 주입된 후 초기화 용도로 호출됨

 - 첨부된 메소드 init()을 수행해 2개의 아이템을 미리 추가

 

 @RequiredArgsConstructor
 - 롬복(Lombok)에서 제공하는 애노테이션으로 final이 붙은 멤버 변수만 사용해 생성자를 자동으로 만들어줌

 - 생성자를 통해 해당 멤버변수를 자동 주입

 

2. 상품 목록 view 구현

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>상품 목록</h2>
    </div>
    <div class="row">
        <div class="col">
            <button class="btn btn-primary float-end"
                    onclick="location.href='addForm.html'"
                    th:onclick="|location.href='@{/basic/items/add}'|"
                    type="button">
                상품 등록
            </button>
        </div>
    </div>
    <hr class="my-4">
    <div>
        <table class="table">
            <thead>
            <tr>
                <th>ID</th>
                <th>상품명</th>
                <th>가격</th>
                <th>수량</th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="item : ${items}">
                <td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원 ID</a></td>
                <td><a href="item.html" th:href="@{|/basic/items/${item.id}|}"
                       th:text="${item.itemName}">상품명</a></td>
                <td th:text="${item.price}">상품 가격</td>
                <td th:text="${item.quantity}">수량</td>
            </tr>
            </tbody>
        </table>
    </div>
</div> <!-- /container -->
</body>
</html>

 

 

타임리프 사용법 간단히 알아보기

1. 타임리프 사용선언

 타임리프를 HTML 페이지에서 사용하기 위해선 다음과같이 html 태그에 작성해줘야 함.

<html xmlns:th="http://www.thymeleaf.org">

 

2. 속성 변경 - th:href

th:href="@{/css/bootstrap.min.css}"

기존 href="value1"를 th:href="value2"로 변경해줌
 타임리프 뷰 템플릿을 거치면 원래 값을 th:xxx 으로 변경. 만약 값이 없다면 새로 생성.
HTML을 그대로 볼 때는 href 속성이 그대로 사용되고 뷰 템플릿을 거치면 th:href의 값이 href로 대치됨

 대부분의 HTML 태그의 속성을 th:xxx 로 변경할 수 있음

 

 

3. 타임리프 핵심

 th:xxx부분은 서버사이드에서 렌더링되고 기존 HTML 태그 속성을 대치함

 만약 th:xxx이 없으면 기존 html의 xxx속성이 그대로 사용됨
 HTML파일을 그냥 탐색기로 열어도 th:xxx 속성을 웹 브라우저에서는 읽지 못하기에 무시하고 기본 xxx속성을 읽어서 웹페이지는 깨지지않고 렌더링됨

※ 이를 네추럴 템플릿이라고 함.

 

 

4. URL링크 표현식 - @{...}

th:href="@{/css/bootstrap.min.css}"

 URL 링크를 사용하는 경우 @{...}를 사용하는데 이를 URL링크 표현식이라 함.
 URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함함.

 

 

5. 리터럴 대체 문법 - |...|

※ 타임리프에서 문자와 표현식 등을 합쳐서 쓰고자 할때 사용 

th:onclick="|location.href='@{/basic/items/add}'|"

 

타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 함 (+ 연산자)

<span th:text="'Welcome to our application, ' + ${user.name} + '!'">

 

 다음과 같이 리터럴 대체 문법을 사용하면, 더하기 연산자 없이 편리하게 사용할 수 있음.

<span th:text="|Welcome to our application, ${user.name}!|">

 

※ 상세 설명

 만들고 싶은 코드

location.href='/basic/items/add'

 

 리터럴 대체 문법 없이 그냥 사용하면 문자와 표현식을 각각 따로 더해서 사용해야 하므로 다음과 같이 복잡해짐.

th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"

 

 리터럴 대체 문법을 사용하면 다음과 같이 편리하게 사용할 수 있음

th:onclick="|location.href='@{/basic/items/add}'|"

※ 기존에는 자바의 문자열 결합처럼 +연산자와 escape 기호를 사용해 하나하나 작성해야했지만,
리터럴 대체 문법을 사용하면 자바스크립트의 백틱(``)처럼 편리하게 사용 가능.

 

 

6. th:each 반복 출력

<tr th:each="item : ${items}">

 반복은 th:each를 사용

 - 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있음

 - 컬렉션의 수 만큼 <tr>...</tr> 이 하위 태그를 포함해서 생성.

 

 

7. 변수 표현식 - ${...}

<td th:text="${item.price}">10000</td>

 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있음.

내부적으로 item.getPrice() 프로퍼티 접근법을 사용함.

 

 

8. URL 링크 표현식2 - @{...}

th:href="@{/basic/items/{itemId}(itemId=${item.id})}"

<!-- 위와 같은 코드. 리터럴 대체 문법 활용가능. -->
th:href="@{|/basic/items/${item.id}|}"

 경로를 템플릿처럼 사용할 수 있다.
 경로변수({itemId}) 뿐 아니라 쿼리 파라미터도 생성할 수 있음
ex) th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
생성 링크 - http://localhost:8080/basic/items/1?query=test

 리터럴 대체 문법을 사용해 간단하게 사용할 수도 있음

 

 

상품 상세

1. 상품 상세 controller 구현

@GetMapping("/{itemId}")
public String item(Model model, @PathVariable Long itemId) {
    Item item = itemRepository.findById(itemId);
    model.addAttribute("item", item);

    return "/basic/item";
}

 PathVariable로 넘어온 아이템 아이디로 상품을 조회 후 모델에 추가해 뷰 템플릿을 호출

 

2. 상품 상세 view 구현

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 상세</h2>
    </div>
    <h2 th:if="${param.status}" th:text="'저장 완료'"></h2>
    <div>
        <label for="itemId">상품 ID</label>
        <input type="text" id="itemId" name="itemId" class="form-control"
               value="1" th:value="${item.id}" readonly>
    </div>
    <div>
        <label for="itemName">상품명</label>
        <input type="text" id="itemName" name="itemName" class="form-control"
               value="상품A" th:value="${item.itemName}" readonly>
    </div>
    <div>
        <label for="price">가격</label>
        <input type="text" id="price" name="price" class="form-control"
               value="10000" th:value="${item.price}" readonly>
    </div>
    <div>
        <label for="quantity">수량</label>
        <input type="text" id="quantity" name="quantity" class="form-control"
               value="10" th:value="${item.quantity}" readonly>
    </div>
    <hr class="my-4">
    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-primary btn-lg"
                    onclick="location.href='editForm.html'"
                    th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
                    type="button">상품 수정
            </button>
        </div>
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg"
                    onclick="location.href='items.html'"
                    th:onclick="|location.href='@{/basic/items}'|"
                    type="button">목록으로
            </button>
        </div>
    </div>
</div> <!-- /container -->
</body>
</html>

 th:value th:value="${item.id}"

 - 모델에 있는 item 정보를 획득하고 프로퍼티 접근법 ( item.getId() )으로 출력

 - value 속성을 th:value 속성으로 변경

 

 

상품 등록 폼

1. 상품 등록 controller 구현

@GetMapping("/add")
public String addForm() {
    return "basic/addForm";
}

해당 컨트롤러에서는 단순하게 뷰 템플릿만 호출해서 상품 등록페이지로 이동만 담당

 

2. 상품 등록 view 구현

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 등록 폼</h2>
    </div>
    <h4 class="mb-3">상품 입력</h4>
    <form action="item.html" th:action method="post">
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" name="itemName" class="formcontrol"
                   placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" name="price" class="form-control"
                   placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" name="quantity" class="formcontrol"
                   placeholder="수량을 입력하세요">
        </div>
        <hr class="my-4">
        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">상품
                    등록
                </button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href=item.html"
                        th:onclick="|location.href='@{/basic/items}'|" type="button">취소
                </button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>

 th:action

 - HTML form에서 action 에 값이 없으면 현재 URL에 데이터를 전송
 - 상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 똑같이 맞추고 HTTP 메서드로 두 기능을 구분.

     상품 등록 폼: GET /basic/items/add
     상품 등록 처리: POST /basic/items/add
※ 이렇게 하면 하나의 URL로 등록 폼과, 등록 처리를 깔끔하게 처리할 수 있음.

 

 

상품 등록 처리 : @ModelAttribute

※ HTML Form, HTTP 메서드는 POST 방식을 사용해 데이터를 전송
 Content-Type: application/x-www-form-urlencoded
 메세지 바디에 쿼리 파라미터 형식으로 전달
 - itemName=name&price=10000&quantity=10

 

1. 상품 등록 처리 controller - @RequestParam로 처리 v1

@PostMapping("/add")
public String saveLegacy(@RequestParam String itemName,
                         @RequestParam int price,
                         @RequestParam Integer quantity,
                         Model model) {
    Item item = new Item();
    item.setItemName(itemName);
    item.setPrice(price);
    item.setQuantity(quantity);

    Item save = itemRepository.save(item);
    model.addAttribute("item", save);
    return "basic/item";
}

 @RequestParam으로 요청 파라미터 데이터를 해당 변수에 각각 받는다
 Item 객체를 생성해 전달받은 파라미터로 값을 세팅한 뒤 itemRepository  저장.
 저장된 item을 Model 객체에 담아 뷰에 전달

 

2. 상품 등록 처리 controller - @ModelAttribute로 처리 v2

위 방법은 간단하게 아이템을 저장하는 로직임에도 불구하고 코드가 너무 김.

전달받는 3개의 요청 파라미터도 결국 하나의 객체를 만들기 위한 파라미터들이기에 이를 한번에 매핑시켜 객체로 받을수있는 @ModelAttribute애노테이션이 있음.

@PostMapping("/add")
// 요청 파라미터를 프로퍼티 접근법으로 Item 객체를 생성 하고(V1의 setXxx처럼)
// model에 넣어줌(@ModelAttribute("item")의 "item"이라는 이름으로)
public String saveV2(@ModelAttribute("item") Item item, Model model) {		
	itemRepository.save(item);

	// model.addAttribute("item", item); // 자동 추가 생략가능
	return "basic/item";
}

 @ModelAttribute 애노테이션을 활용해 요청 파라미터를 처리해줌.
 - Item 객체를 생성 후, 요청 파라미터의 값을 프로퍼티 접근법(setXxx)으로 입력해줌


 @ModelAttribute - Model 자동 추가
 - 위 코드를 보면 Model 객체에 저장된 item을 추가해주는 로직을 주석처리함.

 - Model에 @ModelAttribute로 지정한 객체를 자동으로 넣어줌

 

 모델에 데이터를 담을때는 이름이 필요한데 이름은 @ModelAttribute 애노테이션에 지정한 속성("item")을 사용함

 - 만약 다음과 같이 @ModelAttribute의 이름을 다르게 지정하면 다른 이름으로 모델에 포함된다.

이름을 hello 로 지정 하면 → @ModelAttribute("hello") Item item

모델에 hello 이름으로 저장 → model.addAttribute("hello", item)

 

 

3. 상품 등록 처리 controller - @ModelAttribute 이름 생략 처리 v3

@PostMapping("/add")
// @ModelAttribute의 이름을 넣지 않으면 클래스명(Item)의 첫글자만 소문자로 변경해서 model.addAttribute에 등록함
// Ex) @ModelAttribute HelloItem item → model.addAttribute("helloItem", item);
public String saveV3(@ModelAttribute Item item, Model model) {		
	itemRepository.save(item);

	// model.addAttribute("item", item); // 자동 추가 생략가능
	return "basic/item";
}

 @ModelAttribute 애노테이션에서 name 속성을 생략할수도 있음
 - 생략하면 모델에 저장될 때 클래스명에서 첫 글자를 소문자로 변경해 등록함.
 ex) @ModelAttribute HelloWord helloWord이면 Model.addAttribute("helloWord", helloWord) 와 같다.

 

 

4. 상품 등록 처리 controller - @ModelAttribute 전체 생략 처리 v4

@PostMapping("/add")
// @ModelAttribute 생략가능
public String saveV4(Item item) {		
	itemRepository.save(item);
	return "basic/item";
}

 @ModelAttribute 애노테이션도 생략이 가능

 - 대상 객체가 모델에 자동등록되는 기능도 정상 동작
 - 객체가 아니라 기본타입(String, int ...)이면 @RequestParam이 동작 

 

 

상품 수정

1. 상품 수정 폼 이동 controller 구현

@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
	Item item = itemRepository.findById(itemId);
	model.addAttribute("item",item);
	return "basic/editForm";
}

 수정에 필요한 정보를 조회해서 model에 담고, 수정용 폼 뷰를 호출

 

2. 상품 수정 폼 view 구현

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}" href="/css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 수정 폼</h2>
    </div>
    <form action="item.html" th:action method="post">
        <div>
            <label for="id">상품 ID</label>
            <input type="text" id="id" name="id" class="form-control" value="1" th:value="${item.id}"
                   readonly>
        </div>
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" name="itemName" class="formcontrol"
                   value="상품A" th:value="${item.itemName}">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" name="price" class="form-control"
                   value="10000" th:value="${item.price}">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" name="quantity" class="formcontrol"
                   value="10" th:value="${item.quantity}">
        </div>
        <hr class="my-4">
        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">저장</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='item.html'"
                        th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|"
                        type="button">취소
                </button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>

 

3. 상품 수정 처리 controller 구현

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
	itemRepository.update(itemId, item);
	return "redirect:/basic/items/{itemId}";
}

 return "redirect:/basic/items/{itemId}"
 - 상품 수정은 마지막에 뷰 템플릿 호출이 아닌 상품 상세 화면으로 이동하도록 리다이렉트를 호출함
 - 스프링에서는 redirect:/... 를 사용해 편리하게 리다이렉트를 지원함
(만약 스프링이 아니라면 응답 상태코드를 3xx로 설정해서 동작시켜야 함.)
 - 컨트롤러에 매핑된 @PathVariable의 값인 itemId가 그대로 사용되어 매핑됨

 

 

PRG (Post/Redirect/Get)

1. 상품 등록 페이지에서 상품 등록 시 발생 할 수 있는 이슈

@PostMapping("/add")
// @ModelAttribute 생략가능
public String saveV4(Item item) {		
	itemRepository.save(item);
	return "basic/item";
}

 상품등록페이지 및 수정페이지에서 등록이 완료된상태에서 새로고침을 하면 마지막으로 요청했던 URL 경로로 재요청을 하게 됨

 마지막에 Post 방식으로 상품등록을 했다면 해당 상품등록 요청이 재전송되어 중복등록되는 치명적인 문제가 생김.

Post메서드로 요청이 들어온 /add Url을 처리하여 itemRepository에 저장 후 바로 view를 보여줌.

브라우저는 Url 정보와 post메서드로 보낸 데이터를 그대로 가지고 있어서 새로 고침 시 계속 그 url과 데이터를 가지고 다시 /add Url을 처리함.

 

 

 

 

2. PRG : Post/Redirect/Get을 이용하여 해결

 웹 브라우저의 새로 고침은 마지막에 서버에 전송한 URL로 데이터를 다시 전송함
 새로 고침 문제를 해결하려면 상품 저장 후에 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트로 다른 URL(/basic/items/{id})을 호출해주면 됨
 웹 브라우저는 리다이렉트의 영향으로 상품 저장 후에 상품 상세 화면으로 이동함
 마지막에 호출한 내용이 상품 상세 화면인 GET /items/{id} 가 됨.
 이후 새로고침을 해도 GET 요청은 멱등성을 보장하기에 새로 고침 문제를 해결할 수 있음.

 

 

 

3. PRG 적용한 상품 등록 controller 코드

@PostMapping("/add")
public String saveV5(Item item) {
	itemRepository.save(item);
	return "redirect:/basic/items/"+item.getId();
}

 return "redirect:/basic/items/" + item.getId();
 - URL에 변수를 더해 사용하는 것은 URL 인코딩이 안되기에 위험함.
 ※ RedirectAttributes를 사용해서 해결 가능

 

 

4. PRG 적용한 상품 등록 controller 코드 - RedirectAttributes 적용

// RedirectAttributes 적용(URL 인코딩 기능, @PathVariable 기능, 쿼리 파라미터 추가 기능 활용가능)
@PostMapping("/add")
public String saveV6(Item item, RedirectAttributes redirectAttributes) {
	Item savedItem = itemRepository.save(item);
	// redirect와 관련된 속성을 넣어줌
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	
	// 치환자가 없는 redirectAttributes attribute는 쿼리 파라미터 형식으로 들어감
	redirectAttributes.addAttribute("status", true);
	
	// redirectAttributes에 넣은 "itemId"값이 @PathVariable처럼 치환이 가능
	return "redirect:/basic/items/{itemId}";
}

 RedirectAttributes : URL 인코딩도 해주고, pathVarible , 쿼리 파라미터까지 처리해줌

 redirect:/basic/items/{itemId}

 - redirectAttributes.addAttribute("itemId", savedItem.getId());

   ※ return에 치환자인 {itemId}에 있으니 pathVariable 바인딩됨 

 - redirectAttributes.addAttribute("status", true);

   ※ return에 치환자 없는 나머지는 쿼리 파라미터로 처리됨 ?status=true

url 리다이렉트 결과 :  http://localhost:8080/basic/items/3?status=true

 

5. view 템플릿에 ?status=true 파라미터로 저장 완료 메세지 출력

<div class="container">
	<div class="py-5 text-center">
	<h2>상품 상세</h2>
</div>

<!-- 추가 -->
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>

 th:if : 해당 조건이 참이면 실행

 ${param.status} : 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능

 원래는 컨트롤러에서 모델에 직접 담고 값을 꺼내야 하지만, 쿼리 파라미터는 자주 사용해서 타임리프에서 직접 지원함

상품 목록에서 상품 상세로 이동한 경우에는 ?status=true파라미터가 없기 때문에 해당 메시지가 출력되지 않음

반응형
반응형

로깅

1. 로깅 라이브러리

1.1 로깅 라이브러리 종류

 스프링 부트 라이브러리를 사용하면 스프링 부트 로깅 라이브러리( spring-boot-starter-logging )가 함께 포함됨.

 스프링 부트 로깅 라이브러리는 기본으로 다음 로깅 라이브러리를 사용.

SLF4J - http://www.slf4j.org

Logback - http://logback.qos.ch

 

1.2 사용법

 클래스 참조 변수 선언

/* getClass()메서드를 통해 사용되는 클래스 타입 반환하여 삽입 */
private Logger log = LoggerFactory.getLogger(getClass());

/* 직접적으로 해당 클래스타입을 입력해줘도 된다. */
private static final Logger log = LoggerFactory.getLogger(Xxx.class);

 

 롬복 사용

@Slf4j
public class TestController {
	...
}

 

1.3 코드

package hello.springmvc.basic;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

//@Controller // 반환값이 String이면 뷰 이름으로 인식하기에 뷰를 찾고 뷰가 렌더링된다.

//@Slf4j // private final Logger log = LoggerFactory.getLogger(getClass()); 안써도됨 lombok에서 제공
@RestController // return 값으로 뷰를 찾는 것이 아니라, HTTP 메시지 바디에 바로 입력한다.
public class LogTestController {
	//private final Logger log = LoggerFactory.getLogger(LogTestController.class);
	private final Logger log = LoggerFactory.getLogger(getClass());
	
	@RequestMapping("/log-test")
	public String logTest() {
		String name = "Spring";
		
		System.out.println("name = "+ name);
		
		// 이렇게 사용하면 안됨
		// argument에 +를 먼저 연산하고 메모리를 할당함, 쓸데없는 리소스를 낭비하게됨
		log.trace("trace log="+ name);
		
		log.trace("trace log={}",name);
		log.debug("debug log={}",name);
		log.info("info log={}", name);
		log.warn("warn log={}", name);
		log.error("error log={}", name);
		
		return "ok";
	}
}

 @RestController
 - @Controller는 반환값이 String이면 뷰 이름으로 인식하기에 뷰를 찾고 뷰가 렌더링됨
 - @RestController는 반환 값으로 뷰를 찾는게 아니라 HTTP 메세지 바디에 바로 입력함
 - 클래스레벨이아닌 메서드레벨에서 @ResponseBody를 사용하면 클래스레벨에 @Controller를 사용하더라도 바로 HTTP 메세지 바디에 입력해서 반환을 해줌

 

 로그 출력 포맷

시간, 로그 레벨, 프로세스 ID(PID), 쓰레드 명, 클래스 명, 로그 메세지

 

1.4 로그 레벨

 작성한 로그 출력코드는 5개인데 로그는 3개가 출력되는 이유

 - 로그의 출력 레벨이 있음.

 - 로그레벨을 설정하면 그 로그 보다 우선순위가 높은 것만 출력

 - 스프링 부트에서 기본으로 설정되어 있는 로그레벨은 info

 - 그렇기에 info보다 우선순위가 낮은 debug, trace는 출력되지 않음.

 

 로그 레벨
 - TRACE > DEBUG > INFO > WARN > ERROR
 - 개발서버는 debug까지 출력가능
 - 운영서버는 통상적으로 info만 출력

 

 로그를 임의로 내가 원하는대로 변경하는법

설정파일(application.properties)에서 레벨을 변경

#전체 로그 레벨 설정(기본 info)
lolgging.level.root=info

#hello.springmvc 패캐지와 그 하위 로그 레벨 설정
logging.level.hello.springmvc=[변경을 원하는 로그 레벨]

 

1.5 올바른 로그 사용법

 기존의 문자열 결합을 이용한 출력문 사용

System.out.println(name + "님 안녕하세요.");

/*로그도 위와같이 사용한다면?*/
log.debug(name + "님 안녕하세요.");

※ 로그 레벨을 info로 설정해도 해당 코드에 있는 name + "님 안녕하세요."는 자바 특성상 실행이 안되어도 연산이 되버어림.

자바 컴파일 시점에서 사용하지도 않는 debug레벨에 있는 연산을 평가해버리니 리소스 낭비.


 새로운 방식의 로그 출력 방식

log.debug("{} 님 안녕하세요.", name);

※ 로그 출력레벨이 debug 이상이면 debug내의 연산은 수행되지 않음.  

1.6 로그 사용시 장점
 쓰레드 정보, 클래스 이름같은 정보를 함께 볼 수 있고, 출력 모양을 조정 가능
 로그 레벨에 따라 개발 서버에서는 모든 로그를 출력하고 운영서버에서는 출력하지 않게끔 로그를 조절 가능
콘솔에만 출력하는게 아니라 파일이나 네트워크 등 로그를 별도의 위치에 남길 수 있음
 특히 파일로 남길 때 일별, 특정 용량에 따라 로그를 분할하는것도 가능
 성능도 System.out보다 좋음 (내부 버퍼링, 멀티 쓰레드 등) 

 

 

요청매핑(RequestMapping)

1. 기본매핑

/**
 * 기본 요청
 * 둘 다 허용한다 /hello-basic, /hello-basic/
 * 스프링 부트 3.0 부터는 /hello-basic , /hello-basic/ 는 서로 다른 URL 요청으로 인식함
 * HTTP 메서드 모두 허용 GET, POST, HEAD, PUT, PATCH, DELETE
 */
@RequestMapping("/hello-basic")
public String helloBasic() {
	log.info("helloBasic");
	return "ok";
}

 @RequestMapping("/hello-basic")
 - /hello-basic URL 호출이 오면 이 메서드가 실행되도록 매핑
 - 대부분의 속성을 배열[]로 제공하기에 다중 설정도 가능
    ex) @RequestMapping(value = {"/hello-basic", "/hello-go"})
 - method 속성으로 HTTP 메서드를 지정하지 않으면 모든 메서드에 무관하게 호출
   (GET, HEAD, POST, PATCH, DELETE)

 

 

2. HTTP 특정 method 허용 매핑

/**
 * method 특정 HTTP 메서드 요청만 허용한다.
 * GET, HEAD, POST, PUT, PATCH, DELETE
 */
@RequestMapping(value = "/mapping-get-v1", method = RequestMethod.GET)
public String mappingGetV1() {
	log.info("mappingGetV1");
	return "ok";
}

 method가 GET일 경우에만 매핑이되며 다른 방식으로 요청하면 HTTP 405(Method Not Allowd)가 반환

 

 

3. HTTP 특정 method 허용 매핑 축약

/**
 * 편리한 축약 애노테이션
 *
 * @GetMapping
 * @PostMapping
 * @PutMapping
 * @DeleteMapping
 * @PatchMapping
 */
@GetMapping(value = "/mapping-get-v2")
public String mappingGetV2() {
	log.info("mapping-get-v2");
	return "ok";
}

 매번 method 속성을 설정해서 HTTP 메서드를 지정해주는게 번거롭고 가독성도 떨어지기에 전용 애노테이션을 만들어서 해결
 GetMapping, PostMapping, PatchMapping, DeleteMapping등 이름에 의미를 부여해 더 직관적임
 - 애노테이션 내부에는 @RequestMapping과 method를 미리 지정해놓음

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
	...
}

 

 

4. PahVariable(경로 변수)를 사용한 매핑

/**
 * PathVariable 사용
 * 변수명이 같으면 아래와 같이 생략 가능
 * @PathVariable("userId") String userid -> @PathVariable String userId
 */
@GetMapping("/mapping/{userId}")
public String mappingPath(@PathVariable String userId) {
	log.info("mappingPath userId={}", userId);
	return "/mapping/{userId} ok";
}

 최근 HTTP API는 위와 같이 리소스 경로에 식별자를 넣는 스타일을 선호
  ex) /mapping/userA
  ex) /users/1
 @RequestMapping은 URL 경로를 템플릿화 할 수 있는데 @PathVariable 애노테이션을 사용하면 매칭 되는 부분을 편리하게 조회할 수 있음
 @PathVariable의 이름과 파라미터 이름이 같으면 생략 가능하다. 
⇒ @PathVariable("username") String username → PathVariable String username

 

 

5. 다수의 PahVariable(경로 변수)를 사용한 매핑

/**
* PathVariable 사용 다중
*/
@GetMapping("/mapping/users/{userId}/orders/{orderId}")
public String mappingPath(@PathVariable String userId, @PathVariable Long orderId) {
	log.info("mappingPath userId={}, orderId={}", userId, orderId);
	return "/mapping/users/{userId}/orders/{orderId} ok";
}

 하나 이상의 PathVariable도 사용이 가능

 

 

6. 특정 파라미터 조건 매핑

/**
* 파라미터로 추가 매핑
* params="mode",		// 파라미터 이름만 있어도됨
* params="!mode"		// 특정 파라미터이름이 안 들어가있어야 함
* params="mode=debug"	// 특정 파라미터이름과 값이여야함
* params="mode!=debug"	// 특정 파라미터이름과 값이면 안됨
* params = {"mode=debug","data=good"} // 여러개의 특정 파라미터이름과 값이여야함
*/
// 특정 파라미터 정보가 있을때만 호출 됨(url경로 뿐아니라 파라미터 정보까지 추가로 더 매핑한것)
@GetMapping(value = "/mapping-param", params = "mode=debug")
public String mappingParam() {
	log.info("mappingParam");
	return "/mapping-param ok";
}

 특정 파라미터를 조건식으로 매핑해서 매핑여부를 결정할 수 있음
   ex) http://localhost:8080/mapping-param?mode=debug
 잘 사용하지 않음

 

 

7. 특정 헤더 조건 매핑

/**
* 특정 헤더로 추가 매핑
* headers="mode"		// 헤더에 특정 헤더명이 들어가있어야함
* headers="!mode"		// 헤더에 특정 헤더명이 안 들어가있어야함
* headers="mode=debug"	// 헤더에 특정 헤더명과 값이여야함
* headers="mode!=debug" // 헤더에 특정 헤더명과 값이면 안됨
*/
@GetMapping(value = "/mapping-header", headers = "mode=debug")
public String mappingHeader() {
	log.info("mappingHeader");
	return "/mapping-header ok";
}

 특정 파라미터 매핑과 동일하게 헤더 역시 조건매핑이 가능

 

 

8. 미디어 타입 조건 매핑 1 - HTTP 요청 Content-Type, consume

/**
* Content-Type 헤더 기반 추가 매핑 Media Type
* consumes="application/json"	// Content-Type 헤더에 Media Type이 "application/json"이여야함
* consumes="!application/json"	// Content-Type 헤더에 Media Type이 "application/json"이면 안됨
* consumes="application/*"		// Content-Type 헤더에 Media Type이 "application/*"이여야함
* consumes="*\/*"				// Content-Type 헤더에 Media Type이 "*\/*"이여야함
* MediaType.APPLICATION_JSON_VALUE = "application/json"
*/
// 요청 컨텐트 타입이 json이 여야함
@PostMapping(value = "/mapping-consume", consumes = "application/json")
public String mappingConsumes() {
	log.info("mappingConsumes");
	return "/mapping-consume ok";
}

 HTTP 요청의 Content-Type 헤더를 기반으로 미디어 타입으로 매핑
 일치하지 않을 경우 HTTP 415(Unsupported Media Type)을 반환
 조건을 배열로 설정할수도 있고 상수로 제공하는 매직넘버를 사용해도 됨

 사용 예시

consumes = "application/json"
consumes = {"text/plain", "application/*"}
consumes = MediaType.TEXT_PLAIN_VALUE

 

 

9. 미디어 타입 조건 매핑 2 - HTTP 요청 Accept, produce

/**
* Accept 헤더 기반 Media Type
* produces = "text/html"	// Accept 헤더에 Media Type이 "text/html"이여야함
* produces = "!text/html"	// Accept 헤더에 Media Type이 "text/html"이면 안됨
* produces = "text/*"		// Accept 헤더에 Media Type이 "text/*"이여야함
* produces = "*\/*"			// Accept 헤더에 Media Type이 "*\/*"	이여야함
* MediaType.MediaType.TEXT_PLAIN_VALUE = "text/plain"
*/
// 클라이언트가(브라우저가) text/html만 받아들일수 있다는 뜻
@PostMapping(value = "/mapping-produce", produces = "text/html")
public String mappingProduces() {
	log.info("mappingProduces");
	return "/mapping-produce ok";
}

 HTTP 요청의 Accept 헤더를 기반으로 미디어 타입으로 매핑
 만약 맞지 않으면 HTTP 406(Not Acceptable)을 반환

 

 

요청 매핑(Request Mapping) - API 예시

1. API 명세 예시

기능 HTTP Method URI
회원 목록 조회 GET /mapping/users
회원 등록 POST /mapping/users
회원 조회 GET /mapping/users/{userId}
회원 수정 PATCH /mapping/users/{userId}
회원 삭제 DELETE /mapping/users/{userId}

 

2. 코드 예시

package hello.springmvc.basic.requestmapping;

import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("/mapping/users")
@RestController
public class MappingClassController {	
	@GetMapping
	public String users() {
		return "get users";
	}

	@PostMapping
	public String addUser() {
		return "post users";
	}
	
	@GetMapping("/{userId}")
	public String findUser(@PathVariable String userId) {
		return "get userId=" + userId;
	}
	
	@PatchMapping("/{userId}")
	public String updateUser(@PathVariable String userId) {
		return "update userId=" + userId;
	}
	
	@DeleteMapping("/{userId}")
	public String deleteUser(@PathVariable String userId) {
		return "delete userId="+userId;
	}
}

 

 

HTTP 요청

1. 기본, 헤더 조회 예제

package hello.springmvc.basic.request;

import java.util.Locale;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpMethod;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
public class RequestHeaderController {
	
	@RequestMapping("/headers")
	public String headers(HttpServletRequest request,
			HttpServletResponse response,
			HttpMethod httpMethod,
			Locale locale,
			// MultiValueMap : Map과 유사한데 하나의 키에 여라값을 받을 수 있음(keyA=value1&keyA=value2)
			@RequestHeader MultiValueMap<String, String> headerMap,
			@RequestHeader("host") String host,
			@CookieValue(value = "myCookie", required = false) String cookie
			) {
		log.info("request={}", request);
		log.info("response={}", response);
		log.info("httpMethod={}", httpMethod);
		log.info("locale={}", locale);
		log.info("headerMap={}", headerMap);
		log.info("header host={}", host);
		log.info("myCookie={}", cookie);
		
		return "ok";
	}
}

 HttpMethod httpMethod 객체
 - HTTP 메서드를 조회(org.springframework.http.HttpMethod)


 Locale locale 객체
 - Locale 정보를 조회(ko-kr, euc-kr, kr ...)


 @RequestHeader MultiValueMap<String, String> headerMap
 - 모든 HTTP 헤더를 MultiValueMap 형식으로 조회


 @RequestHeader("host")String host
 - 특정 HTTP 헤더를 조회
 - 속성
     : 필수 값 여부(required)
     : 기본 값 속성(defaultValue)


 @CookieValue(value = "myCookie", required = false) String cookie
- 특정 쿠키를 조회
- 속성
    : 필수 값 여부(required)
    : 기본 값 속성(defaultValue)


 MultiValueMap
 - Map과 유사하지만 하나의 키에 여러 값을 받을 수 있음  
 - HTTP header, HTTP 쿼리 파라미터와 같이 하나의 키에 여러 값을 받을 때 사용
    ex) keyA=value1&keyA=value2

MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("keyA", "value1");
map.add("keyA", "value2");

//[value1, value2]
List<String> values = map.get("keyA");

 

 

2. HTTP 요청 파라미터 - 쿼리 파라미터와 HTML Form

2.1 HttpServletRequest객체

 HTTP 요청 메세지를 개발자가 사용하기 편하게 변환해 제공하는 것이 HttpServletRequest 객체
 이 객체내의 getParameter()를 이용하면 요청 파라미터를 조회할 수 있음

 queryString으로 요청 메세지를 전달하는 것은 GET 전송 방식

 HTML Form에서 요청 메세지 바디에 전달 하는 것은 POST  전송방식  
 GET 쿼리 파라미터 전송의 URL 예시

http://localhost:8080/request-param?username=hello&age=20

 POST, HTML Form 전송의 요청 메세지 예시

POST /request-param ...
content-type: application/x-www-form-urlencoded

username=hello&age=20

※ 위 GET쿼리 파라미터 전송 방식과 POST, HTML Form 전송의 요청 메세지 방식은 모두 형식이 동일 하기때문에

구분없이 getParameter() 메서드를 이용해 조회할 수 있음 이를 요청 파라미터(request parameter)조회라고함.

 

 

2.2 request.getParameter()메서드로 데이터 사용 예제 코드

@Controller
public class RequestParamController {
	//서블릿때 사용하던 쿼리 스트링 추출 방식
	@RequestMapping("/request-param-v1")
	public void requestParamV1(HttpServletRequest request, HttpServletResponse response) throws IOException {
		String username = request.getParameter("username");
		int age = Integer.parseInt(request.getParameter("age"));
		log.info("username={}, age={}", username, age);

		response.getWriter().write("ok");
	}
}

 

 

2.3 @RequestParam어노테이션으로 데이터 사용 예제 코드

@Controller
public class RequestParamController {
	@ResponseBody // @RestController랑 같은 효과 바로 html body로 String을 뿌려줌 viewResolver를 안탐
	@RequestMapping("/request-param-v2")
	public String reqeustParamV2(
			@RequestParam("username") String memberName,
			@RequestParam("age") int memberAge) {

		log.info("username={}, age={}",memberName, memberAge);
		return "ok";
	}
		
	@ResponseBody 
	@RequestMapping("/request-param-v3")
	public String reqeustParamV3(
			// 파라미터 명과 변수명이 같으면 파라미터 명 생략가능
			@RequestParam String username,
			@RequestParam int age) {

		log.info("username={}, age={}",username, age);
		return "ok";
	}
		
	@ResponseBody 
	@RequestMapping("/request-param-v4")
	// 파라미터 명과 변수명이 같고 단순타입(String int Integer) 파라미터 명 @RequestParam 생략가능
	// 과한 생략이라는 생각이듬
	public String reqeustParamV4(String username, int age) {
		log.info("username={}, age={}",username, age);
		return "ok";
	}
	
	@ResponseBody 
	@RequestMapping("/request-param-required")
	public String reqeustParamRequired(
			// required = true가 기본 값 파라미터가 없으면 오류남
			// required = false일 경우 bad request 400 에러남
			// /request-param-required?username=
			// 위와 같은 url로 paramter없이 입력 시 null 아닌 ""로 인식하여 required를 통과함
			@RequestParam(required = true) String username, 
			@RequestParam(required = false) Integer age) {
		
		// java에 int에 null을 넣을수 없음
		//int a = null;
		// Integer는 참조변수형이라 null이 가능
		//Integer b =null;
				
		log.info("username={}, age={}",username, age);
		return "ok";
	}
		
	@ResponseBody 
	@RequestMapping("/request-param-default")
	public String reqeustParamDefault(
			// 파라미터가 없을 경우 기본 값 세팅
			// defaultValue를 사용하면 required가 의미가 없음
			// /request-param-required?username=
			// 빈 파라미터 값도 설정한 defaultValue 값으로 처리함
			@RequestParam(required = true, defaultValue = "guest") String username, 
			@RequestParam(required = false, defaultValue = "-1") int age) {
				
		log.info("username={}, age={}",username, age);
		return "ok";
	}
		
	@ResponseBody 
	@RequestMapping("/request-param-map")
	// 파라미터를 Map, MultiValueMap으로 받을 수 있음(대부분 파라미터 값은 한 개임)
	// @RequestParam Map : Map(key=value)
	// @RequestParam MultiValueMap : MultivalueMap(key=[value1,value2,...])
	public String reqeustParamMap(@RequestParam Map<String, Object> paramMap) {
		log.info("username={}, age={}",paramMap.get("username"), paramMap.get("age"));
		return "ok";
	}
}

 @ResponseBody
 - View 조회를 무시하고, HTTP message body에 직접 해당 내용을 입력
 - 클래스레벨에서 @Controller를 사용하는 경우 메서드레벨에서 해당 애노테이션을 사용해서 메세지 바디에 직접 내용입력하는게 가능


 @RequestParam("username") String usernam
 - 파라미터 이름으로 바인딩

 

 @RequestParam String username
 - HTTP 파라미터 이름이 변수 이름과 같을경우 파라미터 속성 생략이 가능
   ex) @RequestParam("username") String username → @RequestParam String username

 String username, int age
String, int, Integer 등의 단순 타입이면 @RequestParam도 생략이 가능

 

 RequestParam의 속성 2가지

 ① required : 파라미터 필수 여부 속성 

 - 사용 방법

    @RequestParam(required = true)
 - 기본값은 파라미터 필수 (required  = true)

 - 해당 파라미터를 공백(ex: username=)으로 전송하면 빈 문자로 통과가 됨
 - required가 true인 파라미터를 보내주지 않으면 400 예외(BAD_REQUEST)가 발생

 - 원시타입은 null이 들어갈 수 없어서 required가 false여도 500에러가 발생

     int형으로 에러가 발생하면 Integer같은 wrapper 타입을 사용해야 함
    혹은 기본값을 설정해주는 defaultValue를 사용하면 됨


② defaultValue : 파라미터가 없는 경우 기본값으로 설정한 값이 적용

 - 사용 방법

    @RequestParam(defaultValue =  "20")
 - 이미 기본값이 있기에 required는 의미가 없어 빼도 됨 
 - 빈 문자("")의 경우에도 설정한 기본 값이 적용 

 

 @RequestParam Map

 - Map을 이용해 한 번에 받을 수도 있음

 - Map(key=value) 형식

 

 @RequestParam MultiValueMap

 - 파라미터의 값이 1개가 확실하면 Map을 써도 되지만 그렇지 않다면 MultiValueMap을 사용
 - MultiValueMap(key=[value1, value2, ...] 형식

 ex) (key=userIds, value=[id1, id2])

 

2.3 @ModelAttribute어노테이션으로 데이터 사용 예제 코드
 HelloData 코드

package hello.springmvc.basic;

import lombok.Data;

// 요청 파라미터를 바인딩할 객체 HelloData
// @Getter, @Setter, @ToString, @EqualsAndHashCode, @RequiredArgsConstructor를 자동으로 적용해줌
@Data
public class HelloData {
	private String username;
	private int age;
}

 @ModelAttribute어노테이션으로 데이터 사용

@Controller
public class RequestParamController {
	@ResponseBody 
	@RequestMapping("/model-attribute-v1")
	// 요청 파라미터의 이름으로 HelloData 객체의 프로퍼티를 찾아서 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 입력(바인딩) 한다
	public String modelAttributeV1(@ModelAttribute HelloData helloData) {
		log.info("helloData={}", helloData);
		return "ok";
	}	

	@ResponseBody 
	@RequestMapping("/model-attribute-v2")
	// @ModelAttribute 생략가능
	// @RequestParam도 생략 가능함 그럼 Spring은 어떤 걸 바인딩 할까
	// String int Integer같은 단순 타입은 @RequestParam로 바인딩
	// 나머지는 @ModelAttribute로 바인딩
	public String modelAttributeV2( HelloData helloData) {
		log.info("helloData={}", helloData);
		return "ok";
	}
}

 ?username=spring&age=20 이라는 쿼리스트링을 담아서 요청을하면 바로 HelloData 객체에 담겨서 사용할 수 있음
 스프링MVC는 @ModelAttribute가 있으면 다음을 수행
 ① HelloData 객체를 생성

 ② 요청 파라미터의 이름으로 HelloData 객체의 프로퍼티를 찾음

 ③ 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 바인딩
      ※ 파라미터 이름이 username 이면 setUsername() 메서드를 찾아 호출

 

@ModelAttribute는 생략가능

 - @ModelAttribute 생략 vs @RequestParam 생략

 - 스프링은 해당 생략시 다음과 같은 규칙을 적용  
    String, int, Integer 같은 단순 타입 = @RequestParam

    나머지 = @ModelAttribute (argument resolver로 지정해둔 타입은 제외)

 

3. HTTP 요청 파라미터 - 단순 텍스트

※ HTTP 메세지 바디를 통해 데이터가 직접 넘어오는 경우는 HTML Form 방식을 제외하고 @RequestParam, @ModelAttribute를 사용할 수 없음

 

3.1 예제 v1

@Controller
public class RequestBodyStringController {
	@PostMapping("/request-body-string-v1")
	public void requestBodyString(HttpServletRequest request, HttpServletResponse response)throws IOException {
		ServletInputStream inputStream = request.getInputStream();
		String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
		
		log.info("messageBody={}",messageBody);
		response.getWriter().write("ok");
	}
}

 HttpServletRequst에서 getInputStream()으로 읽어와서 문자열로 변환해서 읽을 수 있음

 

 

3.2 예제 v2

@Controller
public class RequestBodyStringController {
	@PostMapping("/request-body-string-v2")
	// InputStream(Reader): HTTP 요청 메시지 바디의 내용을 직접 조회
	// OutputStream(Writer): HTTP 응답 메시지의 바디에 직접 결과 출력
	public void requestBodyStringV2(InputStream inputStream, Writer responseWriter)throws IOException {
		String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
		
		log.info("messageBody={}",messageBody);
		responseWriter.write("ok");
	}
}

 매개변수에서 바로 inputStream과 writer를 받을수도 있음

  - InputStream(Reader): HTTP 요청 메세지 바디의 내용을 직접 조회
  - OutputStream(Writer): HTTP 응답 메세지의 바디에 직접 결과 출력

 

 

3.3 예제 v3

@Controller
public class RequestBodyStringController {
	@PostMapping("/request-body-string-v3")
	// 메시지 바디 정보를 직접 조회, 요청 파라미터를 조회하는 기능과 관계 없음 @RequestParam X, @ModelAttribute X
	public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity)throws IOException {
		// HttpEntity: HTTP header, body 정보를 편리하게 조회
		String messageBody = httpEntity.getBody();
		httpEntity.getHeaders();
		
		log.info("messageBody={}",messageBody);
		
		// HttpEntity는 응답에도 사용 가능 메시지 바디 정보 직접 반환
		// 헤더 정보 포함 가능
		// view 조회X
		return new HttpEntity<>("ok");
	}
}

HttpEntity: HTTP header, body 정보를 편리하게 조회할 수 있게 해줌
 - 메세지 바디 정보를 직접 조회 가능(getBody())
 - 요청 파라미터를 조회하는 기능과 관계 없다.(@RequestParam, @ModelAttribute)
 응답에서도 사용할 수 있음

 - 메세지 바디 정보 직접 반환

 - 헤더 정보포함도 가능

 - View 조회는 안됨

 

 

3.3 예제 v3 다른방식

@Controller
public class RequestBodyStringController {
	@PostMapping("/request-body-string-v3-another")
	// RequestEntity : HttpMethod, url 정보가 추가, 요청에서 사용
	public HttpEntity<String> requestBodyStringV3Another(RequestEntity<String> httpEntity)throws IOException {
		String messageBody = httpEntity.getBody();
		httpEntity.getHeaders();
		log.info("messageBody={}",messageBody);
		
		// ResponseEntity : HTTP 상태 코드 설정 가능, 응답에서 사용
		return new ResponseEntity<>("ok", HttpStatus.CREATED);
	}
}

 HttpEntity를 상속받은 RequestEntity, ResponseEntity 객체들도 같은 기능을 제공

 - RequestEntity : HttpMethod, url 정보가 추가, 요청에서 사용

 - ResponseEntity : HTTP 상태 코드 설정 가능, 응답에서 사용

    ex) return new ResponseEntity("Hello World", responseHeaders, HttpStatus.CREATED)

 

 

3.4 예제 v4

@Controller
public class RequestBodyStringController {
	// @ResponseBody를 사용하면 응답 결과를 HTTP 메시지 바디에 직접 담아서 전달할 수 있다.(view를 사용하지 않음)
	@ResponseBody
	@PostMapping("/request-body-string-v4")
	// @RequestBody : HTTP 메시지 바디 정보를 편리하게 조회할 수 있다. 
	// 헤더 정보가 필요하다면 HttpEntity를 사용하거나 @RequestHeader를 사용하면 된다.
	// 메시지 바디를 직접 조회하는 기능은 요청 파라미터를 조회하는 @RequestParam, @ModelAttribute와는 전혀 관계가 없다.
	public String requestBodyStringV4(@RequestBody String messageBody)throws IOException {
		log.info("messageBody={}",messageBody);
		return "ok";
	}
	
	// 요청 파라미터를 조회하는 기능: @RequestParam , @ModelAttribute
	// HTTP 메시지 바디를 직접 조회하는 기능: @RequestBody
}

 @RequestBody
 - HTTP 메세지 바디 정보를 편리하게 조회하게 해주는 애노테이션

 - 만약 바디가 아니라 헤더정보가 필요하면 HttpEntity나 @RequestHeader 애노테이션을 사용하면 됨
 - 요청 파라미터를 조회하는 @RequestParam, @ModelAttribute와는 관계가 없다.

 

 

4. HTTP 요청 파라미터 - Json

※ HTTP 요청 메세지 바디에는 JSON이 주로 사용됨

 JSON은 다음과 같은 구조인데, 이를 객체로 변환하여 로직을 수행

 

4.1 예제 v1

@Controller
public class RequestBodyJsonController {
	// Jackson 라이브러리인 objectMapper
	private ObjectMapper objectMapper = new ObjectMapper();
	
	@PostMapping("/request-body-json-v1")
	public void requestBodyJsonV1(HttpServletRequest request, HttpServletResponse response) throws IOException{
		
		// HttpServletRequest를 사용해서 직접 HTTP 메시지 바디에서 데이터를 읽어와서, 문자로 변환
		ServletInputStream inputStream = request.getInputStream();
		String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
		
		log.info("messageBody={}", messageBody);
		
		// 문자로 된 JSON 데이터를 Jackson 라이브러리인 objectMapper를 사용해서 자바 객체로 변환
		HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
		log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
		response.getWriter().write("ok");
	}
}

 HttpServletRequest를 사용해서 직접 HTTP 메세지 바디에서 데이터를 읽어와, 문자로 변환
 문자로 된 JSON 데이터를 Jackson 라이브러리인 ObjectMapper를 사용해 자바 객체변환

 

 

4.2 예제 v2

@Controller
public class RequestBodyJsonController {
	// Jackson 라이브러리인 objectMapper
	private ObjectMapper objectMapper = new ObjectMapper();

	@ResponseBody
	@PostMapping("/request-body-json-v2")
	// @RequestBody를 사용해서 HTTP 메시지에서 데이터를 꺼내고 messageBody에 저장
	public String requestBodyJsonV2(@RequestBody String messageBody) throws IOException{
		log.info("messageBody={}", messageBody);
		
		// 문자로 된 JSON 데이터인 messageBody를 objectMapper를 통해서 자바 객체로 변환
		HelloData helloData = objectMapper.readValue(messageBody, HelloData.class);
		log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
		
		return "ok";
	}

 @RequestBody를 사용해서 HTTP 메시지에서 데이터를 꺼내고 messageBody에 저장

 문자로 된 JSON 데이터인 messageBody 를 objectMapper 를 통해서 자바 객체로 변환

 

4.3 예제 v3

@Controller
public class RequestBodyJsonController {
	@ResponseBody
	@PostMapping("/request-body-json-v3")
	// @RequestBody에 직접 만든 객체 HelloData를 지정할 수 있다.
	// HttpEntity, @RequestBody를 사용하면 HTTP 메시지 컨버터가 HTTP 메시지 바디의 내용을 우리가 원하는 문자나 객체 등으로 변환해줌
	// HTTP 메시지 컨버터는 문자 뿐만 아니라 JSON도 객체로 변환해주는데, 
	// V2의 HelloData helloData = objectMapper.readValue(messageBody, HelloData.class)를 처리해줌
	// @RequestBody는 생략 불가능 (HTTP 메시지 바디가 아니라 요청 파라미터 @ModelAttribute로 처리함)
	public String requestBodyJsonV3(@RequestBody HelloData helloData) throws IOException{
		log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
		return "ok";
	}
}

 @RequestBody 객체 파라미터에 직접 만든 객체를 지정할 수 있음

 

 @RequestBody는 생략 불가능

 - @ModelAttribute , @RequestParam 과 같은 해당 애노테이션을 생략시 다음과 같은 규칙을 적용

String , int , Integer 같은 단순 타입 = @RequestParam
나머지 = @ModelAttribute (argument resolver 로 지정해둔 타입 외)

- @RequestBody를 생략하면 @ModelAttribute 가 적용되어버림

     ※ HelloData data → @ModelAttribute HelloData data

- 생략하면 HTTP 메시지 바디가 아니라 요청 파라미터를 처리하게 됨

 

 주의 

- HTTP 요청시에 content-type이 application/json인지 꼭! 확인해야 함.

- 그래야 JSON을 처리할 수 있는 HTTP 메시지 컨버터가 실행됨

 

 

4.4 예제 v4, v5

@Controller
public class RequestBodyJsonController {
	@ResponseBody
	@PostMapping("/request-body-json-v4")
	public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity)throws IOException {
		HelloData data = httpEntity.getBody();
		log.info("username={}, age={}",data.getUsername(), data.getAge());
		return "ok";
	}
	
	// @ResponseBody : 객체 → HTTP 메시지 컨버터 → JSON 응답
	@ResponseBody
	@PostMapping("/request-body-json-v5")
	// @RequestBody : JSON 요청 → HTTP 메시지 컨버터 → 객체
	public HelloData requestBodyJsonV5(@RequestBody HelloData data) throws IOException{
		log.info("username={}, age={}", data.getUsername(), data.getAge());
		return data;
	}
}

 HttpEntity, @ResponseBody로 data 객체로 변환

※ 응답의 경우에도 @ResponseBody 를 사용하면 해당 객체를 HTTP 메시지 바디에 직접 넣어줄 수 있음

 

 @RequestBody 요청

JSON 요청 → HTTP 메시지 컨버터  객체

 

 @ResponseBody

응답 객체  HTTP 메시지 컨버터  JSON 응답

 

 HTTP 메시지 컨버터

 - @RequestBody, HttpEntity를 사용하면 HTTP 메시지 컨버터가 HTTP 메시지 바디의 내용을 우리가 원하는 문자나 객체 등으로 변환해줌.

 - HTTP 메시지 컨버터는 문자 뿐만 아니라 JSON도 객체로 변환해주는데, 우리가 방금 V2에서 했던 수동 작업을 대신 처리해줌

 

 

HTTP 응답

1. 정적 리소스, 뷰 템플릿, HTTP 메세지 사용

※ HTTP 요청에 대해서 서버에서 비즈니스 로직이 다 수행된 다음 이제 응답을 해야하는데 스프링(서버)에서 응답 데이터를 반드는 방식은 크게 세 가지가 있음

 정적 리소스
 뷰 템플릿 사용
 HTTP 메세지 사용

 

1.1 정적 리소스

 스프링 부트는 클래스패스에 다음 디렉토리에 있는 정적 리소스를 제공함.
  - /static, /public, /resources, /META-INF/resources
 src/main/resources는 리소스를 보관하는 곳이고, 클래스패스의 시작 경로임
위의 디렉토리에 리소스를 넣어두면 스프링 부트가 정적 리소스로 서비스를 제공
 예를 들어, 정적 리소스 경로 src/main/resources/static/baisc/hello-form.html에 해당 파일이 있다면 웹 브라우저에서는 컨트롤러를 통하지않고 정적리소스 경로 뒤의 경로를 입력해 바로 가져올 수 있음 

 

1.2 뷰 템플릿

 뷰 템플릿을 거쳐서 HTML이 생성되고, 뷰가 응답을 만들어서 전달

 일반적으로 HTML을 동적으로 생성하는 용도로 사용하지만, 뷰 템플릿이 만들 수 있는 것이라면 뭐든지 가능

 스프링 부트는 기본 뷰 템플릿 경로를 제공

 - 기본 뷰 템플릿 경로 src/main/resources/templates

 

 뷰 템플릿 생성 예제 코드

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Title</title>
</head>
<body>
	<p th:text="${data}">empty</p>
</body>
</html>

 뷰 템플릿 호출 예제 코드

package hello.springmvc.basic.response;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class ResponseViewController {
	// 정적 리소스
	// 스프링 부트는 클래스패스의 다음 디렉토리에 있는 정적 리소스를 제공한다.
	// src/main/resources/static , src/main/resources/public , src/main/resources , /META-INF/resources
	// src/main/resources는 리소스를 보관하는 곳이고, 또 클래스패스의 시작 경로
	// 다음 경로에 파일이 들어있으면 src/main/resources/static/basic/hello-form.html
	// 웹 브라우저에서 다음과 같이 실행하면 된다. http://localhost:8080/basic/hello-form.html
	// view 템플릿 경로 src/main/resources/templates
	@RequestMapping("/response-view-v1")
	public ModelAndView responseViewV1() {
		ModelAndView mav = new ModelAndView("response/hello").addObject("data","hello!");
		return mav;
	}

	@RequestMapping("/response-view-v2")
	public String responseViewV2(Model model) {
		model.addAttribute("data","hello!");
		// @Controller와 @RequestMapping가 있으면 view의 논리 이름으로 viewResolver가 가져가서 render해줌
		return "response/hello";
	}
	
	// controller경로의 이름과 view의 논리적 이름이 같으면 return값이 없어도 viewResolver가 가져가서 render해줌
	@RequestMapping("/response/hello")
	public void responseViewV3(Model model) {
		model.addAttribute("data","hello!");
	}
}

뷰 템플릿 호출 예제의 반환 타입이 다 다름 (ModelAndView, String, void)
 ModelAndView를 반환하는 경우(responseViewV1)
 - 객체에서 View를 꺼내어 물리적인 뷰 이름으로 완성한 뒤 뷰를 찾아 렌더링을 한다. 


 String을 반환하는 경우(responseViewV2)
 - @ResponseBody(혹은 클래스레벨에서 @RestController)가 없으면 response/hello라는 문자가  뷰 리졸버로 전달되어 실행되서 뷰를 찾고 렌더링함.
 - @ResponseBody(혹은 클래스레벨에서 @RestController)가 있으면 뷰 리졸버를 실행하지 않고 HTTP 메세지 바디에 직접 response/hello 라는 문자가 입력됨
 - 위 코드에서는 /response/hello를 반환하는데 뷰 리졸버는 물리적 이름을 찾아서 렌더링을 실행
    실행 결과 : templates/response/hello.html


void를 반환하는 경우(responseViewV3)
 - @Controller를 사용하고 HttpServletResponse, OutputStream(Writer)같은 HTTP 메세지 바디를 처리하는 파라미터가 없으면 요청 URL을 참고해서 논리 뷰 이름으로 사용
※ 요청 URL: /response/hello

    뷰 경로 : templates/response/hello.html  
 - 이 방식은 명시성이 너무 떨어지고 이런 케이스가 나오는 경우도 거의 없어 권장하지 않음

 

 Thymeleaf 스프링 부트 설정

 - build.gradle 파일에 하기 라이브러리를 추가하면 스프링 부트가 자동으로 ThymeleafViewResolver 와 필요한 스프링 빈들을 등록

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 - 뷰 리졸버 설정 방법.

application.properties
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

※ 이 설정은 기본 값 이기 때문에 변경이 필요할 때만 설정하면 됨

 

 

1.3 HTTP 메세지 사용(HTTP API, 메세지 바디에 직접 입력)

 예제 코드

package hello.springmvc.basic.response;

import java.io.IOException;
import javax.servlet.http.HttpServletResponse;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import hello.springmvc.basic.HelloData;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Controller
// @RestController 
// @RestController = @Controller + @ResponseBody
// 뷰 템플릿을 사용하는 것이 아니라, Rest API(HTTP API)를 만들 때 사용할때 사용하는 컨트롤러에 붙여주는 어노테이션
public class ResponseBodyController {
	@GetMapping("/response-body-string-v1")
	public void responseBodyV1(HttpServletResponse response) throws IOException{
		response.getWriter().write("ok");
	}

	@GetMapping("/response-body-string-v2")
	public ResponseEntity<String> responseBodyV2(){
		return new ResponseEntity<>("ok", HttpStatus.OK);
	}
	
	@ResponseBody
	@GetMapping("/response-body-string-v3")
	public String responseBodyV3(){
		return "ok";
	}

	@GetMapping("/response-body-json-v1")
	public ResponseEntity<HelloData> responseBodyJsonV1(){
		HelloData helloData = new HelloData();
		helloData.setUsername("userA");
		helloData.setAge(20);
		return new ResponseEntity<>(helloData, HttpStatus.OK);
	}
	
	// 프로그램 조건에 따라서 동적으로 http 상태코드를 변경하려면 ResponseEntity를 사용
	@ResponseStatus(HttpStatus.OK)
	@ResponseBody
	@GetMapping("/response-body-json-v2")
	public HelloData responseBodyJsonV2(){
		HelloData helloData = new HelloData();
		helloData.setUsername("userA");
		helloData.setAge(20);
		return helloData;
	}
}

 responseBodyV1
 - 서블릿을 직접 다룰 때와 같이 HttpServletResponse 객체를 통해 HTTP 메세지 바디에 직접 OK 응답 메세지를 전달
 : response.getWriter().write("ok")


 responseBodyV2
 - ResponseEntity 엔티티는 HttpEntity를 상속받았는데, HttpEntity는 HTTP메세지의 헤더, 바디 정보를 가지고 있다면 ResponseEntity는 HTTP 응답코드가 추가되었다고 생각하면 됨
 : return new ResponseEntity<>(helloData, HttpStatus.OK);


 responseBodyV3
 - @ResponseBody 애노테이션을 사용하면 view 를 사용하지 않고 HTTP 메세지 컨버터를 통해 HTTP 메세지를 직접 입력할 수 있음 ResponseEntity도 동일한 방식으로 동작


 responseBodyJsonV1
 - ResponseEntity를 반환. HTTP 메세지 컨버터를 통해서 객체는 JSON으로 변환되어 반환됨


 responseBodyJsonV2
 - ResponseEntity는 HTTP 응답 코드를 설정할 수 있는데 @ResponseBody를 사용하면 설정하기가 까다로움. 그래서 이런 경우에는 @ResponseStatus 애노테이션을 이용하여 상태코드를 설정할 수 있음
 - 정적으로 상태코드를 작성한 것이기에 유연하지는 못함 그렇기에 동적으로 상태코드가 변경되야하는 상황이라면 ResponseEntity를 사용

 

 @RestController

 - @Controller 대신에 @RestController 애노테이션을 사용하면, 해당 컨트롤러에 모두 @ResponseBody가 적용되는 효과가 있음

- 뷰 템플릿을 사용하는 것이 아니라, HTTP 메시지 바디에 직접 데이터를 입력

- Rest API(HTTP API)를 만들 때 사용하는 컨트롤러

 - @ResponseBody는 클래스 레벨에 두면 전체 메서드에 적용되는데, @RestController 에노테이션 안에 @ResponseBody + @Controller가 적용되어 있음

 

 

HTTP 메시지 컨버터

1. HTTP 메세지 컨버터란

지금까지 여러 애노테이션을 이용해서 JSON, queryString등을 @RequestBody, @ModelAttribute 등으로 편하게 객체로 변환해서 사용했다. 그런데 이쯤에서 어떻게 스프링이 객체로 변환을 해주는지 의문을 가질 필요가 있다.

 

1.1 @ResponseBody의 사용원리

  @ResponseBody를 사용하니 HTTP의 BODY에 문자 내용을 직접 반환하는데 그림을 보면 viewResolver 대신HttpMessageConverter가 동작한다. 
 - 기본 문자처리: StringHttpMessageConverter
 - 기본 객체처리: MappingJackson2HttpMessageConverter
 - byte 처리등등 기타 여러 HttpMessageConverter가 기본으로 등록되어 있음


답 시 클라이언트의 HTTP Accept 헤더와 서버의 컨트롤러 반환 타입 정보 둘을 조합해 HttpMessageConverter가 선택됨.
  스프링 MVC는 다음의 경우에 HTTP 메세지 컨버터를 적용
 - HTTP 요청: @RequestBody, HttpEntity(RequestEntity)
 - HTTP 응답: @ResponseBody, HttpEntity(ResponseEntity)

 

 

2. HTTP 메세지 컨버터인터페이스

  org.springframework.http.converter.HttpMessageConverter 살펴보기

package org.springframework.http.converter;

public interface HttpMessageConverter<T> {
	// 메세지 컨버터가 해당 클래스, 미디어 타입을 지원하는지 체크하는 메서드
	boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
	boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
    
	List<MediaType> getSupportedMediaTypes();
    
	// 메세지 컨버터를 통해 메세지를 실제로 변환하는 메서드
	T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
	throws IOException, HttpMessageNotReadableException;
    
	void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage) 
	throws IOException, HttpMessageNotWritableException;
}

  HTTP 메세지 컨버터는 요청 및 응답 둘 다 사용됨.
 - 요청시 JSON → 객체
 - 응답시 객체 → JSON


  canRead(), canWrite()
- 메세지 컨버터가 해당 클래스, 미디어 타입을 지원하는지 체크하는 메서드

 T read(), void write()
- 메세지 컨버터를 통해 메세지를 실제로 변환하는 메서드

 

 

3. 스프링 부트 기본 메세지 컨버터

3.1 컨버터의 종류

0 = ByteArrayHttpMessageConverter
1 = StringHttpMessageConverter
2 = MappingJackson2HttpMessageConverter
... 일부 생략 ...

 

3.2 컨버터 사용여부 및 우선순위

컨버터를 사용할 대상의 클래스 타입과 미디어 타입을 체크한 뒤 사용여부를 결정

 등록된 메세지 컨버터들이 순회하며 만족한다면 해당 컨버터를 사용하고 조건을 만족하지 않으면 다음 컨버터로 우선순위가 넘어감.

 

3.3 주요 컨버터 정리

 ByteArrayHttpMessageConverter : byte[] 데이터를 처리
클래스 타입: byte[], 미디어타입: */*
요청 예) @RequestBody byte[] data
응답 예) @ResponseBody return byte[]  미디어 타입 application/octet-stream


 StringHttpMessageConverter : String 문자로 데이터를 처리
클래스 타입: String, 미디어 타입: */*
요청 예) @RequestBody String data
응답 예) @ResponseBody return "ok" 미디어 타입 text/plain


 MappingJackson2HttpMessageConverte r: application/json
클래스 타입: 객체 또는 HashMap, 미디어 타입: application/json 관련
요청 예) @RequestBody HelloData data
응답 예) @ResponseBody return helloData 미디어 타입 application/json 관련

 

 

4. HTTP 요청 데이터 읽기 / 응답 데이터 생성

4.1 HTTP 요청 데이터 읽기

 HTTP 요청이 오면 컨트롤러에서 @RequestBody , HttpEntity 파라미터를 사용함.

 메시지 컨버터가 메시지를 읽을 수 있는지 확인하기 위해 canRead()를 호출함.

  - @RequestBody의 대상 클래스 타입(byte[] , String , HelloData)을 지원하는지 여부 체크

  - HTTP 요청의 Content-Type 미디어 타입(text/plain , application/json , */*)을 지원하는지 여부 체크

 canRead() 조건을 만족하면 read() 를 호출해서 객체 생성하고, 반환

 

4.2 HTTP 응답 데이터 생성

 컨트롤러에서 @ResponseBody , HttpEntity 로 값이 반환됨.

 메시지 컨버터가 메시지를 쓸 수 있는지 확인하기 위해 canWrite() 를 호출함.

  - return의 대상 클래스 타입( byte[] , String , HelloData )을 지원하는지 여부 체크

  - HTTP 요청의 Accept 미디어 타입(text/plain , application/json , */*)을 지원하는지 여부 체크

    (더 정확히는 @RequestMapping 의 produces)

 canWrite() 조건을 만족하면 write() 를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성

 

 

요청 매핑 핸들러 어댑터(RequestMappingHandlerAdapter)의 구조

1. RequestMappingHandlerAdapter

1.1 매핑 핸들러가 동작하는 곳

※ 애노테이션 기반의 컨트롤러인 @RequestMapping을 처리하는 핸들러 어댑터 RequestMappingHandlerAdapter에서 매핑 핸들러가 동작

 

1.2 RequestMappingHandlerAdapter의 동작 방식

 애노테이션 기반의 컨트롤러(@RequestMapping)는 매우 다양한 파라미터를 사용할 수 있었음

HttpServletRequest나 Model부터 @RequestParam, @ModelAttribute 같은 애노테이션 그리고 @RequestBody , HttpEntity 같은 HTTP 메시지를 처리하는 부분까지 매우 큰 유연함을 보여주었음

이렇게 많은 요청 파라미터를 유연하게 처리할 수 있는 이유가 바로 ArgumentResolver 덕분

애노테이션 기반 컨트롤러를 처리하는 RequestMappingHandlerAdapter는 이 ArgumentResolver를 호출해서 컨트롤러(핸들러)가 필요로 하는 다양한 파라미터의 값(객체)을 생성함.

 그리고 이렇게 파리미터의 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨줌

 스프링은 30개가 넘는 ArgumentResolver를 기본으로 제공함

 

ArgumentResolver에서 지원하는 파라미터 목록메뉴얼

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-annarguments

 

 

2. ArgumentResolver 인터페이스

※ 정확히는 HandlerMethodArgumentResolver인데 줄여서 ArgumentResolver라 부름

public interface HandlerMethodArgumentResolver {
	boolean supportsParameter(MethodParameter parameter);

	@Nullable
	Object resolveArgument(MethodParameter parameter, 
				@Nullable ModelAndViewContainer mavContainer,
				NativeWebRequest webRequest, 
				@Nullable WebDataBinderFactory binderFactory) 
		throws Exception;
}

2.1 동작 방식
 ArgumentResolver의 supportsParameter() 메서드를 호출해 해당 파라미터를 지원하는지 체크
 - (지원할 경우) resolveArgument() 메서드를 호출해서 실제 객체를 생성
 - (지원안할경우) 다음 ArgumentResolver로 우선순위가 넘어감.

 

 

3. ReturnValueHandler

※ 정확히는 HandlerMethodReturnValueHandler인데 줄여서ReturnValueHandler라 부름

 이 또한 ArgumentResolver와 비슷, 요청이 아닌 응답 값을 변환하고 처리
 컨트롤러에서 String으로 뷰 이름을 반환해도, 동작하는 이유가 이 ReturnValueHandler 덕분
 스프링은 10개가 넘는 ReturnValueHandler를 지원
    ex) ModelAndView, @ResponseBody, HttpEntity, String

 

ReturnValueHandler에서 지원하는 응답값 목록메뉴얼
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-return-types

 

 

4. HTTP 메세지 컨버터

4.1 HTTP 메시지 컨버터 위치

 위 그림처럼 요청의 경우 컨트롤러가 파라미터로 @RequestBody, HttpEntity를 사용할때 HTTP 메세지 컨버터를 사용 

- @RequestBody, HttpEntity를 처리하는 각각의 ArgumentResolver가 있는데,

   이 ArgumentResolver들이 HTTP 메세지 컨버터를 사용해서 필요한 객체를 생성


 응답의 경우에도  컨트롤러의 반환값으로 @ResponseBody, HttpEntity가 사용되는 시점에서 HTTP 메세지 컨버터를 사용
- @ResponseBody와 HttpEntity를 처리하는 각각의 ReturnValueHandler가 있는데,

  이 ReturnValueHandler들이 HTTP 메세지 컨버터를 호출해서 응답 결과를 얻음.

 

 

4.2 확장
 스프링은 ArgumentReolver나 ReturnValueHandler, MessageConverter를 모두 인터페이스로 제공하기에 다음과 같은 인터페이스를 언제든지 확장해서 사용 가능

- HandlerMethodArgumentResolver
- HandlerMethodReturnValueHandler
- HttpMessageConverter

※ 대부분은 이미 스프링에서 구현되어 제공되기에 실제로 확장할 일이 많지는 않음.

 만약 기능 확장을 할 때는 WebMvcConfigurer를 상속받아 스프링 빈으로 등록하면 됨.

@Bean
public WebMvcConfigurer webMvcConfigurer() {
	return new WebMvcConfigurer() {
		@Override
		public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers){
			//...
		}

		@Override
		public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
			//...
		}
	};
}
반응형
반응형

스프링 MVC 전체 구조

1. 직접 만든 MVC 프레임워크 구조와 스프링 MVC 구조 비교

1.1 직접 만든 MVC 프레임워크 구조

1.2 스프링 MVC 구조

※ 직접 만든 프레임워크와 스프링 MVC는 명칭만 조금 다르고 거의 같은 구조와 기능으로 되어있음

 - FrontController → DispatcherServlet

 - handlerMappingMap  HandlerMapping

 - MyHandlerAdapter  HandlerAdapter

 - ModelView  ModelAndView

 - viewResolver  ViewResolver

 - MyView  View

 

2. DispatcherServlet

2.1 DispatcherServlet 개요

FrontController와 마찬가지로 부모 클래스에서 HttpServlet을 상속받아 사용하며, 서블릿으로 동작.
        ◦ DispatcherServelt → FrameworkServlet → HttpServletBean → HttpServlet

 스프링 부트 구동시 DispatcherServlet을 서블릿으로 자동등록하며 모든 경로(urlPatterns="/")에 대해 매핑.

Spring MVC 역시 프론트 컨트롤러 패턴으로 구현되어 있고 DispatcherServlet이 프론트 컨트롤러의 역할

 

※ DispatcherServlet의 다이어그램

 

2.2 DispatcherServlet 요청 흐름 및 핵심 로직 분석

 DispatcherServlet 요청 흐름

 - 서블릿이 호출되면 HttpServlet이 제공하는 service() 메서드가 호출
 - 스프링 MVC는 DispatcherServlet의 부모인 FrameworkServlet에서 service()를 오버라이드 해둠
 - FrameworkServlet.service()를 시작으로 여러 메서드가 호출되며 DispatcherServlet.doDispatch()가 호출 

 

DispacherServlet.doDispatch() 핵심 로직 분석

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
	HttpServletRequest processedRequest = request;
	HandlerExecutionChain mappedHandler = null;
	ModelAndView mv = null;

	// 1. 핸들러 조회 (요청에 맞는 적절한 핸들러를 찾아 반환)
	mappedHandler = getHandler(processedRequest);
	if (mappedHandler == null) {
		// 적절한 핸들러를 찾지 못한경우 404 에러코드를 반환
		noHandlerFound(processedRequest, response);
		return;
	}

	// 2.핸들러 어댑터 조회-핸들러를 처리할 수 있는 어댑터 (찾은 핸들러를 처리할 수 있는 핸들러 어댑터를 찾아줌, 만약 찾지 못할경우 ServletException 발생)
	HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
	
	// 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행  -> 5. ModelAndView 반환
	mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

	processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
}

// 실제 코드는 복잡하게 되있는데 결국 render() 메서드를 호출
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, 
					HandlerExecutionChain mappedHandler, ModelAndView mv, 
					Exception exception) throws Exception {
	// 뷰 렌더링 호출
	render(mv, request, response);
}

protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
	View view;
	// 6. 뷰 리졸버를 통해서 뷰 찾기
	// 7. View 반환 
	String viewName = mv.getViewName(); 
	view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
	
	// 8. 뷰 렌더링
	// ModelAndView에서 View를 찾아 뷰 리졸버를 이용해 뷰의 물리적 이름을 완성해서 forward 해줌
	view.render(mv.getModelInternal(), request, response);
}

 

3. 스프링 MVC 동작 순서

① 핸들러 조회 : 핸들러 매핑을 통해 URL에 매핑된 핸들러(컨트롤러) 조회
② 핸들러 어댑터 조회: 핸들러를 실행할 수 있는 핸들러 어댑터 조회
③ 핸들러 어댑터 실행: 핸들러 어댑터 실행
④ 핸들러 실행: 핸들러 어댑터가 실제 핸들러를 실행
⑤ ModelAndView 반환: 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환해 반환.
⑥ viewResolver 호출: 뷰 리졸버를 찾아 실행
 (JSP: InternalResourceViewResolver가 자등 등록되어 사용됨)
⑦ View 반환: 뷰 리졸버는 뷰의 논리 이름을 물이 이름으로 바꾸고 렌더링 역할을 담당하는 뷰 객체 반환.
(JSP: InternalResourceView(JstlView)를 반환하는데, 내부에는 forward() 가 있음)
⑧ 뷰 렌더링: 뷰를 통해서 뷰를 렌더링

 

4. 스프링 MVC 인터페이스
 SpringMVC는 DispatcherServlet 코드 변경을 하지않고도 원하는 기능을 변경하거나 확장을 할 수 있음. 

그리고 이런 인터페이스들을 구현해 DispatcherServlet에 등록하면 커스텀 컨트롤러를 만들 수도 있음
 주요 인터페이스 목록
 - 핸들러 매핑: org.springframework.web.servlet.HandlerMapping
 - 핸들러 어댑터: org.springframework.web.servlet.HandlerAdapter
 - 뷰 리졸버: org.springframework.web.servlet.ViewResolver
 - 뷰: org.springframework.web.servlet.View

 

 

핸들러 매핑과 핸들러 어댑터

※ 직접 만든 MVC 프레임워크에서는 핸들러 매핑과 핸들러 어댑터를 단순하게 Map, List 콜렉션을 이용해서 등록한 뒤 검색해서 사용함.
 springMVC에서는 어떻게 핸들러 매핑과 핸들러 어댑터를 사용하는지 확인 해보기.

 

1. 과거 버전의 스프링 컨트롤러(Controller인터페이스를 구현)

1.1 예제 1

package hello.servlet.web.springmvc.old;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;


// @Controller 애노테이션과는 다른 Controller 인터페이스임
// /springmvc/old-controller 라는 이름의 스프링 빈으로 등록되었고, 스프링 빈의 이름으로 URL 매핑함.  
// 스프링에서 name을 urlpatterns랑 맞춤
@Component("/springmvc/old-controller")
public class OldController implements Controller{
	@Override
	public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
		System.out.println("OldController.handleRequest");
		return null;
	}
}

 

1.2 스프링 MVC가 URL을 통해 컨트롤러를 찾는 방법

이 컨트롤러가 호출되기 위해 HandlerMapping과 HandlerAdapter가 필요
        ◦ HandlerMapping
          - 핸들러 매핑에서 이 컨트롤러를 찾을 수 있어야 함.
          - 스프링 빈의 이름으로 핸드러를 찾을 수 있는 핸들러 매핑이 필요.

          - 직접 만든 MVC 프레임워크의 관련 소스

	private final Map<String, Object> handlerMappingMap = new HashMap<>();
    
	private void initHandlerMappingMap() {
		handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
		handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
		handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
		

		handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
		handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
		handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
	}

        ◦ HandlerAdapter
          - 핸들러 매핑을 통해 찾은 핸들러를 실행할 수있는 핸들러 어댑터가 필요  
          - Controller 인터페이스를 실행할 수 있는 핸들러 어댑터를 찾아야 함.

          - 직접 만든 MVC 프레임워크의 관련 소스

	private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

	// 사용할 어댑터를 리스트 콜렉션에 추가하는 작업을 하는 메서드
	private void initHandlerAdapters() {
		handlerAdapters.add(new ControllerV3HandlerAdapter());
		handlerAdapters.add(new ControllerV4HandlerAdapter());
	}

※ 스프링 부트에서는 직접 만든 MVC프레임워크처럼 Map이나 List 같은 객체로 저장하는것이 아니라

인터페이스로 구현된 핸들러 매핑과 핸들러 어댑터 객체를 자동으로 등록해줌

 

 스프링 부트가 자동 등록하는 핸들러 매핑 구현체와 핸들러 어댑터 구현체

 - HandlerMapping을 찾는 우선순위

0 = RequestMappingHandlerMapping : 애노테이션 기반의 컨트롤러인 @Requestmapping에서 핸들러를 찾음. 
1 = BeanNameUrlHandlerMapping : 스프링 빈의 이름으로 핸들러를 찾음.
...

 - HandlerAdapter을 찾는 우선순위

0 = RequestmappingHandlerAdapter : 애노테이션 기반의 컨트롤러인 @Requestmapping를 사용한 핸들러를 처리
1 = HttpRequestHandlerAdapter : HttpRequesthandler 처리
2 = SimpleControllerHandlerAdapter : Controller 인터페이스(애노테이션 X) 처리
...

• 예제1의 OldController의 핸들러 매핑과 핸들러 어댑터 찾고 실행하는 순서

 ※ 핸들러 매핑, 핸들러 어댑터 모두 순서대로 찾고 만약 없을 경우 다음 순서로 넘어간다. 
 - 핸들러 매핑으로 핸들러 조회
: HandlerMapping에서 순서대로 수행하는데 1 = BeanNameUrlHandlerMapping에서 검색이 되기에 해당 URL을 빈 이름으로 등록한 핸들러(OldController)가 매핑되어 반환됨.


  - 핸들러 어댑터 조회
: 어댑터를 순서대로 호출하며 이전에 찾은 핸들러(OldController)가 실행가능한 어댑터를 찾음.  
이 중에서 SimpleControllerHandlerAdapter가 Controller 인터페이스를 실행 가능하기에 반환.


 - 핸들러 어댑터 실행
: 반환된 어댑터(SimpleControllerHandlerAdapter)는 핸들러인 OldController를 내부에서 실행하면서 핸들러 정보를 넘겨주고 그 결과를 반환. 

 

 

2. 과거 버전의 스프링 컨트롤러(HttpRequestHandler인터페이스를 구현)

2.1 예제 2

package hello.servlet.web.springmvc.old;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestHandler;

@Component("/springmvc/request-handler")
public class MyHttpRequestHandler implements HttpRequestHandler{
	@Override
	public void handleRequest(HttpServletRequest request, HttpServletResponse response)throws ServletException, IOException {
		System.out.println("MyHttpRequestHandler.handleRequest");
	}
}

2.2 스프링 MVC가 예제2의 HttpRequestHandler 핸들러 매핑과 핸들러 어댑터 찾고 실행하는 순서
 - 핸들러 매핑으로 핸들러 조회
: HandlerMapping에서 순서대로 수행하는데 1 = BeanNameUrlHandlerMapping에서 검색이 되기에 해당 URL을 빈 이름으로 등록한 핸들러(MyHttpRequestHandler)가 매핑되어 반환됨.


  - 핸들러 어댑터 조회
: 어댑터를 순서대로 호출하며 이전에 찾은 핸들러(MyHttpRequestHandler)가 실행가능한 어댑터를 찾음.  
이 중에서 HttpRequestHandlerAdapter가 HttpRequestHandler 인터페이스를 실행 가능하기에 반환.


 - 핸들러 어댑터 실행
: 반환된 어댑터(HttpRequestHandlerAdapter)는 핸들러인 MyHttpRequestHandler를 내부에서 실행하면서 핸들러 정보를 넘겨주고 그 결과를 반환. 

 

※ @RequestMapping

 - 가장 우선순위가 높은 핸들러 매핑과 핸들러 어댑터는 RequestMappingHandlerMapping, RequestMappingHandlerAdapter임.

 - @RequestMapping 의 앞글자를 따서 만든 이름인데, 이것이 바로 지금 스프링에서 주로 사용하는 애노테이션 기반의 컨트롤러를 지원하는 매핑과 어댑터이다.

 - 실무에서는 99.9% 이 방식의 컨트롤러를 사용.

 - 추후에 다룰 예정

 

뷰리졸버

1. 뷰리졸버 적용 예시(OldController를 통해)

 소스 코드

 - OldController

@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
    System.out.println("OldController.handleRequest");
    return new ModelAndView("new-form");
}

※ 컨트롤러는 정상 실행 되지만 view 페이지 인 new-form이 호출이 안되고 404에러 발생

 - 원하는 viewPath "/WEB-INF/views/new-form.jsp"를 물리이름으로 완성하지 못했기 때문에 페이지를 못찾음

 - 어떤 경로인지 상위 경로 prefix와 이게 html인지 jsp인지 확장자를 저장한 suffix를 등록 해줘야함
 - application.properties라는 속성 파일에 다음 코드를 추가

spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp

 

2. 뷰 리졸버 동작 방식

※ 스프링 부트는 InternalResourceViewResolver 라는 뷰 리졸버를 자동으로 등록하는데,

이때 application.properties 에 등록한 spring.mvc.view.prefix , spring.mvc.view.suffix 설정 정보를 사용해서 등록함.

2.1 구성도

※ 위 springMVC 구조에서 6번, 7번 과정에서 뷰 리졸버가 동작.
※ 스프링 부트는 구동시 자동 등록하는 뷰 리졸버가 많은데, 이중 몇가지를 말하면 다음과 같음.

1 = BeanNameViewResolver : 빈 이름으로 뷰를 찾아서 반환한다. (예: 엑셀 파일 생성 기능에 사용)
2 = InternalResourceViewResolver : JSP를 처리할 수 있는 뷰를 반환한다.

 

2.2 예제 OldController의 뷰리졸버 동작 순서
 핸들러 어댑터 호출
 - 핸들러 어댑터를 통해 new-form 이라는 논리 뷰 이름을 획득
 ViewResolver 호출
 - new-form 이라는 뷰 이름으로 viewResolver를 순서대로 호출.
 - BeanNameViewResolver 는 new-form 이라는 이름의 스프링 빈으로 등록된 뷰를 찾아야 하는데 없음.
 - 스프링부트에 자동으로 등록된 InternalResourceViewResolver가 호출.
 InternalResourceViewResolver
 - 이 뷰 리졸버는 InternalResourceView 를 반환
뷰 - InternalResourceView
 - InternalResourceView 는 JSP처럼 포워드 forward() 를 호출해서 처리할 수 있는 경우에 사용
 view.render()
 - view.render() 가 호출되고 InternalResourceView 는 forward() 를 사용해서 JSP를 실행


※  InternalResourceViewResolver는 만약 JSTL 라이브러리가 있으면 InternalResourceView를
상속받은 JstlView를 반환.

JstlView 는 JSTL 태그 사용시 약간의 부가 기능이 추가.


※  다른 뷰는 실제 뷰를 렌더링하지만, JSP의 경우 forward() 통해서 해당 JSP로 이동(실행)해야 렌더링이
됨.

JSP를 제외한 나머지 뷰 템플릿들은 forward() 과정 없이 바로 렌더링.


※  Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver 를 등록해야 함.

최근에는 라이브러리만 추가하면 스프링 부트가 이런 작업도 모두 자동화해줌

 

 

스프링 MVC 적용하기(version 1)

1. @RequestMapping

 기존에 @WebServlet에서 urlPattern을 사용해주고, Component에 빈 이름으로 URL을 작성해서 사용해지만,

스프링 MVC가 @RequestMapping 애노테이션을 사용해서 유연하고 편리하게 컨트롤러 구현이 가능해짐.

 

@RequestMapping이 사용하는 HandlerMapping과 HandlerAdapter

        ◦ RequestMappingHandlerMapping

        ◦ RequestMappingHandlerAdapter

※ 가장 우선순위가 높은 핸들러 매핑과 핸들러 어댑터

 

2. 코드

2.1 SpringMemberFormControllerV1 - 회원 등록 컨트롤러

package hello.servlet.web.springmvc.v1;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

/* @Controller와 같음
// 스프링 빈으로 등록
@Component
// 핸들러로 인식
@RequestMapping
*/

// 스프링이 자동으로 스프링 빈으로 등록한다. (내부에 @Component 애노테이션이 있어서 컴포넌트 스캔의 대상이 됨)
// 스프링 MVC에서 애노테이션 기반 컨트롤러(핸들러)로 인식
// RequestMapnpingHandlerMapping에서 매핑시켜줄수 있는(사용할 수 있는) 핸들러로 인식된다는 의미.
@Controller
public class SpringMemberFormControllerV1 {
	// 해당 URL이 호출되면 이 메서드가 호출. 애노테이션을 기반으로 동작하기 때문에, 메서드의 이름은 임의로 지으면 됨
	@RequestMapping("/springmvc/v1/members/new-form")
    // 모델과 뷰 정보를 담아 반환하면 됨
	public ModelAndView process() {
		return new ModelAndView("new-form");
	}
}

2.2 SpringMemberSaveControllerV1 - 회원 저장 컨트롤러

package hello.servlet.web.springmvc.v1;

import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;

@Controller
public class SpringMemberSaveControllerV1 {
	private MemberRepository memberRepository = MemberRepository.getInstance();
	
	@RequestMapping("/springmvc/v1/members/save")
	public ModelAndView process(HttpServletRequest request, HttpServletResponse response) {
		String username = request.getParameter("username");
		int age = Integer.parseInt(request.getParameter("age"));
		
		Member member = new Member(username, age);
		memberRepository.save(member);
		
		ModelAndView mv = new ModelAndView("save-result");
		// 아래와 같은 소스
		//mv.getModel().put("member", member);
		// 스프링이 제공하는 ModelAndView를 통해 Model 데이터를 추가할 때는 addObject()를 사용하면 됨
		mv.addObject("member", member);
		
		return mv;
	}	
}

2.3 SpringMemberListControllerV1- 회원 목록 컨트롤러

package hello.servlet.web.springmvc.v1;

import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;

@Controller
public class SpringMemberListControllerV1 {
	private MemberRepository memberRepository = MemberRepository.getInstance();
	
	@RequestMapping("/springmvc/v1/members")
	public ModelAndView process(HttpServletRequest request, HttpServletResponse response) {
		List<Member> members = memberRepository.findAll();
		ModelAndView mv = new ModelAndView("members");
		mv.addObject("members", members);
		return mv;
	}
}

※ @Controller 애노테이션이 없어도 직접 설정영역에서 빈으로 등록해줘도 되고, 
클래스영역에 @RequestMapping과 @Component 애노테이션을 사용하면 정상적으로 등록되어 사용할 수 있음.

대체적으로 그냥 @Controller를 사용함.

 

 

스프링 MVC 컨트롤러 통합(version 2)

애노테이션 기반 컨트롤러 구축시 URL 매핑이 클래스 단위가 아니라 메서드 단위로 되는 것을 확인할 수 있었는데,

이러한 메서드들을 하나의 컨트롤러로 통합할수도 있음.

1. 코드

package hello.servlet.web.springmvc.v2;

import java.util.List;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;

@Controller
// 기존 V1의 매핑 URL을 보면 /springmvc/v1/members 까지는 중복되는데 이런 중복되는 경로를 클래스단위의 @RequestMapping에 작성해서 경로 조합이 가능함.
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
	private MemberRepository memberRepository = MemberRepository.getInstance();
	
	@RequestMapping("/new-form")
	public ModelAndView newForm() {
		return new ModelAndView("new-form");
	}
	
	@RequestMapping("/save")
	public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
		String username = request.getParameter("username");
		int age = Integer.parseInt(request.getParameter("age"));
		
		Member member = new Member(username, age);
		memberRepository.save(member);
		
		ModelAndView mv = new ModelAndView("save-result");
		mv.addObject("member", member);
		
		return mv;
	}

	@RequestMapping
	public ModelAndView members(HttpServletRequest request, HttpServletResponse response) {
		List<Member> members = memberRepository.findAll();
		ModelAndView mv = new ModelAndView("members");
		mv.addObject("members", members);
		return mv;
	}
}

 

 

스프링 MVC 실용적인 방식(version 3)

1. 코드

package hello.servlet.web.springmvc.v3;

import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;

/**
 * v3
 * Model 도입
 * ViewName 직접 반환
 * @RequestParam 사용
 * @RequestMapping -> @GetMapping, @PostMapping
 */
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
	private MemberRepository memberRepository = MemberRepository.getInstance();
	
	//@RequestMapping(value = "/new-form", method = RequestMethod.GET)
	@GetMapping("/new-form")
	public String newForm() {
		return "new-form";
	}
	
	//@RequestMapping(value = "/save", method = RequestMethod.POST)
	@PostMapping("/save")
	public String save(
			@RequestParam("username") String username, 
			@RequestParam("age") int age,
			Model model) {
		
		Member member = new Member(username, age);
		memberRepository.save(member);
		
		model.addAttribute("member", member);
		
		return "save-result";
	}

	//@RequestMapping(method = RequestMethod.GET)
	@GetMapping("")
	public String members(Model model) {
		List<Member> members = memberRepository.findAll();
		model.addAttribute("members", members);
		return "members";
	}
}

 Model model
 - save(), members()에서 Model을 파라미터로 받음.
 - 추가할 데이터는 이 Model에 추가해주면 되며, 기존처럼 ModelAndView 객체를 만든 뒤 여기에 데이터를 넣어줄 필요가 없어짐.


 viewName 직접 반환
 - 애노테이션 기반의 컨트롤러는 ModelAndView 뿐 아니라 ViewName을 직접 반환해도 동작함.


 @RequestParam 사용
 - 스프링은 HTTP 요청 파라미터를 @RequestParam으로 받을 수 있음.

 - 코드에서 사용 된 @RequestParam("username")은 request.getParameter("username")과 거의 같음.
 - 그래서 GET, POST Form 방식의 데이터 가져오는것을 모두 지원한다. 


@RequestMapping → @GetMapping, @PostMapping

 - @RequestMapping 애노테이션은 URL만 매칭하는게 아니라 HTTP Method도 구분할 수 있음.

 - 예를들어 회원 등록 폼 요청은 GET 으로 올 때만 매핑시키려면 method 속성에 GET을 작성하면 됨.

@RequestMapping(value = "/new-form", method = RequestMethod.GET)

 - @GetMapping, @PostMapping는 이런 method를 미리 지정해둔 애노테이션으로 가독성과 편의성이 좋아 쓰기 편함.

※ Put, Delete, Patch 모두 존재


  참고
 @GetMapping, @PostMapping  내부 코드를 보면

결국 @RequestMapping 애노테이션의 메소드를 미리 지정해둔 애노테이션임

이런 애노테이션의 유연함이 스프링 사용을 편리하게 해줌.

 - @GetMapping 에노테이션 소스

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@RequestMapping(method = RequestMethod.GET)
public @interface GetMapping {
	...
}
반응형

+ Recent posts