1. Overview

Spring Boot 에서 spring-data-redis 라이브러리를 활용해서 Redis 를 사용하는 방법을 알아봅니다.

Redis 에 대한 개념과 로컬 설치 방법은 Redis 설치 및 명령어 글을 확인해주세요.


2. Java 의 Redis Client

Java 의 Redis Client 는 크게 두 가지가 있습니다.

Jedis 와 Lettuce 인데요.

원래 Jedis 를 많이 사용했으나 여러 가지 단점 (멀티 쓰레드 불안정, Pool 한계 등등..) 과 Lettuce 의 장점 (Netty 기반이라 비동기 지원 가능) 때문에 Lettuce 로 추세가 넘어가고 있었습니다.

그러다 결국 Spring Boot 2.0 부터 Jedis 가 기본 클라이언트에서 deprecated 되고 Lettuce 가 탑재되었습니다.


3. Spring Boot 에서 Redis 설정

Spring Boot 에서 Redis 를 사용하는 방법은 RedisRepositoryRedisTemplate 두 가지가 있습니다.

그 전에 먼저 공통 세팅이 필요합니다.


implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  • build.gradlespring-boot-starter-data-redis 추가하고 빌드해줍니다.

spring:
  redis:
    host: localhost
    port: 6379
  • application.yaml 에 host 와 port 를 설정합니다.
  • localhost:6379 는 기본값이기 때문에 만약 Redis 를 localhost:6379 로 띄웠다면 따로 설정하지 않아도 연결이 됩니다.
  • 하지만 일반적으로 운영 서버에서는 별도의 Host 를 사용하기 때문에 값을 이렇게 별도의 값을 세팅하고 Configuration 에서 Bean 에 등록해줍니다.

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }
}
  • Redis 사용을 위한 기본 Configuration 입니다.
  • application.yaml 에 설정한 값을 @Value 어노테이션으로 주입합니다.

3.1. RedisRepository

Spring Data Redis 의 Redis Repository 를 이용하면 간단하게 Domain Entity 를 Redis Hash 로 만들 수 있습니다.

다만 트랜잭션을 지원하지 않기 때문에 만약 트랜잭션을 적용하고 싶다면 RedisTemplate 을 사용해야 합니다.


Entity

@Getter
@RedisHash(value = "people", timeToLive = 30)
public class Person {

    @Id
    private String id;
    private String name;
    private Integer age;
    private LocalDateTime createdAt;

    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
        this.createdAt = LocalDateTime.now();
    }
}
  • Redis 에 저장할 자료구조인 객체를 정의합니다.
  • 일반적인 객체 선언 후 @RedisHash 를 붙이면 됩니다.
    • value : Redis 의 keyspace 값으로 사용됩니다.
    • timeToLive : 만료시간을 seconds 단위로 설정할 수 있습니다. 기본값은 만료시간이 없는 -1L 입니다.
  • @Id 어노테이션이 붙은 필드가 Redis Key 값이 되며 null 로 세팅하면 랜덤값이 설정됩니다.
    • keyspace 와 합쳐져서 레디스에 저장된 최종 키 값은 keyspace:id 가 됩니다.

Repository

public interface PersonRedisRepository extends CrudRepository<Person, String> {
}
  • CrudRepository 를 상속받는 Repository 클래스 추가합니다.

Example

@SpringBootTest
public class RedisRepositoryTest {

    @Autowired
    private PersonRedisRepository repo;

    @Test
    void test() {
        Person person = new Person("Park", 20);

        // 저장
        repo.save(person);

        // `keyspace:id` 값을 가져옴
        repo.findById(person.getId());

        // Person Entity 의 @RedisHash 에 정의되어 있는 keyspace (people) 에 속한 키의 갯수를 구함
        repo.count();

        // 삭제
        repo.delete(person);
    }
}
  • JPA 와 동일하게 사용할 수 있습니다.
  • 여기서는 id 값을 따로 설정하지 않아서 랜덤한 키값이 들어갑니다.
  • 저장할때 save() 를 사용하고 값을 조회할 때 findById() 를 사용합니다.

redis-cli 로 데이터 확인

  • id 를 따로 설정하지 않은 null 값이라 랜덤한 키값이 들어갔습니다.
  • 데이터를 저장하면 membermember:{randomKey} 라는 두개의 키값이 저장되었습니다.
  • member 키값은 Set 자료구조이며, Member 엔티티에 해당하는 모든 Key 를 갖고 있습니다.
  • member:{randomKey} 값은 Hash 자료구조이며 테스트 코드에서 작성한 값대로 field, value 가 세팅한 걸 확인할 수 있습니다.
  • timeToLive 를 설정했기 때문에 30초 뒤에 사라집니다. ttl 명령어로 확인할 수 있습니다.

3.2. RedisTemplate

RedisTemplate 을 사용하면 특정 Entity 뿐만 아니라 여러가지 원하는 타입을 넣을 수 있습니다.

template 을 선언한 후 원하는 타입에 맞는 Operations 을 꺼내서 사용합니다.


config 설정 추가

@Configuration
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}
  • RedisTemplateLettuceConnectionFactory 을 적용해주기 위해 설정해줍니다.

Example

@SpringBootTest
public class RedisTemplateTest {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Test
    void testStrings() {
        // given
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        String key = "stringKey";

        // when
        valueOperations.set(key, "hello");

        // then
        String value = valueOperations.get(key);
        assertThat(value).isEqualTo("hello");
    }


    @Test
    void testSet() {
        // given
        SetOperations<String, String> setOperations = redisTemplate.opsForSet();
        String key = "setKey";

        // when
        setOperations.add(key, "h", "e", "l", "l", "o");

        // then
        Set<String> members = setOperations.members(key);
        Long size = setOperations.size(key);

        assertThat(members).containsOnly("h", "e", "l", "o");
        assertThat(size).isEqualTo(4);
    }

