| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- Workflow
- firebase
- http-only cookie
- functional interface
- lambda
- AWS
- Claude
- gemini cli
- 오픈소스
- spring boot
- Java
- S3
- optional
- bluegreen
- Kotlin
- n8n
- WebFlux
- 홈서버
- Quarkus
- coroutine
- 무중단배포
- 자동화
- 오픈소스 기여
- AI
- FCM
- Interface
- Security
- class
- GEMINI
- Today
- Total
빠르게 학습하고 빠르게 적용하자
JSON 문자열을 RDB에 넣었다가... MySQL→MongoDB 메타데이터 마이그레이션기 본문
배경
데이터 저장을 전부 RDB에 저장했습니다. 문제마다, 각 테스트 케이스마다 카디널리티와 애트리뷰트 수가 다르다보니 각각 데이터를 저장할 수 없었습니다. 따라서 우선적으로 메타데이터 라는 이름으로 Json 형식으로 저장했었습니다.
하지만 서비스가 커지고 문제가 발생했었습니다. 이미 등록한 문제를 풀어보니 메타데이터에서 수정이 필요한 부분이 있었습니다. NULL값이 들어가야하는데 공백이 들어가있어 문제를 급하게 수정해야하는 상황이였습니다. 그런데 데이터 베이스에 JSON형태로 들어가 있다보니 수정이 필요한 부분만 딱 찝어서 수정이 어려웠고 결국 하나하나 읽은 후 새 데이터로 덮어써야했습니다.
이러한 문제를 겪다보니.. 역시 JSON데이터를 문자열로 저장하는 것은 아니라고 판단했습니다. 따라서 NoSQL로 이전하는것이 바람직하다고 생각했습니다.
저장 구조
Problem 테이블
컬럼 타입 내용
| schema_sql | TEXT | SqlGenerator가 생성한 CREATE TABLE SQL |
| schema_metadata | JSON | SchemaMetadata 객체 그대로 |
TestCase 테이블
컬럼 타입 내용
| init_sql | TEXT | 생성된 INSERT SQL |
| init_metadata | JSON | InitMetadata 객체 그대로 |
| answer | TEXT | 탭 구분 TSV |
| answer_metadata | JSON | AnswerMetadata 객체 그대로 |
metadata에서 SQL이나 TSV는 SqlGenerator로 언제든 재생성할 수 있습니다. 그런데 metadata와 SQL/TSV를 둘 다 저장하고 있었습니다. 만약 SQL만 수동으로 수정하거나, 한쪽만 업데이트되면 metadata와 SQL 간 내용이 어긋나게 됩니다. 단일 진실 공급원(Single Source of Truth)이 깨지는 거죠.
또한 텍스트 데이터에서 데이터 자체에 탭(\\t)이나 개행(\\n)같은 파싱 방법이 불안정했습니다.
마지막으로 컬럼마다 ObjectMapper 인스턴스가 새로 생성되고 있었습니다. ObjectMapper는 스레드 세이프하므로 싱글턴으로 공유하는 것이 맞는데, 불필요한 인스턴스가 계속 만들어지고 있었던 거죠.

