Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5주차] 해원, 은채, 태균, 수진 #4

Open
tmdcheol opened this issue Jul 31, 2024 · 6 comments
Open

[5주차] 해원, 은채, 태균, 수진 #4

tmdcheol opened this issue Jul 31, 2024 · 6 comments

Comments

@tmdcheol
Copy link
Contributor

tmdcheol commented Jul 31, 2024

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

  • 밑에 명시된 chapter는 각 조에서 담당인원 정하여 github에 markdown으로 정리 및 다음 주 수업 때 발표
    • chapter 8
    • chapter 10,11
    • chapter 15,16,17
@silverchaeJ
Copy link

silverchaeJ commented Aug 1, 2024

Chapter15 간단한 웹 어플리케이션 구조

1.웹 어플리케이션의 구성 요성


웹 어플리케이션을 개발할 때 사용하는 전형적인 구조는 다음 요소를 포함한다.

  • 프론트 서블릿
  • 컨트롤러 + 뷰
  • 서비스
  • DAO

프론트 서블릿의 역할

  • 프론트 서블릿은 웹 브라우저의 모든 요청을 받는 창구
  • 프론트 서블릿은 요청을 분석해서 알맞은 컨트롤러에 전달
    ( 스프링 MVC에서는 DispatcherServlet이 프론트 서블릿의 역할을 수행한다.)

[웹 어플리케이션의 구조]
image
컨트롤러는 실제 웹 브라우저의 요청을 처리한다.
지금까지 구현해본 스프링 컨트롤러가 이에 해당한다.
컨트롤러는 클라이언트(브라우저)의 요청을 처리하기 위해 알맞은 기능을 실행하고 그 결과를 뷰에 전달한다.

(이해를 돕기위한 예시사진)
다운로드

컨트롤러의 주요 역할

  • 클라이언트가 요구한 기능을 실행

  • 응답 결과를 생성하는데 필요한 모델 생성

  • 응답 결과를 생성할 뷰 선택

    컨트롤러는 로직 실행을 위해 서비스에 위임한다.
    컨트롤러는 어플리케이션이 제공하는 기능과 사용자 요청을 연결하는 매개체이고, 기능 제공을 위한 로직을 직접 수행하지 않는다.

@PostMapping
public String submit(
   	@ModelAttribute("command") ChangePwdCommand pwdCmd, 
       Errors errors, 
       HttpSession session){
   	
   new ChangePwdCommandValidator().validate(pwdCmd, errors);
   if(errors.hasErrors()){
   	return "edit/changePwdForm";
   }	
   AuthInfo authInfo = (AuthInfo) session.getAttribute("authInfo");
   try{
   	//컨트롤러는 로직 실행을 서비스에 위임
       changePasswordService.changePassword(
       		authInfo.getEmail(),
               pwdCmd.getCurrendPassword(),
               pwdCmd.getNewPassword());
               
       return "edit/changePwd";
   }catch(IdPasswordNotMatchingException e){
   	errors.rejectValue("currentPassword", "notMatching");
       return "edit/changePwdForm";
   }
}

위에 코드는 ChangePasswordService에 비밀번호 변경 처리를 위임했다

서비스의 역할

  • 서비스는 기능의 로직을 구현
  • DB와 웹 어플리케이션 간에 데이터를 이동시켜주는 역할
    (서비스는 DB연동이 필요하면 DAO를 사용)

2.서비스의 구현


서비스는 핵심이 되는 기능의 로직을 제공한다.

예를 들어 비밀번호 변경 기능은 아래와 같은 로직을 서비스에서 수행한다.

  • DB에서 비밀번호를 변경할 회원의 데이터를 구한다.
  • 존재하지 않으면 익셉션을 발생시킨다.
  • 회원 데이터의 비밀번호를 변경한다.
  • 변경 내역을 DB에 반영한다.

🤔서비스 메서드를 트랙잭션 범위에서 실행해야 하는 이유

->웹 어플리케이션을 사용하든 명령행에서 실행하든 비밀번호 변경 기능을 위해서 서비스는 동일한 로직을 수행하는데
중간에 실패하면 이전까지 했던 것을 취소해야 하고, 모든 과정을 성공적으로 진행했을 때 완료해야하기 때문이다.

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

같은 데이터를 사용하는 기능들을 한 개의 서비스 클래스에 모아서 구현할 수 있다.

예를 들어 회원 가입 기능과 비밀번호 변경 기능은 모두 회원에 대한 기능이므로 다음과 같이 MemberService 클래스에 기능을 구현할 수 있다.

public class MemberService{
	@Transactional
    public void regist(RegisterRequest req){...}
    
    @Transactional 
    public void changePassword(String email, String lodPwd, String newPwd){...}
}

회원가입 기능은 RegisterRequest 클래스를 파라미터로 사용했다.

필요한 데이터를 전달받기 위해 별도 타입을 만들면 스프링 MVC의 커맨드 객체로 해당 타입을 사용할 수 있어 편하다.

회원 가입 요청을 처리하는 컨트롤러 클래스의 코드는 다음과 같이 서비스 메서드의 입력 파라미터로 사용되는 타입을 커맨드 객체로 사용했다.

@PostMapping("/register/step3")
public String handleSteo3(RegisterRequest regReq, Errors errors){
	...
    memberRegisterService.regist(regReq);
    ...
}

비밀번호 변경의 changePassword() 메서드처럼 웹 요청 파라미터를 커맨드 객체로 받고 커맨드 객체의 프로퍼티를 서비스 메서드에 인자로 전달할 수도 있다.

@RequestMapping(method=RequestMethod.POST)
public String submit(@ModelAttribute("command") ChangePwdCommand pwdCmd, Errors errors, HttpSession session){
	...
    changePasswordService.changePassword(
    	authInfo.getEmail(),
        pwdCmd.getCurrentPassword(),
        pwdCmd.getNewPassword()
    );
    ...
}

커맨드 클래스를 작성한 이유는 스프링 MVC가 제공하는 폼 값 바인딩과 검증, 스프링 폼 태그와의 연동 기능을 사용하기 위함이다.

3.컨트롤러에서 DAO접근


서비스 메서드에서는 어떤 로직도 수행하지 않고 단순히 DAO의 메서드만 호출하고 끝나는 경우도 있다.

예를 들어 회원 데이터 조회를 위한 서비스 메서드를 다음과 같이 구현하곤 한다.

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

여기서 MemberService 클래스의 getMember() 메서드는 MemberDao의 selectByID()만 실행할 뿐 추가 로직은 없다.

컨트롤러 클래스는 이 서비스 메서드를 통해 회원 정보를 구한다.

@RequestMapping("/member/detail/{id}")
public String detail(@PathVariable("id") Long id, Model model){
	//사실상 DAO를 직접 호출하는 것과 동일
    Member member = memberService.getMember(id);
  //Member member = memberDao.selectByEmail(id);
    if(member==null)
    	return "member/noFound";
    model.addAttribute("member",member);
    return "member/memberDatail";
}

4.패키지 구성


패키지의 구성을 조금 더 정확하게 분리하면 아래 그림처럼 '웹 요청을 처리하기 위한 것'과 '기능을 제공하기 위한 것'으로 구분할 수 있다.

image (1)

웹 요청을 처리하기 위한 영역

  • 컨트롤러: 사용자의 요청을 받아들이고, 요청에 맞는 서비스를 호출하며, 결과를 뷰로 전달합니다.
  • Validator: 커맨드 객체의 값을 검증합니다.
  • 커맨드 객체: 폼 데이터를 담는 객체입니다.
    이 패키지는 보통 web.member와 같은 형태로 구성합니다.

기능 제공 영역

이 영역에는 실제 비즈니스 로직과 데이터 처리를 담당하는 클래스들이 위치합니다:

  • 서비스: 비즈니스 로직을 처리합니다.
  • DAO: 데이터베이스와 상호작용하여 데이터를 처리합니다.
  • 모델: 도메인 객체로, 비즈니스 데이터를 담는 객체입니다.
    이 패키지는 보통 domain.member와 같은 형태로 구성합니다. 기능 영역은 더 세부적으로 service, dao, model과 같이 나눌 수 있습니다.

기능 영역은 다음과 같이 service, dao,model 같은 세부 패키지로 구분하기도 한다.
image (2)

도메인 주도 설계(DDD)의 적용


:도메인 주도 설계는 복잡한 애플리케이션의 구조를 체계적으로 관리하기 위한 방법론입니다. DDD에서는 애플리케이션을 UI, 서비스, 도메인, 인프라의 네 영역으로 나눕니다

  • UI (User Interface):

역할: 사용자와의 상호작용을 담당합니다.

구성 요소: 컨트롤러, 뷰

예시: web.member 패키지

  • 서비스 (Service):

역할: 도메인 로직을 조정하고 트랜잭션을 관리합니다.

구성 요소: 애플리케이션 서비스

예시: domain.member.service 패키지

  • 도메인 (Domain):

역할: 핵심 비즈니스 로직과 규칙을 포함합니다.

구성 요소: 도메인 모델, 엔티티, 밸류 오브젝트, 도메인 이벤트, 애그리게잇

예시: domain.member.model 패키지

  • 인프라 (Infrastructure):

역할: 데이터베이스와의 상호작용을 담당합니다.

구성 요소: 리포지토리, DAO, 외부 시스템과의 통신

예시: domain.member.dao 패키지

DDD의 장점

  • 코드의 일관성 유지: 도메인 모델과 비즈니스 로직이 잘 정의되어 있어 코드가 복잡해져도 일관성을 유지할 수 있습니다.
  • 유지 보수성 향상: 비즈니스 로직이 서비스가 아닌 도메인 모델에 집중되어 있어, 변경 사항이 발생해도 쉽게 관리할 수 있습니다.
  • 확장성: 새로운 기능 추가나 변경이 발생해도 구조가 잘 정의되어 있어 확장이 용이합니다.

chapter16 JSON 응답과 요청 처리

1.JSON 개요

Jackson은 자바 객체와 JSON 형식 문자열 간 변환을 처리하는 라이브러리이다.

스프링 MVC에서 Jackson 라이브러리를 이용해서 자바 객체를 JSON으로 변환하려면 클래스 패스에 Jackson 라이브러리를 추가하면 된다.


2.JSON 의존 설정


Jackson은 아래 그림과 같이 자바 객체와 JSON 사이의 변환을 처리한다.

image (3)

3.@RestController로 JSON 형식 응답


스프링 MVC에서 JSON 형식으로 데이터를 응답하기 위해선 @controller 대신 @RestController 어노테이션을 사용하면 된다.

@RestController
public class RestMemberController{
	private MemberDao memberDao;
    private MemberRegisterService registerService;
    
    @GetMapping("/api/members")
    public List<Member> members(){
    	return memberDao.selectAll();
    }
    
    @GetMapping("/api/members/{id}")
    public Member member(@PathVariable Long id, HttpServletResponse response) throws IOException{
    	...
    }
    ...
}

@RestController 어노테이션을 붙이면 스프링 MVC는 요청 매핑 어노테이션을 붙인 메서드가 리턴한 객체를 알맞은 형식으로 변환해서 응답데이터로 전송한다. 이때 Jackson을 사용하면 JSON 형식의 문자열로 변환해서 응답한다.
리턴 타입이 List인 경우에 List 객체를 JSON 형식의 배열로 변환한 결과이다.

image (4)

@RestController 어노테이션이 추가되기 전에는 다음과 같이 @controller 어노테이션과 @responsebody 어노테이션을 사용했다.

@Controller
public class RestMemberController{
	private MemberDao memberDao;
    private MemberRegisterService registerService;
@RequestMapping(path="/api/members", method=RequestMethod.GET)
@ResponseBody
public List<Member> members(){
	return memberDao.selectAll();
}
}

3.1 JsonIgnore를 이용한 제외 처리


응답 결과에 password가 포함되어 있으면 안되기 때문에 응답 결과에서 제외시켜야 한다.

Jackson이 제공하는 @JsonIgnore 어노테이션을 사용하면 이를 간단히 처리할 수 있다.

