본문 바로가기

스터디/스프링

Spring Data JDBC - 엔티티가 지원하는 타입편

2. 엔티티에서 사용할수 있는 변수타입 (1.1.7 RELEASE)

기본적으로 원시타입과 그 참조타입은 모두 사용 가능합니다.
Enum또한 사용가능하며 Enum의 name이 저장되게 됩니다. (VARCHAR 컬럼만 사용해야합니다.)

  CREATE TABLE ENUM_ENTITY
  (
      id     BIGINT auto_increment,
      active varchar(255),
      primary key (id) 
  )
  public class EnumEntity {
      @Id
      private Long id;

      private Active active;

      public EnumEntity() {
      }

      public EnumEntity(final Active active) {
          this.active = active;
      }

      public Long getId() {
          return id;
      }

      public Active getActive() {
          return active;
      }
  }

  public enum Active {
      Y(true),
      N(false);

      private final boolean value;

      Active(final boolean value) {
          this.value = value;
      }

      public boolean value() {
          return value;
      }
  }


  public interface EnumEntityRepository extends CrudRepository<EnumEntity, Long> {
      @Query("SELECT * FROM ENUM_ENTITY WHERE active = :active")
      List<EnumEntity> findAllByActive(@Param("active") String active);
  }

아래 테스트는 통과합니다.

아쉽게도 Enum으로 조회는 안되서 name()을 이용해 조회해야 합니다.

      @DisplayName("enum을 필드로 사용하고 저장, 꺼낼수 있다.")
      @Test
      void save() {
          //given
          EnumEntity enumEntity = new EnumEntity(Active.Y);
          EnumEntity save = enumEntityRepository.save(enumEntity);

          //when
          EnumEntity entity = enumEntityRepository.findById(save.getId()).orElseThrow(NoSuchElementException::new);

          //then
          assertThat(entity.getActive()).isEqualTo(Active.Y);
      }

      @DisplayName("Enum의 name으로 저장되어 있다.")
      @Test
      void load() {
          //given
          EnumEntity enumEntity = new EnumEntity(Active.Y);
          EnumEntity save = enumEntityRepository.save(enumEntity);

          //when
          List<EnumEntity> allByActive = enumEntityRepository.findAllByActive(Active.Y.name());

          //then
          assertThat(allByActive).hasSize(1);
      }

java.util.Date, java.time.LocalDate, java.time.LocalDateTime, 그리고 java.time.LocalTime이 사용가능합니다.

사용하는 데이터베이스가 지원을 한다면 위에 언급한 타입들의 배열 혹은 Collection 타입을 사용할 수 도 있습니다.

1 : 1 관계 (OneToOne)

다른 entity를 참조하는 경우, 1:1, 1:N 연관 관계 혹은 embedded 타입인 경우가 있습니다.
1:1, 1:N 연관 관계인 경우 참조 되는 엔티티의 id는 선택사항(optional)입니다.
참조의 대상이 되는 엔티티는 자신을 참조하는 엔티티의 테이블의 이름과 같은 이름의 Column이름을 가집니다.

CREATE TABLE SUPER_ONE
(
    id         BIGINT auto_increment,
    super_name varchar(255),
    primary key (id)
);

CREATE TABLE SUB_ONE
(
    id        BIGINT auto_increment,
    super_one BIGINT,
    sub_name  varchar(255),
    primary key (id)
);

ALTER TABLE SUB_ONE
    ADD FOREIGN KEY (super_one)
        REFERENCES SUB_ONE (id);
public class SuperOne {
    @Id
    private Long id;
    private String superName;
    private SubOne subOne;

    private SuperOne() {
    }

    public SuperOne(final String superName, final SubOne subOne) {
        this.superName = superName;
        this.subOne = subOne;
    }

    public Long getId() {
        return id;
    }

    public SubOne getSubOne() {
        return subOne;
    }
}

public class SubOne {
    // Embedded가 아닌 참조 엔티티는 id가 필요하다.
    @Id
    private Long id;
    // 선언할 필요없다. 자신을 참조하는 엔티티의 테이블 명으로 컬럼이 필요하도록 기본전략이 걸려있다.
    // private Long superOne;
    private String subName;

    private SubOne() {
    }

