Spring

728x90
JPQL 실행 시 Flush와 영속성 컨텍스트 동작 확인

JPQL 실행 시 Flush와 영속성 컨텍스트 동작 확인

  1. 이 테스트는 두 가지 주요 목적을 가지고 있습니다:

    1. JPQL 실행 전 flush가 실행되는지 확인
    2. 영속성 컨텍스트에 동일한 ID를 가진 엔티티가 존재할 경우, JPQL로 가져온 값이 영속성 컨텍스트의 값으로 대체되지 않는지 확인

 

@Test
@DisplayName("닉네임 변경 요청시 해당 회원의 닉네임을 변경한다.")
void updateMemberWithNickNameRequest() throws Exception {
  //given
  Member member = saveMemberAndMemberImage();
  Long memberId = member.getId();

  Jwt jwtCreatedBySavedMember = generateTokenWithMemberId(memberId);

  MemberUpdateRequest memberUpdateRequest = MemberUpdateRequest.builder()
    .nickname(CHANGED_NICK_NAME)
    .build();

  String jsonRequest = objectMapper.writeValueAsString(memberUpdateRequest);

  //when & then
  mockMvc.perform(
      MockMvcRequestBuilders.patch("/api/members/{memberId}", memberId)
        .header(AUTHORIZATION_STRING, JWT_TOKEN_PREFIX + jwtCreatedBySavedMember.getAccessToken())
        .contentType(MediaType.APPLICATION_JSON)
        .content(jsonRequest))
    .andExpect(status().isOk());

  Member updatedMember = memberProvider.findById(memberId);
  assertThat(updatedMember.getNickname()).isEqualTo(CHANGED_NICK_NAME);
}

 

테스트 설명

  1. Member 저장

    Member member = saveMemberAndMemberImage();
    Long memberId = member.getId();
    

    이 메서드에 의해 memberimage가 저장됩니다. @GeneratedValue(strategy = GenerationType.IDENTITY)로 설정되어 있기 때문에 member가 저장될 때 바로 저장 쿼리가 실행됩니다. 이 상태에서 영속성 컨텍스트와 데이터베이스 모두에 member가 존재합니다. 이때 Member 엔티티의 isWithdrawal 필드는 데이터베이스에 의해 기본값으로 초기화되지만, 해당필드의 초기화 상태가 영속성 컨텍스트에는 반영되지 않습니다.

 

  1. MockMvc 요청

    mockMvc.perform(
            MockMvcRequestBuilders.patch("/api/members/{memberId}", memberId)
                .header(AUTHORIZATION_STRING, JWT_TOKEN_PREFIX + jwtCreatedBySavedMember.getAccessToken())
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonRequest))
        .andExpect(status().isOk());
    

    이 요청은 member의 닉네임을 변경합니다. updateMember 메서드는 JPQL을 통해 member를 조회합니다. 이때 JPQL memberQueryService.findById(memberId) 이 실행되기 전에 flush가 자동으로 실행됩니다.

    @Transactional
    public void updateMember(Long memberIdFromJwt, Long memberId, MemberUpdateRequest memberUpdateRequest) {
        validateMemberId(memberIdFromJwt, memberId);
        Member member = memberQueryService.findById(memberId); // JPQL 실행 전 flush() 호출
    
        Optional.ofNullable(memberUpdateRequest.getAlert())
            .ifPresent(member::updateAlert);
    
        Optional.ofNullable(memberUpdateRequest.getNickname())
            .ifPresent(member::updateNickname);
    }
    

    entityManager.clear()를 호출하지 않았으므로, entityManager에는 여전히 saveMemberAndMemberImage() 메서드에 의해 저장된 member가 존재합니다. 이 상태에서 JPQL로 가져온 member는 영속성 컨텍스트에 이미 존재하는 member로 대체되지 않습니다.

    (JPQL 을 통해 값을 가져오더라도, 영속성 컨텍스트안에 같은 ID 를 가진 객체가 있다면 JPQL 을 통해 가져온 값을 버리고 영속성 컨텍스트 안에 값을 유지하기 때문에 DB에 의해 기본값으로 초기화된 엔티티가 현재 영속성 컨텍스트 안의 엔티티로 대체되지 않습니다.)

 

  1. Update 와 Flush

    Member updatedMember = memberProvider.findById(memberId);
    assertThat(updatedMember.getNickname()).isEqualTo(CHANGED_NICK_NAME);
    

    이 코드에서 memberProvider.findById(memberId)는 JPQL 쿼리를 실행하며, 쿼리 실행 전에 flush가 호출됩니다. flush 가 호출될때, mockMvc.perform() 에 의해 실행된 Member 엔티티 업데이트 쿼리가 나갈것입니다. 트랜잭션의 전파에 의해 test 코드에 걸어놓은 트랜잭션이 끝나지 않았으므로 mockMvc.perform 에 의해 실행된 update 쿼리는 mockMvc.perform 이 실행이 완료된 후에도 아직 실행되지 않기 때문입니다.

 