    @Test
    void testHash() {
        // given
        HashOperations<String, Object, Object> hashOperations = redisTemplate.opsForHash();
        String key = "hashKey";

        // when
        hashOperations.put(key, "hello", "world");

        // then
        Object value = hashOperations.get(key, "hello");
        assertThat(value).isEqualTo("world");

        Map<Object, Object> entries = hashOperations.entries(key);
        assertThat(entries.keySet()).containsExactly("hello");
        assertThat(entries.values()).containsExactly("world");

        Long size = hashOperations.size(key);
        assertThat(size).isEqualTo(entries.size());
    }
}
  • 위에서부터 차례대로 Strings, Set, Hash 자료구조에 대한 Operations 입니다.
  • redisTemplate 을 주입받은 후에 원하는 Key, Value 타입에 맞게 Operations 을 선언해서 사용할 수 있습니다.
  • 가장 흔하게 사용되는 RedisTemplate<String, String> 을 지원하는 StringRedisTemplate 타입도 따로 있습니다.

Reference

1. Swagger 란?

Swagger 는 OAS(Open Api Specification)를 위한 프레임워크입니다.

개발자들의 필수 과제인 API 문서화를 쉽게 할 수 있도록 도와주며, 파라미터를 넣어서 실제로 어떤 응답이 오는지 테스트도 할 수 있습니다.

또한, 협업하는 클라이언트 개발자들에게도 Swagger 만 전달해주면 API Path 와 Request, Response 값 및 제약 등을 한번에 알려줄 수 있습니다.


1.1. OpenAPI 와의 관계

Swagger 공식 블로그 포스팅을 보면 Swagger 와 OpenAPI 의 차이가 나와있습니다.

OpenAPI는 RESTful API 설계를 위한 업계 표준 사양을 나타내고 Swagger는 SmartBear 도구 세트를 나타냅니다

요약하면 Swagger 는 이제 OpenAPI 사양을 구현하기 위한 도구 세트 (Swagger Editor, Swagger UI, SwaggerHub) 가 되었으며, 브랜드명만 변경하지 않은 채 그대로 사용하기로 했습니다.


2. 적용

Swagger 2.x 버전을 적용할 수도 있고 3.x 버전을 적용할 수도 있습니다.

큰 차이는 없기 때문에 최신 버전인 Swagger 3.x 버전을 적용합니다.


2.1. 의존성 추가

/* build.gradle */

dependencies {
    // ..
    implementation 'io.springfox:springfox-boot-starter:3.0.0'
    // ..
}

2.2. Config 추가

@Configuration
public class SwaggerConfig {

    @Bean
    public Docket api() {
        return new Docket(DocumentationType.OAS_30)
                .useDefaultResponseMessages(false)
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.example.springswagger.controller"))
                .paths(PathSelectors.any())
                .build()
                .apiInfo(apiInfo());
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Practice Swagger")
                .description("practice swagger config")
                .version("1.0")
                .build();
    }
}
  • Docket: Swagger 설정의 핵심이 되는 Bean
  • useDefaultResponseMessages: Swagger 에서 제공해주는 기본 응답 코드 (200, 401, 403, 404). false 로 설정하면 기본 응답 코드를 노출하지 않음
  • apis: api 스펙이 작성되어 있는 패키지 (Controller) 를 지정
  • paths: apis 에 있는 API 중 특정 path 를 선택
  • apiInfo:Swagger UI 로 노출할 정보

2.3. Controller 에 적용

@RestController
public class HelloController {

    @Operation(summary = "test hello", description = "hello api example")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "OK !!"),
            @ApiResponse(responseCode = "400", description = "BAD REQUEST !!"),
            @ApiResponse(responseCode = "404", description = "NOT FOUND !!"),
            @ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR !!")
    })
    @GetMapping("/hello")
    public ResponseEntity<String> hello(@Parameter(description = "이름", required = true, example = "Park") @RequestParam String name) {
        return ResponseEntity.ok("hello " + name);
    }
}
  • 적용한 설정들이 어떻게 표현되는지는 아래에서 설명합니다.

2.4. 실행 후 접속

로컬 실행 후 URL 접속하면 되는데 Swagger 2.x 버전과 조금 다릅니다.


3. 적용된 설정 확인

  • SwaggerConfig 에서 설정한 정보들은 Swagger 상단에 나옵니다.

  • Controller 에서 세팅한 설정입니다.

4. SpringDoc ?

Swagger 는 SpringFox 외에 SpringDoc 으로도 설정할 수 있습니다.

특히 현재 Spring Boot 2.6 버전에서 SpringFox Swagger 3.0 의 사용하지 못하는 이슈가 존재합니다.

이럴 때 SpringDoc 공식 문서 를 참고하면 Spring Boot 를 2.5 버전대로 내리지 않아도 Swagger 를 적용할 수 있습니다.


Reference

1. Overview

JPA 를 사용할 때 ID 값으로 엔티티를 가져오는 두 가지 메소드가 존재합니다.

비슷하지만 다른 이 두가지 메소드의 차이점에 대해서 알아봅시다.


1.1. getById

@Override
public T getById(ID id) {

    Assert.notNull(id, ID_MUST_NOT_BE_NULL);
    return em.getReference(getDomainClass(), id);
}

getById() 는 원래 getOne() 이었으나 해당 메소드가 Deprecated 되고 대체되었습니다.

내부적으로 EntityManager.getReference() 메소드를 호출하기 때문에 엔티티를 직접 반환하는게 아니라 프록시만 반환합니다.

프록시만 반환하기 때문에 실제로 사용하기 전까지는 DB 에 접근하지 않으며, 만약 나중에 프록시에서 DB 에 접근하려고 할 때 데이터가 없다면 EntityNotFoundException 이 발생합니다.


1.2. findById

@Override
public Optional<T> findById(ID id) {

    Assert.notNull(id, ID_MUST_NOT_BE_NULL);

    Class<T> domainType = getDomainClass();

    if (metadata == null) {
        return Optional.ofNullable(em.find(domainType, id));
    }

    LockModeType type = metadata.getLockModeType();

    Map<String, Object> hints = new HashMap<>();
    getQueryHints().withFetchGraphs(em).forEach(hints::put);

    return Optional.ofNullable(type == null ? em.find(domainType, id, hints) : em.find(domainType, id, type, hints));
}

우리가 잘 알고 있는 메소드입니다.

실제 DB 에 요청해서 엔티티를 가져옵니다.

정확히 말하면 영속성 컨텍스트의 1차 캐시를 먼저 확인하고 데이터가 없으면 실제 DB 에 데이터가 있는지 확인합니다.


2. 차이점