다음과 같이 JSON 응답에 포함시키지 않을 대상에 어노테이션을 붙인다.

public class Member{
	private Long id;
    private String email;
    @JsonIgnore
    private String password;
    private String name;
    private LocalDateTime registerDateTime;
}

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


Jackson에서 날짜나 시간 값을 특정한 형식으로 표현하는 가장 쉬운 방법은 @jsonformat을 사용하는 것이다.

public class Member{
	private Long id;
    private String email;
    private String name;
    @JsonFormat(pattern="yyyyMMddHHmmss")
    private LocalDateTime registerDateTime;
}

Json 응답 결과는 다음과 같다.

{
  "id":1,
  "email":"[email protected]",
  "name:"spring",
  "registerDateTime":"202208150130"
}

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


스프링 MVC는 자바 객체를 HTTP 응답으로 변환할 때 HttpMessageConverter라는 것을 사용한다.

JSON으로 변환할 때 사용하는 컨버터를 새롭게 등록해서 날짜 형식을 원하는 형태로 변환하도록 설정하면 모든 날짜 형식에 동일한 변환 규칙을 적용할 수 있다.

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfiguration{
	...
    @Override
    public viod extendMessageConverter{
    	List<HttpMessageConverter<?>> converters){
        	ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
            .json()
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .build();
            converters.add(0,new MappingJackson2HttpMessageConverter(objectMapper));
        }	
    }
}

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


응답 헤더의 Content-type이 application/json인 것을 알 수 있다.

Content-Type: application/json

4.@RequestBody로 JSON 요청 처리


POST, PUT 방식을 사용하면 name=이름&age=20과 같은 쿼리 문자열 형식이 아니라 다음과 같은 JSON 형식의 데이터를 요청 데이터로 전송할 수 있다.

{"name":"이름", "age":20}

JSON 형식으로 전송된 요청 데이터를 커맨드 객체로 전달받는 방법은 커맨드 객체에 @RequestBody 어노테이션을 붙이면 된다.

@RestController{
	...
    @PostMapping("/api/members")
    public void new Member(@RequestBody @Valid RegisterRequest regReq, HttpServletResponse response){
    	...
    }
}

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


특정 패턴을 가진 문자열을 LocalDateTime이나 Date 타입으로 변환하고 싶다면 @jsonformat 어노테이션의 pattern 속성을 이용해서 패턴을 지정한다.

@JsonFormat(pattern="yyyyMMddHHmmss")
private LocalDateTime birthDateTime;

@JsonFormat(pattern="yyyyMMdd HHmmss")
private Date birthDate;

4.2 요청 객체 검증하기


newMember()의 regReq 파라미터에 @Valid 어노테이션이 붙어있다.

JSON 형식으로 전송한 데이터를 변환한 객체도 동일한 방식으로 @Valid 어노테이션이나 별도 Validator를 이용해서 검증할 수 있다.

Validator를 사용할 경우 직접 상태 코드를 처리해야 한다.

import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;

@RestController
@Validated
public class RestMemberController {
    // ... 필드 선언 및 의존성 주입

    @PostMapping("/api/members")
    public void newMember(@RequestBody RegisterRequest regReq, HttpServletResponse response) {
        // 커스텀 Validator를 사용하여 요청 데이터를 검증
        if (!isValid(regReq)) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid request data");
        }
        // 요청 처리 로직
    }

    private boolean isValid(RegisterRequest regReq) {
        // 검증 로직 구현
        return true; // 실제 검증 로직에 따라 수정
    }
}

위 코드에서는 ResponseStatusException을 사용하여 상태 코드와 오류 메시지를 설정합니다

. isValid 메서드는 요청 데이터를 검증하는 커스텀 로직을 구현합니다.

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


지금까지는 상태 코드를 지정하기 위해 HttpServletResponse의 setStatus(), sendError()를 사용했다. 문제는 HttpServletResponse를 이용해서 404 응답을 하면 JSON 형식이 아닌 서버가 기본으로 제공하는 HTML을 응답 결과로 제공한다는 점이다.

API를 호출하는 입장에서 JSON과 HTML을 모두 처리하는 것은 부담스럽다. 처리에 실패한 경우 HTML 응답 데이터 대신에 JSON 형식의 응답 데이터를 전송해야 API 호출 프로그램이 일관된 방법으로 응답을 처리할 수 있다.

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

정상인 경우와 비정상인 경우 모두 JSON 응답을 전송하는 방법은 ResponseEntity를 사용하는 것이다.

에러 상황일 때 응답으로 사용할 ErrorResponse 클래스이다.

public class ErrorResponse{
	private String message;
    
    public ErrorResponse(String message){
    	this.message = message;
    }
    
    public String getMessage(){
    	return message;
    }
}

}
ResponseEntity를 사용하면 member() 메서드를 아래와 같이 구현할 수 있다.

@RestController
public class RestMemberController{
	...
    @GetMapping("/api/members/{id}")
    public ResponseEntity<Object> member(@PathVariable Long id){
    	Member member = memberDao.selectById(id);
        if(member==null){
        	//ErrorResponse를 body로 지정해서,
            //ErrorResponse를 JSON으로 변환한다.
        	return ResponseEntity
            	.status(HttpStatus.NOT_FOUND)
               	.body(new ErrorResponse("no member"));
        }
        //member를 body로 지정해서, member 객체를 JSON으로 변환한다.
        return ResponseEntity.status(HttpStatus.OK).body(member);
    }
}

스프링 MVC는 리턴 타입이 ResponseEntity이면 body로 지정한 객체를 사용해서 변환을 처리한다. ResponseEntity의 status로 지정한 값을 응답 상태 코드로 사용한다.

존재하지 않는 ID를 이용해서 실행한 결과 404 상태 코드와 함께 JSON 형식으로 응답 데이터를 전송한 것을 확인할 수 있다.
image (5)

ResponseEntity를 생성하는 기본 방법은 status와 body를 이용해서 상태 코드와 JSON으로 변환할 객체를 지정하는 것이다.

ResponseEntity.status(상태코드).body(객체)

200(OK) 응답 코드와 몸체 데이터를 생성할 경우 다음과 같이 ok() 메서드를 이용할 수 있다.

ResponseEntity.ok(member)

만약 몸체 내용이 없다면 다음과 같이 body를 지정하지 않고 build()로 바로 생성한다.

ResponseEntity.status(HttpStatus.NOT_FOUND).build()

몸체 내용이 없는 경우 status() 메서드 대신에 다음과 같이 관련 메서드를 사용해도 된다.

ResponseEntity.notFound().build()

  • noContent(): 204
  • badRequest(): 400
  • notFound(): 404

newMember()에서 201(Created) 상태 코드와 Location 헤더를 함께 전송하는 방법

//1
response.setHeader("Location", "/api/members/"+newMemberId);
response.setStatus(HttpServletResponse.SC_CREATED);

//2
URI uri = URI.create("/api/members/"+newMemberId);
return ResponseEntity.created(uri).build();

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


앞선 코드는 member가 존재하지 않을 때 기본 HTML 에러 응답 대신에 JSON 응답을 제공하기 위해 ResponseEntity를 사용했다.

그런데 회원이 존재하지 않을 때 404 상태 코드를 응답해야 하는 기능이 많으면 에러 응답을 위해 ResponseEntity를 생성하는 코드 중복이 발생한다.

이때 @ExceptionHandler 어노테이션을 적용한 메서드에서 에러 응답을 처리하도록 구현하면 중복을 없앨 수 있다.

