이번 포스팅에서는 @DateTimeFormat, @PathVariable, 익셉션 처리에 대해서 다룹니다.
지난 포스팅까지 작성했던 프로젝트를 활용해서 새로운 기능을 구현해 보겠습니다.
회원 가입 일자를 기준으로 회원을 검색하는 기능을 구현해 보겠습니다.
MemberDao.java
public class MemberDao {
private JdbcTemplate jdbcTemplate;
private RowMapper<Member> memRowMapper =
new RowMapper<Member>() {
@Override
public Member mapRow(ResultSet rs, int rowNum)
throws SQLException {
Member member = new Member(rs.getString("EMAIL"),
rs.getString("PASSWORD"),
rs.getString("NAME"),
rs.getTimestamp("REGDATE").toLocalDateTime());
member.setId(rs.getLong("ID"));
return member;
}
};
public MemberDao(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
public Member selectByEmail(String email) {
List<Member> results = jdbcTemplate.query(
"select * from MEMBER where EMAIL = ?",
memRowMapper, email);
return results.isEmpty() ? null : results.get(0);
}
public void insert(Member member) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(new PreparedStatementCreator() {
@Override
public PreparedStatement createPreparedStatement(Connection con)
throws SQLException {
// 파라미터로 전달받은 Connection을 이용해서 PreparedStatement 생성
PreparedStatement pstmt = con.prepareStatement(
"insert into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) " +
"values (?, ?, ?, ?)",
new String[] { "ID" });
// 인덱스 파라미터 값 설정
pstmt.setString(1, member.getEmail());
pstmt.setString(2, member.getPassword());
pstmt.setString(3, member.getName());
pstmt.setTimestamp(4,
Timestamp.valueOf(member.getRegisterDateTime()));
// 생성한 PreparedStatement 객체 리턴
return pstmt;
}
}, keyHolder);
Number keyValue = keyHolder.getKey();
member.setId(keyValue.longValue());
}
public void update(Member member) {
jdbcTemplate.update(
"update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?",
member.getName(), member.getPassword(), member.getEmail());
}
public List<Member> selectAll() {
List<Member> results = jdbcTemplate.query("select * from MEMBER",
memRowMapper);
return results;
}
public int count() {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from MEMBER", Integer.class);
return count;
}
public List<Member> selectByRegdate(LocalDateTime from, LocalDateTime to) {
List<Member> results = jdbcTemplate.query(
"select * from MEMBER where REGDATE between ? and ? " +
"order by REGDATE desc",
memRowMapper,
from, to);
return results;
}
public Member selectById(Long memId) {
List<Member> results = jdbcTemplate.query(
"select * from MEMBER where ID = ?",
memRowMapper, memId);
return results.isEmpty() ? null : results.get(0);
}
}
selectByRegdate() 메서드는 쿼리문을 돌려서 REGDATE 값이 두 파라미터로 전달받은 from과 to 사이에 있는 Member 목록을 구합니다.
쿼리문을 돌릴 때 파라미터를 쿼리문에 맵핑하는 코드를 중복으로 많이 사용하기 때문에 RowMapper를 생성해서 사용함으로써 코드의 중복을 제거해 줬습니다.
이 회원 가입 시간을 기준 시간으로 표현하기 위해서 커맨드 클래스를 구현해 사용하겠습니다.
ListCommand.java
public class ListCommand {
@DateTimeFormat(pattern = "yyyyMMddHH")
private LocalDateTime from;
@DateTimeFormat(pattern = "yyyyMMddHH")
private LocalDateTime to;
public LocalDateTime getFrom() {
return from;
}
public void setFrom(LocalDateTime from) {
this.from = from;
}
public LocalDateTime getTo() {
return to;
}
public void setTo(LocalDateTime to) {
this.to = to;
}
}
검색을 위한 입력 폼은 <input type="text" name="from" /> 이런 식으로 정의합니다.
문제는 String인 from을 LocalDateTime 타입으로 변환해야 한다는 것입니다.
이를 적용하기 위해 @DateTimeFormat 애노테이션을 사용했습니다.
속성값으로 yyyyMMddHH를 주었는데 이는 "2018030115" -> "2018년 3월 1일 15시"로 변환해 줍니다.
이제 컨트롤러 코드를 작성해 조회해 보겠습니다.
MemberListController.java
@Controller
public class MemberListController {
private MemberDao memberDao;
public void setMemberDao(MemberDao memberDao) {
this.memberDao = memberDao;
}
@RequestMapping("/members")
public String list(
@ModelAttribute("cmd") ListCommand listCommand,
Errors errors, Model model) {
if (errors.hasErrors()) {
return "member/memberList";
}
if (listCommand.getFrom() != null && listCommand.getTo() != null) {
List<Member> members = memberDao.selectByRegdate(
listCommand.getFrom(), listCommand.getTo());
model.addAttribute("members", members);
}
return "member/memberList";
}
}
위에서 우리는 @DateTimeFormat 애노테이션을 사용했습니다.
속성값으로 yyyyMMddHH를 주었기 때문에 그 형식에 맞춰서 조회를 했지만, 만약 지정한 형식을 벗어나서 값을 입력하면 어떻게 될까요? 당연히 에러가 납니다. 이를 처리하기 위해 에러가 났을 경우 Errors 타입 파라미터 요청을 매핑 애노테이션 적용 메서드에 추가하면 됩니다. 이때도 Errors 파라미터는 listCommand 파라미터 뒤에 위치시켜야 합니다. (순서 바뀌면 안 됨)
이 에러를 처리하기 위해 문구를 추가해 보겠습니다.
label.properties
typeMismatch.java.time.LocalDateTime=잘못된 형식
새로운 컨트롤러 클래스를 작성했으니 설정 클래스에 관련 빈 설정을 추가해 줍니다.
ControllerConfig.java
@Autowired
private MemberDao memberDao;
@Bean
public MemberListController memberListController() {
MemberListController controller = new MemberListController();
controller.setMemberDao(memberDao);
return controller;
}
폼으로 입력받은 String이 커맨드 객체의 LocalDateTime 타입 프로퍼티로 잘 변환되는지 확인하기 위해 뷰 코드를 작성해 보겠습니다.
JSTL은 자바 8의 LocalDateTime을 지원하지 않으므로 태그 파일을 사용해서 값을 지정한 형식으로 출력해 보겠습니다.
formatDateTime.tag
<%@ tag body-content="empty" pageEncoding="utf-8" %>
<%@ tag import="java.time.format.DateTimeFormatter" %>
<%@ tag trimDirectiveWhitespaces="true" %>
<%@ attribute name="value" required="true"
type="java.time.temporal.TemporalAccessor" %>
<%@ attribute name="pattern" type="java.lang.String" %>
<%
if (pattern == null) pattern = "yyyy-MM-dd";
%>
<%= DateTimeFormatter.ofPattern(pattern).format(value) %>
MemberListController 클래스의 list() 메서드는 커맨드 객체로 받은 ListCommand의 from 프로퍼티와 to 프로퍼티를 이용해서 해당 기간에 가입한 Member 목록을 구하고, 뷰에 "members" 속성을 전달합니다.
뷰 코드는 이에 맞게 ListCommand 객체를 위한 폼을 제공하고 members 속성을 이용해 회원 목록을 출력하도록 구현하면 됩니다.
memberList.jsp
<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="tf" tagdir="/WEB-INF/tags" %>
<!DOCTYPE html>
<html>
<head>
<title>회원 조회</title>
</head>
<body>
<form:form modelAttribute="cmd">
<p>
<label>from: <form:input path="from" /></label>
<form:errors path="from" />
~
<label>to:<form:input path="to" /></label>
<form:errors path="to" />
<input type="submit" value="조회">
</p>
</form:form>
<c:if test="${! empty members}">
<table>
<tr>
<th>아이디</th><th>이메일</th>
<th>이름</th><th>가입일</th>
</tr>
<c:forEach var="mem" items="${members}">
<tr>
<td>${mem.id}</td>
<td><a href="<c:url value="/members/${mem.id}"/>">
${mem.email}</a></td>
<td>${mem.name}</td>
<td><tf:formatDateTime value="${mem.registerDateTime }"
pattern="yyyy-MM-dd" /></td>
</tr>
</c:forEach>
</table>
</c:if>
</body>
</html>
<form:input> 태그를 이용해서 커맨드 객체의 from 프로퍼티와 to 프로퍼티를 위한 <input> 태그를 생성했습니다.
이를 통해 String을 LocalDateTime으로 변환해 조회할 준비를 마쳤습니다.
값이 알맞게 변환되어 전환된 것을 확인할 수 있습니다.
값을 잘못 입력한 경우에도 에러 메시지가 적절하게 출력되는 모습을 볼 수 있습니다.
그럼 어떻게 String 타입이 LocalDateTime으로 변환될까요?
WebDataBinder가 이 값 변환에 관여합니다.
요청 URL로 메서드를 실행시키기 위해서는 RequestMappingHandlerAdapter 객체를 사용합니다.
이 핸들러 어댑터 객체는 요청 파라미터와 커맨드 객체 사이의 변환 처리를 위해 WebDataBinder를 이용합니다.
WebDataBinder가 커맨드 객체를 생성하고 프로퍼티와 같은 이름을 갖는 요청 파라미터를 이용해서 프로퍼티 값을 생성합니다.
그런데 이때 직접 변환하는 것이 아니라 Conversion Service에 그 역할을 위임합니다.
@EnableWebMvc를 사용하면 DefaultFormattingConversionService를 ConversionService로 사용합니다.
int, long과 같은 기본 데이터 타입뿐만 아니라 @DateTimeFormat을 사용한 시간 관련 타입 변환 기능도 제공합니다.
<form:input>에도 사용되는데 path 속성에 지정한 프로퍼티 값을 String으로 변환해서 <input> 태그의 value 속성 값으로 생성합니다.
ex)
<form:input path="from" />
=> <input type="text" id ="from" name="from" value="2018030115">
다음은 회원의 정보를 조회하는 코드를 작성해 보겠습니다.
회원 번호가 10번인 회원을 조회한다고 가정해 보겠습니다.
그럼 요청 URL에 10이라는 번호를 파라미터로 보내서 조회를 할 수 있습니다.
ex) http://localhost:8080/members/10
이를 사용하면 각 회원마다 경로의 마지막 부분이 달라집니다.
이렇게 경로의 일부가 고정되어 있지 않고 달라질 때 사용할 수 있는 것이 @PathVariable 어노테이션입니다.
그럼 이를 활용해 코드를 작성해 보겠습니다.
MemberDetailController.java
@Controller
public class MemberDetailController {
private MemberDao memberDao;
public void setMemberDao(MemberDao memberDao) {
this.memberDao = memberDao;
}
@GetMapping("/members/{id}")
public String detail(@PathVariable("id") Long memId, Model model) {
Member member = memberDao.selectById(memId);
if (member == null) {
throw new MemberNotFoundException();
}
model.addAttribute("member", member);
return "member/memberDetail";
}
@ExceptionHandler(TypeMismatchException.class)
public String handleTypeMismatchException() {
return "member/invalidId";
}
@ExceptionHandler(MemberNotFoundException.class)
public String handleNotFoundException() {
return "member/noMember";
}
}
매핑 경로에 중괄호 안에 적힌 값을 경로 변수라고 부른다. 이를 @PathVariable 파라미터로 전달해서 사용하게 됩니다.
ex) @GetMapping("/members/{id}") => @PathVariable("id") Long memId
만약 id가 10인 경우, memId에 10을 String 타입에서 long 타입으로 변환하여 전달합니다.
그런데 만약 존재하지 않은 id를 경로변수를 사용하면 어떻게 될까요? 혹은 숫자가 아닌 문자를 경로에 넣으면 어떻게 될까요?
이를 해결하기 위해 TypeMismatchException과 MemberNotFoundException을 처리해 줬습니다.
@ExceptionHandler을 사용하면 에러 응답을 보내는 대신 뷰 이름을 리턴해 에러 시 띄울 화면을 보여줄 수 있습니다.
그럼 컨트롤러 코드를 작성했으니 설정 클래스에 빈으로 등록하겠습니다.
CotrollerConfig.java
@Bean
public MemberDetailController memberDetailController() {
MemberDetailController controller = new MemberDetailController();
controller.setMemberDao(memberDao);
return controller;
}
그럼 회원 정보를 보여줄 뷰 코드와 에러 시 보여줄 뷰 코드를 작성해 보겠습니다.
memberDetail.jsp
<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="tf" tagdir="/WEB-INF/tags" %>
<!DOCTYPE html>
<html>
<head>
<title>회원 정보</title>
</head>
<body>
<p>아이디: ${member.id}</p>
<p>이메일: ${member.email}</p>
<p>이름: ${member.name}</p>
<p>가입일: <tf:formatDateTime value="${member.registerDateTime}"
pattern="yyyy-MM-dd HH:mm" /> </p>
</body>
</html>
noMember.jsp
<%@ page contentType="text/html; charset=utf-8" %>
<!DOCTYPE html>
<html>
<head>
<title>에러</title>
</head>
<body>
존재하지 않는 회원입니다.
</body>
</html>
invalidId.jsp
<%@ page contentType="text/html; charset=utf-8" %>
<!DOCTYPE html>
<html>
<head>
<title>에러</title>
</head>
<body>
잘못된 요청입니다.
</body>
</html>
그럼 코드가 잘 작성됐는지 확인해 보기 위해 한 번 실행해 보겠습니다.
회원 정보도 잘 읽어오고 에러 처리도 잘하는 모습입니다.
이처럼 @ExceptionHandler를 적용하면 해당 컨트롤러에서 발생한 익셉션만을 처리합니다.
다수의 컨트롤러에서 동일 타입의 익셉션이 발생한다면 공통으로 익셉션을 처리하면 좋겠죠?
이는 @ControllerAdvice 애노테이션을 사용하면 중복을 없앨 수 있습니다.
ex) @ControllerAdvice("spring") => spring 패키지와 그 하위 패키지에 속한 컨트롤러 클래스를 위한 공통 기능 정의
그래서 @ControllerAdvice가 붙은 클래스에서 @ExceptionHandler로 정의한 익셉션이 발생하면 메서드를 통해서 익셉션을 처리하게 됩니다.
이를 위해서는 역시 스프링에 빈으로 등록해야 합니다.
그럼 @ExeptionHandler의 적용 메서드 우선순위에 대해서 알아보겠습니다.
- 컨트롤러 클래스에 있는 @ExceptionHandler
- @ControllerAdvice 클래스에 있는 @ExceptionHandler
@ExceptionHandler 적용 메서드의 파라미터와 리턴타입에 쓸 수 있는 것이 무엇이 있는지 알아보겠습니다.
@ExceptionHandler(MemberNotFoundException.class)
public String handleNotFoundException() {
return "member/noMember";
}
위의 코드에서는 파라미터는 사용하지 않았고 뷰를 리턴했습니다.
- 사용 가능 파라미터 타입
- HttpServletRequest, HttpServiceResponse, HttpSession
- Model
- 익셉션
- 사용 가능 리턴 타입
- ModelAndView
- String(뷰 이름)
- (@ResponseBody를 붙인) 임의 객체
- @ResponseEntity
출처: 초보 웹 개발자를 위한 스프링 5 프로그래밍 입문
'Spring' 카테고리의 다른 글
[Spring] JSON 응답과 요청 처리(@RestController, @RequestBody, ResponseEntity) (0) | 2023.12.31 |
---|---|
[Spring] 웹 어플리케이션의 구조 (1) | 2023.12.30 |
[Spring] 스프링 MVC 3 : 세션, 인터셉터, 쿠키 (Session, Interceptor, Cookie) (1) | 2023.12.16 |
[Spring] 스프링 MVC 2 : 메시지, 커맨드 객체 검증 (1) | 2023.12.08 |
[Spring] 스프링 MVC 1 : 요청, 매핑, 커맨드 객체, 리다이렉트, 폼 태그, 모델 (0) | 2023.12.03 |