LandvibeDev / 2024-spring5-programming-introduction

스프링5 프로그래밍 입문 레포지토리입니다. (2024 썸머코딩 서버)
0 stars 0 forks source link

[5주차] 수지, 지웅, 건우 #5

Open tmdcheol opened 3 months ago

tmdcheol commented 3 months ago

스프링 프로그래밍 입문 5 남은 부분 정독

io-uty commented 3 months ago

[Chapter 15] 간단한 웹 어플리케이션의 구조

1 간단한 웹 어플리케이션의 구성요소

2 서비스의 구현

3 컨트롤러에서의 DAO 접근

public class MemberService{
...
    public Member getMember(Long id){
        return memberDao.selectById(id);
    }
}

4 패키지 구성

[Chapter 16] JSON 응답과 요청 처리

1 JSON 개요

2 Jackson 의존 설정

public class Person { <=> { "name":"이름",
    private String name;      "age":10
    private int age;         }
    ..get/set 메서드
}

3 @RestController로 JSON 형식 응답

3.1 @JsonIgnore를 이용한 제외 처리

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;
}

3.2 날짜 형식 변환 처리: @JsonFormat 사용

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"

3.3 날짜 형식 변환 처리 : 기본 적용 설정

3.4 응답 데이터의 컨텐츠 형식

자바객체 ⇒ JSON


JSON ⇒ 자바객체

4 @RequestBody로 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{
        ...
        }
    }
}

4.1 JSON 데이터의 날짜 형식 다루기

 //특정 패턴을 가진 문자열을 변환하고자 할 때
 @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));
        }
    }

4.2 요청 객체 검증하기

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);
    }
}

5 ResponseEntiry로 객체 리턴하고 응답 코드 지정하기

5.1 ResponseEntity를 이용한 응답 데이터 처리

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);
    }

5.2 @ExceptionHandler 적용 메서드에서 ResponseEntity로 응답하기

⇒ @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"));
}

5.3 @Valid 에러 결과를 JSON으로 응답하기

[Chapter 17] 프로필과 프로퍼티 파일

1 프로필

1.1 @Configuration 설정에서 프로필 사용하기

//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(){
        ...
    }
}

1.2 @Configuration을 이용한 프로필 설정

주의) 중첩된 @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(){
            ...
        }
    }    
}

1.3 다수 프로필 설정

//real, test 프로필 모두 사용
@Configuration
@Profile(”real,test”)
public class DataSourceJndiConfig{ ... )

//real 프로필 비활성화
@Configuration
@Profile(”!real”)
public class DataSourceJndiConfig{ ... )

1.4 어플리케이션에서 프로필 설정하기

<servlet>
    ...
    <init-param>
        <param-name>spring.profiles.active</param-name>
        <param-value>dev</param-value>
    </init-param>
    ...
</servlet>

2 프로퍼티 파일을 이용한 프로퍼티 설정

2.1 @Configuration 애노테이션 이용 자바 설정에서의 프로퍼티 사용

2.2 빈 클래스에서 사용하기

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;
    }
}
jgw1202 commented 3 months ago

Chapter 10 : 스프링 MVC 프레임워크 동작 방식

1. 스프링 MVC 핵심 구성 요소

위 사진은 스프링 MVC의 핵심 구성요소를 보여주는 그림입니다. 위 사진에서 컨트롤러와 JSP 가 개발자가 직접 구현해야 하고 스프링 빈으로 등록해야 하는 것들 입니다.

중앙에 위치한 DispatcherServlet 은 모든 연결을 담당합니다.

(1) 웹 브라우저로부터 요청이 들어오면 디스패쳐 서블릿은 그 요청을 처리하기 위한 컨트롤러 객체를 탐색하게 됩니다.

(2) 이때 디스패쳐 서블릿은 직접 컨트롤러를 검색하지않고, HandlerMapping 이라는 빈 객체에게 컨트롤러 검색을 요청합니다.

