파란하늘의 지식창고
반응형

2년 반쯤 전에 Spring Data JDBC를 살펴보면서 Data JPA를 대체할 수 있을지 고민해보았었다.

2019.07.28 - [Study/Java] - Spring Data JDBC로 Spring Data Jpa를 대체할 수 있을까?

시간이 흘렀고 기존에 살펴보았던 Spring Data JDBC 1.0.9.RELEASE도 현재 2.3.2 버전으로 판올림 되었다.

현재 버전의 Spring Data JDBC가 사용할만한지 살펴보았다.

Spring Data JDBC란?

Spring Data JDBC는 데이터와 java object를 연계하기 위해 제공되는 Spring Data의 라이브러리 중 하나이다.

(DB를 연결하여 사용하기 위해 제공되는 Spring JDBC와 다른 라이브러리이다.)

Spring Data JPA가 가진 복잡한 부분을 많이 덜어내어 가볍게 DB를 조회하는 것에 초점을 둔 라이브러리라고 볼 수 있다.

기본적으로 JdbcTemplate을 제공하여 db를 조회할 수 있게 하고 transactionManager를 기존과 동일하게 지원한다.

하지만 Spring Data JPA가 제공하는 hibernate 기반 영속성이나 1,2차 캐시, ddl auto create 같은 schema 관리를 지원하지 않는다.

있으면 좋지만 깊게 사용하지 않는다면 불필요한 것들을 모두 걷어내고 경량화하여 query를 만들고 domain에 mapping 하는 것만 중점을 두었다.

JdbcTemplate을 직접 사용하여 queryForObject 같은 method를 사용할 수도 있지만 repository를 통한 query method 같은 Spring Data의 편리한 기능을 사용할 수 있다. 

구성하기

maven dependency 설정

dependency를 다음과 같이 추가한다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>org.mariadb.jdbc</groupId>
    <artifactId>mariadb-java-client</artifactId>
</dependency>

spring-boot-starter-data-jdbc 의존성과 사용할 db client를 추가하면 된다.

이렇게 추가되는 각 db별 client를 spring data에서 사용할 수 있도록 해주는 dialect를 제공해준다.

현재 Spring Data JDBC는 다음과 같은 db에 대한 dialect를 제공한다.

  • DB2
  • H2
  • HSQLDB
  • MariaDB
  • Microsoft SQL Server
  • MySQL
  • Oracle
  • Postgres

만약 위에 해당하지 않는 db를 Spring Data JDBC에서 사용하려면 해당 db client에 대한 dialect를 별도로 만들어 사용하면 된다.

spring config 설정

DataSource에 대한 설정을 따로 하고 (관련 설명은 생략)

@Configuration
@PropertySource(value = "classpath:jdbc-user.properties", ignoreResourceNotFound = true)
@PropertySource(value = "classpath:jdbc-user-${spring.profiles.active}.properties", ignoreResourceNotFound = true)
public class JdbcUserConfig {

	@Bean
	@ConfigurationProperties("datasource.user")
	public DataSourceProperties userDataSourceProperties() {
		return new DataSourceProperties();
	}
	
	@Bean
	public DataSource userDataSource() {
		return userDataSourceProperties().initializeDataSourceBuilder().build();
	}
}

해당 DataSource를 사용하는 Spring Data JDBC 설정을 한다.

@Configuration
@EnableJdbcAuditing
@EnableJdbcRepositories(basePackages = "net.luversof.user.**.repository", jdbcOperationsRef = "userNamedParameterJdbcOperations", transactionManagerRef = "userTransactionManager")
public class UserDataJdbcConfig extends AbstractJdbcConfiguration {

	@Bean
	@Primary
	public NamedParameterJdbcOperations userNamedParameterJdbcOperations(@Qualifier("userDataSource") DataSource userDataSource) {
		return new NamedParameterJdbcTemplate(userDataSource);
	}
	