❗️이때 중요한것은 우린 아직 entityManager.clear() 를 한번도 호출하지 않았다는 점입니다. Member 엔티티 안에는 DB의 기본값에 의해 초기화되는 isWithdrawal 이라는 필드가 있는데, 아직 영속성 컨텍스트 안에 있는 entityclear() 가 된 적이 없고, 따라서 DB에 의해 초기화된 필드는 영속성 컨텍스트 안에 적용이 되지 않았을 것입니다.

이를 검증하기 위해 아래와 같이 최종적으로 찾은 updatedMemberisWithdrawal 필드를 가져와보면 null 인것을 확인할수 있으며

 

updatedMembergetisWithdrawalnull

 

만약 isWithdrawal 이 초기화된 상태를 보고싶다면 movkMvc.perform() 메서드 호출 이전 또는 이후에 entityManagerclear 메서드를 실행해주면 잘초기화된 isWithdrawal 값을 볼수있습니다.

entityManagerFlushAfterMvcPerform

entityManagerClearAfterMvcPerform

참고로 mockMvc.perform 이후에 clear() 를 호출하는 로직에서 entityManger.flush() 가 추가된 이유는 만약 entityManger.clear() 만 호출시 mockMvc.perform() 에 의해 업데이트된 member 엔티티의 변경사항이 모두 detach 되면서 업데이트 쿼리가 나가지 않습니다. 따라서 mockMvc.perform 이후에는 entityManger.flush() 를 추가해 member 엔티티의 update 정보가 사라지지 않도록 합니다.

 

결론

  1. JPQL이 실행되기 전에 flush가 실행되는지 확인:

    • mockMvc.perform() 이후, JPQL로 인해 update 쿼리가 실행되는 것을 확인했습니다. 이는 JPQL이 실행되기 전에 flush가 자동으로 호출된다는 것을 의미합니다.
  2. 영속성 컨텍스트에 동일한 ID의 엔티티가 존재할 때 JPQL로 가져온 값이 대체되지 않는지 확인:

    • entityManager.clear()를 호출하지 않으면, JPQL로 가져온 값이 영속성 컨텍스트에 이미 존재하는 값으로 대체되지 않는다는 것을 확인했습니다. 예를 들어, member.getIsWithdrawal()null인 것을 확인했습니다.

       

사실, 엔티티 클래스 안에 초기화 로직을 넣거나 다른 방법으로 영속성 컨텍스트와 데이터베이스 간의 불일치를 줄이는 것이 가장 좋은 방법이지만, 이번 테스트는 이 불일치를 확인하기 위해 수행되었습니다.

728x90
728x90
API 통신시 값이 null 일때 해당 값을 응답 또는 요청에 포함시켜야하나?

API 통신 시 null 값 처리: 포함 vs. 미포함의 장단점

JSON 기반의 API 통신을 할 때, 값이 null일 경우 해당 값을 요청이나 응답에 포함시켜야 하는지에 대해 고민해볼 필요가 있습니다. 이 글에서는 이 주제에 대해 StackOverflow 글을 기반하여 정리해보겠습니다.

stackoverflow

isitworthtoexcludenullfields

StackOverflow에서 같은 고민을 하는 사람들의 의견을 참고한 결과, Twitter와 같은 대형 플랫폼의 경우 "someGenericProperty":null과 같은 26바이트를 차지하는 필드를 없애는 것만으로도 300GB 이상의 트래픽을 줄일 수 있다고 합니다. 하루에 수십억 건의 API 요청이 오가는 플랫폼에서는 이는 상당한 절감 효과를 가져올 수 있습니다.

 

그러나, null 값을 포함시키지 않는 것이 항상 최선의 선택은 아니라고 합니다. 일관성을 유지하는 것이 API 사용자의 혼란을 줄이고 예측 가능한 동작을 보장하며 디버깅을 용이하게 할 수 있습니다.

excludenullfieldsAnswer

 

위 글을 기반으로 대략적으로 정리를 해보자면 내용은 아래와 같습니다.

 

일관성 유지:

  • API 사용자는 모든 응답에서 예상 가능한 구조를 기대합니다. 필드가 항상 존재하면 API의 사용성과 신뢰성이 높아집니다.

 

잠재적 문제점:

  • 특정 데이터 요소가 부재할 경우, 그 원인을 파악하기 어려울 수 있습니다. 예를 들어, 필드가 누락된 이유가 데이터가 없는 것인지, 아니면 데이터 전달 중 문제가 발생한 것인지 알기 어려울 수 있습니다.
  • 필드가 예상대로 존재하지 않으면 소비자 애플리케이션의 안정성이 저하될 수 있습니다.

 

구현 관련 고려사항:

  • null 값을 제외하려면 서버 측에서 추가 로직이 필요합니다. 이는 코드 복잡성을 증가시키고 유지보수 부담을 가중시킬 수 있습니다.

 

성능 관련:

  • 네트워크 대기 시간과 대역폭을 고려해야 합니다. 대부분의 경우, 약간의 대역폭을 희생하더라도 일관된 응답 구조가 선호됩니다.

 

예외 상황:

  • 극도로 제한된 대역폭 환경에서는 필드를 제외하는 것이 더 나을 수 있습니다.
  • 희소 데이터 구조를 처리할 때도 필드를 제외하는 것이 유리할 수 있습니다.

 

결론적으로 예외적인 상황이 아닌 이상, null 값을 포함하더라도 해당 필드를 응답에 추가하는 것이 API 사용의 일관성을 유지하는 데 도움이 된다고 합니다.

728x90
728x90
JPA에서 단방향 및 양방향 일대일 관계의 외래키 처리와 지연 로딩 문제

JPA에서 단방향 및 양방향 일대일 관계의 외래키 처리와 지연 로딩 문제

❗️일대일 대상 테이블에 외래키 단방향 관계는 JPA에서 지원하지 않으며, 양방향 관계만 지원합니다.

 

[인프런 김영한님 자료]

memberAndLockerImage

대상 테이블에 외래키가 존재하는 경우, 프록시 기능의 한계로 인해 지연 로딩으로 설정해도 항상 즉시 로딩됩니다.

예를 들어, Member 객체를 가져왔을 때 JPA는 가져온 Member 객체에 Locker가 있는지 여부를 알아야 합니다.

 

1. 외래키가 Locker에 있을 때

외래키가 Locker에 있다면, Member 엔티티를 가져올 때 Member 클래스에 정의된 Locker가 존재하는지 확인해야 합니다. 이때 Member 테이블에는 외래키가 존재하지 않기 때문에, 지연 로딩 설정 여부와 상관없이 Locker 테이블을 조회하여 Member와 연결된 Locker가 있는지 확인하는 추가 쿼리가 발생합니다.

 

2. 외래키가 Member에 있을 때

외래키가 Member에 있다면, Member 테이블의 조회만으로도 Member와 연결된 Locker가 있는지 여부를 판단할 수 있습니다. 따라서 이 경우에는 지연 로딩(lazy loading) 또는 즉시 로딩(eager loading) 설정이 가능합니다.

 

요약

  • 외래키가 Locker에 있는 경우: Member를 조회할 때 항상 Locker 테이블을 추가로 조회해야 하므로, 지연 로딩 설정이 무시되고 즉시 로딩이 이루어집니다.
  • 외래키가 Member에 있는 경우: Member 테이블만으로 Locker와의 연결 여부를 확인할 수 있으므로, 지연 로딩과 즉시 로딩 설정이 가능합니다.
728x90
728x90
@DynamicInsert

