JPA 기초 7 - 값 타입

JPA 기초 7 - 값 타입

객체지향적 기법인 값 타입에 대해 학습합니다

 

📕 값 타입


값 타입은 엔티티 클래스의 필드를 추상화하는 기법이다.

먼저 값 타입에 대해 알아보기 전에 값 타입을 쓰는 이유를 먼저 알아보자.

정보처리기사 데이터베이스 과목에서 정규화는 굉장히 비중있게 나오는 내용이지만, 실제 현업에서는 일반적으로 정규화를 잘 하지 않는 것 같다.

 

이유가 뭘까?

 

정규화를 하는 가장 목적은 이상현상을 최소화하는 것일 것이다.

쉽게 말하면 중복 데이터를 최대한 줄여서 데이터가 꼬이지 않게, 데이터 일관성을 유지하고자 하는 일련의 작업이다.

그리고 부가적인 장점으로 중복 데이터가 줄어들게 되어 데이터의 저장용량이 최적화된다는 점이 있다.

 

🤔 … 조금 더 쉽게 이해할 수 있게 예를 들어보자. (예시가 어거지일 수 있습니다. 양해바랍니다.)

학교에 대한 모델링을 한다고 했을 때 학생에 대한 테이블을 만들어야 할 것이다.

학생 테이블에는 학번, 이름, 나이, 전공 컬럼을 만들었다고 가정하자.

 

학번이름나이전공
20131212홍길동20컴퓨터과학
20131213아무개20컴퓨터과학

 

그렇게 서비스를 잘 운영하던 중 갑자기 휴학생 테이블이 필요해졌고, 즉시 테이블을 만들었다고 가정하자.

 

학번이름나이전공사유기간
20131212홍길동20컴퓨터과학군대20151212

 

이렇게 홍길동의 데이터는 두개의 테이블에 각각 나누어 입력돼버렸다.

두 테이블에 중복되는 데이터는 학번, 이름, 나이, 전공인 상태이다.

 

이 상태에서 만약 둘 중 하나의 테이블에서 홍길동의 데이터가 업데이트 된다면, 데이터의 무결성을 보장해주기 위해 반대쪽 테이블에도 똑같이 업데이트를 쳐줘야만 할 것이다.

이런 작업들이 이어지다보면 반드시 휴먼에러로 인한 실수가 발생할 것이고, 이는 데이터가 꼬여버리는 결과를 만들게 될 것이다.

 

따라서 이렇게 중복되는 데이터들을 추출하여 별도의 테이블로 만들고, 재사용하게 만들면 이러한 번거로운 작업들을 피할 수 있게 되며, 데이터를 안전하게 핸들링할 수 있을 것이다.

 

정규화가 마냥 장점만 있을 것 같지만, 웹 개발 분야에서는 정규화를 하게되면 생기는 단점이 생각보다 만만찮다.

 

알고리즘자료구조에 대한 공부를 하다보면 시간복잡도공간복잡도라는 개념이 나온다.

시간복잡도와 공간복잡도는 반비례한다.

메모리를 많이 사용할수록 더욱 더 효율적인 연산을 할 수 있게 되기 때문이다.

하지만 현대에 들어 하드웨어의 엄청난 발달과 함께 공간복잡도의 중요성이 미미해진 반면, 시간복잡도의 중요성은 여전하다.

왜냐하면 이제는 메모리의 부족을 느낄 상황 자체가 매우 희박하기 때문이다.

또한 시간은 우주적인 관점에서 봐도 절대적인 가치로, 시간은 그 어떤것을 주고도 살 수 없지만, 메모리는 돈주고 사서 증설할 수 있다. 한마디로 시간은 대체불가의 자원이지만, 메모리는 값싼 자원이다.

 

인터넷 사용자들의 행위를 분석하여 통계를 내보니, 읽기(read)쓰기(write)의 비율이 약 8:2 혹은 7:3 정도로 읽기의 비율이 훨씬 더 높았다고 한다.

