1. Overview

JPA 에서 연관 관계를 설정할 때 여러가지 옵션을 추가할 수 있습니다.

삭제에 관련된 옵션은 orphanRemovalcascade 가 있는데 둘이 어떤점이 다른지 알아봅니다.

 

2. orphanRemoval

JPA 2.0 부터 지원하며 부모 엔티티와 관계가 끊어진 자식 엔티티를 자동으로 삭제해줍니다.

@OneToMany@OneToOne 에서 지원하는 옵션입니다.

아마 @ManyToOne 는 보통 연관 관계의 주인인 엔티티가 사용해서 저 옵션이 없는 것 같네요.

 

@Entity
public class School {

    @OneToMany(mappedBy = "school", orphanRemoval = true)
    private List<Teacher> teachers = new ArrayList<>();
}

@Entity
public class Teacher {

    @ManyToOne
    @JoinColumn(name = "school_id")
    private School school;
}

위 도메인은 School(1) <-> Teacher(N) 인 다대일 양방향 관계를 나타냅니다.

원래는 연관 관계의 주인만 외래키의 저장, 조회, 삭제 권한을 갖기 때문에 School.teachers Collections 에서 데이터를 지워도 반영되지 않습니다.

그러나 orphanRemoval = true 옵션을 추가해주면 부모 엔티티의 컬렉션에서 자식 엔티티를 삭제할 때 참조가 끊어지면서 DB 에서도 삭제됩니다.

 

3. CascadeType.REMOVE

@Entity
public class School {

    @OneToMany(mappedBy = "school", cascade = CascadeType.REMOVE)
    private List<Teacher> teachers = new ArrayList<>();
}

@Entity
public class Teacher {

    @ManyToOne
    @JoinColumn(name = "school_id")
    private School school;
}

cascade 는 영속성 전이에 관한 옵션입니다.

설정된 엔티티가 저장/수정/삭제 될 때 연관된 엔티티들도 전부 동일한 액션을 해줍니다.

그래서 연관 관계의 주인이 아니더라도 해당 엔티티를 지우면 관련된 모든 엔티티들이 삭제됩니다.

 

4. Conclusion

  • 부모 엔티티가 삭제되면 자식 엔티티도 전부 삭제되는 것은 동일하지만 원인이 다름
    • orphanRemoval = true 옵션은 부모 엔티티가 사라지면서 자식 엔티티와의 참조(연결)가 끊어져서 삭제됨
    • CascadeType.REMOVE 옵션은 원래 엔티티가 삭제될 때 연관된 엔티티를 전부 삭제하는 옵션
  • orphanRemoval 옵션은 Collections 에서 자식 엔티티를 삭제하는 걸로 DB 에서도 삭제 가능하지만 cascade 는 불가능

 

Reference

1. Overview

JPA 의 Entity 는 그 자체로 테이블을 나타내기 때문에 어떻게 설계하는지가 중요합니다.

데이터는 테이블에 저장되어 있지만 그걸 사용하는 코드는 객체입니다.

객체와 테이블의 차이점을 알아야 제대로 된 엔티티 설계를 할 수 있습니다.

데이터베이스는 외래키를 이용해서 서로 다른 테이블끼리 상호 작용을 하는데, JPA 에서는 연관 관계라는 걸 이용합니다.

JPA 는 엔티티 사이의 연관 관계를 위해 @OneToOne, @OneToMany, @ManyToOne 라는 어노테이션을 제공합니다.


2. 연관 관계란?

JPA 에서 연관 관계를 매핑할 때 고려해야 할 점은 크게 두 가지가 있습니다.

  • 단뱡향, 양방향
  • 연관 관계의 주인

2.1. 단방향, 양방향

데이터베이스 관점에서는 Join 을 통해 여러 테이블을 한꺼번에 조회 가능하기 때문에 방향이라는 개념이 없습니다.

그러나 엔티티 (객체) 가 다른 연관된 엔티티를 조회하려면 필드값으로 참조해야 합니다.

예를 들어 Member (1) : Car (N) 관계의 테이블이 있다고 가정합니다.

Member 의 자동차 목록을 가져오기 위해선 List<Car> cars 필드값이 필요합니다.

이를 Member -> Car 단방향 참조라고 합니다.

반대로 Car 엔티티만 존재할 때, 이 자동차의 소유자를 알고 싶다면 Member member 필드값이 필요합니다.

이것 역시 Car -> Member 단방향 참조입니다.

이렇게 Member <-> Car 처럼 각 엔티티가 서로를 단방향으로 참조하고 있는 걸 양방향 참조 라고 합니다.