	@Bean
	@Primary
	public TransactionManager userTransactionManager(@Qualifier("userDataSource") DataSource userDataSource) {
		return new DataSourceTransactionManager(userDataSource);
	}
}

사용하기

위와 같이 datasource와 data jdbc에 대한 설정을 하였으면 Data JPA를 사용한 것과 유사하게 사용할 수 있다.

domain을 만들고

@Data
public class User {

	@Id
	private long id;

	private String username;

	private String password;

	@CreatedDate
	private LocalDateTime createdDate;

	private boolean accountNonExpired;
	
	private boolean accountNonLocked;
	
	private boolean credentialsNonExpired;
	
	private boolean enabled;

}

해당 domain을 사용하는 repostiory를 만들면 된다.

public interface UserRepository extends CrudRepository<User, Long> {

	Optional<User> findByUsername(String username);

	List<User> findByIdIn(Collection<Long> ids);

}

여기까지만 보면 Data JPA와 유사하여 크게 거부감 없이 사용할 수 있을 것 같다.

Entity State Detection Strategies

Spring Data JDBC는 다음과 같은 규칙으로 insert 인지 update인지 분기를 한다.

  1. @Id property 값이 null이거나 primitive type인 경우 0이면 신규로 간주
  2. @Version property의 값이 null이거나 primitive type인 경우 0 이면 신규로 간주
  3. Persistable interface를 구현하여 isNew method가 true 면 신규로 간주
  4. EntityInformation 구현을 상위 repository에 선언하여 사용. (여러 하위 repository가 같이 사용할 수 있음)

https://docs.spring.io/spring-data/jdbc/docs/current/reference/html/#is-new-state-detection

이 이외에도 EventListener를 통해 이러한 변경 전이나 후에 domain에 관여를 할 수 있다.

https://spring.io/blog/2021/09/09/spring-data-jdbc-how-to-use-custom-id-generation

javax.persistence-api (JPA)와 관련 없음

Spring Data JPA는 JPA 기반의 라이브러리이고 JPA는 hibernate로부터 표준화되었다.

관련하여 사용되는 많은 annotation이 javax.persistence-api에 있고 (jakarta.persistence-api로 변경 예정) 이를 사용한다.

Spring Data JDBC는 이와 연관관계가 없다.

따라서 기존에 Data JPA에서 사용하던 많은 javax.persistence.*로 시작하는 package의 annotaion은 모두 spring-data-commons와 spring-data-relational에서 제공하는 유사한 이름의 annotation으로 변경되어야 한다.

위의 예제에서 보여준 @Id나 @Table annotation도 이 package의 annotation들이다.

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

이름이 비슷해 보여도 참조가 아예 다르다.

Many to One 관계없음

JPA의 경우 One-to-One, Many-to-One, One-to-Many를 설정하여 각 domain이 쌍방향으로 관계를 제공한다.

코드 테스트하다 toString에 이 상호 참조로 인해 순환 참조 에러를 보기도 하고

jackson mapper에서 응답이 순환 참조되는 걸 막기 위해 @JsonIgnore나 @JsonBackReference를 사용하기도 했었다.

보통 다음과 같은 형태로 선언하여 사용하였다.

public class User {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private long id;

	//... 중간 생략
	
	@JsonManagedReference
	@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
	private List<UserAuthority> userAuthorityList;

}

User에서 One-to-Many로 참조하는 UserAuthority는 다음과 같다.

public class UserAuthority {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private long idx;
	
	@JsonBackReference
	@ManyToOne
	private User user;

	private String authority;

}

JDBC에선 이렇게 쌍방향으로 참조를 쓰지 않는다.

따라서 One-to-Many만 참조하고 Many-to-One은 고려하지 않는다.

