[Spring Data] Spring Data JDBC를 이용한 다양한 ID 전략과 수동 ID 전략 구현시 주의점
최근 Kotlin + Spring Boot + Spring Data JDBC 조합으로 해당 기술 스택을 이용해 DDD 개발 방법론에 대해 복습하고 있습니다. 현업에서 Python을 사용하다보니 Spring에 대한 지식이 많이 떨어졌다는 것을 느꼈는데요.
평소 Spring Data JPA라는 ORM을 사용하여 데이터베이스 연결 기반의 서버를 만들었었는데, DDD를 접하게 되면서 Command와 Query 분리에 대한 중요성을 깨닫고 ORM이 아닌 가급적 JDBC와 같은 DB API를 직접 사용하는 경우가 잦게 되었습니다. 이에 따라 JPA가 아닌 Spring Data JDBC를 사용하게 되었습니다.
그런데, 우리가 데이터베이스 연동 작업을 하다보면 각 Row에 해당하는 고유값인 ID를 생성해야 할 때가 있습니다. 보통이라면 데이터베이스에서 제공하는 Auto Increment 등을 사용하겠지만 내가 직접 고유의 ID를 만들어주고 싶은 때가 있습니다. 이번에는 직접 이 매커니즘을 구현해서 적용하였는데, 공교롭게도 Spring Data에서 제공하는 Auditing과 호환되지 않는 문제점이 발생하여 몇 가지 검색을 하던 중 이명헌님의 우아한 테크 세미나 를 보게된 뒤 이 글을 쓰게 되었습니다.
데이터베이스 ID 전략
Hibernate, JPA와 같은 ORM을 사용했다면 @GeneratedValue라는 어노테이션을 이용해 데이터베이스에서 제공하는 IDENTITY나 Sequence 등을 사용했을 것입니다. 저 또한 이렇게 사용을 했었고, 가장 간편하면서 접근하기 쉬운 방법입니다.
이처럼 하나의 테이블에 해당하는 데이터 행(row)의 고유 값을 우리는 ID라고 합니다. 이 ID는 Auto-increment처럼 특정 숫자에서 시작하여 특정 값만큼 증가해 사용할 수 있는 방법이 있습니다.
이 외에도 숫자만으로 표현하기 부족한 데이터량이거나 순서쌍을 가지지 않은 데이터로 보존하고자 하는 경우, UUID를 사용할 수도 있고, 일반 숫자만으론 데이터량이 모자라지만 순서쌍을 보존하고 싶다면 UULD를 이용하는 등 다양한 방법으로 ID를 생성할 수 있습니다. 이 글에서 우리는 이를 ID 전략이라고 합니다.
Spring Data JDBC에서 구현해보는 ID 전략
공교롭게도 Spring Data JDBC에서는 @GeneratedValue 어노테이션을 제공하지 않습니다. GeneratedValue는 ORM에서 DDL을 위한 어노테이션이므로 우리는 이를 별도의 방법으로 구현해줘야 합니다.
Auto-increment
앞서 여러번 말했듯, Auto-increment는 DB 필드에 auto increment를 걸어두고 사용하는 전략입니다. 이 전략을 이용할 때는 Spring의 init sql이나 Flyway, Lighthouse와 같은 DB 마이그레이션 라이브러리를 이용해 DDL에 auto increment sql을 추가하는 방법이 있습니다.
Spring에서 제공하는 init mode는 Spring Application이 Load되었을 때 SQL 코드를 실행하는 설정 메서드입니다. 위와 같이 설정한 후
classpath:resources/sql/schema.sql 경로에 DDL SQL 코드를 삽입하면 DDL 코드가 실행됩니다.
이 코드는 PostgreSQL을 예시로 하여 작성해보겠습니다. schema.sql에는 DDL 코드를 삽입하고, Application을 실행하면 이제 Auto-increment로 post 테이블 데이터의 고유값이 매겨지게 됩니다.
Entity 클래스를 만들 때는 DDL 코드에서 정의한 컬럼 이름을 클래스의 멤버 변수 이름으로 동일하게 구현해줍니다. 만약 다른 이름을 지정하고자 하는 경우 @Column 어노테이션을 이용해서 연결할 컬럼 이름을 수동으로 지정할 수도 있습니다.
UUID Generate 및 수동 전략
Hibernate에서는 org.hibernate.id.UUIDGenerator 클래스를 이용하여 데이터의 Insert 발생시 해당 컬럼의 UUID를 무작위로 생성하여 데이터에 삽입하는 방식으로 쉽게 구현할 수 있습니다. 그렇다면 Spring Data JDBC에서는 어떻게 해볼 수 있을까요?
간단하게 UUID는 Java에서 제공하는 UUID 클래스가 있습니다. UUID 클래스의 정적 메서드인 randomUUID를 Entity 객체 생성시 넣어줘도 객체 생성 때 ID가 생성되어 들어가기 때문에 어찌보면 이렇게 할 수도 있을 것입니다.
하지만 위 방법을 사용하면 문제가 생깁니다. 만약 이 데이터에 Spring Data의 @CreatedDate나 @LastModifiedDate와 같은 Audit을 사용해보겠습니다.
멤버 변수에 위처럼 createdAt과 updatedAt를 추가해줍니다.
그런데, 쿼리는 UPDATE 쿼리로 동작하며 오류가 나타납니다. save 메서드를 호출했을 뿐인데 INSERT 쿼리가 아닌 UPDATE 쿼리가 발생합니다 왜 그런 것일까요?
Spring Data JDBC Persistable 인터페이스
여기서 Spring Data JDBC에서 제공하는 save, insert, update 메서드에 대해 알아보겠습니다. insert와 update는 각각 쿼리를 INSERT 쿼리를, UPDATE 쿼리를 호출하는 것의 차이지만 save 메서드는 ID 값과 Persistable의 isNew 변수에 따라 다르게 작동합니다.
위 코드는 JDBC의 CrudRepository의 내장 메서드 중 save 메서드가 동작하는 코드입니다. JDBC의 CrudRepository는 JdbcAggregateTemplate 클래스의 메서드를 사용하며 save 메서드는 Entity의 isNew 메서드에 따라 INSERT 쿼리를 사용할지, UPDATE 쿼리를 사용할지를 선택하는 모습을 볼 수 있습니다.
어? 그러면 save 메서드를 사용하지 않고, insert 메서드를 직접 호출하면 되지 않나요?
그래서 직접 insert 메서드를 사용하니 데이터는 추가되는 모습을 볼 수 있습니다.
하지만, created_at에는 날짜가 추가되지 않는 모습입니다. 아무래도 isNew의 영향이지 않나 싶네요.
그렇다면 이 isNew 값은 어떻게 결정되는 것일까요?
Spring Data에는 IsNewStrategy라는 인터페이스가 존재하는데, 이 인터페이스는 이 Entity 객체가 새로운 객체인지 아닌지를 나타내주는 추상 메서드입니다. 이를 영속 과정에서 사용하는 구현체로 PersistenceEntityIsNewStrategy 클래스가 있고, 이 코드가 바로 우리가 사용하는 isNew의 핵심입니다.
코드를 보면, isNew는 valueLookup.apply 메서드를 통해 Id 어노테이션을 지정한 필드의 값을 검사하며 이 값이 null인 경우에만 isNew를 true로 보고 있는 것을 알 수 있습니다.
우리는 여태까지 생성자에 Id를 미리 생성하고 사용하였기 때문에 INSERT 쿼리가 아닌 UPDATE 쿼리로 동작한 것입니다.
따라서 Entity 생성시, 해당 Entity가 새로운 데이터인지 아닌지를 생성자에 넣도록하여 구현할 수 있도록 하는 것이 바로 Persistable 인터페이스의 역할입니다. Persistable은 PersistenceEntityIsNewStrategy와 마찬가지로 IsNewStrategy 인터페이스를 추상체로 지니고 있는 PersistableIsNewStrategy를 사용합니다.
PersistableIsNewStrategy는 주어진 entity 인스턴스가 Persistable 인스턴스인지 검사한 후, 구현된 isNew 메서드를 통해 새로운 데이터인지 아닌지를 구분합니다.
데이터를 삽입하고 나면 이렇게 created_at에도 Auditing이 적용되는 것을 볼 수 있습니다.
BeforeSaveCallback (Lifecycle event)
isNew를 entity 인스턴스 생성마다 준다는 것은 다소 번거로운 일일 수 있습니다. 그럴 때는 save 메서드가 호출될 때 ID를 주는 방법을 사용해볼 수 있습니다.
Spring Data JDBC에서는 데이터가 CRUD될 때 발생하는 Event-callback 메서드를 제공합니다. 이 콜백 메서드를 구현하여 save 등의 메서드 발생시에 ID를 주입하는 방법으로 구현의 번거로움을 덜 수 있습니다.
Entity 생성자에는 Id를 제거하고, 그 Id는 나중에 초기화 한다는 lateinit 키워드를 붙여줍니다.
그리고, 만들어준 Callback 클래스를 Bean에 등록해줍니다. 위 코드처럼 별도의 Configuration 클래스를 만들고 Import 하시는 것을 권장합니다.
이후 레코드 또한 잘 삽입되는 것을 볼 수 있습니다.
마치며...
Spring Data JDBC를 이용해서 수동으로 ID 전략을 어떻게 할 수 있는지 그리고 구현시 참고해야 할 것은 어떤 부분인지 알아봤습니다.
처음 프로젝트를 시작했을 땐 도메인 모델에서 직접 ID를 Generate하여 이를 Entity 인스턴스에 옮기는 방향으로 CRUD를 진행했었고, save 메서드 호출시 UPDATE 쿼리가 발생하여 insert 메서드를 직접 호출하는 방향으로 문제가 해결이 되었다고 생각했습니다.
하지만 Auditing 기능을 활성화 하면서 created_at이 정상 작동하지 않는 것을 보고 여러 레퍼런스들을 참고했고, 그러던 중 우아한 테크 세미나 컨텐츠를 찾아 솔루션을 도출해내었습니다.
Spring Data JDBC를 사용하면서 느낀건 기존 Spring Data JPA가 가진 ORM의 복잡성을 좀 더 간단하게 사용할 수 있는 부분도 있었지만 이들의 모티브가 에릭 에반스의 DDD(도메인 주도 개발)인 것을 알고 사용하니 더욱 DDD에 최적화된 방식을 이용하고 있다는 것을 알고 더 공부가 잘 되어지는 듯 합니다. Spring으로 DDD를 공부하고 계신 분들이라면 꼭 사용해보셨으면 좋겠습니다.
'Programming > Spring' 카테고리의 다른 글
[Spring Data] @Transactional 어노테이션으로 보는 Spring의 트랜잭션 이야기 (0) | 2022.05.05 |
---|---|
[Spring boot] Axon Framework로 시작하는 CQRS 기초 (0) | 2022.02.21 |
[Spring Data] Spring Data JDBC를 이용한 DB 연동 (응용편) (0) | 2021.06.19 |
[Spring Data] Spring Data JDBC를 이용한 DB 연동 (기본편) (0) | 2021.06.05 |
[Spring Data] Spring Data module (0) | 2021.05.29 |