핸들러매핑은 클라이언트의 요청 경로를 이용해서 이를 처리할 컨트롤러 빈 객체를 디스패쳐 서블릿에게 전달합니다. ex) 웹 요청 경로가 '/hello' 라면 등록된 컨트롤러 빈 중에 '/hello' 요청 경로를 처리할 컨트롤러를 리턴해줍니다. 단, 디스패쳐 서블릿이 컨트롤러 객체를 전달 받았다고 해서 바로 컨트롤러 객체의 메소드를 실행할 수 있는 것은 아닙니다.

(3) 디스패쳐 서블릿은 핸들러 매핑이 찾아준 컨트롤러 객체를 처리할 수 있는 HandlerAdapter 빈에게 요청 처리를 위임합니다.

(4~5) 핸들러 어댑터는 컨트롤러의 알맞은 메소드를 호출해서 요청을 처리합니다.

(6) 이후, 그 결과를 디스패쳐 서블릿에게 리턴합니다.

(7) 핸들러 어댑터로부터 컨트롤러의 요청 처리 결과를 ModelAndView로 받으면 디스패쳐서블릿은 결과를 보여줄 뷰를 찾기 위해 ViewResolver 빈 객체를 사용합니다.

ModelAndView는 컨트롤러가 리턴한 뷰 이름을 담고 있는데 ViewResolver는 이 뷰 이름에 해당하는 View 객체를 찾거나 생성해서 리턴합니다.

(8) 디스패쳐서블릿은 뷰 리졸버가 리턴한 View 객체에게 응답 결과 생성을 요청합니다.

(9) 이후 웹 브라우저에 전송할 응답결과를 생성하고 모든 과정이 끝이 납니다.

2. @Controller를 위한 HandlerMapping과 HandlerAdapter

@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"를 사용합니다.

Chapter 11 : MVC 1 : 요청 매핑, 커맨드 객체, 리다이렉트, 폼 태그, 모델

1. 요청 매핑 어노테이션을 이용한 경로 매핑

웹 어플리케이션을 개발하는 것은 다음 코드를 작성하는 것입니다.

  • 특정 요청 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"이 됩니다.

2. GET과 POST의 구분

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 을 사용해도 같습니다.

3. 요청 파라미터 접근

<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"를 뷰 이름으로 리턴할 것입니다.

4. 리다이렉트 처리

웹 브라우저에서 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() 메소드를 통해 리다이렉트를 할 수 있습니다.

5. 커맨드 객체를 이용해 요청 파리미터 사용

만약 폼이 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 라고 이해하면 편합니다.

6. @ModelAttribute 어노테이션으로 커맨드 객체 속성 이름 변경

커맨드 객체에 접근할 때 사용할 속성 이름을 변경하고 싶다면 파라미터에 @ModelAttribute 어노테이션을 적용하면 됩니다.

@PostMapping("register/step3")
public String handleStep3(@ModelAttribute("formData") RegisterRequest regReq) 
...

@ModelAttribute 어노테이션은 모델에서 사용할 속성 이름을 값으로 설정합니다. 위 설정을 사용하면 뷰 코드에서 "formData" 라는 이름으로 커맨드 객체에 접근할 수 있습니다.

7. 커맨드 객체와 스프링 폼 연동

스프링 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";
   }
   ...