@GetMapping("/api/members/{id}")
public ResponseEntity<Object> member(@PathVariable Long id){
  	Member member = memberDao.selectById(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() 메서드는 Member 자체를 리턴한다.

회원 데이터가 존재하면 Member 객체를 리턴하므로 JSON으로 변환한 결과를 응답한다.

회원 데이터가 존재하지 않으면 MemberNotFoundException을 발생한다.

이 익셉션이 발생하면 @ExceptionHandler 어노테이션을 사용한 handleNoData() 메서드가 에러를 처리한다.

404 상태 코드와 ErrorResponse 객체를 몸체로 갖는 ResponseEntity를 반환한다.

즉, MemberNotFoundException가 발생하면 상태코드가 404이고 몸체가 JSON 형식인 응답을 전송한다.

@RestControllerAdvice 어노테이션을 이용해서 에러 처리 코드를 별도 클래스로 분리할 수도 있다.
@RestControllerAdvice는 @ControllerAdvice와 동일하다.
차이는 @RestController와 동일하게 응답을 JSON, XML 형식으로 변환한다는 것이다.

@RestControllerAdvice("controller")
public class ApiExceptionAdvice{
	@ExceptionHandler(MemberNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNoData(){
		return ResponseEntity
    			.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("no member");
    }
}

@RestControllerAdvice를 사용하면 에러 처리 코드가 한 곳에 모여 효과적으로 에러 응답을 관리할 수 있다.

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


@Valid 어노테이션을 붙인 커맨드 객체가 값 검증에 실패하면 400 상태 코드를 응답한다.

@PostMapping("/api/members")
public ResponseEntity<Object> newMember(@RequestBody @Valid RegisterRequest regReq){
	...
}

문제는 HttpServletResponse를 이용해서 상태 코드를 응답했을 때와 마찬가지로 HTML 응답을 전송한다.

@Valid 어노테이션을 이용한 검증을 실패했을 때 HTML 응답 대신 JSON 형식의 응답을 제공하고 싶으면 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() 메서드를 이용해서 검증 에러가 있는지 확인한다.

검증에러가 존재하면 모든 에러 정보를 구하고 각 에러 코드 값을 연결한 문자열을 생성해서 errorCodes 변수에 할당한다.

이처럼 코드를 수정한 후 검증에 실패하는 데이터를 전송하면 JSON 응답이 오는 것을 확인할 수 있다.


@RequestBody를 붙인 경우 @Valid를 붙인 객체의 검증에 실패했을 때 Errors 타입 파라미터가 존재하지 않으면 MethodArgumentNotValidException이 발생한다.

따라서 다음과 같이 @ExceptionHandler 어노테이션을 이용해서 검증 실패시 에러 응답을 생성해도 된다.

@RestControllerAdvice("controller")
public class ApiExceptionAdvice{
	@ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleBindException(MethodArgumentNotValidException ex){
    	String errorCodes=ex.getBindingResult().getAllErrors()
        				.stream()
                        .map(error->error.getCodes()[0])
                        .collect(Collectors.joining(","));
                        
        return ResponseEntity
        		.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("errorCodes="+errorCodes);
    }
}

Chapter17 프로필과 프로퍼티 파일


1.프로필


프로필(Profile)은 스프링에서 환경별로 설정을 다르게 관리할 수 있도록 해주는 기능입니다.

이를 통해 개발 환경과 실제 서비스 환경의 설정을 분리하여 관리할 수 있으며, 실수를 방지하고 효율적인 개발과 배포를 도와줍니다.

프로필의 개념

프로필은 논리적인 이름으로서 설정 집합에 프로필을 지정할 수 있다. 스프링 컨테이너는 설정 집합 중에서 지정한 이름을 사용하는 프로필을 선택하고 해당 프로필에 속한 설정을 이용해서 컨테이너를 초기화할 수 있다.

프로필 적용의 예시

예를 들어 로컬 개발 환경을 위한 DataSource 설정을 "dev" 프로필로 지정하고 실 서비스를 위한 설정을 "real" 프로필로 지정한 뒤, "dev" 프로필을 이용해서 스프링 컨테이너를 초기화할 수 있다. 그럼 "dev" 프로필에 정의된 빈을 사용하게 된다.
image (6)

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


@configuration 어노테이션을 이용한 설정에서 프로필을 지정하려면 @Profile 어노테이션을 이용한다.

@Configuration
@Profile("dev")
public class DsDevConfig{
	@Bean(destroyMethod="close")
    public DataSource dataSource(){
    	...
    }
}

@Configuration
@Profile("real")
public class DsRealConfig{
	@Bean(destroyMethod="close")
    public DataSource dataSource(){
    	...
    }
}

스프링 컨테이너를 초기화할 때 "dev" 프로필을 활성화하면 DsDevConfig 클래스를 설정으로 사용한다.

두 dataSource 빈 중에 어떤 빈을 사용할지는 활성화한 프로필에 따라 달라진다. 특정 프로필을 선택하려면 컨테이너를 초기화하기 전에 setActiveProfiles() 메서드를 사용해서 프로필을 설정해야 한다.

context.getEnviroment().setActiveProfiles("dev");
context.register(MemberConfig.class, DsDevConfig.class, DsRealConfig.class);
context.refresh();

프로필을 사용할 때 주의할 점은 설정 정보를 전달하기 전에 어떤 프로필을 사용할지 지정해야 한다는 점이다.

dev로 설정 후 register() 메서드로 설정 파일 목록을 지정한다. 이후 refresh() 메서드를 실행해서 컨테이너를 초기화했다.

시스템 프로퍼티를 이용하여 설정할 수도 있다.

java -Dspring.profiles.active=dev main.Main

자바의 시스템 프로퍼티뿐만 아니라 OS의 "spring.profiles.active" 환경 변수에 값을 설정해도 된다. 프로필의 우선순위는 다음과 같다.

  • setActiveProfiles()
  • 자바 시스템 프로퍼티
  • OS 환경 변수

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


중첩 클래스를 이용해서 프로필 설정을 한 곳으로 모을 수 있다.

@Configuration
public class MemberConfigWithProfile{
	@Autowired
    private DataSource dataSource;
    
    @Bean
    public MemberDao memberDao(){
    	return new MemberDao(dataSource);
    }
    
    @Profile("dev")
	public class DsDevConfig{
		@Bean(destroyMethod="close")
   	 	public DataSource dataSource(){
    	...
    	}
	}

    @Profile("real")
    public class DsRealConfig{
        @Bean(destroyMethod="close")
        public DataSource dataSource(){
            ...
        }
    }    
}

1.3 다수 프로필 설정


스프링 설정은 두 개 이상의 프로필 이름을 가질 수 있다.

@Configuration
@Profile("real,test")
public class DataSourceJndiConfig{
	...
}

프로필을 설정할 때 느낌표를 사용하면 해당 프로필이 활성화되지 않았을 때 사용한다는 것을 의미한다.

보통 "!프로필" 형식은 특정 프로필이 사용되지 않을 때 기본으로 사용할 설정을 지정하는 용도로 사용된다.

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


스프링은 외부의 프로퍼티 파일을 이용해서 스프링 빈을 설정하는 방법을 제공하고 있다.

다음과 같은 db.properties 파일이 있다고 하자. 이 파일의 프로퍼티 값을 자바 설정에서 사용할 수 있으며 이를 통해 설정 일부를 외부 프로퍼티 파일을 사용해서 변경할 수 있다.

db.driver=com.mysql.jdbc.Driver
db.url=jdbc:mysql://localhost/spring5fs?characterEncoding=utf8
db.user=spring5
db.password=spring5

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


자바 설정에서 프로퍼티 파일을 이용하려면 다음 두 가지를 설정한다.

 * PropertySourcesPlaceholderConfigurer 빈 설정
 * @Value 어노테이션으로 프로퍼티 값 사용

 ```java
 @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 타입 빈을 설정하는 메서드가 정적(static)이라는 것이다. 이는 PropertySourcesPlaceholderConfigurer 클래스가 특수한 목적의 빈이기 때문이며 정적 메서드로 지정하지 않으면 원하는 방식으로 동작하지 않는다.

PropertySourcesPlaceholderConfigurer 타입 빈은 setLocation() 메서드로 전달받은 프로퍼티 파일 목록 정보를 읽어와 필요할 때 사용한다. 이를 위한 것이 @Value 어노테이션이다.

```java
@Configuration
public class DsConfigWithProp{
	@Value("${db.driver}")
    private String driver;
    ...
}

@value 어노테이션이 ${구분자} 형식의 플레이스홀더 값으로 갖고 있다. ${db.driver} 플레이스홀더 값을 db.properties에 "db.driver" 프로퍼티 값으로 치환한다.

따라서 실제 빈을 생성하는 메서드는 @value 어노테이션이 붙은 필드를 통해서 해당 프로퍼티의 값을 사용할 수 있다.

2.2 빈 클래스에서 사용하기


빈으로 사용할 클래스에도 @value 어노테이션을 붙일 수 있다.

@value 어노테이션을 필드에 붙이면 플레이스홀더에 해당하는 프로퍼티를 필드에 할당한다.

public class Info{
	@Value("${info.version}")
    private String version
    ...
}

다음과 같이 @value 어노테이션을 set 메서드에 적용할 수도 있다

public class Info{
	private String version;
   	...
    @Value("${info.version}")
    public void setVersion(String version){
    	this.version=version;
    }
}

@hongsujin2eeZyo
Copy link

hongsujin2eeZyo commented Aug 3, 2024

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

1. 웹 어플리케이션의 구성요소

-프론트 서블릿
-컨트롤러 + 뷰
-서비스
-DAO

프론트 서블릿 : 웹 브라우저의 모든 요청을 받는 창구 역할을 하고
요청을 분석해서 알맞은 컨트롤러에 전달함, 스프링 MVC에서는 DispatcherServlet이 그 역할을 한다. 이를 통해서 애플리케이션의 다른 부분에서 요청 처리를 통합적으로 관리 할 수 있습니다.

컨트롤러 : 웹 브라우저의 요청을 처리함, 요청을 처리하기위해 알맞을 기능을 실행하고 그 결과를 뷰에 전달한다.요청 처리를 위해 필요한 데이터는 서비스와 상호작용을 하며 얻게되며
컨트롤러의 주요 역할은 다음과 같습니다.

컨트롤러의 주요 역할

  • 클라이언트가 요구한 기능을 실행
  • 응답결과를 생성하는데 필요한 모델 생성
  • 응답 결과를 생성할 뷰 선택

*컨트롤러는 직접 비즈니스 로직을 구현하지 않고, 서비스에 처리를 위임한다.
이로 인해서 비즈니스 로직과 웹 요청 처리 로직을 분리할 수 있게된다.

서비스 : 기능의 로직을 구현함, 서비스는 애플리케이션의 핵심 기능을 담당하며, 데이터의 처리를 위해 DAO와 상호작용 한다. 이 말은 DB 연동이 필요하면 DAO를 사용한다 라고 설명할수있음

DAO : Data Access Object의 약자, DB와 웹 어플리케이션 간에 데이터를 이동시켜 주는 역할을 함, DAO를 통해 데이터베이스에 접근하여 데이터를 조회, 삽입, 업데이트, 삭제할 수 있다.

2. 서비스의 구현

서비스 구현 예시 : 비밀번호 변경 기능

비밀번호 변경 기능의 로직은 다음과 같은 단계를 따른다.

  • DB에서 비밀번호를 변경할 회원의 데이터 구하기
  • 회원 데이터가 존재하지 않으면 Exception 발생시키기
  • 회원 데이터의 비밀번호 변경하기
  • 변경 내역을 DB에 반영하기

이런 로직들은 한 번의 과정으로 끝나기 보다는 위의 예시 처럼 몇 단계의 과정을 거치곤 한다.
중간과정에서 실패하면 이전까지 했던걸 취소해야하고, 모든 과정을 성공적으로 진행했을 때 완료 해야한다.
이런 이유로 서비스 메서드를 트랜잭션 범위에서 실행해야 한다.

스프링에서는 @transactional 을 이용하여 트랜잭션 범위에서 서비스 기능을 수행할 수 있다.

@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: 메서드가 트랜잭션 내에서 실행됨을 나타냅니다. 트랜잭션 내에서 실행된 모든 작업이 성공하거나 실패하는 경우에만 데이터베이스에 반영됩니다.

서비스 메서드의 파라미터 전달 방식

  1. 개별 파라미터 사용
public void changePAssword(String email, String oldPwd, String newPwd)

각 파라미터가 서비스 매서드에서 필요한 정보를 직접 전달한다.
장점:
이 방식은 간단한 데이터를 전달할 떄 유용하다.
위 코드에서는 비밀번호 변경을 위한 이메일주소, 현재 비밀번호, 새 비밀번호가 있다

  1. 별도의 클래스를 이용한 파라미터 전달
public void regist(RegisterRequest req)

서비스 매서드에 필요한 데이터를 객체로 묶어 전달하는 방식이다.
장점:
전달할 데이터가 많아질 경우 파라미터 리스트를 단순화할 수 있다.
데이터의 캡슐화를 강화할 수 있다.
데이터를 객체로 관리하기 때문에 데이터의 유효성 검증을 수행할 수 있다.

  1. 커맨드 객체를 이용한 웹 요청 파라미터 처리
@RequestMapping(method = RequestMethod.POST)
public String submit(
	@ModelAttibute("command") ChangePwdCommand pwdCmd,
	Errors errors, HttpSession session){
	...
	changePAsswordService.changePassword(
			authInfo.getEmail(),
			pwdCmd.getCurrentPassword(),
			pwdCmd.getNewPassword();
			...
	}

스프링 MVC는 웹 요청 파라미터를 객체로 자동으로 바인딩해주는 기능을 제공한다.

@ModelAttribute를 사용하면 폼 데이터가 자동으로 해당 객체의 필드에 바인딩됩니다.
예를 들어, ChangePwdCommand 클래스가 웹 폼의 데이터와 매칭됩니다.

장점:
폼 데이터와 서비스 매서드 간의 데이터 전달을 효율적으로 관리할 수 있습니다.
커맨드 객체의 필드를 통해 클라이언트로부터 전송된 데이터를 쉽게 접근하고 처리할 수 있습니다.

서비스 메서드의 결과 전달 방식(2가지)

  1. 리턴 값을 이용한 정상 결과 전달
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 객체는 인증된 사용자의 정보를 담고 있습니다. 이 객체를 클라이언트에 전달함으로써 인증이 성공적으로 이루어졌음을 알립니다.

  1. 익셉션을 이용한 비정상 결과 전달
@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";
	}
}

기능 실행 중 문제가 발생할 경우, 익셉션이 발생한다.
익셉션이 발생했다는것은 메서드는 호출자에게 비정상적인 상황을 알리게 되는것
예를 들어, WrongIdPasswordException은 사용자가 입력한 이메일이나 비밀번호가 잘못된 경우에 발생합니다.
여기서는 errors.reject를 통해 오류 메시지를 설정하고, 사용자가 다시 로그인 폼을 제출하도록 구현이 되어있음.

3. 컨트롤러에서의 DAO 접근

서비스 메서드에서 로직 수행하지 않고 DAO 메서드만 호출하고 끝나기도 한다
이런 경우는 주로 데이터베이스에서 특정 데이터를 조회하는 단순한 작업에서 이루어진다.

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

getMember 메서드는 memberDao 객체의 selectById 메서드를 호출하여 데이터베이스에서 특정 id에 해당하는 Member 객체를 조회합니다.조회된 Member 객체를 그대로 반환합니다.

4. 패키지 구성

패키지 구성 요소들은
웹 요청을 처리하기 위한 것과 기능을 제공하기 위한 것으로 구분할 수 있다.

웹 요청을 처리하기 위한 영역
-> 컨트롤러 클래스와 관련 클래스들

기능 제공 영역에는
-> 기능 제공을 위해 필요한 서비스, DAO, 그리고 Member와 같은 모델 클래스가 있음

웹 어플리케이션이 복잡해진다면,, -> 도메인 주도 설계를 통해 해결한다.

컨트롤러-서비스-DAO 구조는 간단한 웹 어플리케이션을 개발할 때는 괜찮지만, 기능이 많아지고 로직이 추가되면 구조적인 부분의 코드도 함께 복잡해진다.

도메인 주도 설계를 통해 해결할수있다.
도메인 주도 설계는 UI-서비스-도메인-인프라 구조로 어플리케이션을 구성하며,
UI는 컨트롤러 영역에, 인프라는 DAO 영역에 대응한다.

중요한 것은 도메인 모델 및 업무 로직이 서비스 영역이 아닌 도메인 영역에 위치한다는 것이다.도메인 영역은 정해진 패턴에 따라 모델을 구현하므로, 업무가 복잡해져도 일정 수준의 복잡도로 코드를 유지할 수 있도록 해준다.

[Chapter 16] JSON 응답과 요청

1. JSON 개요

SON (JavaScript Object Notation)은 데이터를 구조화하고 교환하는 데 널리 사용되는 경량의 데이터 형식입니다. JSON은 사람과 기계가 모두 쉽게 읽고 쓸 수 있으며, 클라이언트와 서버간의 데이터 교환에 주로 사용된다.

{
	"name":"유관순",
    "birthday": "1902-12-16",
	"age":"17",
	"related":["남동순","류예도"]
    ```
}

JSON의 기본 구조

객체(Object): {}로 둘러싸인 키-값 쌍의 집합입니다. 키는 문자열로, 값은 다양한 데이터 타입이 될 수 있습니다.
예: {"name": "유관순", "age": 17}

배열(Array): []로 둘러싸인 값의 순서 있는 집합입니다. 배열의 요소는 객체, 배열, 문자열, 숫자 등 다양한 데이터 타입이 될 수 있습니다.
예: ["남동순", "류예도"]

위의 예시는 유관순이라는 사람에 대한 정보를 담고있습니다.

각 키-값 쌍이 다음과 같은 의미를 갖게된다.

"name": "유관순": 이름이 유관순인 사람
"birthday": "1902-12-16": 생일이 1902년 12월 16일
"age": 17: 나이가 17세
"related": ["남동순", "류예도"]: 관련된 사람들로 남동순과 류예도가 있음

JSON의 특징

  1. 간결함: XML과 같은 다른 데이터 형식에 비해 훨씬 간결하며, 데이터 전송량을 줄일 수 있습니다.
  2. 표준화: 언어에 종속되지 않으며, 거의 모든 프로그래밍 언어에서 JSON을 쉽게 처리할 수 있는 라이브러리를 제공합니다.
  3. 사람과 기계가 읽기 쉬움: JSON은 간단한 문법을 사용하여, 사람이 쉽게 이해하고 기계가 쉽게 읽을수있다.

이러한 특징 때문에 JSON은 웹 애플리케이션의 데이터 교환 표준으로 널리 사용되고 있습니다.

2. Jackson 의존 설정

Jackson - 자바 객체와 JSON 간의 변환을 처리하는 라이브러리

스프링에서는 Jackson을 이용하여 자바 객체를 JSON 형식으로 직렬화하거나, JSON 데이터를 자바 객체로 역직렬화하는 작업을 쉽게 할 수 있습니다.

스프링에서 Jackson을 사용하려면, pom.xml 파일에 Jackson 관련 의존성을 추가하면 된다.

자바 객체와 JSON간의 변환 예시.

public class Person{
	private String name;
    private int age;
    
    ...get/set 메서드
}

^
||
v

{
	"name":"이름",
    "age": 10
}

Person 객체를 JSON 형식으로 변환하면 다음과 같은 JSON 문자열이 생성됩니다

3. @RestController로 JSON 형식 응답

스프링에서 JSON 형식으로 데이터를 응답하는 방법
-> @controller 대신 @RestController 를 사용하면 됨

@RestController 사용법

@RestController@controller@responsebody를 결합한 형태로, 메서드의 반환값을 HTTP 응답 본문으로 직접 전송합니다. 따라서, 별도로 @responsebody를 사용할 필요가 없습니다.
반환되는 객체는 자동으로 JSON 형식으로 변환됩니다. 이때 Jackson 라이브러리가 클래스패스에 있으면 스프링은 Jackson을 사용하여 객체를 JSON으로 변환합니다.

3.1 @JsonIgnore를 이용한 제외 처리

@JsonIgnore 애노테이션을 사용하면 JSON 직렬화 및 역직렬화 시 특정 필드를 무시할 수 있습니다. 이는 특히 암호와 같은 민감한 정보를 API 응답에 포함시키지 않기 위해 유용합니다. @JsonIgnore를 적용한 필드는 JSON 변환 과정에서 무시되어, 클라이언트에게 전송되지 않습니다.

다음과 같이 JSON 응답에 포함시키지 않을 대상에 @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;
}

사용 시 주의점
@JsonIgnore는 필드에 직접 적용해야 하며, 메서드나 클래스 레벨에서는 적용되지 않습니다.

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

@jsonformat 애노테이션을 사용하면 Jackson을 통해 변환 할때 날짜와 시간의 형식을 지정할 수 있습니다. 기본적으로 Jackson은 LocalDateTime을 ISO-8601 형식으로 출력하지만, 이를 원하는 형식으로 조정하고자 할 때 @jsonformat의 pattern 속성을 활용할 수 있습니다.

기본 ISO-8601 형식 출력
@jsonformat을 사용하여 ISO-8601 형식의 문자열로 변환됩니다

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) // ISO-8601 형식으로 변환
	private LocalDateTime registerDateTime;
}

해당 부분 출력 결과 : "2018-03-01T11:07:49"

사용자 지정 패턴 사용
원하는 형식으로 변환해서 출력하고 싶다면 pattern 속성을 사용하면 된다.

import com.fasterxml.jackson.annotation.JsonFormat;
 
public class Member{

	private Long id;
	private String email;
	private String name;
	@JsonFormat(pattern = "yyyyMMddHHmmss")
	private LocalDateTime registerDateTime;
}

이 예제에서는 @jsonformat(pattern = "yyyyMMddHHmmss")을 사용
해당 부분 출력 결과 : "20180301020749"

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

날짜 형식을 변환할 모든 대상에 @jsonformat을 붙여야 한다면 번거로운데
스프링 MVC 설정을 변경하여 Jackson의 변환 규칙을 모든 날짜 타입에 적용하면@jsonformat을 사용하지 않고도 변환이 가능하다.

	...생략
public class MvcConfig implements webMvcConfigurer{
	...생략
	@Override
	public void extendMessageConverters(
		List<HttpMessageConverter<?>> converters){
		ObjectMapper objectMapper = Jackson2ObjectMapperBuilder
			.json()
			*.featuresToDisable(
				SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)*
                //타임스탬프형식 비활성화
			.build();
		converters.add(0,
			new MappingJackson2HttpMessageConverter(objectMapper));
		}
	}

ObjectMapper를 생성하고, SerializationFeature.WRITE_DATES_AS_TIMESTAMPS 기능을 비활성화하여 날짜가 타임스탬프가 아닌 ISO-8601 형식으로 출력되도록 합니다.

4. @RequestBody로 JSON 요청 처리

지금까지는 응답을 JSON으로 변환하는거였고,
반대로 JSON 형식의 요청 데이터를 자바 객체로 변환하는 기능에 대해 살펴보자

POST 방식이나 PUT 방식을 사용하면 name=이름&age=17 과 같은쿼리 문자열 형식이 아니라 다음과같은 형식의 데이터를 요청 데이터로 전송 할 수 있다.

{"name":"이름", "age":17 }

방법 -> 커맨드 객체에 @RequestBody만 붙이면 됨

...생략
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
...생략

@RestController
public class RestMemberController{
	...
	@PostMapping("/api/members")
	public void newMember(
		**@RequestBody** @valid RegisterRequest regReq,
		HttpServletResponse response) throws IOException{
		...
		}
	}
}

@RequestBody는 클라이언트가 전송한 JSON 형식의 요청 본문을 자바 객체로 자동으로 변환합니다. 이 과정에서 스프링의 메시지 변환기(HttpMessageConverter)가 Jackson을 사용하여 JSON을 자바 객체로 변환합니다.
@Valid 애노테이션과 함께 사용할 경우, 요청 데이터가 지정한 유효성 검증 규칙을 충족하는지 검사합니다.

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

yyyy-MM-ddTHH:mm:ss와 같이 특정 패턴 문자열을 LocalDateTime과 Date로 변환하고 싶다면
@jsonformat 애노테이션의 pattern 속성사용해서 패턴을 지정함

 @JsonFormat(pattern = "yyyyMMddHHmmss")
 private LocalDateTime birthDateTime;
 
 @JsonFormat(pattern = "yyyyMMdd HHmmss")
 private Date birthDate;

특정 속성이 아니라 해당 타입을 갖는 모든 속성에 적용하고 싶다면 스프링 MVC 설정을 추가하면 된다.

...
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 요청 객체 검증하기

JSON 요청 데이터를 자바 객체로 변환한 후, 데이터의 유효성을 검증하는 것은 매우 중요합니다. 이를 통해 잘못된 데이터 입력을 방지할 수 있습니다. 요청 객체의 검증을 위해 @Valid 애노테이션과 Validator를 사용할 수 있다.

@Valid를 사용한 경우 검증에 실패하면 400 상태코드를 응답한다.

Validator를 사용할 경우 다음과 같이 직접 상태 코드를 처리해야 한다.

PostMapping("/api/members")
public void newMember(
		@RequestBody RegisterRequest regRea, Errors errors,
		HttpServletResponse response) throws IOException {
	try {
       // Validator를 통해 수동 검증
		new RegisterRequestValidator) validate(regRea, errors);
     
        // 검증 실패 시 400 Bad Request 응답
		if (errors.hasErrors()) {
			response.sendError(HttpServletResponse.SC_BAD_REQUEST);
			return;
		} ...
} catch (DuplicateMemberException dupEx) {
	response.sendError(HttpServletResponse.SC_CONFLICT);
	}
}

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

ResponseEntity를 사용하면 API 응답을 보다 유연하게 제어할 수 있으며, JSON 응답과 상태 코드를 함께 설정할 수 있습니다.
에러 처리나 다양한 상태 응답을 JSON으로 처리할 때 유용합니다.

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

정상인 경우와 비정상인 경우 둘 다 JSON 응답 전송하는 방법은 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);
	}

리턴타입이 ResponseEntity면 ResponseEntity의 body로 지정한 객체를 사용해 변환한다
→ 위의 경우에는 member 객체 그리고 ErrorResponse 객체를 JSON으로 변환

상태코드와 몸체 데이터를 설정하는 기본 방법

ResponseEntity.status(상태코드).body(객체)

상태코드 200(OK) 와 몸체데이터를 설정하는 방법

ResponseEntity.ok(member)

몸체 내용이 없을때 body를 지정하지 않고 build()로 바로 생성

ResponseEntity.status(HttpStatus.NOT_FOUND).build()

또는 status() 메서드 대신에 다음과 같이 관련 메서드를 사용해도 된다. (몸체내용x)

 ReponseEntity.notFound().build()

몸체가 없을때 status() 대신 사용할수있는 메서드
noContent() : 204 return ResponseEntity.noContent().build();
badRequest() : 400 return ResponseEntity.badRequest().build();
notFound() : 404 return ResponseEntity.notFound().build();

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

한 메서드에서 정상 응답과 에러 응답을 ResponseBody로 생성하면 코드가 중복될 수 있다.

@ExceptionHandler를 사용하면 컨트롤러의 메서드에서 발생한 예외를 처리하고, 공통된 에러 응답 로직을 중앙에서 관리할 수 있다. 이 접근법을 사용하면 중복된 코드 작성 없이 에러 응답을 일관되게 처리할 수 있다.

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

getMember 메서드는 정상적인 경우 Member 객체를 JSON으로 변환하여 응답합니다.
@ExceptionHandler 애노테이션을 붙인 handleNoData 메서드는 MemberNotFoundException 예외가 발생했을 때 호출됩니다. 이 메서드는 404 상태 코드와 ErrorResponse 객체를 JSON 형식으로 응답합니다.

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

@Valid를 붙인 커맨드 객체가 값 검증에 실패하면 400 상태코드를 응답한다.
HttpServletResponse 와 마찬가지로 HTML을 응답 결과로 전송한다.

HTML 응답 데이터 대신에 JSON 형식 응답을 제공하고 싶다면 다음과 같이 Errors 타입 파라미터를 추가해서 직접 에러 응답을 생성하면 된다.

@PostMapping("/api/members")
public ResponseEntity<Object> newMember(
	@RequestBody @Valid RegisterRequest regReq,
	Errors errors){
if(errors.hasErrors()){
	String errorCodes = errors.getAllErrors()
		.stream()
		.map(error->error.getCodes()[0])
		.collect(Collectors.joining(","));
	return ResponseEntity
		.status(HttpStatus.BAD_REQUEST)
		.body(new ErrorResponse("errorCodes = " + errorCodes));
	}
	...생략
}

이 코드는 hasErrors 메서드를 이용하여 검증 에러가 존재하는지 확인한다. 검증에러가 존재하면 getAllErrors() 메서드로 모든 에러 정보를 구하고 각 에러의 코드 값을 연결한 문자열을 생성해서 errorCodes 변수에 할당한다.

@requestbody를 사용하면 @Valid를 붙인 객체 검증에 실패했을 때 Errors 타입 파라미터가 존재하지 않으면 MethodArgumentNotValidException이 발생한다.
따라서 @ExceptionHandler를 이용해서 검증 실패시 에러 응답을 생성하면 된다.

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

1. 프로필

개발을 진행하는 동안에는 실제 서비스 목적으로 운영중인 DB를 이용할 수 없음
-> 개발용 DB를 따로 사용하거나 개발 PC에 직접 DB를 설치해서 사용한다

실제 서비스 환경에서는 웹 서버와 DB서버가 서로 다른 장비에 설치된 경우가 많음
개발 환경에서 사용한 DB 계정과 실 서비스 환경에서 사용할 DB 계정이 다른 경우도 많음
따라서 개발을 완료한 어플리케이션을 실제 서버에 배포하려면 실 서비스 환경에 맞는 JDBC 연결 정보를 사용해야함.

실 서비스 장비에 배포하기 전에 설정 정보를 변경하고 배포하는 방법은 원시적이며 실수하기 쉽다.
->오타를 입력할 수 있음
->개발 환경설정을 실 서비스 환경에 배포할 수 있음
->실 서비스 정보를 그대로 두고 개발을 진행할 수 있음

이러한 실수를 방지하기 위한 방법은??
처음부텉 개발 목적 설정과 실 서비스 목적의 설정을 구분해서 작성한다. 이러한 기능을 프로필이라고 한다.

설정 집합에 프로필을 지정할 수 있으며 스프링 컨테이너는 설정 집합 중에서 지정한 이름을 사용하는 프로필을 선택하고 해당 프로필에 속한 설정을 이용해서 컨테이너를 초기화할 수 있음

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

@configuration 어노테이션을 이용한 설정에서 프로필을 지정하려면 @Profile 애노테이션을 이용한다.

@Configuration
@Profile("dev")
public class DsDevConfig{

}
@Configuration
@Profile("real")
public class DsRealConfig{

}

같은 타입의 빈을 설정하고 있다고 가정하고
두 빈 중에서 어떤 빈을 사용할지는 활성화한 프로필에 따라 달라진다
dev 프로필을 활성화 하면 @Profile("dev") 애노테이션을 붙인 설정 클래스의 빈을 사용하고
real 프로필을 활성화 하면 @Profile("real") 애노테이션을 붙인 설정 클래스의 빈을 사용한다.

특정 프로필을 선택하려면 컨테이너를 초기화하기 전에 setActiveProfile() 메서드를 사용해서 프로필을 선택해야함.

AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.getEnvrionment().setActiveProfiles("dev"); 
//-> "dev" 프로필에 속한 설정이 사용되는 것
context.register(MemberConfig.class, DsDevConfig.class, DsRealConfig.class);
context.refresh();

프로필을 사용할 때 주의할 점
다음 순서를 지켜야함!!

  1. 설정 정보를 전달하기 전에 어떤 프로필을 사용할지 지정
  2. register() 메서드로 설정 파일 목록을 지정
  3. refresh() 메서드를 실행해서 컨테이너를 초기화

이 순서를 지키지 않고 프로필을 선택하기 전에 설정 정보를 먼저 전달하면 프로필을 지정한 설정이 사용되지 않기 때문에 설정을 읽어오는 과정에서 빈을 찾지 못해 익셉션이 발생함.

두개 이상 프로필을 활성화 하고 싶다면??
->다음과 같이 각 프로필 이름을 메서드에 파라미터로 전달하면 된다.

context.getEnvironment().setActiveProfiles("dev","mysql")

프로필을 선택하는 또다른 방법

방법 2
spring.profildes.active 시스템 프로퍼티에 사용할 프로필 값을 지정하는 것.
두 개 이상일 경우 사용할 프로필을 콤마로 구분해서 설정하면 된다.

시스템 프로퍼티는 명령행에서 -D 옵션을 이용하거나 System.setProperty()를 이용해서 지정할 수 있음

java -Dspring.profiles.active=dev main.Main

위와 같이 시스템 프로퍼티로 프로필을 설정하면 setActivePropfiles() 메서드를 사용하지않아도 "dev" 프로필이 활성화된다.

방법 3
OS의 spring.profiles.active 환경 변수에 값을 설정해도 된다.

프로필 우선순위
1.setActiveProfiles
2. 자바 시스템 프로퍼티
3. OS 환경 변수

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

중첩 클래스를 이용해서 프로필 설정을 한 곳으로 모을 수 있다

@Configuration
public class MemberConfigWithProfile{
	@Autowired
	private DataSource dataSource;
	
	@Bean
	public MemberDao memberDao(){
		return new MemberDao(dataSource);
	}

	@Configuration
	@Profile("dev")
	public static class DsDevConfig{
		```
	}
	
	@Configuration
	@Profile("real")
	public static class DsRealConfig{
		```
	}
}

주의할 점 : 중접 클래스는 static이어야 한다.

1.3 다수 프로필 설정

스프링 설정은 두 개 이상의 프로필 이름을 가질 수 있다.
아래 코드는 real과 test 프로필을 갖는 설정 예이다.

@Configuration
@Profilde("real,test")
public class DataSourceJndiConfig{
}

프로필 값을 지정할때 느낌표(!)를 사용할 수도 있다.

@Configuration
@Profile("!real")
public class DsDevConfig{
	```
}

!프로필 형식은 특정 프로필이 사용되지 않을 때 기본으로 사용할 설정을 지정하는 용도로 사용된다.

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

웹 어플리케이션의 경우에 프로필 선택 방법

  1. spring.profiles.active 시스템 프로퍼티나 활경 변수를 사용함
  2. web.xml에서 다음과 같이 spring.profiles.active 초기화 파라미터를 이용

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

스프링은 외부의 프로퍼티 파일을 이용해서 스프링 빈을 설정하는 방법을 제공하고 있다.
application.properties 파일

db.driver=com.mysql.jdbc.Driver
db.url=jdbc:mysql://localhost/spring5fs?chararcterEncoding=utf8
db.user=spring5
db.password=spring5

이 파일의 프로퍼티 값을 자바 설정에서 사용할 수 있으며 이를 통해 설정 일부를 외부 프로퍼티 파일을 사용해서 변경할 수 있다.

2.1 @configuration을 이용 자바 설정에서의 프로퍼티 사용

자바 설정에서 프로퍼티를 사용하려면 다음 두가지를 설정한다

  • propertySourcesPlaceholderConfigurer 빈 설정
  • @value 애노테이션으로 프로퍼티 값 사용
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 빈 설정 메서드는 항상 정적 메서드이어야한다
정적 메서드로 지정하지 않으면 원하는 방식으로 동작하지 않는다.

PropertySourcesPlaceholderConfigurer 타입 빈은 setLocatuons() 메서드로 전달받은 프로퍼티 파일 목록 정보를 읽어와서 필요할 때 사용한다.
이를 위한 것이 @value 애노테이션이다.

package config;
	```

@Congifuration
public class DsConfigWithProp{
	@Value("${db.driver}")
	private String driver;
	```
}

@value 애노테이션이 ${구분자} 형식의 플레이스홀더를 값으로 갖고있다
이 경우 PropertySourcesPlaceholderConfigurer는 플레이스홀더의 값을 일치하는 프로퍼티 값으로 치환한다.

위 예의 경우 ${db.driver} 플레이스홀더를 db.properties에 정의되어있는 "db.driver" 프로퍼티 값으로 치환한다.

따라서 실제 빈을 생성하는 메서드는 @value 애노테이션이 붙은 필드를 통해서 해당 프로퍼티의 값을 사용할 수 있다.

2.2 빈 클래스에서 사용하기

빈으로 사용할 클래스에도 @value 애노테이션을 붙일 수 있다.

package spring;

import org.springframework.beans.factory.annotation.Value;

public class Info{
	
	@Value("${Info.version}")
	private String version;
	```
}

다음과 같이 @value 애노테이션을 set 메서드에 적용할 수도 있다

public class Info{
	private String version;
    
   	```
    
    @Value("${info.version}")
    public void setVersion(String version){
    	this.version=version;
    }
}

@haewonee
Copy link

haewonee commented Aug 4, 2024

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

스프링 MVC 핵심 구성 요소

지금부터 스프링 MVC의 핵심 구성 요소와 각 요소 간의 관계를 알아보고자 한다.

DispatcherServlet은 모든 연결을 담당한다.
웹 브라우저로부터 요청이 들어오면 DispatcherServlet은 그 요청을 처리하기 위한 컨트롤러 객체를 검색하는데,

이때 DispatcherServlet은 직접 컨트롤러를 검색하지 않고 HandlerMapping이라는 빈 객체에게 컨트롤러 검색을 요청한다.
HandlerMapping은 클라이언트의 요청 경로를 이용해서 이를 처리할 컨트롤러 빈 객체를 DispatcherServlet에 전달한다.

컨트롤러 객체를 DispatcherServlet이 전달받았다고 해서 바로 컨트롤러 객체의 메서드를 실행할 수 있는 것은 아니다. 스프링 MVC는 다양한 방식의 핸들러(컨트롤러)를 지원하므로, 각 핸들러를 유연하게 처리하기 위해 적절한 HandlerAdapter를 사용해야한다.

DispatcherServlet은 HandlerMapping이 찾아준 컨트롤러 객체를 처리할 수 있는 HandlerAdapter 빈에게 요청 처리를 위임한다.
HandlerAdapter는 컨트롤러의 알맞은 메서드를 호출해서 요청을 처리하고 그 결과를 DispatcherServlet에 리턴하는데, 이때 컨트롤러의 처리 결과를 ModelAndView라는 객체로 변환해서 DispatcherServlet에 리턴한다.

HandlerAdapter로부터 컨트롤러의 요청 처리 결과를 ModelAndView로 받으면 DispatcherServlet은 결과를 보여줄 뷰를 찾기 위해 ViewResolver 빈 객체를 사용한다.
ModelAndView는 컨트롤러가 리턴한 뷰(논리적 뷰) 이름을 담고 있는데 ViewResolver는 이 논리적 뷰 이름을 실제 뷰(JSP파일)로 변환한다. 이 과정에서 새로운 View 객체가 생성된다. 이를 DispatcherServlet에 리턴한다.
DispatcherServlet은 생성된 View 객체를 사용하여 JSP를 렌더링하고, 결과를 클라이언트에게 응답으로 반환한다.

클라이언트의 요청을 실제로 처리하는 것은 컨트롤러인데 왜 컨트롤러를 검색할때 ControllerMapping타입이 아니라 HandlerMapping을 사용할까?

이는 @controller 애노테이션을 붙인 클래스를 이용해서 클라이언트의 요청을 처리하지만 자신이 직접 만든 클래스를 이용해 클라이언트의 요청을 처리할 수도 있기 때문이다.
스프링MVC 입장에서 @controller 적용 객체나 Controller 인터페이스를 구현한 객체는 모두 핸들러가 된다.
++DispatcherServlet은 실행 결과를 ModelAndView라는 타입으로 받아야하는데 핸들러의 실제 구현 타입에 따라 ModelAndView를 리턴하는 객체가 아닐 수 있다. 이를 변환해주는 처리를 HandlerAdapter가 해준다.

DispatcherServlet과 스프링 컨테이너


DispatcherServlet은 전달받은 설정 파일을 이용해 스프링 컨테이너를 생성하고 그 컨테이너로부터 필요한 빈 객체를 구한다.
따라서 DispatcherServlet이 사용하는 설정 파일에 이들 빈에 대한 정의가 포함되어 있어야 한다.

JSP를 위한 ViewResolver

@Bean
public ViewResolver viewResolver(){
	InternalResourceViewResolver vr = new InternalResourceViewResolver();
    vr.setPrefix("/WEB-INF/view/");
    vr.setSuffix(".jsp");
    return vr;
}

컨트롤러의 실행 결과를 받은 DispatcherServlet은 ViewResolver에게 뷰 이름에 해당하는 View 객체를 요청한다.
이때 InternalResourceViewResolver는 "prefix+뷰이름+suffix"에 해당하는 경로를 뷰 코드로 사용하는 View 객체를 리턴한다.
뷰 이름이 "haewon"이라면 "WEB-INF/view/haewon.jsp"경로를 뷰 코드로 사용하는 InternalResourceView 객체를 리턴하는 것이다.
DispatcherServlet이 InternalResourceView 객체에 응답 생성을 요청하면 그 객체는 경로에 지정한 JSP 코드를 실행해서 응답 결과를 생성한다.

@Controller
public class haewonController{
	@RequestMapping("/haewon")
	public String haewon(Model model, @RequestParam(value = "name, 
    required = false) String name){
    	model. addAttribute("greeting", "안녕하세요," + name);
        return "haewon";
        }
}

DispatcherServlet은 컨트롤러의 실행 결과를 HandlerAdapter를 통해서 ModelAndView형대로 받는다고 했다.
Model에 담긴 값은 View 객체에 Map 형식으로 전달된다.
예를 들어 haewonController 클래스는 다음과 같이 Model에 "greeting" 속성을 설정했는데 이 경우 DispatcherServlet은 View 객체에 응답 생성을 요청할 때 greeting 키를 갖는 Map 객체를 View 객체에 전달한다.
View객체는 전달받은 Map객체에 담긴 값을 이용해서 알맞은 응답 결과를 출력한다.

디폴트 핸들러와 HandlerMapping의 우선순위

디폴트 핸들러는 어떠한 HandlerMapping에도 매핑되지 않는 요청을 처리하는 핸들러이다. 이를 통해 특정 URL 패턴을 처리하지 않는 나머지 모든 요청을 처리할 수 있다.
결론적으로 HandlerMapping의 우선순위가 디폴트 핸들러보다 높다.
그렇기에 웹 브라우저의 요청이 들어오면 DispatcherServlet은 다음과 같이 동작한다.

  • DispatcherServlet은 요청 URL을 기반으로 등록된 HandlerMapping 빈을 사용하여 적절한 핸들러를 찾는다.
  • 적절한 핸들러가 발견되면, DispatcherServlet은 해당 핸들러를 실행한다.
  • 만약 어떤 HandlerMapping에서도 핸들러를 찾지 못한 경우, 디폴트 핸들러가 요청을 처리한다.

정리

DispatcherServlet은 웹 브라우저의 요청을 받기 위한 창구 역할을 하고, 다른 주요 구성 요소들을 이용해서 요청 흐름을 제어하는 역할을 한다.
HandlerMapping은 클라이언트의 요청을 처리할 핸들러 객체를 찾아준다.
핸들러 객체는 클라이언트의 요청을 실제로 처리한 뒤 뷰 정보와 모델을 설정한다.
HandlerAdapter는 Dispatcher와 핸들러 객체 사이의 변환을 알맞게 처리해준다.
ViewResolver는 요청 처리 결과를 생성할 View를 찾아주고 View는 최종적으로 클라이언트에 응답을 생성해서 전달한다.

@haewonee
Copy link

haewonee commented Aug 4, 2024

##MVC1 : 요청 매핑, 커맨드 객체, 리다이렉트, 폼 태그, 모델

요청 매핑

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

  • 특정 요청 URL을 처리할 코드
  • 처리 결과를 HTML과 같은 형식으로 응답하는 코드

이 중 첫 번째는 @controller 애노테이션을 사용한 컨트롤러 클래스를 이용해서 구현한다.

@Controller
public class HaewonController{
	@GetMapping("/haewon")
    public String haewon(Model model, @RequestParam(value = "name, 
    required = false)String name){
		model.addAttribute("greeting","안녕하세요"+name);
        return "haewon";
       }
 }

컨트롤러 클래스는 요청 매핑 애노테이션(@RequestMapping, @GetMapping 등) 을 사용해서 메서드가 처리할 요청 경로를 지정한다.
위 코드의 HaewonController 클래스는 @GetMapping 을 사용해서 "/haewon"의 요청 경로를 haewon()메서드가 처리하도록 설정하고 있다.

요청 매핑 애노테이션을 적용한 메서드를 두 개 이상 정의하는 것도 가능하다.
아래 코드와 같이 컨트롤러 클래스를 한 개만 만들고 n개의 메서드에서 각 요청 경로를 처리하도록 구현할 수 있다.

왼쪽 코드는 각 요청 매핑 애노테이션의 경로가 "/haewon"으로 시작하는데, 이렇게 메서드마다 중복되는 경로를 쓰지 않고 오른쪽 코드처럼 수정할 수 있다.
공통되는 부분의 경로를 담은 @RequestMapping 애노테이션을 클래스에 적용하고 각 메서드는 나머지 경로를 값으로 갖는 요청 매핑 애노테이션을 적용하는 것이다.

Get과 Post

@RequestMapping은 HTTP 메서드(GET, POST, PUT, DELETE 등)를 포함하여 다양한 요청을 매핑하는 데 사용할 수 있는 다목적 어노테이션이다.
-> 별도의 설정이 없다면 @RequestMapping에 지정한 경로와 일치하는 요청을 처리한다.
But, 내가 만약 Post방식 요청만 처리하고 싶다면? -> 메서드 앞에 @PostMapping 애노테이션을 사용해서 제한할 수 있다.
반대로 Get방식 요청만 처리하고 싶다면 메서드 앞에 @GetMapping 애노테이션을 붙여주면 된다.

@Controller
public class HaewonController{
	@GetMapping("/haewon/haha"); //같은경로
    public String form(){ ...
	}
    @PostMapping("/haewon/haha"); //같은경로
    public String login(){ ...
	}
}

Get, Post 애노테이션을 사용하면 위의 코드처럼 같은 경로에 대해 Get과 Post 방식을 각각 다른 메서드가 처리하도록 설정할 수 있다.

요청 파라미터 접근

컨트롤러 메서드에서 요청 파라미터를 사용하는 방법은 책에서 두가지가 나와있다.

  • HttpServletRequest 사용하기
  • @RequestParam 애노테이션 사용하기
@PostMapping("/haewon/step1"){
public String handleStep1(HttpServletRequest request){
	String agreeParam = request.getParam("agree");
    	 ~~~~
 }

첫 번째 방법은 위의 코드처럼 컨트롤러 처리 메서드의 파라미터로 HttpServletRequest 타입을 사용하고 getParameter() 메서드를 이용해서 파라미터의 값을 구하는 것이다.

@PostMapping("/haewon/step1"){
public String handleStep1(@RequestParam(value = "agree", defaultValue = 
"false")Boolean agreeVal){
	if(!agreeVal){
    return "haewon/step1";
    }
    return "haewon/step2";
 }

요청 파라미터에 접근하는 두 번째 방법은 위의 코드처럼 @RequestParam 애노테이션을 사용하는 것이다.
위 코드는 agree 요청 파라미터의 값을 읽어와 Boolean 타입으로 변환해서 agreeVal 파라미터에 전달한다.

위의 handleStep1 메서드는 agree 요청 파라미터 값이 true가 아니라면 "haewon/step1" 뷰 이름을 리턴할 것이고 화면은 왼쪽 그림처럼 뜨게 될 것이다. true라면 "haewon/step2" 뷰 이름을 리턴할 것이고 화면은 오른쪽그림 처럼 뜨게 될것이다.
->이는 입력 파라미터의 값에 따라 다른 뷰 결과를 보여준다.

리다이렉트 처리

웹 브라우저에서 http:/localhost:8080/~~~/haewon/step1 주소를 직접 입력하면 에러 화면이 출력된다.
->이는 우리가 아까 봤던 코드에서 @PostMapping 애노테이션을 사용해주었기에 Get방식 요청 처리는 지원하지 않는다.

하지만 잘못된 전송 방식으로 요청이 왔을 때 에러 화면보다 알맞은 경로로 리다이렉트 하는 것이 더 좋을 수 있다.

컨트롤러에서 특정 페이지로 리다이렉트 시키는 방법은 "redirect:경로"를 뷰 이름으로 리턴하면 된다

아까 코드 생략
@GetMapping("haewon/step1")
public String handleStep2Get(){
	return "redirect:/haewon/step2";
}

위 코드는 /haewon/step1 경로를 Get방식으로 접근할 때 /haewon/step2 경로로 리다이렉트 해준 코드이다.
-> 이는 Get방식 요청이 들어와도 에러화면을 띄우지 않고 /haewon/step2를 화면에 띄우게 된다.

커맨드 객체를 이용해 요청 파라미터 사용하기

step1.jsp가 생성하는 폼이 email, name, password, confirmPassword 파라미터를 이용해서 정보를 서버에 전송한다고 해보자.

폼 요청을 처리하는 컨트롤러 코드는 각 파라미터의 값을 구하기 위해 다음과 같은 코드를 사용할 수 있다.

@PostMapping("/haewon/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);
    ~~~~
}

위 코드는 올바르게 동작하나, 요청 파라미터 개수가 증가할 때마다 코드의 길이도 길어지게된다.
->** 스프링은 요청 파라미터의 값을 커맨드 객체에 담아주는 기능을 제공하여 이런 불편함을 줄이게 해준다.**
예를 들어 이름이 email인 요청 파라미터의 값을 커맨드 객체의 setEmail() 메서드를 사용해서 커맨드 객체에 전달하는 기능을 제공한다.
커맨드 객체라고해서 특별한 코드를 작성하는 것은 아니고 세터 메서드를 포함하는 객체를 커맨드 객체로 사용하면 된다.

@PostMapping("/haewon/step3")
public String handleStep3(RegisterRequest reqReq){
	~~~
}

커맨드 객체는 다음과 같이 요청 매핑 애노테이션이 적용된 메서드의 파라미터에 위치한다.
RegisterRequest 클래스에는 setEmail(), setName() , setPassword(), setConfirmPassword() 메서드가 존재한다.
스프링은 이 메서드들을 사용해서 email, name, password, confirmpassword 요청 파라미터 값을 커맨드 객체에 복사한 뒤 reqReq 파라미터로 전달한다.
-> 즉 스프링MVC가 handleStep3() 메서드에 전달할 RegisterRequest 객체를 생성하고 그 객체의 세터 메서드를 이용해 일치하는 요청 파라미터의 값을 전달하는 것이다.

뷰 JSP 코드에서 커맨드 사용하기

스프링 MVC는 커맨드 객체의 (첫 글자를 소문자로 바꾼) 클래스 이름과 동일한 속성 이름을 사용해서 커맨드 객체를 뷰에 전달한다.
커맨드 객체의 클래스 이름이 위에서 봤던 코드처럼 RegisterRequest인 경우 JSP 코드는 registerRequest라는 이름을 사용해서 커맨드 객체에 접근할 수 있다.

if) 커맨드 객체에 접근할 때 사용할 속성 이름을 변경하고 싶다면
-> 커맨드 객체로 사용할 파라미터에 @ModelAttribute 애노테이션 적용

@PostMapping("/haewon/step3")
public String handleStep3(@ModelAttribute("formData") RegisterRequest 
reqReq){
	~~~
}

위 코드는 @ModelAttribute를 사용하여 뷰 코드에서 formData라는 이름으로 커맨드 객체에 접근할 수 있게 되었다.

주요 에러 발생 상황

요청 매핑 애노테이션과 관련된 주요 익셉션

  • 404 에러: 요청 경로를 처리할 컨트롤러가 존재하지 않거나, WebMvcConfigurer를 이용한 설정이 없거나 뷰 이름에 해당하는 JSP 파일이 존재하지 않는다면 발생하는 에러.
  • 405 에러: 지원하지 않는 전송 방식을 사용한 경우 발생하는 에러.
    ex) POST 방식만 처리하는 요청 경로를 GET방식으로 연결하면 발생

@RequestParam이나 커맨드 객체와 관련된 주요 익셉션

  • 400에러:
    -요청 파라미터의 값을 @RequestParam이 적용된 파라미터 타입으로 변환할 수 없는 경우 발생
    -요청 파라미터 값을 커맨드 객체에 복사하는 과정에서도 발생. 만약 커맨드 객체의 프로퍼티가 int 타입인데 요청 파라미터의 값이 "abc"라면, "abc"를 int 타입으로 변환할 수 없기에 에러 발생

Model을 통해 컨트롤러에서 뷰에 데이터 전달하기

컨트롤러는 뷰가 응답 화면을 구성하는데 필요한 데이터를 생성해서 전달해야하는데 이때 사용하는 것이 Model이다.
뷰에 데이터를 전달하는 컨트롤러는 다음 두가지를 하면 된다.

  • 요청 매핑 애노테이션이 적용된 메서드의 파라미터로 Model을 추가
  • Model 파라미터의 addAttribute()메서드로 뷰에서 사용할 데이터 전달
@Controller
public class HaewonController{
	@RequestMapping("/haewon")
    public String haewon(Model model, @RequestParam(value = "name",
    required = false)String name){
		model.addAttribute("greeting","안녕하세요"+name);
        return "haewon";
     }
}

addAttribute()메서드의 첫번째 파라미터는 속성 이름이다. 뷰 코드는 이 이름을 사용해서 데이터에 접근한다.
위 코드에서는 greeting을 사용해 데이터에 접근하고 있다.
JSP에선 ${greeting}을 사용해서 속성값에 접근한다.

지금까지 구현한 컨트롤러는 두가지 특징이 있었다.

  • Model을 이용해서 뷰에 전달할 데이터 설정
  • 결과를 보여줄 뷰 이름을 리턴

지금까진 이 둘을 따로 처리한 예시들을 봤으나 ModelAndView는 모델과 뷰 이름을 함께 제공하기에 이를 사용하면 이 두 가지를 한번에 처리할 수 있다.

@taekyun0219
Copy link

taekyun0219 commented Aug 4, 2024

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

JDBC API를 이용하면 DB연동에 필요한 Connection을 구한 다음 쿼리를 실행하기 위한 PreparedStatement를 생성함. 그리고 쿼리를 실행한 뒤에는 finally 블록에서 ResultSet, PreparedStatement, Connection을 닫음

→ 그냥 구조적으로 반복되고 중복되는 코드가 많음.

→ 그래서 스프링은 구조적인 반복을 줄이기 위해 템플릿 메서드 패턴전략 패턴 을 함께 사용하기 위해 JdbcTemplate 클래스를 제공함.

그리고 스프링의 또 다른 장점은 트랜잭션 관리가 쉽다는 것임. JDBC API로 트랜잭션을 처리하려면 Connnection의 setAutoCommit(false)을 이용해서 자동 커밋을 비활성화하고, commit()과 rollback() 메서드를 이용해서 트랜잭션을 커밋하거나 롤백해야함

또한 트랜잭션을 적용하고 싶은 메서드에 @transactional 애노테이션을 붙이기만 하면 커밋과 롤백처리는 스프링이 알아서 처리함.

실제 서비스 운영 환경에서는 서로 다른 장비를 이용해서 자바 프로그램과 DBMS를 실행함. 자바 프로그램에서 DBMS로 커넥션을 생성하는 시간은 매우 길기 때문에 DB 커넥션을 생성하는 시간은 전체 성능에 영향을 줄 수 있음.

→ 최초 연결에 따른 응답 속도 저하와 동시 접속자가 많을 때 발생하는 부하를 줄이기 위해 사용하는 것이 커넥션 풀 이다.

커넥션 풀은 일정 개수의 DB 커넥션을 미리 만들어주는 기법으로, DB 커넥션이 필요한 프로그램은 커넥션 풀에서 커넥션을 가져와 사용한 뒤, 커넥션을 다시 풀에 반납.

  • 커넥션을 미리 생성해주기 때문에 시간을 아끼고, 동시 접속자가 많아도 커넥션을 생성하는 부하가 적어서 더 많은 동시 접속자 처리 가능 (이 책에서는 Tomcat JDBC 모듈 사용)

2 DB 테이블 생성

이 장에서는 DB를 사용해서 MemberDao 클래스를 구현함

package spring;

import java.util.Collection;

public class MemberDao{

	public Member selectByEmail(String email){
		return null;
	}
	
	public void insert(Member member){
	}
	
	public void update(Member member){
	}
	
	public Collection<Member> selectAll(){
		return null;
	}
}

그리고 DBMS로 MySQL을 사용함.

여기서 계속 나오는 DBMS는 데이터베이스 관리 시스템(영어: database management system, DBMS)은 다수의 사용자들이 데이터베이스 내의 데이터를 접근할 수 있도록 해주는 소프트웨어 도구의 집합이다.

3 DataSource 설정

JDBC API는 DriverManager 외에 DataSource를 이용해서 DB연결을 구함

DataSource를 사용하면 다음 방식으로 Connection을 구할 수 있음

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

→ DB 연동에 사용할 DataSource를 스프링 빈으로 등록하고, DB 연동 기능을 구현한 빈 객체는 DataSource를 주입받아 사용함 ( Tomcat JDBC 모듈은 javax.sql.DataSource를 구현한 DataSource 클래스를 제공)

이 클래스를 스프링 빈으로 등록해서 DataSource로 사용할 수 있다

package config;

import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DbConfig {

	@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;
	}
}
  • DataSource ds = new DataSource(); : DataSource 객체를 생성
  • ds.setDriverClassName("com.mysql.jdbc.Driver"); : JDBC 드라이버 클래스를 지정, MySQL 드라이버 클래스를 사용
  • ds.setUrl("jdbc:mysql://localhost/spring5fs?characterEncoding=utf8"); : JDBC URL을 지정, 데이터베이스와 테이블의 캐릭터셋을 UTF-8로 설정했으므로 MySQL에 연결할 때 사용할 캐릭터셋을 UTF-8로 지정
  • ds.setUsername("spring5");
    ds.setPassword("spring5"); : DB에 연결할 때 사용할 사용자 계정과 암호를 지정