따라서 단순하게 다음과 같은 판단 한다.

  • Set<some entity>는 One-to-Many로 간주한다.
    참조된 entity의 table에는 참조하는 entity의 table과 동일한 이름의 추가 column이 있어야 한다.
  • Map<simple type, some entity>는 One-to-Many로 간주한다.
    참조된 table에는 2개의 column이 있어야 한다.
    key에 대해 _key 접미사를 가진 column과 Set과 동일하게 참조하는 entity의 table과 동일한 이름의 column이 있어야 한다.
  • List<some entity>는 Map<Integer, some entity>로 간주한다. (map의 규칙을 따른다.)

https://docs.spring.io/spring-data/jdbc/docs/current/reference/html/#jdbc.entity-persistence.types

따라서 기존 JPA에서 서로가 서로의 domain을 참조하던 형태는 단방향으로 변경되어 다음과 같이 된다.

public class User {

	@Id
	private long id;

	// ... 중간 생략

	@MappedCollection(idColumn = "USER_ID", keyColumn = "IDX")
	private List<UserAuthority> userAuthorityList;

}
public class UserAuthority {

	@Id
	private long idx;
	
	@JsonBackReference
	private long userId;

	private String authority;

}

 

userAuthority의 User 객체 참조가 foreign key 참조로 변경되었다.

확인이 필요한 부분들

query 생성 시 대문자 snake case 규칙? (mariadb)

mariadb의 경우 대문자 snake case로 column을 조회한다.

SELECT `USER`.`ID` AS `ID`, `USER`.`ENABLED` AS `ENABLED`, `USER`.`PASSWORD` AS `PASSWORD`, `USER`.`USERNAME` AS `USERNAME`, `USER`.`userType` AS `userType`, `USER`.`externalId` AS `externalId`, `USER`.`createdDate` AS `createdDate`, `USER`.`accountNonLocked` AS `accountNonLocked`, `USER`.`accountNonExpired` AS `accountNonExpired`, `USER`.`credentialsNonExpired` AS `credentialsNonExpired` FROM `USER` WHERE `USER`.`USERNAME` = ?

만약 db의 column을 camel case로 지정하였다면 domain에 @Column으로 db column에 대한 이름을 명시해야 한다.

public class User {

	@Id
	private long id;

	private String username;

	private String password;

	@CreatedDate
	@Column("createdDate")
	private LocalDateTime createdDate;

	@Column("accountNonExpired")
	private boolean accountNonExpired;
	
	@Column("accountNonLocked")
	private boolean accountNonLocked;
	
	@Column("credentialsNonExpired")
	private boolean credentialsNonExpired;

    // .. 이하 생략

}

이 부분은 mariadb의 data jdbc dialect의 문제인지 공통 strategy 설정이 있는지 따로 확인이 필요하다.

Conveter의 사용

Spring Data JDBC는 R2DBC와 동일하게 기본적인 java type에 대해서만 지원하고 UUID 같은 값을 지원하지 않는다.

