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

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

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

        return ResponseEntity.ok(response);
    }
}

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

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

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

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

 

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

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

 

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

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

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

 

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

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

 

스프링 부트 오류 페이지

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

API 예외 처리

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

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

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

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

 

HandlerExceptionResolver

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

스프링이 제공하는 exceptionResolver

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

@ExceptionHandler

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

@ControllerAdvice

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

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

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

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

 

 

 

인증과 인가?

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

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

 

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

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

 

필터와 인터셉터

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

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

 

필터

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

인터셉터

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

 

호출 순서

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

 

인가 구현 - 필터

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

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

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

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

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

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

 

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

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

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

 

인가 구현 - 인터셉터

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

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

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

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

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

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

 

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

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

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

 

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

 

필터 vs 스프링 인터셉터

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

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

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

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

 

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

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

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

 

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

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

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

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

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

 

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

 

세션 방식이란?

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

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

 

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

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

 

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

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

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

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

1. Session 직접 구현하기

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

 

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

 

세션에서 주의할 점은

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

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

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

 

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

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

 

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

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

 

2. HttpSession 사용

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

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

기본 쿠키 이름은 JSESSIONID이다.

 

HttpServletRequest.getSession(create=true)

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

HttpSession.setSessionAttribute(name, value)

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

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

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

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

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

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

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

 

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

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

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

 

Bean Validation 사용 방법

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

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

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

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

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

 

Bean Validation의 작동 방식

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

 

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

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

 

@Valid와 @Validated의 차이점?

@Valid

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

@Validated

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

 

Bean Validation - Http Message Converter

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

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

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

 

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

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

 

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

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

 

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

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

 

 

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

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

 

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

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

 

Validation(유효성 검사) 이란?

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

 

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

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

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

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

 

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

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

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

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

 

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

 

BindingResult란?

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

 

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

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

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

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

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

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

 

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

 

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

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

로 직접 만들 수 있다.

 

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

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

 

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

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

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

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

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

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

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

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

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

 

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

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

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

 

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

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

 

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

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

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

 

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

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

 

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

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

 

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

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

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

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

 

 

 

 

 

 

 

 

 

 

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

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

 

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

 

스프링 메세지 소스 설정

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

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

웹에 메세지 기능 사용하기

//messages.properties

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

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

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

 

//messages_en.properties

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

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

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

 

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

타임리프 기본 사용법

텍스트 출력

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

Espace 기본 제공

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

기본 객체 제공

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

연산 기능 제공

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

속성 값 설정

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

반복

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

템플릿 조각과 레이아웃

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

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

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

 

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

 

스프링 통합 기능

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

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

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

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

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

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

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

th:field를 사용하면

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

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

 

@ModelAttribute NoArgs Setter 이슈

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

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

 

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

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

순으로 진행하기 때문에

 

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

 

 

 

 

스프링 mvc 강의 1편의 마지막 쯤으로 가니까 타임리프를 활용한 프로젝트 구현이 진행됐다.

사실 기존에 만들던 캘린더 API는 프론트가 있다고 가정하고 만든 api 형태의 백엔드라, 타임리프를 사용할 이유가 없었다.

 

그런데 강의에서 백엔드 개발자더라도 SSR을 위해 타임리프로 화면 그리기 정도는 할 줄 알아야 한다라고 이야기했고,

또 간단한 어드민 페이지 정도는 프론트에 맡기는 거보단 직접 만드는게 편하다고 했기에

타임리프를 써서 간단한 어드민 페이지를 만들어봤다.

 

개발일지 생성 화면

개발 일지 생성 화면이다.

스케줄과 개발일지 작성자를 선택한 후에 내용을 입력하면 된다.

 

(물론 이건 어드민 화면이므로 작성자와 스케줄을 직접 선택했지만,

실제 서비스에선 로그인 한 후에 스케줄에 댓글을 다는 식으로 개발일지를 남기려 했다.)

// Admin 컨트롤러
@Controller
@RequiredArgsConstructor
public class AdminDevLogController{
    
    private final ScheduleService scheduleService;
    private final UserService userService;
    private final DevLogService devLogService;
    