    public SubOne(final String subName) {
        this.subName = subName;
    }

    public Long getId() {
        return id;
    }
}

Embedded를 이용한 VO 표현하기

하나의 테이블에서 파생된 embedded 엔티티의 경우 당연하게도 id 필드는 사용할 수 없습니다.
같은 테이블에서 몇가지 컬럼을 모아 embedded를 이용해 표현하였기 때문입니다.

아래 예시에서는 MEMBER의 일부분인 first_namelast_nameembedded 엔티티로 표현하고 있습니다.

  CREATE TABLE MEMBER
  (
      id BIGINT auto_increment,
      first_name varchar(255),
      last_name  varchar(255),
      primary key (id)
  );
  public class Member {
      @Id
      private Long id;

      @Embedded.Nullable // Embedded라고 선언해야한다.
      private Name name;

      private Member() {
    }

      public Member(final Name name) {
          this.name = name;
      }

      public Name getName() {
          return name;
      }
  }

  /**
   * embedded Type 이다.
   * VO의 성격을 띈다.
   */
  public class Name {
      // id 필드가 존재해선 안된다.    
      private String firstName;
      private String lastName;

      private Name() {
      }

      public Name(final String firstName, final String lastName) {
          this.firstName = firstName;
          this.lastName = lastName;
      }

      public String getFirstName() {
          return firstName;
      }

      public String getLastName() {
          return lastName;
      }
  }

1 : N 관계 (OneToMany)

Set<Entity> 는 1 : n 의 연관관계를 나타냅니다.
마찬가지로 참조되는 객체는 참조하는 객체의 테이블 이름으로 된 Column을 기본적으로 가집니다.

  CREATE TABLE SET_SINGLE
  (
      id BIGINT auto_increment,
      primary key (id)
  );

  CREATE TABLE SET_MANY
  (
      id         BIGINT auto_increment,
      set_single BIGINT,
      many_name  varchar(255),
      primary key (id)
  );

  ALTER TABLE SET_MANY
      ADD FOREIGN KEY (set_single)
          REFERENCES SET_SINGLE (id);
  public class SetSingle {
      @Id
      private Long id;

      private Set<SetMany> manies;

      private SetSingle() {
      }

      public SetSingle(final Set<SetMany> manies) {
          this.manies = manies;
      }

      public Long getId() {
          return id;
      }

      public Set<SetMany> getManies() {
          return manies;
      }
  }

  public class SetMany {
      @Id
      private Long id;
      // 아래 이름으로 된 외래키를 기대한다. 주석처리 하고 사용하여도 무방하다.
      private Long setSingle;
      private String manyName;

      public SetMany() {
      }

      public SetMany(final String manyName) {
          this.manyName = manyName;
      }

      public Long getSetSingle() {
          return setSingle;
      }
  }

Map<some type, entity>List<entity>는 두개의 추가 Column이 필요합니다.
fk의 대상이 되는 Column의 이름으로 [참조하는 테이블의 이름]이 필요합니다.
그리고 Map과 List의 Key로 사용될 Column으로 [참조하는 테이블 이름]_KEY 로 되어있는 Column이 필요합니다.

예제. Map

  CREATE TABLE MAP_SINGLE
  (
      id BIGINT auto_increment,
      primary key (id)
  );

  CREATE TABLE MAP_MANY
  (
      id         BIGINT auto_increment,
      map_single BIGINT,
      map_single_key    varchar(255),
      content    varchar(255),
      primary key (id)
  );

  ALTER TABLE MAP_MANY
      ADD FOREIGN KEY (map_single)
          REFERENCES MAP_SINGLE (id);
  public class MapSingle {
      @Id
      private Long id;

      private Map<String, MapMany> mapManyMap;

      public MapSingle() {
      }

      public MapSingle(final Map<String, MapMany> mapManyMap) {
          this.mapManyMap = mapManyMap;
      }

      public Map<String, MapMany> getMapManyMap() {
          return mapManyMap;
      }
  }

  public class MapMany {
      @Id
      private Long id;

      // 참조하는 객체의 이름으로 된 외래키를 기본적으로 기대한다. 기본적으로 database에 동일한 이름의 Column이 존재한다면 주석처리해도 무방하다.
      private Long mapSingle;

          // suffix 로 [참조하는테이블이름]_KEY 형태의 컬럼을 Map의 key 사용하기 위해 필요하다. 기본적으로 database에 동일한 이름의 Column이 존재한다면 주석처리해도 무방하다.
      private String mapSingleKey;

      private String content;

      public MapMany() {
      }

      public MapMany(final String content) {
          this.content = content;
      }

      public String getContent() {
          return content;
      }
  }

