Open tmdcheol opened 3 months ago
핵심이 되는 기능의 로직 제공
Example) 비밀번호 변경 기능에서 필요한 로직
@Transactional
public void changePassword(String email, String oldPwd, String newPwd){
Member member = memberDao.selectByEmail(email); //1
if(member==null)
throw new MemberNetFoundException(); //2
member.changePassword(oldPwd, newPwd); //3
memberDao.update(member);//4
}
서비스 메서드를 @Transactional 애노테이션을 이용해 트랜잭션 범위에서 실행
서비스 클래스가 제공할 기능의 개수는 몇 개가 적당할까?
서비스 메서드의 parameter
public void changePAssword(String email, String oldPwd, String newPwd)
public void regist(RegisterRequest req)
@RequestMapping(method = RequestMethod.POST)
public String submit(
@ModelAttibute("command") ChangePwdCommand pwdCmd,
Errors errors, HttpSession session){
...
changePAsswordService.changePassword(
authInfo.getEmail(),
pwdCmd.getCurrentPassword(),
pwdCmd.getNewPassword();
...
}
서비스 메서드 기능 실행 후 결과
public class AuthService {
... 생략
public Authinfo authenticate(String email, String password) {
Member member = memberDao.selectByEmail(email);
if (member == null) {
throw new WrongldPasswordException);
}
if (!member.matchPassword(password)) {
throw new WrongldPasswordException();
}
return new AuthInfo(member.getld(), member.getEmail),
member.getName());
}
}
//성공할 경우 AuthInfo객체 리턴
@RequestMapping(method = RequestMethod.POST)
public String submit(
LoginCommand loginCommand, Errors errors, HttpSession session,
HttpServletResponse response) {
...
try {
AuthInfo authInfo = authService.authenticate(
loginCommand.getEmail),
loginCommand.getPassword());
session.setAttribute("authinfo", authinfo);
...
return "login/loginSuccess";
} catch (WrongldPasswordException e) {
// 서비스는 기능 실행에 실패할 경우 익셉션을 발생시킨다.
errors.reject("idPasswordNotMatching");
return "login/loginForm";
}
}
public class MemberService{
...
public Member getMember(Long id){
return memberDao.selectById(id);
}
}
public class Person { <=> { "name":"이름",
private String name; "age":10
private int age; }
..get/set 메서드
}
스프링 MVC에서 JSON 형식으로 데이터 응답하는 방법
→ @Controller 애노테이션 대신 @RestController 애노테이션 사용
→ 매핑 애노테이션을 붙인 메서드가 리턴한 객체를 알맞게 변환해 응답 데이터로 전송
: 이 때 클래스 패스에 Jackson이 존재하면 JSON 형식 문자열로 변환하여 응답
import com.fasterxml.jackson.annotation.JsonIgnore;
public class Member{
private Long id;
private String email;
@JsonIgnore;
private String password;
private String name;
private LocalDateTime registerDateTime;
}
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonFormat.Shape;
public class Member{
private Long id;
private String email;
private String name;
@JsonFormat(shape = Shape.STRING)
private LocalDateTime registerDateTime;
}
//형식을 원하는 형식으로 변환하고싶다면
@JsonFormat(pattern = "yyyyMMddHHmmss"
매번 @JsonFormat 애노테이션 사용한다면 번거로움
→ 스프링 MVC 설정을 변경하여 Jackson 변환규칙을 모든 날짜타입에 적용
원래 HttpMessageConverter 사용하여 자바객체를 HTTP응답으로 변환
⇒ MappingJackson2HttpMessageConverter 새롭게 등록
//MvcConfig클래스
...
public class MvcConfig implements VecMvcConfigurer{
...
@Override
public void extendMessageConverters(
List<HttpMessageConverter<?>> converters){
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
.json()
*.featuresToDisable(
SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)*
.build();
converters.add(0,
new MappingJackson2HttpMessageConverter(objectMapper));
}
}
원하는 타입의 형식으로 출력하고싶다면 Jackson2ObjectMapperBuilder#simpleDateFormat() 메서드 사용
//위에서 .featuresToDisable(...)을 변경
.simpleDateFormat("yyyyMMddHHmmss")
//ISO-8601 형식 대신 원하는 패턴을 원한다면
.serializerByType(LocalDateTime.class,
new LocalDateTimeSerializer(formatter))
개발자도구 실행 후 JSON이 제공하는 API를 호출하면 헤더의 Content-Type 확인 가능
→ application/json
자바객체 ⇒ JSON
JSON ⇒ 자바객체
**import org.springframework.web.bind.annotation.RequestBody;**
...
@RestController
public class RestMemberController{
...
@PostMapping("/api/members")
public void newMember(
**@RequestBody** @valid RegisterRequest regReq,
HttpServletResponse response) throws IOException{
...
}
}
}
//특정 패턴을 가진 문자열을 변환하고자 할 때
@JsonFormat(pattern = "yyyyMMddHHmmss")
private LocalDateTime birthDateTime;
@JsonFormat(pattern = "yyyyMMdd HHmmss")
private Date birthDate;
...
public class MvcConfig implements VecMvcConfigurer{
...
@Override
public void extendMessageConverters(
List<HttpMessageConverter<?>> converters){
ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
.json()
**.featuresToDisable(SerializationFeature.INDENT_OUTPUT)
.deserailizerByType(LocalDateTime.class,
new LocalDateTimeeserializer(formatter))
.simpleDateFormat("yyyyMMdd HHmmss")
.build();
converters.add(0,
new MappingJackson2HttpMessageConverter(objectMapper));
}
}
PostMapping("/api/members")
public void newMember(
@RequestBody RegisterRequest regRea, Errors errors,
HttpServletResponse response) throws IOException {
try {
new RegisterRequestValidator) validate(regRea, errors);
if (errors.hasErrors()) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
} ...
} catch (DuplicateMemberException dupEx) {
response.sendError(HttpServletResponse.SC_CONFLICT);
}
}
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@RestController
public class RestMemberController {
private MemberDao memberDao;
.. 생략
@GetMapping("/api/members/{id}")
public ResponseEntity<Object> member @PathVariable Long id) {
Member member = memberDao.selectByld (id);
if (member == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("no member"));
}
return ResponseEntity.status(HttpStatus.OK).body(member);
}
리턴타입이 ResponseEntity면 ResponseEntity의 body로 지정한 객체를 사용해 변환
→ 이 경우 member객체, ErrorResponse를 JSON으로 변환
생성하는 기본 방법 : ResponseEntity.status(상태코드).body(객체)
200(OK) 응답 코드와 몸체 데이터를 생성할 경우 : ResponseEntity.ok(member)
몸체가 없다면
: ResponseEntity.status(HttpStatus.NOT_FOUND).build()
: ReponseEntity.notFound().build()
⇒ @ExceptionHandler 애노테이션을 적용한 메서드에서 에러 응답을 처리
@GetMapping("/api/members/{id}")
public ResponseEntity<Object> member @PathVariable Long id) {
Member member = memberDao.selectByld (id);
if (member == null) {
throw new MemberNotFoundException();
}
return member;
}
@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNoData() {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("no member"));
}
Member 자체를 리턴
⇒ 회원 데이터가 존재하면 JSON으로 변환한 결과 응답
⇒ 존재하지 않으면 MemberNotFoundException 발생
→ @ExceptionHandler 애노테이션 사용한 handleNoData() 메서드가 에러 처리
→ 404이고 몸체가 JSON인 형식의 응답 전송
@RestControllerAdvice 애노테이션을 이용해 에러코드 처리할수도 o
@ControllerAdvice 애노테이션과 동일,
차이는 @RestController 애노테이션과 같이 응답을 JSON 이나 XML같은 형식으로 변환
@Valid 애노테이션을 붙인 커맨드 객체가 값 검증에 실패하면 400 상태 코드 응답
→ 문제는 HttpServletResponse 이용했을 때와 같이 HTML 응답을 전송한다는 것
⇒ Errors 타입 파라미터를 추가해 직접 에러 응답 생성!
@PostMapping("/api/members")
public ResponseEntity<Object> newMember(
@RequestBody @Valid RegisterRequest regReq,
Errors errors) {
if (errors.hasErrors()) {
String errorCodes = errors.getAllErrors) // List<ObjectError>
.stream()
.map (error → error.getCodes) [0]) // error= ObjectError
.collect(Collectors.joining(","));
return ResponseEntity
.status (HttpStatus.BAD_REQUEST)
body(new ErrorResponse("errorCodes = " + errorCodes));
}
...생략
}
hasErrors()메서드를 이용해 검증 에러가 존재하는지 확인
→ 존재하면 getAllErrors() 메서드로 모든 에러 정보 구하고,
각 에러 코드값 연결한 문자열을 errorCodes 변수에 할당
//DsDevConfig 클래스를 설정으로 사용
@Configuration
@Profile("dev")
public class DsDevConfig{
@Bean(destroyMethod="close")
public DataSource dataSource(){
...
}
}
//"dev"가 아닌 "real" 프로필 활성화 했을 대
@Configuration
@Profile("real")
public class DsRealConfig{
@Bean(destroyMethod="close")
public DataSource dataSource(){
...
}
}
“dev”프로필을 활성화 → @Profile(”dev”) 애노테이션 붙인 설정 클래스의 빈 사용
“real”프로필을 활성화 → @Profile(”real”) 애노테이션 붙인 설정 클래스의 빈 사용
특정 프로필을 선택하는 법
1 초기화 전 setActiveProfiles() 메서드 사용
context.getEnvironment().setActiveProfiles(”dev”);
...
getEnvironment() : 스프링 실행 환경을 설정하는 데 사용하는 Environment 리턴
→ 이 메서드를 사용해 사용할 프로필 선택 가능 (이 경우엔 dev)
⇒ 따라서 DsDevConfig 클래스와 DsRealConfig 클래스에 정의된 “dataSource” 중 DsDevConfig에 정의된 “dataSource” 빈 사용
주의할 점 : 설정 정보 전달 전에 사용할 프로필을 지정해야 함
⇒ 프로필 선택 전 선택정보를 먼저 전달하면 빈을 찾지 못해 익셉션 발생
두 개 이상 프로필을 활성화 하고싶다면 파라미터로 전달
2 spring.profiles.active 시스템 프로퍼티에 사용할 프로필 값 지정
명령행에서 -D 옵션 이용
java -Dspring.profiles.active=dev main.Main
System.setProperty() 이용
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(
MemberConfig.class, DsDevConfig.class, DsRealConfig.class);
프로필 우선순위
→ setActiveProfiles() - 자바 시스템 프로퍼티 - OS 환경변수
주의) 중첩된 @Configuration 설정 사용할 때 중첩 클래스는 static이어야 함
@Configuration
public class MemberConfigWithProfile{
@Autowired
private DataSource dataSource;
@Bean
public MemberDao memberDao(){
return new MemberDao(dataSource);
}
@Configuration
@Profile("dev")
public class DsDevConfig{
@Bean(destroyMethod="close")
public DataSource dataSource(){
...
}
}
@Configuration
@Profile("real")
public class DsRealConfig{
@Bean(destroyMethod="close")
public DataSource dataSource(){
...
}
}
}
//real, test 프로필 모두 사용
@Configuration
@Profile(”real,test”)
public class DataSourceJndiConfig{ ... )
//real 프로필 비활성화
@Configuration
@Profile(”!real”)
public class DataSourceJndiConfig{ ... )
<servlet>
...
<init-param>
<param-name>spring.profiles.active</param-name>
<param-value>dev</param-value>
</init-param>
...
</servlet>
프로퍼티 사용을 위한 설정
1 PropertySourcesPlaceholderConfigurer 빈 설정
package config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.PropertySoucesPlaceholderConfigurer;
import org.springframework.core.io.ClassPathResource;
@Configuration
public class PropertyConfig{
@Bean
public static PropertySourcesPlaceholderConfigurer properties(){
PropertySourcesPlaceholderConfigurer configurer =
new PropertySourcesPlaceholderConfigurer();
configurer.setLocations(
new ClassPathResource("db.properties"),
new ClassPathResource("info.properties"));
return configurer;
}
}
위 setLocations() 메서드는 프로퍼티 파일 목록을 인자로 전달받는다
→ Resource 타입을 이용해 파일 경로 전달
PropertySourcesPlaceholderConfigurer 빈 설정 메서드는 항상 정적 메서드
→ 정적 메서드로 지정하지 않으면 원하는 방식으로 동작 x
2 @Value 애노테이션
package config;
...
@Congifuration
public class DsConfigWithProp{
Value("${db.driver}")
private String driver;
...
}
package spring;
import ...;
public class Info{
@Value("${Info.version}")
private String version;
...
}
public class Info{
private String version;
...
@Value("${info.version}")
public void setVersion(String version){
this.version=version;
}
}
위 사진은 스프링 MVC의 핵심 구성요소를 보여주는 그림입니다. 위 사진에서 컨트롤러와 JSP 가 개발자가 직접 구현해야 하고 스프링 빈으로 등록해야 하는 것들 입니다.
중앙에 위치한 DispatcherServlet 은 모든 연결을 담당합니다.
(1) 웹 브라우저로부터 요청이 들어오면 디스패쳐 서블릿은 그 요청을 처리하기 위한 컨트롤러 객체를 탐색하게 됩니다.
(2) 이때 디스패쳐 서블릿은 직접 컨트롤러를 검색하지않고, HandlerMapping 이라는 빈 객체에게 컨트롤러 검색을 요청합니다.
핸들러매핑은 클라이언트의 요청 경로를 이용해서 이를 처리할 컨트롤러 빈 객체를 디스패쳐 서블릿에게 전달합니다. ex) 웹 요청 경로가 '/hello' 라면 등록된 컨트롤러 빈 중에 '/hello' 요청 경로를 처리할 컨트롤러를 리턴해줍니다. 단, 디스패쳐 서블릿이 컨트롤러 객체를 전달 받았다고 해서 바로 컨트롤러 객체의 메소드를 실행할 수 있는 것은 아닙니다.
(3) 디스패쳐 서블릿은 핸들러 매핑이 찾아준 컨트롤러 객체를 처리할 수 있는 HandlerAdapter 빈에게 요청 처리를 위임합니다.
(4~5) 핸들러 어댑터는 컨트롤러의 알맞은 메소드를 호출해서 요청을 처리합니다.
(6) 이후, 그 결과를 디스패쳐 서블릿에게 리턴합니다.
(7) 핸들러 어댑터로부터 컨트롤러의 요청 처리 결과를 ModelAndView로 받으면 디스패쳐서블릿은 결과를 보여줄 뷰를 찾기 위해 ViewResolver 빈 객체를 사용합니다.
ModelAndView는 컨트롤러가 리턴한 뷰 이름을 담고 있는데 ViewResolver는 이 뷰 이름에 해당하는 View 객체를 찾거나 생성해서 리턴합니다.
(8) 디스패쳐서블릿은 뷰 리졸버가 리턴한 View 객체에게 응답 결과 생성을 요청합니다.
(9) 이후 웹 브라우저에 전송할 응답결과를 생성하고 모든 과정이 끝이 납니다.
@Controller 적용 객체는 디스패쳐 서블릿 입장에서 보면 한 종류의 핸들러 객체입니다. 디스패쳐 서블릿은 웹 브라우저의 요청을 처리할 핸들러 객체를 찾기 위해 HandlerMapping을 사용하고 핸들러를 실행하기 위해 HandlerMapping과 HandlerAdapter타입의 빈을 사용하므로 핸들러에 알맞은 HandlerMapping 빈과 HandlerAdapter 빈이 스프링 설정에 등록되어야 합니다.
RequestMappingHandlerMapping은 @Controller 어노테이션이 적용된 객체의 요청 매핑 어노테이션(@GetMapping) 값을 이용해 웹 브라우저의 요청을 처리할 컨트롤러 빈을 찾습니다. RequestMappingHandlerAdapter은 컨트롤러 메서드를 알맞게 실행하고 그 결과를 ModelAndView 객체로 변환해서 디스패쳐 서블릿에 리턴합니다.
@Controller
public class HelloController {
@RequestMapping("/hello")
public String hello(Model model, @RequestParam(value = "name", required = false) String name) {
model.attribute("hi", "안녕" + name);
return "hello";
}
}
RequestMappingHandlerAdapter는 컨트롤러 메소드 결과 값이 String 타입이면 해당 값을 뷰 이름으로 갖는 ModelAndView 객체를 생성해서 디스패쳐 서블릿에게 리턴하게 됩니다. 이때, 첫번째 파라미터로 전달한 Model 객체에 보관된 값도 ModelAndView에 함께 전달됩니다.
위 코드에서는 "hello"를 리턴하므로 view 이름으로 "hello"를 사용합니다.
웹 어플리케이션을 개발하는 것은 다음 코드를 작성하는 것입니다.
- 특정 요청 URL을 처리할 코드
- 처리 결과를 HTML과 같은 형식으로 응답하는 코드
특정 요청 URL을 처리하는 코드는 @Controller 어노테이션을 사용한 컨트롤러 클래스를 이용해 구현합니다. 컨트롤러 클래스는 요청 매핑 어노테이션을 사용해서 메소드가 처리할 요청 경로를 지정합니다. 요청 매핑 어노테이션에는 @RequestMapping, @GetMapping, @PostMapping 등이 있습니다.
@Controller
public class HelloController {
@RequestMapping("/hello")
public String hello(Model model, @RequestParam(value = "name", required = false) String name) {
model.attribute("hi", "안녕" + name);
return "hello";
}
}
앞서 본 코드가 @GetMapping 어노테이션을 사용하여 "/hello" 요청 경로를 hello() 메소드가 처리하도록 설정한 코드입니다.
요청 매핑 어노테이션을 적용한 메소드를 두 개 이상 정의할 수도 있습니다. 만약, 회원 가입 상황에서 일반적인 회원 가입 과정은 '약관 동의 -> 회원 정보 입력 -> 가입 완료' 순인데 각 과정을 위한 URL은 다음과 같다고 해봅시다. 약관 동의 : http://localhost:8080/register/step1 정보 입력 : http://localhost:8080/register/step2 가입 완료 : http://localhost:8080/register/step3
이렇게 여러 단계를 거쳐 하나의 기능이 완성되는 경우, 관련 요청 경로를 한 개의 컨트롤러 클래스에서 처리하면 코드 관리에 도움이 됩니다.
@Controller
public class RegisterController {
@RequestMapping("/register/step1")
public String handleStep1() {
return "register/step1";
}
@RequestMapping("/register/step2")
public String handleStep2() {
return "register/step2";
}
@RequestMapping("/register/step3")
public String handleStep3() {
return "register/step3";
}
}
위 코드를 보면 각 요청 매핑 어노테이션의 경로가 "/register" 로 똑같이 시작합니다. 이런 경우 다음 코드처럼 공통되는 부분의 경로를 담은 @RequestMapping 어노테이션을 클래스에 적용하고 각 메소드는 나머지 경로를 값으로 갖는 요청 어노테이션을 적용할 수 있습니다.
@Controller
@RequestMapping("/register")
public class RegisterController {
@RequestMapping("/step1")
public String handleStep1() {
return "register/step1";
}
@RequestMapping("/step2")
public String handleStep2() {
return "register/step2";
}
@RequestMapping("/step3")
public String handleStep3() {
return "register/step3";
}
}
스프링 MVC는 클래스에 적용한 요청 매핑 어노테이션의 경로와 메소드에 적용한 요청 매핑 어노테이션의 경로를 합쳐서 경로를 찾기 때문에 위 코드에서 handleStep1() 메소드가 처리하는 경로는 "/step1"이 아니라 "register/step1"이 됩니다.
HTML 폼 코드에서
<form action="step2" method="post">
라고 돼있다고 해봅시다.
@Controller
public class RegisterController {
@PostMapping("/register/step2")
public String handleStep2() {
return "register/step2";
}
}
그렇다면 폼을 전송할 때 HTTP 메소드 중 POST 방식을 사용한다는 것인데, 스프링 MVC는 별도 설정이 없으면 GET, POST 방식에 상관없이 @RequestMapping 에 지정한 경로와 일치하는 요청을 처리합니다. 만약 POST 방식 요청만 처리하고 싶다면 다음과 같이 @PostMapping 어노테이션을 사용하여 제한할 수 있습니다.
@Controller
public class RegisterController {
@GetMapping("/member/login")
public String form() {
...
}
}
위 코드처럼 설정하면 handleStep2 메소드는 POST 방식의 /register/step2 요청 경로만 처리하며 같은 요청 경로의 GET 요청은 처리하지 않게 됩니다. 마찬가지로 @GetMapping 을 사용해도 같습니다.
<form action="step2" method = "post">
<label>
<input type="checkbox" name="agree" value = true> 약관 동의
</label>
<input type="submit" value = 다음 단계 />
</form>
위 코드를 보면 다음처럼 약관에 동의할 경우 값이 true 인 'agree' 요청 파라미터의 값을 POST 방식으로 전송합니다. 따라서 폼에 지정한 agree 요청 파라미터 값을 이용해서 약관 동의 여부를 할 수 있습니다.
@PostMapping("/register/step2")
public String handleStep2(HttpServletRequest request) {
String agreeParam = request.getParameter("agree");
if (agreeParam == null || !agreeParam.equals("true")) {
return "register/step1";
}
return "register/step2";
}
컨트롤러 메소드에서 요청 파라미터를 사용하는 첫번째 방법은 HttpServletRequest 를 직접 이용하는 것입니다. ex) 컨트롤러 처리 메소드의 파라미터로 HttpServletRequest 타입을 사용하고 HttpServletRequest의 getParameter() 메소드를 이용하여 파라미터의 값을 구하면 됩니다.
@PostMapping("/register/step2")
public String handleStep2(@RequestParam(value="agree", defaultValue="false") Boolean agree) {
if (!agree) {
return "register/step1";
}
return "register/step2";
}
속성 | 타입 | 설명 |
---|---|---|
value | String | HTTP 요청 파라미터의 이름 지정 |
required | boolean | 필수 여부 지정. 값이 true이면서 요청 파라미터 값 없으면 exception |
defaultValue | String | 요청 파라미터 값이 없을 때 사용할 문자열 값 지정 |
요청 파라미터에 접근하는 두번째 방법은 @RequestParam 어노테이션을 이용하는 것입니다. 요청 파라미터 개수가 몇 개 안되면 이 어노테이션을 사용해 간단한 요청 파라미터 값을 구할 수 있습니다.
handleStep2() 메소드는 agree 요청에 따라 파라미터의 값이 true가 아니면 다시 약관 동의 폼을 보여주기 위해 "register/step1" 뷰 이름을 리턴합니다. 약관에 동의 했다면 입력 폼을 보여주기 위해 "register/step2"를 뷰 이름으로 리턴할 것입니다.
웹 브라우저에서 http://localhost:8080/register/step2 주소로 직접 입력하면, HTTP 405 - Method not allowed 에러 화면이 출력될 것입니다. handleStep2() 메소드는 POST 방식만을 처리했기 때문입니다. 위 메소드는 GET 요청의 처리를 지원하지 않으므로 스프링 MVC는 405 상태 코드를 응답하게 됩니다. 405 상태코드란 서버가 요청 메서드를 알고 있지만 대상 리소스가 이 메서드를 지원하지 않음을 가리킵니다.
잘못된 전송 방식으로 요청이 왔을 때 에러화면 보다 알맞은 경로로 리다이렉트 하는 것이 더 좋을 때가 있습니다. 컨트롤러에서 특정 페이지로 리다이렉트 시키는 방법은 간단합니다.
"redirect:경로"
위 형태처럼 뷰 이름을 리턴하면 됩니다.
@Controller
public class RegisterController {
// handleStep1(), handleStep2() ...
@GetMapping("/register/step2")
public String handleStep2Get() {
return "redirect:/register/step1";
}
}
위 handleStep2Get() 메소드를 통해 리다이렉트를 할 수 있습니다.
만약 폼이 eamil, name, password, confirmPassword 정보를 파라민터로 서버에 전송한다고 해봅시다.
그렇다면 폼 전송 요청을 처리하는 컨트롤러 코드는 각 파라미터의 값을 구하기 위해 다음과 같은 코드를 작성해야 합니다.
@PostMapping("register/step3")
public String handleStep3(HttpServletRequest request) {
String email = request.getParameter("email");
String name = request.getParameter("name");
String password = request.getParameter("password");
String confirmPassword = request.getParameter("confirmPassword");
RegisterRequest regReq = new RegisterRequest();
regReq.setEmail(email);
regReq.setName(name);
...
위 코드는 올바르게 동작하지만, 요청 파라미터 개수가 증가할 때 마다 메소드의 코드 길이가 비례하게 늘어날 것 입니다.
스프링은 이러한 불편함을 줄이기 위해 요청 파라미터의 값을 커맨드 객체에 담아주는 기능을 제공합니다. 예를 들어, name인 요청 파라미터의 값을 커맨드 객체의 setName() 메소드를 사용해서 커맨드 객체에 전달하는 기능을 제공합니다. 요청 파라미터의 값을 전달받을 수 있는 세터 메소드를 포함한 객체를 커맨드 객체로 간단하게 사용만 해주면 됩니다.
@PostMapping("register/step3")
public String handleStep3(RegisterRequest request) {
...
@Getter
@Setter
public class RegisterRequest {
private String email;
private String name;
private String password;
private String confirmPassword;
}
RegisterRequest 클래스 안에는 setEmail(), setName(), setPassword(), setConfirmPassword() 메소드가 있습니다.
여기서 사용한 @Getter , @Setter는 롬복 라이브러리에서 끌어다 온 어노테이션입니다. 굳이 직접 게터와 세터를 구현하지 않아도 어노테이션으로 자동생성을 해줍니다.
스프링은 이 메소드들을 사용해서 email,name,password,confirmPassword 요청 파라미터의 값을 커맨드 객체에 복사한 뒤 regReg 파라미터로 전달하게 됩니다.
커맨드 객체를 포괄적으로 DTO 라고 이해하면 편합니다.
커맨드 객체에 접근할 때 사용할 속성 이름을 변경하고 싶다면 파라미터에 @ModelAttribute 어노테이션을 적용하면 됩니다.
@PostMapping("register/step3")
public String handleStep3(@ModelAttribute("formData") RegisterRequest regReq)
...
@ModelAttribute 어노테이션은 모델에서 사용할 속성 이름을 값으로 설정합니다. 위 설정을 사용하면 뷰 코드에서 "formData" 라는 이름으로 커맨드 객체에 접근할 수 있습니다.
스프링 MVC가 제공하는 커스텀 태그를 사용하면 좀 더 간단하게 커맨드 객체의 값을 출력할 수 있습니다. 스프링은
...
<form:form action="step3" modelAttribute="registerRequest">
...
<form:input path="email" />
...
<form:input path="name" />
...
<form:input path="password" />
...
<form:input path="comfirmPassword" />
...
@Controller
public class RegisterController {
...
@PostMapping("/register/step2")
public String handleStep2(@RequestParam(value="agree", defaultValue="false") Boolean agree, Model model) {
if(!agree) {
return "register/step1";
}
model.attribute("registerRequest", new RegisterRequest());
return "register/step2";
}
...
java Member member; Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { // 반복되는 코드 conn = DriverManager.getConnection("jdbc:mysql://localhost/spring5fs", "spring5", "spring5"); pstmt = conn.prepareStatement("select * from MEMBER where EMAIL = ?"); pstmt.setString(1, email); rs = pstmt.executeQuery(); if (rs.next()) { member = new Member(rs.getString("EMAIL"), rs.getString("PASSWORD"), rs.getString("NAME"), rs.getTimestamp("REGDATE")); member.setId(rs.getLong("ID")); // 핵심 코드 return member; } else { return null; } } catch (SQLException e) { e.printStackTrace(); throw e; } finally { // 반복되는 코드 if (rs != null) try { rs.close(); } catch (SQLException e) {} if (pstmt != null) try { pstmt.close(); } catch (SQLException e1) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} }
JDBC API를 이용하면 DB 연동에 필요한 Connection을 구한 다음 쿼리를 실행하기 위한 PreparedStatement를 생성함.
쿼리를 실행한 뒤에는 finally 블록에서 ResultSet, PreparedStatement, Connection을 닫음.
점선으로 표시한 부분은 데이터 처리와 상관없는 코드지만, JDBC 프로그래밍 시 구조적으로 반복됨.
템플릿 메서드 패턴과 전략 패턴을 활용한 해결
스프링은 두 패턴을 엮은 JdbcTemplate 클래스를 제공함.
이 클래스를 사용하면 중복된 코드를 줄일 수 있음.
java List<Member> results = jdbcTemplate.query( "select * from MEMBER where EMAIL = ?", 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")); member.setId(rs.getLong("ID")); return member; } }, email ); return results.isEmpty() ? null : results.get(0);
자바 8의 람다를 사용하면 코드를 더 줄일 수 있음.
java List<Member> results = jdbcTemplate.query( "select * from MEMBER where EMAIL = ?", (ResultSet rs, int rowNum) -> { Member member = new Member(rs.getString("EMAIL"), rs.getString("PASSWORD"), rs.getString("NAME"), rs.getTimestamp("REGDATE")); member.setId(rs.getLong("ID")); return member; }, email ); return results.isEmpty() ? null : results.get(0);
스프링이 제공하는 또 다른 장점
트랜잭션 관리가 쉬움.
JDBC API로 트랜잭션을 처리하려면 Connection의 setAutoCommit(false)을 이용해서
자동 커밋을 비활성화하고 commit()과 rollback() 메서드를 이용해서 트랜잭션을 커밋하거나 롤백해야 함.
java public void insert(Member member) { Connection conn = null; PreparedStatement pstmt = null; try { conn = DriverManager.getConnection( "jdbc:mysql://localhost/spring4fs?characterEncoding=utf8", "spring4", "spring4" ); conn.setAutoCommit(false); // ...(DB 쿼리 실행) conn.commit(); } catch (SQLException ex) { if (conn != null) try { conn.rollback(); } catch (SQLException e) {} } finally { if (pstmt != null) try { pstmt.close(); } catch (SQLException e) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} } }
스프링을 사용하면 트랜잭션을 적용하고 싶은 메서드에 @Transactional 애노테이션을 붙이기만 하면 됨.
java @Transactional public void insert(Member member) { // 핵심 코드 }
커밋과 롤백 처리는 스프링이 알아서 처리하므로 코드를 작성하는 사람은 트랜잭션 처리를 제외한 핵심 코드만 집중해서 작성하면 됨.
이 장에서 사용할 예제 코드 대부분은 앞서 작성했던 3장에서 가져올 것임. 3장 예제를 작성하지 않았다면 책에서 제공하는 소스 코드를 다운로드한 뒤에 따라 하면 됨.
8장을 위한 메이븐 프로젝트를 생성함. sp5-chap08 폴더를 생성함. sp5-chap08의 하위 폴더로 src\main\java 폴더를 생성함. sp5-chap08 폴더에 pom.xml 파일을 생성함.
``java <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<groupId>sp5</groupId>
<artifactId>sp5-chap08</artifactId>
<version>0.0.1-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
<version>8.5.27</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.45</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>utf-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
``
MySQL에 root 사용자로 연결한 뒤 아래 쿼리를 실행하여 DB 사용자, 데이터베이스, 테이블을 생성함.
java create user 'spring5'@'localhost' identified by 'spring5'; create database spring5fs character set=utf8; grant all privileges on spring5fs.* to 'spring5'@'localhost'; create table spring5fs.MEMBER ( ID int auto_increment primary key, EMAIL varchar(255), PASSWORD varchar(100), NAME varchar(100), REGDATE datetime, unique key (EMAIL) ) engine=InnoDB character set=utf8;
JDBC API는 DriverManager 외에 DataSource를 이용해서 DB 연결을 구하는 방법을 정의하고 있음. DataSource를 사용하면 다음 방식으로 Connection을 구할 수 있음.
java Connection conn = null; try { // dataSource는 생성자나 설정 메서드를 이용해서 주입받음 conn = dataSource.getConnection(); // ... }
스프링이 제공하는 DB 연동 기능은 DataSource를 사용해서 DB Connection을 구함. DB 연동에 사용할 DataSource를 스프링 빈으로 등록하고 DB 연동 기능을 구현한 빈 객체는 DataSource를 주입받아 사용함.
Tomcat JDBC 모듈의 org.apache.tomcat.jdbc.pool.DataSource 클래스는 커넥션 풀 기능을 제공하는 DataSource 구현 클래스임. 주요 설정 메서드는 다음과 같음.
설정 메서드 설명
java @Bean(destroyMethod = "close") public DataSource dataSource() { DataSource ds = new DataSource(); ds.setDriverClassName("com.mysql.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8"); ds.setUsername("spring5"); ds.setPassword("spring5"); ds.setInitialSize(2); ds.setMaxActive(10); return ds; }
JDBC 프로그래밍은 보통 이런식으로 코드를 짠다고 합니다.
java Member member; Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { // 반복되는 코드 conn = DriverManager.getConnection("jdbc:mysql://localhost/spring5fs", "spring5", "spring5"); pstmt = conn.prepareStatement("select * from MEMBER where EMAIL = ?"); pstmt.setString(1, email); rs = pstmt.executeQuery(); if (rs.next()) { member = new Member(rs.getString("EMAIL"), rs.getString("PASSWORD"), rs.getString("NAME"), rs.getTimestamp("REGDATE")); member.setId(rs.getLong("ID")); // 핵심 코드 return member; } else { return null; } } catch (SQLException e) { e.printStackTrace(); throw e; } finally { // 반복되는 코드 if (rs != null) try { rs.close(); } catch (SQLException e) {} if (pstmt != null) try { pstmt.close(); } catch (SQLException e1) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} }
JDBC API를 이용하면 DB 연동에 필요한 Connection을 구한 다음 쿼리를 실행하기 위한 PreparedStatement를 생성함. 쿼리를 실행한 뒤에는 finally 블록에서 ResultSet, PreparedStatement, Connection을 닫음. 점선으로 표시한 부분은 데이터 처리와 상관없는 코드지만, JDBC 프로그래밍 시 구조적으로 반복됨. 템플릿 메서드 패턴과 전략 패턴을 활용한 해결 스프링은 두 패턴을 엮은 JdbcTemplate 클래스를 제공함. 이 클래스를 사용하면 중복된 코드를 줄일 수 있음.
java List<Member> results = jdbcTemplate.query( "select * from MEMBER where EMAIL = ?", 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")); member.setId(rs.getLong("ID")); return member; } }, email ); return results.isEmpty() ? null : results.get(0);
자바 8의 람다를 사용하면 코드를 더 줄일 수 있음.
java List<Member> results = jdbcTemplate.query( "select * from MEMBER where EMAIL = ?", (ResultSet rs, int rowNum) -> { Member member = new Member(rs.getString("EMAIL"), rs.getString("PASSWORD"), rs.getString("NAME"), rs.getTimestamp("REGDATE")); member.setId(rs.getLong("ID")); return member; }, email ); return results.isEmpty() ? null : results.get(0);
JDBC API로 트랜잭션을 처리하려면 Connection의 setAutoCommit(false)을 이용해서 자동 커밋을 비활성화하고 commit()과 rollback() 메서드를 이용해서 트랜잭션을 커밋하거나 롤백해야 함.
java public void insert(Member member) { Connection conn = null; PreparedStatement pstmt = null; try { conn = DriverManager.getConnection( "jdbc:mysql://localhost/spring4fs?characterEncoding=utf8", "spring4", "spring4" ); conn.setAutoCommit(false); // ...(DB 쿼리 실행) conn.commit(); } catch (SQLException ex) { if (conn != null) try { conn.rollback(); } catch (SQLException e) {} } finally { if (pstmt != null) try { pstmt.close(); } catch (SQLException e) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} } }
스프링을 사용하면 트랜잭션을 적용하고 싶은 메서드에 @Transactional 애노테이션을 붙이기만 하면 됨.
java @Transactional public void insert(Member member) { // 핵심 코드 }
커밋과 롤백 처리는 스프링이 알아서 처리하므로 코드를 작성하는 사람은 트랜잭션 처리를 제외한 핵심 코드만 집중해서 작성하면 됨.
이 장에서 사용할 예제 코드 대부분은 앞서 작성했던 3장에서 가져올 것임. 3장 예제를 작성하지 않았다면 책에서 제공하는 소스 코드를 다운로드한 뒤에 따라 하면 됨.
8장에서 예시를 위한 메이븐 프로젝트를 생성함. sp5-chap08 폴더를 생성함. sp5-chap08의 하위 폴더로 src\main\java 폴더를 생성함. sp5-chap08 폴더에 pom.xml 파일을 생성함.
``xml <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<groupId>sp5</groupId>
<artifactId>sp5-chap08</artifactId>
<version>0.0.1-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
<version>5.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
<version>8.5.27</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.45</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>utf-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
``
DBMS로 MySQL을 사용함. MySQL에 root 사용자로 연결한 뒤 아래 쿼리를 실행하여 DB 사용자, 데이터베이스, 테이블을 생성함.
sql create user 'spring5'@'localhost' identified by 'spring5'; create database spring5fs character set=utf8; grant all privileges on spring5fs.* to 'spring5'@'localhost'; create table spring5fs.MEMBER ( ID int auto_increment primary key, EMAIL varchar(255), PASSWORD varchar(100), NAME varchar(100), REGDATE datetime, unique key (EMAIL) ) engine=InnoDB character set=utf8;
JDBC API는 DriverManager 외에 DataSource를 이용해서 DB 연결을 구하는 방법을 정의하고 있음. DataSource를 사용하면 다음 방식으로 Connection을 구할 수 있음.
java Connection conn = null; try { // dataSource는 생성자나 설정 메서드를 이용해서 주입받음 conn = dataSource.getConnection(); // ... }
스프링이 제공하는 DB 연동 기능은 DataSource를 사용해서 DB Connection을 구함. DB 연동에 사용할 DataSource를 스프링 빈으로 등록하고 DB 연동 기능을 구현한 빈 객체는 DataSource를 주입받아 사용함.
java @Bean(destroyMethod = "close") public DataSource dataSource() { DataSource ds = new DataSource(); ds.setDriverClassName("com.mysql.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8"); ds.setUsername("spring5"); ds.setPassword("spring5"); ds.setInitialSize(2); ds.setMaxActive(10); return ds; }
Tomcat JDBC 모듈의 org.apache.tomcat.jdbc.pool.DataSource 클래스는 커넥션 풀 기능을 제공하는 DataSource 구현 클래스임.
스프링을 사용하면 DataSource나 Connection, Statement, ResultSet을 직접 사용하지 않고 JdbcTemplate을 이용해서 편리하게 쿼리를 실행할 수 있음.
가장 먼저 해야 할 작업은 JdbcTemplate 객체를 생성하는 것임. 코드는 아래와 같음.
``java package spring;
import javax.sql.DataSource; import org.springframework.jdbc.core.JdbcTemplate;
public class MemberDao { private JdbcTemplate jdbcTemplate;
public MemberDao(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
} ``
JdbcTemplate 객체를 생성하려면 DataSource를 생성자에 전달하면 됨.
이를 위해 DataSource를 주입받도록 MemberDao 클래스의 생성자를 구현함.
다음과 같이 설정 메서드 방식을 이용해서 DataSource를 주입받고 JdbcTemplate을 생성할 수도 있음.
public class MemberDao {
private JdbcTemplate jdbcTemplate;
public void setDataSource(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
}
``
스프링 설정에 MemberDao 빈 설정을 추가함.
``java
@Configuration
public class AppCtx {
@Bean(destroyMethod = "close")
public DataSource dataSource() {
DataSource ds = new DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8");
ds.setUsername("spring5");
ds.setPassword("spring5");
ds.setInitialSize(2);
ds.setMaxActive(10);
return ds;
}
@Bean
public MemberDao memberDao() {
return new MemberDao(dataSource());
}
}
``
## 4.2 JdbcTemplate을 이용한 조회 쿼리 실행
JdbcTemplate 클래스는 SELECT 쿼리 실행을 위한 query() 메서드를 제공함. 자주 사용되는 쿼리 메서드는 다음과 같음.
List<T> query(String sql, RowMapper<T> rowMapper)
List<T> query(String sql, Object[] args, RowMapper<T> rowMapper)
List<T> query(String sql, RowMapper<T> rowMapper, Object... args)
query() 메서드는 sql 파라미터로 전달받은 쿼리를 실행하고 RowMapper를 이용해서 ResultSet의 결과를 자바 객체로 변환함.
``java
package spring;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
public class MemberDao {
private JdbcTemplate jdbcTemplate;
public MemberDao(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
public Member selectByEmail(String email) {
List<Member> results = jdbcTemplate.query(
"select * from MEMBER where EMAIL = ?",
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;
}
},
email
);
return results.isEmpty() ? null : results.get(0);
}
}
``
JdbcTemplate의 query() 메서드를 이용해서 쿼리를 실행함.
쿼리는 인덱스 파라미터(물음표)를 포함하고 있음.
인덱스 파라미터에 들어갈 값은 query() 메서드의 세 번째 파라미터로 전달함.
람다를 사용하면 임의 클래스를 사용하는 것보다 간결함.
``java
List<Member> results = jdbcTemplate.query(
"select * from MEMBER where EMAIL = ?",
(ResultSet rs, int rowNum) -> {
Member member = new Member(
rs.getString("EMAIL"),
rs.getString("PASSWORD"),
rs.getString("NAME"),
rs.getTimestamp("REGDATE").toLocalDateTime());
member.setId(rs.getLong("ID"));
return member;
},
email
);
``
동일한 RowMapper 구현을 여러 곳에서 사용한다면 RowMapper 인터페이스를 구현한 클래스를 만들어서 코드 중복을 막을 수 있음.
``java
public class MemberRowMapper implements 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;
}
}
List<Member> results = jdbcTemplate.query(
"select * from MEMBER where EMAIL = ? and NAME = ?",
new MemberRowMapper(),
email, name
);
``
selectByEmail() 메서드는 지정한 이메일에 해당하는 MEMBER 데이터가 존재하면 해당 Member 객체를 리턴하고, 그렇지 않으면 null을 리턴함.
## 4.3 결과가 1행인 경우 사용할 수 있는 queryForObject() 메서드
다음은 MEMBER 테이블의 전체 행 개수를 구하는 코드임.
query() 메서드를 사용했음.
``java
public int count() {
List<Integer> results = jdbcTemplate.query(
"select count(*) from MEMBER",
new RowMapper<Integer>() {
@Override
public Integer mapRow(ResultSet rs, int rowNum) throws SQLException {
return rs.getInt(1);
}
}
);
return results.get(0);
}
``
count(*) 쿼리는 결과가 한 행 뿐이므로 쿼리 결과를 List로 받기보다는 Integer와 같은 정수 타입으로 받으면 더 편리할 것임.
이를 위한 메서드가 queryForObject()임.
``java
public int count() {
Integer count = jdbcTemplate.queryForObject(
"select count(*) from MEMBER",
Integer.class
);
return count;
}
``
이렇게 하면 count(*) 쿼리 실행 코드를 더 간단하게 구현할 수 있음.
## 4.3 결과가 1행인 경우 사용할 수 있는 queryForObject() 메서드
queryForObject() 메서드는 쿼리 실행 결과 행이 한 개인 경우에 사용할 수 있는 메서드이다.
queryForObject() 메서드의 두 번째 파라미터는 칼럼을 읽어올 때 사용할 타입을 지정한다.
예를 들어 평균을 구할 때는 다음과 같이 Double 타입을 사용할 수 있다.
``java
double avg = jdbcTemplate.queryForObject(
"select avg(height) from FURNITURE where TYPE=? and STATUS=?",
Double.class,
100, "S"
);
``
이 코드에서 볼 수 있듯이 queryForObject() 메서드도 쿼리에 인덱스 파라미터(물음표)를 사용할 수 있음.
인덱스 파라미터가 존재하면 파라미터의 값을 가변 인자로 전달함.
실행 결과 칼럼이 두 개 이상이면 RowMapper를 파라미터로 전달해서 결과를 생성할 수 있다.
예를 들어 특정 ID를 갖는 회원 데이터를 queryForObject()로 읽어오고 싶다면 다음 코드를 사용할 수 있음.
``java
Member member = jdbcTemplate.queryForObject(
"select * from MEMBER where ID = ?",
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;
}
},
100
);
``
queryForObject() 메서드를 사용한 위 코드와 기존의 query() 메서드를 사용한 코드의 차이점은 리턴 타입이 List가 아니라
RowMapper로 변환해주는 타입(위 코드에서는 Member)이라는 점이다.
주요 queryForObject() 메서드는 다음과 같다.
T queryForObject(String sql, Class<T> requiredType)
T queryForObject(String sql, Class<T> requiredType, Object... args)
T queryForObject(String sql, RowMapper<T> rowMapper)
T queryForObject(String sql, RowMapper<T> rowMapper, Object... args)
queryForObject() 메서드를 사용하려면 쿼리 실행 결과는 반드시 한 행이어야 한다. 만약 쿼리 실행 결과 행이 없거나 두 개 이상이면 IncorrectResultSizeDataAccessException이 발생한다. 행의 개수가 0이면 하위 클래스인 EmptyResultDataAccessException이 발생한다. 따라서 결과 행이 정확히 한 개가 아니면 queryForObject() 메서드 대신 query() 메서드를 사용해야 한다.
## 4.4 JdbcTemplate을 이용한 변경 쿼리 실행
INSERT, UPDATE, DELETE 쿼리는 update() 메서드를 사용한다.
int update(String sql)
int update(String sql, Object... args)
update() 메서드는 쿼리 실행 결과로 변경된 행의 개수를 리턴한다. update() 메서드의 사용 예는 다음과 같다.
``java
package spring;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
public class MemberDao {
private JdbcTemplate jdbcTemplate;
public MemberDao(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
public void update(Member member) {
jdbcTemplate.update(
"update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?",
member.getName(), member.getPassword(), member.getEmail()
);
}
}
``
## 4.5 PreparedStatementCreator를 이용한 쿼리 실행
쿼리에서 사용할 값을 인자로 전달하는 방법 외에도 PreparedStatementCreator를 인자로 받아 직접 PreparedStatement를 생성하고 설정해야 할 때가 있다.
PreparedStatementCreator 인터페이스는 아래와 같다.
``java
package org.springframework.jdbc.core;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public interface PreparedStatementCreator {
PreparedStatement createPreparedStatement(Connection con) throws SQLException;
}
``
PreparedStatementCreator 인터페이스의 createPreparedStatement() 메서드는 Connection 타입의 파라미터를 가진다.
PreparedStatementCreator를 구현한 클래스는 createPreparedStatement() 메서드의 파라미터로 전달받는 Connection을 이용해서
PreparedStatement 객체를 생성하고 인덱스 파라미터를 알맞게 설정한 뒤에 리턴하면 된다.
PreparedStatementCreator 인터페이스 예제 코드임
``java
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 (?, ?, ?, ?)"
);
// 인덱스 파라미터의 값 설정
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;
}
});
``
JdbcTemplate 클래스가 제공하는 메서드 중에서 PreparedStatementCreator 인터페이스를 파라미터로 갖는 메서드는 다음과 같다.
List<T> query(PreparedStatementCreator psc, RowMapper<T> rowMapper)
int update(PreparedStatementCreator psc)
int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder)
위 목록에서 세 번째 메서드는 자동 생성되는 키값을 구할 때 사용한다.
## 4.6 INSERT 쿼리 실행 시 KeyHolder를 이용해서 자동 생성 키값 구하기
MySQL AUTO_INCREMENT 칼럼은 행이 추가되면 자동으로 값이 할당되는 칼럼으로서 주로 기본 키 칼럼에 사용된다.
예를 들어 MEMBER 테이블을 생성할 때 사용한 쿼리도 다음 코드처럼 기본 키 칼럼을 AUTO_INCREMENT 칼럼으로 지정했다.
``sql
create table spring5fs.MEMBER (
ID int auto_increment primary key,
EMAIL varchar(255),
PASSWORD varchar(100),
NAME varchar(100),
REGDATE datetime,
unique key (EMAIL)
) engine=InnoDB character set=utf8;
``
AUTO_INCREMENT와 같은 자동 증가 칼럼을 가진 테이블에 값을 삽입하면 해당 칼럼의 값이 자동으로 생성된다.
따라서 아래 코드처럼 INSERT 쿼리에 자동 증가 칼럼에 해당하는 값은 지정하지 않음.
``java
jdbcTemplate.update(
"insert into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) values (?, ?, ?, ?)",
member.getEmail(), member.getPassword(), member.getName(),
new Timestamp(member.getRegisterDate().getTime())
);
``
쿼리 실행 후에 생성된 키값을 알고 싶다면 어떻게 해야 할까?
update() 메서드는 변경된 행의 개수를 리턴할 뿐 생성된 키값을 리턴하지는 않는다.
JdbcTemplate은 자동으로 생성된 키값을 구할 수 있는 방법을 제공하고 있는데,
그것은 바로 KeyHolder를 사용하는 것이다.
KeyHolder를 사용하면 다음과 같이 MemberDao의 insert() 메서드에서 삽입하는 Member 객체의 ID 값을 구할 수 있다.
``java
package spring;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
public class MemberDao {
private JdbcTemplate jdbcTemplate;
public MemberDao(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
public void insert(final Member member) {
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(new PreparedStatementCreator() {
@Override
public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
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()));
return pstmt;
}
}, keyHolder);
Number keyValue = keyHolder.getKey();
member.setId(keyValue.longValue());
}
}
``
GeneratedKeyHolder 객체를 생성한다.
이 클래스는 자동 생성된 키값을 구해주는 KeyHolder 구현 클래스이다.
update() 메서드는 PreparedStatementCreator 객체와 KeyHolder 객체를 파라미터로 갖는다.
PreparedStatementCreator 임의 클래스를 이용해서 PreparedStatement 객체를 직접 생성한다.
여기서 주목할 점은 Connection의 prepareStatement() 메서드를 이용해서
PreparedStatement 객체를 생성하는데 두 번째 파라미터로 String 배열인 {"ID"}를 준다.
이 두 번째 파라미터는 자동 생성되는 키 칼럼 목록을 지정할 때 사용한다.
JdbcTemplate.update() 메서드의 두 번째 파라미터로 KeyHolder 객체를 전달한다.
아래는 람다식을 사용한 예제 이다.
``java
jdbcTemplate.update((Connection con) -> {
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, new Timestamp(member.getRegisterDate().getTime()));
return pstmt;
}, keyHolder);
``
자바 8을 더욱 코드를 간략화 할 수 있다.
이 책에서는 자바8 사용을 권장하는듯 하다.
## 5. MemberDao 테스트
위 코드를 보면 JdbcTemplate을 이용해서 MemberDao 클래스를 완성했다.
메인 클래스를 작성해서 MemberDao가 정상적으로 동작하는지 확인해보자.
``java
package config;
import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import spring.MemberDao;
@Configuration
public class AppCtx {
@Bean(destroyMethod = "close")
public DataSource dataSource() {
DataSource ds = new DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8");
ds.setUsername("spring5");
ds.setPassword("spring5");
ds.setInitialSize(2);
ds.setMaxActive(10);
ds.setTestWhileIdle(true);
ds.setMinEvictableIdleTimeMillis(60000 * 3);
ds.setTimeBetweenEvictionRunsMillis(10000);
return ds;
}
@Bean
public MemberDao memberDao() {
return new MemberDao(dataSource());
}
}
``
``java
package main;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import config.AppCtx;
import spring.Member;
import spring.MemberDao;
public class MainForMemberDao {
private static MemberDao memberDao;
public static void main(String[] args) {
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppCtx.class);
memberDao = ctx.getBean(MemberDao.class);
selectAll();
updateMember();
insertMember();
ctx.close();
}
private static void selectAll() {
System.out.println("----- selectAll");
int total = memberDao.count();
System.out.println("Total: " + total);
List<Member> members = memberDao.selectAll();
for (Member m : members) {
System.out.println(m.getId() + ": " + m.getEmail() + ": " + m.getName());
}
}
private static void updateMember() {
System.out.println("----- updateMember");
Member member = memberDao.selectByEmail("madvirus@madvirus.net");
if (member != null) {
String oldPw = member.getPassword();
String newPw = Double.toHexString(Math.random());
member.changePassword(oldPw, newPw);
memberDao.update(member);
System.out.println("Password changed from " + oldPw + " to " + newPw);
}
}
private static void insertMember() {
System.out.println("----- insertMember");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMddHHmmss");
String prefix = formatter.format(LocalDateTime.now());
Member member = new Member(prefix + "@test.com", prefix, prefix, LocalDateTime.now());
memberDao.insert(member);
System.out.println(member.getId() + " : " + member.getEmail());
}
}
``
selectAll() 메서드는 memberDao.count() 메서드를 실행해서 전체 행의 개수를 구하고,
memberDao.selectAll() 메서드를 이용해서 전체 Member 데이터를 구한 뒤 콘솔에 출력함.
updateMember() 메서드는 EMAIL 칼럼 값이 "madvirus@madvirus.net"인 Member 객체를 구한 뒤 임의의 새로운 암호로 변경함.
insertMember() 메서드는 현재 시간을 기반으로 새로운 Member를 생성하고 삽입함.
MainForMemberDao 클래스 실행 MainForMemberDao 클래스를 실행할 때 DB 연결 정보가 올바르지 않으면 다음과 같은 예외가 발생할 수 있음.
plaintext java.sql.SQLException: Access denied for user 'spring5'@'localhost' (using password: YES) at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:965) ... 생략 ... at main.MainForMemberDao.main(MainForMemberDao.java:22) Exception in thread "main" org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection; nested exception is java.sql.SQLException: Access denied for user 'spring5'@'localhost' (using password: YES) at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:81) ... 생략 ... at main.MainForMemberDao.main(MainForMemberDao.java:22) Caused by: java.sql.SQLException: Access denied for user 'spring5'@'localhost' (using password: YES) at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:965)
이 에러 메시지는 MySQL 서버에 연결할 권한이 없는 경우 발생함. 예를 들어, MySQL DB에 생성한 'springfs' DB에 접근할 때 사용한 'spring5' 계정의 암호를 잘못 입력한 경우 발생. DB 연결 정보는 DataSource에 있으므로 DataSource를 잘못 설정하면 연결을 구할 수 없다는 예외(CannotGetJdbcConnectionException)가 발생함.
java @Bean(destroyMethod = "close") public DataSource dataSource() { DataSource ds = new DataSource(); ds.setDriverClassName("com.mysql.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8"); ds.setUsername("spring5"); ds.setPassword("badpw"); // 잘못된 비밀번호 return ds; }
plaintext Exception in thread "main" org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection; nested exception is com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server. at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(DataSourceUtils.java:81) ... 생략 ... at main.MainForMemberDao.main(MainForMemberDao.java:22) Caused by: com.mysql.jdbc.exceptions.jdbc4.CommunicationsException: Communications link failure
로컬에 설치된 DBMS를 이용해서 테스트할 때 이런 에러가 발생하는 이유는 주로 DBMS를 실행하지 않았기 때문임.
java jdbcTemplate.update("update MEMBER set NAME = ?, PASSWORD=? where" + "EMAIL = ?", member.getName(), member.getPassword(), member.getEmail());
잘못된 부분이 없는 것 같지만, 실제 사용하는 쿼리는 다음과 같음:
sql update MEMBER set NAME = ?, PASSWORD=?whereEMAIL = ?
where 뒤에 공백문자가 없음. 문자열을 연결할 때 줄이 바뀌는 부분에서 실수로 공백문자를 누락하면 다음과 유사한 예외가 발생함:
``plaintext Exception in thread "main" org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback: bad SQL grammar [update MEMBER set NAME = ?, PASSWORD=?where EMAIL = ?]; nested exception is com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'whereEMAIL='madvirus@madvirus.net' at line 1 ... 생략 ... at main.MainForMemberDao.main(MainForMemberDao.java:23) Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'where EMAIL = 'madvirus@madvirus.net' at line 1
DB 연동 과정에서 자주 발생하는 세 가지 종류의 예외를 살펴봤음. 이 외에도 에러 메시지를 보면 문제 발생 원인을 찾는 데 도움이 됨. DB 연동 코드를 실행하는 과정에서 예외가 발생하면 당황하지 말고 예외 메시지를 차분히 살펴보는 습관을 들이자.
SQL 문법이 잘못됐을 때 발생한 메시지를 보면 예외 클래스가 org.springframework.jdbc 패키지에 속한 BadSqlGrammarException 클래스임을 알 수 있음.
에러 메시지를 보면 BadSqlGrammarException이 발생한 이유는 MySQLSyntaxErrorException이 발생했기 때문임.
plaintext org.springframework.jdbc.BadSqlGrammarException: Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLSyntaxErrorException:
위와 같은 예외가 발생할 때 사용한 코드는 다음과 같음:
``java jdbcTemplate.update("update MEMBER set NAME=?, PASSWORD=? where" + "EMAIL = ?", member.getName(), member.getPassword(), member.getEmail());
BadSqlGrammarException을 발생한 메서드는 JdbcTemplate 클래스의 update() 메서드임. JdbcTemplate의 update() 메서드는 DB 연동을 위해 JDBC API를 사용하는데,
JDBC API를 사용하는 과정에서 SQLException이 발생하면 이 예외를 알맞은 DataAccessException으로 변환해서 발생함.
따라서 다음과 유사한 방식으로 예외를 변환해서 재발생함:
java try { // JDBC 사용 코드 } catch (SQLException ex) { throw convertSqlToDataException(ex); }
예를 들어사, MySQL용 JDBC 드라이버는 SQL 문법이 잘못된 경우 SQLException을 상속받은 MySQLSyntaxErrorException을 발생시키는데 JdbcTemplate은 이 예외를 DataAccessException을 상속받은 BadSqlGrammarException으로 변환함.
DataAccessException은 스프링이 제공하는 예외 타입으로 데이터 연결에 문제가 있을 때 스프링 모듈이 발생시킴.
``java try { // JDBC 연동 코드 } catch (SQLException ex) { // 예외 처리 }
try { // 하이버네이트 연동 코드 } catch (HibernateException ex) { // 예외 처리 }
try { // JPA 연동 코드 } catch (PersistenceException ex) { // 예외 처리 }
// 스프링 try { // DB 연동 코드 } catch (DataAccessException ex) { // 예외 처리 } ``
스프링의 연동 기능을 사용하면 예외를 동일한 방식으로 처리할 수 있음.
BadSqlGrammarException은 DataAccessException을 상속받은 하위 타입임.
BadSqlGrammarException은 실행할 쿼리가 올바르지 않은 경우에 사용됨.
스프링은 이 외에도 DuplicateKeyException, QueryTimeoutException 등 DataAccessException을 상속한 다양한 예외 클래스를 제공함.
각 예외 클래스의 이름은 문제가 발생한 원인을 의미함. 따라서 예외가 발생한 경우 예외 타입의 이름만으로도 어느 정도 문제 원인을 유추할 수 있음.
DataAccessException은 RuntimeException임. JDBC를 직접 이용하면 다음과 같이 try~catch를 이용해서 예외를 처리해야한다.
java // JDBC를 직접 사용하면 SQLException을 반드시 알맞게 처리해주어야 함 try { pstmt = conn.prepareStatement(someQuery); // ... } catch (SQLException ex) { // 예외 처리 }
jdbcTemplate.update(someQuery, param1);
이메일이 유효한지 여부를 판단하기 위해 실제로 검증 목적의 메일을 발송하는 서비스를 사용한 경험이 있을 것임.
이들 서비스는 이메일에 함께 보낸 링크를 클릭하면 최종적으로 이메일이 유효하다고 판단하고 해당 이메일을 사용할 수 있도록 함.
이렇게 이메일 인증 시점에 테이블의 데이터를 변경하는 기능은 다음 코드처럼 회원 정보에서 이메일을 수정하고 인증 상태를 변경하는 두 쿼리를 실행할 것임.
``java jdbcTemplate.update("update MEMBER set EMAIL = ?", email); jdbcTemplate.update("insert into EMAIL_AUTH values (?, 'T')", email); 그러나 첫 번째 쿼리를 실행한 후 두 번째 쿼리를 실행하는 시점에 문제가 발생하면 어떻게 해야할까
예를 들어, 코드를 잘못 수정/배포해서 두 번째 쿼리에서 사용할 테이블 이름이 잘못되었을 수도 있고,
중복된 값이 존재해서 INSERT 쿼리를 실행하는데 실패할 수도 있음.
두 번째 쿼리가 실패했음에도 불구하고 첫 번째 쿼리 실행 결과가 DB에 반영되면
이후 해당 사용자의 이메일 주소는 인증되지 않은 채로 계속 남아 있게 됨.
따라서 두 번째 쿼리 실행에 실패하면 첫 번째 쿼리 실행 결과도 취소해야 올바른 상태를 유지할 수 있음.
이렇게 두 개 이상의 쿼리를 한 작업으로 실행해야 할 때 사용하는 것이 트랜잭션임.
트랜잭션은 여러 쿼리를 논리적으로 하나의 작업으로 묶어줌.
한 트랜잭션으로 묶인 쿼리 중 하나라도 실패하면 전체 쿼리를 실패로 간주하고 실패 이전에 실행한 쿼리를 취소함 . 쿼리 실행 결과를 취소하고 DB를 기존 상태로 되돌리는 것을 롤백(rollback)이라고 부름.
반면에 트랜잭션으로 묶인 모든 쿼리가 성공해서 쿼리 결과를 DB에 실제로 반영하는 것을 커밋(commit)이라고 함.
트랜잭션을 시작하면 트랜잭션을 커밋하거나 롤백할 때까지 실행한 쿼리들이 하나의 작업 단위가 됨. JDBC는 Connection의 setAutoCommit(false)를 이용해서 트랜잭션을 시작하고 commit()과 rollback()을 이용해서 트랜잭션을 반영(커밋)하거나 취소(롤백)함.
``java Connection conn = null; try { conn = DriverManager.getConnection(jdbcUrl, user, pw); conn.setAutoCommit(false); // 트랜잭션 범위 시작
// 쿼리 실행
conn.commit(); // 트랜잭션 범위 종료: 커밋
} catch (SQLException ex) { if (conn != null) { try { conn.rollback(); // 트랜잭션 범위 종료: 롤백 } catch (SQLException e) { } } } finally { if (conn != null) { try { conn.close(); } catch (SQLException e) { } } } ``
스프링이 제공하는 @Transactional 애노테이션을 사용하면 트랜잭션 범위를 매우 쉽게 지정할 수 있음. 다음과 같이 트랜잭션 범위에서 실행하고 싶은 메서드에 @Transactional 애노테이션만 붙이면 됨.
``java import org.springframework.transaction.annotation.Transactional;
public class ChangePasswordService { private MemberDao memberDao;
@Transactional
public void changePassword(String email, String oldPwd, String newPwd) {
Member member = memberDao.selectByEmail(email);
if (member == null) {
throw new MemberNotFoundException();
}
member.changePassword(oldPwd, newPwd);
memberDao.update(member);
}
public void setMemberDao(MemberDao memberDao) {
this.memberDao = memberDao;
}
} ``
@Transactional 애노테이션이 제대로 동작하려면 다음의 두 가지 내용을 스프링 설정에 추가해야 함:
플랫폼 트랜잭션 매니저(PlatformTransactionManager) 빈 설정=>
@Transactional 애노테이션 활성화 설정
``java import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration @EnableTransactionManagement public class AppCtx { @Bean(destroyMethod = "close") public DataSource dataSource() { DataSource ds = new DataSource(); ds.setDriverClassName("com.mysql.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8"); ds.setUsername("spring5"); ds.setPassword("spring5"); return ds; }
@Bean
public PlatformTransactionManager transactionManager() {
DataSourceTransactionManager tm = new DataSourceTransactionManager();
tm.setDataSource(dataSource());
return tm;
}
@Bean
public MemberDao memberDao() {
return new MemberDao(dataSource());
}
} ``
PlatformTransactionManager는 스프링이 제공하는 트랜잭션 매니저 인터페이스임.
스프링은 구현기술에 상관없이 동일한 방식으로 트랜잭션을 처리하기 위해 이 인터페이스를 사용함.
JDBC는 DataSourceTransactionManager 클래스를 PlatformTransactionManager로 사용함.
@EnableTransactionManagement 애노테이션은 @Transactional 애노테이션이 붙은 메서드를 트랜잭션 범위에서 실행하는 기능을 활성화함.
등록된 PlatformTransactionManager 빈을 사용해서 트랜잭션을 적용함.
트랜잭션 처리를 위한 설정을 완료하면 트랜잭션 범위에서 실행하고 싶은 스프링 빈 객체의 메서드에 @Transactional 애노테이션을 붙이면 됨. 예를 들어, ChangePasswordService 클래스의 changePassword() 메서드를 트랜잭션 범위에서 실행하고 싶으면 다음과 같이 changePassword() 메서드에 @Transactional 애노테이션을 붙이면 됨.
``java package spring;
import org.springframework.transaction.annotation.Transactional;
public class ChangePasswordService { private MemberDao memberDao;
@Transactional
public void changePassword(String email, String oldPwd, String newPwd) {
Member member = memberDao.selectByEmail(email);
if (member == null) {
throw new MemberNotFoundException();
}
member.changePassword(oldPwd, newPwd);
memberDao.update(member);
}
public void setMemberDao(MemberDao memberDao) {
this.memberDao = memberDao;
}
} ``
AppCtx 설정 먼저, AppCtx 설정 클래스에 트랜잭션 관련 설정과 ChangePasswordService 클래스를 빈으로 추가한다.
``java package config;
import org.apache.tomcat.jdbc.pool.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement;
import spring.ChangePasswordService; import spring.MemberDao;
@Configuration @EnableTransactionManagement public class AppCtx {
@Bean(destroyMethod = "close")
public DataSource dataSource() {
DataSource ds = new DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8");
ds.setUsername("spring5");
ds.setPassword("spring5");
ds.setInitialSize(2);
ds.setMaxActive(10);
ds.setTestWhileIdle(true);
ds.setMinEvictableIdleTimeMillis(60000 * 3);
ds.setTimeBetweenEvictionRunsMillis(10 * 1000);
return ds;
}
@Bean
public PlatformTransactionManager transactionManager() {
DataSourceTransactionManager tm = new DataSourceTransactionManager();
tm.setDataSource(dataSource());
return tm;
}
@Bean
public MemberDao memberDao() {
return new MemberDao(dataSource());
}
@Bean
public ChangePasswordService changePwdSvc() {
ChangePasswordService pwdSvc = new ChangePasswordService();
pwdSvc.setMemberDao(memberDao());
return pwdSvc;
}
} ``
MainForCPS 클래스 트랜잭션이 적용된 ChangePasswordService를 이용해 암호 변경 기능을 실행하는 메인 클래스를 작성함.
``java package main;
import org.springframework.context.annotation.AnnotationConfigApplicationContext; import config.AppCtx; import spring.ChangePasswordService; import spring.MemberNotFoundException; import spring.WrongIdPasswordException;
public class MainForCPS { public static void main(String[] args) { AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(AppCtx.class); ChangePasswordService cps = ctx.getBean("changePwdSvc", ChangePasswordService.class); try { cps.changePassword("madvirus@madvirus.net", "1234", "1111"); System.out.println("암호를 변경했습니다."); } catch (MemberNotFoundException e) { System.out.println("회원 데이터가 존재하지 않습니다."); } catch (WrongIdPasswordException e) { System.out.println("암호가 올바르지 않습니다."); } ctx.close(); } } ``
트랜잭션 관련 로그 메시지를 출력하기 위해 Logback 설정을 추가한다. pom.xml 파일에 Logback 의존성을 추가한다.
``xml
``
src/main/resources 폴더에 logback.xml 파일을 작성.
``xml <?xml version="1.0" encoding="UTF-8"?>
``
MainForCPS 클래스를 실행하면 트랜잭션이 시작되고 커밋되며, 로그 메시지가 콘솔에 출력된다.
plaintext 2018-02-12 11:06:49,211 DEBUG o.s.j.d.DataSourceTransactionManager - Switching JDBC Connection [ProxyConnection..] to manual commit 2018-02-12 11:06:49,229 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL query 2018-02-12 11:06:49,230 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [select * from MEMBER where EMAIL = ?] 2018-02-12 11:06:49,289 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL update 2018-02-12 11:06:49,290 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?] 2018-02-12 11:06:49,291 DEBUG o.s.j.c.JdbcTemplate - SQL update affected 1 rows 2018-02-12 11:06:49,292 DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction commit 2018-02-12 11:06:49,292 DEBUG o.s.j.d.DataSourceTransactionManager - Committing JDBC transaction on Connection [ProxyConnection..] 2018-02-12 11:06:49,295 DEBUG o.s.j.d.DataSourceTransactionManager - Releasing JDBC Connection [ProxyConnection..] after transaction 2018-02-12 11:06:49,295 DEBUG o.s.j.d.DataSourceUtils - Returning JDBC Connection to DataSource 암호를 변경했습니다.
로그를 통해 트랜잭션이 시작되고 커밋되는 과정을 확인할 수 있다.
WrongIdPasswordException이 발생하면 트랜잭션이 롤백된다. 로그 메시지를 통해 이를 확인할 수 있다.
plaintext 2018-02-12 11:32:00,547 DEBUG o.s.j.d.DataSourceTransactionManager - Switching JDBC Connection [ProxyConnection..] to manual commit 2018-02-12 11:32:00,568 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL query 2018-02-12 11:32:00.569 DEBUG o.s.j.c.JdbcTemplate - Executing prepared SQL statement [select * from MEMBER where EMAIL= ?] 2018-02-12 11:32:00,659 DEBUG o.s.j.d.DataSourceTransactionManager - Initiating transaction rollback 2018-02-12 11:32:00,659 DEBUG o.s.j.d.DataSourceTransactionManager - Rolling back JDBC transaction on Connection [ProxyConnection.. 생략] 2018-02-12 11:32:00,661 DEBUG o.s.j.d.DataSourceTransactionManager - Releasing JDBC Connection [ProxyConnection..] after transaction 2018-02-12 11:32:00,661 DEBUG o.s.j.d.DataSourceUtils - Returning JDBC Connection to DataSource 암호가 올바르지 않습니다.
트랜잭션을 롤백한다는 로그 메시지가 출력된다.
@Transactional과 프록시 트랜잭션 처리는 프록시를 통해 이루어진다. @EnableTransactionManagement 태그를 사용하면 스프링은 @Transactional 애노테이션이 적용된 빈 객체를 찾아서 알맞은 프록시 객체를 생성한다.
트랜잭션 롤백 처리 프록시 객체는 원본 객체의 메서드를 실행하는 과정에서 RuntimeException이 발생하면 트랜잭션을 롤백한다.
java try { cps.changePassword("madvirus@madvirus.net", "1234", "1111"); System.out.println("암호를 변경했습니다."); } catch (MemberNotFoundException e) { System.out.println("회원 데이터가 존재하지 않습니다."); } catch (WrongIdPasswordException e) { System.out.println("암호가 올바르지 않습니다."); }
이 코드의 실행 결과를 보면 WrongIdPasswordException이 발생했을 때 트랜잭션이 롤백된다.
java @Transactional(rollbackFor=SQLException.class) public void someMethod() { // 메서드 내용 }
@Transactional의 rollbackFor 속성을 설정하면 RuntimeException뿐만 아니라 SQLException이 발생하는 경우에도 트랜잭션을 롤백한다. 여러 예외 타입을 지정하고 싶다면 {SQLException.class, IOException.class}와 같이 배열로 지정하면 된다.
@Transactional의 주요 속성 속성 타입 설명 value String 트랜잭션을 관리할 때 사용할 PlatformTransactionManager 빈의 이름을 지정한다. 기본값은 ""이다. propagation Propagation 트랜잭션 전파 타입을 지정한다. 기본값은 Propagation.REQUIRED이다. isolation Isolation 트랜잭션 격리 레벨을 지정한다. 기본값은 Isolation.DEFAULT이다. timeout int 트랜잭션 제한 시간을 지정한다. 기본값은 -1로 이 경우 데이터베이스의 타임아웃 시간을 사용한다. 초 단위로 지정한다. @Transactional 애노테이션의 value 속성값이 없으면 등록된 빈 중에서 타입이 PlatformTransactionManager인 빈을 사용한다.
java Member member; Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DriverManager.getConnection("jdbc:mysql://localhost/spring5fs", "spring5", "spring5"); pstmt = conn.prepareStatement("select * from MEMBER where EMAIL = ?"); pstmt.setString(1, email); rs = pstmt.executeQuery(); if (rs.next()) { member = new Member(rs.getString("EMAIL"), rs.getString("PASSWORD"), rs.getString("NAME"), rs.getTimestamp("REGDATE")); member.setId(rs.getLong("ID")); return member; } else { return null; } } catch (SQLException e) { e.printStackTrace(); throw e; } finally { if (rs != null) try { rs.close(); } catch (SQLException e) {} if (pstmt != null) try { pstmt.close(); } catch (SQLException e1) {} if (conn != null) try { conn.close(); } catch (SQLException e) {} }
JDBC API를 이용한 DB 연동 코드 구조는 반복되는 코드가 많다. 스프링은 이를 보완하기 위해 JdbcTemplate 클래스를 제공한다고 한다.
java List<Member> results = jdbcTemplate.query( "select * from MEMBER where EMAIL = ?", (ResultSet rs, int rowNum) -> { Member member = new Member(rs.getString("EMAIL"), rs.getString("PASSWORD"), rs.getString("NAME"), rs.getTimestamp("REGDATE")); member.setId(rs.getLong("ID")); return member; }, email); return results.isEmpty() ? null : results.get(0);
이 코드에서 스프링의 장점은 트랜잭션 관리가 쉽다는 것이다.
@Transactional 애노테이션을 사용하면 자동으로 트랜잭션 처리를 할 수 있다.
java @Transactional public void insert(Member member) { // 핵심 코드 }
2.1 프로젝트 생성 메이븐 프로젝트를 생성한다. 생성 과정을 요약하면 아래와 같다
프로젝트를 위한 sp5-chap08 폴더를 생성.
sp5-chap08의 하위 폴더로 src\main\java 폴더를 생성.
sp5-chap08 폴더에 pom.xml 파일을 생성.
MySQL에 root 사용자로 연결한 뒤 다음 쿼리를 실행해서 DB 사용자, 데이터베이스, 테이블을 생성한다.
sql create user 'spring5'@'localhost' identified by 'spring5'; create database spring5fs character set=utf8; grant all privileges on spring5fs.* to 'spring5'@'localhost'; create table spring5fs.MEMBER ( ID int auto_increment primary key, EMAIL varchar(255), PASSWORD varchar(100), NAME varchar(100), REGDATE datetime, unique key (EMAIL) ) engine=InnoDB character set=utf8;
이 쿼리를 실행하면 spring5 계정으로 접속할 수 있고, MEMBER 테이블이 생성된다.
JDBC API는 DriverManager 외에 DataSource를 이용해서 DB 연결을 구하는 방법을 제공한다. 스프링은 DataSource를 빈으로 등록하고 이를 이용해 DB 연동을 수행한다.
java @Bean(destroyMethod = "close") public DataSource dataSource() { DataSource ds = new DataSource(); ds.setDriverClassName("com.mysql.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8"); ds.setUsername("spring5"); ds.setPassword("spring5"); ds.setInitialSize(2); ds.setMaxActive(10); return ds; }
Tomcat JDBC 모듈의 DataSource 클래스는 커넥션 풀 기능을 제공한다. 주요 설정 메서드는 다음과 같다:
``java public class MemberDao { private JdbcTemplate jdbcTemplate;
public MemberDao(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
} ``
java public Member selectByEmail(String email) { List<Member> results = jdbcTemplate.query( "select * from MEMBER where EMAIL = ?", (ResultSet rs, int rowNum) -> { Member member = new Member( rs.getString("EMAIL"), rs.getString("PASSWORD"), rs.getString("NAME"), rs.getTimestamp("REGDATE").toLocalDateTime()); member.setId(rs.getLong("ID")); return member; }, email); return results.isEmpty() ? null : results.get(0); }
java public int count() { Integer count = jdbcTemplate.queryForObject( "select count(*) from MEMBER", Integer.class); return count; }
java @Transactional public void changePassword(String email, String oldPwd, String newPwd) { Member member = memberDao.selectByEmail(email); if (member == null) throw new MemberNotFoundException(); member.changePassword(oldPwd, newPwd); memberDao.update(member); }
스프링은 @Transactional 애노테이션을 적용하기 위해 내부적으로 AOP를 사용한다.
``java public class ChangePasswordService { private MemberDao memberDao;
@Transactional
public void changePassword(String email, String oldPwd, String newPwd) {
Member member = memberDao.selectByEmail(email);
if (member == null) throw new MemberNotFoundException();
member.changePassword(oldPwd, newPwd);
memberDao.update(member);
}
public void setMemberDao(MemberDao memberDao) {
this.memberDao = memberDao;
}
} ``
스프링은 프록시 객체를 생성하여 @Transactional 애노테이션이 적용된 메서드를 트랜잭션 범위에서 실행한다.
java try { cps.changePassword("madvirus@madvirus.net", "1234", "1111"); System.out.println("암호를 변경했습니다."); } catch (MemberNotFoundException e) { System.out.println("회원 데이터가 존재하지 않습니다."); } catch (WrongIdPasswordException e) { System.out.println("암호가 올바르지 않습니다."); }
@Transactional 애노테이션을 사용하면 RuntimeException이 발생할 때 자동으로 트랜잭션을 롤백한다.
java @Transactional(rollbackFor=SQLException.class) public void someMethod() { // 메서드 내용 }
value String 트랜잭션을 관리할 때 사용할 PlatformTransactionManager 빈의 이름
propagation Propagation 트랜잭션 전파 타입을 지정
isolation Isolation 트랜잭션 격리 레벨을 지정
timeout int 트랜잭션 제한 시간을 지정 (초 단위)
proxyTargetClass 클래스를 이용해서 프록시를 생성할지 여부를 지정
order AOP 적용 순서를 지정
트랜잭션 전파 유형은 다음과 같다:
완성된 AppCtx 설정 클래스와 콘솔을 이용한 Main 클래스 예제는 다음과 같다.
``java package config;
import org.apache.tomcat.jdbc.pool.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import spring.*;
@Configuration @EnableTransactionManagement public class AppCtx { @Bean(destroyMethod = "close") public DataSource dataSource() { DataSource ds = new DataSource(); ds.setDriverClassName("com.mysql.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8"); ds.setUsername("spring5"); ds.setPassword("spring5"); ds.setInitialSize(2); ds.setMaxActive(10); ds.setTestWhileIdle(true); ds.setMinEvictableIdleTimeMillis(60000 3); ds.setTimeBetweenEvictionRunsMillis(10 1000); return ds; }
@Bean
public PlatformTransactionManager transactionManager() {
DataSourceTransactionManager tm = new DataSourceTransactionManager();
tm.setDataSource(dataSource());
return tm;
}
@Bean
public MemberDao memberDao() {
return new MemberDao(dataSource());
}
@Bean
public MemberRegisterService memberRegSvc() {
return new MemberRegisterService(memberDao());
}
@Bean
public ChangePasswordService changePwdSvc() {
ChangePasswordService pwdSvc = new ChangePasswordService();
pwdSvc.setMemberDao(memberDao());
return pwdSvc;
}
@Bean
public MemberPrinter memberPrinter() {
return new MemberPrinter();
}
@Bean
public MemberListPrinter listPrinter() {
return new MemberListPrinter(memberDao(), memberPrinter());
}
@Bean
public MemberInfoPrinter infoPrinter() {
MemberInfoPrinter infoPrinter = new MemberInfoPrinter();
infoPrinter.setMemberDao(memberDao());
infoPrinter.setPrinter(memberPrinter());
return infoPrinter;
}
} ``
``java package main;
import org.springframework.context.annotation.AnnotationConfigApplicationContext; import config.AppCtx; import spring.*;
import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader;
public class Main { private static AnnotationConfigApplicationContext ctx = null;
public static void main(String[] args) throws IOException {
ctx = new AnnotationConfigApplicationContext(AppCtx.class);
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
while (true) {
System.out.println(":");
String command = reader.readLine();
if (command.equalsIgnoreCase("exit")) {
System.out.println("종료합니다.");
break;
}
if (command.startsWith("new")) {
processNewCommand(command.split(" "));
} else if (command.startsWith("change")) {
processChangeCommand(command.split(" "));
} else if (command.equals("list")) {
processListCommand();
} else if (command.startsWith("info")) {
processInfoCommand(command.split(" "));
} else {
printHelp();
}
}
ctx.close();
}
private static void processNewCommand(String[] arg) {
if (arg.length != 5) {
printHelp();
return;
}
MemberRegisterService regSvc = ctx.getBean("memberRegSvc", MemberRegisterService.class);
RegisterRequest req = new RegisterRequest();
req.setEmail(arg[1]);
req.setName(arg[2]);
req.setPassword(arg[3]);
req.setConfirmPassword(arg[4]);
if (!req.isPasswordEqualToConfirmPassword()) {
System.out.println("암호와 확인 암호가 일치하지 않습니다.\n");
return;
}
try {
regSvc.regist(req);
System.out.println("등록했습니다.\n");
} catch (DuplicateMemberException e) {
System.out.println("이미 존재하는 이메일입니다.\n");
}
}
private static void processChangeCommand(String[] arg) {
if (arg.length != 4) {
printHelp();
return;
}
ChangePasswordService changePwdSvc = ctx.getBean("changePwdSvc", ChangePasswordService.class);
try {
changePwdSvc.changePassword(arg[1], arg[2], arg[3]);
System.out.println("암호를 변경했습니다.\n");
} catch (MemberNotFoundException e) {
System.out.println("존재하지 않는 이메일입니다.\n");
} catch (WrongIdPasswordException e) {
System.out.println("암호가 일치하지 않습니다.\n");
}
}
private static void processListCommand() {
MemberListPrinter listPrinter = ctx.getBean("listPrinter", MemberListPrinter.class);
listPrinter.printAll();
}
private static void processInfoCommand(String[] arg) {
if (arg.length != 2) {
printHelp();
return;
}
MemberInfoPrinter infoPrinter = ctx.getBean("infoPrinter", MemberInfoPrinter.class);
infoPrinter.printMemberInfo(arg[1]);
}
private static void printHelp() {
System.out.println();
System.out.println("잘못된 명령입니다. 아래 사용법을 확인하세요.");
System.out.println("명령어 사용법:");
System.out.println("new 이메일 이름 암호 암호확인");
System.out.println("change 이메일 현재암호 새암호");
System.out.println("list");
System.out.println("info 이메일");
System.out.println("exit");
System.out.println();
}
} ``
이와 같이 spring과 DB의 연결 방법과 상호작용법을 알아 보았습니다. 감사합니다.
스프링 프로그래밍 입문 5 남은 부분 정독