    // 개발일지 생성 화면
    @GetMapping("/admin/devLogs/create-devLogs")
    public String createDevLogForm(Model model){
        
        Pageable pageable = PageRequest.of(0,20);
        List<Schedule> scheduleList = scheduleService.findSchedules(pageable).getContent();
        List<User> users = userService.findAllBySearch(pageable, null, null).getContent();
        
        List<String> userList = users.stream()
            .map(u -> u.getAccountId())
            .collect(toList());
        
        model.addAttribute("scheduleList", scheduleList);
        model.addAttribute("userList", userList);
        
        return "devLogs/devLogs-create-form";
    }
    
    // 개발일지 생성
    @PostMapping("/admin/devLogs/create-devLogs")
    public String createDevLog(CreateDevLogRequest request){
        
        devLogService.createDevLog(request);
        
        return "redirect:/admin/devLogs/read-devLogs";    
    }
}
<!-- devLogs-create-form.html -->
<!DOCTYPE HTML>
<html xmlns: th=”http://www.thymeleaf.org”>
<head>
    <title>BlazingDevs Calender!</title>
    <link th:href="@{/css/create-form.css}" rel="stylesheet" />
</head>
<body>
    <div class="title">
        <h1>개발일지 생성</h1>
    </div>

    <div class="container">
        <form role="form" action="/admin/devLogs/create-devLogs" method="post">
            <div class="form-group">
                <label>스케줄</label>
                <select name="scheduleId">
                    <option th:each="schedule: ${scheduleList}" th:text="${schedule.name}" th:value="${schedule.id}"></option>
                </select>
            </div>
            <div class="form-group">
                <label>유저</label>
                <select name="userAccountId">
                    <option th:each="user: ${userList}" th:text="${user}"></option>
                </select>
            </div>
            <div class="form-group">
                <label>내용</label>
                <input type="text" name="content" class="form-control" placeholder="내용을 입력하세요"/>
            </div>
            <button type="submit" class="btn">생성</button>
        </form>
    </div>
</body>
</html>

 

mvc 강의 1편 마지막에서 간단히 배운 내용으로 직접 만들어 봤다.

  • 타임리프 사용 선언 : <html xmls: th="http://www.thymeleaf.org">
  • html 속성 변경 : th:바꿀 속성="${내용}", 이때 내용은 컨트롤러에서 model.addAttribute로 넘겨줄 수 있다.
  • th:xxx 가 붙은 부분은 SSR로 서버가 실행될때 변경된다.
  • th:each="xxx : ${xxxList}" 사용시 리스트로 받아온 값을 이용해 반복문을 사용할 수 있다.
  • "${내용}" 과 같은 리터럴은 다른 문자열과 함께 사용할 수 없다. 무조건 따로 쓴 후 + 로 합쳐야한다.
  • |...| 을 사용하면 리터럴을 대체해 사용할 수 있다. (<span th:text = “|Hello world ${username}|”>)

 

PRG 패턴

 POST 방식으로 온 요청에 대해서 GET 방식의 웹페이지로 리다이렉트 시키는 패턴을 말한다.

Post 방식으로 온 요청을 사용한 후 비즈니스 로직(값 DB에 저장)을 진행한 다음 get 방식으로 html을 다시 반환하면

사용자가 뒤로가기,새로고침을 누를 시 방금 전의 post 요청이 계속해서 다시 발생한다.

 

따라서 redirect를 이용해 새로운 페이지로 이동을 시켜준다.

브라우저는 302 응답코드와 url을 받으며, 받은 url로 다시 한 번 get 요청을 보내고 이동시킨다.

이렇게 하면 비즈니스 로직이 중복 발생하지 않는다.

또 브라우저는 3XX 요청을 받은 페이지는 저장하지 않아서 뒤로 가기를 하더라도 요청 페이지가 다시 나오지 않는다.

 

어드민 페이지를 만들면서 느낀 점

 

1. 프론트 화면을 만들면 생각보다 신경써야할 내용들이 많다.

뒤로가기 구현, 메인 화면으로 돌아가기 구현 등의 프론트가 신경써야할 부분 뿐만 아니라

회원가입 시 ID 중복 체크, 유저 추가 시 중복 방지와 같은 백엔드 api에선 단순히 오류만 보내고 끝냈는데

화면을 구현하면 더 신경써야 할 로직들이 많았다.

 

2. Contoller 를 새로 만들면서 느낀 점

왜 service 계층이 dto에 의존하지 않는 것이 좋은지 잘 알겠다.