getById() 는 해당 엔티티를 사용하기 전까진 DB 에 접근하지 않기 때문에 성능상으로 좀더 유리합니다.

따라서 특정 엔티티의 ID 값만 활용할 일이 있다면 DB 에 접근하지 않고 프록시만 가져와서 사용할 수 있습니다.


3. Test

실제로 테스트 하면서 SQL 쿼리문이 어떻게 날라가는지 확인해봅니다.


3.1. Domain

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;
}
@Entity
@Setter
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}
  • Member 엔티티와 Post 엔티티를 정의합니다.
  • MemberPost 를 작성할 수 있는 관계입니다.

3.2. Service Layer

@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final MemberRepository memberRepository;
    private final PostRepository postRepository;

    // Member 생성
    public Member createMember() {
        Member member = new Member();
        return memberRepository.save(member);
    }

    // getById 로 Member 를 가져와서 Post 생성
    public void createPostByGet(Long memberId) {
        Member member = memberRepository.getById(memberId);
        Post post = new Post();
        post.setMember(member);
        postRepository.save(post);
    }

    // findById 로 Member 를 가져와서 Post 생성
    public void createPostByFind(Long memberId) {
        Member member = memberRepository.findById(memberId).get();
        Post post = new Post();
        post.setMember(member);
        postRepository.save(post);
    }
}
  • getById() 를 사용하는 버전과 findById() 를 사용하는 두 가지 버전의 메소드를 정의합니다.

3.3. Test Code

@SpringBootTest
public class PostServiceTest {

    @Autowired
    private PostService postService;

    @Test
    void testGetById() {
        Member member = postService.createMember();
        postService.createPostByGet(member.getId());
    }

    @Test
    void testFindById() {
        Member member = postService.createMember();
        postService.createPostByFind(member.getId());
    }
}
  • 두 개의 메소드를 따로 실행해서 SQL 쿼리문을 확인해본다.

3.4. SQL Query

-- getById() 사용한 경우 member 테이블 조회하는 쿼리가 날아가지 않음

Hibernate: 
    insert 
    into
        member
        (member_id) 
    values
        (null)
Hibernate: 
    insert 
    into
        post
        (post_id, member_id) 
    values
        (null, ?)
  • getById() 는 실제 테이블을 조회하는 대신 프록시 객체만 가져옵니다.
  • 프록시 객체만 있는 경우 ID 값을 제외한 나머지 값을 사용하기 전까지는 실제 DB 에 액세스 하지 않기 때문에 SELECT 쿼리가 날아가지 않습니다.

-- findById() 사용한 경우 post 테이블을 조회

Hibernate: 
    insert 
    into
        member
        (member_id) 
    values
        (null)
Hibernate: 
    select
        member0_.member_id as member_i1_2_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
Hibernate: 
    insert 
    into
        post
        (post_id, member_id) 
    values
        (null, ?)
  • findById() 를 사용하면 DB 에 바로 액세스해서 데이터를 가져옵니다.

4. Conclusion

실제 DB 에 접근하냐 하지 않냐는 성능에 영향이 갈 수 있습니다.

위의 예시처럼 단순히 특정 엔티티의 ID 값만 필요한 경우에는 모든 데이터를 가져올 필요가 없습니다.

따라서 상황에 따라 적절한 메소드를 사용하면 됩니다.


Reference

Overview

Spring 에서 @Transactional 을 사용할 때 지정할 수 있는 옵션들을 알아봅니다.

  • isolation
  • propagation
  • readOnly
  • rollbackFor
  • timeout

1. isolation

데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질 네가지가 존재합니다. (ACID)

  • 원자성(Atomicity): 한 트랜잭션 내에서 실행한 작업들은 하나로 간주함 (모두 성공 또는 모두 실패)
  • 일관성(Consistency): 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 함
  • 격리성(Isolation): 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않아야 함
  • 지속성(Durability): 트랜잭션을 성공적으로 마치면 결과가 항상 저장되어야 함

@Transactionalisolation동시에 여러 사용자가 데이터에 접근할 때 어디까지 허용할까? 를 정하는 옵션이라고 생각하면 됩니다.

트랜잭션의 격리 수준 (Isolation) 과 데이터의 일관성 (Consistency) 는 비례합니다.

격리 수준이 약할수록 데이터 접근 및 수정이 자유롭지만 일관성이 떨어지고 격리 수준이 강해진다면 데이터의 일관성이 증가합니다.

Spring 의 @Transactional 에서는 총 5가지 isolation 옵션을 제공합니다.


1.1. DEFAULT

사용하는 DB 의 기본 격리 수준을 따름


1.2. READ_UNCOMMITTED

한 트랜잭션이 처리 중인 커밋되지 않은 데이터를 다른 트랜잭션에서 접근 가능합니다.

DB 에 커밋하지 않은, 즉 존재하지 않는 데이터를 읽는 현상을 Dirty Read 라고 합니다.

데이터 정합성에 문제가 많아서 웬만하면 권장되지 않고 아예 지원하지 않는 경우도 있습니다.

Dirty Read 가 가능하기 때문에 잘못된 데이터를 읽을 수 있습니다.

  • A 트랜잭션이 데이터 1 을 조회하여 2 로 변경하고 아직 커밋하지 않음
  • B 트랜잭션이 동일한 데이터를 조회해서 2 라는 값을 받음 (Dirty Read)
  • A 트랜잭션에서 오류가 발생해서 데이터를 롤백 (2 -> 1)
  • 실제 데이터는 1 이지만 B 트랜잭션은 2 라는 잘못된 데이터를 읽은 셈

1.3. READ_COMMITTED

트랜잭션은 커밋한 데이터만 읽을 수 있습니다.

A 트랜잭션이 데이터를 변경해도 커밋하기 전이라면 B 트랜잭션은 변경되기 전의 데이터를 조회할 수 있습니다.

이 때, B 트랜잭션은 Undo 영역에서 데이터를 가져옵니다. (MVCC - Multi Version Concurrency Control 참조)

매 조회 시마다 새로운 스냅샷을 뜨기 때문에 다른 트랜잭션이 커밋한 후 다시 조회하면 변경된 데이터를 볼 수 있습니다.

