H2 는 관계형 데이터베이스를 지원하는 임베디드 데이터베이스이다. 임베디드로 존재하기 때문에 속도가 빠르며 서버 환경이 바뀌더라도 동일하게 동작하는 것을 보장해준다는 장점이 있다.
하지만 MySQL 뿐만 아니라 대부분의 관계형 데이터베이스를 모두 지원하기 때문에 H2 에서 제공하는 기능은 포괄적일 수밖에 없다. 즉, MySQL 만의 고유 기능을 제공하지 못하는 경우가 발생할 수도 있다. 욜로가 서비스에서는 두 좌표간의 거리를 구하기 위해 MySQL 함수인 ST_Distance_Sphere 를 사용하지만, H2 는 MySQL 의 함수를 제공하지 않는다. 따라서 그대로 MySQL 함수를 사용하되, H2 도 적용하고 싶다면 H2 의 사용자 정의 함수를 추가하여 대체할 수 있다.
H2 에서 사용하고자 하는 사용자 정의 함수를 프로젝트 내에서 추가하기 위해서는 다음과 같은 과정을 거친다.
완전히 동일하지는 않더라도 MySQL 함수를 대체할 수 있는 기능을 미리 구현해둔다. 대체하고 싶은 함수는 ST_Distance_Sphere 이며, ST_Distance_Sphere 는 매개변수로 Point 함수를 받기 때문에 Point 도 구현해야 한다. Point() 함수는 매개변수로 double 타입의 데이터를 받으므로 다음과 같이 구현하였다.
SELECT *
FROM running_crew
WHERE ST_Distance_Sphere(POINT(?2, ?1), departure) <= ?3 AND archived = true
;
H2Function.java
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class H2Function {
public static Point POINT(final Double latitude, final Double longitude) {
return wktToPoint(latitude, longitude);
}
public static double ST_Distance_Sphere(final Point start, final Point end) {
return start.distance(end);
}
private static Point wktToPoint(final Double latitude, final Double longitude) {
final String wellKnownText = String.format("POINT(%f %f)", longitude, latitude);
try {
return (Point) new WKTReader().read(wellKnownText);
} catch (ParseException e) {
throw new IllegalArgumentException(e.getMessage());
}
}
}
추후에 H2 에 함수를 적용하면 쿼리문 실행 시 이 함수가 대신 실행된다.
RunningCrewRepositoryTest.java
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DataJpaTest
@DisplayName("RunningCrew 레포지토리 테스트")
class RunningCrewRepositoryTest {
@Autowired
private EntityManager entityManager; // TestEntityManager 는 createNativeQuery 메서드를 지원하지 않음
@DisplayName("내 주변 러닝크루 목록 조회")
@Nested
public class findNearby {
private static final String ALIAS_FORMAT = "CREATE ALIAS IF NOT EXISTS %s FOR \\"%s.%s.%s\\"";
private static final String PACKAGE_PATH = "com.dobugs.yologaapi.repository";
private static final String CLASS_NAME = "H2Function";
private static final String CUSTOM_METHOD_FORMAT = String.format(ALIAS_FORMAT, "%s", PACKAGE_PATH, CLASS_NAME, "%s");
private static final String ST_DISTANCE_SPHERE = "ST_Distance_Sphere";
private static final String POINT = "POINT";
private static final Long HOST_ID = 0L;
@BeforeEach
void setUp() {
// CREATE ALIAS IF NOT EXISTS ST_Distance_Sphere FOR \\"com.dobugs.yologaapi.repository.H2Function.ST_Distance_Sphere\\"
entityManager
.createNativeQuery(String.format(CUSTOM_METHOD_FORMAT, ST_DISTANCE_SPHERE, ST_DISTANCE_SPHERE))
.executeUpdate();
// CREATE ALIAS IF NOT EXISTS POINT FOR \\"com.dobugs.yologaapi.repository.H2Function.POINT\\"
entityManager
.createNativeQuery(String.format(CUSTOM_METHOD_FORMAT, POINT, POINT))
.executeUpdate();
}
@DisplayName("내 주변에 있는 러닝크루 목록을 조회한다")
@Test
void success() {
final int count = 3;
for (int i = 0; i < count; i++) {
runningCrewRepository.save(createRunningCrew(HOST_ID));
}
final Pageable pageable = Pageable.unpaged();
final Page<RunningCrew> runningCrews = runningCrewRepository.findNearby(LATITUDE, LONGITUDE, 1000, pageable);
assertThat(runningCrews).hasSize(count);
}
}
...
}
H2 는 테스트 시에만 사용하기 때문에 테스트 내의 EntityManager 에 사용자 정의 함수를 설정하는 쿼리문을 실행하였다. 쿼리문을 실행하기 위해 @Sql 어노테이션을 사용해도 되지만, @Sql 사용 시 Transaction 이 rollback 되지 않아 다른 테스트 코드에도 영향을 끼쳐 선택하지 않았다.
MySQL 대신 테스트용 데이터베이스로 H2 를 사용하면 MySQL 과 완전히 호환되지 않기 때문에 ‘내가 테스트하려는 코드가 제대로 테스트 되고 있는 것인가?’ 라는 근본적인 질문이 생길 수 있다.
MySQL 과 유사한 관계형 데이터베이스이기 때문에 간편하게 테스트하기 위해 H2 를 사용했는데 사용하다보면 지원하지 않는 것들이 더 눈에 띈다. 그러다보면 H2 가 MySQL 을 완벽하게 대체할 수 있는 것이 맞나? 하는 생각이 든다.
이건 개발자마다 의견이 갈릴 수 있을거라고 생각한다. 개인적으로 생각해봤을 땐, MySQL 자체를 테스트하는 것이 아닌 ‘내가 작성한 코드가 올바르게 동작하며 이것이 적절하게 저장되었는지’ 를 알기 위해 테스트한다. 개발자가 개입하는 어플리케이션 계층과 개입할 수 없는 데이터 계층으로 나누어 지는데, 여기에서 데이터 계층 자체의 테스트는 필요 없다는 이야기이다. 다만 데이터 계층에 올바르게 접근할 수 있도록 규격(ex.SQL) 은 맞춰야 하는데 이를 위해 관계형 데이터베이스인 H2 를 사용한다.