@bean(destroyMethod = "close") 이 destroyMethod 속성값을 close로 설정하여 커넥션 풀에 보관된 Connection을 닫음

3.1 Tomcat JDBC의 주요 프로퍼티

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

이때 커넥션의 상태를 알아야하는데, 커넥션 풀은 커넥션을 생성하고 유지함. 커넥션 풀에 커넥션을 요청하면 해당 커넥션은 활성(active) 상태가 되고, 커넥션을 다시 커넥션 풀에 반환하면 유휴(idle) 상태가 됨.

→DataSource#getConnection()을 실행하면 커넥션이 활성상태가 되고, close하면 커넥션은 풀로 돌아가 유휴 상태가 됨.

package dbquery;

import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

import javax.sql.DataSource;

public class DbQuery {
	private DataSource dataSource;

	public DbQuery(DataSource dataSource) {
		this.dataSource = dataSource;
	}

	public int count() {
		Connection conn = null;
		try {
			conn = dataSource.getConnection();
			try (Statement stmt = conn.createStatement();
					ResultSet rs = stmt.executeQuery("select count(*) from MEMBER")) {
				rs.next();
				return rs.getInt(1);
			}
		} catch (SQLException e) {
			throw new RuntimeException(e);
		} finally {
			if (conn != null)
				try {
					conn.close();
				} catch (SQLException e) {
				}
		}
	}

}