[Spring] @DynamicInsert 사용이유

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Knowledge {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;
	@Column(nullable = false)
	private String thumbnailImageUrl;
	@Column(nullable = false)
	private String contentImageUrl;
	@Column(columnDefinition = "boolean default false")
	private Boolean isDeleted;
	@Column(updatable = false)
	@CreationTimestamp
	private LocalDateTime createdAt;

}

위와 같은 knowledge 객체를 생성 및 저장 할 때 isDeleted 필드 값을 설정하지 않으면, 데이터베이스에 null 값이 저장됩니다. 이는 데이터베이스에 기본값이 true 또는 false로 설정되어 있더라도 발생할 수 있는 문제입니다.

 

예를 들어, Knowledge Entity 가 위와 같을때 Knowledge 객체를 저장하게 되면(isDeleted 가 null 인 상태로), 아래와 같은 쿼리가 생성됩니다:

2024-01-03T16:21:44.759+09:00 DEBUG 32029 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        knowledge
        (thumbnail_image_url, contentImageUrl, isDeleted, createdAt) 
    values
        (?, ?, ?, ?)

이 쿼리는 isDeleted 필드가 명시적으로 null로 설정되었기 때문에, 데이터베이스에 false가 아닌 null이 저장되는 문제를 발생시킵니다.

 

이를 해결하기 위해 @DynamicInsert 어노테이션을 사용할 수 있습니다. @DynamicInsert를 사용하면 isDeleted 필드가 null일 경우 INSERT 문에서 제외됩니다. 따라서 데이터베이스는 isDeleted 필드에 대한 기본값을 적용하여 false를 저장하게 됩니다.

2024-01-03T16:21:44.759+09:00 DEBUG 32029 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        knowledge
        (thumbnail_image_url, content_image_url, created_at) 
    values
        (?, ?, ?)

@DynamicInsert 어노테이션을 사용하면, 엔티티 객체의 null 값을 가진 필드를 INSERT 문에서 제외하여 데이터베이스의 기본값이 적용되도록 할 수 있습니다. 이를 통해 Knowledge 엔티티 클래스의 isDeleted 필드가 올바르게 처리되며, 불필요한 null 값 저장을 방지할 수 있습니다.

728x90
728x90
optional 클래스의 orElseThrow

Optional 클래스의 orElseThrow

orElseThrowOptional 클래스의 메서드로, 값이 존재하면 그 값을 반환하고, 값이 존재하지 않으면 예외를 던지도록 설계되었습니다. 이 메서드는 Supplier 인터페이스를 통해 예외를 생성하는데, Supplier는 인자를 받지 않고 결과를 반환하는 함수형 인터페이스입니다.

 

orElseThrow 메서드는 다음과 같이 정의되어 있습니다

 public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X {
      if (value != null) {
          return value;
      } else {
          throw exceptionSupplier.get();
      }
  }

이 메서드는 값이 존재하면 그 값을 반환하고, 값이 없으면 Supplier를 통해 예외를 생성하여 던집니다. Supplier@FunctionalInterface 어노테이션이 붙은 인터페이스로, 단 하나의 추상 메서드 get을 가지고 있습니다

@FunctionalInterface
public interface Supplier<T> {

    /**
     * Gets a result.
     *
     * @return a result
     */
    T get();
}