대부분의 DB 기본 격리 수준이며 REPEATABLE_READ 와 함께 가장 많이 사용되는 방식입니다.

Non-Repeatable Read 현상이 발생할 수 있습니다.

트랜잭션에서 조회한 데이터가 트랜잭션이 끝나기 전에 다른 트랜잭션에 의해 변경되면 다시 읽었을 때 새로운 값이 읽히며 데이터 불일치하는 현상을 말합니다.

하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때 항상 같은 결과를 가져와야 한다는 REPEATABLE READ 정합성 정의에 어긋납니다.

  • A 트랜잭션이 데이터 (row) 를 읽음
  • B 트랜잭션이 같은 데이터를 수정하고 커밋
  • A 트랜잭션이 다시 같은 데이터를 읽었는데 데이터가 달라짐

1.4. REPEATABLE_READ

간단히 말하면 하나의 트랜잭션은 하나의 스냅샷만 사용하는 겁니다.

A 트랜잭션이 시작하고 처음 조회한 데이터의 스냅샷을 저장하고 이후에 동일한 쿼리를 호출하면 스냅샷에서 데이터를 가져옵니다.

따라서 중간에 B 트랜잭션이 새로 커밋해도 A 트랜잭션이 조회하는 데이터는 변하지 않습니다.

Phantom Read 라는 다른 트랜잭션에서 수행한 작업에 의해 안보였던 데이터가 보이는 현상이 발생할 수 있습니다.

REPEATABLE_READ 격리 수준은 조회한 데이터에 대해서만 Shared Lock 이 걸리기 때문에 다른 트랜잭션이 새로운 데이터를 추가할 수 있습니다.

  • A 트랜잭션이 조회한 데이터는 0 건
  • B 트랜잭션이 새로운 데이터를 추가하고 커밋
  • A 트랜잭션이 같은 쿼리로 다시 조회했더니 B 트랜잭션이 추가한 데이터까지 같이 조회됨

1.5. SERIALIZABLE

가장 단순하고 엄격한 격리 수준입니다.

이름 그대로 순차적으로 트랜잭션을 진행시키며 읽기 작업에도 잠금을 걸어 여러 트랜잭션이 동시에 같은 데이터에 접근하지 못합니다.

가장 안전하지만 성능 저하가 발생하기 때문에 극도의 안정성을 필요로 하지 않으면 자주 사용되지 않습니다.


2. propagation

현재 진행중인 트랜잭션 (부모 트랜잭션) 이 존재할 때 새로운 트랜잭션 메소드를 호출하는 경우 어떤 정책을 사용할 지에 대한 정의입니다.

예를 들어, 기존 트랜잭션에 참여해서 그대로 이어갈 수도 있고, 새로운 트랜잭션을 생성할 수도 있으며 non-transactional 상태로 실행할 수도 있습니다.

처음에 non-transactional 상태로 실행한다라는 개념에 대해 착각을 했었는데 트랜잭션은 존재하지만 커밋, 롤백이 되지 않는 상태입니다.

그래서 NOT_SUPPORTED 같은 트랜잭션은 TransactionSynchronizationManager.getCurrentTransactionName() 메소드로 조회했을 때 이름이 존재하지만 JPA Dirty Checking 은 동작하지 않습니다.


Spring 의 @Transactional 에서는 다음과 같은 propagation 옵션을 제공합니다.

  • REQUIRED: 기본값이며 부모 트랜잭션이 존재할 경우 참여하고 없는 경우 새 트랜잭션을 시작
  • SUPPORTS: 부모 트랜잭션이 존재할 경우 참여하고 없는 경우 non-transactional 상태로 실행
  • MANDATORY: 부모 트랜잭션이 있으면 참여하고 없으면 예외 발생
  • REQUIRES_NEW: 부모 트랜잭션을 무시하고 무조건 새로운 트랜잭션이 생성
  • NOT_SUPPORTED: non-transactional 상태로 실행하며 부모 트랜잭션이 존재하는 경우 일시 정지시킴
  • NEVER: non-transactional 상태로 실행하며 부모 트랜잭션이 존재하는 경우 예외 발생
  • NESTED:
    • 부모 트랜잭션과는 별개의 중첩된 트랜잭션을 만듬
    • 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만 자신의 커밋과 롤백은 부모 트랜잭션에게 영향을 주지 않음
    • 부모 트랜잭션이 없는 경우 새로운 트랜잭션을 만듬 (REQUIRED 와 동일)
    • DB 가 SAVEPOINT 를 지원해야 사용 가능 (Oracle)
    • JpaTransactionManager 에서는 지원하지 않음

3. readOnly

  • 기본값: false
  • 사용법: @Transactional(readOnly = true)

기본값은 false 이며 true 로 세팅하는 경우 트랜잭션을 읽기 전용으로 변경합니다.

만약 읽기 전용 트랜잭션 내에서 INSERT, UPDATE, DELETE 작업을 해도 반영이 되지 않거나 DB 종류에 따라서 아예 예외가 발생하는 경우도 있습니다.

성능 향상을 위해 사용하거나 읽기 외의 다른 동작을 방지하기 위해 사용하기도 합니다.


3.1. JPA 에서 Dirty Checking 무시

JPA 에는 Dirty Checking 이라는 기능이 있습니다.

개발자가 임의로 UPDATE 쿼리를 사용하지 않아도 트랜잭션 커밋 시에 1차 캐시에 저장되어 있는 Entity 와 스냅샷을 비교해서 변경된 부분이 있으면 UPDATE 쿼리를 날려주는 기능입니다.

하지만 readOnly = true 옵션을 주면 스프링 프레임워크가 하이버네이트의 FlushMode 를 MANUAL 로 설정해서 Dirty Checking 에 필요한 스냅샷 비교 등을 생략하기 때문에 성능이 향상됩니다.


4. rollbackFor

  • 기본값: RuntimeException, Error
  • 사용법: @Transactional(rollbackFor = {IOException.class, ClassNotFoundException.class})

사용할 때 @Transactional(rollbackFor = IOException.class) 처럼 Exception 을 하나만 지정한다면 중괄호를 생략할 수 있습니다.

기본적으로 트랜잭션은 종료 시 변경된 데이터를 커밋합니다.