태그를 사용하려면 커맨드 객체가 존재해야 합니다. 위 코드 처럼 "registerRequest" 인 객체를 모델에 넣어야 정상 동작합니다. ## 8. 주요 에러 발생 상황 ### 404 에러? 요청 경로를 처리할 컨트롤러가 존재하지 않거나 WebMvcConfigurer를 이용한 설정이 없다면 발생합니다. 404 에러가 발생한다면 다음 사항을 확인해야 합니다. - 요청 경로가 올바른가? - 컨트롤러에 설정한 경로가 올바른가? - 컨트롤러 클래스를 빈으로 등록했는가? - 컨트롤러 클래스에 @Controller 어노테이션을 적용했는가? ### 405 에러? 지원하지 않는 전송 방식을 사용한 경우 발생합니다. 예를 들면, POST 방식만 처리하는 요청 경로를 GET 방식으로 연결하는 경우가 있습니다. ## 9. Model을 통해 컨트롤러에서 뷰 데이터 전달 컨트롤러는 뷰가 응답 화면을 구성하는데 필요한 데이터를 생성해서 전달해야 한다. 이때 사용하는 것이 Model 이다. - 요청 매핑 어노테이션이 적용된 메소드의 파라미터로 Model을 추가 - Model 파라미터의 addAttribute() 메소드로 뷰에서 사용할 데이터를 전달 뷰에 데이터를 전달하는 컨트롤러는 위 두가지를 하면 됩니다. addAtribute() 메소드의 첫번째 파라미터는 속성 이름입니다. 뷰 코드는 이 이름을 사용하여 데이터에 접근합니다. ModelAndView를 활용하면 Model을 통해 뷰에 전달할 데이터 설정, 결과를 보여줄 뷰 이름을 리턴하는 것을 한번에 처리할 수 있습니다. 요청 매핑 어노테이션을 적용한 메소드는 String 타입 대신 ModelAndView 를 리턴할 수 있습니다. ``` @Controller @RequestMapping("/survey") public class SurveyController { @GetMapping public ModelAndView form() { List questions = createQutestions(); ModelAndView mav = new ModelAndView(); mav.addObject("questions", questions); mav.setViewName("survey/surveyForm"); return mav; } } ``` 뷰에 전달할 모델 데이터는 addObject() 메소드로 추가하고, 뷰이름은 setViewNamee() 메소드를 이용해 지정합니다.
wldnd7145 commented 3 months ago

Chapter 08: DB 연동

1. 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) { // 핵심 코드 }

커밋과 롤백 처리는 스프링이 알아서 처리하므로 코드를 작성하는 사람은 트랜잭션 처리를 제외한 핵심 코드만 집중해서 작성하면 됨.

2. 프로젝트 준비

이 장에서 사용할 예제 코드 대부분은 앞서 작성했던 3장에서 가져올 것임. 3장 예제를 작성하지 않았다면 책에서 제공하는 소스 코드를 다운로드한 뒤에 따라 하면 됨.

2.1 프로젝트 생성

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">

4.0.0
<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>

``

2.2 DB 테이블 생성

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;

3. DataSource 설정

JDBC API는 DriverManager 외에 DataSource를 이용해서 DB 연결을 구하는 방법을 정의하고 있음. DataSource를 사용하면 다음 방식으로 Connection을 구할 수 있음.

java Connection conn = null; try { // dataSource는 생성자나 설정 메서드를 이용해서 주입받음 conn = dataSource.getConnection(); // ... }

스프링이 제공하는 DB 연동 기능은 DataSource를 사용해서 DB Connection을 구함. DB 연동에 사용할 DataSource를 스프링 빈으로 등록하고 DB 연동 기능을 구현한 빈 객체는 DataSource를 주입받아 사용함.

3.1 Tomcat JDBC의 주요 프로퍼티

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; }

1. JDBC 프로그래밍의 단점을 보완하는 스프링

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) { // 핵심 코드 }

커밋과 롤백 처리는 스프링이 알아서 처리하므로 코드를 작성하는 사람은 트랜잭션 처리를 제외한 핵심 코드만 집중해서 작성하면 됨.

2. 프로젝트 준비

이 장에서 사용할 예제 코드 대부분은 앞서 작성했던 3장에서 가져올 것임. 3장 예제를 작성하지 않았다면 책에서 제공하는 소스 코드를 다운로드한 뒤에 따라 하면 됨.

2.1 프로젝트 생성

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">

4.0.0
<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>

``

2.2 DB 테이블 생성

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;

3. DataSource 설정

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; }

3.1 Tomcat JDBC의 주요 프로퍼티

Tomcat JDBC 모듈의 org.apache.tomcat.jdbc.pool.DataSource 클래스는 커넥션 풀 기능을 제공하는 DataSource 구현 클래스임.

설정 메서드 설명

4. JdbcTemplate을 이용한 쿼리 실행

스프링을 사용하면 DataSource나 Connection, Statement, ResultSet을 직접 사용하지 않고 JdbcTemplate을 이용해서 편리하게 쿼리를 실행할 수 있음.

4.1 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를 생성하고 삽입함.
wldnd7145 commented 3 months ago

5. MemberDao 테스트

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)가 발생함.