이때, conn = dataSource.getConnection(); 를 실행해서 DataSource에서 커넥션을 가져와 커넥션 conn 은 활성상태가 되고, conn.close(); 을 실행하여 커넥션을 종료하면 실제 커넥션을 끊지 않고 풀에 반환함. 그럼 conn은 유휴 상태가 됨.

커넥션 풀을 사용하는 이유는 성능 때문인데, 커넥션 풀을 초기화할 때 최소 수준의 커넥션을 initialSize로 지정하고, MaxActive로는 활성상태가 가능한 최대 커넥션 수를 지정할 수 있음

커넥션 풀에 생성된 커넥션은 지속적으로 재사용되며, 한 커넥션이 영원히 유지되는 것은 아님

→ ex) DBMS에 5분동안 쿼리를 실행하지 않으면 DB 연결을 끊도록 설정했는데 DBMS는 해당 커넥션의 연결을 끊지만 커넥션은 여전히 풀 속에 남아있음. 이때 해당 커넥션을 풀에서 가져와 사용하면 익셉션이 발생하게 됨.

→ 이런 문제를 방지하기 위해 커넥션 풀의 커넥션이 유효한지 주기적으로 검사해야됨. 이와 관련된 속성이

minEvictableIdleTimeMillis, timeBetweenEvictionRunsMillis, testWhileIdele 임.

