@Table 카탈로그(catalog) 속성의 한계 (런타임 동적 변경 불가)
Spring Boot + JPA로 개발하면서 프로젝트에서 기본적으로 사용하는 스키마 이외의 같은 DB의 다른 스키마에 접근해야 하는 요구사항이 있었다. 처음에는 JPA의 @Table 애너테이션에 있는 catalog (mysql 환경이므로, catalog로 스키마 설정 가능) 속성만 바꾸면 간단히 해결될 거라고 생각했다. (예를 들어, 엔티티에 @Table(catalog = "myapp_ci")처럼 설정하기) 그리고 실제로 catalog 설정 후 테스트 해보았을 때 잘 동작하는 것을 확인할 수 있었다.
문제는 스키마 이름이 환경(운영, QA, 개발)별로 다르다는 것이었다. 그러면 엔티티에 @Table(catalog = "myapp_ci") 에서 catalog 부분을 환경별로 값만 달리 주면 되지 않을까?
그러나 막상 적용해보니 런타임에 해당 값을 동적으로 변경할 수 없는 구조였다. 그 이유는 JPA 구현체가 애플리케이션 시작 시점에 엔티티 메타모델을 고정하기 때문이다. 한번 고정된 테이블 스키마나 카탈로그 정보는 서버 동작 중에 바꿀 수 없다. (참고로 MySQL의 경우 @Table에 schema를 지정해도 테이블을 찾지 못하고, catalog에 데이터베이스 이름을 지정해야 인식되는 차이도 있다.) 결국 컴파일된 엔티티 클래스의 @Table 설정만으로는 환경별 스키마 분리를 구현하기 어려움을 깨달았다.
프로파일별 스키마 설정과 @Value 주입
이 문제를 해결하기 위해 환경별 설정 파일(Spring Profile)을 활용하는 방법을 선택했다. application-ci.yml, application-qa.yml, application-prod.yml 등 프로파일별 YAML에 스키마 이름을 설정해두고, 코드에서 이를 읽어 사용하는 것이다. 예를 들어 application-ci.yml에는 app.schema: ci_schema를, application-qa.yml에는 app.schema: qa_schema를 넣어 둔다. 이렇게 설정한 값을 사용할 때는 Spring의 @Value 애너테이션으로 주입받으면 된다:
@Value("\\${app.schema}")
lateinit var schemaName: String // 스키마 동적 변경을 위하여 네이티브 쿼리 작성
이렇게 하면 애플리케이션이 구동될 때 활성화된 프로파일에 맞는 스키마명이 schema 변수에 할당된다. 엔티티 클래스의 @Table에는 더 이상 catalog/schema를 하드코딩하지 않고, 대신 이 변수를 사용해 네이티브 쿼리를 만들 수 있다. 즉, JPA가 아닌 순수 SQL을 사용하여 테이블을 질의하면서, 테이블 이름 앞에 schema 변수를 동적으로 붙이는 방식이다.
네이티브 쿼리 작성하기
val sql = """
SELECT
r.id,
r.title,
r.type,
r.created_at,
FROM $schemaName.my_table AS r
WHERE r.id = :id
AND r.deleted_at IS NULL
""".trimIndent()
val resultList = entityManager.createNativeQuery(sql)
.setParameter("id", id)
.resultList
val result = resultList.firstOrNull() as? Array<Any>
위 코드에서 보듯이, 미리 주입받은 schemaName 변수를 SQL 문자열에 사용하여 테이블을 {스키마}.my_table 형태로 수동 지정했다. (schemaName는 신뢰할 수 있는 설정값이므로 SQL 인젝션 우려는 없지만, 가능하면 쿼리 빌딩에 주의가 필요하다.) 이렇게 하면 각 환경별로 다른 스키마의 테이블을 같은 코드로 질의할 수 있게 된다.
네이티브 쿼리 결과를 DTO로 매핑하기
네이티브 SQL을 실행하면 JPA가 리턴하는 결과는 Object[] 배열 리스트 형태로 나오게 된다. 즉, 엔티티 매핑이 아닌 원시 쿼리이기 때문에 각 로우(row)를 컬럼값들의 배열로 받게 된다. 이를 우리가 원하는 DTO(데이터 클래스) 객체로 변환해야 하는데, 수동으로 매핑 과정을 거쳐야 한다.
return result?.let {
MyTableDetailModel(
id = it[0] as Int,
title = it[1] as? String,
type = it[2] as String,
createdAt = (it[3] as? Timestamp)?.toLocalDateTime(),
)
}
이런 식으로 네이티브 쿼리 결과를 DTO에 수동 매핑해주었다. 주의할 점은 SELECT 절의 순서와 DTO 필드의 매핑 순서를 일치시키고 타입 캐스팅에 유의하는 것이다.
결과가 없을 때 firstOrNull()으로 처리하기
네이티브 쿼리 결과가 없을 경우의 처리도 중요하다. JPA에서는 Query#getSingleResult()를 사용하면 결과가 없을 때 예외(NoResultException)가 발생한다. 처음에는 예외를 try-catch로 잡아서 처리할 수도 있겠지만, 이런 방식은 지저분하고 불필요한 예외 흐름을 만들기 쉽다. 대신 결과 리스트를 받아놓고 비어있는지 확인하는 방법이 더 낫다. Kotlin의 경우 리스트에 대해 firstOrNull() 확장 함수를 쓰면 편리하다.
val result = resultList.firstOrNull() as? Array<Any>
이렇게 하면 결과가 없을 때 null을 얻을 수 있고, 한 개가 있을 때 그 값을 얻는다. 예외를 일으키지 않고도 빈 결과를 표현할 수 있다는 점에서 훨씬 깔끔하다. 실제로도 "찾는 엔티티가 없음"은 정상적인 시나리오일 수 있으므로, 예외보다는 Optional이나 null로 다루는 것이 좋다.
실제로 이러한 방법을 적용한 뒤, CI/QA/PROD 각각의 환경에서 별도 코드 수정이나 배포 없이 설정만으로 원하는 스키마의 데이터를 조회할 수 있었다.
'Spring' 카테고리의 다른 글
QueryDSL 페이징 Count 쿼리 작성 시 groupBy 사용의 문제점 분석 (0) | 2025.09.06 |
---|---|
pc ↔ 모바일 전환 시 일회용 토큰을 이용하여 로그인 기능 유지하기 (1) | 2025.07.06 |
Prometheus + Grafana instance 별칭 설정하기 (0) | 2025.06.09 |
Spring Boot Actuator, Grafana & Prometheus 모니터링 구축 및 연결 (0) | 2025.05.19 |
JpaSystemException: Could not extract column [6] from JDBC ResultSet [MONTH] 오류 해결 (1) | 2025.02.18 |