잘못된 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("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 연동 코드를 실행하는 과정에서 예외가 발생하면 당황하지 말고 예외 메시지를 차분히 살펴보는 습관을 들이자.

6. 스프링의 예외 변환 처리

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);

7. 트랜잭션 처리

이메일이 유효한지 여부를 판단하기 위해 실제로 검증 목적의 메일을 발송하는 서비스를 사용한 경험이 있을 것임.

이들 서비스는 이메일에 함께 보낸 링크를 클릭하면 최종적으로 이메일이 유효하다고 판단하고 해당 이메일을 사용할 수 있도록 함.

이렇게 이메일 인증 시점에 테이블의 데이터를 변경하는 기능은 다음 코드처럼 회원 정보에서 이메일을 수정하고 인증 상태를 변경하는 두 쿼리를 실행할 것임.

``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) { } } } ``

7.1 @Transactional을 이용한 트랜잭션 처리

스프링이 제공하는 @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;
}

} ``

7.1 트랜잭션 테스트

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 설정

트랜잭션 관련 로그 메시지를 출력하기 위해 Logback 설정을 추가한다. pom.xml 파일에 Logback 의존성을 추가한다.

``xml

org.slf4j slf4j-api 1.7.25 ch.qos.logback logback-classic 1.2.3

``

src/main/resources 폴더에 logback.xml 파일을 작성.

``xml <?xml version="1.0" encoding="UTF-8"?>

%d %5p %c{2} - %m%n

``

실행 결과 확인

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인 빈을 사용한다.

1. 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 연동 코드 구조는 반복되는 코드가 많다. 스프링은 이를 보완하기 위해 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. 프로젝트 준비

2.1 프로젝트 생성 메이븐 프로젝트를 생성한다. 생성 과정을 요약하면 아래와 같다

프로젝트를 위한 sp5-chap08 폴더를 생성.

sp5-chap08의 하위 폴더로 src\main\java 폴더를 생성.

sp5-chap08 폴더에 pom.xml 파일을 생성.

2.2 DB 테이블 생성

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 테이블이 생성된다.

3. DataSource 설정

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; }

3.1 Tomcat JDBC의 주요 프로퍼티

Tomcat JDBC 모듈의 DataSource 클래스는 커넥션 풀 기능을 제공한다. 주요 설정 메서드는 다음과 같다:

4. JdbcTemplate을 이용한 쿼리 실행

4.1 JdbcTemplate 생성하기

``java public class MemberDao { private JdbcTemplate jdbcTemplate;

public MemberDao(DataSource dataSource) {
    this.jdbcTemplate = new JdbcTemplate(dataSource);
}

} ``

4.2 JdbcTemplate을 이용한 조회 쿼리 실행

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); }

4.3 queryForObject() 메서드

java public int count() { Integer count = jdbcTemplate.queryForObject( "select count(*) from MEMBER", Integer.class); return count; }

7. 트랜잭션 처리

7.1 @Transactional을 이용한 트랜잭션 처리

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); }

7.2 @Transactional과 프록시

스프링은 @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("암호가 올바르지 않습니다."); }

7.3 트랜잭션 롤백 처리

@Transactional 애노테이션을 사용하면 RuntimeException이 발생할 때 자동으로 트랜잭션을 롤백한다.

java @Transactional(rollbackFor=SQLException.class) public void someMethod() { // 메서드 내용 }

7.4 @Transactional의 주요 속성

value String 트랜잭션을 관리할 때 사용할 PlatformTransactionManager 빈의 이름

propagation Propagation 트랜잭션 전파 타입을 지정

isolation Isolation 트랜잭션 격리 레벨을 지정

timeout int 트랜잭션 제한 시간을 지정 (초 단위)

7.5 @EnableTransactionManagement 애노테이션의 주요 속성

proxyTargetClass 클래스를 이용해서 프록시를 생성할지 여부를 지정

order AOP 적용 순서를 지정

7.6 트랜잭션 전파

트랜잭션 전파 유형은 다음과 같다:

8. 전체 기능 연동한 코드 실행

완성된 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의 연결 방법과 상호작용법을 알아 보았습니다. 감사합니다.