ds.setTestWhileIdle(true); //유휴 커넥션 검사
ds.setMinEvictableIdleTimeMillis(60000 * 3); //최소 유휴 시간 3분
ds.setTimeBetweenEvictionRunsMillis(10 * 1000); //10초 주기

4 JdbcTemplate을 이용한 쿼리 실행

스프링을 사용하면 JdbcTemplate을 이용해서 편리하게 쿼리를 실행할 수 있음

MemberDao클래스에 코드를 채워나가 보자

가장 먼저 JdbcTemplate 객체를 생성

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 객체를 생성하려면 this.jdbcTemplate = new JdbcTemplate(dataSource); 처럼 DataSource를 생성자게 전달하면 됨.

JdbcTemplate을 생성하는 코드를 MemberDao 클래스에 추가했으니 스프링 설정에 MemberDao 빈 설정을 추가하자

@Configuration
public class AppCtx{
	@Bean(destroyMethod = "close")
	public DataSource dataSource(){
		DataSource ds = new DataSource();
		...
		
	}
	
	**@Bean
	public MemberDao memberDao(){
		return new MemberDao(dataSource());
	}**
}

JdbcTemplate 클래스는 SELECT 쿼리 실행을 위한 query() 메서드를 제공함

query() 메서드는 sql 파라미터로 전달받은 쿼리를 실행하고 RowMapper를 이용해서 ResultSet의 결과를 자바 객체로 변환