그리고, 방문자 행동 분석시 사용자가 페이지 로딩과도한 시간을 기다릴 경우 페이지 이탈율이 급속도로 증가한다는 연구결과도 있다.

즉, 사용자를 웹 사이트에 오래 머무르게 하는 것이 회사의 매출에 직접적인 영향을 끼친다는 뜻이다.

그러니까 웹 개발에서는 읽기쓰기보다 더 중요한 위치를 갖는다고 볼 수 있을 것 같다.

 

문제는 정규화를 해서 테이블이 잘게 쪼개지는 만큼 조인(join)을 해야하며, 많아지는 조인은 데이터베이스에 부하를 발생시킨다.

또한, 조인으로 인해 트랜잭션로킹 단위가 커지게 되고, 이는 데이터베이스의 공유도를 감소시켜 동시성 성능이 떨어지게 만든다.

결과적으로 이는 질의(query)에 대한 응답속도가 느려짐을 뜻하며, 고객이 느끼는 응답시간이 늘어남을 뜻하기도 한다.

 

정리하자면, 정규화를 시행했을 때 기대되는 이득은 중복 데이터 제거, 저장용량의 최적화, 이상현상의 최소화라고 볼 수 있고, 이러한 이득을 얻기 위해 동시성을 포기해야만 한다. 이는 곧 시간적인 손실(=성능)이다.

문제는 최근 트렌드는 저장용량을 크게 신경쓰지 않으면서 최대한 효율적인 연산을 하는 것이기 때문에, 정규화의 장점 중 저장용량의 최적화는 큰 장점이 되지 못하며, 유의미한 장점이라고 볼 수 있는 것은 결국 이상현상의 최소화이다.

즉, 데이터의 무결성속도라는, 서로간에 장단점이 실로 명확하여 상황에 따라 정규화 혹은 반정규화를 진행해야 하는 trade-off가 있다고 볼 수 있다.

 

결국 서비스를 운영하는 회사 입장에서 고객이 느끼는 응답속도의 저하는 굉장히 커다란 이슈임과 동시에,

운영 데이터베이스에 많은 부하가 걸린다는 것은 전체 서비스의 다운이라는 최악의 상황을 초래할수도 있기 때문에 결과적으로 지나친 정규화를 하지 않는 쪽으로 가는 것 같다.

 

“그래서 값 타입 이야기 하다 말고 뜬금없이 왜 정규화 타령이냐?” 라는 생각이 들수도 있다.

 

위의 이유로 인해 실무에서는 지나친 정규화를 하지 않음으로써 테이블들의 컬럼이 수십개 이상인 경우가 굉장히 많다.

이를 엔티티 클래스와 매핑하게 되면 테이블의 컬럼과 엔티티 클래스의 필드는 일대일로 매핑되므로

엔티티 클래스의 필드도 수십개 이상이 되는 경우가 굉장히 흔하다.

 

문제는 ORM을 실용적으로(=편리하게) 사용할 경우 엔티티 클래스가 PM(Persistence Model)DM(Domain Model)의 역할을 같이하게 된다는 것에서 발생한다.

이러면 서비스 레이어(Service Layer)에는 코드가 별로 없고, 도메인 코드가 엔티티 클래스안에 들어있기 때문에 서비스 클래스는 엔티티들을 한데 모아 하나의 트랜잭션(Transaction)으로 묶어줌과 동시에 흐름제어를 하는 역할을 맡게 되는 경우가 많다.

 

이에 대한 자세한 예제가 궁금하다면 깃허브를 참고하시기 바랍니다.

 

이러한 이유로 안그래도 엔티티 클래스에는 굉장히 많은 필드가 들어있는데, 도메인 코드마저 몰려있다면 엔티티 클래스 내부의 복잡도는 상상을 초월하게 커질수 있다.

그래서 이 경우 엔티티 내부 필드들의 공통점을 찾아 별도의 클래스로 뽑아내는 작업을 하게된다.

이 때 값 타입을 사용하게 된다고 이해하면 될 것 같다.

 