List 의 경우 Map<Integer, Entity> 처럼 동작한다고 보면됩니다.

이 경우 key 로 사용되는 컬럼이 List의 순서를 보장해줍니다.

  CREATE TABLE LIST_SINGLE
  (
      id BIGINT auto_increment,
      primary key (id)
  );

  CREATE TABLE LIST_MANY
  (
      id              BIGINT auto_increment,
      list_single     BIGINT,
      list_single_key varchar(255),
      content         varchar(255),
      primary key (id)
  );

  ALTER TABLE LIST_MANY
      ADD FOREIGN KEY (list_single)
          REFERENCES LIST_SINGLE (id);
  public class ListSingle {
      @Id
      private Long id;

      private List<ListMany> listManyList;

      public ListSingle() {
      }

      public ListSingle(final List<ListMany> listManyList) {
          this.listManyList = listManyList;
      }

      public Long getId() {
          return id;
      }

      public List<ListMany> getListManyList() {
          return listManyList;
      }
  }

  public class ListMany {
      @Id
      private Long id;

      // 기본적으로 database에 동일한 이름의 Column이 존재한다면 주석처리해도 무방하다.
      private Long listSingle;

      // 기본적으로 database에 동일한 이름의 Column이 존재한다면 주석처리해도 무방하다.
      private Long listSingleKey;

      public ListMany() {
      }
  }

Embedded를 이용한 일급컬렉션 표현

단일관계가 아닌 OneToMany와 같은 상황에서 Embedded 를 이용해서 일급컬렉션을 표현할 수 있습니다.

  CREATE TABLE RACING_GAME
  (
      id BIGINT AUTO_INCREMENT,
      primary key (id)
  );

  CREATE TABLE RACING_CAR
  (
      id          BIGINT AUTO_INCREMENT,
      racing_game BIGINT,
      car_name    VARCHAR(255),
      primary key (id)
  );

  ALTER TABLE RACING_CAR
      ADD FOREIGN KEY (racing_game)
          REFERENCES RACING_GAME (id);
  public class RacingGame {
      @Id
      private Long id;

      @Embedded.Nullable
      private RacingCars racingCars;

      private RacingGame() {
      }

      public RacingGame(final RacingCars racingCars) {
          this.racingCars = racingCars;
      }

      public Long getId() {
          return id;
      }

      public Set<RacingCar> getRacingCars() {
          return racingCars.getRacingCars();
      }
  }

  //일급컬렉션 객체
  public class RacingCars {
      private Set<RacingCar> racingCars;

      public RacingCars(final Set<RacingCar> racingCars) {
          this.racingCars = racingCars;
      }

      public Set<RacingCar> getRacingCars() {
          return new HashSet<>(racingCars);
      }
  }

  public class RacingCar {
      @Id
      private Long id;
      private String carName;

      private RacingCar() {
      }

      public RacingCar(final String carName) {
          this.carName = carName;
      }

      public Long getId() {
          return id;
      }

      public String getCarName() {
          return carName;
      }
  }

일대다 연관관계에서 Collection을 사용 할 때

데이터베이스에 Spring Data JDBC가 지정한대로 Column 이름이 저장되어 있으면 그리 어렵지 않게 사용 할 수 있습니다.

그런데 참조키 Column이름을 [참조하는테이블이름]으로 짓기보단 id가 포함된게 더 보기 좋은것 같습니다.
Column이름과 기본전략이 상이한 경우 다음과 같이 사용할 수 있습니다.

@MappedCollection를 이용하여 문제를 해결할 수 있습니다.

일대다 연관관계에서 @CreatedDate, @LastModifiedDate 사용하기