`

orElseThrow 메서드를 사용할 때는 보통 람다 표현식을 사용하여 예외 객체를 생성합니다. 예를 들어, find 메서드에서 특정 ID에 해당하는 Knowledge 객체를 찾지 못했을 때 예외를 던지도록 다음과 같이 작성할 수 있습니다:

public Knowledge find(Long knowledgeId) {
		return knowledgeRepository.findById(knowledgeId).orElseThrow(() -> new CustomRuntimeException(
			KnowledgeException.KNOWLEDGE_NOT_FOUND));
	}

이 코드는 knowledgeId에 해당하는 Knowledge 객체가 존재하지 않으면 CustomRuntimeException을 던집니다. 여기서 사용된 람다 표현식 () -> new CustomRuntimeException(KnowledgeException.KNOWLEDGE_NOT_FOUND)Supplier<CustomRuntimeException> 타입의 인스턴스를 생성합니다. (CustomRuntimeException 구조)

 

최종적으로 위와같이

public Knowledge find(Long knowledgeId) {
		return knowledgeRepository.findById(knowledgeId).orElseThrow(() -> new CustomRuntimeException(
			KnowledgeException.KNOWLEDGE_NOT_FOUND));
	}

예외가 던져진다하면 orElseThrow 안에 제네릭은 타입 추론에 의하여

public <CustomRuntimeException extends Throwable> T orElseThrow(Supplier<? extends CustomRuntimeException> exceptionSupplier) throws CustomRuntimeException {
    if (value != null) {
        return value;
    } else {
        throw exceptionSupplier.get();
    }
}

이렇게 바뀌고 value 즉 위에선 Knowledge 가 없는경우 CustomRuntimeExceptionthrow 되게 됩니다.

그후 ControllerAdvice 등의 예외 처리 로직에 의하여 예외가 처리 됩니다.

728x90
728x90
Enum 타입으로 Exception 구현하기

[Spring] Enum 타입으로 Exception 구현하기

기존에 예외 처리를 다음과 같이 Enum 타입으로 구현했습니다.

public enum AdminException {

	FAIL_TO_SIGN_IN(HttpStatus.BAD_REQUEST, "로그인에 실패했습니다.");

	private final HttpStatus status;
	private final String message;
}

 

Enum 타입은 기본적으로 java.lang.Enum을 암시적으로 상속받기 때문에 extends 키워드를 사용할 수 없습니다. 따라서 상속 기능을 구현하기 위해 interface를 사용했으며, interface는 다중 상속을 지원하므로 아래 코드와 같이 enum 클래스들이 CustomException 인터페이스를 implements 하도록 했습니다.

public interface CustomException {
	
	HttpStatus getHttpStatus();

	String getErrorMessage();

	String getName();
}

@RequiredArgsConstructor
public enum AdminException implements CustomException {

	FAIL_TO_SIGN_IN(HttpStatus.BAD_REQUEST, "로그인에 실패했습니다.");

	private final HttpStatus status;
	private final String message;

	@Override
	public HttpStatus getHttpStatus() {
		return status;
	}

	@Override
	public String getErrorMessage() {
		return message;
	}

	@Override
	public String getName() {
		return name();
	}
}

 

Enum 타입이 특정 인터페이스를 implements한 이유는 RuntimeException을 상속받는 특정 클래스(아래에서는 CustomRumtimeException)에서 모든 예외에 대해 동일한 응답을 제공하기 위해서였습니다. 해당 클래스는 다음과 같습니다.

@Getter
@RequiredArgsConstructor
public class CustomRuntimeException extends RuntimeException {

	private final CustomException customException;

	public String getMessage() {
		return customException.getErrorMessage();
	}
}

위 코드는 CustomRuntimeException 클래스가 CustomException 인터페이스를 구현하는 열거형을 사용하여 예외 정보를 처리하고, 예외 발생 시 일관된 응답을 반환하도록 설계되었습니다. 이를 통해 각 예외에 대한 HTTP 상태 코드와 메시지를 통일된 형식으로 관리할 수 있습니다.

최종적으로 CustomRuntimeExceptionControllerAdvice 또는 RestControllerAdvice에서 아래와 같이 예외를 잡고 응답을 보낼 수 있습니다.

@ExceptionHandler(CustomRuntimeException.class)
public ResponseEntity<Map<String, String>> customExceptionHandler(CustomRuntimeException e) {

    // 응답 맵 생성
    Map<String, String> responseMap = new LinkedHashMap<>();
    responseMap.put("status", e.getCustomException().getHttpStatus().toString());
    responseMap.put("message", e.getCustomException().getErrorMessage());

    // 응답 반환
    return ResponseEntity.status(e.getCustomException().getHttpStatus())
        .body(responseMap);
}

이렇게 하면 예외 발생 시 CustomRuntimeException을 던지고, GlobalExceptionHandler가 해당 예외를 잡아 일관된 형식으로 응답을 반환하게 됩니다.

728x90

'Spring' 카테고리의 다른 글

[Spring] DynamicInsert 사용 이유  (0) 2024.07.22
Optional 클래스의 orElseThrow  (0) 2024.07.22
docker hub 사용하는 방법  (1) 2023.11.27
redis를 통해 로그아웃 기능 구현  (0) 2023.11.27
Jasypt 를 통한 암호화  (2) 2023.11.27

+ Recent posts