사용 방법은 매우 간단하다.

 

// file: 'Customer.java'
@Entity
@Getter
@ToString(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class Customer extends BaseEntity {
    @NonNull
    private String firstName;

    @NonNull
    private String lastName;

    @NonNull
    private String phoneNumber;

    @NonNull
    private String city;

    @NonNull
    private String street;

    @NonNull
    private String zipcode;

    @Builder
    public static Customer of(@NonNull String firstName,
                              @NonNull String lastName,
                              @NonNull String phoneNumber,
                              @NonNull String city,
                              @NonNull String street,
                              @NonNull String zipcode) {
        return new Customer(firstName, lastName, phoneNumber, city, street, zipcode);
    }

    public String getName() {
        return firstName + " " + lastName;
    }

    public String getAddress() {
        return city + " " + street + " " + zipcode;
    }
}

 

커머스 사이트에서 사용할만한 예제 클래스를 작성했다.

고객에 대한 필수 정보라고 할만한 것들만 넣었음에도 벌써 코드가 꽤 길어져있다.

단순 예제조차 이정도의 길이인데, 실무에서는 얼마나 더 길지 상상해보면 될 것 같다.

그럼 이제 Customer를 값 타입을 이용해서 리팩토링해보겠다.

 

// file: 'Customer.java'
@Entity
@Getter
@ToString(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class Customer extends BaseEntity {
    @NonNull
    private Name name;

    @NonNull
    private String phoneNumber;

    @NonNull
    private Address address;

    @Builder
    public static Customer of(@NonNull Name name, @NonNull String phoneNumber, @NonNull Address address) {
        return new Customer(name, phoneNumber, address);
    }

    public void changeAddress(String address) {
        this.address.changeAddress(address);
    }
}

 

// file: 'Address.java'
@Getter
@ToString
@Embeddable
@EqualsAndHashCode
@Access(AccessType.FIELD)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class Address {
    @NonNull
    private String city;

    @NonNull
    private String street;

    @NonNull
    private String zipcode;

    @Builder
    public static Address of(@NonNull String city, @NonNull String street, @NonNull String zipcode) {
        return new Address(city, street, zipcode);
    }

    public String getAddress() {
        return city + " " + street + " " + zipcode;
    }

    public void changeAddress(String address) {
        String[] s = address.split(" ");
        if(s.length != 3) {
            throw new IllegalArgumentException("입력 주소가 올바르지 않습니다.");
        }
        this.city = s[0];
        this.street = s[1];
        this.zipcode = s[2];
    }
}

 

// file: 'Name.java'
@Getter
@ToString
@Embeddable // 값 타입임을 선언
@EqualsAndHashCode
@Access(AccessType.FIELD) // JPA가 접근자 매핑으로 오해하므로 필드 매핑임을 명시하여 컴파일 에러 방지
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class Name {
    @NonNull
    private String firstName;

    @NonNull
    private String lastName;

    @Builder
    public static Name of(@NonNull String firstName, @NonNull String lastName) {
        return new Name(firstName, lastName);
    }

    public String getName() {
        return firstName + " " + lastName;
    }
}

 

이렇게 값 타입으로 사용할 클래스에 @Embeddable을 선언하여 JPA에 값 타입임을 알려주고, 이 클래스를 엔티티 클래스에 포함시켜 사용하면 된다.

책에서는 엔티티 클래스에서 값 타입을 사용할 때 @Embedded를 선언하도록 적혀있지만 이는 생략해도 정상적으로 작동하기 때문에 작성하지 않았다.

이렇게 사용함으로써 얻는 한가지 더 큰 이점은, 값 타입은 프로젝트 전체 엔티티에서 재사용 할 수 있다는 점이다.

@MappedSuperclass@Embeddable을 적절하게 사용하면 굉장히 많은 부분을 추상화하여 재사용하도록 변경할 수 있다.

이는 명백히 객체지향적인 접근 방법이기도 하기 때문에 ORM을 사용하는 관점에서 매우 권장할만한 방법이라고 볼 수 있다고 생각한다.

 

💥 주의점


어떻게 보면 굉장히 어이없는 주의점일수도 있다.

왜냐하면 자바를 사용하는 입장에서 매우 당연한 일이기 때문이다.

값 타입은 오브젝트이기 때문에 주소참조를 하게된다.

 

Call By Reference, Call By Value

혹은

얕은복사, 깊은복사 라고 불리는 개념들에 대한 이야기다.

 

// file: 'CustomerTest.java'
@DataJpaTest
class CustomerTest {
    @Autowired
    CustomerRepository repository;

    @Test
    void customer(){
        Name name = Name.of("firstName", "lastName");
        Address address = Address.of("city", "street", "zipcode");

        Customer customer1 = Customer.of(name, "01000000001", address);
        Customer customer2 = Customer.of(name, "01000000002", address);

        Customer saveCustomer1 = repository.save(customer1); // insert 쿼리 작성(쓰기지연)
        Customer saveCustomer2 = repository.save(customer2); // insert 쿼리 작성(쓰기지연)
        repository.flush(); // insert 쿼리 두번 발생

        System.out.println("saveCustomer1 = " + saveCustomer1);
        System.out.println("saveCustomer2 = " + saveCustomer2);

        customer1.changeAddress("changeCity changeStreet changeZipcode"); // update 쿼리 두번 작성(쓰기지연)
        repository.flush(); // update 쿼리 두번 발생

        System.out.println("changeCustomer1 = " + saveCustomer1);
        System.out.println("changeCustomer2 = " + saveCustomer2);
    }
}

 

간단하고 직관적인 테스트 코드를 작성했다.

address를 중간에 한번 변경하게 되는데, 이 경우에 address의 주소를 customer1customer2가 공유 참조하고 있으므로 값이 함께 변한다.

 

실행 결과를 보자.

 

Hibernate: 
    insert 
    into
        customer
        (id, create_at, update_at, city, street, zipcode, first_name, last_name, phone_number) 
    values
        (null, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        customer
        (id, create_at, update_at, city, street, zipcode, first_name, last_name, phone_number) 
    values
        (null, ?, ?, ?, ?, ?, ?, ?, ?)
        
saveCustomer1 = Customer(super=BaseEntity(id=1, createAt=2021-07-12T22:31:06.759843, updateAt=2021-07-12T22:31:06.759843), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000001, address=Address(city=city, street=street, zipcode=zipcode))
saveCustomer2 = Customer(super=BaseEntity(id=2, createAt=2021-07-12T22:31:06.798738400, updateAt=2021-07-12T22:31:06.798738400), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000002, address=Address(city=city, street=street, zipcode=zipcode))

Hibernate: 
    update
        customer 
    set
        create_at=?,
        update_at=?,
        city=?,
        street=?,
        zipcode=?,
        first_name=?,
        last_name=?,
        phone_number=? 
    where
        id=?
Hibernate: 
    update
        customer 
    set
        create_at=?,
        update_at=?,
        city=?,
        street=?,
        zipcode=?,
        first_name=?,
        last_name=?,
        phone_number=? 
    where
        id=?
        
changeCustomer1 = Customer(super=BaseEntity(id=1, createAt=2021-07-12T22:31:06.759843, updateAt=2021-07-12T22:31:06.825667500), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000001, address=Address(city=changeCity, street=changeStreet, zipcode=changeZipcode))
changeCustomer2 = Customer(super=BaseEntity(id=2, createAt=2021-07-12T22:31:06.798738400, updateAt=2021-07-12T22:31:06.826664100), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000002, address=Address(city=changeCity, street=changeStreet, zipcode=changeZipcode))

 

address를 한번만 바꿨을 뿐인데 이를 참조하고 있는 customer1customer2address 값이 동시에 바뀌었음을 확인할 수 있다.

 

이와 같은 상황을 얕은 복사라고 부른다.

사실 자바를 공부하고 있다면 굉장히 기초적인 내용이긴 한데, 복잡한 실무상황에서 이런 문제가 발생하면 디버깅이 매우매우매우매우 어렵다.

값 타입은 그냥 JPA관련 어노테이션이 붙었다 뿐이지, 단순히 오브젝트 타입의 필드이기 때문에 위와 같은 자바의 문제점이 그대로 적용된다.

 

어떻게 해결할까?

 

깊은 복사를 사용하면 된다.

뭐 실제로 이렇게 값 타입을 사용하면서 값을 복사해서 쓰는 경우가 얼마나 있을지는 나도 잘 모르겠지만, 일단 알아두면 도움이 될 것 같다.

나같은 경우엔 복사용 메서드를 생성해서 사용한다.

 

// file: 'Address.java'
public Address newInstance() {
    return Address.builder()
                  .city(this.city)
                  .street(this.street)
                  .zipcode(this.zipcode)
                  .build();
}

 

// file: 'CustomerTest.java'
@DataJpaTest
class CustomerTest {
    @Autowired
    CustomerRepository repository;

    @Test
    void customer(){
        Name name = Name.of("firstName", "lastName");
        Address address = Address.of("city", "street", "zipcode");

        Customer customer1 = Customer.of(name, "01000000001", address);
        Customer customer2 = Customer.of(name, "01000000002", address.newInstance()); // 깊은 복사

        Customer saveCustomer1 = repository.save(customer1); // insert 쿼리 작성(쓰기지연)
        Customer saveCustomer2 = repository.save(customer2); // insert 쿼리 작성(쓰기지연)
        repository.flush(); // insert 쿼리 두번 발생

        System.out.println("saveCustomer1 = " + saveCustomer1);
        System.out.println("saveCustomer2 = " + saveCustomer2);

        customer1.changeAddress("changeCity changeStreet changeZipcode"); // update 쿼리 한번 작성(쓰기지연)
        repository.flush(); // update 쿼리 한번 발생

        System.out.println("changeCustomer1 = " + saveCustomer1);
        System.out.println("changeCustomer2 = " + saveCustomer2);
    }
}

 

결과를 확인한다.

 

Hibernate: 
    insert 
    into
        customer
        (id, create_at, update_at, city, street, zipcode, first_name, last_name, phone_number) 
    values
        (null, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        customer
        (id, create_at, update_at, city, street, zipcode, first_name, last_name, phone_number) 
    values
        (null, ?, ?, ?, ?, ?, ?, ?, ?)
        
saveCustomer1 = Customer(super=BaseEntity(id=1, createAt=2021-07-12T22:45:36.286366900, updateAt=2021-07-12T22:45:36.286366900), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000001, address=Address(city=city, street=street, zipcode=zipcode))
saveCustomer2 = Customer(super=BaseEntity(id=2, createAt=2021-07-12T22:45:36.335236400, updateAt=2021-07-12T22:45:36.335236400), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000002, address=Address(city=city, street=street, zipcode=zipcode))

Hibernate: 
    update
        customer 
    set
        create_at=?,
        update_at=?,
        city=?,
        street=?,
        zipcode=?,
        first_name=?,
        last_name=?,
        phone_number=? 
    where
        id=?
        
changeCustomer1 = Customer(super=BaseEntity(id=1, createAt=2021-07-12T22:45:36.286366900, updateAt=2021-07-12T22:45:36.367150), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000001, address=Address(city=changeCity, street=changeStreet, zipcode=changeZipcode))
changeCustomer2 = Customer(super=BaseEntity(id=2, createAt=2021-07-12T22:45:36.335236400, updateAt=2021-07-12T22:45:36.335236400), name=Name(firstName=firstName, lastName=lastName), phoneNumber=01000000002, address=Address(city=city, street=street, zipcode=zipcode))

 

customer1 의 값만 안전하게 변경되고, update 쿼리 또한 한번만 발생한 것을 확인 할 수 있다.

 


© 2022. All rights reserved.