글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.

 

애플리케이션의 비즈니스 로직이 올바르게 동작하려면 데이터를 사전 검증하는 작업이 필요하다.
이것을 유효성 검사 또는 데이터 검증이라고 부른다.

유효성 검사(validation)은 프로그래밍에서 매우 중요한 부분이며,
자바에서 가장 신경 써야 하는 것 중 하나로 NullPointException 예외가 있다.

일반적인 애플리케이션 유효성 검사의 문제점

  1. 계층별로 진행하는 유효성 검사는 검증 로직이 각 클래스별로 분산돼 있어 관리가 어렵다.
  2. 검증 로직에 의외로 중복이 많아 여러 곳에 유사한 기능의 코드가 존재할 수 있다.
  3. 검증해야 할 값이 많다면 검증하는 코드가 길어진다.
  4. 이러한 문제로 코드가 복잡해지고 가독성이 떨어진다.
위의 문제를 해결하기 위해 자바 진영에서는 2009년부터 Bean Validation이라는 데이터 유효성 검사 프레임워크를 제공
Bean Validation은 어노테이션을 통해 다양한 데이터를 검증하는 기능을 제공
Bean Validation을 사용한다는 것은 유효성 검사를 위한 로직을 DTO 같은 도메인 모델과 묶어서 각 계층에서 사용하면서 검증 자체를 도메인 모델에 얹는 방식으로 수행한다는 의미
어노테이션을 사용한 검증 방식이기 때문에 코드의 간결함 유지 가능

 

Hibernate Validator

  • Bean Validation 명세의 구현체
  • 스프링 부트에서는 Hibernate Validator를 유효성 검사 표준으로 채택해서 사용
  • 도메인 모델에서 어노테이션을 통한 필드값 검증을 가능하게 도와준다.

 

스프링 부트용 유효성 검사 관련 의존성 추가

// Gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Maven
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

 

스프링 부트의 유효성 검사

  • 유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시
  • 부트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에 유효성 검사를 DTO 객체를 대상으로 수행하는 것이 일반적

// ValidationRequestDto 클래스

import com.springboot.valid_exception.config.annotation.Telephone;
import com.springboot.valid_exception.data.group.ValidationGroup1;
import com.springboot.valid_exception.data.group.ValidationGroup2;
import lombok.*;
import javax.validation.constraints.*;

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {

    @NotBlank
    private String name;

    @Email
    private String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    //@Telephone(groups = ValidationGroup2.class)
    //@Telephone
    private String phoneNumber;

    @Min(value = 20, groups = ValidationGroup1.class)
    @Max(value = 40, groups = ValidationGroup1.class)
    //@Min(value = 20)
    //@Max(value = 40)
    private int age;

    @Size(min = 0, max = 40)
    private String description;

    @Positive(groups = ValidationGroup2.class)
    //@Positive
    private int count;

    @AssertTrue
    private boolean booleanCheck;

}
// ValidationController 클래스

import com.springboot.valid_exception.data.dto.ValidRequestDto;
import javax.validation.Valid;
import com.springboot.valid_exception.data.dto.ValidatedRequestDto;
import com.springboot.valid_exception.data.group.ValidationGroup1;
import com.springboot.valid_exception.data.group.ValidationGroup2;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/validation")
public class ValidationController {

    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

    @PostMapping("/valid")
    public ResponseEntity<String> checkValidationByValid(
            @Valid @RequestBody ValidRequestDto validRequestDto) {
        LOGGER.info(validRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
    }

    @PostMapping("/validated")
    public ResponseEntity<String> checkValidation(
            @Validated @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/group1")
    public ResponseEntity<String> checkValidation1(
            @Validated(ValidationGroup1.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/group2")
    public ResponseEntity<String> checkValidation2(
            @Validated(ValidationGroup2.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/all-group")
    public ResponseEntity<String> checkValidation3(
            @Validated({ValidationGroup1.class,
                    ValidationGroup2.class}) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }
}

 

대표적인 유효성 검사를 위한 어노테이션

  • 문자열 검증
    • @Null : null값만 허용
    • @NotNull : null 허용x
    • @NotEmpty : null, “” 허용 x
    • @NotBlank : null, “”, “ “ 허용x
  • 최댓값/최솟값 검증
    • BigDecimal, BigInteger, int, long 등의 타입을 지원
    • @DecimalMax(value = “$numberString”) : $numberString보다 작은 값을 허용
    • @DecimalMin(value = “$numberString”) : $numberString보다 큰 값 허용
    • @Min(value = $number) $number 이상의 값을 허용
    • @Max(vlaue = $number) $number 이하의 값을 허
  • 값의 범위 검증
    • BigDecimal, BigInteger, int, long 등의 타입을 지원
    • @Positive : 양수를 허용
    • @PositiveOrZero : 0을 포함한 양수 허용
    • @Negative : 음수 허용
    • @NegativeOrZero : 0을 포함한 음수 허용
  • 시간에 대한 검증
    • Date, LocalDate, LocalDateTime 등의 타입 지원
    • @Future : 현재보다 미래의 날짜 허용
    • @FutureOrPresent : 현재를 포함한 미래의 날짜 허용
    • @Post : 현재보다 과거의 날짜 허용
    • @PostOrPresent : 현재 포함한 과거의 날짜 허용
  • 이메일 검증
    • @Email : 이메일 형식을 검사, “”는 허용
  • 자릿수 범위 검증
    • BigDecimal, BigInteger, int, long 등의 타입을 지원
    • @Digits(Integer = $number1, fraction = $number2) : $number1의 정수 자릿수와 $number2의 소수 자릿수를 허용
  • Boolean 검증
    • @AssertTrue : true인지 체크, null 값은 체크하지 않
    • @AssertFalse : false인지 체크, null값 체크하지 않음
  • 문자열 길이 검증
    • @Size(min = $number1, max = $number2) : $number1 이상 $number2 이하의 범위를 허용
  • 정규식 검증
    • @Pattern(regexp = “$expression”) : 정규식을 검사, 정규식은 자바의 java.util.regex.Pattern 패키지의 컨벤션을 따름
정규식(Regular Expression)?
특정한 규칙을 가진 문자열 집합을 표현하기 위해 쓰이는 형식
전화번호, 주민번호, 이메일과 같이 특정 형식의 값을 검증해야 할 때가 있다.
이러한 값은 정규식을 통해 쉽게 검증 가능

 

@Validate활용

  • @Valid 어노테이션은 자바에서 지원하는 어노테이션
  • 스프링도 @Validated라는 별도의 어노테이션으로 유효성 검사를 지원
  • @Validated는 @Valid 어노테이션의 기능을 포함하고 있기 때문에 @Validated로 변경 가능
  • 또한 @Validated는 유효성 검사를 그룹으로 묶어 대상을 특정할 수 있는 기능이 있다.
  • @Validated : 어노테이션에 특정 그룹을 설정하지 않은 경우 gruops가 설정되지 않은 필드에 대해 유효성 검사를 수행
  • @Validated 어노테이션에 특정 그룹을 설정하는 경우 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사를 수행

 

커스텀 Validation 추가

  • 실무에서는 유효성 검사를 할 때 자바 또는 스프링의 유효성 검사 어노테이션에서 제공하지 않는 기능을 써야 할때도 있다.
  • 이 경우 ConstraintValidator와 커스텀 어노테이션을 조합해 별도의 유효성 검사 어노테이션 생성 가능
  • 전화번호 형식이 일치하는지 확인하는 간단한 유효성 검사 어노테이션
package com.springboot.valid_exception.config.annotation;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;


public class TelephoneValidator implements ConstraintValidator<Telephone, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if(value==null){
            return false;
        }
        return value.matches("01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$");
    }
}
package com.springboot.valid_exception.config.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.validation.Constraint;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TelephoneValidator.class)
public @interface Telephone {
    String message() default "전화번호 형식이 일치하지 않습니다.";
    Class[] groups() default {};
    Class[] payload() default {};
}

 

@Target 어노테이션

  • 이 어노테이션을 어디서 선언할 수 있는지 정의하는데 사용
ElementType.PACKAGE
ElementType.TYPE
ElementType.CONSTRUCTOR
ElementType.FILED
ElementType.METHOD
ElementType.ANNOTATION_TYPE
ElementType.LOCAL_VARIABLE
ElementType.PARAMETER
ElementType.TYPE_PARAMETER
ElementType.TYPE_USE

@Retention

  • 이 이노테이션이 실제로 적용되고 유지되는 범위를 의미
RetentionPolicy.RUNTIME 컴파일 이후에도 JVM에 의해 계속 참조. 리플렉션이나 로깅에 많이 사용되는 정책
RetentionPolicy.CLASS 컴파일러가 클래스를 참조할 때까지 유지
RetentionPolicy.SOURCE 컴파일 전까지만 유지. 컴파일 이후 사라짐

 

예외 처리

애플리케이션을 개발할 때는 불파기하게 많은 오류가 발생하게 된다.
자바에서는 이러한 오류를 try-catch, throw 구문을 활용해 처리
스프링 부트에서는 더욱 편리하게 예외를 처리할 수 있는 기능을 제공
스프링 부트에서 적용할 수 있는 예외 처리 방식에 대해 학습

예외와 에러

  • 예외(exception) : 입력 값의 처리가 불가능하거나 참조된 값이 잘못된 경우 등 애플리케이션이 정상적으로 동작하지 못하는 상황을 의미,
    예외는 개발자가 직접처리할 수 있는 것이므로 미리 코드 설계를 통해 처리 가능
  • 에러(error) : 에러는 주로 자바의 가상머신에서 발생시키는 것으로 예외와 달리 애플리케이션에서 코드로 처리할 수 있는 것이 거의 없다.
    대표적인 예로 메모리부족(OutOfMemory), 스택 오버플로(StackOverFlow)등이 있다.
    에러는 발생 시점에 처리하는 것이 아닌 미리 애플리케이션의 코드를 살펴보면서
    문제가 발생하지 않도록 예방해서 원천적으로 차단해야 한다.

 

예외 클래스

Checked Exception과 UnChecked Exception

  • Checked Exception : 컴파일 단계에서 확인 가능한 예외 상황, 이러한 예외는 IDE에서 캐치해서 반드시 예외 처리를 할 수 있게 표시 해준다.
  • Uncheck Exception : 런타임 단계에서 확인되는 예외 상황, 문법상 문제는 없지만 프로그램 동작 중 예기치 않은 상황이 생겨 발생하는 예
  • 간단히 분류하자면 RuntimeException을 상속받은 Exception 클래스는 Unchecked Exception이고 그렇지 않은 Exception 클래스는 Checked Exception

예외 처리 방법

예외가 발생 시 처리하는 방법은 크게 3가지가 있다.

  • 예외 복구
    • 예외 상황을 파악해서 문제를 해결하는 방식 대표적인 방법이 try-catch
  • 예외 처리 회피
    • 예외가 발생한 시점에서 바로 처리하는 것이 아닌 예외가 발생한 메서드를 호출한 곳에서 에러 처리를 할 수 있게 전가하는 방식, 이때 throw 키워드를 사용해 어떤 예외가 발생했는지 호출부에 내용을 전달할 수 있다.
  • 예외 전환
    • 앞의 두 방식을 적절하게 섞은 방식, 예외가 발생 시 어떤 예외가 발생했느냐에 따라 호출부로 예외 내용을 전달하면서 좀 더 적합한 예외 타입으로 전달할 필요가 있다. 또는 애플리케이션에서 예외 처리를 좀 더 단순하게 하기 위해 래핑(wrapping)해야 하는 경우도 있다. 이런 경우 try-catch방식을 사용하면서 catch 블록에서 throw 키워드를 사용해 다른 예외 타입으로 전달하면 된다.

스프링 부트의 예외 처리 식

  • @(Rest)ControllerAdvice와 @ExceptionHandler를 통해 모든 컨트롤러의 예외를 처리
  • @ExceptionHandler를 통해 특정 컨트롤러의 예외를 처리
@ControllerAdvice 대신 @RestControllerAdvice를 사용하면 결괏값을 JSON 형태로 반환할 수 있다.
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice //(basePackages = "com.springboot.valid_exception")
public class CustomExceptionHandler {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionHandler.class);

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("Advice 내 exceptionHandler 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleException(MethodArgumentNotValidException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

    @ExceptionHandler(value = CustomException.class)
    public ResponseEntity<Map<String, String>> handleException(CustomException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", e.getHttpStatusType());
        map.put("code", Integer.toString(e.getHttpStatusCode()));
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, e.getHttpStatus());
    }
}
import com.springboot.valid_exception.common.Constants.ExceptionClass;
import com.springboot.valid_exception.common.exception.CustomException;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    private final Logger LOGGER = LoggerFactory.getLogger(ExceptionController.class);

    @GetMapping
    public void getRuntimeException() {
        throw new RuntimeException("getRuntimeException 메소드 호출");
    }

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.setContentType(MediaType.APPLICATION_JSON);
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("클래스 내 handleException 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

    @GetMapping("/custom")
    public void getCustomException() throws CustomException {
        throw new CustomException(ExceptionClass.PRODUCT, HttpStatus.BAD_REQUEST, "getCustomException 메소드 호출");
    }
}

 

커스텀 예외

  • 커스텀 예외 클래스 생성에 필요한 내용
  1. 에러 타입(error type) : HttpStatus의 reasonPharse
  2. 에러 코드(error code) : HttpStatus의 value
  3. 메세지 (message) : 상황별 상세 메세지

public class Exception Throwable {
	static final long serialVersionUID = -3387516993124229948L;
    
    public Exception() {
    	super();
    }
    
    public Exception(String message) {
    	super(message);
    }
    
    public Exception(String message, Throwable cause) {
    	super(message, cause);
    } 
    
    public Exception(Throwable) {
    	super(cause);
    }
    
    protected Exception(String message, Throwable cause,
    					boolean enableSuppression,
                        boolean writableStackTrace) {
		super(message, cause, enableSuppression, writableStackTrace);                        
    }
}
package com.springboot.valid_exception.common.exception;

import com.springboot.valid_exception.common.Constants;
import org.springframework.http.HttpStatus;

public class CustomException extends Exception{

    private static final long serialVersionUID = 4300333310379239987L;

    private Constants.ExceptionClass exceptionClass;
    private HttpStatus httpStatus;

    public CustomException(Constants.ExceptionClass exceptionClass, HttpStatus httpStatus,
        String message) {
        super(exceptionClass.toString() + message);
        this.exceptionClass = exceptionClass;
        this.httpStatus = httpStatus;
    }

    public Constants.ExceptionClass getExceptionClass() {
        return exceptionClass;
    }

    public int getHttpStatusCode() {
        return httpStatus.value();
    }

    public String getHttpStatusType() {
        return httpStatus.getReasonPhrase();
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }

}
package com.springboot.valid_exception.common.exception;

import java.util.HashMap;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice //(basePackages = "com.springboot.valid_exception")
public class CustomExceptionHandler {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomExceptionHandler.class);

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleException(RuntimeException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("Advice 내 exceptionHandler 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleException(MethodArgumentNotValidException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

    @ExceptionHandler(value = CustomException.class)
    public ResponseEntity<Map<String, String>> handleException(CustomException e,
        HttpServletRequest request) {
        HttpHeaders responseHeaders = new HttpHeaders();
        LOGGER.error("Advice 내 handleException 호출, {}, {}", request.getRequestURI(),
            e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", e.getHttpStatusType());
        map.put("code", Integer.toString(e.getHttpStatusCode()));
        map.put("message", e.getMessage());

        return new ResponseEntity<>(map, responseHeaders, e.getHttpStatus());
    }
}
 글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.
왜 연관관계 매핑이 필요할까에 대해서 책에서 말하는 내용을 먼저 요약하자면

복잡한 애플리케이션에서 RDBMS를 사용할 때 테이블 하나만 사용해서 애플리케이션의 모든 기능을 구현하기란 사실상 불가능하다.
설계가 복잡해지면 각 도메인에 맞는 테이블을 설계하고 연관관계를 설정해서
조인(Join)등의 기능을 활용해야 한다.

JPA를 사용하는 애플리케이션도 테이블의 연관관계를 엔티티(entity) 간의 연관관계로 표현할 수 있다.
하지만 객체와 테이블의 성질이 달라 정확한 연관관계를 표현할 수는 없다.

JPA에서 이러한 제약을 보완하면서 연관관계를 매핑하고 사용하는 방법에 대해 알아본다.

 

연관관계 매핑 종류와 방향

  • One To One : 일대일(1 : 1)
  • One To Many : 일대다(1 : N)
  • Many To One : 다대일(N :1)
  • Many To Many : 다대다(N : M)
  • 단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식
  • 양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식
  • 연관관계가 설정 시 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다. 이런 관계에서 주인(Owner)이라는 개념이 사용 일반적으로 외래키를 가진 테이블이 관계의 주인 주인은 외래키를 사용할 수 있으나 상대 엔티티는 읽는 작업만 수행 가능하다.

일대일 매핑(1 : 1)

예시) 하나의 상품에 하나의 상품정보만 매핑이 되는구조(1 : 1)
// 상품 엔티티

package com.springboot.relationship.data.entity;

import java.util.ArrayList;
import java.util.List;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToMany;
import javax.persistence.ManyToOne;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;
import javax.persistence.Table;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @OneToOne(mappedBy = "product")
    @ToString.Exclude
    private ProductDetail productDetail;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;

    @ManyToMany
    @ToString.Exclude
    private List<Producer> producers = new ArrayList<>();

    public void addProducer(Producer producer){
        this.producers.add(producer);
    }

}

 

// 상품정보 엔티티

package com.springboot.relationship.data.entity;

import lombok.*;
import javax.persistence.*;

@Entity
@Table(name = "product_detail")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ProductDetail extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String description;

    /*
    @OneToOne 어노테이션에 'optional=false' 속성 설정 시 product가 null인 값을 허용하지 않음
    */
    @OneToOne //(optional=false)
    @JoinColumn(name = "product_number")
    private Product product;

}
  • @JoinColumn : 어노테이션을 사용해 매핑할 외래키 설정
    • name : 매핑할 외래키의 이름을 설정
    • referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명을 지정
    • foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정(unique, nullable, updatable 등)
// 상품정보 엔티티 객체들을 사용하기 위해 리포지토리 인터페이스 생성

package com.springboot.relationship.data.repository;

import com.springboot.relationship.data.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;


public interface ProductRepository extends JpaRepository<Product, Long> {


}
// ProductRepository와 ProductDetailRepository에 대한 테스트 코드

package com.springboot.relationship.data.repository;

import com.springboot.relationship.data.entity.Product;
import com.springboot.relationship.data.entity.ProductDetail;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class ProductDetailRepositoryTest {

    @Autowired
    ProductDetailRepository productDetailRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    public void saveAndReadTest1() {
        Product product = new Product();
        product.setName("스프링 부트 JPA");
        product.setPrice(5000);
        product.setStock(500);

        productRepository.save(product);

        ProductDetail productDetail = new ProductDetail();
        productDetail.setProduct(product);
        productDetail.setDescription("스프링 부트와 JPA를 함께 볼 수 있는 책");

        productDetailRepository.save(productDetail);

        // 생성한 데이터 조회
        System.out.println("savedProduct : " + productDetailRepository.findById(
                productDetail.getId()).get().getProduct());

        System.out.println("savedProductDetail : " + productDetailRepository.findById(
                productDetail.getId()).get());
    }
}

 

일대일 양방향 매핑