하지만 @Transactional 에서 rollbackFor 속성을 지정하면 특정 Exception 발생 시 데이터를 커밋하지 않고 롤백하도록 변경할 수 있습니다.

기본값은 {} 라고 나와있지만 사실 RuntimeExceptionError 가 세팅되어 있습니다.

내부 로직으로 들어가 설명을 보면 둘 다 예측 불가능한 예외 상황이기 때문에 기본값으로 들어가 있다고 합니다.

중요한 점은 이 값은 그냥 기본값이 아니라 아예 지정된 값이기 때문에 rollbackFor 속성으로 다른 Exception 을 추가해도 RuntimeException 이나 Error 는 여전히 데이터를 롤백합니다.

만약 강제로 데이터 롤백을 막고 싶다면 noRollbackFor 옵션으로 지정해주면 됩니다.


5. timeout

  • 기본값: -1
  • 사용법: @Transactional(timeout = 2)

지정한 시간 내에 해당 메소드 수행이 완료되이 않은 경우 JpaSystemException 을 발생시킵니다.

JpaSystemExceptionRuntimeException 을 상속받기 때문에 데이터 역시 롤백 처리 됩니다.

초 단위로 지정할 수 있으며 기본값인 -1 인 경우엔 timeout 을 지원하지 않습니다.

지정된 timeout 을 초과하면 다음과 같은 에러 로그를 보여줍니다.

org.springframework.orm.jpa.JpaSystemException: transaction timeout expired; nested exception is org.hibernate.TransactionException: transaction timeout expired

5.1. timeout 과 noRollbackFor 옵션을 같이 사용한다면?

호기심에 noRollbackFor = {RuntimeException.class, JpaSystemException.class} 옵션을 추가하고 타임아웃 테스트를 해보았습니다.

Exception 은 발생하지만 롤백 처리가 됩니다.


Reference

1. Overview

Java 에서 테스트 코드를 짤 때 특정 자료구조의 원소 값을 확인해야 하는 테스트가 있습니다.

반복문을 돌면서 일일히 확인해야 하거나 그냥 코드 한줄 한줄 입력하는 방법도 있지만 org.assertj.core.api.Assertions 에서 제공하는 assertThat().contains() 를 사용하면 좀 더 깔끔하게 확인할 수 있습니다.


2. contains

Assertions.assertThat 이후에 사용하는 contains 메소드는 단순합니다.

중복여부, 순서에 관계 없이 값만 일치하면 테스트가 성공합니다.


3. 사용법

void containsTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);

    // Success: 모든 원소를 입력하지 않아도 성공
    assertThat(list).contains(1, 2);

    // Success: 중복된 값이 있어도 포함만 되어 있으면 성공
    assertThat(list).contains(1, 2, 2);

    // Success: 순서가 바뀌어도 값만 맞으면 성공
    assertThat(list).contains(3, 2);

    // Fail: List 에 없는 값을 입력하면 실패
    assertThat(list).contains(1, 2, 3, 4);
}

assertThat(비교대상 자료구조).contains(원소1, 원소2, 원소3, ..) 형식으로 사용합니다.

위 예시만 보면 사용법을 한눈에 알 수 있습니다.


4. String, Array, Set, List 모두 사용 가능

@Test
void stringContainsTest() {
    String str = "abc";
    assertThat(str).contains("a", "b", "c");
}

@Test
void arrayContainsTest() {
    int[] arr = {1, 2, 3, 4};
    assertThat(arr).contains(1, 2, 3, 4);
}

@Test
void setContainsTest() {
    Set<Integer> set = Set.of(1, 2, 3);
    assertThat(set).contains(1, 2, 3);
}

List 는 위에서 테스트 했었고, 다른 자료구조도 가능합니다.


5. containsOnly, containsExactly

추가적으로 좀 더 구체적인 테스트를 위한 여러 가지 메소드가 제공됩니다.

그 중에서 두 가지만 추가로 알아봅니다.


5.1. containsOnly: 순서, 중복을 무시하는 대신 원소값과 갯수가 정확히 일치

/*
 * containsOnly 실패 케이스
 *
 * assertThat(list).containsOnly(1, 2);       -> 원소 3 이 일치하지 않아서 실패
 * assertThat(list).containsOnly(1, 2, 3, 4); -> 원소 4 가 일치하지 않아서 실패
 */
@Test
void containsOnlyTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);

    assertThat(list).containsOnly(1, 2, 3);
    assertThat(list).containsOnly(3, 2, 1);
    assertThat(list).containsOnly(1, 2, 3, 3);
}

containsOnly 는 원소의 순서, 중복 여부 관계 없이 값만 일치하면 됩니다.

contains 와 다른 점은 원소의 갯수까지 정확히 일치해야 한다는 점입니다.

예를 들어 위 list 에서 contains(1, 2) 는 성공하지만 containsOnly(1, 2) 는 실패합니다.


5.2. containsExactly: 순서를 포함해서 정확히 일치

/*
 * containsExactly 실패 케이스
 *
 * assertThat(list).containsExactly(1, 2);       -> 원소 3 이 일치하지 않아서
 * assertThat(list).containsExactly(3, 2, 1);    -> list 의 순서가 달라서 실패
 * assertThat(list).containsExactly(1, 2, 3, 3); -> list 에 중복된 원소가 있어서 실패
 */
@Test
void containsExactlyTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);

    assertThat(list).containsExactly(1, 2, 3);
}

containsExactly 는 원소가 정확히 일치해야 합니다.

중복된 값이 있어도 안되고 순서가 달라져도 안됩니다.

특정 자료구조의 정확한 값을 테스트 하고 싶은 경우에는 이 메소드를 사용할 수 있습니다.


Referecne

1. H2 Database 홈페이지에서 다운로드

다운로드 링크 : https://www.h2database.com/html/main.html


최신 버전보다는 안정화된 버전이 괜찮습니다.



2. 압축 풀고 실행

다운 받은 파일의 압축을 풀면 다음과 같은 구성으로 되어 있습니다.

여기서 ./bin/h2.sh 를 입력하면 h2 console 을 실행합니다.

만약 권한이 없으면 chmod 755 ./bin/h2.sh 로 권한을 부여합니다.



3. 한번 연결해서 ~.mv.db 파일 생성 후 실행

처음 진입하면 아래와 같은 화면이 나옵니다.

