본문 바로가기

개발노트/JAVA

[JPA/Hibernate] When using custom class as a field of entity along with JPA (Hibernate)

아래와 같은 Entity 클래스가 있다.

 

이 클래스는 `ClientCustomConfig` 라는 내가 정의한 타입을 인스턴스 변수로 쓰고 있다.

이런 경우, 주의해야할 점이 있어서 기록을 한다.

Entity Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Getter
@Entity
@Table(name = "`client`")
@EntityListeners(AuditingEntityListener.class)
public class Client implements Serializable {
 
    private static final long serialVersionUID = 201803260020190325L;
 
    @Id
    @GeneratedValue
    @Column(name = "no")
    private Long no;
 
    @Column(name = "name", length = 256)
    private String name;
 
    @Column(name = "email", length = 191)
    private String email;
 
    @Column(name = "password", length = 256)
    private String password;
 
    @Setter
    @Convert(converter = ClientCustomConfigConverter.class)
    @Column(name = "custom_config")
    private ClientCustomConfig customConfig;
 
    public ClientCustomConfig getCustomConfig() {
        if (null == customConfig) {
            customConfig = new ClientCustomConfig();
        }
        return customConfig;
    }
 
    public void addTab(ClientCustomConfig.Tab tab) {
        customConfig.addTab(tab);
    }
 
    Client() {}
 
    private Client(long no) {
        this.no = no;
    }
 
    public static Client create(ClientDto.CreateRequest request) {
        Client client = new Client();
        client.name = request.getName();
        client.email = request.getEmail();
 
        return client;
    }
 
    static Client fromClientNo(long clientNo) {
        return new Client(clientNo);
    }
 
}

 

`@Convert` 의 converter 로 사용하고 있는 `ClientCustomConfigConverter` 클래스는 아래와 같이 작성.

 

ClientCustomConfigConverter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Converter
public class ClientCustomConfigConverter implements AttributeConverter<ClientCustomConfig, String> {
 
    private static final ObjectMapper mapper = new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    private static final Logger logger = LoggerFactory.getLogger(ClientCustomConfigConverter.class);
 
    @Override
    public String convertToDatabaseColumn(ClientCustomConfig attribute) {
        if (attribute == null) {
            attribute = new ClientCustomConfig();
        }
        try {
            String json = mapper.writeValueAsString(attribute);
            logger.debug(json);
            return json;
        catch (JsonProcessingException e) {
            logger.error("Object converting to databaseColumn failed. Object: {}", attribute);
            throw new RuntimeException(e);
        }
    }
 
    @Override
    public ClientCustomConfig convertToEntityAttribute(String json) {
        if (json == null) {
            return new ClientCustomConfig();
        }
        try {
            ClientCustomConfig config = mapper.readValue(json, new TypeReference<ClientCustomConfig>(){});
            logger.debug(config.toString());
            return config;
        catch (IOException e) {
            logger.error("json string(databaseColumn) converting to object failed. json: {}", json);
            throw new RuntimeException(e);
        }
    }
 
}
ClientCustomConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
 * To avoid dirty checking from hibernate which uses equals() and hashCode(), annotated @Data including overriding
 * those methods.
 */
@Data
public class ClientCustomConfig implements Serializable {
    private static final long serialVersionUID = 201805300020190529L;
 
    private List<Tab> tabs = new ArrayList<>();
    private List<Tag> tags = new ArrayList<>();
 
    public void addTab(Tab tab) {
        tabs.sort(ClientCustomConfig.Tab::compareTo);
        tab.setRank(tabs.size());
        tabs.add(tab);
    }
 
    public enum TabType {
        PROJECT, CUSTOM
    }
 
    @Data
    public static class Tab implements Comparable<Tab>, Serializable {
        private static final long serialVersionUID = 201805300020190529L;
 
        private Integer rank;
        private String title;
        private String color;
        @Enumerated(EnumType.STRING)
        private TabType tabType;
        private Long projectNo;
        private LinkedHashSet<Long> surveys = new LinkedHashSet<>();
 
        public LinkedHashSet<Long> getSurveys() {
            if (null == surveys) {
                surveys = new LinkedHashSet<>();
            }
            return new LinkedHashSet<>(surveys);
        }
 
        @Override
        public int compareTo(Tab o) {
            return rank.compareTo(o.rank);
        }
    }
 
    @Data
    public static class Tag implements Comparable<Tag>, Serializable {
        private static final long serialVersionUID = 201805300020190529L;
 
        private Integer rank;
        private String memo;
 
        @Override
        public int compareTo(Tag o) {
            return rank.compareTo(o.rank);
        }
 
    }
}

 

AttributeConverter 인터페이스를 구현한 클래스인데, 이것과 동등하게 hibernate 에서 제공하는 UserType 인터페이스를 구현하는 UserType 으로도 구현할 수 있다.

확인해본 결과, 두 개의 동작 방식은 같았다. 이왕이면 코드가 간결한 AttributeConverter 를 작성하는 것이 더 읽기 쉬울 것 같다.

 

여튼, Spring Data 의 JPA 기본 구현체인 hibernate 는 Entity 가 load 될 때마다 일정 매커니즘을 따라 dirty checking 을 수행하고, 메모리에 적재한 엔티티와 DB에서 fetch해서 가져온 엔티티 클래스가 동등한지(equality) 비교를 하여 동등하지 않을 경우 즉각 update 구문을 실행하여 DB의 엔티티와 메모리의 엔티티의 sync를 맞추는 일련의 작업이 포함되어 있다.

 

동등성을 비교하기 위해 수행하는 메소드는 .equals(Object o) 이다. equals()의 결과가 true 이면, 동등하기 때문에 update 문을 수행하지 않고, equals()의 결과가 false이면, sync를 맞추기 위해 memory에 있는 Entity를 기준으로 DB 의 Entity 를 덮어쓰기 위한 update 구문을 실행하도록 되어있다.

 

그래서 CustomClientConfig 클래스에 적절한 equals() 메소드(hashCode() 메소드도)를 오버라이딩 하지 않으면 실제 Entity 변경을 하지도 않았는데 select 할 때마다 update 가 실행될 수 있다.

 

가장 간단하게는 Lombok project의 `@EqualsAndHashCode` 어노테이션을 사용하면 Object.equals(Object o) 의 계약에 따라 잘 구현해주긴 한다.

 

Select 할 땐 그게 주의 사항이고.

 

의도적으로 엔티티 변경을 통해 Update 를 수행하고자 할 때, `@Test` `@Transactional` 때문에 Update가 수행되지 않는 것도 경험하였다.

 

이건 Test 시,  `@Transactional` 인 경우, repository.save(Entity S) 를 수행할 때, 실제 DB에 insert, update 를 하지 않고 메모리 상에서만 영속화하여 발생하는 현상으로 추정하는데 정확한 건 모르겠다. 다른 @Test + @Transactional 에서는 insert, update 가 로그로도 잘 남기 때문.

(로그 : Rolled back transaction for test context [DefaultTestContext@4445629 testCla..... )

 

`@Transactional` 을 빼면 DB에 값이 반영되어 있는 것을 확인할 수 있었다. 아마도 @Converter(AttributeConverter) 가 연관되어 있을 수도 있을 것 같다.