  • 앞에서 생성한 일대일 단방향 설정을 양방향으로 설정 변경
  • 사실 객체에서의 양방향 개념은 양쪽에서 단방향으로 서로를 매핑하는 것을 의
  • 일대일 양방향을 위한 Product 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @OneToOne(mappedBy = "product")
    private ProductDetail productDetail;

}
  • JPA에서도 실제 데이터 베이스의 연관관계를 반영해서 한쪽 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋다.
  • 이 경우 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줘야 하는데, 이때 사용되는 속성 값이 mappedBy, mappedBy는 어떤 객체가 주인인지 표시하는 속성, 여기서는 Product 객체에 붙음
  • 양방향으로 연관관계 설정 후 toString 사용할 때 순환참조 발생
  • 대체로 단방향으로 연관관계 설정 및 양방향 설정이 필요할 경우 순환참조 제거를 위해 exclude를 사용해 ToString에서 제외 설정하는 것이 좋다.
@OneToOne(mappedBy = "product")
@ToString.Exclude
    private ProductDetail productDetail;

 

다대일(N : 1), 일대다(1 : N) 매핑

  • 상품 테이블과 공급업체 테이블은 상품 테이블의 입장에서 볼 경우에는 다대일
  • 공급업체 테이블의 입장에서 볼 경우에는 일대다 관계로 볼 수 있다.

 

다대일 단방향 매핑

  • 공급업체 엔티티 클래스(Provider)
  • BaseEntity를 통해 생성일자와 변경일자를 상속받는다.
  • 일반적으로 외래키를 갖는 쪽이 주인 역할을 수행하기 때문에 상품 엔티티가 공급업체 엔티티의 주인
// 공급업체 엔티티

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
// 상품 엔티티와 공급업체 엔티티의 다대일 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @OneToOne(mappedBy = "product")
    @ToString.Exclude // 예제 9.8
    private ProductDetail productDetail;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;
// ProviderRepository 인터페이스
public interface ProviderRepository extends JpaRepository<Provider, Long> {

}
// Product, Provider 엔티티 연관관계 테스트
@SpringBootTest
class ProviderRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    ProviderRepository providerRepository;

    @Test
    void relationshipTest1() {
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("ㅇㅇ물산");

        providerRepository.save(provider);

        Product product = new Product();
        product.setName("가위");
        product.setPrice(5000);
        product.setStock(500);
        product.setProvider(provider);

        productRepository.save(product);

        // 테스트
        System.out.println("product : " + productRepository.findById(1L).orElseThrow(RuntimeException::new));

        System.out.println("provider : " + productRepository.findById(1L).orElseThrow(RuntimeException::new).getProvider());
    }
}

 

다대일 양방향 매핑

  • 공급업체 엔티티와 상품 엔티티의 일대다 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    // 일대다 연관관계의 경우 여러 상품 엔티티가 포함될 수 있어 컬렉션(Collection, List, Map)형식으로 필드를 생성
    private List<Product> productList = new ArrayList<>();

}
// Provider 엔티티 기반의 테스트 코드
@SpringBootTest
class ProviderRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    ProviderRepository providerRepository;

@Test
    void relationshipTest() {
        // 테스트 데이터 생성
        Provider provider = new Provider();
        provider.setName("ㅇㅇ상사");

        providerRepository.save(provider);

        Product product1 = new Product();
        product1.setName("펜");
        product1.setPrice(2000);
        product1.setStock(100);
        product1.setProvider(provider);

        Product product2 = new Product();
        product2.setName("가방");
        product2.setPrice(20000);
        product2.setStock(200);
        product2.setProvider(provider);

        Product product3 = new Product();
        product3.setName("노트");
        product3.setPrice(3000);
        product3.setStock(1000);
        product3.setProvider(provider);

        productRepository.save(product1);
        productRepository.save(product2);
        productRepository.save(product3);

        System.out.println("check 1");

        List<Product> products = providerRepository.findById(provider.getId()).get()
                .getProductList();

        for (Product product : products) {
            System.out.println(product);
        }

    }
}
  • Provider 엔티티 클래스는 Product 엔티티와의 연관관계에서 주인이 아니기 때문에 외래키를 관리할 수 없다.
  • 주인이 아닌 엔티티에서 연관관계를 설정한 예
provider.getProductList().add(product1); // 무시
provider.getProductList().add(product2); // 무시
provider.getProductList().add(product3); // 무시

 

일대다 단방향 매핑

  • OneToMany를 사용하는 입장에서는 어느 엔티티 클래스도 연관관계의 주인이 될 수 없다.

// 상품분류 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
@EqualsAndHashCode
@Table(name = "category")
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String code;

    private String name;

    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "category_id")
    private List<Product> products = new ArrayList<>();

}
// CategoryRepository 인터페이스
public interface CategoryRepository extends JpaRepository<Category, Long> {

}
// CategoryRepository를 활용한 테스트
@SpringBootTest
class CategoryRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    @Autowired
    CategoryRepository categoryRepository;

    @Test
    void relationshipTest(){
        // 테스트 데이터 생성
        Product product = new Product();
        product.setName("펜");
        product.setPrice(2000);
        product.setStock(100);

        productRepository.save(product);

        Category category = new Category();
        category.setCode("S1");
        category.setName("도서");
        category.getProducts().add(product);

        categoryRepository.save(category);

        // 테스트 코드
        List<Product> products = categoryRepository.findById(1L).get().getProducts();

        for(Product foundProduct : products){
            System.out.println(product);
        }
    }
}

 

다대다 매핑

  • 다대다(M : N) 연관관계는 실무에서 거의 사용되지 않는 구성
  • ex) 상품과 생산업체 → 한 종류의 상품이 여러 생산업체를 통해 생산될 수 있고, 생산업체 한 곳이 여러 상품을 생산할 수도 있다.
  • 다대다 연관관계에서는 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다.
  • 이런 경우 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일 관계로 해결

 

다대다 단방향 매핑

// 생산업체 엔티티
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "producer")
public class Producer extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String code;

    private String name;

    @ManyToMany
    @ToString.Exclude
    private List<Product> products = new ArrayList<>();

    public void addProduct(Product product){
        products.add(product);
    }

}
// ProducerRepository 인터페이스
public interface ProducerRepository extends JpaRepository<Producer, Long> {

}
// 생산업체 연관관계 테스트
@SpringBootTest
class ProducerRepositoryTest {

    @Autowired
    ProducerRepository producerRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    @Transactional
    void relationshipTest() {
        Product product1 = saveProduct("동글펜", 500, 1000);
        Product product2 = saveProduct("네모 공책", 100, 2000);
        Product product3 = saveProduct("지우개", 152, 1234);

        Producer producer1 = saveProducer("flature");
        Producer producer2 = saveProducer("wikibooks");

        producer1.addProduct(product1);
        producer1.addProduct(product2);

        producer2.addProduct(product2);
        producer2.addProduct(product3);

        producerRepository.saveAll(Lists.newArrayList(producer1, producer2));

        System.out.println(producerRepository.findById(1L).get().getProducts());
    }

		private Product saveProduct(String name, Integer price, Integer stock) {
		        Product product = new Product();
		        product.setName(name);
		        product.setPrice(price);
		        product.setStock(stock);
		
		        return productRepository.save(product);
		    }
		
		    private Producer saveProducer(String name) {
		        Producer producer = new Producer();
		        producer.setName(name);
		
		        return producerRepository.save(producer);
		    }
}

 

다대다 양방향 매핑

// 상품 엔티티에서 생산업체 엔티티 연관관계 설정
@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    @OneToOne(mappedBy = "product")
    @ToString.Exclude // 예제 9.8
    private ProductDetail productDetail;

    @ManyToOne
    @JoinColumn(name = "provider_id")
    @ToString.Exclude
    private Provider provider;

    @ManyToMany
    @ToString.Exclude
    private List<Producer> producers = new ArrayList<>();

    public void addProducer(Producer producer){
        this.producers.add(producer);
    }

}
// 다대다 양방향 연관관계 테스트
@Test
    @Transactional
    void relationshipTest2() {
        Product product1 = saveProduct("동글펜", 500, 1000);
        Product product2 = saveProduct("네모 공책", 100, 2000);
        Product product3 = saveProduct("지우개", 152, 1234);

        Producer producer1 = saveProducer("flature");
        Producer producer2 = saveProducer("wikibooks");

        producer1.addProduct(product1);
        producer1.addProduct(product2);
        producer2.addProduct(product2);
        producer2.addProduct(product3);

        product1.addProducer(producer1);
        product2.addProducer(producer1);
        product2.addProducer(producer2);
        product3.addProducer(producer2);

        producerRepository.saveAll(Lists.newArrayList(producer1, producer2));
        productRepository.saveAll(Lists.newArrayList(product1, product2, product3));

        System.out.println("products : " + producerRepository.findById(1L).get().getProducts());

        System.out.println("producers : " + productRepository.findById(2L).get().getProducers());

    }
  • 다대다 연관관계를 설정하면 중간 테이블을 통해 연관된 엔티티의 값을 가져올 수 있다.
  • 다만 다대다 연관관계에서는 중간 테이블이 생성되기 때문에 예기치 못한 쿼리가 생길 수 있음
  • 즉 관리하기 힘든 포인트가 발생한다는 문제가 있다.
  • 다대다 연관관계의 한계를 극복하기 위해 중간 테이블 생성 대신 일대다 다대일로 연관관계를 맺을 수 있는 중간 엔티티로 승격시켜 JPA에서 관리할 수 있게 생성하는 것이 좋다.

 

영속성 전이

  • 영속성 전이(cascade)란 특정 엔티티의 영속성 상태를 변경할 때 그엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는 것을 의미

영속성 전이 적용

  • 여기서 사용할 엔티티는 상품 엔티티와 공급업체 엔티티
  • 예)한 가게가 새로운 공급업체와 계약하며 몇 가지 새 상품을 입고시키는 상황에 어떻게 영속성 전이가 적용이 되는 상
// 공급업체 엔티티에 영속성 전이 설정

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();

}
// 영속성 전이 테스트

@Test
    void cascadeTest() {
        Provider provider = savedProvider("새로운 공급업체");

        Product product1 = savedProduct("상품1", 1000, 1000);
        Product product2 = savedProduct("상품2", 500, 1500);
        Product product3 = savedProduct("상품3", 750, 500);

        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.save(provider);

    }

		private Provider savedProvider(String name) {
        Provider provider = new Provider();
        provider.setName(name);

        return provider;
    }

    private Product savedProduct(String name, Integer price, Integer stock) {
        Product product = new Product();
        product.setName(name);
        product.setPrice(price);
        product.setStock(stock);

        return product;
    }

 

고아 객체

  • JPA에서 고아(orphan)란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미
  • JPA에는 이러한 고아 객체를 자동으로 제거하는 기능이 있다.
// 공급업체 엔티티에 고아 객체를 제거하는 기능을 추가

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "provider")
public class Provider extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "provider", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @ToString.Exclude
    private List<Product> productList = new ArrayList<>();

}
// 고아 객체의 제거 기능 테스트

@Test
    @Transactional
    void orphanRemovalTest() {
        Provider provider = savedProvider("새로운 공급업체");

        Product product1 = savedProduct("상품1", 1000, 1000);
        Product product2 = savedProduct("상품2", 500, 1500);
        Product product3 = savedProduct("상품3", 750, 500);

        // 연관관계 설정
        product1.setProvider(provider);
        product2.setProvider(provider);
        product3.setProvider(provider);

        provider.getProductList().addAll(Lists.newArrayList(product1, product2, product3));

        providerRepository.saveAndFlush(provider);

        System.out.println("## Before Removal ##");
        System.out.println("## provider list ##");
        providerRepository.findAll().forEach(System.out::println);

        System.out.println("## product list ##");
        productRepository.findAll().forEach(System.out::println);

        // 연관관계 제거
        Provider foundProvider = providerRepository.findById(1L).get();
        foundProvider.getProductList().remove(0);

        System.out.println("## After Removal ##");
        System.out.println("## provider list ##");
        providerRepository.findAll().forEach(System.out::println);

        System.out.println("## product list ##");
        productRepository.findAll().forEach(System.out::println);
    }

 

정리

  • 이번 장에서는 연관관계를 설정하는 방법과 영속성 전이라는 개념을 알아봄
  • JPA 사용 시 영속이라는 개념은 매우 중요
  • 코드를 통해 편리하게 DB에 접근하기 위해서는 중간에서 엔티티의 상태를 조율하는 영속성 컨텍스트가 어떻게 동작하는지 이해해야 한다.
 글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.

 

JPQL

  • JPA Query Language, JPA에서 사용할 수 있는 쿼리를 의미
  • SQL과 매우 비슷해서 DB 쿼리에 익숙한 사람이라면 어렵지 않게 사용 가능
-- JPQL 쿼리의 기본 구조
SELECT p FROM Product p WHERE p.number = ?1;

 

쿼리 메서드 살펴보기

  • 리포지토리는 JpaRepository를 상속받는 것만으로도 다양한 CRUD 메서드를 제공
  • 하지만 이러한 기본 메서드들은 식별자 기반으로 생성되기 때문에 결국 별도의 메서드를 정의해서 사용하는 경우가 많다.
  • 간단한 쿼리문을 작성하기 위해 사용되는 것이 쿼리 메서드

쿼리 메서드의 생성

  • 쿼리 메서드는 크게 동작을 결정하는 주제(Subject)와 서술어(Predicate)로 구분
// 리턴타입 + (주제+서술어(속성)) 구조의 메서드
List<Person> findByLastnameAndEmail(String lastName, String email)

쿼리 메서드의 주제 키워드

  • find…By
  • read…By
  • get…By
  • query…By
  • search…By
  • stream…By
  • 조회하는 기능을 수행하는 키워드 … 으로 표시한 영역에는 도메인(엔티티)을 표현할 수 있다.
  • 그러나 리포지토리에서 이미 도메인을 설정한 후에 메서드를 사용하기 때문에 중복으로 판단해 생략하기도 한다.

쿼리 메서드의 조건자 키워드

  • Is : 값의 일치를 조건으로 사용하는 조건자 키워드. 생략되는 경우가 많으며 Eqauls와 동일한 기능을 수행
  • (Is)Not : 값의 불일치를 조건으로 사용하는 조건자 키워드. Is는 생략하고 Not 키워드만 사용할 수도 있다.
// Not 키워드를 사용한 쿼리 메서드
Product findByNumberIsNot(Long number);
Product findByNumberNot(Long number);
  • (Is)Null, (Is)NotNull : 값이 null인지 검사하는 조건자 키워드
// Null 키워드를 사용한 쿼리 메서드
List<Product> findByUpdatedAtNull();
List<Product> findByUpdatedAtIsNull();
List<Product> findByUpdatedAtNotNull();
List<Product> findByUpdatedAtIsNotNull();
  • (Is)True, (Is)False : boolean 타입으로 지정된 칼럼값을 확인하는 키워드
// True, False 키워드를 사용한 쿼리 메서드
Product findByisActiveTrue();
Product findByisActiveIsTrue();
Product findByisActiveFalse();
Product findByisActiveIsFalse();
  • And, Or : 여러 조건을 묶을 때 사용
// And, Or 키워드를 사용한 쿼리 메서드
Product findByNumberAndName(Long number, String name);
Product findByNumberOrName(Long number, String name);
  • (Is)Greater Than, (Is)LessThan, (Is)Between : 숫자나 datetime 칼럼을 대상으로 한 비교 연산에 사용할 수 있는 조건자 키워드. Greater Than, LessThan, 키워드는 비교 대상에 대한 초과/ 미만의 개념으로 비교 연산을 수행, 경곗값을 포함하려면 Equal 키워드를 추가하면 된다.
// GreaterThan LessThan, Between 키워드를 사용한 쿼리 메서드
List<Product> findByPriceIsGreaterThan(Long price);
List<Product> findByPriceGreaterThan(Long price);
List<Product> findByPriceGreaterThanEqual(Long price);
List<Product> findByPriceIsLessThan(Long price);
List<Product> findBypriceLessThan(Long price);
List<Product> findBypriceLessThanEqual(Long price);
List<Product> findByPriceIsBetween(Long lowPrice, Long highPrice);
List<Product> findByPriceBetween(Long lowPrice, Long highPrice);
  • Is)StartingWith(==StartsWith), (Is)EndingWith(==EndsWith), (Is)Containing(==Contains), (Is)Like : 컬럼 값에서 일부 일치 여부를 확인하는 조건자 키워드. SQL 쿼리문에서 값의 일부를 포함하는 값을 추출할 떄 사용하는 ‘%’ 키워드와 동일한 역할을 하는 키워드. 자동으로 생성되는 SQL문을 보면 Containing 키워드는 문자열의 양 끝, StartingWith 키워드는 문자열의 앞, EndingWith 키워드는 문자열의 끝에 ‘%’가 배치
// 부분 일치 키워드를 사용한 쿼리 메서드
List<Produuct> findByNameLike(String name);
List<Product> findByNameIsLike(String name);
List<Product> findByNameContains(String name);
List<Product> findByNameIsContaining(String name);

List<Product> findByNameStartsWith(String name);
List<Product> findByNameStartingWith(String name);
List<Product> findByNameIsStartingWith(String name);

List<Product> findByNameEndsWith(String name);
List<Product> findByNameEndingWith(String name);
List<Product> findByNameIsEndingWith(String name);

 

정렬과 페이징 처리

정렬 처리하기

  • 쿼리 메서드의 정렬 처리
// Asc : 오름차순, Desc : 내림차순
List<Product> findByNameOrderByNumberAsc(String name);
List<Product> findByNameOrderByNumberDesc(String name);
  • 쿼리 메서드에서 여러 정렬 기준 사용
// And를 붙이지 않음
List<Product> findByNameOrderByPriceAscStockDesc(String name);
  • 매개변수를 활용한 쿼리 정렬
List<Product> findByName(String name, Sort sort);
  • 쿼리 메서드에 Sort 객체 전달
productRepository.findByName("펜", Sort.by(Order.asc("price")));
productRepository.findByName("펜", Sort.by(Order.asc("rpcie"), Order.desc("stock")));
  • 쿼리 메서드에서 정렬 부분을 분리
@SpringBootTest
class ProductRepositoryTest {
	@Autowired
    ProductRepository productRepository;
    
    @Test
    void springAndPagingTest() {
    	.. 상단 코드 생략..
        Sytem.out.println(productRepository.findByName("펜", getSort()));
    }
    
    private Sort getSort() {
    
    	return Sort.by(
        	Order.asc("price"),
            Order.desc("stock")
        );
    }
}

 

페이징 처리

  • 페이징이란 데이터베이스의 레코드를 개수로 나눠 페이지를 구분하는 것을 의미
  • JPA에서는 이 같은 페이징 처리를 위해 Page와 Pageable을 사용

 

  • 페이징 처리를 위한 쿼리 메서드 예시
Page<Product> findByName(String namme, Pageable pageable);
  • 페이징 쿼리 메서드를 호출하는 방법
Page<Product> productPage = productRepository.findByName("펜", PageReuqest.of(0, 2));

 

@Query 어노테이션 사용하기

  • @Query 어노테이션을 사용해 직접 JPQL을 작성할 수도 있다.
  • JPQL을 사용하면 JPA 구현체에서 자동으로 쿼리 문장을 해석하고 실행하게 된다.
  • 보통 튜닝된 쿼리를 사용하고자 할 때 직접 SQL을 작성

 

  • @Query 어노테이션을 사용하는 메서드
@Query("SELECT p From Product p Where p.name = ?1")
List<Product> findByName(String name);
  • @Query 어노테이션과 @Param 어노테이션을 사용하는 메서드
@Query("SELECT p fROM pRODUCT P wHERE P.Name = :name")
List<Product> findByNameParma(@Param("name") String name);
  • 특정 칼럼만 추출하는 쿼리
@Query("SELECT p.name, p.price. p.stock From Product p WHERE p.name = :name")
List<Object[]> findByNameParam2(@Param("name")String name);

 

QueryDSL?

  • 정적 타입을 이용해 SQL과 같은 쿼리를 생성할 수 있도록 지원하는 프레임워크
  • 문자열이나 XML 파일을 통해 쿼리를 작성하는 대신 QueryDSL이 제공하는 플루언트(Fluent) API를 활용해 쿼리를 생성할 수 있음

QueryDSL의 장점

  • IDE가 제공하는 코드 자동 완성 기능 사용할 수 있다.
  • 문법적으로 잘못된 쿼리를 허용하지 않는다. 따라서 정상적으로 활용된 QueryDSL은 문법 오류를 발생시키지 않는다.
  • 고정된 SQL 쿼리를 작성하지 않기 때문에 동적으로 쿼리를 생성할 수 있다.
  • 코드로 작성하므로 가독성 및 생산성이 향상
  • 도메인 타입과 프로퍼티를 안전하게 참조할 수 있다.

QueryDSL 관련 의존성 추가

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
</dependency>

QueryDSL에서 JPAAnnotationProcessor 사용을 위한 APT 플러그인 추가

<plugin>
    <groupId>com.mysema.maven</groupId>
    <artifactId>apt-maven-plugin</artifactId>
    <version>1.1.3</version>
    <executions>
        <execution>
            <goals>
                <goal>process</goal>
            </goals>
            <configuration>
                <outputDirectory>src/main/generated</outputDirectory>
                <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
            </configuration>
        </execution>
    </executions>
</plugin>

기본적인 QueryDSL 사용하기

  • List<T> fetch() : 조회 결과를 리스트로 반환
  • T fetchOne : 단 한건의 결과를 반환
  • T fetchFirst() : 여러 건의 조회 결과 중 1건을 반환. 내부 로직을 살펴보면 .limit(1).fetchOne() 으로 구현돼 있다.
  • Long fetchCount() : 조회 결과의 개수를 반환
  • QueryResult<T> fetchResults() : 조회 결과 리스트와 개수를 포함한 QueryResults를 반환합니다.

QuerydslPredicateExecutor, QuerydslRepositorySupport 활용

  • 스프링 데이터 JPA에서는 QueryDSL을 더욱 편하게 사용할 수 있게 QuerydslPredicateExecutor 인터페이스와 QuerydslRepositorySupport 클래스를 제공한다.

QuerydslPredicateExecutor 인터페이스

  • QuerydslPredicateExecutor 는 JpaRepository와 함께 리포지토리에서 QueryDSL을 사용할 수 있게 인터페이스를 제공
package com.springboot.advanced_jpa.data.repository;

import com.springboot.advanced_jpa.data.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;


public interface QProductRepository extends JpaRepository<Product, Long>,
    QuerydslPredicateExecutor<Product> {

}

// Reference (https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/querydsl/QuerydslPredicateExecutor.html)
  • QuerydslPredicateExecutor에서 제공하는 메서드
Optional<T> findOne(Predicate predicate);

Iterable<T> findAll(Predicate predicate);

Iterable<T> findAll(Predicate predicate, Sort sort);

Iterable<T> findAll(Predicate predicate, OrderSpecifier<?>...orders);

Iterable<T> findAll(OrderSpecifier<?>...orders);

Page<T> findAll(Predicate pridicate, Pageable pageable);

long count(Predicate predicate);

boolean exists(Predicate predicate);

QuerydslRepositorySupport 추상 클래스 사용하기

  • QuerydslRepositorySupport 클래스 역시 QueryDSL라이브러리를 사용하는데 유용한 기능을 제공
  • 가장 보편적으로 사용하는 방식은 customRepository를 활용해 리포지토리를 구현하는 방식

  • JpaRepository와 QuerydslRepositorySupport는 Spring Data JPA에서 제공하는 인터페이스와 클래스, 나머지 ProductRepository와 ProductRepositoryCustom, ProductRepositoryCustomImpl은 직접 구현해야 한다.
    • JpaRepository를 상속받는 ProductRepository를 생성
    • 이때 직접 구현한 쿼리를 생성하기 위해서는 JpaRepository를 상속받지 않는 리포지토리 인터페이스인 ProductRepositoryCustom을 생성. 이 인터페이스에 정의하고자 하는 기능들을 메서드로 정의
    • ProductRepositoryCustom에서 정의한 메서드를 사용하기 위해 ProductRepository에서 ProductRepositoryCustom을 상속받습니다.
    • ProductRepositoryCustom에서 정의된 메서드를 기반으로 실제 쿼리를 작성하기 위해 구현체인 ProductRepositoryCustomImpl 클래스를 생성
    • ProductRepositoryCustomImpl 클래스에서는 다양한 방법으로 쿼리를 구현할 수 있지만 QueryDSL을 사용하기 위해 QueryDslRepositorySupport를 상속받는다.
package com.springboot.advanced_jpa.data.repository.support;

import com.springboot.advanced_jpa.data.entity.Product;
import java.util.List;

/**
 * 필요한 쿼리를 작성할 메소드를 정의하는 인터페이스
 */
public interface ProductRepositoryCustom {

    List<Product> findByName(String name);

}
package com.springboot.advanced_jpa.data.repository.support;

import com.springboot.advanced_jpa.data.entity.Product;
import com.springboot.advanced_jpa.data.entity.QProduct;
import java.util.List;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import org.springframework.stereotype.Component;

@Component
public class ProductRepositoryCustomImpl extends QuerydslRepositorySupport implements
    ProductRepositoryCustom {

    public ProductRepositoryCustomImpl() {
        super(Product.class);
    }

    @Override
    public List<Product> findByName(String name) {
        QProduct product = QProduct.product;

        List<Product> productList = from(product)
            .where(product.name.eq(name))
            .select(product)
            .fetch();

        return productList;
    }
}

// Reference (https://docs.spring.io/spring-data/jpa/docs/current/api/org/springframework/data/jpa/repository/support/QuerydslRepositorySupport.html)
// JPAQuery를 활용한 QueryDSL 테스트 코드

import com.querydsl.core.Tuple;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import com.springboot.advanced_jpa.data.entity.Product;
import com.springboot.advanced_jpa.data.entity.QProduct;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.time.LocalDateTime;
import java.util.List;

@SpringBootTest
class ProductRepositoryTest {

    @Autowired
    ProductRepository productRepository;

    // 이후 코드는 QueryDSL 관련 테스트입니다.
    @PersistenceContext
    EntityManager entityManager;

    // 예제 8.26
    @Test
    void queryDslTest() {
        JPAQuery<Product> query = new JPAQuery(entityManager);
        QProduct qProduct = QProduct.product;

        List<Product> productList = query
                .from(qProduct)
                .where(qProduct.name.eq("펜"))
                .orderBy(qProduct.price.asc())
                .fetch();

        for (Product product : productList) {
            System.out.println("----------------");
            System.out.println();
            System.out.println("Product Number : " + product.getNumber());
            System.out.println("Product Name : " + product.getName());
            System.out.println("Product Price : " + product.getPrice());
            System.out.println("Product Stock : " + product.getStock());
            System.out.println();
            System.out.println("----------------");
        }
    }

    // 예제 8.27
    @Test
    void queryDslTest2() {
        JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
        QProduct qProduct = QProduct.product;

        List<Product> productList = jpaQueryFactory.selectFrom(qProduct)
                .where(qProduct.name.eq("펜"))
                .orderBy(qProduct.price.asc())
                .fetch();

        for (Product product : productList) {
            System.out.println("----------------");
            System.out.println();
            System.out.println("Product Number : " + product.getNumber());
            System.out.println("Product Name : " + product.getName());
            System.out.println("Product Price : " + product.getPrice());
            System.out.println("Product Stock : " + product.getStock());
            System.out.println();
            System.out.println("----------------");
        }
    }

    // 예제 8.28
    @Test
    void queryDslTest3() {
        JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
        QProduct qProduct = QProduct.product;

        List<String> productList = jpaQueryFactory
                .select(qProduct.name)
                .from(qProduct)
                .where(qProduct.name.eq("펜"))
                .orderBy(qProduct.price.asc())
                .fetch();

        for (String product : productList) {
            System.out.println("----------------");
            System.out.println("Product Name : " + product);
            System.out.println("----------------");
        }

        List<Tuple> tupleList = jpaQueryFactory
                .select(qProduct.name, qProduct.price)
                .from(qProduct)
                .where(qProduct.name.eq("펜"))
                .orderBy(qProduct.price.asc())
                .fetch();

        for (Tuple product : tupleList) {
            System.out.println("----------------");
            System.out.println("Product Name : " + product.get(qProduct.name));
            System.out.println("Product Name : " + product.get(qProduct.price));
            System.out.println("----------------");
        }
    }

    @Test
    void queryDslTupleTest() {
        JPAQueryFactory jpaQueryFactory = new JPAQueryFactory(entityManager);
        QProduct qProduct = QProduct.product;

        List<Tuple> productList = jpaQueryFactory
                .select(qProduct.name, qProduct.price)
                .from(qProduct)
                .where(qProduct.name.eq("펜"))
                .orderBy(qProduct.price.asc())
                .fetch();

        for (Tuple product : productList) {
            System.out.println("----------------");
            System.out.println("Product Name : " + product.get(qProduct.name));
            System.out.println("Product Price : " + product.get(qProduct.price));
            System.out.println("----------------");
        }
    }

    // 예제 8.30
    /**
     * Bean 객체로 등록된 JPAQueryFactory를 활용
     */
    @Autowired
    JPAQueryFactory jpaQueryFactory;

    @Test
    void queryDslTest4() {
        QProduct qProduct = QProduct.product;

        List<String> productList = jpaQueryFactory
                .select(qProduct.name)
                .from(qProduct)
                .where(qProduct.name.eq("펜"))
                .orderBy(qProduct.price.asc())
                .fetch();

        for (String product : productList) {
            System.out.println("----------------");
            System.out.println("Product Name : " + product);
            System.out.println("----------------");
        }
    }

}

JPA  Auditing 적용

  • Audit : 감시하다
  • 각 데이터마다 ‘누가’, ‘언제’ 데이터를 생성했고 변경했는지 감시한다는 의미로 사용
  • 대표적으로 많이 사용되는 필드
    • 생성 주체
    • 생성 일자
    • 변경 주체
    • 변경 일
  • 이러한 필드들은 매번 엔티티를 생성하거나 변경할 때마다 값을 주입해야 하는 번거로움이 있음
  • Spring Data JPA에서는 이러한 값을 자동으로 넣어주는 기능을 제공

JPA Auditing 기능 활성화

  • EnableJpaAuditing 추가
@SpringBootApplication
@EnableJpaAuditing
public class AdvancedJpaApplication {

	public static void main(String[] args) {
    	SpringApplication.run(JpaApplication.class, args);
    }
    
}
  • Configuration 클래스 생성
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {
	
}

BaseEntity 만들기

  • 코드의 중복을 없애기 위해서는 각 엔티티에 공통으로 들어가게 되는 칼럼(필드)을 하나의 클래스로 빼는 작업을 수행해야 한다.
package com.springboot.advanced_jpa.data.entity;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;

@Getter
@Setter
@ToString
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

}
  • @MappedSuperClass : JPA 엔티티 클래스가 상속받을 경우 자식 클래스에게 매핑 정보를 전달
  • @EntiryListeners : 엔티티를 데이터베이스에 적용하기 전후로 콜백을 요청할 수 있게 하는 어노테이션
  • AuditingEntityListener : 엔티티의 Auditing 정보를 주입하는 JPA 엔티티 리스너 클래스입니다.
  • @CreateDate : 데이터의 생성 날짜를 자동으로 주입하는 어노테이션
  • @LastModifiedDate : 데이터 수정 날짜를 자동으로 주입하는 어노테이션
// BaseEntity를 상속받은 Product 엔티티 클래스
package com.springboot.advanced_jpa.data.entity;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

}
JPA Auditing 기능에는
@CreatedBy, @ModifiedBy 어노테이션도 존재. 누가 엔티티를 생성했고 수정했는지 자동으로 값을 주입하는 기능 이 기능을 사용하려면 AuditorAware를 스프링 빈으로 등록할 필요가 있다.
이 글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.

마리아 DB 설치

  1. (https://mariadb.org/download/) 로 접속한다.

마리아 DB 다운로드

2. 개발 환경에 맞춰 설정하고 설치 파일을 내려 받는다.

3. 설치 프로그램 다운로드가 완료 되면 설치를 진행 한다.

4. 계속 Next를 진행하다가 마리아 DB의 root 패스워드 및 기본 인코딩 설정이 나오면 패스워드 입력 후 진행

5. 다음 서버 이름과 포트 번호를 입력하은 창이 나오면 원하는 포트와 이름을 적고 Next 진행 기본 포트는 3306

 

ORM

  • Object Relational Mapping, 객체 관계 매핑
  • 자바와 같은 객체지향 언어에서 의미하는 객체(클래스)와 RDB(Relational DB)의 테이블을 자동으로 매핑하는 방법
  • 클래스는 DB 테이블과 매핑하기 위해 만들어진 것이 아니기 때문에 RDB 테이블과 어쩔 수 없는 불일치가 존재
  • ORM은 이 둘(클래스, 테이블)의 불일치와 제약사항을 해결하는 역할
  • ORM을 이용하면 쿼리문 작성이 아닌 코드(메서드)로 데이터를 조작 가능

ORM의 장점

  1. ORM을 사용하면서 데이터베이스 쿼리를 객체지향적으로 조작 가능
    • 쿼리문을 작성하는 양이 현저히 줄어 개발 비용이 줄어든다.
    • 객체지향적으로 데이터베이스에 접근할 수 있어 코드의 가독성을 높인다.
  2. 재사용 및 유지보수가 편리
    • ORM을 통해 매핑된 객체는 모두 독립적으로 작성되어 있어 재사용이 용이
    • 객체들은 각 클래스로 나뉘어 있어 유지보수가 수월
  3. 데이터베이스에 대한 종속성이 줄어든다.
    • ORM을 통해 자동 생성된 SQL문은 객체를 기반으로 데이터베이스 테이블을 관리하기 때문에 데이터베이스에 종속적이지 않다.
    • 데이터베이스를 교체하는 상황에서도 비교적 적은 리스크를 부담

 

ORM의 단점

  1. ORM만으로 온전한 서비스를 구현하기에 한계가 있다.
    • 복잡한 서비스의 경우 직접 쿼리를 구현하지 않고 코드로 구현하기 어렵다.
    • 복잡한 쿼리를 정확한 설계 없이 ORM만으로 구성하게 되면 속도 저하 등의 성능 문제가 발생할 수 있다.
  2. 애플리케이션의 객체 관점과 데이터베이스의 관계 관점의 불일치가 발생
    • 세분성(Granularity) : ORM의 자동 설계 방법에 따라 데이터베이스에 있는 테이블 수와 애플리케이션의 엔티티(Entity)클래스의 수가 다른 경우가 생긴다.(클래스가 테이블의 수보다 많아질 수 있다.)
    • 상속성(Inheritance) : RDBMS에는 상속 개념이 없다.
    • 식별성(Identity) : RDBMS는 기본키(primary key)로 동일성을 정의. 하지만 자바는 두 객체의 값이 같아도 다르다고 판단할 수 있다.
    • 연관성(Associations) : 객체지향 언어는 객체를 참조함으로써 연관성을 나타내지만 RDBMS에서는 외래키를 삽입함으로써 연관성을 표현, 또한 객체지향 언어에서는 객체를 참조할 때 방향성이 존재하지만 RDBMS에는 외래키를 삽입하는 것은 양방향의 관계를 가지기 때문에 방향성이 없다.
    • 탐색(Navigation) : 자바와 RDBMS는 어떤 값(객체)에 접근하는 방식이 다르다. 자바는 특정 값에 접근하기 위해 객체 참조 같은 연결 수단 활용, 반면 RDBMS에는 쿼리를 최소화하고 조인을 통해 여러 테이블을 로드하고 값을 추출하는 접근 방식을 택하고 있다.

 

JPA

  • Java Persistence API, 자바 진영의 ORM 기술 표준으로 채택된 인터페이스의 모음
  • ORM이 큰 개념이라면 JPA는 더 구체화된 스펙을 포함
  • JPA는 실제로 동작하는 것이 아니고 어떻게 동작해야 하는지 메커니즘을 정리한 표준 명세
  • JPA의 메커니즘을 보면 내부적으로 JDBC를 사용
  • 개발자가 직접 JDBC를 구현하면 SQL에 의존하게 되는 문제 등이 있어 개발 효율이 떨어짐
  • JPA는 이 같은 문제점을 보완, 개발자 대신 적절한 SQL을 생성하고 DB를 조작해서 객체를 자동 매핑하는 역할 수행
  • JPA 기반의 구현체로는 대표적으로 세 가지가 있다.

하이버네이트

  • 자바의 ORM 프레임워크, JPA가 정의하는 인터페이스를 구현하고 있는 JPA 구현체 중 하나

Spring Data JPA

  • JPA를 편리하게 사용할 수 있도록 지원하는 스프링 하위 프로젝트 중 하나
  • CRUD 처리에 필요한 인터페이스 제공
  • 하이버네이트의 엔티티 매니저를 직접 다루지 않고 리포지토리를 정의해 사용함으로써 스프링이 적합한 쿼리를 동적으로 생성하는 방식으로 데이터베이스를 조작
  • 하이버네이트에서 자주 사용되는 기능을 더 쉽게 구현한 라이브러리

 

영속성 컨텍스트(Persistence Context)

  • 애플리케이션과 데이터베이스 사이에서 엔티티와 레코드의 괴리를 해소하는 기능과 객체를 보관하는 기능을 수행
  • 엔티티 객체가 영속성 컨텍스트에 들어오면 JPA는 엔티티 객체의 매핑 정보를 데이터베이스에 반영하는 작업 수행
  • 엔티티 객체가 영속성 컨텍스트에 들어와 JPA 관리 대상이 되는 시점부터는 해당 객체를 영속 객체라고 부른다.
  • 영속성 컨텍스트는 세션 단위의 생명주기를 가진다.
    데이터베이스에 접근하기 위한 세션이 생성되면 영속성 컨텍스트가 만들어지고, 세션이 종료되면 영속성 컨텍스트도 없어진다.

엔티티 매니저(EntityManager)

  • 엔티티를 관리하는 객체
  • 데이터베이스에 접근해서 CRUD 작업을 수행
  • Spring Data JPA를 사용하면 리포지토리를 사용해서 데이터 베이스에 접근하는데,
    실제 내부 구현체인 SimpleJpaRepository가 엔티티 매니저를 사용
  • 엔티티 매니저는 엔티티 매니저 팩토리(EntityManagerFactory)가 만든다.
  • 엔티티 매니저 팩토리는 데이터베이스에 대응하는 객체로서 스프링 부트에서는 자동 설정 기능이 있기 때문에
    application.properties에 작성한 최소한의 설정만으로 동작하지만 JPA의 구현체 중 하나의 하이버네이트에서는 persistence.xml이라는 설정 파일을 구성하고 사용해야 하는 객체이다.

엔티티의 생명주기

  • 엔티티 객체는 영속성 컨텍스트에서 다음과 같은 4가지 상태로 구분
  1. 비영속(New)
    • 영속성 컨텍스트에 추가되지 않은 엔티티 객체의 상태
  2. 영속(Managed)
    • 영속성 컨텍스트에 의해 엔티티 객체가 관리되는 상태
  3. 준영속(Detached)
    • 영속성 컨텍스트에 의해 관리되던 엔티티 객체가 컨텍스트와 분리된 상태
  4. 삭제(Removed)
    • 데이터베이스에서 레코드를 사제하기 위해 영속성 컨텍스트에 삭제 요청을 한 상태

출처 : https://llmooon.github.io/spring/jpa/JPA-영속성관리

데이터베이스 연동

프로젝트 생성

  • 버전 : 2.5.6
  • groupId : com.springboot
  • name : jpa
  • artifactId : jpa
  • Developer : Lombok, Spring Configuration Processor
  • Web : Srping Web
  • SQL : Spring Data JPA, MariaDB Driver

 

엔티티 설계

  • Spring Data JPA를 사용 시 데이터베이스에 테이블을 생성하기 위해 직접 쿼리를 작성할 필요가 없음
  • 이 기능을 사용하게 가능 하는 것이 엔티티(Entity)
  • JPA에서는 엔티티는 데이터베이스의 테이블에 대응하는 클래스
  • 엔티티에는 데이터베이스에 쓰일 테이블과 칼럼을 정의

데이터베이스 테이블

 

엔티티 관련 기본 어노테이션

  • @Entity
    • 해당 클래스가 엔티티임을 명시하기 위한 어노테이션
    • 클래스 자체는 테이블과 일대일로 매칭, 해당 클래스의 인스턴스는 매핑되는 테이블에서 하나의 레코드를 의미
  • @Table
    • 엔티티 클래스는 테이블과 매핑되므로 특별한 경우가 아니면 @Table 어노테이션이 필요하지 않다.
    • @Table 어노테이션을 사용할 때는 클래스의 이름과 테이블의 이름을 다르게 지정해야 하는 경우
    • @Table 어노테이션을 명시하지 않으면 테이블의 이름과 클래스의 이름이 동일하다는 의미,
      서로 다른 이름을 쓰려면 @Table(name = "값") 형태로 데이터베이스의 테이블을 명시해야 함
    •  대체로 자바의 명명법과 데이터베이스가 사용하는 명명법이 다르기 때문에 자주 사용
  • @Id
    • 엔티티 클래스의 필드는 테이블의 칼럼과 매핑
    • @Id 어노테이션이 선언된 필드는 테이블의 기본값 역할로 사용
    • 모든 엔티티는 @Id 어노테이션이 필요
  • @GeneratedValue
    • 일반적으로 @Id 어노테이션과 함께 사용 시 해당 필드의 값을 어떤 방식으로 자동 생성할지 결정할 떄 사용
    • GeneratedValue를 사용하지 않는 방식(직접 할당)
      • 애플리케이션에서 자체적으로 고유한 기본값을 생성할 경우 사용하는 방식
      • 내부에 정해진 규칙에 의해 기본값을 생성하고 식별자로 사용
    • AUTO
      • @GeneratedValue의 기본 설정값
      • 기본값을 사용하는 데이터베이스에 맞게 자동 생성
    • IDENTITY
      • 기본값 생성을 데이터베이스에 위임하는 방식
      • 데이터베이스의 AUTO_INCREMENT를 사용해 기본값을 생성
    • SEQUENCE
      • @SeqneceGenerator를 정의할 때는 name, sequeceName, allocationSize를 활용
      • @GeneratedValue에 생성기를 설정
    • TABLE
      • 어떤 DBMS를 사용하더라도 동일하게 동작하기를 원할 경우 사용
      • 식별자로 사용할 숫자의 보관 테이블을 별도로 생성해서 엔티티를 생성할 때마다 값을 갱신하며 사용
      • @TableGenerator 어노테이션으로 테이블 정보를 설정
  • @Column
    • 엔티티 클래스의 필드는 자동으로 테이블 칼럼으로 매핑, 그래서 별다른 설정을 하지 않을 예정이라면 이 어노테이션을 명시하지 않아도 괜찮다.
    • name : 데이터베이스의 칼럼명을 설정하는 속성, 명시하지 않으면 필드명으로 지정
    • nullable : 레코드를 생성할 떄 칼럼 값에 null 처리가 가능한지를 명시하는 속성
    • length : 데이터베이스에 저장하는 데이터의 최대 길이를 설정
    • unique : 해당 칼럼을 유니크로 설정
  • @Transient
    • 엔티티 클래스에는 선언돼 있는 필드지만 데이터베이스에서는 필요 없을 경우 이 어노테이션을 사용해 데이터베이스에서 이용하지 않게 할 수 있다.

 

리포지토리 인터페이스 설계

  • Spring Data JPA는 JpaRepository를 기반으로 더욱 쉽게 데이터베이스를 사용할 수 있는 아키텍처를 제공
  • 스프링 부트로 JpaRepository를 상속하는 인터페이스를 생성하면 기존의 다양한 메서드를 손쉽게 활용할 수 있다.

리포지토리 인터페이스 생성

import com.springboot.jpa.data.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {

}

JpaRepository에서 제공하는 기본 메서드

  • findAll() 메소드
    • Member 테이블에서 레코드 전체 목록을 조회
    • List<Member> 객체가 리턴
  • findById(id)
    • Member 테이블에서 기본키 필드 값이 id인 레코드를 조회
    • Optional<Member> 타입의 객체가 리턴
    • 이 객체의 get 메서드를 호출하면 Member 객체가 리턴 예) Member m = memberRepository.findById(id).get();
  • save(member)
    • Member 객체를 Member 테이블에 저장
    • 객체의 id(기본키) 속성값이 0이면 INSERT / 0이 아니면 UPDATE
      saveAll(memberList)
    • Member 객체 목록을 Member 테이블에 저장
  • delete(member)
    • Member 객체의 id(기본키) 속성값과 일치하는 레코드를 삭제
  • deleteAll(memberList)
    • Member 객체 목록을 테이블에서 삭제
  • count()
    • Member 테이블의 전체 레코드 수를 리턴
  • exists(id)
    • Member 테이블에서 id에 해당하는 레코드가 있는지 true/false를 리턴
  • flush()
    • 지금까지 Member 테이블에 대한 데이터 변경 작업들이 디스크에 모두 기록

 

리포지토리 메서드의 생성 규칙

  • FindBy
    • SQL문의 where 절 역할을 수행하는 구분, findBy 뒤에 엔티티의 필드값을 입력해서 사용
    • 예) findByName(String name)
  • AND, OR
    • 조건을 여러 개 설정하기 위해 사용
    • 예) findByNameAnadEmail(String name, String email)
  • Like/NotLike
    • SQL문의 like와 동일한 기능 수행, 특정 문자를 포함하는지 여부를 조건으로 추가
    • 비슷한 키워드로 Containing, Contains, isContaining이 있다.
  • StartsWith/StartingWith 
    • 특정 키워드로 시작하는 문자열 조건을 설정
  • EndsWith/EndingWith
    • 특정 키워드로 끝나는 문자열 조건을 설정
  • IsNull/IsNotNull
    • 레코드 값이 Null이거나 Null이 아닌 값을 검색
  • True/False
    • 타입의 레코드를 검색할때 사용
  • Before/After
    • 시간을 기준으로 값을 검색
  • LessThan/GreaterThan
    • 특정 값(숫자)을 기준으로 대소 비교를 할 떄 사용
  • Between
    • 두 값(숫자) 사이의 데이터를 조회
  • OrderBy
    • SQL 문에서 order by와 동일한 기능을 수행
    • 예) List<Member> findByNameOrderByAgeAsc(int age);
  • countBy
    • SQL 문의 count와 동일한 기능을 수행하며, 결괏값의 개수(count)를 추출

 

DAO 설계

  • DAO(Data Access Object)는 데이트베이스에 접근하기 위한 로직을 관리하기 위한 객체
  • 비즈니스 로직의 동작 과정에서 데이터를 조작하는 기능은 DAO 객체가 수행
  • 다만 스프링 데이터 JPA에서 DAO의 개념은 리포지토리가 대체하고 있다.

DAO 클래스 생성

  • DAO클래스는 일반적으로 '인터페이스-구현체' 구성으로 생성
  • 일반적으로 데이터베이스에 접근하는 메서드는 리턴 값으로 데이터 객체를 전달
  • 이때 객체를 엔티티 객체로 전달할지, DTO 객체로 전달할지에 대해서는 개발자마다 의견이 분분
  • 일반적인 설계 원칙에서 엔티티 객체는 데이터베이스에 접근하는 계층에서만 사용하도록 정의
  • 다른 계층으로 데이터를 전달할 때는 DTO 객체 사용
// ProductDAO 인터페이스
public interface ProductDAO {

		Product insertProduct(Product product);

		Product selectProduct(Long number);

		Product updateProductName(Long number, String name) throws Exception;

		void deleteProduct(Long number) throws Exception;
}
// ProductDAO 인터페이스의 구현체 클래스
@Component
public class ProductDAOImpl implements ProductDAO {
		
		private final ProductRepository productRepository;
		
		@Autowired
		public ProductDAOImpl(ProductRepository productRepository) {
			this.productRepository = productRepository;
	}

		@Override
		public Product insertProduct(Product product) {
			Product savedProduct = productRepository.save(product);
			return savedProduct ;
	}

		@Override
		public Product selectProduct(Long number) {
			Product selectedProduct = productRepository.findById(number);
			return selectedProduct;
	}

		@Override
    public Product updateProductName(Long number, String name) throws Exception {
        Optional<Product> selectedProduct = productRepository.findById(number);

        Product updatedProduct;
        if (selectedProduct.isPresent()) {
            Product product = selectedProduct.get();

            product.setName(name);
            product.setUpdatedAt(LocalDateTime.now());

            updatedProduct = productRepository.save(product);
        } else {
            throw new Exception();
        }

        return updatedProduct;
    }

		@Override
    public void deleteProduct(Long number) throws Exception {
        Optional<Product> selectedProduct = productRepository.findById(number);

        if (selectedProduct.isPresent()) {
            Product product = selectedProduct.get();

            productRepository.delete(product);
        } else {
            throw new Exception();
        }
    }
}
  • ProductDAOImpl 클래스를 스프링이 관리하는 빈으로 등록하려면 @Componet or @Service 어노테이션 지정
  • 빈으로 등록된 객체는 다른 클래스가 인터페이스를 가지고 의존성을 주입받을 때 이 구현체를 찾아 주입하게 된다.

 

DAO 연동을 위한 컨트롤러와 서비스 설계

  • 앞에서 설계한 구성 요소들을 클라이언트의 요청과 연결하려면 컨트롤러와 서비스를 생성해야 한다.
  • 먼저 DAO의 메서드를 호출하고 그 외 비즈니스 로직을 수행하는 서비스 레이어를 생성한 후 컨트롤러를 생성

서비스 클래스 만들기

  • 서비스 레이어에서는 도메인 모델(Domain Model)을 활용해 애플리케이션에서 제공하는 핵심 기능을 제공
  • 핵심 기능을 구현하려면 세부 기능을 정의해야 한다. 세부 기능이 모여 핵심 기능을 구현
  • 이런 모든 로직을 서비스 레이어에서 포함하기란 쉽지 않은 일이다 아키텍처의 한계를 극복하기 위해 아키텍처를 서비스 로직과 비즈니스 로직으로 분리하기도 한다.
  • 도메인을 활용한 세부 기능들을 비즈니스 레이어의 로직에서 구현, 서비스 레이어에서는 기능들을 종합해서 핵심 기능을 전달하도록 구성하는 경우가 대표적
// ProductDto 클래스
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ProductDto {

    private String name;

    private int price;

    private int stock;

}
// ProductResponseDto 클래스

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;


@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ProductResponseDto {

    private Long number;

    private String name;

    private int price;

    private int stock;

}
// ProductService 인터페이스

import com.springboot.jpa.data.dto.ProductDto;
import com.springboot.jpa.data.dto.ProductResponseDto;

public interface ProductService {

    ProductResponseDto getProduct(Long number);

    ProductResponseDto saveProduct(ProductDto productDto);

    ProductResponseDto changeProductName(Long number, String name) throws Exception;

    void deleteProduct(Long number) throws Exception;

}
  • 위 인터페이스는 DAO에서 구현한 기능을 서비스 인터페이스에서 호출해 결과값을 가져오는 작업을 수행하도록 설계
  • 서비스에서는 클라이언트가 요청한 데이터를 적절하게 가공해서 컨트롤러에게 넘기는 역할을 한다.
// ProductServiceImpl 클래스

import com.springboot.jpa.data.dao.ProductDAO;
import com.springboot.jpa.data.dto.ProductDto;
import com.springboot.jpa.data.dto.ProductResponseDto;
import com.springboot.jpa.data.entity.Product;
import com.springboot.jpa.service.ProductService;
import java.time.LocalDateTime;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class ProductServiceImpl implements ProductService {

    private final ProductDAO productDAO;

    @Autowired
    public ProductServiceImpl(ProductDAO productDAO) {
        this.productDAO = productDAO;
    }

    @Override
    public ProductResponseDto getProduct(Long number) {
        Product product = productDAO.selectProduct(number);

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(product.getNumber());
        productResponseDto.setName(product.getName());
        productResponseDto.setPrice(product.getPrice());
        productResponseDto.setStock(product.getStock());

        return productResponseDto;
    }

    @Override
    public ProductResponseDto saveProduct(ProductDto productDto) {
        Product product = new Product();
        product.setName(productDto.getName());
        product.setPrice(productDto.getPrice());
        product.setStock(productDto.getStock());
        product.setCreatedAt(LocalDateTime.now());
        product.setUpdatedAt(LocalDateTime.now());

        Product savedProduct = productDAO.insertProduct(product);

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(savedProduct.getNumber());
        productResponseDto.setName(savedProduct.getName());
        productResponseDto.setPrice(savedProduct.getPrice());
        productResponseDto.setStock(savedProduct.getStock());

        return productResponseDto;
    }

    @Override
    public ProductResponseDto changeProductName(Long number, String name) throws Exception {
        Product changedProduct = productDAO.updateProductName(number, name);

        ProductResponseDto productResponseDto = new ProductResponseDto();
        productResponseDto.setNumber(changedProduct.getNumber());
        productResponseDto.setName(changedProduct.getName());
        productResponseDto.setPrice(changedProduct.getPrice());
        productResponseDto.setStock(changedProduct.getStock());

        return productResponseDto;
    }

    @Override
    public void deleteProduct(Long number) throws Exception {
        productDAO.deleteProduct(number);
    }
}

컨트롤러 생성

  • 서비스 객체의 설계를 마친 후에는 비즈니스 로직과 클라이언트의 요청을 연결하는 컨트롤러를 생성해야 한다.
// ProductController

import com.springboot.jpa.data.dto.ChangeProductNameDto;
import com.springboot.jpa.data.dto.ProductDto;
import com.springboot.jpa.data.dto.ProductResponseDto;
import com.springboot.jpa.service.ProductService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/product")
public class ProductController {

    private final ProductService productService;

    @Autowired
    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping()
    public ResponseEntity<ProductResponseDto> getProduct(Long number) {
        ProductResponseDto productResponseDto = productService.getProduct(number);

        return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
    }

    @PostMapping()
    public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto) {
        ProductResponseDto productResponseDto = productService.saveProduct(productDto);

        return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
    }

    @PutMapping()
    public ResponseEntity<ProductResponseDto> changeProductName(
            @RequestBody ChangeProductNameDto changeProductNameDto) throws Exception {
        ProductResponseDto productResponseDto = productService.changeProductName(
                changeProductNameDto.getNumber(),
                changeProductNameDto.getName());

        return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);

    }

    @DeleteMapping()
    public ResponseEntity<String> deleteProduct(Long number) throws Exception {
        productService.deleteProduct(number);

        return ResponseEntity.status(HttpStatus.OK).body("정상적으로 삭제되었습니다.");
    }

}
이 글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.

GET API

  • 웹 애플리케이션 서버에서 값을 가져올 때 사용하는 API
  • @RequestMapping : HTTP의 모든 요청을 받는 어노테이션
  • 스프링 4.3 버전 이후로는 새로 나온 어노테이션(@GetMapping, @DeleteMapping, @PostMapping, @PutMapping)을 사용하기 때문에 @RequestMapping은 잘 사용되지 않음