(reference 문서 상에는 UUID를 @Id로 지정해서 보여주는데 binary column을 UUID로 매칭 할 경우 지원하는 converter가 없다는 에러가 발생한다.

BytesToUuidConverter나 UuidToBytesConverter를 등록해서 사용한다면 대략 다음과 같이 할 수 있다.

@Configuration
@EnableJdbcAuditing
@EnableJdbcRepositories(basePackages = "net.luversof.user.**.repository", jdbcOperationsRef = "userNamedParameterJdbcOperations", transactionManagerRef = "userTransactionManager")
public class UserDataJdbcConfig extends AbstractJdbcConfiguration {

	@Bean
	@Primary
	public NamedParameterJdbcOperations userNamedParameterJdbcOperations(@Qualifier("userDataSource") DataSource userDataSource) {
		return new NamedParameterJdbcTemplate(userDataSource);
	}
	
	@Bean
	@Primary
	public TransactionManager userTransactionManager(@Qualifier("userDataSource") DataSource userDataSource) {
		return new DataSourceTransactionManager(userDataSource);
	}

	@Override
	protected List<?> userConverters() {
		List<Converter<?, ?>> userConverters = new ArrayList<>();
		userConverters.add(new UuidToBytesConverter());
		userConverters.add(new BytesToUuidConverter());
		return userConverters;
	}
	
	
	@WritingConverter
	static class UuidToBytesConverter extends StringBasedConverter implements Converter<UUID, byte[]> {

		@Override
		public byte[] convert(UUID source) {
			return fromString(source.toString());
		}
	}

	@ReadingConverter
	static class BytesToUuidConverter extends StringBasedConverter implements Converter<byte[], UUID> {

		@Override
		public UUID convert(byte[] source) {

			if (ObjectUtils.isEmpty(source)) {
				return null;
			}
			
			return UUID.fromString(toString(source));
		}
	}
	
	public static final Charset CHARSET = StandardCharsets.UTF_8;
	
	static class StringBasedConverter {

		byte[] fromString(String source) {
			return source.getBytes(CHARSET);
		}

		String toString(byte[] source) {
			return new String(source, CHARSET);
		}
	}
}

하지만 db binary column에서 가져온 byte에 대해 reading에 문제가 발생했다.

별도의 converter를 사용하는 부분에 대해 좀 더 확인이 필요하다.

spring boot data jdbc auto configuration 설정

테스트를 하면서 느낀 것 중 하나는 Spring Data JDBC에 대해 spring boot의 auto configuration 제공이 현재 문제가 있다.

AbstractJdbcConfiguration을 구현하여 사용하는 식으로 가이드를 하고 있는데 이를 통해 JdbcMappingContext, JdbcConverter, JdbcCustomConversions, JdbcAggregateTemplate, DataAccessStrategy, Dialect bean을 생성하게 된다.

missing bean 선언이 아니고 abstract configuration 구현을 해야하는 형태이고 multiple dataSource 사용에 대한 고려가 되어 있지 않다.

multiple dataSource 사용 불가

문제는 기존 Data JPA의 경우 @EnableJpaRepositories를 복수로 사용하여 여러 Data Jpa Repository를 사용할 수 있었다.

Spring Data JDBC는 현재 지원하지 않는다.

각 repository별 JdbcConverter 제공이 되지 않고 있기 때문에 여러 가지 db (예를 들면 mysql과 mssql을 같이 사용하는?) 식의 처리를 할 수 없다.

이에 대해 @EnableJdbcRepositories에 jdbcConverter 지정에 대한 issue가 있긴 하지만 관련 개선이 그리 빠르게 진행되는 것 같지 않다.

https://github.com/spring-projects/spring-data-relational/issues/544

https://github.com/spring-projects/spring-data-relational/pull/1005

multiple @EnableJdbcRepositories 사용 불가

위의 이슈와 연계해서 @EnableJdbcRepositories도 복수로 사용을 할 수 없다.

JPA의 경우 EntityManagerFactoryBean과 TransactionManager를 복수로 사용하는 경우 @Primary로 지정하고 @EnableJpaRepositories를 복수로 사용할 수 있었다.

Data JDBC의 경우 불가능하다.

결국 JdbcTemplate만 복수 사용할 수 있는 정도이다.

Auditing zonedDateTime 미지원

왜 그런지 이해가 되지 않는데 아직 zonedDatetime에 대한 auditing을 지원하지 않는다.

https://github.com/spring-projects/spring-data-relational/issues/635

https://docs.spring.io/spring-data/jdbc/docs/current/reference/html/#auditing

결론

아직까지 기존에 사용하던 Spring Data JPA를 Spring Data JDBC로 변경하는 부분에 대해서는 회의적이다.

아니면 정말 JdbcTemplate만 사용한다면 사용할 수 있을 것 같다.

 

참고 자료

https://docs.google.com/presentation/d/1E7Y_L8TO6ZRZfFjBO6f_GBZxzdV0Klw0XNuH1Kv4JWA

https://spring.io/blog/2018/09/24/spring-data-jdbc-references-and-aggregates

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요