select * from member where email = ?

이런 인덱스 기반 파라미터를 가진 쿼리이면 args 파라미터를 이용해서 각 인덱스 파라미터의 값을 지정함

RowMapper의 mapRow() 메서드는 SQL 실행 결과로 구한 ResultSet에서 한 행의 데이터를 읽어와 자바 객체로 변환하는 매퍼 기능을 구현함

package spring;

import java.sql.Connection;
import java.sql.PreparedStatement;
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.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper;
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 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);
	}

"select * from MEMBER where EMAIL = ?" 은 jdbcTemplate의 query() 메서드를 이용해 쿼리를 실행함. 이 쿼리는 인덱스 파라미터(물음표)를 포함함. 그리고 이 인덱스 파라미터에 들어갈 값을 email 로 지정함.

그리고 new RowMapper 이 부분부터 임의 클래스를 이용해서 RowMapper의 객체를 전달함. 이 RowMapper는 ResultSet에서 데이터를 읽어와 Member객체로 변환해주는 기능을 제공함

그래서 이 selectByEmail() 메서드는 지정한 이메일에 해당하는 Member데이터가 존재하면 해당 Member 객체를 리턴하고, 그렇지 않으면 null을 리턴하도록 구현함.

MemberDao에서 jdbcTemplate의 query()를 사용하는 또 다른 메서드는 selectAll()이다. 참고로 selectAll() 메서드의 리턴타입을 Collection에서 List로 변경함

