구현한 기능들에 대한 api 테스트를 마쳐서 MVC controller 기능을 이용해 view를 구현해본다.
thymeleaf는 spring과 통합하여 사용할 수 있는 SSR(server side Rendering) 도구이다. 기존 html의 형태를 유지하면서 필요한 데이터를 view에 출력할 수 있는 네츄럴 템플릿이다.
기본적으로 프로그램 내에서 예외가 발생하여 controller까지 도달하면 spring의 defaultErrorHandler를 이용해 예외를 처리한다.
웹애플리케이션에서 에러를 처리하는 방식은 WAS가 전달받은 에러를 확인하고, 해당 에러에 맞게 설정된 경로로 다시 에러 처리 요청을 보낸다.
defaultErrorHandler의 경우 HTTP상태코드에 따라 요청을 보내도록 설정돼있다. (ex 4xx
, 500
)
필요시 Webconfig를 이용해 직접 경로를 설정할 수 있지만, 거의 사용되지 않는다.
에러가 발생해도 에러 페이지로 이동하지 않고 해당 페이지에서 에러 내용을 사용자에게 알리고 싶다.
BindingResult
를 사용하면 에러 발생시 에러에 대한 정보를 BindingResult
에 담에서 그대로 View를 출력한다.
사용방법은 reject()
, rejectValue()
메서드를 호출해서 에러에 대한 정보를 추가해주면 된다.
직접 위의 메서드를 이용해 에러를 담으면, 컨트롤러에서 DTO에 대한 검증을 진행하고 결과를 bindingResult
에 담아야 한다.
Spring의 Validation 어노테이션을 이용하면 보다 간편하게 검증을 진행할 수 있다.
@PostMapping("/members/login")
public String login(HttpServletRequest request, @Valid @ModelAttribute LoginRequest loginRequest, BindingResult bindingResult) {
log.info("login request: {}", loginRequest.getLoginId());
if(bindingResult.hasErrors()){
log.info("errors={}", bindingResult);
return "/only/members/loginForm";
}
try{
Member member = memberService.login(loginRequest);
log.info("login success loginId:{}", member.getLoginId());
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_MEMBER, member);
}
catch (RestApiException e){
ErrorCode errorCode = e.getErrorCode();
bindingResult.reject(errorCode.name(), errorCode.getMessage());
return "/only/members/loginForm";
}
return "redirect:/diary/all";
}
입력받는 객체 뒤에 BingindingResult
를 매개변수로 추가하면 앞에 있는 객체에 대한 검증 결과를 자동으로 저장해 Model에 담아 View에 전달한다.
또 예외가 발생한 경우 직접 bindingResult
에 errorcode와 message를 담아 view에 전달할 수 있다.
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
<p th:each="err:${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>
#fields
는 thymeleaf가 제공하는 유틸리티 객체로 바인딩 및 검증과 관련된 작업에서 사용한다.<div class="invalid-feedback" th:errors="*{loginId}"></div>
@Valid
어노테이션을 사용해 검증을 진행하면 검증 오류가 발생한 필드명으로 해당 오류 정보에 접근할 수 있다. (*{loginId}
)@ModelAttribute
가 정상적으로 적용되지 않았다.
Spring의 @ModelAttribute
작동 규칙은 다음과 같다.
1. NoArgsConstructor과 AllArgsConstructor 둘 다 있는 경우
NoArgsConstructor 호출하고, setter 호출하여 param을 필드에 각각 초기화한다.
DTO작성 시에@NoArgsContructor
를 추가했었다. 따라서 1번 규칙에 맞게 작동하는데 Setter
가 없으니 바인딩이 정상적으로 이뤄지지 않았던 것이다.
@Getter
@Setter
@NoArgsContructor
@AllArgsConstructor
public class LoginRequest {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
[Spring] @ModelAttribute 사용할 때 주의할 점
@NoArgsConstructor, @Getter 언제, 왜 사용할까?
@RequestMapping()
의 consums
속성을 이용해 다루는 데이터 타입마다 매칭 되는 controller가 다르게 하고싶었는데, @GetMapping
은 body가 없어 데이터 타입에 따른 구분을 할 수 없다는 점이 문제가 됐다.
설령 @GetMapping
에서도 데이터 타입을 구분할 수 있다고 해도 URL은 다르게 하는 게 맞다.
역할 구분
API는 데이터를 반환하거나 처리하기 위한 목적으로 사용됩니다 (예: JSON, XML).
View 렌더링은 HTML 페이지를 반환하여 클라이언트(브라우저)에 UI를 제공하기 위한 것입니다.
같은 URL에서 동작을 혼합하면, 유지보수 및 디버깅이 어려워질 수 있습니다.
RESTful 원칙 준수
RESTful API 설계 원칙에서는 특정 리소스에 대한 요청이 항상 일관된 결과를 반환해야 합니다. 예를 들어, /login API는 JSON 형식의 응답을 항상 반환해야 합니다.
View 렌더링이 같은 URL에서 동작하면 응답이 일관되지 않을 수 있습니다 (예: JSON 또는 HTML).
클라이언트 혼란 방지
API는 일반적으로 브라우저가 아닌 다른 클라이언트(예: 모바일 앱, 프론트엔드 SPA)에서 사용됩니다.
같은 URL에서 서로 다른 응답 형식을 반환하면 클라이언트에서 예측 가능한 처리가 어려워집니다.
따라서 api url은 앞에 /api
를 추가했다.
api와 view의 url을 다르게 함으로써 interceptor에서 redirect요청을 보낼 때도 각각 다른 url을 지정해야했다.
@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String requestURI = request.getRequestURI();
log.info("loginInterceptor: {}", requestURI);
HttpSession session = request.getSession(false);
if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
log.info("Invalid member request");
String basePath = requestURI.contains("/api") ? "/api/members/login" : "/ ";
response.sendRedirect(basePath + "?redirect=" + requestURI);
return false;
}
return true;
}
}
요청 url에 따라 다른 redirect요청을 보내도록 하였다.
api와 view를 동시에 구현하다보니 조금씩 작동 방식의 차이가 느껴진다.
일단 이제와서 뼈저리게 느낀 건 초기에 url설정을 정확히 하고 어떤 방식으로 클라이언트에게 데이터를 받을 것인지와 dto에 대한 설계도 확실히 해야하는 것 같다.
pageRequest
dto에 diaryId
값을 저장해 요청하도록 api controller를 설계했고 dto도 그렇게 설계했다.
하지만 view를 렌더링 하는 방식에서는 pathVariable로 diaryId
값을 받는다. 또 요청 form에서는 diaryId
값을 설정하지 않기 때문에 비어있는 값으로 controller에 전달된다.
이 상태로 pageRequest
를 service 계층에 넘기면 diaryId
값이 없기 때문에 오류가 발생한다.
diaryId
값을 form요청 시 억지로 넣어서 전달할 수도 있겠지만, pathVariable을 사용하는 상태에서 그런 방식은 억지스럽고 부자연스럽다.
일단은 pathVariable으로 받은 값을 직접 pageRequest에 set해주는 방식으로 오류를 해결했는데, 이 방식은 바람직하지 못하다고 생각한다.
dto 필드에 final키워드를 붙이는 일관성이 깨졌고, 이렇게 값을 직접 저장하는 방식은 유지보수성이 매우 떨어진다.
page 수정 로직을 테스트해보는 과정에서 페이지가 수정되지 않는 문제를 알았다.
로그를 출력해보면서 매개변수로 넘어오는 값과 메서드의 호출을 확인해봤을 때 모두 정상적이었고, 변경감지가 정상적으로 작동하지 않는다고 판단했다.
기존 테스트에서는 mock으로 repository를 가짜 객체로 설정하고 테스트했기 때문에 실제 DB에 수정 결과가 반영되는지를 확인하지 못했다.
JPA는 변경감지를 이용해 영속성 엔티티로 관리되고 있는 객체가 변경되는 경우 변경사항을 반영하여 DB에 저장한다.
이때 변경을 감지하는 작업의 단위로 트랜잭션이 사용된다.
즉 한 트랜잭션 내에서 영속성 컨텍스트로 관리되고 있는 객체의 변경을 감지하고, 트랜잭션이 종료될 때 변경 사항을 모두 반영하여 DB에 저장하는 것이다.
트랜잭션을 repository 계층의 메서드에 걸어놨다.
@Repository
@Transactional
public class PageRepository {
...
}
//@Transactional
public Page updatePage(PageUpdateRequest pageUpdateDTO) {
Page page = pageRepository.findOne(pageUpdateDTO.getPageId());
checkLimitTimePassed(page);
page.changeTitle(pageUpdateDTO.getTitle());
page.changeContent(pageUpdateDTO.getContent());
return page;
}
따라서 findOne()
메서드로 찾은 엔티티가 영속성으로 관리될 것이라 생각했지만, 실제로는 findOne()
메서드 호출이 끝난 시점에서 트랜잭션이 종료되어 영속성 컨텍스트가 비워지고 DB에 변경 결과가 flush 된다.
무작정 트랜잭션 단위를 repository 계층에만 걸어주면 된다고 막연히 생각했는데, 변경감지에 대한 부분도 고려해주어야 함을 알았다.
오오 멋진 프로젝트네! 결과물이 궁금하구만