다른 칸은 전부 그대로 두고 JDBC URL 부분만 표시한 것처럼 바꿔줍니다.

그리고 연결 버튼을 눌러서 진입합니다.


그럼 다음과 같이 내 Root 폴더에 my-db-test.mv.db 파일이 생깁니다.

이후에 다시 h2 console 에서 연결 끊기 후 jdbc:h2:tcp://localhost/~/my-db-test 로 접속해서 사용하면 됩니다.



4. Spring Boot 에 연결

application.yml 에서 DB 설정할 때 spring.datasource.url 에 JDBC URL 과 동일하게 세팅합니다.

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/my-db-test
    username: sa
    password:
    driver-class-name: org.h2.Driver

Reference

Overview

Spring Boot 로 REST API 를 테스트 하다가 이상한 이슈에 직면했습니다.

클라이언트에서 @RequestBody 로 요청을 받기 위한 DTO 클래스를 만들고 값을 입력 받았는데 null 값이 입력되는 겁니다.

처음에는 오타가 있거나 잘못 만든 건 줄 알았는데 변수명을 바꾸니 잘 동작했습니다.

예를 들어, 변수명이 aCount 일 때는 동작하지 않았는데 aaCount 로 바꾸니 제대로 값이 들어왔습니다.

그래서 이것저것 바꿔가면서 테스트를 하였고 Jackson, Lombok 에 대해서 알게 된 사실을 정리합니다.

쓰다보니 글이 장문이 되었는데 결론과 해결법만 알고 싶으면 마지막만 보면 됩니다.


1. Jackson

Spring 은 JSON 데이터를 매핑하기 위한 Message Converter 로 Jackson 을 사용합니다.

(Http Message Converters with the Spring Framework - Baeldung 참고)

위에서 제시한 문제의 원인은 Lombok 이었지만 Jackson 의 JsonMessageConverter 의 동작에도 원인이 숨겨져 있습니다.

이를 확인하기 위해서는 Jackson 의 DTO <-> Json 과정이 어떻게 이루어지는 지 먼저 파악이 필요합니다.


1.1. Jackson 은 Getter 의 이름을 기반으로 Json Key 값을 만든다

Jackson 에는 한 가지 재미있는 사실이 있습니다.

Object -> Json 으로 변환 하면 해당 Object 의 필드명을 기준으로 될거라고 생각했는데 사실 Getter의 이름 기준으로 바뀝니다.


public class JacksonDto {
    private String name;

    public String getNameChange() {
        return name;
    }
}
  • 필드명은 name 이지만 Getter 이름은 getNameChange() 입니다.

public class DtoTest {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Test
    void test_jackson_dto() throws Exception {
        JacksonDto jacksonDto = new JacksonDto("my name");
        String content = objectMapper.writeValueAsString(jacksonDto);

        // 출력 = Jackson : {"nameChange":"my name"}
        System.out.println("Jackson : " + content);
    }
}
  • 필드명 대신 Getter 의 이름으로 Json Key 값이 설정되었습니다.
  • Getter 의 이름은 당연하게도 필드명과 동일하게 지어와서 지금까지 눈치 채지 못했습니다.

1.2. Jackson 이 Json Key 이름을 변환하는 데는 일정한 규칙이 있다

Object 의 필드명을 Getter 로 바꿀 때 일반적으로 맨 앞 글자를 대문자로 바꿔줍니다.

ex) name -> getName()

Jackson 은 Getter 를 기준으로 변환시키기 때문에 Jackson 내부적으로도 나름의 기준을 갖고 변홥합니다.

기본적으로는 JavaBeans 규약을 따르지만 다른 부분이 있었습니다.

먼저 JavaBeans 규약을 먼저 알아봅니다.


2. JavaBeans 규약

JavaBeans 는 메서드 이름에서 필드명을 추출할 때 일정한 규칙이 존재합니다.

stack overflow 의 Naming convention for getters/setters in Java 의 답변을 보면 Java Bean 규약을 첨부한 답변이 있습니다.

여기서 8.8 Capitalization of inferred names 챕터를 보면 아래와 같습니다.


When we use design patterns to infer a property or event name, we need to decide what rules to follow for capitalizing the inferred name.
If we extract the name from the middle of a normal mixedCase style Java name then the name will, by default, begin with a capital letter.
Java programmers are accustomed to having normal identifiers start with lower case letters.
Vigorous reviewer input has convinced us that we should follow this same conventional rule for property and event names.

Thus when we extract a property or event name from the middle of an existing Java name, we normally convert the first character to lower case.
However to support the occasional use of all upper-case names, we check if the first two characters of the name are both upper case and if
so leave it alone. So for example,

“FooBah” becomes “fooBah”
“Z” becomes “z”
“URL” becomes “URL”

We provide a method Introspector.decapitalize which implements this conversion rule.


간단히 요약하면 클래스의 이름은 일반적으로 대문자로 시작하지만, 개발자들은 식별자가 소문자로 시작하는 것에 익숙하기 때문에 첫 번째 글자를 소문자로 변환한다는 겁니다.

다만, 모든 문자를 대문자로 사용하는 경우도 있기 때문에 이런 경우는 예외로 둔다고 합니다.

그리고 예외 케이스를 판별하기 위해 첫 두 문자가 모두 대문자인지를 확인합니다.

그리고 java.beans 패키지에 있는 Introspector 클래스를 확인해보면 실제로 어떤 로직이 들어가있는 지 알 수 있습니다.


public class Introspector {
    // ...

    public static String decapitalize(String name) {
        if (name == null || name.length() == 0) {
            return name;
        }
        if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) &&
                        Character.isUpperCase(name.charAt(0))){
            return name;
        }
        char chars[] = name.toCharArray();
        chars[0] = Character.toLowerCase(chars[0]);
        return new String(chars);
    }

    // ...
}
  • 맨 앞 두개가 전부 대문자라면 그대로 리턴하고 아니라면 맨 앞 문자 하나만 소문자로 바꿔서 리턴합니다.

3. 그렇다면 Jackson 에서는?

Jackson 도 JavaBeans 규약을 따르지만 다른 점이 하나 있습니다.

테스트로 알아본 Jackson 의 규칙은 다음과 같습니다.

  1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.
  2. 나머지 모든 케이스에서는 맨 앞 글자만 소문자로 바꿔준다.