package spring;

import java.sql.Connection;
import java.sql.PreparedStatement;
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.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper;
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 Member selectByEmail(String email) {
	..
	}

	public List<Member> selectAll() {
		List<Member> results = jdbcTemplate.query("select * from MEMBER",
				(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;
				});
		return results;
	}

위 코드는 setByEmail() 메서드와 동일한 RowMapper 임의 클래스를 사용함. 이와같이 Member를 위한 RowMapper 구현 클래스를 이용하도록 두 메서드를 수정하면 RowMapper 임의 클래스나 람다 식 중복을 제거할 수 있다.

그리고 Member 테이블의 전체 행 개수를 구하는 코드를 추가해보자. 이 코드도 query() 메서드를 사용했다.

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

count(8) 쿼리는 결과가 한 행뿐이니 쿼리 결과를 List로 받기보다는 Integer와 같은 정수타입으로 받으면 편리하다. 이를 위한 메서드가 queryForObject()이다. 이 메서드는 쿼리 실행 결과 행이 한 개인 경우에 사용할 수 있다. 두번째 파라미터인 Integer.class는 칼럼을 읽어올 때 사용할 타입을 지정하는 것이다.

ex) 평균을 구한다면 Double.class를 사용할 수 있다.

INSERT,UPDATE,DELETE 쿼리는 update() 메서드를 사용한다.

public void update(Member member) {
		jdbcTemplate.update(
				"update MEMBER set NAME = ?, PASSWORD = ? where EMAIL = ?",
				member.getName(), member.getPassword(), member.getEmail());
	}

update() 메서드는 쿼리 실행 결과로 변경된 행의 개수를 리턴한다.

MySQL의 AUTO_INCREMENT 칼럼은 행이 추가되면 자동으로 값이 할당되는 칼럼으로서 주요키 칼럼에 사용된다. 따라서 자동 증가 칼럼에 해당하는 값은 지정하지 않는다. 그런데 쿼리 실행 후에 변경된 키값을 알고싶다면 어떻게 해야할까?

JdbcTemplate는 자동으로 생성된 키 값을 구할 수 있는 방법을 제공하는데, 그게 바로 KeyHolder를 사용하는 것이다. KeyHolder를 사용하면 MemberDao의 insert() 메서드에서 삽입하는 Member 객체의 ID값을 구할 수 있다.

public void insert(Member member) {
		**KeyHolder keyHolder = new GeneratedKeyHolder();**
		jdbcTemplate.update(new PreparedStatementCreator() {
			@Override
			public PreparedStatement createPreparedStatement(Connection con)
					throws SQLException {
				// 파라미터로 전달받은 Connection을 이용해서 PreparedStatement 생성
				PreparedStatement pstmt = con.prepareStatement(
						"insert into MEMBER (EMAIL, PASSWORD, NAME, REGDATE) " +
						"values (?, ?, ?, ?)",
						new String[] { "ID" });
				// 인덱스 파라미터 값 설정
				pstmt.setString(1, member.getEmail());
				pstmt.setString(2, member.getPassword());
				pstmt.setString(3, member.getName());
				pstmt.setTimestamp(4,
						Timestamp.valueOf(member.getRegisterDateTime()));
				// 생성한 PreparedStatement 객체 리턴
				return pstmt;
			}
		}, keyHolder);
		Number keyValue = keyHolder.getKey();
		member.setId(keyValue.longValue());
	}
  • KeyHolder keyHolder = new GeneratedKeyHolder(); : GeneratedKeyHolder 객체를 생성한다. 이 클래스는 자동 생성된 키값을 구해주는 KeyHolder 구현 클래스이다.
  • jdbcTemplate.update(new PreparedStatementCreator() : update() 메서드는 PreparedStatementCreator 객체와 KeyHolder 객체를 파라미터로 갖는다

즉, JdbcTemplate의 update() 메서드는 PreparedStatement를 실행한 후 자동 생성된 키값을 KeyHolder에 보관하고, 이 보관된 키값은 getKey() 메서드를 이용해서 구한다.

5 MemberDao 테스트하기

지금까지 JdbcTemplate을 이용해서 MemberDao 클래스를 완성했다.

이 설정을 사용하는 메인 클래스를 통해 MemberDao가 정상적으로 동작하는지 보자

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);
		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("[email protected]");
		String oldPw = member.getPassword();
		String newPw = Double.toHexString(Math.random());
		member.changePassword(oldPw, newPw);

		memberDao.update(member);
		System.out.println("암호 변경: " + oldPw + " > " + newPw);
	}

	private static DateTimeFormatter formatter = 
			DateTimeFormatter.ofPattern("MMddHHmmss");

	private static void insertMember() {
		System.out.println("----- insertMember");

		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() + " 데이터 추가");
	}

}
  • AnnotationConfigApplicationContext ctx =
    new AnnotationConfigApplicationContext(AppCtx.class); : AppCtx 설정을 사용해서 스프링 컨테이너를 생성한다. 그리고 컨테이너로부터 “memberDao” 빈을 구해서 memberDao 필드에 할당한다.

MainForMemberDao 클래스를 실행할 때 DB 연결 정보가 올바르지 않으면 다음과 같은 익셉션이 발생할 수 있다. (콘솔에 메시지가 출력됨)

  • Access denied for user ‘spring5@localhost’

이 에러메시지는 MySQL 서버에 연결할 권한이 없는 경우에 발생한다. 예를 들어 MySQL DB에 접근할 때 사용한 ‘spring5’ 계정의 암호를 잘못 입력한 경우 위 메시지가 발생한다.

  • CannotGetJdbcConnectionException, Communications link failure

이건 DB를 실행하지 않았거나 방화벽에 막혀 있어서 DB에 연결할 수 없다면 연결 자체를 할 수 없다는 에러 메시지가 출력된다

6 스프링의 익셉션 변환 처리

SQL 문법이 잘못됐을 때 발생한 메시지를 보면 익셉션 클래스가 org.springframework.jdbc 패키지에 속한 BadSqlGrammarException 클래스임을 알 수 있다. JDBC API를 사용하는 과정에서 SQLException이 발생하면 이 익셉션을 알맞은 DataAccessException으로 변환해서 발생한다.

DataAccessException은 스프링이 제공하는 익셉션 타입으로, 데이터 연결에 문제가 있을 때 스프링 모듈이 발생시킨다. 그렇다면 스프링은 왜 SQLException을 그대로 전파하지 않고 DataAccessException으로 변환할까?

주된 이유는 연동 기술에 상관없이 동일하게 익셉션을 처리할 수 있도록 하기 위함이다. 그래서 각 연동 기술에 따라 발생하는 익셉션을 스프링이 제공하는 익셉션으로 변환함으로써 구현 기술에 상관없이 동일한 코드로 익셉션을 처리할 수 있게 된다.

7 트랜잭션 처리

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

jdbcTemplate.update("update MEMBER set EMAIL = ?", email);
jdbcTemplate.update("insert into EMAIL_AUTH values (?,'T')", email);

그런데 만약 첫번째 쿼리를 실행한 후 두번째 쿼리를 실행하는 시점에서 문제가 발생하면 어떻게 될까?

→ 결과적으로는 두번째 쿼리 실행에 실패하면 첫번째 쿼리 실행 결과도 취소해야 올바른 상태를 유지한다.

이렇게 두개 이상의 쿼리를 한 작업으로 실행해야 할 때 사용하는 것이 트랜잭션이다.
트랜잭션은 여러 쿼리를 논리적으로 하나의 작업으로 묶어준다.
한 트랜잭션으로 묶인 쿼리 중 하나라도 실패하면 전체 쿼리를 실패로 간주하고 실패 이전에 실행한 쿼리를 취소한다.

  • 쿼리 실행 결과를 취소하고 DB를 기존 상태로 되돌리는 것을 롤백(rollback)
  • 트랜잭션으로 묶인 모든 쿼리가 성공해서 쿼리결과를 DB에 실제로 반영하는 것을 커밋(commit)

JDBC는 Connection의 setAutoCommit(false)를 이용해서 트랜잭션을 시작하고, commit()과 rollback()을 이용해서 트랜잭션을 반영하거나 취소한다.

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 애노테이션만 붙이면 된다.

→ 스프링은 @transactional 애노테이션이 붙은 changePassword() 메서드를 동일한 트랜잭션 범위에서 실행한다.

이 애노테이션이 제대로 동작하려면 다음의 두가지 내용을 스프링 설정에 추가해야한다.

  • 플랫폼 트랜잭션 매니저 (PlatformTransactionManager) 빈 설정
  • @transactional 애노테이션 활성화 설정
  1. PlatformTransactionManager는 스프링이 제공하는 트랜잭션 매니저 인터페이스이다. 스프링은 구현 기술에 상관없이 동일한 방식으로 트랜잭션을 처리하기 위해 이 인터페이스를 사용한다.
  2. @EnableTransactoinManagement 애노테이션은 @transactional 애노테이션이 붙은 메서드를 트랜잭션 범위에서 실행하는 기능을 활성화한다.

트랜잭션 처리를 위한 설정을 완료하면 이제 @transactional 애노테이션을 붙이면 된다.

근데 트랜잭션을 시작하고, 커밋하고, 롤백하는 것은 누가 어떻게 처리하는 걸까?

→ 앞서 7장에서 공통으로 적용되는 기능을 구현하는 방법으로 AOP를 설명했는데, 트랜잭션도 공통 기능 중 하나이다. 스프링은 @transactional 애노테이션을 이용해서 트랜잭션을 처리하기 위해 내부적으로 AOP를 사용한다. 그러므로 트랜잭션 처리도 프록시를 통해서 이루어진다는 것을 알 수 있다.

실제로 @transactional 애노테이션을 적용하기 위해 @EnableTransaction Management 태그를 사용하면 스프링은 @transactional 애노테이션이 적용된 빈 객체를 찾아서 알맞은 프록시 객체를 생성한다

→ 프록시 객체는 @transactional 애노테이션이 붙은 메서드를 호출하면 PlatformTransactionManager를 사용해서 트랜잭션을 시작하고, 실제 객체의 메서드를 호출한 뒤 성공적으로 실행되면 트랜잭션을 커밋한다.

커밋을 수행하는 주체가 프록시 객체였던 것처럼 롤백을 처리하는 주체 또한 프록시 객체이다.

try{
	cps.changePassword("[email protected]", "1234", "1111");
	System.out.println("암호를 변경했습니다.");
} catch (MemberNotFoundException e){
	System.out.println("회원 데이터가 존재하지 않습니다.");
} catch (WrongPasswordException e){
	System.out.println("암호가 올바르지 않습니다.");
}

이 코드의 실행결과를 보면 WrongPasswordException이 발생했을 때 트랜잭션이 롤백된 것을 알 수 있다. 실제로 @transactional을 처리하기 위한 프록시 객체는 원본 객체의 메서드를 실행하는 과정에서 RuntimeException이 발생하면 트랜잭션을 롤백한다

  • 별도 설정을 추가하지 않으면 발생한 익셉션이 RuntimeException일 때 트랜잭션을 롤백한다. WrongIdPasswordException 클래스를 구현할 때 RuntimeException을 상속한 이유는 바로 트랜잭션 롤백을 염두해 두었기 때문
  • JdbcTemplate은 DB연동 과정에 문제가 있으면 DataAccessException을 발생한다고 했는데 DataAccessException 또한 RuntimeException을 상속받고 있다. 따라서 JdbcTemplate 기능을 실행하는 도중 익셉션이 발생해도 프록시는 트랜잭션을 롤백한다
  • 하지만 SQLException은 RuntimeException을 상속하고 있지 않으므로 트랜잭션을 롤백하지 않음. 만약 트랜잭션을 롤백하고 싶다면 @transactional의 rollbackFor 속성을 사용해야 한다

@silverchaeJ
Copy link

8장
커넥션 풀 설정 부분에서 주요 프로퍼티에 대한 설명이 좀 더 자세했으면 좋겠습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants