728x90
반응형
728x90
반응형
반응형

환경: springboot2.5 이상, mysql 5.7 버전

 

1. spring jpa h2디비 적용 시 테이블 명이 아래와 같을 때 아무 설정을 안 해주면 camel case -> snake로 변형되어 적용된다.

@Table(name = "GlobalAccounts")

즉 위 테이블이 global_accounts 로 변경되어 생성된다.

테이블 명 그대로 적용되어야 한다면 아래 설정을 추가한다.

spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl

 

2. 프로덕션 코드의 설정파일이 yml 이고 테스트 코드 설정파일이 properties 면 자동으로 설정되지 않는 것다. 도대체 왜지?!!!

https://mageddo.com/tools/yaml-converter 로 굳이 properties -> yml을 변환하여 설정하였다.

그게 싫다면 아래 어노태이션을 매 테스트 클래스마다 넣어야 하는데 매우 별로..

@TestPropertySource(locations = "classpath:application.properties")

 

+ application.properties 와 application.yml 둘 다 있을 경우 yml -> properties 순으로 로딩되어 같은 key값이 있을경우 properties의 값으로 덮어짐

 

3. rownum에 대하여

mysql 8.0 이상에서는 row_number() 윈도우 함수를 사용가능하나 그 이하 버전에서는 지원하지 않는다.

그래서 아래와 같이 session variable을 이용하여 쿼리 내에서 등수를 뽑곤 하는데..

여기서

1. session variable 명이 h2 예약어(rownum) 이면 identifier 에러가 나고

expected identifier [42001-200]

http://www.h2database.com/html/advanced.html

2. 다른 이름으로 바꿔도.. 제대로된 순위가 나오지 않는다..

 

h2에서 등수를 뽑거나,, session variable을 사용하려면 아래 h2함수를 사용해야 하는 것 같다..

http://h2database.com/html/functions.html#set

 

즉.. springboot jpql로 native query를 테스트하려면.. h2 문법도 잘 알아야할 듯하다.

 

4. String value too long 에러

h2의 경우 String에 어마무시한 json을 담을 경우 아래와 같은 에러를 만날 수 있는데,

Caused by: org.h2.jdbc.JdbcSQLDataException: Value too long for column "options CHARACTER VARYING(255)"

String은 기본 255 길이까지 가능이라, 그 이상의 데이터는 최장 길이를 아래와 같이 명시해주어야 한다..

@Column(length = 512)
private String options;
728x90
반응형
반응형

환경: spring jpa 2.5

Native Query 사용 시 `Space is not allowed after parameter prefix ':'`

라는 에러 발생

 

아래 쿼리의 @rownum := @rownum+1 에서 발생

:= 가 문제였음.

select * from 
(select seasonid, profileid, score, @rownum := @rownum+1 as ranking
from Season, (SELECT @rownum :=0)r

 

해결: \\를 추가하여 해결

@rownum \\:= @rownum+1

 

728x90
반응형
반응형

이슈

- build 시 intelliJ면 아래와 같은 에러가 남

- gradle 일 경우 에러 안 남

 

해결

- interface일 경우 @PathVariable에 name 을 명시

 

참고

https://shanepark.tistory.com/331

728x90
반응형
반응형

서로 다른 레이어에서 변수들을 전달할 때 서로 다른 dto를 사용하는데, 얼핏 비슷하면서도 한두 개 다른 변수들을 하나씩 매핑해 주는 게 매우 귀찮고 번거로웠다.

어휴..

그래서 이를 자동(?)으로 해주는 라이브러리 같은 게 있을까 싶어서 몇 개 찾아보았다.

구글링 해보면 여러 개가 나오는데 아래 3개를 먼저 확인해 보았다.

1. 스프링에 기본 내장되어 있는 BeanUtils.copyProperties

2. 사용하기 편해 보이는 ModelMapper

3. 요즘 제일 인기 있는 Mapstruct

 

실제로 사용하려는 목적이기에 현실적인 사용성을 중심으로 살펴보았다.

  dependency di주입 원리(setter 선언 필요?) 필드 커스텀 가능 여부
Mapstruct 추가 필요(4개) 빈 주입 필요
interface 작성
리플랙션X 
- 컴파일 시 작성된 getter/setter or 빌더 or 생성자 등 관련 코드를 이용하여 변환 class 만듦
- setter 없어도 됨
매핑으로 커스텀 설정 가능
ModelMapper 추가 필요(1개) 주입 필요 없음 리플렉션O getter/setter 기반
- 필드명/타입이 동일할 경우 필드 엑서스 레벨을 private으로 설정하면 setter가 필요없지만
- 커스텀 시 setter 선언 필요
커스텀을 위해 typeMap과 addMapping 을 이용하여 손수 매핑해줘야 함
BeanUtils.copyProperties 추가 필요 없음
spring 내장
주입 필요 없음
static method
void type
리플렉션O getter/setter 기반
- setter 선언 필요
- 커스텀 시 setter 수정 필요
더 복잡한 변환이 필요할 경우 사용 불가하며 spring BeanWrapper를 직접 구현해야 함

Mapstruct

lombok과 함께 사용가능(lombok compile 이후에 작동)

implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
testAnnotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
//If you are using Lombok 1.18.16 or newer you also need to add lombok-mapstruct-binding in order to make Lombok and MapStruct work together.
implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
@Mapper(config = CommonMapper.class,
        nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
        nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface CharacterUpdateRequestMapper {

  @Mapping(target = "searchType", source = "findType")
  @Mapping(target = "searchValue", source = "value")
  @Mapping(target = "characterDetail", source = "detailCharacterData")
  CharacterUpdateRequest toRequest(ExternalCharacterUpdateRequest external);

  @Mapping(target = "position", source = "lastSavePosition")
  @Mapping(target = "openedDivisions", ignore = true)
  @Mapping(target = "divisionFlattened", ignore = true)
  CharacterDetail toDetail(ExternalCharacterDetail external);
}
@RequiredArgsConstructor
...
private final CharacterUpdateRequestMapper characterUpdateRequestMapper;

public CharacterUpdateRequest usingMapStruct() {
  return characterUpdateRequestMapper.toRequest(externalCharacterUpdateRequest);
}

필드명이 다를 경우만 mapping을 적어주면 된다(nested object도. 을 사용하여 매핑 가능).

class type이 다르면 converter를 별도로 구현해야 한다.

 

컴파일 시 구현체를 자동으로 생성해 주며 잘못된 매핑을 적을 경우 컴파일이 되지 않기 때문에 런타임 에러를 방지할 수 있다.

참고자료

https://mapstruct.org/documentation/stable/reference/html/#defining-mapper

https://madplay.github.io/post/mapstruct-in-springboot


ModelMapper

implementation 'org.modelmapper:modelmapper:3.1.1'
public class ModelMapperUtils {

  private static final ModelMapper MODEL_MAPPER;

  static {
    MODEL_MAPPER = new ModelMapper();
    MODEL_MAPPER.getConfiguration()
        .setMatchingStrategy(MatchingStrategies.STRICT)
        .setFieldAccessLevel(AccessLevel.PRIVATE)// 필드 전체 이름이 같아서 setter가 필요없다면
        .setFieldMatchingEnabled(true);
  }

  public static CharacterUpdateRequest fromExternalCharacterUpdateRequest(ExternalCharacterUpdateRequest externalCharacterUpdateRequest) {
    //클래스 매핑하고 필드 매핑해야함
    MODEL_MAPPER.createTypeMap(ExternalCharacterUpdateRequest.class, CharacterUpdateRequest.class)
        .addMapping(ExternalCharacterUpdateRequest::getDetailCharacterData, CharacterUpdateRequest::setCharacterDetail)
        .addMapping(ExternalCharacterUpdateRequest::getFindType, CharacterUpdateRequest::setSearchType);
    MODEL_MAPPER.createTypeMap(ExternalCharacterDetail.class, CharacterDetail.class)
        .addMapping(ExternalCharacterDetail::getLastSavePosition, CharacterDetail::setPosition);
    return MODEL_MAPPER.map(externalCharacterUpdateRequest, CharacterUpdateRequest.class);
  }
}

같은 형/타입일 경우 자동으로 매핑해 주고, 변형된 필드일 경우 손수 getter/setter를 사용하여 매핑해줘야 한다.

런타임 시점에 Reflection API를 사용하여 객체를 매핑하기 때문에 컴파일 시점에 성능 최적화를 하지 못하고 다른 방식보다 오버헤드가 많다는 특징이 있다.

참고자료

https://stackoverflow.com/questions/70274381/how-to-map-a-nested-list-using-modelmapper

https://www.baeldung.com/java-modelmapper


BeanUtils.copyProperties

예전에 다른 분께 memory leak 이슈가 있으니 사용에 조심해야 한다고 피드백받았던 적이 있어서 잠시 사용을 안 했었는데, 다시 찾아보니 관련 reference가 잘 나오지 않는다..

1. Circular References (순환 참조)

  • 객체 간에 순환 참조가 있는 경우, 객체가 서로를 참조하게 되어 가비지 컬렉션이 되지 않을 수 있습니다. 이로 인해 메모리 누수가 발생할 수 있습니다.

2. Large Object Graphs

  • 복사하는 객체가 큰 그래프를 가지거나 깊게 중첩된 경우, 복사 과정에서 많은 메모리를 소모할 수 있습니다. 특히, 여러 번 복사할 경우, 사용하지 않는 객체가 메모리에 남아 있을 수 있습니다.

3. Static Fields

  • 복사하려는 객체가 정적 필드를 포함하고 있다면, 이 필드는 여전히 메모리에 남아 있어 메모리 누수가 발생할 수 있습니다. 정적 필드는 클래스 로딩 시 한 번만 메모리에 할당됩니다.

 

dto 내부에서 생성자 및 세팅 함수 작성 시 유리하다.

public CharacterUpdateRequest(ExternalCharacterUpdateRequest externalCharacterUpdateRequest) {
  BeanUtils.copyProperties(externalCharacterUpdateRequest, this);
  super.setSearchValue("custom setting");
}

같은 형/타입일 경우 자동으로 매핑해 주고, 그 외에는 setter 이용하는 등 개발해줘야 한다.

 

변형폼:

//일부만 변환할 때
public static void copyProperties(Object source, Object target, Class<?> editable)

//일부만 제외할 때
public static void copyProperties(Object source, Object target, String... ignoreProperties)

시작은 mapStruct 말고 modelMapper를 쓰려고 시작한 서베이인데,
1. 리플렉션 방식은 필드 개수가 많아지면 성능이 구려짐
2. 결국 getter/setter를 통해 일대일 매핑
3. 가독성이 mapStruct가 훨씬 좋음
위의 이유로 인해 mapStruct 쓰는 게 나을 것 같다는 생각을 하였다.

mapStruct 사용후기

before: 아래와 같은 함수를 2쌍 만들어 주었어야 함

  public static OasisBannerResponse from(StaticOasisBanner entity) {
  return OasisBannerResponse.builder()
      .seq(entity.getSeq())
      .exposureType(entity.getExpsType())
      .regDate(entity.getRegDate())
      .startDate(entity.getStartDate())
      .endDate(entity.getEndDate())
      .linkType(CommonEnumUtil.fromCode(entity.getLinkType().toString(), LinkType.values()).getCode())
      .linkUrl(entity.getLinkUrl())
      .iconImageUrl(entity.getIconImgUrl())
      .backgroundColor(entity.getBackgroundColor())
      .koreanText(entity.getKoText())
      .englishText(entity.getEnText())
      .registerer(entity.getRegisterer())
      .updater(entity.getUpdater())
      .build();
}

after: 인터페이스에 변수 매핑하면 끝..

object -> entity 한방향 매핑만 잘 연결해 놓으면 역방향 매핑(entity -> object)은 @InheritInverseConfiguration만 달아주면 알아서 매핑해준다는게 너무 편리하였다.

@Mapper(config = CommonMapper.class,
        nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
        nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
public interface OasisBannerMapper {

  @Mapping(target = "exposureType", source = "expsType")
  @Mapping(target = "iconImageUrl", source = "iconImgUrl")
  @Mapping(target = "koreanText", source = "koText")
  @Mapping(target = "englishText", source = "enText")
  @Mapping(target = "linkType", expression = "java(convertLinkType(entity.getLinkType()))")
  OasisBannerResponse toResponse(StaticOasisBanner entity);


  @Mapping(target = "extra", ignore = true)
  @Mapping(target = "lastDate", ignore = true)
  @InheritInverseConfiguration
  StaticOasisBanner toEntity(OasisBannerResponse request);

 

그러나...

단점 1. 커스텀이 (생각보다) 쉽지 않다(러닝 커브가 있음)

적절한 설정값을 확인해야 하고 다양한 매핑을 지원해야 하는지 확인해야 하는 등, 특이점이 많은 매핑에는 아직 시간이 걸린다(가끔은 그냥 빌더가 더 편하다).

예시로..

  @Mapping(target = "expsType", source = "request.exposureType")
  @Mapping(target = "iconImgUrl", source = "request.iconImageUrl")
  @Mapping(target = "koText", source = "request.koreanText")
  @Mapping(target = "enText", source = "request.englishText")
  @Mapping(target = "extra", ignore = true)
  @Mapping(target = "lastDate", ignore = true)
  @Mapping(target = "updater", source = "updater")
//  @InheritInverseConfiguration
  StaticOasisBanner toEntity(OasisBannerResponse request, String updater);

위처럼 argument가 여러 개일 경우 @InheritInverseConfiguration를 사용하지 못하고 하나씩 다 매핑해줘야 한다.(이걸로 못 찾고 시간을 허비함..)

@InheritInverseConfiguration를 사용하여 반대 매핑도 자동으로 되는 게 매력적이었는데, 다시 수동으로 하려니 메리트가 절감된 느낌

 

단점 2. 에러가 친절하지 못함

단점 1에 기술한대로 추가 arguments가 있을 때는 모두 매핑해야 한다는 사실을 모르고 처음에는 생각대로 해봤는데, 빌드조차 되지 않았다. 그런데 에러 내용이 갑자기 querydsl 관련 에러였다?

처음에는 빌드창을 조금만 띄워놓았어서 에러내용을 끝까지 못 보고 querydsl 설정이 꼬였나 싶어서 거기만 팠는데,, 에러창을 더 열어보니 결국은 mapstruct 빌드로 부터 시작한 것이었다.

파악해 보니, 빌드 순서가 lombok -> mapstruct -> querydsl 순이었고

mapstruct 빌드가 실패하면 querydsl도 빌드되지 않기 때문에 에러메시지는 querydsl이 실패한 것처럼 보이나,

실제로 에러의 시작점은 mapstruct이었던 것이다(처음에 이것을 모르고 querydsl 설정이 문제라고 생각하여 시간을 허비함).

 

단점 3. @MappingTarget사용 시 setter 사용

@InheritConfiguration
void setEntity(OasisBannerResponse request, @MappingTarget StaticOasisBanner entity);

위처럼 @MappingTarget을 이용하면 setter 기반으로 매핑을 만들어주기 때문에 target 객체에 setter를 선언해야 한다..

setter를 최소화하고 싶었는데.... 고민이 더 필요할 것 같다.

기존 <interface>

@Mapping(target = "exposureType", source = "expsType")
@Mapping(target = "iconImageUrl", source = "iconImgUrl")
@Mapping(target = "koreanText", source = "koText")
@Mapping(target = "englishText", source = "enText")
@Mapping(target = "linkType", source = "linkType", qualifiedByName = "convertToLinkTypeFrom")
OasisBannerResponse toResponse(StaticOasisBanner entity);

-> 구현체: Builder를 이용한 객체 생성(builder -> constructor -> getter/setter 순으로 찾아서 구현체 생성)

@Override
public OasisBannerResponse toResponse(StaticOasisBanner entity) {
    if ( entity == null ) {
        return null;
    }

    OasisBannerResponse.OasisBannerResponseBuilder oasisBannerResponse = OasisBannerResponse.builder();

    if ( entity.getExpsType() != null ) {
        oasisBannerResponse.exposureType( entity.getExpsType() );
    }
    ... 생략

 

<interface> MappingTarget 사용 시

 

@InheritConfiguration
void setEntity(OasisBannerRequest request, @MappingTarget StaticOasisBanner entity);

 

-> 구현체: setter를 이용한 매핑

@Override
public void setEntity(OasisBannerRequest request, StaticOasisBanner entity) {
    if ( request == null ) {
        return;
    }

    entity.setExpsType( request.getExposureType() );
    if ( request.getIconImageUrl() != null ) {
        entity.setIconImgUrl( request.getIconImageUrl() );
    }
    
    ... 생략

 

단점 4. lombok의 @Singular와 함께 사용 불가한 듯

<before> 기존에는 아래처럼 구현했었음

public class OasisBannerResponse {

    @JsonInclude(value = JsonInclude.Include.NON_DEFAULT)
    @JsonProperty("freeWayName")
    @Singular
    private List<CodeValueModel> freeWayNames;

... 변수 생략

    public static OasisBannerResponse from(StaticOasisBanner entity) {
      return OasisBannerResponse.builder()
          .startDate(entity.getStartDate())
          .endDate(entity.getEndDate())
          .linkType(entity.getLinkType())
          .linkUrl(entity.getLinkUrl())
          .iconImageUrl(entity.getIconImgUrl())
          .backgroundColor(entity.getBackgroundColor())
          .freeWayName(new CodeValueModel(LanguageType.KO.getCode(), entity.getKoText()))
          .freeWayName(new CodeValueModel(LanguageType.EN.getCode(), entity.getEnText()))
          .build();
	}
}

<after> mapStruct 사용

기댓값: singular처럼 매핑 -> but 컴파일 에러 남

  @Mapping(target = "iconImageUrl", source = "iconImgUrl")
  @Mapping(target = "freeWayName", source = "enText", qualifiedByName = "addEnglish")
  @Mapping(target = "freeWayName", source = "koText", qualifiedByName = "addKorean")
  OasisBannerResponse toResponse(StaticOasisBanner entity);

  @Named("addKorean")
  default CodeValueModel addKorean(String koText) {
    return toCodeValueModel(LanguageType.KO, koText);
  }

  @Named("addEnglish")
  default CodeValueModel addEnglish(String enText) {
    return toCodeValueModel(LanguageType.EN, enText);
  }
error: Target property "freeWayName" must not be mapped more than once.

error: Unmapped target property: "freeWayNames".

여러 개 있어서 싫고,,매핑을 안 해서 싫더냐?! 거참 까다로운 자식..

해결: 결국.. entity를 다 받아서 했다..ㅠ

@Mapping(target = "iconImageUrl", source = "iconImgUrl")
@Mapping(target = "freeWayNames", source = ".", qualifiedByName = "setFreeWayNames")
OasisBannerResponse toResponse(StaticOasisBanner entity);

@Named("setFreeWayNames")
default List<CodeValueModel> setFreeWayNames(StaticOasisBanner entity) {
  List<CodeValueModel> codes = new ArrayList<>();
  setLanguageText(LanguageType.KO, entity.getKoText(), codes);
  setLanguageText(LanguageType.EN, entity.getEnText(), codes);
  return codes;
}

그리고 

@Singular
private List<CodeValueModel> freeWayNames;

@Singular 선언이 있으면 위처럼 freeWayNames에 매핑을 했음에도 freeWayName도 매핑하라는 에러가 나고, 결국 @Singular를 지워야만 한다.

error: Unmapped target property: "freeWayName".

즉, 두개를 동시에 못쓴다는 이야기..


참고

1. 함수를 통해 변수를 변환하고 매핑해야 하는 경우

여러 가지 방법이 있겠지만 expression = "java()" 이 방법은 코드를 하드코딩하는 방법이라 잘못된 점을 빌드할 때까지 알 수 없음..

qualifiedByName = "" 방법을 사용하는 게 더 나은 것 같다.

enum을 더 효율적으로 매핑할 수 있는지 확인 필요

2. 설정을 함수별로 세팅할 수 있음

@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
             nullValueCheckStrategy = NullValueCheckStrategy.ALWAYS)
void update(AmcPackageRequest amcPackageRequest, @MappingTarget AmcPackage amcPackage);

3. MappingConfig 안에서 세부 설정을 할 수 있음

특히 collection mapping관련해서 설정값을 확인할 필요 있음


참고

성능: https://www.baeldung.com/java-performance-mapping-frameworks

사용방법: 

https://medium.com/naver-cloud-platform/%EA%B8%B0%EC%88%A0-%EC%BB%A8%ED%85%90%EC%B8%A0-%EB%AC%B8%EC%9E%90-%EC%95%8C%EB%A6%BC-%EB%B0%9C%EC%86%A1-%EC%84%9C%EB%B9%84%EC%8A%A4-sens%EC%9D%98-mapstruct-%EC%A0%81%EC%9A%A9%EA%B8%B0-8fd2bc2bc33b

https://jiwondev.tistory.com/250

728x90
반응형
반응형

환경: springboot 2.7.3, java11, gradle 7.5

테스트를 돌리는데

분명 이전에 성공했던 테스트인데

시간이 흐른 뒤 재실행했을 때 아래와 같은 에러가 났다.

Execution failed for task ':test'. > No tests found for given includes:

 

그 사이 코드가 바뀐 것도 없어 안될 리가 없을 터.

게다가 다른 클래스의 테스트 코드는 잘 실행되고 한 클래스의 테스트코드만 안돼서 이상했다.

 

구글링 해보면

junit vintage 버전 충돌 어쩌구라고 하는데 나에게는 해당되지 않는 듯하여 과감하게 버리고

https://www.inflearn.com/questions/15495/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EB%8F%84%EC%A4%91-%EC%97%90%EB%9F%AC-%EB%B0%9C%EC%83%9D

 

테스트 도중 에러 발생 - 인프런 | 질문 & 답변

FAILURE: Build failed with an exception. * What went wrong: Execution failed for task ':test'. > No tests found for given includes: [jpabook.jpashop....

www.inflearn.com

여기 문제와 비슷한 것 같아서 설정을 바꿔보았는데도 동일하였다.

 

한 30분 씨름했는데,, 해결은 

invalid cache 날리고 혹시 몰라서 gradle 클린하고 테스트 실행하니까 잘되었다..ㅎㅎㅎㅎ 츠암나..

 

728x90
반응형
반응형

Entity Graph

  • Entity를 조회하는 시점에 연관 Entity들을 함께 조회할 수 있도록 해주는 기능

종류

  • 정적 선언 - @NamedEntityGraph
  • 동적 선언 - EntityManager.createEntityGraph()
@NamedEntityGraphs({
        @NamedEntityGraph(name = "orderWithCustomer", attributeNodes = { //같이 가져와
                @NamedAttributeNode("customer")
        }),
        @NamedEntityGraph(name = "orderWithOrderItems", attributeNodes = {
                @NamedAttributeNode("orderItems")
        }),
        @NamedEntityGraph(name = "orderWithCustomerAndOrderItems", attributeNodes = {
                @NamedAttributeNode("customer"),
                @NamedAttributeNode("orderItems")
        }),
        @NamedEntityGraph(name = "orderWithCustomerAndOrderItemsAndItem", attributeNodes = {
                @NamedAttributeNode("customer"),
                @NamedAttributeNode(value = "orderItems", subgraph = "orderItems")
        }, subgraphs = @NamedSubgraph(name = "orderItems", attributeNodes = { //orderItem 한 단계 더 들어갈 때
                @NamedAttributeNode("item")
        }))
})
@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long orderId;

    @ManyToOne(optional = false)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    private List<OrderItem> orderItems;

}
public interface OrderRepository extends JpaRepository<Order, Long> {
    //get, read, query, find -> select문
    //All -> 의미 없음; 뭘 가져올지는 return type으로
    //by -> 조건

    @EntityGraph("orderWithCustomer")
    //select * from order left join customer -> 커스토머 가져오고 
    //select * from orderItem left join item -> 추가로 발생
    List<Order> getAllBy();

    @EntityGraph("orderWithOrderItems")
    //select * from order left join orderItem -> 한방에
    List<Order> readAllBy();

    @EntityGraph("orderWithCustomerAndOrderItems")
    //select * from order left join customer left join orderItem -> 한방에
    List<Order> queryAllBy();

   @EntityGraph("orderWithCustomerAndOrderItemsAndItem")
    //select * from order left join customer left join orderItem left join item -> 한방에
    List<Order> findAllBy();

}

Pagination 쿼리에 Fetch Join

Pagination 쿼리에 Fetch Join을 적용하면 실제로는 모든 레코드를 가져오는 쿼리가 실행된다

: 다 가져와서 필요한 부분만 발라서 줌 

: DB 서버는 전체를 부르게 되니 부하 오짐

실제로는 에러가 아닌 warning에 아래와 같은 메세지가 나고 있었다..

디비에서는 다가져와서 메모리에서 할게 ㅎㅎ

HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
public Page<Order> getPagedOrderWithAssociations(Pageable pageable) {
    QOrder order = QOrder.order;
    QCustomer customer = QCustomer.customer;
    QOrderItem orderItem = QOrderItem.orderItem;
    QItem item = QItem.item;

    // TODO : pagination + fetch join ???
    //사라진 limit
    JPQLQuery<Order> query = from(order)
            .innerJoin(order.customer, customer).fetchJoin()
            .leftJoin(order.orderItems, orderItem).fetchJoin()
            .innerJoin(orderItem.item, item).fetchJoin();

    JPQLQuery<Order> pagedQuery = getQuerydsl().applyPagination(pageable, query);

    long totalCount = 0L;

    try {
        totalCount = pagedQuery.fetchCount();
    } catch (NoResultException e) {
        // ignore
    }

    List<Order> list = pagedQuery.fetch();

    return new PageImpl<>(list, pageable, totalCount);
}

 

해결 방법? 정해진 해결방법은 없음.

  • Pagination 쿼리와 Fetch Join을 분리
  • Pagination 쿼리에서는 entity가 아닌 ID만을 가져온다(where절을 만족하는 놈으로 미리 쳐버리는게 효율이 좋겠지)
  • Fetch Join에는 앞서 가져온 ID 조건을 추가

Q. 이럴 때 in절로 인한 디비 부하가 있을 수 있을텐데.. 괜찮나? 물론 전체를 가져오는 것 보다는 나을 것이며 PK니까 관련 인덱스도 있겠지만... DBA는 in절을 매우 싫어했는데ㅠ

public Page<Order> getPagedOrderWithAssociations(Pageable pageable) {
    QOrder order = QOrder.order;
    QCustomer customer = QCustomer.customer;
    QOrderItem orderItem = QOrderItem.orderItem;
    QItem item = QItem.item;

    // TODO #1 : pagination query
    //여기서 where절 미리 쳐버렸!
    JPQLQuery<Long> query = from(order).select(order.orderId);
    JPQLQuery<Long> pagedQuery = getQuerydsl().applyPagination(pageable, query);

    long totalCount = 0L;
    try {
        totalCount = pagedQuery.fetchCount();
    } catch (NoResultException ex) {
        // ignore
    }

    List<Long> ids = pagedQuery.fetch();

    // TODO #2 : fetch join
    List<Order> list = from(order)
            .innerJoin(order.customer, customer).fetchJoin()
            .leftJoin(order.orderItems, orderItem).fetchJoin()
            .innerJoin(orderItem.item, item).fetchJoin()
            .where(order.orderId.in(ids))
            .fetch();

    return new PageImpl<>(list, pageable, totalCount);
}

 

cf.) pagination query 에서 offset, limit 에 bind 된 parameter 값은 왜 log에 안 나오죠?

limit 뒤의 값을 알고싶어요..

select 
        order0_.order_id as order_id1_3_,
        order0_.customer_id as customer3_3_,
        order0_.order_dt as order_dt2_3_ 
from orders order0_
where
        order0_.order_id=?
limit ?
o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [BIGINT] - [1]

이유?

  • org.hibernate.type.descriptor.sql.BasicBinder
    • bind 된 parameter 값을 logging
  • offset, limit 는 DBMS 별로 지원이 될 수도 있고 안 될 수도 있는 쿼리
    • cf.) org.hibernate.dialect.pagination.LimitHandler
      • MySQLDialect vs Oracle8iDialect
  • offset, limit 는 BasicBinder 에서 처리가 되지 않음
  • dialect 에서도 logging을 해주지 않고 있음
package org.hibernate.dialect.pagination;

...

public interface LimitHandler {
  boolean supportsLimit(); //각 dbms에서 구현

  boolean supportsLimitOffset();
public class MySQLDialect extends Dialect {
  private static final Pattern ESCAPE_PATTERN = Pattern.compile("\\", 16);
  public static final String ESCAPE_PATTERN_REPLACEMENT = Matcher.quoteReplacement("\\\\");
  private final UniqueDelegate uniqueDelegate;
  private final MySQLStorageEngine storageEngine;
  private static final LimitHandler LIMIT_HANDLER = new AbstractLimitHandler() {
    public String processSql(String sql, RowSelection selection) {
      boolean hasOffset = LimitHelper.hasFirstRow(selection);
      return sql + (hasOffset ? " limit ?, ?" : " limit ?");
    }

    public boolean supportsLimit() {
      return true;
    }
  };

ㅋ 구현체에서 안 찍어줌 

해결방법

  • 굳이 offset, limit 값을 로깅하길 원한다면
    • log4jdbc와 같은 JDBC 레벨에서의 로깅이 가능한 라이브러리를 써야

둘 이상의 컬렉션을 Fetch Join하는 경우

  • Order Entity에 OrderAttribute Entity로의 일대다(1:N) 연관관계를 추가하는 경우
    • Order-OrderDetails (1:N)
    • Order-OrderAttributes (1:N)

둘 이상의 컬렉션을 Fetch Join하는 경우 MultipleBagFetchException 발생

@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private List<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id")
private List<OrderAttribute> attributes;

//
  public List<Order> getOrdersWithAssociations() {
        QOrder order = QOrder.order;
        QCustomer customer = QCustomer.customer;
        QOrderItem orderItem = QOrderItem.orderItem;
        QItem item = QItem.item;
        QOrderAttribute attribute = QOrderAttribute.orderAttribute;

        return from(order)
                .innerJoin(order.customer, customer).fetchJoin()
                .leftJoin(order.orderItems, orderItem).fetchJoin()
                .leftJoin(order.attributes, attribute).fetchJoin()
                .innerJoin(orderItem.item, item).fetchJoin()
                .fetch();
    }
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: 
[com.nhn.edu.jpa.entity.Order.orderItems, com.jpa.entity.Order.attributes]

트러블 슈팅 - 둘 이상의 컬렉션을 Fetch Join하는 경우

MultipleBagFetchException

  • Hibernate는 collection type으로 list, set, bag, map 등 다양한 타입을 지원
  • Java의 java.util.List 타입은 기본적으로 Hibernate의 Bag 타입으로 맵핑됨
  • Bag은 Hibernate에서 중복 요소를 허용하는 비순차(unordered) 컬렉션
  • 둘 이상의 컬렉션(Bag)을 Fetch Join하는 경우, 그 결과로 만들어지는 카테시안 곱(Cartesian Product)에서
    어느 행이 유효한 중복을 포함하고 있고 어느 행이 그렇지 않은 지 판단할 수 없어 Bag 컬렉션으로 변환될 수 없기 때문에 MultipleBagFetchException 예외 발생
    • 조인 할 때 row에 옆으로 쫙 늘어나면서 데이터가 n*n으로 나올거잖슴,, 반복되는 내용 때문인지 중복이 왜 나는지 구분이 안되니
    • 애초에 결과 값에 중복 허용 안하면 카테시안 곱에 의한 중복이 아니라는게 확실해짐

해결 방법

1. List를 Set으로 변경 : 중복 비허용

  • 도메인이나 비즈니스 로직에 따라 중복이 나오는게 맞는 것인지 고려해보고 적용
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
private Set<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id")
private Set<OrderAttribute> attributes;

//

    @Override
    public List<Order> getOrdersWithAssociations() {
        QOrder order = QOrder.order;
        QCustomer customer = QCustomer.customer;
        QOrderItem orderItem = QOrderItem.orderItem;
        QItem item = QItem.item;
        QOrderAttribute attribute = QOrderAttribute.orderAttribute;

        return from(order)
                .innerJoin(order.customer, customer).fetchJoin()
                .leftJoin(order.orderItems, orderItem).fetchJoin()
                .leftJoin(order.attributes, attribute).fetchJoin()
                .innerJoin(orderItem.item, item).fetchJoin()
                .fetch();
    }

2. @OrderColumn 적용 : 순서를 부여(ordered)

  • 디비 스키마를 변경할 수 있는지 고려해야함
WARN QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
@OneToMany(cascade = CascadeType.ALL)
@JoinColumn(name = "order_id")
@OrderColumn //순서를 부여함; 중복 허용
private List<OrderItem> orderItems;

@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id")
@OrderColumn
private List<OrderAttribute> attributes;
Hibernate: 
    
    create table order_attributes (
       order_attribute_id bigint generated by default as identity,
        attribute varchar(255),
        order_id bigint,
        attributes_order integer, ///순서를 저장하기 위한 콜롬이 추가로 생성된
        primary key (order_attribute_id)
    )
Hibernate: 
    
    create table order_items (
       order_line_id bigint generated by default as identity,
        quantity bigint,
        item_id bigint,
        order_id bigint,
        order_items_order integer, ///순서를 저장하기 위한 콜롬이 추가로 생성된
        primary key (order_line_id)
    )

1, 2번으로 해결이 안되면 Jpa를 쓰지말자! ㅋㅋ


Repository: spring이 제공하는 데이터에 접근하는 layer

  • Spring Data Repository
  • Repository는 JPA의 개념이 아니고, Spring Framework가 제공해주는 것임.
    • data access layer 구현을 위해 반복해서 작성했던, 유사한 코드를 줄일 수 있는 추상화 제공

 

이름 규칙으로 join 쿼리 가져오기(N+1 날 수 있음)

public interface MemberRepository extends JpaRepository<Member, Long> {
    // select m from Member m inner join MemberDetail md where md.type = ?
    // 연관관계가 있을때만 가능
    // _ 로 안으로 들어갈 수 있음
    List<Member> findByDetails_Pk_Type(String type);
}

 

DTO Projection

DTO Projection 이란: entity 바를 때

  • Repository 메서드가 Entity를 반환하는 것이 아니라 원하는 필드만 뽑아서 DTO(Data Transfer Object)로 반환하는 것
  • 메모리나 디비 성능에 도움

Dto Projection 방법

  • Interface 기반 Projection: 아래에 계속!
  • Class 기반 (DTO) Projection: 생성자
  • Dynamic Projection: runtime에 결정

 

트러블 슈팅 - Spring Data Repository 로는 Dto Projection을 할 수 없다?

Spring Data Repository를 이용한 Dto Projection

  • Repository 메서드의 반환 값으로 Entity가 아닌 Dto를 사용할 수 있다
  • interface / class
  • @Value + SpEL (target)
public interface OrderRepository extends OrderRepositoryCustom, JpaRepository<Order, Long> {
    List<OrderDto> findAllBy();
}

public interface OrderDto {
    Long getOrderId();
    CustomerDto getCustomer();
    List<OrderItemDto> getOrderItems();

    interface CustomerDto {
        String getCustomerName();
    }

    interface OrderItemDto {
        ItemDto getItem();
        Long getQuantity();
    }

    interface ItemDto {
        String getItemName();
        Long getItemPrice();
    }
}
Hibernate: 
    select
        order0_.order_id as order_id1_3_,
        order0_.customer_id as customer3_3_,
        order0_.order_dt as order_dt2_3_ 
    from
        orders order0_
Hibernate: 
    select
        customer0_.customer_id as customer1_0_0_,
        customer0_.customer_name as customer2_0_0_ 
    from
        customers customer0_ 
    where
        customer0_.customer_id=?

Hibernate: 
    select
        customer0_.customer_id as customer1_0_0_,
        customer0_.customer_name as customer2_0_0_ 
    from
        customers customer0_ 
    where
        customer0_.customer_id=?

Hibernate: 
    select
        orderitems0_.order_id as order_id4_2_0_,
        orderitems0_.order_line_id as order_li1_2_0_,
        orderitems0_.order_line_id as order_li1_2_1_,
        orderitems0_.item_id as item_id3_2_1_,
        orderitems0_.quantity as quantity2_2_1_,
        item1_.item_id as item_id1_1_2_,
        item1_.item_name as item_nam2_1_2_,
        item1_.item_price as item_pri3_1_2_ 
    from
        order_items orderitems0_ 
    left outer join
        items item1_ 
            on orderitems0_.item_id=item1_.item_id 
    where
        orderitems0_.order_id=?

Hibernate: 
    select
        orderitems0_.order_id as order_id4_2_0_,
        orderitems0_.order_line_id as order_li1_2_0_,
        orderitems0_.order_line_id as order_li1_2_1_,
        orderitems0_.item_id as item_id3_2_1_,
        orderitems0_.quantity as quantity2_2_1_,
        item1_.item_id as item_id1_1_2_,
        item1_.item_name as item_nam2_1_2_,
        item1_.item_price as item_pri3_1_2_ 
    from
        order_items orderitems0_ 
    left outer join
        items item1_ 
            on orderitems0_.item_id=item1_.item_id 
    where
        orderitems0_.order_id=?

Hibernate: 
    select
        orderitems0_.order_id as order_id4_2_0_,
        orderitems0_.order_line_id as order_li1_2_0_,
        orderitems0_.order_line_id as order_li1_2_1_,
        orderitems0_.item_id as item_id3_2_1_,
        orderitems0_.quantity as quantity2_2_1_,
        item1_.item_id as item_id1_1_2_,
        item1_.item_name as item_nam2_1_2_,
        item1_.item_price as item_pri3_1_2_ 
    from
        order_items orderitems0_ 
    left outer join
        items item1_ 
            on orderitems0_.item_id=item1_.item_id 
    where
        orderitems0_.order_id=?

하지만 N+1은 난다.

예제

  • interface / class
  • @Value + SpEL (target)
/*
{
    "name": "",
    "details": [{
        "type": "",
        "description": ""
    }]
}
 */
public interface MemberDto {
  String getName();
  List<MemberDetailDto> getDetails();

   interface MemberDetailDto{
    //target = memberDetailEntity
     @Value("#{target.pk.type}") ///!
    String getType();
    String getDescription();
  }
//아래처럼 하면 pk 안의 type으로 나옴
//  interface MemberDetailDto{
//    PkDto getPk();
//    String getDescription();
//
//    interface PkDto{
//      String getType();
//    }
//  }
}
728x90
반응형
반응형

JPA (Java Persistence API)

  • 자바 ORM 기술 표준
  • 표준 명세
    • JSR 338 - Java Persistence 2.2

JPA (Jakarta Persistence API)

  • Jakarta Persistence 3.1

JPA 주요 Spec 및 Hibernate version

Java Persistence 2.2 (Hibernate 5.3+)

  • Stream query results
  • @Repeatable annotations
  • Support Java 8 Date and Time API
  • Support CDI Injection in AttributeConverters

Jakarta Persistence 3.1 (Hibernate 6.1+)

  • UUID as Basic Java Type
  • JPQL / Criteria API의 확장 - 날짜, 숫자 함수 추가 등
  • ...

Hibernate 최신 버전

  • Hibernate 5.6
  • Hibernate 6.1

springboot 2.7.9 -> hibernate 5.6

JPA는 스펙; hibernate는 구현


SQL 설정

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=debug

binding parameters :: request ?에 대한 바인딩 로그

logging.level.org.hibernate.type.descriptor.sql.BasicBinder=trace
  • cf.) org.hibernate.type.descriptor.sql.BasicExtractor :: result set 보여줌

기본 키 맵핑 전략

@GeneratedValue(strategy = GenerationType.IDENTITY)

자동 생성

  • TABLE 전략: 채번 테이블을 사용
  • SEQUENCE 전략: 데이터베이스 시퀀스를 사용해서 기본 키를 할당
    • ex.) Oracle
  • IDENTITY 전략: 기본 키 생성을 데이터베이스에 위임
    • ex.) MySQL
  • AUTO 전략: 선택한 데이터베이스 방언(dialect)에 따라 기본 키 맵핑 전략을 자동으로 선택

직접 할당

  • 애플리케이션에서 직접 식별자 값을 할당

복합 Key (Composite key)

  • @IdClass
  • @EmbeddedId / @Embeddable

복합 Key Class 제약조건

  • public class
  • public 기본(no-arg) 생성자
  • Serializable 인터페이스 구현
  • equals(), hashCode() 메서드 정의

영속성 전이 (cascade)

바뀔 때 같이 바뀔래? 삭제/수정같이?

  • Entity의 영속성 상태 변화를 연관된 Entity에도 함께 적용
  • 연관관계의 다중성 (Multiplicity) 지정 시 cascade 속성으로 설정
@OneToOne(cascade = CascadeType.PERSIST)
@OneToMany(cascade = CascadeType.ALL)
@ManyToOne(cascade = { CascadeType.PERSIST, CascadeType.REMOVE })

cascade 종류

안 쓰면 각각 저장; 연관관계도 같이 저장

-> 멤버 저장하면 멤버 디테일도 저장

근데 두 엔티티의 lifecycle이 항상 같을 수 없어,, 진짜 필요할 때 고려해서 넣는 게 좋음

public enum CascadeType {
    ALL,        /* PERSIST, MERGE, REMOVE, REFRESH, DETACH */
    PERSIST,    // cf.) EntityManager.persist()
    MERGE,      // cf.) EntityManager.merge()
    REMOVE,     // cf.) EntityManager.remove()
    REFRESH,    // cf.) EntityManager.refresh()
    DETACH      // cf.) EntityManager.detach()
}

연관관계의 방향성

  • 단방향(unidirectional)
  • 양방향(bidirectional)

양방향 연관 관계

  • 관계의 주인(owner)
    • 양방향의 연관 관계의 주인은 외래 키(FK)가 있는 곳
      • 주인만 @JoinColumn 어노테이션 사용 가능
    • 연관 관계의 주인이 아닌 경우, mappedBy 속성으로 연관 관계의 주인을 지정

단방향 vs 양방향

단방향 맵핑만으로 연관관계 맵핑은 이미 완료

  • JPA 연관관계도 내부적으로 FK 참조를 기반으로 구현하므로 본질적으로 참조의 방향은 단방향

단방향에 비해 양방향은 복잡하고 양방향 연관관계를 맵핑하려면 객체에서 양쪽 방향을 모두 관리해야 함

  • 물리적으로 존재하지 않는 연관관계를 처리하기 위해 mappedBy 속성을 통해 관계의 주인을 정해야 함

단방향을 양방향으로 만들면 반대 방향으로의 객체 그래프 탐색 가능

  • 우선적으로는 단방향 맵핑을 사용하고 반대 방향으로의 객체 그래프 탐색 기능이 필요할 때 양방향을 사용

 

  • 일반적으로 단방향으로도 충분하지만 그게 아닌 경우가 있다.
    • 일대다 연관관계 시 '다'에 해당하는 양만큼 update문이 나갈 수 있음 -> 양방향 연관관계 필요
    • 복합키까지 쓰는 경우라면, 그리고 그 값이 FK에도 쓰는 경우라면 @MapsId로 지정해야 한다.
  • 예시: 단방향으로 설정 시 원치 않은 update문이 나갈 수 있음
class Member{
...
    @OneToMany(cascade = CascadeType.ALL) //member 바뀌면 아래도 알아서 바뀌라
    @JoinColumn(name = "member_id")
    private List<MemberDetail> details = new ArrayList<>();

}


//CASCADE 넣으면 이거 하나로 끝
  memberRepository.save(member);
Hibernate: insert into members (create_dt, name, member_id) values (?, ?, ?)
Hibernate: insert into member_details (description, type, member_detail_id) values (?, ?, ?)
Hibernate: insert into member_details (description, type, member_detail_id) values (?, ?, ?)
Hibernate: update member_details set member_id=? where member_detail_id=?
Hibernate: update member_details set member_id=? where member_detail_id=?

insert 할 때 한 번에 하면 되지 않나 왜 update를?

 

해결: 양방향 일대다(1:N)로 변경

class Member{
...
    @OneToMany(cascade = CascadeType.ALL, mappedBy = "member")
    private List<MemberDetail> details = new ArrayList<>();
}
//

class MemberDetail{
...
    @ManyToOne
    @JoinColumn(name = "member_id")//column이름; 양방향일 때 관계 주인은 fk를 가지고 있는 여기!
    private Member member;
}

///
//양방향이기 때문에 양쪽으로 다 세팅해야 함
memberDetail1.setMember(member);

member.getDetails().add(memberDetail1);
 Repeated column in mapping for entity: com.jpa.entity.MemberDetail column: member_id (should be mapped with insert="false" update="false")

근데 에러가 남 두둥

@Entity
@Table(name = "MemberDetails")
public class MemberDetail {
    @EmbeddedId
    private Pk pk;

    private String description;

    @ManyToOne
    @JoinColumn(name = "member_id")///
    private Member member;

    @Getter
    @Setter
    @NoArgsConstructor
    @EqualsAndHashCode
    @Embeddable
    public static class Pk implements Serializable {
        @Column(name = "member_id") ////
        private Long memberId;

        private String type;
    }
}

@JoinColumn, @pk안의 @Column 모두 업데이트 가능하게 만들어서 에러

하나는 조회용이라고 밝혀줘야

@ManyToOne
@JoinColumn(name = "member_id", insertable = false, updatable = false)
private Member member;

그러면 이렇게 하면 될까? nope!
왜냐면 cascade 때문에. 그걸로 업데이트/인서트 하겠다고 한 건데 안 하겠다고 하면(updatable false) 안되지

그렇다면?

@ManyToOne
@MapsId("memberId")
private Member member;

pk에도 쓰이는 칼럼이 @JoinColumn에서도 써야 한다면, 같은 거를 쓴다고 알려줘야 함

MapsId 만 써도 되는데 PK가 복합키라서 그중에 뭐? 를 알려줘야 할 때

PK에서 사용되는 콜롬이 FK에도 쓰인다! @MapsId(변수명)

 

그러면 insert 세 개만 나간다!

정리: 양방향을 맺자

@Entity
@Table(name = "Orders")
public class Order {
...

    @OneToMany(mappedBy = "order", cascade = CascadeType.Merge, CasecadeType.PERSIST)//order 저장 시 detail도 저장하게 하려면 여기다가 cascade option 필요
    private List<OrderDetail> details = new ArrayList<>();
}

////

@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
...
    @EmbeddedId
    private Pk pk = new Pk();

    @ManyToOne
//    @JoinColumn(name = "order_id") //원래대로라면 이렇게 하지만 PK에도 사용되니..
    @MapsId("orderId")
    private Order order;
    
    @Getter
    @Setter
    @NoArgsConstructor
    @EqualsAndHashCode
    @Embeddable
    public static class Pk implements Serializable {
        @Column(name = "order_id")
        private Long orderId;
        private String type;
    }
}

////

 @Transactional
public void doSomething() {
    Order order = new Order();
    order.setOrderDate(LocalDateTime.now());

    OrderDetail orderDetail1 = new OrderDetail("type1");
    orderDetail1.setDescription("order1-type1");
    orderDetail1.setOrder(order);

    OrderDetail orderDetail2 = new OrderDetail("type2");
    orderDetail2.setDescription("order1-type2");
    orderDetail2.setOrder(order);

    order.getDetails().add(orderDetail1);
    order.getDetails().add(orderDetail2);

    orderRepository.save(order); ///여기서 한번만 해도 detail이 들어가려면 Cascade가 필요한 것이다!
}

N + 1 문제

JPA에서 N+1 문제는 자식 엔티티가 얼마나 있는지에 관계없이 부모 엔티티 개수에 따라 추가적인 쿼리가 발생하기 때문에, 자식 엔티티의 개수와는 상관이 없습니다. 이 문제는 정확히 부모 엔티티의 개수와 관련이 있습니다. 좀 더 명확히 설명하자면 다음과 같습니다:

1. N+1 문제의 본질

N+1 문제란, 부모 엔티티를 조회하는 1번의 쿼리와, 각 부모 엔티티에 대해 자식 엔티티를 조회하기 위한 N번의 추가 쿼리가 발생하는 문제를 의미합니다. 여기서 N은 부모 엔티티의 개수를 의미합니다.

  • 1번의 쿼리: 부모 엔티티를 조회하는 쿼리입니다.
  • N번의 쿼리: 각 부모 엔티티마다 자식 엔티티를 조회하는 쿼리가 발생하는 것입니다.

이 문제는 자식 엔티티의 수가 아니라, 부모 엔티티의 수만큼 추가적인 쿼리가 발생하는 것이 문제의 핵심입니다.

2. 부모 1건, 자식 여러 건의 경우

부모 엔티티가 1건이라면 자식 엔티티가 아무리 많더라도, 부모 엔티티를 조회한 후 자식 엔티티를 조회하기 위한 쿼리가 단 1번 발생합니다.

@Entity
public class Parent {
    @Id
    private Long id;

    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
    private List<Child> children;
}

@Entity
public class Child {
    @Id
    private Long id;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
}

  • 쿼리 한 번으로 N 건의 레코드를 가져왔을 때, 연관관계 Entity를 가져오기 위해 쿼리를 N번 추가 수행하는 문제
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long orderId;

    @ManyToOne(optional = false) //eager -> join
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;

    @OneToMany(cascade = CascadeType.ALL) //lazy 아직..
    @JoinColumn(name = "order_id")
    private List<OrderItem> orderItems;
}

//
@Entity
@Table(name = "OrderItems")
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_line_id")
    private Long orderLineId;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    private Long quantity;
}
public void getOne() {
    //단건은 연관관계 매핑을 고려해서 join사용
    orderRepository.findById(1L);
}

public void getMulti() {
    //findall은 명확하게 모르는 쿼리를 날리면 우선 실행하고 연관관계매핑을 적용
    orderRepository.findAll();
}

단 건을 실행하면 join으로 한 번에 가져오는데

findAll실행하면 우선 order 실행하고 eager인 customer 실행

Hibernate: 
    select
        order0_.order_id as order_id1_3_,
        order0_.customer_id as customer3_3_,
        order0_.order_dt as order_dt2_3_ 
    from
        orders order0_
//
    select
        customer0_.customer_id as customer1_0_0_,
        customer0_.customer_name as customer2_0_0_ 
    from
        customers customer0_ 
    where
        customer0_.customer_id=?
//
    select
        customer0_.customer_id as customer1_0_0_,
        customer0_.customer_name as customer2_0_0_ 
    from
        customers customer0_ 
    where
        customer0_.customer_id=?

 

feachType의 문제가 아니야!

해결 방법

  • Fetch Join
    • join을 사용하여 쿼리 하나로 실행하겠다.
    • JPQL join fetch
      • fetch 없으면, from절에 있는 메인 엔티티만 반환. 우선 진행시켜! 그 후에 from절 전에 있는 메인 엔티티를 하나씩 따져보면서 다시 N+1 실행
      • fetch를 써야 select절에 다른 것들도 받음
    • Querydsl fetchJoin()
  • 그룹화하여 쿼리 실행 횟수를 줄이겠다.
  • Entity Graph //선언적으로 어디까지 탐색할 수 있는지 지정가능
  • 그 외
    • Hibernate @BatchSize //나눠서
    • Hibernate @Fetch(FetchMode.SUBSELECT) //in절에 넣어서 실행
  • 주의. 그지 같은 join문으로 인해.. 성능이 더 나빠질 수 있음.
@Query("select o from Order o "
       + " inner join fetch o.customer as c "
       + " left join fetch o.orderItems as oi "
       + " inner join fetch oi.item as i")
List<Order> getOrdersWithAssociations();

Querydsl

복잡한 쿼리 작성 시 컴파일러의 도움을 받을 수 있음

  • JPA에서 제공하는 객체 지향 쿼리
    • JPQL: Entity 객체를 조회하는 객체 지향 쿼리 // text 기반이라 compiler의 도움을 못 받음
    • Criteria API: JPQL을 생성하는 빌더 클래스 //복잡함
  • third party library를 이용하는 방법
    • Querydsl
    • jOOQ //native query 기반

JPQL vs Criteria API

  • JPQL
    • SQL을 추상화해서 특정 DBMS에 의존적이지 않은 객체지향 쿼리
    • 문제 : 결국은 SQL이라는 점
      • 문자 기반 쿼리이다 보니 컴파일 타임에 오류를 발견할 수 없다
  • Criteria API
    • 프로그래밍 코드로 JPQL을 작성할 수 있고 동적 쿼리 작성이 쉽다
    • 컴파일 타임에 오류를 발견할 수 있고 IDE의 도움을 받을 수 있다
    • 문제 : 너무 복잡

Querydsl

  • Criteria API처럼 정적 타입을 이용해서 JPQL을 코드로 작성할 수 있도록 해 주는 오픈소스 프레임워크
  • Criteria API에 비해 복잡하지 않고 매우 편리하고 실용적

Spring Data JPA + Querydsl

  • QuerydslPredicateExecutor
  • QuerydslRepositorySupport //join이 많을 경우; 추상 interface -> impl

Custom Repository 구현

Custom Repository를 위한 interface 생성

@NoRepositoryBean
public interface MemberRepositoryCustom {
    List<Member> getMembersWithAssociation();
}

Custom Repository 기능 구현

  • QuerydslRepositorySupport 클래스 상속
    • 기본 생성자에서 Entity를 인자로 전달받는 상위 클래스의 생성자를 호출
  • Custom Repository interfade 구현
  • 구현 메서드에서 Querydsl의 Q-type class를 이용해서 쿼리 수행
public class MemberRepositoryImpl extends QuerydslRepositorySupport implements MemberRepositoryCustom {
    public MemberRepositoryImpl() {
        super(Member.class);
    }

    @Override
    public List<Member> getMembersWithAssociation() {  
        // ...
    }
}
<plugin>
 <groupId>com.mysema.maven</groupId>
 <artifactId>apt-maven-plugin</artifactId>
 <version>1.1.3</version>
 <configuration>
  <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
 </configuration>
 <executions>
  <execution>
   <phase>generate-sources</phase>
   <goals>
    <goal>process</goal>
   </goals>
   <configuration>
    <outputDirectory>target/generated-sources/annotations/java</outputDirectory> //해당 경로에 만든다
   </configuration>
  </execution>
 </executions>
</plugin>

project 설정 필요

기본 Repository interface 변경

  • 기본 Repository interface가 Custom Repository interface를 상속받도록
public interface MemberRepository extends MemberRepositoryCustom, JpaRepository<Member, Long> {
}

기본 Repository interface를 이용해서 Custom Repository interface에 선언된 확장 메서드 호출


트러블 슈팅 - Custom Repository 구현 시 흔히 하는 실수: naming rule

  • 기본 Repository interface
    • MemberRepository
  • Custom Repository interface
    • MemberRepositoryCustom
  • Custom Repository 구현 class
    • MemberRepositoryCustomImpl (X)
    • MemberRepositoryImpl (O)

참고) query dsl 사용 예시

  @Override
  public List<Student> getStudentsWithAssociations() {
    QStudent student = QStudent.student;//1개만 쓰면
    QStudent student1 = new QStudent("student1");//inner query 등으로 추가적으로 더 필요하면 인스턴스 더 만들어
    QEnrollment enrollment = QEnrollment.enrollment;
    QCourse course = QCourse.course;

    return from(student)
        .leftJoin(student.enrollments, enrollment).fetchJoin() //연관관계, q타입 걸치고
        .innerJoin(enrollment.course, course).fetchJoin()
//        .where(student.name.eq("gg"))
//        .select(student.studentId)
//        .fetchOne()
        .fetch() //list 반환 시 fetch; 하나만 가져올거면 fetchOne
        ;
  }

Repository 설정

@EnableJpaRepositories

public @interface EnableJpaRepositories {
	String[] basePackages() default {};

	Class<?>[] basePackageClasses() default {};

	Filter[] includeFilters() default {};

	Filter[] excludeFilters() default {};

	String repositoryImplementationPostfix() default "Impl";

    // ...
}

Spring Boot에서는

dependency에 spring-data-jpa를 추가하면 @EnableJpaRepositories 없어도 기본으로 세팅해 줌

  • Spring Data JPA Repository를 위한 auto configuration
    • org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
      • @Import(JpaRepositoriesImportSelector)
        • JpaRepositoriesRegistrar
          • @EnableJpaRepositories

Spring Data Repository 메서드 탐색 순서

  • Spring Data Repository 추상화 덕분에 interface 선언만으로도 쿼리 생성 가능
  • interface에 선언된 메서드의 실제 구현체는 아래 순서로 탐색하게 된다
  1. 기본 구현체(JpaRepository의 구현체인 SimpleJpaRepository::saveAll etc.)
  2. 메서드 이름 규칙 적용 (cf. 메서드 이름으로 쿼리 생성 findByNameLikeAndPhone etc.)
  3. Custom Repository Implementation

  1. MemberRepositoryImpl -> MemberRepositoryCustomImpl 로 바꾸면? O
  2. OrderRepositoryImpl -> OrderRepositoryCustomImpl 로 바꾸면? O

Repository Fragment 를 이용한 Custom Repository 구현

: 최근에 바뀜; 하나의 커스텀 레파지토리에 다 넣을 필요 없이 여러개로 구현체를 나눠서 구현 가능(repository fragment)

: 나눠서 구현하고 주 repository에 상속하듯 사용해도 된다

Repository Fragment 를 이용한 Custom Repository

// no `@NoRepositoryBean`
public interface CustomizedMemberRepository {
    List<Member> getMembersWithAssociation();
}
// NOT MemberRepository + `Impl`
// BUT **CustomizedMemberRepository** + `Impl`
public class CustomizedMemberRepositoryImpl implements CustomizedMemberRepository {
    // ...
}

여러 개의 Custom Repository 구현 가능

  • 앞서 본 CustomizedMemberRepository interface와 CustomizedMemberRepositoryImpl class 와 같이
  • 예를 들면 GuestRepository interface, GuestRepositoryImpl class 같이 여러 개의 Custom Repository 구현 가능

Custom Repository 들로 구성된 Repository

public interface MemberRepository
    extends CustomizedMemberRepository, GuestRepository {
}

참고) @Repository  어노테이션이란(not jpa 과거에..)

  • 방식: streotype bean -> 해당 이름으로 된 빈들을 찾아서 자동으로 등록

그러나 jpa는.. 그 방식이 아니고

  • interface extends JpaRepository -> 다 뒤져서 jpa 후보군으로 등록
  • @NoRepositoryBean -> 그 후보군에서 빼줘

Qtype이 안 생겨서 -> no complie... -> 못 찾으면.. 세상 망함.. 온 세상이 빨개요..


cf.) 트러블 슈팅 - Querydsl Q-type class variable로 "member"나 "order"를 쓸 수 없다?!

  • Querydsl에서 Q-type class 인스턴스 생성 시 variable을 "member"로 주면 에러 발생

예제

QMember member = new QMember("member");
QOrder order = new QOrder("order");
unexpected token: member
unexpected token: order

이유

  • JPQL에 MEMBER OF 연산자가 있기 때문에 MEMBER가 예약어라 variable에 쓸 수 없음
@Query("SELECT m FROM Member m WHERE :detail MEMBER OF m.details")
Member getMemberContainingDetail(MemberDetail detail);
  • 마찬가지로 JPQL에 ORDER BY 연산자가 있기 때문에 ORDER가 예약어라 variable에 쓸 수 없음

해결방법

  • 아래 코드에서 각각의 Q-type class의 static 변수는 variable 값이 뭐라고 되어 있을까?
QMember member = QMember.member;
QOrder order = QOrder.order;

member, order :: 예약어; 다른걸로 쓰면 된다.

소스를 까보면 지들도 member를 회피하기 위해 member1로 쓰고있음

@Generated("com.querydsl.codegen.DefaultEntitySerializer")
public class QMember extends EntityPathBase<Member> {

    private static final long serialVersionUID = 1029385075L;

    public static final QMember member = new QMember("member1"); /// member를 회피하기 위한
728x90
반응형
반응형

22년 초에 restTemplate을 사용하는 프로젝트를 작업하다가 위 문구를 보게 되었다.

음..? 잘 쓰고 있던 rest template이 deprecated 된다고? 

 

그래서 그 이후에 신규로 진행하는 프로젝트는 webClient를 사용하였다.

왜 webClient를 사용하였냐고 물으신다면, 위에서처럼 굳이 spring java doc에 대체하여 쓰라고 할 정도니, 스프링 진영에서 정식으로 밀고 있는 것이라 생각했기 때문이다(곧 대세가 될 것이라 생각했다).

참고로 webClient는 springframework 5에 추가된 것으로 기본적으로 reactive, non-blocking 요청을 지원한다(그래서 응답이 Mono, Flux 등으로 온다).

무튼 그렇게 webClient를 신규 프로젝트들에서 사용하게 되는데, 설정과 사용 시 상당한 라인의 코드가 필요한 것을 깨닫게 되었다.

공통 설정을 빈에 등록하는 코드, 그걸 가져와서 서비스마다 주입을 하고, 주입된 webClient로 get/post 등의 요청을 하는데도 상당한 코드가 필요하다.

get 사용 예시

물론 공통화하여 사용하고 있기는 하지만 외부 api가 새로 추가할 때마다 비슷한 양을 추가해야 한다.

사실 처음에는 webClient를 사용함으로써 webFlux에 친숙해지고, 궁극적으로는 non-blocking 요청에 대한 친근감(..)이 생기지 않을까 하는 마음이 컸다. 하지만 업무에서는 실질적으로 동기 요청이 훨씬 많았고, 이를 위해 억지로 mono.block()을 하고 있어 코드 양만 늘어난 샘이 되었다.. 결국 제대로 활용하지 못하고 있다는 생각이 들었다.


그렇게 시간이 지나고 22년 11월 springframework6이 정식(GA) 출시하면서 진짜 restTemplate에 @Deprecated가 달렸는지 궁금해졌다.

그런데 새로운 프래임워크를 열어보기도 전, 현재 사용하는 프로젝트(springboot2.7.3; springframework 5.3)에서 먼저 확인하니 안내 문구가 바뀌어져 있었다?

(확인해 보니 springframework3, 4에는 안내하는 javadoc 조차 없음)

NOTE: As of 5.0 this class is in maintenance mode, with only minor requests for changes and bugs to be accepted going forward. Please, consider using the org.springframework.web.reactive.client.WebClient which has a more modern API and supports sync, async, and streaming scenarios.

webClient를 추천하는 문구는 그대로인데.. deprecated 된다는 말은 쏙 빠지고, 유지보수모드(간단한 버그수정만 지원)로 전환한다는 말로 바뀌어져 있었다. 요 녀석들.. 고도의 밑장 빼기인가..


위에서 webClient를 사용하면서도 굳이 이걸 써야하는가? 다음 프로젝트에서도 또 webClient를 쓸 것인가? 대해 의문을 가지고 있었는데.. 마침 springframework6 문서에서 Http interface에 대한 글을 보게 된다.

https://docs.spring.io/spring-framework/docs/6.0.0/reference/html/integration.html#rest-http-interface

 

Integration

The Spring Framework provides abstractions for the asynchronous execution and scheduling of tasks with the TaskExecutor and TaskScheduler interfaces, respectively. Spring also features implementations of those interfaces that support thread pools or delega

docs.spring.io

얼핏 보니 생김새는 feign과 비슷하고 내부는 webClient로 되어 있는 듯하다.

쓱싹 만들어본다.

아래와 같이 세팅하고 받아준다.

http interface 는 webClient 기반이라 webflux dependency가 필요하다.

implementation 'org.springframework.boot:spring-boot-starter-webflux'

 

참고로 springboot3은 gradle7.6, java17 기반이니 11을 기본으로 사용하였다면 꼭 설정을 바꿔야지 아니면 아래와 같은 에러를 만난다.

 

1. api의 정보가 담긴 interface를 아래와 같이 만든다.

@HttpExchange(url = "/api/arena-ring")
public interface GiaArenaRingService {

  @GetExchange("/{id}")
  Map<String, Object> getArenaRingGame(@PathVariable BigInteger id);
}

2. 이 interface의 구현체는 스프링으로 부터 자동으로 생성되는데, 아래와 같이 빈을 등록해야 한다.

@Configuration
public class GiaHttpConfig {

  @Bean
  GiaArenaRingService getGiaArenaRingService(@Value("${gia-aapoker-dev}") String url) {
    WebClient client = WebClient.builder().baseUrl(url).build();
    HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder(WebClientAdapter.forClient(client)).build();
    GiaArenaRingService service = factory.createClient(GiaArenaRingService.class);
    return service;
  }
}

3. 사용하고자 하는 곳에서 이 빈을 주입한 후 해당 함수를 호출하면 된다.

@Service
@RequiredArgsConstructor
public class ExternalService {

  private final GiaArenaRingService giaArenaRingService;
  
    public Map<String, Object> getArenaRingGameHttpInterface(BigInteger id) {
        return giaArenaRingService.getArenaRingGame(id);
      }
  }

 

끝.

예시는 바로 객체를 꺼내 오도록 했으나 기존의 webClient처럼 Mono나 Flux로 반환하게끔 할 수도 있다(support both blocking and reactive return values).

 

사용법이 간단하고 api 스펙을 interface로 선언하기만 하면 되어 한눈에 볼 수 있다는 장점이 있는 것 같다.

webClient 기반이라 기존 webClient에서 지원하던 기능들은 설정방식만 조금 다를 뿐 다 지원할 듯하다.

가독성이 떨어지고 코드의 양이 많았던 webClient의 단점을 어느 정도 보완해 줄 수 있을 것 같아 기회가 되면 사용해 볼 생각.

추가 가이드: https://www.baeldung.com/spring-6-http-interface

 

++ 더불어

restTemplate, webClient 이 아직도 건재하다는 소식에 힘입어 세 방법 모두 사용해 본다.

스프링6 공식문서에 소개된 http clients

(restTemplate와 webClient를 공통 빈으로 등록하면 효율성과 가독성의 측면이 더 좋겠으나 샘플 프로젝트이므로 생략)