성능상 문제도 없는데 전부 양방향으로 참조 하면 되지 않을까? 하는 생각이 들 수도 있습니다.

하지만 양방향 참조를 한다는건 엔티티의 필드 갯수가 그만큼 늘어난다는 뜻입니다.

특히 사용자를 나타내는 User, Member, Account 등의 엔티티가 모든 엔티티에 대해 참조를 해버리면 필드 갯수가 어마어마하게 많은 복잡한 클래스가 될 겁니다.

그렇기 때문에 엔티티 설계를 할 때 참조가 꼭 필요하지 않은 상황이라면 단방향 참조만 하는 것을 추천합니다.


2.2. 연관 관계의 주인

두 엔티티가 양방향 관계일 때는 연관 관계의 주인를 정해야 합니다.

연관 관계의 주인이란 외래키를 관리하며 외래키 저장, 수정, 삭제의 권한을 갖는 실질적인 엔티티이고, 주인이 아닌 엔티티는 조회만 가능합니다.

주인이 아닌 엔티티에서 mappedBy 속성을 사용해서 주인 엔티티 필드에 붙이면 됩니다.

연관 관계의 주인은 mappedBy 속성을 사용하지 않습니다.

예를 들어 다대일에서 @ManyToOne 을 사용하는 다(N) 쪽은 항상 연관 관계의 주인이기 때문에 mappedBy 옵션을 지원하지 않습니다.

일반적으로 외래키가 있는 곳을 연관 관계의 주인로 많이 설정합니다. (다대일에서 다 쪽)


3. 일대일 (1:1)

요구사항

  • 도메인: 사람(Person), 회사(Compnay), 집(House)
  • 회사는 소유자가 존재한다 (Company -> Person)
  • 집은 주인이 존재한다 (House -> Person)
  • 사람은 집 정보를 갖고 있다 (Person -> House)

우선 사람이라는 공통 Domain Entity 가 존재합니다.

@Entity
public class Person {

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

    @Column(name = "name")
    private String name;

    @OneToOne(mappedBy = "person")
    private House house;
}

3.1. 일대일 (1:1) 단방향

@Entity
public class Company {

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

    @Column(name = "name")
    private String name;

    @OneToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "person_id")
    private Person person;
}
  • Company(1) -> Person(1)

두 테이블은 일대일 관계이기 때문에 어느 쪽에서 외래키를 관리할 지만 정하면 됩니다.

어느 쪽에서 참조하냐에 따라 외래키 위치가 정해지기 때문에 설계할 때 신중하게 정하는게 좋습니다.

예를 들어, 사람은 회사를 소유하지 않을 수도 있지만 주인 없는 회사는 일반적으로 없습니다.

객체 지향 관점에서 보면 사람이 회사를 소유하고 있기 때문에 PersonCompany 필드를 갖는게 자연스럽습니다.

하지만 그렇게 되면 person 테이블이 company_id 라는 외래키 컬럼을 갖게 되는데, 회사를 소유하지 않은 사람은 해당 컬럼값이 null 로 세팅됩니다.

null 값을 갖는 건 데이터베이스 관점에서는 바람직하지 않기 때문에 company 테이블에 person_id 컬럼을 만들면 null 데이터는 사라집니다.

이처럼 객체 지향 관점이냐 데이터베이스 관점이냐에 따라 외래키의 위치가 달라집니다.

또한 나중에 회사를 여러 사람이 공동 소유하거나, 한 사람이 소유할 수 있는 회사가 많아지면서 일대일 관계가 다대일 관계로 확장될 수 있습니다.

어느 쪽이 옳다고 정해진 것은 없으므로 여러 가지 상황과 확장성을 고려해서 테이블 및 엔티티를 설계하는게 좋습니다.


3.2. 일대일 (1:1) 양방향

@Entity
public class House {

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

    @Column(name = "address")
    private String address;

    @OneToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "person_id")
    private Person person;
}
  • House(1) <-> Person(1)

양쪽에 @OneToOne 으로 모두 참조값을 넣어주고 mappedBy 로 연관 관계의 주인을 설정해주면 됩니다.

두 엔티티가 서로를 참조할 수 있기 때문에 객체지향 관점에서는 걱정할 것이 없고 데이터베이스 관점으로 정하거나 추후 확장성을 고려해서 연관 관계의 주인을 설정해주는게 좋습니다.

여기서는 Person 객체에서 mappedBy 가 설정되어 있고 House 는 설정하지 않았기 때문에 House 가 연관 관계의 주인이 되어 외래키를 관리합니다.