JavaBeans 규약과 다른 부분은 1 번입니다.

JavaBeans 규약에서는 앞 두 글자가 대문자인 경우 그대로 사용한다고 했으나 Jackson 은 맨 앞부터 이어진 대문자를 모두 소문자로 변경합니다.

예제를 통해서 확인해보겠습니다.


3.1. 맨 앞 두 글자가 모두 대문자인 경우 이어진 대문자를 모두 소문자로 변경한다.

사실 JavaBeans 규약과 다른 게 이 부분입니다.

Jackson 에서는 맨 앞 두글자가 대문자라면 이어진 모든 대문자를 소문자로 변경합니다.

  • AAaa -> aaaa : 앞 두 글자가 대문자라서 소문자로 변경
  • BBBb -> bbbb : 앞 두 글자가 대문자라서 이어진 세번째 문자까지 소문자로 변경
  • CCcC -> cccC : 앞 두 글자를 소문자로 변경하지만 맨 뒤의 대문자는 이어져 있지 않아서 그대로 사용
  • DDDD -> dddd : 앞 두 글자부터 이어진 대문자를 모두 소문자로 변경

3.1.1. DTO 정의

@ToString
@NoArgsConstructor
public class OneDto {
    private String AAaa;
    private String BBBb;
    private String CCcC;
    private String DDDD;

    public String getAAaa() {
        return AAaa;
    }

    public String getBBBb() {
        return BBBb;
    }

    public String getCCcC() {
        return CCcC;
    }

    public String getDDDD() {
        return DDDD;
    }
}

3.1.2. Controller 작성

@RestController
public class HelloController {

    @PostMapping("/one")
    public ResponseEntity<OneDto> postOne(@RequestBody OneDto dto) {
        System.out.println("----- Request POST /one ------");
        System.out.println(dto);

        return ResponseEntity.ok(dto);
    }
}
  • 실제로 요청이 왔을 때 값이 어떻게 들어오는 지 확인합니다.
  • 받은 @RequestBody 값을 그대로 다시 Response 로 내려줍니다.

3.1.3. Request

POST http://localhost:8080/one
Content-Type: application/json

{
  "AAaa": "a",
  "BBBb": "b",
  "CCcC": "c",
  "DDDD": "d"
}
  • IntelliJ 에서 제공하는 http request tool 을 사용했습니다.

3.1.4. Log

----- Request POST /one ------
OneDto(AAaa=null, BBBb=null, CCcC=null, DDDD=null)
  • Controller 에서 찍어둔 print 입니다.
  • 값이 전부 null 로 들어옵니다.

3.1.5. Response

{
  "aaaa": null,
  "bbbb": null,
  "cccC": null,
  "dddd": null
}
  • 예측한 대로 나오는 걸 확인할 수 있습니다.
  • 요청으로 들어온 OneDto 값을 그대로 리턴했을 뿐인데 Message Converter 에 의해 요청값과 응답값의 Json Key 값이 바꼈습니다.

3.2. 맨 앞 두글자가 대문자가 아니면 맨 앞 글자만 소문자로 바꿔준다

이거는 그냥 단순하게 1 번을 제외한 모든 케이스에서는 맨 앞글자만 소문자로 바꿔줍니다.

뒤에 오는 대문자나 소문자는 신경쓰지 않습니다.


3.2.1. DTO 정의

@NoArgsConstructor
public class TwoDto {
    private String aaaa;
    private String bbbB;

    private String Cccc;
    private String DddD;

    private String eEee;
    private String fFfF;

    public String getAaaa() {
        return aaaa;
    }

    public String getBbbB() {
        return bbbB;
    }

    public String getCccc() {
        return Cccc;
    }

    public String getDddD() {
        return DddD;
    }

    public String geteEee() {
        return eEee;
    }

    public String getfFfF() {
        return fFfF;
    }
}
  • DTO 를 정의하구 Controller 코드는 OneDto 와 동일하게 실행합니다.

3.2.2. Request

POST http://localhost:8080/two
Content-Type: application/json

{
  "aaaa": "a",
  "bbbB": "b",
  "Cccc": "c",
  "DddD": "d",
  "eEee": "e",
  "fFfF": "f"
}

3.2.3. Log

----- Request POST /two ------
TwoDto(aaaa=a, bbbB=b, Cccc=null, DddD=null, eEee=e, fFfF=f)
  • Cccc, DddD 를 제외한 나머지는 전부 값이 제대로 들어옵니다.

3.2.4. Response

{
  "aaaa": "a",
  "bbbB": "b",
  "cccc": null,
  "dddD": null,
  "eEee": "e",
  "fFfF": "f"
}
  • 예측한 대로 잘 나옵니다.
  • 맨 앞 글자가 대문자였던 CcccDddD 만 바뀌고 나머지는 그대로입니다.
  • 중요하게 볼 점은 TwoDto 의 필드명과 달라진 애들은 값이 제대로 들어오지 않는다는 사실입니다.

3.3. Jackson 결론

우리는 지금까지의 테스트를 통해서 한 가지 사실을 알았습니다.

DTO 의 필드명이 대문자로 시작하면 Request 요청 시 값이 제대로 들어오지 않습니다.

필드명이 대문자로 시작하면 Getter 도 대문자로 시작하는 수밖에 없습니다.

Jackson 의 규칙에 따라서 get 이후가 대문자로 시작하면 최소한 첫 글자는 항상 소문자로 바뀝니다.

따라서 필드명과 일치하지 않아 데이터가 들어가지 않는 현상입니다.

필드명을 대문자로 시작하는 경우는 많이 없지만 URL 처럼 모두 대문자로 사용했다가 안될 가능성도 있습니다.


4. Lombok 은 무슨 관계일까?

Lombok 은 개발자들이 일일히 만들어야 하는 반복적인 코드를 줄일수 있게 도와주는 라이브러리입니다.

그 중에서도 @Getter 어노테이션은 거의 모든 Object 에 필수적으로 사용됩니다.

제가 이슈를 겪었던 DTO 오브젝트도 롬복을 사용했습니다.

그렇다면 롬복의 문제점은 무엇일까요?


4.1. Lombok 의 Getter 생성 규칙