JPA와 마찬가지로 Spring Data JDBC 도 Auditing을 제공합니다.
따라서 엔티티의 생성시각과 수정시각을 @CreateDate@LastModifiedDate를 이용해서 자동으로 관리해 줄 수 있습니다.

생성일과 수정일로 관리할 컬럼에 아래와 같이 어노테이션을 작성합니다.

public class Article {
    @Id
    private Long id;

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @MappedCollection(idColumn = "article_id", keyColumn = "article_key")
    private List<Comment> comments;

    private Article() {
    }

    public Article(final List<Comment> comments) {
        this.comments = comments;
    }

    public Long getId() {
        return id;
    }

    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public LocalDateTime getUpdatedAt() {
        return updatedAt;
    }

    public List<Comment> getComments() {
        return comments;
    }

}

그 다음 @EnableJdbcAuditing을 이용해서 Audit 기능을 활성화시켜주면 끝입니다.

@EnableJdbcAuditing
@Configuration
public class ApplicationListenerConfiguration {

}

아래는 테스트 코드입니다.

@SpringBootTest
@ExtendWith(SpringExtension.class)
class ArticleTest {

    @Autowired
    private ArticleRepository articleRepository;

    @Autowired
    private CommentRepository commentRepository;

    @AfterEach
    void tearDown() {
        commentRepository.deleteAll();
        articleRepository.deleteAll();
    }

    @DisplayName("컬럼기본전략과 데이터베이스의 컬럼이 상이한 경우에도 가능하다")
    @Test
    void save() {
        //given
        Comment comment1 = new Comment("asdf");
        Comment comment2 = new Comment("qwer");
        Comment comment3 = new Comment("zxcv");

        Article article1 = new Article(Arrays.asList(comment1, comment2, comment3));

        //when
        Article save = articleRepository.save(article1);
        Article load = articleRepository.findById(save.getId()).orElseThrow(NoSuchElementException::new);

        //then
        System.out.println(save.getCreatedAt());
        System.out.println(load.getCreatedAt());
        assertThat(save.getCreatedAt().equals(load.getCreatedAt()));
        assertThat(load.getComments()).hasSize(3);
    }
}

표준출력으로 출력한 두줄이 있는데요.
이 두줄을 확인하면 다른 점이 있습니다.

image

코드상에서 자동으로 생성된 LocalDateTime과 DB에서 조회한 LocalDateTime이 조금 다른 것을 확인할 수 있습니다.
DB에는 시간 데이터가 조금 절삭되어 저장이 되기때문에 윈도우 환경에서는 위 테스트가 실패할 수 있습니다.

이때 자식 엔티티에게도 생성일과 수정일을 관리하고 싶어서 @CreatedDate@LastModifiedDate를 사용 할 수 있는데요.
Spring Data JDBC의 컨셉상 자식 엔티티에서는 Auditing기능을 사용할 수 없었습니다.

public class Comment {
    @Id
    private Long id;

    private Long articleId;

    private String content;

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    public Comment() {
    }

    public Comment(final String content) {
        this.articleId = articleId;
        this.content = content;
    }

   // ... getter

}

위와 같이 Comment 엔티티를 만들고 아래 테스트 코드를 실행해보면 자식인 Comment 엔티티에겐 Audit 정보가 들어가지 않는 것을 확인 할 수 있습니다.

    @DisplayName("자식 Entity는 Audit 기능을 사용할 수 없다.")
    @Test
    void save2() {
        //given
        Comment comment1 = new Comment("1");
        Comment comment2 = new Comment("2");

        Article article = new Article(Arrays.asList(comment1, comment2));

        //when
        article = articleRepository.save(article);

        //then
        assertThat(article.getComments().get(0).getCreatedAt()).isNull();
        assertThat(article.getComments().get(0).getUpdatedAt()).isNull();
        System.out.println(gson.toJson(article));
    }

image

출력결과에서도 Audit 정보가 null이라 출력되지 않는 것을 볼 수 있습니다.

Spring Data JDBC에서 자식 엔티티는 부모 엔티티의 생명주기를 그대로 따라가도록 설계가 되어있습니다.

따라서 부모 엔티티에 새로운 자식 엔티티를 추가한다면 INSERT 쿼리가 한번 발생하는 것이 아니라 부모에게 속해있던 자식 엔티티를 전부 DELETE 후 다시 전부 INSERT하는 쿼리가 발생하는 것을 확인 할 수 있습니다.