4. 다대일 (N:1)

요구사항

  • 도메인: 학교(School), 학생(Student), 선생(Teacher)
  • 한 학교에 여러 학생이 다닐 수 있다 (Student -> School)
  • 한 학교에 여러 선생이 근무할 수 있다 (Teacher -> School)
  • 학교는 선생님들의 정보를 갖고 있다 (School -> Teacher)

우선 학교라는 공통 Domain Entity 가 존재합니다.

@Entity
public class School {

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

    @OneToMany(mappedBy = "school")
    private List<Teacher> teachers = new ArrayList<>();
}

4.1. 다대일 (N:1) 단방향

@Entity
public class Student {

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

    @Column(name = "name")
    private String name;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "school_id")
    private School school;
}
  • Student(N) -> School(1)

다대일은 다(N) 쪽에 외래키가 있는 게 일반적입니다.

@ManyToOne 을 사용해서 School 필드값을 참조하면 되며, School 엔 별다른 설정을 하지 않아도 됩니다.

코드상으로는 학생은 학교의 정보가 있지만 학교 입장에서는 학생들의 정보를 직접적으로 알 수 없습니다.


4.2. 다대일 (N:1) 양방향

@Entity
public class Teacher {

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

    @Column(name = "name")
    private String name;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "school_id")
    private School school;
}
  • Teacher(N) <-> School(1)

단방향과 동일하지만 School 도메인에 @OneToMany 적용이 필요합니다.

다대일이기 때문에 참조하는 필드는 List 로 설정합니다.

학교는 teachers 를 통해 선생들의 정보를 알 수 있습니다.


5. 다대일 양방향 관계 데이터 저장

다대일 양방향 관계에서 데이터를 저장할 때는 Teacher.setSchool() 메소드와 School.getTeachers().add() 메소드를 모두 호출해서 객체끼리 동기화를 해주는 것이 중요합니다.

특히 주의할 점은 연관 관계의 주인이 데이터를 세팅해야 외래키에 값이 제대로 들어간다는 사실입니다.


5.1. 연관 관계의 주인이 아닌 일(1) 쪽에서만 세팅

School school = new School();
Teacher teacher = new Teacher();

school.getTeachers().add(teacher);  // 주인이 아닌 엔티티가 세팅

schoolRepository.save(school)
teacherRepository.save(teacher);
  • 위 코드처럼 List 에만 데이터를 넣고 저장하면 외래키가 세팅되지 않습니다.
  • school, teacher 각각 데이터는 들어가지만 teacher.school_id 값이 null 이 됩니다.

5.2. 연관 관계의 주인인 다(N) 쪽에서만 세팅

School school = new School();
Teacher teacher = new Teacher();

teacher.setSchool(school);  // 주인인 엔티티가 세팅

schoolRepository.save(school)
teacherRepository.save(teacher);
  • 위 코드를 실행하면 외래키까지 데이터가 제대로 저장됩니다.
  • 연관 관계의 주인이 외래키의 저장, 수정, 삭제를 담당하기 때문입니다.
  • 다만, 순수한 객체끼리의 데이터 동기화를 위해 school.getTeachers().add(teacher) 도 호출하는 것이 좋습니다.
    • 만약 List 에 데이터를 넣지 않으면 school.getTeachers() 를 호출해도 리스트가 비어 있습니다.
    • 물론 다른 트랜잭션에서 호출하면 DB 를 조회해서 가져오긴 하지만 같은 트랜잭션 내에서 추가로 작업을 한다면 혼란이 생길 수 있습니다.

Reference

'Framework > Spring' 카테고리의 다른 글

JPA Batch Size  (0) 2021.09.14
JPA Entity 삭제: orphanRemoval vs CascadeType.REMOVE  (0) 2021.08.28
Spring Boot 에서 Redis 사용하기  (4) 2021.08.11
Spring Boot Swagger 3.x 적용  (4) 2021.07.26
JPA 의 getById() vs findById()  (0) 2021.07.24

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

Ruby 에는 Class 를 Json 으로 표현하는 두 가지 방법이 있습니다.

as_jsonto_json 인데 이 두가지는 약간의 차이가 존재합니다.

to_json 은 String 을 반환하고 as_json 은 Hash 를 반환합니다.


Example

class User
  attr_accessor :name, :age
end

user = User.new("Alice", 22)

# to_json
puts user.to_json       # {"name":"Alice", "age":22}
puts user.to_json.class # String

# as_json
puts user.as_json       # {"name"=>"Alice", "age"=>22}
puts user.as_json.class # Hash

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

+ Recent posts