Lomobk 의 @Getter 어노테이션을 붙이면 클래스의 Getter 메소드를 자동으로 생성해줍니다.

그런데 @Getter 의 생성 규칙은 굉장히 단순합니다.

get 다음에 무조건 필드명의 맨 앞 글자를 대문자로 바꿔서 만들어줍니다.

lombok 의 Github Issue 에도 이 내용에 대한 문의가 있습니다.

제가 문제를 겪었던 필드명도 aCount 였습니다.

Lombok 이 getACount 로 생성해주고 Jackson 을 거치니 acount 가 되어서 필드명이 일치하지 않아 문제가 발생했었습니다.

반면 aaCountgetAaCount 가 되고 Jackson 을 거쳐도 aaCount 가 되어서 정상적으로 값이 들어오죠.


4.2. 인텔리제이 Generator 의 Getter 생성 규칙

public class CountDto {
    private int aCount;

    public int getaCount() {
        return aCount;
    }
}

Lombok 대신 인텔리제이에서 제공하는 제네레이터로 Getter 를 만들면 위 이슈를 회피할 수 있습니다.

getACount 대신에 getaCount 로 만들어주기 때문에 Jackson 을 거쳐도 aCount 라는 필드명과 일치합니다.


Conclusion

지금까지 정리한 내용을 요약하면 아래와 같습니다.

  1. Spring 의 Json Message Converter 는 Jackson 라이브러리를 사용
  2. lombok 의 Getter 는 필드명 맨 앞을 항상 대문자로 만듬
  3. Jackson 라이브러리는 Getter 의 맨 앞 두글자가 전부 대문자인 경우 필드명과 Json key 값이 달라짐
  4. aCount 라는 필드명을 lombok 을 사용해서 Getter 를 만들면 getACount() 가 되기 때문에 이슈가 발생

위 문제를 해결하려면 필드명을 작성할 때 첫 번째는 소문자, 두 번째는 대문자인 케이스로 만들지 않으면 됩니다.

그래도 꼭 사용해야 한다면 lombok 의 @Getter 대신 직접 Getter 를 만들거나, @JsonProperty 를 사용하면 됩니다.


Reference

Overview

토이 프로젝트를 하면서 개발 초반에 작성했던 코드를 보았습니다.

JPA 메서드로 조건을 걸어서 가져오는 로직인데 실제로 쿼리가 날라가는 걸 확인해보니 제가 생각한 것과 다르게 동작하는 걸 알게 되었습니다.


1. Entity

@Entity
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column
    private String email;

    @OneToMany(mappedBy = "member")
    private List<Post> posts = new ArrayList<>();
}


@Entity
public class Post {

    @Id @GeneratedValue
    @Column(name = "post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @Column
    private String content;
}

일반적인 두 개의 Entity 가 존재합니다.

사용자 Member 는 여러 개의 게시글 Post 를 작성할 수 있으므로 1:N 관계입니다.


2. Repository

public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findAllByMember(Member member);
    List<Post> findAllByMemberId(Long memberId);
}

이제 post 테이블에서 post.member_id 를 기준으로 일치하는 데이터들을 가져오는 메서드를 작성합니다.

가능한 메서드는 두 종류가 있습니다.

  1. Member 라는 엔티티를 조건으로 검색하는 findAllByMember
  2. memberId 라는 필드값을 조건으로 검색하는 findAllByMemberId

결론부터 말하자면 1 번 메서드를 사용해야 합니다.


3. Test

두 메서드를 사용했을 때 실제로 쿼리가 어떻게 날라가는 지 확인해보겠습니다.


3.1. Member ID 로 조회

@Test
void testFindPosts() {
    Long memberId = 2L;
    postRepository.findAllByMemberId(memberId);
}

제가 처음에 작성했던 코드입니다.

  • 이유는 단순하게 memberId 값은 이미 알고 있는 데 굳이 Member 엔티티를 한번 조회해야 할 필요가 있을까??
  • Post 에도 member_id 라는 필드가 있으니까 바로 조회하자!

라는 생각으로 사용했습니다.

SELECT * FROM post WHERE post.member_id = ? 를 기대했지만 실제로 날라가는 쿼리는 달랐습니다.


select
    post0_.post_id as post_id1_5_,
    post0_.content as content5_5_,
    post0_.member_id as member_i8_5_,
from
    post post0_ 
left outer join
    member member1_ 
        on post0_.member_id=member1_.member_id 
where
    member1_.member_id=?

예상과는 달리 LEFT OUTER JOIN 쿼리가 발생합니다.


3.2. Member 엔티티로 조회

@Test
void testFindPosts() {
    Long memberId = 2L;
    Member member = memberRepository.findById(memberId).get();
    postRepository.findAllByMember(member);
}

Member 를 한번 조회한 후에 조건으로 엔티티를 넣어봅니다.


select
    post0_.post_id as post_id1_5_,
    post0_.content as content5_5_,
    post0_.member_id as member_i8_5_,
from
    post post0_ 
where
    post0_.member_id=?

우리가 원하던 쿼리가 정상적으로 날라갑니다.


3.3. Query 를 직접 짜서 조회

@Query(value = "SELECT p FROM Post p WHERE p.member.id = :memberId")
List<Post> findAllByMemberIdQuery(@Param("memberId") Long memberId);

만약 극한의 성능 최적화를 해야 한다면 이렇게 직접 쿼리를 짜는 방법도 있습니다.

정상적으로 동작합니다.


Conclusion

처음에는 단순하게 memberId 정보를 내가 갖고 있고 Post 엔티티에도 memberId 필드가 존재하는데 바로 조회하면 되지 않을까?

하는 생각에서 코드를 짰었습니다.

그런데 실제로 쿼리가 날라가는 걸 확인하니 비효율적으로 동작하고 있었던 걸 알 수 있었네요.

보통 Member 데이터에 대해 검증을 하기 위해 조회를 한번 하니 쿼리 한번 아깝다고 생각하지 말고 엔티티로 조건을 거는 게 좋아보입니다.

JPA 에서 @ManyToOne 인 필드로 조건을 걸 때는 항상 Entity 를 사용하자

극한의 성능 최적화가 필요하다면 JPQL 이나 QueryDSL 로 직접 짜자

+ Recent posts