컨트롤러 클래스에 @RestController와 @RequestMapping 설정
@RestController
@RequestMapping("/api/v1/get-api)
public class GetController {
	
}

 

@PathVariable을 활용한 GET 메서드 구현
  • 실무 환경에서는 매개변수를 받지 않는 메서드가 거의 쓰이지 않음
  • 매개변수를 받을 때 자주 쓰이는 방법 중 하나 URL 자체에 값을 담아 요청하는 것
// http://localhost:8080/api/v1/get-api-variable1/{String 값}
@GetMapping(value = "/variable1/{variable}")
public String getVariable1(@PathVariable String variable) {
	return variable; // url에 넣은 입력값이 출력이 된다.
}

// @PathVariable에 변수명을 매핑하는 방법
@GetMapping(value = "/variable1/{variable}")
public String getVariable1(@PathVariable("variable") String var) {
	return var; // url에 넣은 입력값이 출력이 된다.
}

 

@RequestParam을 활용한 GET 메서드 구현
  • URL 경로에 값을 담아 요청을 보내는 방법 외에도 쿼리 형식으로 값을 전달할 수도 있다.
  • URI에서 ?를 기준으로 우측에'{Key}={Value}' 형태로 구성된 요청을 전송하는 방법
  • 쿼리스트링에 어떤 값이 들어올지 모른다면 Map 객체를 활용할 수도 있다.
// http://localhost:8080/api/v1/get-api-request1?name=kim&email=test@naver.com
@GetMapping("/request1")
public String getRequestParam(
	@RequestParam String name,
    	@RequestParam String email) {
    
	return name + " " + email // kim test@naver.com;
}

//  http://localhost:8080/api/v1/get-api-request1?key1=value1&key2=value2
@GetMapping("/request2)
public String getRequestParam2(@RequestParam Map<String, String> param) {
	StringBuilder sb = new StringBuilder();
    
    param.entrySet().forEach(map -> {
    	sb.append(map.getKey() + " : " + map.getValue() + "\n);
        });
        
        return sb.toString();
}
URI와 URL 차이
URL은 우리가 흔히 말하는 웹 주소를 의미하며, 리소스가 어디에 있는지 알려주기 위한 경로를 의미,

반면 URI는 특성 리소스를 식별할 수 있는 식별자를 의미 웹에서는 URL을 통해 리소스가 어느 서버에 위치해 있는지 알 수 있으며, 그 서버에 접근해서 리소스에 접근하기 위해서는 대부분 URI가 필요
DTO 객체를 활용한 GET 메서드 구현

DTO?

  • Data Transfer Object, 다른 레이어 간의 데이터 교환에 활용, 각 클래스 및 인터페이스를 호출하면서 전달하는 매개변수로 사용되는 데이터 객체
  • DTO는 데이터를 교환하는 용도로만 사용되는 객체이기 때문에 DTD에는 별도의 로직이 포함되지 않음

// DTO 클래스의 예시
public class MemberDto {
	private String name;;
    private String email;
    private String organization
    
    public String getName() {
    	return name;
    }
    
    public void setName(String name) {
    	this.name = name;
    }
    
    public String getEmail() {
    	return email;
    }
    
    public void setEmail(String email) {
    	this.email = email;
    }
    
    public String getOrganization() {
    	return organization;
    }
}

 

POST API

  • 웹 에플리케이션을 통해 데이터베이스 등의 저장소에 리소스를 저장할 때 사용되는 API
  • GET API에서는 URL의 경로나 파라미터에 변수를 넣어 요청을 보냄
  • POST API에서는 저장하고자 하는 리소스나 값을 HTTP 바디(body)에 담아 서버에 전달
  • 그래서 URI가 GET API에 비해 간단
@RequestMapping으로 구현하기
@RequestMapping(value = "/domain", method = RequestMethod.POST)
public String postExample() {
	return "Hello Post API";
}

// 위와 결과는 같다 RequestMapping보다는 PostMapping 사용!!!
@PostMapping("/domain)
public String postExample2() {
	return "Hello Post API";
}

 

RequestBody를 활용한 Post 메서드 구현
  • POST 요청에서는 리소스를 담기 위해 HTTP Body에 값을 넣어 전송
  • Body영역에 작성되는 값은 일정한 형태를 취한다.
  • 일반적으로 JSON 형식으로 전송
// http://localhost:8080/api/v1/post-api/member
// @RequestBody와 Map을 활용한 POST API 구현
@PostMapping("/member)
public String PostMember(@RequestBody Map<String, Object> postData) {
	StringBuilder sb = new StringBuilder();
    
    postData.entrySet().forEach(map -> {
    	sb.append(map.getKey() + " : " + map.getValue() + "\n");
    });
    
    return sb.toString();
}

// DTO 객체를 활용한 POST API 구현
// http://localhost:8080/api/v1/post-api/member2
@PostMapping("/member2)
public String postMemberDto(@RequestBody MemberDto memberDto) {
	return memberDto.toString();
}

JSON이란?
JavaScript Object Notation의 줄임말로 자바스크립트의 객체 문법을 따르는 문자 기반의 데이터 포맷
- 현재는 다수의 프로그래밍 환경에서 사용
- 대체로 네트워크를 통해 데이터를 전달할 때 사용
- 문자열 형태로 작성되기 때문에 파싱하기도 쉽다.

 

PUT API

  • 웹 애플리케이션 서버를 통해 데이터베이스 같은 저장소에 존재하는 리소스 값을 업데이트 하는데 사용
  • POST와 비교하면 요청을 받아 실제 데이터베이스에 반영하는 과정에서 차이가 있다
  • 컨트롤러 클래스 구현하는 방법은 POST와 거의 동일, 리소스를 서버에 전달하기 위해 HTTP Body를 활용해야 하기 때문
@RequestBody를 통한 PUT 메서드 구현
// http://localhost:8080/api/v1/put-api/member
@PutMapping("/member")
public String postMember(@RequestBody Map<String, Object> putData) {
	StringBuilder sb = new StringBuilder();
    
    putData.entrySet().forEach(map -> {
    	sb.append(map.getKey() + " : " + map.getValue() + "\n");
    });
    
    return sb.toString();
}

// http://localhost:8080/api/v1/put-api/member2
@PutMapping("/member2")
public MemberDto postMemberDt o2(@ReqeustBody MemberDto memberDto) {
	return memberDto; // 응답으로 memberDto 객체를 JSON 형식으로 반환
}

DELETE API

  • 웹 애플리케이션 서버에서 데이터베이스 등의 저장소에 있는 리소스를 삭제할 때 사용
  • 서버에서는 클라이언트로부터 리소스를 식별할 수 있는 값을 받아 데이터베이스나 캐시에 있는 리소스를 조회하고 삭제하는 역할 수행
  • 컨트롤러를 통해 값을 받는 단계에서는 간단한 값을 받기 때문에 GET 메서드와 같이 URI에 값을 넣어 요청을 받는 형식으로 구현
@PathVariable과 @RequestParam을 활용한 DELETE 메서드 구현
// @PathVariable을 활용한 DELETE 메서드 구현
// http://localhost:8080/api/v1/delete-api/{String 값}
@DeleteMapping(value = "/{variable}"}
public String deleteVariable(@PathVariable String variable) {
	return variable;
}

// @RequestParam을 활용한 DELETE 메서드 구현
// http://localhost:8080/api/v1/delete-api/request1?email=value
@DeleteMapping("/request1)
public String getReuqestParam1(@RequestParam String email) {
	return "email: " + email;
}

REST API 명세를 문서화 하는 방법

Swagger

  • 명세 : 해당 API가 어떤 로직을 수행하는지 설명하고 이 로직을 수행하기 위해 어떤 값을 요청하며, 이에 따른 응답값으로는 무엇을 받을 수 있는지를 정리한 자료

Swagger 의존성 추가

swagger 의존성 추가

Swagger 설정 코드
@Configuration
@EnableSwagger2
public class SwaggerConfiguration {
	
    @Bean
    public Docket api() {
    	return new Docket(DocumentationType.SWAGGER_2)
        	.apiInfo(apiInfo())
            .select()
            .apis(RequestHandlerSelectors.basePackage("com.springboot.api"))
            .paths(PathSelectors.any())
            .build();
    }
    
    private ApiInfo apiInfo() {
    	return new ApiInfoBuilder()
        	.title("Spring Boot Open API Test with Swagger")
            .description("설명 부분")
            .version("1.0.0")
            .build();
            
    }
}
// 기존 코드에 Swagger 명세를 추가
@ApiOperation(value = "GET 메서드 예제", notes = "@RequestParam을 활용한 GET Method")
@GetMapping("/request1")
public String getRequestParam(
	@ApiParam(value = "이름", required = true) @RequestParam String name,
    	@ApiParam(value = "이메일", required = true)@RequestParam String email) {
    
	return name + " " + email // kim test@naver.com;
}
  • @ApiOperation : 대상 API의 설명을 작성하기 위한 어노테이션
  • @ApiParam : 매개변수에 대한 설명 및 설정을 위한 어노테이션, 메서드의 매개변수 뿐 아니라 DTO 객체를 매개변수로 사용할 경우 DTO 클래스 내의 매개변수에도 정의할 수 있음

로깅 라이브러리

로깅?
애플리케이션이 동작하는 동안 시스템의 상태나 동작 정보를 시간순으로 기록하는 것을 의미
로깅은 개발 영역 중 ‘비기능 요구사항’, 즉 사용자가나 고객에게 필요한 기능은 아니다
하지만 디버깅하거나 개발 이후 발생한 문제를 해결할 때 원인을 분석하는데 꼭 필요한 요소

Logback

  • 자바 진영에서 가장 많이 사용되는 로깅 프레임워크는 LogBack
  • log4j 이후에 출시된 로깅 프레임워크, slf4j를 기반으로 구현
  • 과거에 사용되던 log4j에 비해 월등한 성능을 자랑
Logback의 특징
  • 크게 5개의 로그 레벨(TRACE, DEBUG, INFO, WARN, ERROR)를 설정할 수 있다.
    • ERROR : 로직 수행 중에 시스템에 심각한 문제가 발생해서 애플리케이션의 작동이 불가능한 경우
    • WARN : 시스템 에러의 원인이 될 수 있는 경고 레벨을 의미
    • INFO : 애플리케이션의 상태 변경과 같은 정보 전달을 위해 사용
    • DEBUG : 애플리케이션의 디버깅을 위한 메세지를 표시하는 레벨을 의미
    • TRACE : DEBUG 레벨보다더 상세한 메세지를 표현하기 위한 레벨을 의미
  • 실제 운영 환경과 개발 환경에서 각각 다른 출력 레벨을 설정해서 로그를 확인 가능
  • Logback의 설정 파일을 일정시간마다 스캔해서 애플리케이션을 재가동하지 않아도 설정을 변경 가능
  • 별도의 프로그램 지원 없이도 자체적으로 로그 파일을 압축할 수 있음
  • 저장된 로그 파일에 대한 보관 기간 등을 설정해서 관리할 수 있음

 

이 글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.

프로젝트 생성(인텔리제이)

1. 인텔리제이에서 메뉴 탭 -> 파일 -> 새로 만들기 -> 프로젝트 -> Spring Initializer

프로젝트 설명

  • 이름(Name) : 프로젝트 이름
  • 위치(Location) : 프로젝트를 생성할 위치 설정
  • 언어(Language) : JVM 상에서 동작하는 언어를 선택 ‘Java’ 선택
  • 타입(Type) : 빌드 툴 선택, 여기 실습은 Maven 선택 → 요즘은 Gradle 사용
  • 아티팩트(Artifact) : 세부 프로젝트를 식별하는 정보 기입
  • 패키지 이름(Pakage Name) : Group과 Artifact를 설정하면 자동으로 입력
  • JDK : 자바 버전
  • 패키지 생성(Packaging) : 애플리케이션을 쉽게 배포하고 동작하게 할 파일들의 패키징 옵션, 이 책에서는 ‘Jar’ 선택

 

프로젝트 의존성 설정

  • 원하는 의존성을 프로젝트 생성 시 추가 가능, 생성 이후에도 추가 가능

 

스프링부트 버전 변경

  • pom.xml 파일에서 version 부분을 2.5.6(or 원하 버전)으로 변경

 

프로젝트 생성(스프링 공식 사이트)

  • https://start.spring.io 사이트로 들어가서 프로젝트 생성

스프링 공식 사이트에서 프로젝트 생성

  • 설정 후 GENERATE 누르면 압축 파일이 다운로드가 된다
  • 압축 푼 후에 인텔리제이 열기로 열면 끝!!

 

pom.xml(Project Object Model)

pom.xml 파일은 메이븐의 기능을 사용하기 위해 작성하는 파일
프로젝트, 의존성 라이브러리, 빌드 등의 정보 및 해당 프로젝트를 관리하는데 필요한 내용이 기술 있음

 

빌드 관리 도구

메이븐(Maven)

  • 아파치 메이븐은 자바 기반의 프로젝트를 빌드하고 관리하는 데 사용되는 도구
  • pom.xml 파일에 필요한 라이브러리를 추가하면 해당 라이브러리에 필요한 라이브러리까지 함께 내려받아 관리

메이븐의 대표 기능

  • 프로젝트 관리 : 프로젝트 버전과 아티팩트를 관리
  • 빌드 및 패키징 : 의존성을 관리하고 설정된 패키지 형식으로 빌드를 수행
  • 테스트 : 빌드를 수행하기 전에 단위 테스트를 통해 작성된 애플리케이션 코드의 정상 동작 여부를 확인
  • 배포 : 빌드가 완료된 패키지를 원격 저장소에 배포

메이븐의 생명 주기

메이븐의 기능은 생명주기 순서에 따라 관리되고 동작
  1. 기본 생명 주기(Default Lifecycle)
    • validate : 프로젝트를 빌드하는데 필요한 모든 정보를 사용할 수 있는지 검토
    • compile : 프로젝트의 소스코드를 컴파일
    • test : 단위 테스트 프레임워크를 사용해 테스트를 실행
    • pakage : 컴파일한 코드를 가져와서 JAR 등의 형식으로 패키징을 수행
    • verify : 패키지가 유효하며 일정 기준을 충족하는지 확인
    • install : 프로젝트를 사용하는 데 필요한 패키지를 로컬 저장소에 설치
    • deploy : 프로젝트를 통합 또는 릴리스 환경에서 다른 곳에 공유하기 위해 원격 저장소에 패키지를 복사
  2. 클린 생명 주기(Clean LifeCycle)
    • 이전 빌드가 생성한 모든 파일을 제거
  3. 사이트 생명 주기(Site LifeCycle)
    •  
    • site : 메이븐의 설정 파일 정보를 기반으로 프로젝트의 문서 사이트를 생성
    • site-deploy : 생성된 사이트 문서를 웹 서버에 배포

최근에는 그래이들(Gradle)로 빌드 도구가 전한되는 추세이다.
이 글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.

책의 구성 환경

  • Windows 10 64 bit
  • 인텔리제이 Ultimate
  • JDK11
  • 스프링 부트 2.5.6 ~ 2.5.8
  • MariaDB 10.6.5
  • 메이븐(Maven)

자바 JDK 설치

  1. Azul 공식 사이트에 가서 open jdk를 다운로드 받는다.(.msi 확장자로)
  2. 이후에 설치 파일을 실행시켜서 NEXT 눌러서 설치 진행
  3. 환경 변수를 설정 한다. 

인텔리제이 설치

인텔리제이는 젯브레인(JetBrains)에서 제작한 자바용 통합 개발 환경(IDE)이다.
이전에는 이클립스가 많이 사용되었지만 최근에는 인텔리제이가 많이 사용됨
  1. 구글에서 인텔리제이 사이트로 가서 IDEA를 다운로드 받는다.
  2. 다운로드 받은 설치 파일을 실행해서 NEXT를 쭉 눌러서 설치 진행
  3. 설치 옵션 선택 시 아래 아래의 이미지와 같이 선택 후 넘어간다.
  4. 설치 완료 후 재부팅

 

 

이 글은 스프링 부트 핵심 가이드(장정우 저)를 읽고 개인적으로 정리하기 위한 글입니다.

서버간 통신

  • 포털 사이트를 하나의 서비스 단위로 개발한다고 했을 때 서버를 업데이트하거나 애플리케이션을 유지보수 할 때 마다 사이트 작업 중입니다라는 팻말을 걸고 작업을 해야 합니다. 그만큼 개발에 보수적인 입장을 취할 수밖에 없고, 서비스 자체의 규모도 커지기 때문에 서비스를 구동하는데 걸리는 시간도 길어진다.
  • 이 같은 문제를 해결하기 위해 나온 것이 마이크로서비스 아키텍쳐(MSA) 말그대로 서비스 규모를 작게 나누어 구성한 아키텍쳐
    ex) 블로그 프로젝트, 카페 프로젝트, 메일 프로젝트등 애플리케이션을 기능별로 나눠서 개발하게 된다.
  • 각각 독립적인 애플리케이션을 개발 시 서비스 간에 통신해야 하는 경우가 발생(서버 간 통신)
  • 가장 많이 사용되는 방식은 HTTP/HTTPS

출처 : 스프링 부트 핵심 가이드


스프링부트의 동작 방식

  • 부트에서 spring-boot-starter-web 모듈을 사용하면 기본적으로 톰캣(Tomcat)을 사용하는 스프링 MVC 구조를 기반으로 동작
  • 서블릿 : 클라이언트의 요청을 처리하고 결과를 반환하는 자바 웹 프로그래밍 기술 (서블릿 컨테이너에서 관리) 서블릿 컨테이너는 서블릿 인스턴스를 생성하고 관리하는 역할 역할을 수행하는 주체로서 톰캣은 WAS의 역할과 서블릿 컨테이너의 역할을 수행하는 대표적인 컨테이너
    • 서블릿 객체를 생성, 초기화, 호출, 종료하는 생명주기 관리
    • 서블릿 객체는 싱글톤 패턴으로 관리
    • 멀티 스레딩을 지원
  • 스프링에서는 DispatcherServlet이 서블릿의 역할 수행
  • DispatcherServlet의 동작
    1. DispatcherServlet으로 요청(HttpServletReuqest)이 들어오면 DispatcherServlet은 핸들러 매핑(Handler Mapping)을 통해 요청 URI에 매핑된 핸들러(Controller)를 탐색한다.
    2. 핸들러 어댑터(Handler Adapter)로 컨트롤러를 호출
    3. 핸들러 어댑터에 컨트롤러의 응답이 돌아오면 ModelAndView로 응답을 가공해 반환
    4. 뷰 형식으로 리턴하는 컨트롤러를 사용할 때는 뷰 리졸버(View Resolver)를 통해 View(뷰)를 받아 리턴
핸들러 매핑?
요청 정보를 기준으로 어떤 컨트롤러를 사용할지 선정하는 인터페이스
  • 뷰 리졸버는 뷰의 렌더링 역할을 담당하는 뷰 객체를 반환
  • 이 책에서 다룰 애플리케이션은 뷰가 없는 REST 형식의 @ResponseBody를 사용 예정 뷰 리졸버를 호출하지 않고 MessageConverter를 거쳐 JSON 형식으로 변환해서 응답

스프링 부트 동작 구조



레이어드 아키텍처

애플리케이션의 컴포넌트를 유사 관심사를 기준으로 레이어로 묶어 수평적으로 작성한 구조를 의미
여러 방면에서 쓰이는 개념이며, 어떻게 설계하느냐에 따라 용어와 계층의 수가 달라진다.
일반적으로 레이어드 아키텍처라 하면 3계층 또는 4계층 구성을 의미
이 차이는 인프라(데이터베이스)레이어의 추가 여부로 결정

프레젠테이션 계층

  • 애플리케이션의 최상단 계층, 클라이언트의 요청을 해석하고 응답하는 역할
  • UI나 API 제공
  • 프레젠테이션 계층은 별도의비즈니스 로직을 포함하고 있지 않으므로 비즈니스 계층으로 요청을 위임하고 받은 결과를 응답하는 역할만 수행

비즈니스 계층

  • 애플리케이션이 제공하는 기능을 정의하고 세부 작업을 수행하는 도메인 객체를 통해 업무를 위임하는 역할을 수행한다.
  • DDD(Domain-Driven Design) 기반의 아키텍처에서는 비즈니스 로직에 도메인이 포함되기도 하고, 별도로 도메인 계층을 두기도 한다.

데이터 접근 계층

  • 데이터베이스에 접근하는 일련의 작업을 수행

레이어드 아키텍처의 특징

  1. 각 레이어는 가장 가까운 하위 레이어의 의존성을 주입 받는다.
  2. 각 레이어는 관심사에 따라 묶여 있으며, 다른 레이어의 역할을 침범하지 않는다.
    1. 각 컴포넌트의 역할이 명확하므로 코드의 가독성과 기능 구현에 유리
    2. 코드의 확장성도 좋아진다.
  3. 각 레이어가 독립적으로 작성되면 다른 레이어와의 의존성을 낮춰 단위 테스트에 용이

스프링의 레이어드 아키텍처 / 이미지 출처 : https://velog.io/@dnrwhddk1/Spring-레이어드-아키텍처


디자인 패턴

  • 소프트웨어를 설계할 때 자주 발생하는 문제를 해결하기 위해 고안된 해결책
  • 디자인 패턴이 모든 문제의 정답은 아니고 상황에 맞는 최적 패턴을 결정해서 사용하는 것이 바람직하다.

디자인 패턴의 종류

  • 디자인 패턴을 구체화해서 정리한 대표적인 분류 방식으로 GoF 디자인 패턴 이라는 것이 있다.

GoF 디자인 패턴 분류 / 출처 : https://velog.io/@namezin/GoF-design-pattern

  • GoF 디자인 패턴은 생성 패턴,구조 패턴, 행위 패턴의 총 3가지로 구분
  • 생성 패턴
    • 객체 생성에 사용되는 패턴으로, 객체를 수정해도 호출부가 영향을 받지 않게 됨
  • 구조 패턴
    • 객체를 조합해서 더 큰 구조를 만드는 패턴
  • 행위 패턴
    • 객체 간의 알고리즘이나 책임 분배에 관한 패턴
    • 객체 하나로는 수행할 수 없는 작업을 여러 객체를 이용해 작업을 분배. 결합도 최소화를 고려할 필요가 있음

생성 패턴

  • 추상 팩토리 : 구체적인 클래스를 지정하지 않고 상황에 맞는 객체를 생성하기 위한 인터페이스를 제공하는 패턴
  • 빌더 : 객체의 생성과 표현을 분리해 객체를 생성하는 패턴
  • 팩토리 메서드 : 객체 생성을 서브 클래스로 분리해서 위임하는 패턴
  • 프로토 타입 : 원본 객체를 복사해 객체를 생성하는 패턴
  • 싱글톤 : 한 클래스마다 인스턴스를 하나만 생성해서 인스턴스가 하나임을 보장하고 어느 곳에서도 접근할 수 있게 제공하는 패턴

구조 패턴

  • 어댑터 : 클래스의 인터페이스를 의도하는 인터페이스로 변환하는 패턴
  • 브리지 : 추상화와 구현을 분리해서 각각 독립적으로 변형케 하는 패턴
  • 컴포지트 : 여러 객체로 구성된 복합 객체와 단일 객체를 클라이언트에서 구별 없이 다루는 패턴
  • 데코레이터 : 객체의 결합을 통해 기능을 동적으로 유연하게 확장할 수 있게 하는 패턴
  • 퍼사드 : 서브시스템의 인터페이스 집합들에 하나의 통합된 인터페이스를 제공하는 패턴
  • 플라이웨이트 : 특정 클래스의 인스턴스 한 개를 가지고 여러 개의 ‘가상 인스턴스’를 제공할 떄 사용하는 패턴
  • 프락시 : 특정 객체를 직접 참조하지 않고 해당 객체를 대행(프락시)하는 객체를 통해 접근하는 패턴

행위 패턴

  • 책임 연쇄 : 요청 처리 객체를 집합으로 만들어 결합을 느슨하게 만드는 패턴
  • 커맨드 : 실행될 기능을 캡슐화해서 주어진 여러 기능을 실행하도록 클래스를 설계하는 패턴
  • 인터프리터 : 주어진 언어의 문법을 위한 표현 수단을 정의하고 해당 언어로 구성된 문장을 해석하는 패턴
  • 이터레이터 : 내부 구조를 노출하지 않으면서 해당 객체의 집합 원소에 순차적으로 접근하는 방법을 제공하는 패턴
  • 미디에이터 : 한 집합에 속한 객체들의 상호작용을 캡슐화하는 객체를 정의한 패턴
  • 메멘토 : 객체의 상태 정보를 저장하고 필요에 따라 상태를 복원하는 패턴
  • 옵저버 : 객체의 상태 변화를 관찰하는 관찰자들, 즉 옵저버 목록을 객체에 등록해 상태가 변할 때마다 메서드 등을 통해 객체가 직접 옵저버에게 통지하게 하는 디자인 패턴
  • 스테이트 : 상태에 따라 객체가 행동을 변경하게 하는 패턴
  • 스트래티지 : 행동을 클래스로 캡슐화해서 동적으로 행동을 바꿀 수 있게 하는 패턴
  • 템플릿 메서드 : 일정 작업을 처리하는 부분을 서브클래스로 캡슐화해서 전체 수행 구조는 바꾸지 않으면서 특정 단계만 변경해서 수행하는 패턴
  • 비지터 : 실제 로직을 가지고 있는 객체(visitor)가 로직을 적용할 객체를 방문하여 실행하는 패턴

REST API

  • 대중적으로 가장 많이 사용되는 애플리케이션 인터페이스
  • 이 인터페이스를 통해 클라이언트는 서버에 접근하고 자원을 조잘할 수 있음

REST?

  • Representational State Transfer의 약자로 WWW과 같은 분산 하이퍼미디어 시스템 아키텍처의 한 형식
  • 주고받는 자원(Resource)에 이름을 규정하고 URI에 명시해 HTTP 메서드(GET, POST, PUT, DELETE)를 통해 해당 자원의 상태를 주고받는 것을 의미

REST API?

  • API : Application Programing Interface, 애플리케이션에서 제공하는 인터페이스를 의미 API를 통해 서버 또는 프로그래매 사이를 연결할 수 있다.
  • REST API는 REST 아키텍처를 따르는 시스템/애플리케이션 인터페이스라고 볼 수 있다.
  • REST 아키텍처를 구현하는 웹 서비스를 RESTful하다 라고 표현

REST 의 특징

  1. 유니폼 인터페이스
    1. 일관된 인터페이스를 의미, REST 서버는 HTTP 표준 전송 규약을 따르기 때문에 어떤 프로그래밍 언어로 만들어졌느냐와 상관없이 플랫폼 및 기술에 종속되지 않고 타 언어, 플랫폼, 기술등과 호환해 사용할 수 있단느 것을 의미
  2. 무상태성
    1. 서버에 상태 정보를 따로 보관하거나 관리하지 않는다는 의미
  3. 캐시 가능성
    1. HTTP 표준을 그대로 사용하므로 HTTP의 캐싱 기능을 적용할 수 있음
    2. 이 기능을 이용하기 위해 응답과 요청이 모두 캐싱 가능한지 명시가 필요, 캐싱이 가능한 경우 클라이언트에서 캐시에 저장해두고 같은 요청에 대해서는 해당 데이터를 가져다 사용. 이 기능을 사용하면 서버의 트랜잭션 부하가 줄어 효율적이며 사용자 입장에서 성능 개선
  4. 레이어 시스템
    1. REST 서버는 네트워크 상의 여러 계층으로 구성될 수 있다. 그러나 서버의 복잡도와 관계없이 클라이언트는 서버와 연결되는 포인트만 알면 됨
  5. 클라이언트-서버 아키텍처
    1. REST 서버는 API를 제공하고 클라이언트는 사용자 정보를 관리하는 구조로 분리해 설계. 이 구성은 서로에 대한 의존성을 낮추는 기능

URI 설계 규칙 / 출처 : 스프링 부트 핵심 가이드

 

+ Recent posts