application.yml에 쿼리 확인을 위해 다음 설정값을 추가하여 확인해 보도록 하겠습니다.

logging:
  level:
    org:
      springframework:
        jdbc:
          core:
            JdbcTemplate: debug

쿼리를 확인하기 위해 간단히 자식 엔티티를 추가후 잘 추가되었는지 확인하는 테스트를 작성해 보겠습니다.

    @DisplayName("자식 Entity가 추가될때 전부 DELETE 후 전부 INSERT를 진행한다. ")
    @Test
    void save3() {
        //given
        Comment comment1 = new Comment("1");
        Comment comment2 = new Comment("2");

        Article article = new Article(new ArrayList<>(Arrays.asList(comment1, comment2)));
        article = articleRepository.save(article);

        //when
        System.out.println("\n##################\n");
        Comment comment3 = new Comment("3");
        article.addComment(comment3);

        article = articleRepository.save(article);

        //then
        List<Comment> comments = article.getComments();
        assertThat(comments).hasSize(3);
    }

테스트는 정상적으로 잘 작동하는 것을 확인 할 수 있고 이제 디버그 로그를 통해 쿼리가 어떻게 발생했는지 확인해 보겠습니다.

image

### 을 기준으로 아래로 발생한 쿼리를 확인하시면 됩니다.

로그를 보면 알수 있듯

[DELETE FROM comment WHERE comment.article_id = ?]

DELETE 쿼리가 먼저 실행된 후

[INSERT INTO comment (article_id, content, created_at, updated_at, id, article_key) VALUES (?, ?, ?, ?, ?, ?)]

INSERT 쿼리가 3번 발생하는 것을 확인할 수 있습니다.

따라서 자식 엔티티에서 @CreatedDate@LastModifiedDate의 Audit은 사용할 수 가 없고 자식 엔티티의 생성 시간은 부모 엔티티의 @LastModifiedDate 와 동일하게 여기면 됩니다.

이렇게 전부 DELETE 후 INSERT 한다는 내용은 공식문서에 다음과 같이 기재되어있습니다.

If the aggregate root is not new, all referenced entities get deleted, the aggregate root gets updated, and all referenced entities get inserted again. Note that whether an instance is new is part of the instance’s state.

그리고 바로 다음 다음과 같이 이 부분에 대해 설명하는 글 이 적혀있습니다.

This approach has some obvious downsides. If only few of the referenced entities have been actually changed, the deletion and insertion is wasteful. While this process could and probably will be improved, there are certain limitations to what Spring Data JDBC can offer. It does not know the previous state of an aggregate. So any update process always has to take whatever it finds in the database and make sure it converts it to whatever is the state of the entity passed to the save method.

분명한 단점이 있고 Spring Data JDBC가 이 부분에 대해 개선 할 수도 있다고 되어있습니다.
현재 이렇게 구현되어 있는 이유는 Spring Data JDBC는 저장되어 있던 이전 상태들을 알지 못해서 위와 같이 동작한다고 되어있습니다.
결국 UPDATE 를 하고 싶다면 해당 엔티티를 조회후 수정한다음 save() 메서드를 통해 업데이트를 해야 합니다.

따라서 아래와 같이 Comment의 내용을 수정하는 테스트를 작성한다면

    @DisplayName("Update 하려면 조회후 save 해야한다.")
    @Test
    void save4() {
        //given
        Comment comment1 = new Comment("1");
        Comment comment2 = new Comment("2");

        Article article = new Article(new ArrayList<>(Arrays.asList(comment1, comment2)));
        article = articleRepository.save(article);

        //when
        Comment comment = commentRepository.findById(1L).orElseThrow(RuntimeException::new);
        comment.updateContent("22");
        Comment save = commentRepository.save(comment);

        //then
        assertThat(save.getCreatedAt()).isNull();
        assertThat(save.getUpdatedAt()).isNotNull();
    }

정상적으로 UPDATE 쿼리가 발생하는 것을 확인할 수 있습니다.

image

또한 @LastModifedDate 의 Audit이 적용되는 것을 볼 수 있습니다.