NoSQL
구조가 유동적인 데이터를 문자열로 직렬화해서 RDB에 억지로 끼워 넣는 것 자체가 문제였습니다. NoSQL, 그중에서도 MongoDB로 이전하면 JSON 데이터를 네이티브 도큐먼트로 저장할 수 있습니다. 필드 단위 조회와 수정이 가능해지고, 타입 정보도 보존되며, 중복 저장의 필요성도 사라집니다.
Redis로 문제를 한 번 조회하고 전체를 Redis에 저장하기 때문에 성능상으로도 큰 문제는 없을 거라고 생각했습니다.
개선 과정이 너무 재밌을 것 같네요!!
아키텍처 결정: Service-Level
MongoDB를 도입한다고 해서 MySQL을 버리는 건 아닙니다. 문제의 제목, 설명, 난이도 같은 정형 데이터는 여전히 MySQL이 적합합니다. 옮겨야 할 건 구조가 유동적인 메타데이터 세 가지뿐이었습니다.
대상 원래 위치 이전 후
| SchemaMetadata | problems.schema_metadata (JSON) | MongoDB problem_metadata 컬렉션 |
| InitMetadata | test_cases.init_metadata (JSON) | MongoDB testcase_metadata 컬렉션 |
| AnswerMetadata | test_cases.answer_metadata (JSON) | MongoDB testcase_metadata 컬렉션 |
여기서 핵심적인 설계 결정이 하나 필요했습니다.
MySQL 데이터와 MongoDB 데이터를 어디서 조합할 것인가?
선택지는 크게 두 가지였습니다. 하나는 Repository 레벨에서 두 저장소를 모두 알게 하는 방법, 다른 하나는 Service 레벨에서 조합하는 방법입니다.
Service-Level Joining을 선택했습니다. 이유는 Repository는 자기가 담당하는 저장소만 알아야 합니다. MySQL Repository가 MongoDB의 존재를 알게 되는 순간, 저장소 간 의존이 생기고, 테스트할 때 두 저장소를 동시에 모킹해야 하는 번거로움이 따라옵니다. 각 Repository는 자기 저장소만 책임지고, Service에서 양쪽 결과를 copy()로 조합하는 게 훨씬 깔끔합니다.
조회 흐름을 정리하면 이렇습니다.
MySQL에서 Problem 조회 → MongoDB에서 SchemaMetadata 조회 → problem.copy(schemaMetadata = ...) → 완전한 도메인 객체 → Redis 캐싱
기존에 Redis 캐싱이 이미 걸려 있었기 때문에, 조합된 완전한 도메인 객체를 캐싱하면 두 번째 조회부터는 MongoDB에 요청조차 가지 않습니다. 즉, 저장소가 두 개로 늘어났지만 캐시 히트 시 성능 영향은 거의 없을 것으로 예상됩니다.
MongoDB Document 설계
MongoDB 쪽 도큐먼트 구조는 최대한 단순하게 가져갔습니다.
@Document(collection = "problem_metadata")
data class ProblemMetadataDocument(
@Id val id: String? = null,
@Indexed(unique = true) val problemId: Long,
val schemaMetadata: SchemaMetadata
)
@Document(collection = "testcase_metadata")
data class TestCaseMetadataDocument(
@Id val id: String? = null,
@Indexed(unique = true) val testCaseId: Long,
val initMetadata: InitMetadata?,
val answerMetadata: AnswerMetadata?
)
설계할 때 고민했던 포인트가 몇 가지 있습니다.
problemId / testCaseId에 unique index를 걸었습니다. MongoDB의 _id는 ObjectId로 자동 생성되기 때문에, MySQL의 PK와 매핑하려면 별도 필드가 필요합니다. 이 필드에 유니크 인덱스를 걸어두면 중복 삽입을 DB 레벨에서 막을 수 있고, findByProblemId() 같은 조회도 인덱스 스캔으로 처리됩니다.
TestCase 조회 시 배치 조회를 고려했습니다. 문제 하나에 딸린 테스트 케이스가 여러 개이기 때문에, findByTestCaseIdIn(testCaseIds) 메서드를 만들어서 N+1 문제를 방지했습니다. testCaseId 목록을 한 번에 넘기면 MongoDB에서 $in 쿼리로 한 번에 가져옵니다.
Service 레이어 변경
// ProblemService.kt
class ProblemService(
private val problemRepository: ProblemRepository, // MySQL
private val problemMetadataMongoRepository: ProblemMetadataMongoRepository // MongoDB
) {
fun findById(id: Long): Problem {
val problem = problemRepository.findById(id) // MySQL 조회
val metadata = problemMetadataMongoRepository
.findByProblemId(id) // MongoDB 조회
return problem.copy(
schemaMetadata = metadata?.schemaMetadata // 조합
)
}
}
MySQL Repository에서는 메타데이터 관련 파라미터를 전부 제거했습니다. save()에서 schemaMetadata 세팅 라인 삭제, update()에서 schemaMetadata 파라미터 삭제. Repository가 알아야 할 게 줄어든 거죠. 대신 Service의 create()와 update()에서 MongoDB 저장을 별도로 호출합니다.
TestCase 쪽도 같은 패턴입니다만 findAll()에서는 앞서 만들어둔 배치 조회를 활용합니다.
fun findAll(problemId: Long): List<TestCase> {
val testCases = testCaseRepository.findAllByProblemId(problemId)
val testCaseIds = testCases.map { it.id }
val metadataMap = testCaseMetadataMongoRepository
.findByTestCaseIdIn(testCaseIds)
.associateBy { it.testCaseId } // Map으로 변환하여 O(1) lookup
return testCases.map { tc ->
val meta = metadataMap[tc.id]
tc.copy(
initMetadata = meta?.initMetadata,
answerMetadata = meta?.answerMetadata
)
}
}
FlywayMigrationStrategy
Spring Boot가 제공하는 FlywayMigrationStrategy를 오버라이드해서, Flyway migrate() 호출 직전에 데이터를 먼저 MongoDB로 옮겼습니다.
@Configuration
class MetadataMigrationConfig(
private val mongoTemplate: MongoTemplate,
private val jdbcTemplate: JdbcTemplate
) {
@Bean
fun flywayMigrationStrategy(): FlywayMigrationStrategy =
FlywayMigrationStrategy { flyway ->
migrateMetadataToMongo() // 1) JSON 컬럼 살아있을 때 읽어서 이전
flyway.migrate() // 2) 이제 컬럼 DROP해도 안전
}
}
실행 순서를 정리하면 이렇습니다.
앱 시작 → DataSource 초기화 → FlywayMigrationStrategy 실행
└→ migrateMetadataToMongo() (JSON 컬럼 아직 살아있음 ✅)
└→ flyway.migrate() (컬럼 DROP ✅)
JdbcTemplate을 직접 쓴 이유는, 이 시점에서 Exposed나 JPA가 완전히 초기화됐다는 보장이 없어서입니다. JdbcTemplate은 DataSource만 있으면 동작하니까 가장 안전했습니다.
멱등성 보장
앱 재시작할 때마다 중복 삽입되면 안 되니까, collectionExists()로 이미 컬렉션이 있으면 통째로 건너뛰게 했습니다.
private fun migrateMetadataToMongo() {
if (mongoTemplate.collectionExists("problem_metadata")) return
jdbcTemplate.queryForList(
"SELECT id, schema_metadata FROM problems WHERE schema_metadata IS NOT NULL"
).forEach { row ->
val doc = Document("problemId", row["id"])
.append("schemaMetadata", parseJson(row["schema_metadata"] as String))
mongoTemplate.insert(doc, "problem_metadata")
}
jdbcTemplate.queryForList(
"SELECT id, init_metadata, answer_metadata FROM test_cases " +
"WHERE init_metadata IS NOT NULL OR answer_metadata IS NOT NULL"
).forEach { row ->
val doc = Document("testCaseId", row["id"])
row["init_metadata"]?.let { doc.append("initMetadata", parseJson(it as String)) }
row["answer_metadata"]?.let { doc.append("answerMetadata", parseJson(it as String)) }
mongoTemplate.insert(doc, "testcase_metadata")
}
}
MySQL 쪽 정리
데이터가 MongoDB로 넘어간 뒤, Flyway가 JSON 컬럼을 제거합니다.
-- V6__remove_json_metadata_columns.sql
ALTER TABLE problems DROP COLUMN schema_metadata;
ALTER TABLE test_cases DROP COLUMN init_metadata;
ALTER TABLE test_cases DROP COLUMN answer_metadata;
이 시점에서 MySQL에는 정형 데이터만, MongoDB에는 메타데이터만 남습니다.
느낀 점
자동화된 마이그레이션은 수작업 대비 시간을 크게 단축시켰습니다. 데이터의 성격을 무시하고 하나의 DB에 모든 걸 맡기면, 결국 그 대가는 운영할 때 돌아온다는 점을 느꼈습니다..😅