admin 페이지를 위한 컨트롤러를 만들면서, model로 전송할 내용들이 api와는 몇가지 달라진 점이 있는데,

모든 service, repository, domain, dto 코드들을 단 한 줄도 건드리지 않고 재활용하여 만들 수 있었다.

service 계층이 dto를 반환하고 있었다면, 컨트롤러 로직이 복잡해지거나 바꿀 코드들이 많았을 것 같다,

 

저번에 고민했던 dto의 생존 시점 고민이 도움이 됐던 것 같다.

도메인 생성을 제외한 나머지 로직은 dto가 컨트롤러까지만 살아있도록 구현했다.

사실 도메인 생성시에 필요한 값은 변할일이 많이 없으며, 변하더라도 서비스에 영향을 끼칠거라고 판단했다.

 

실제로 바꾼 컨트롤러에서도 생성 dto를 그냥 그대로 재활용했고,

나머지 경우엔 서비스가 dto가 아닌 엔티티만 반환하므로 컨트롤러에서 화면에 맞게 바꾸면 됐다.

 

로직이 Controller - Service 어디에 들어가는지 애매하다면

  1. 앱, 어드민 화면 등으로 화면 구현 바꿨을 때 다른 UI 코드랑 중복이 생기나?
  2. ui에 종속적인가, 핵심 비즈니스 로직인가?

를 생각해 보는 것이 좋을 것 같다.

화면을 변경해도 동일하게 나오는 로직이라면 서비스에 넣는 것도 나쁘지 않은 선택인 것 같다.

 

ui에 종속적인 화면이라면 Controller로, 핵심 비즈니스 로직이라면 service로 넣는 것이 좋은데

사실 핵심 비즈니스 로직이라는 것도 말이 애매하긴 한 것 같다.

 

모든 설계와 디자인 패턴에는 트레이드 오프가 존재하니까 상황에 맞춰서 잘 사용하는 것이 제일 좋아보인다.

 

3. Service의 의존관계

예재의 DevLogService는 3개의 Repository에 의존한다.

  1. 스케줄 선택을 위해 현재 존재하는 스케줄을 조회하는 ScheduleRepository
  2. 유저 선택을 위해 현재 존재하는 유저들을 조회하는 UserRepository
  3. 개발일지 저장, 조회 등의 로직을 담당하는 DevLogRepository

이렇게 하나의 서비스가 여러개의 리포지토리에 의존하면 서비스의 부담이 커지고,

코드 변경에 어려움이 있지 않을까 라는 생각을 했다.

 

하나의 서비스는 한 개의 리포지토리에만 의존하며 한 번에 하나의 비즈니스 로직만 수행하고,

컨트롤러에서 여러개의 서비스를 사용하는 것은 어떨까 싶었다.

 

  1. Controller - service 단일 상속 & service - repository 다중 상속
    1. 현재 방식, service가 모든 핵심 비즈니스 로직 처리
    2. 트랜잭션 원자성 준수
    3. 서비스가 repository에 너무 많이 의존함
  2. Controller - service 다중 상속 & service -repository 단일 상속
    1. 코드 변경에 좋음
    2. service가 단일 로직만 수행함, 변경에 용이, 버그 덜 생김
    3. controller 부담 커짐, 트랜잭션 원자성 어김

장 단점이 존재하지만 Controller 단에서 여러개의 service 코드를 호출할 경우,

트랜잭션 원자성이 깨진다는 큰 단점이 존재했다.

 

서비스 레이어 안에서 다시 계층을 나누는 Facade 패턴도 고민해봤는데,

현재 크기의 프로젝트에선 굳이 나눌 필요성을 못 느꼈고,

서비스 레이어를 쪼갤 경우 몇몇 서비스 레이어가 너무 가벼워질 것 같았다.

(리포지토리의 메소드를 그냥 불러오는 정도의 역할만 수행할 것 같았다.)

 

그래서 그냥 이대로 놔두기로 했다.

Controller를 새로 만드는데 전혀 이상이 없었으니까.

 

공부할수록 부족한 부분이 많이 보인다. 갈 길이 멀다.

근데 부족한게 잘 보이니까 가야할 길은 확실히 보이는 것 같다.

 

이제 mvc 2편을 들으면서 지금 만들어놨던 admin 페이지에 다른 기능들을 추가해봐야겠다.

 

 

 

 

 

 

 

 

 

+ Recent posts