  //using WebClient
  public Mono<Map<String, Object>> getArenaRingGameWebClient(BigInteger id) {
    return WebClient.create(GIA_URL).get().uri(uriBuilder -> uriBuilder.path(STATIC_ARENA_RING_GAME_URI + "/" + id).build()).retrieve()
//        .onStatus(HttpStatus::isError, resp -> Mono.error(new RuntimeException("status is not 200")))
        .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
        }).doOnNext(response -> Optional.ofNullable(response).orElseThrow(() -> new RuntimeException("body is null")));
  }

  //using RestTemplate
  public Map<String, Object> getArenaRingGameRestTemplate(BigInteger id) {
    String url = GIA_URL + STATIC_ARENA_RING_GAME_URI + "/" + id;
    RestTemplate restTemplate = new RestTemplate();
    return restTemplate.getForObject(url, Map.class);
  }

  //using httpClient
  public Map<String, Object> getArenaRingGameHttpInterface(BigInteger id) {
    return giaArenaRingService.getArenaRingGame(id);
  }
restTemplate: 2
webClient: 2
httpClient: 2

세 건 모두 잘 된다.


끝으로.

springboot2.x 를 사용해 본 유저라면 누구든 springboot3을 사용하고 싶어 할 것이다.

관련 migration guide가 있으니 springboot3을 사용하기 전 뭐가 달라졌는지 간단히 살펴보는 것이 좋겠다.

https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide


참고:

https://spring.io/blog/2022/11/16/spring-framework-6-0-goes-ga

 

 

Spring | Home

Cloud Your code, any cloud—we’ve got you covered. Connect and scale your services, whatever your platform.

spring.io

 


참고

apache httpclient

728x90
반응형
반응형

환경: java11, springboot 2.5.6

 

@SpringBootTest로 integration 테스트를 할 때 DB변경사항에 대해서 쿼리로 확인하고 싶을 때가 있다.

테스트가 끝나면 자동으로 메모리에서 사라지기 때문에 테스트 후에는 확인할 수가 없고

테스트 중간에 디버그 포인트를 걸어서 확인하는 방법을 설명한다.

 

1. properties 확인

아래 세가지 h2에 대한 설정 값이 들어있어야 한다. 특히 web-allow를 true로 주는 게 중요하다.

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.h2.console.settings.web-allow-others=true

 

2. @SpringBootTest에 옵션 주기

webEnvironment의 기본 값은 MOCK이기 때문에 테스트 시 별도 포트를 사용하지 않는다.

우리는 테스트 서버를 띄우고 ui로 확인해야하기 때문에 물리적인 포트 할당이 필요하다.

아래와 같이 설정을 변경해준다.

@SpringBootTest(webEnvironment = WebEnvironment.DEFINED_PORT)

 

3. 디버그 걸기

이렇게 까지 하고 디버그를 걸어도 h2 ui가 무한 로딩 중일 텐데, 디버그로 인해 모든 thread가 블로킹되었기 때문이다.

아래와 같이 디버그 옵션을 thread로 변경하여 해당 스래드만 멈추게 한다.

기본값이 All이라서 Thread로 바꿔주어야 한다.(매번 하기엔 좀 번거로울 수 있다.)

 

4. h2 console 확인

http://localhost:8080/h2-console

로 들어가면 아래와 같이 화면이 나온다. 

728x90
반응형
반응형

Get, Post  등등 api를 만들다 보면 데이터를 바인드 하는 방식이 다르다는 것을 알게 된다.

대표적으로 ModelAttribute/Request Param과 Request Body 방식을 비교해 보고, 관련해서 빠질 수 없는 직렬화/역직렬화에 대해 정리해 본다.

 

GET mapping

query param -> object

@GetMapping
List<Product> searchProducts(@Valid ProductCriteria productCriteria) { //다수의 @RequestParam를 dto로 한방에 + 검증도 가능
   return productRepository.search(productCriteria);
}

1. @ModelAttribute의 DTO 요구 사항

@ModelAttribute는 폼 데이터쿼리 파라미터에서 값을 받아서 Java 객체로 바인딩합니다. 

일반적으로 필요한 요소:

  • 기본 생성자 (No-args Constructor): 필수
    @ModelAttribute는 데이터 바인딩을 위해 기본 생성자를 사용합니다. 기본 생성자가 없으면 바인딩이 불가능합니다.
  • Getter/Setter 메서드: 필수
    @ModelAttribute는 요청 데이터(폼 데이터나 쿼리 파라미터)를 필드에 바인딩할 때, 객체 필드에 직접 접근하지 않고 setter 메서드를 사용합니다. 따라서 필드마다 setter가 필요합니다. 또한, 검증 후 데이터를 읽어올 때는 getter가 필요합니다.

1. NoArgsConstructor로 객체 생성 후 Setter로 주입

  • setter가 없다면 값을 넘겨도 null로 세팅된다.
  • setter로 변수 이름 변경 가능하다.(받는 변수명은 idSeq -> 바인딩하는 변수는 seq 로 가능)
@Setter
public class ReceivedUserRequest {

  @NotBlank
  private String mailIdx;
}

2. public AllArgsConstructor 로 주입

public class ReceivedUserRequest {

  @NotBlank
  private String mailIdx;

  public ReceivedUserRequest(String mailIdx) {
    this.mailIdx = mailIdx;
  }
}

Spring은 때때로 생성자 기반 바인딩을 사용할 수 있으며, 이는 주로 다음과 같은 상황에서 발생합니다:

  • 폼 데이터URL 파라미터에서 넘어오는 값이 모든 필드에 전달될 때.
  • 객체 생성 시 한 번에 모든 필드를 초기화할 수 있는 AllArgsConstructor가 있는 경우.

> deserialize 자체에는 getter가 없어도 됨(그렇지만 결국 serialization 하다가 필요해서 에러가 남 ㅋㅋ)

Java implicitly adds a no-arg constructor to all classes when there is no constructors defined. If you define any parameterized constructor then the no-arg constructor will not be added.
You need a default constructor (constructor without any argument) in entities. This is needed because JPA/Hibernate uses the default constructor method to create a bean class using reflections. If you don't specify any constructor (nor Lombok annotation), Java will generate a default constructor (automatically generated by the compiler if no constructors have been defined for the class). But if you add a constructor with parameters (or @AllArgsConstructor), then you'll need to add a no args constructor (or @NoArgsConstructor) as well, for JPA/Hibernate to work.

 


POST mapping

request body -> object

참고로 아래 지식이 필요하다.

When using JSON format, Spring Boot will use an ObjectMapper instance to serialize responses and deserialize requests.

 

<getter/setter/constructor 없이 매핑을 시도하면 에러가 난다>

2. @RequestBody의 DTO 요구 사항

@RequestBody는 JSON 또는 XML과 같은 요청 본문을 Java 객체로 **역직렬화(deserialize)**합니다. @RequestBody는 JSON 데이터를 객체 필드에 직접 바인딩하는 방식이므로 필드 접근 방식이 조금 다릅니다.

필요한 요소:

  • 기본 생성자 (No-args Constructor): 필수 아님
    @RequestBody는 JSON 데이터를 역직렬화할 때 Jackson 라이브러리를 사용합니다. Jackson은 기본 생성자를 사용하거나, @JsonCreator 어노테이션을 사용하여 특정 생성자를 통해 객체를 생성할 수 있습니다. 하지만, 기본적으로 기본 생성자가 있는 것이 편리합니다.
  • Getter/Setter 또는 필드 접근: 필수는 아님
    Jackson은 JSON 데이터를 객체로 변환할 때 필드 또는 Getter/Setter를 통해 값을 설정합니다. 필드가 public이라면 직접 필드에 접근할 수도 있지만, 일반적으로 getter/setter를 통해 데이터를 주고받는 것이 더 안전합니다.

1. setter가 없을 경우, (역직렬화에 setter가 사용되기 때문에)

값을 보내도 null로 인식하여 @NotNull validation이 실패한다.

Field error in object 'cancelRequest' on field 'mailIdx': rejected value [null]; codes [NotBlank.cancelRequest.mailIdx,NotBlank.mailIdx,NotBlank]

 

2. 또한 역직렬화 시 기본 생성자 생성 후 setter를 사용하기 때문에

@NoArgsConstructor나 @AllArgsConstructor가 아닌 일부 생성자만 있는 경우는 아래 에러가 난다.(둘 중 하나만 있어도 매핑이 잘 된다)

 cannot deserialize from Object value (no delegate- or property-based Creator)

 

3. @Getter가 없을 경우, 역직렬화는 문제가 없지만 후에 직렬화 과정에서 null로 내려간다.

 


역직렬화 / 직렬화에 대해 정리하자면 아래와 같다.

역직렬화는 기본적으로 다음과 같은 과정을 거쳐서 처리된다.
- object mapper 사용
- 기본 생성자로 객체를 생성함 -> 기본 생성자가 없으면 에러
-필드값을 찾아서 값을 바인딩해줌 ->  public 필드 또는 public 형태의 setter로 바인딩

직렬화는 다음과 같다.
- Spring에서는 기본적으로 jackson 모듈의 ObjectMapper라는 클래스가 직렬화를 처리하며 ObjectMapper의 writeValueAsString이라는 메서드가 사용됨
- ObjectMapper는 public 필드 또는 public 형태의 getter로 값을 가져옴

 

> 직렬화에 대해 조금 더 깊숙이 들어가면..

application/json타입의 데이터는 스프링부트의 MessageConverter ->  MappingJackson2HttpMessageConverter -> Jackson 라이브러리의 Object mapper클래스를 이용해 Reflection으로 객체를 생성하게 된다. 이때 기본 생성자가 없을 경우 Jackson 라이브러리가 deserialize 할 수 없어 예외가 발생한다.


특히 역직렬화 시 기본 생성자가 사용되면 불변 객체가 아니게 되지 않나 싶은데

@NoArgsConstructor(access = AccessLevel.PRIVATE)

위와 같이 제한을 주면 막 생기는 위험을 막을 수 있다.

728x90
반응형

+ Recent posts