toy/AgileHub

[이슈 #3] DB 설계 개선으로 끌어올린 코드 품질과 유지보수성

EVO. 2025. 3. 6. 23:31

리팩토링을 시작하게 된 배경

오래된 제 코드를 살펴보면서 리팩토링의 필요성을 느꼈습니다.

 

리팩토링을 하려는 이유숨겨진 버그나 취약점을 발견하고 수정하기 위함과 가독성을 높여 확장성을 향상시키기 위함 입니다. 리팩토링을 시작하려 했지만, 오래된 코드의 내용이 기억나지 않아서 먼저 코드를 보면서 어떤 요구사항이고 도메인이었는지 떠올려야 했습니다.

 

그러기 위해서는 먼저 테스트부터 작성할 필요성을 느꼈는데, 테스트 커버리지를 확인해보니 라인 커버리지가 25%에 불과했습니다. 제 생각엔 100%까지 목표로 잡지 않아도 핵심 비즈니스 로직에 대해 80%까지만 가도 충분히 리팩토링하는 데에는 도움이 될 것 같았습니다. 왜냐하면 커버리지가 높더라도 테스트 케이스가 부적절하게 작성되었다면 여전히 중요한 버그가 발생할 수 있기 때문입니다. 또한, 모든 코드를 테스트하는 것이 현실적이지 않을 수도 있습니다. 특히 예외 상황이나 경계 조건을 모두 다루기는 어렵습니다.

 

테스트를 작성하기 전에 가장 오래전부터 뜯어고치고 싶었던 것이 있었습니다. 바로 "DB 설계 구조 변경" 입니다.

 

DB 설계 구조 변경

문제상황 

과거 테이블 구조는 다음과 같습니다. 

 

 

Issue 테이블이 에픽(Epic), 스토리(Story), 테스크(Task) 테이블의 부모 역할을 하는 구조였고, number, content, title, status, label 등의 공통 필드를 가집니다. 에픽(Epic), 스토리(Story), 태스크(Task) 테이블은 각각 issue_id를 외래키(FK)로 사용하여 Issue를 상속받습니다. 즉, Issue 테이블의 데이터를 확장하는 형태 입니다.

 

또한, story 테이블은 epic_id를 FK를 가지면서 특정 에픽에 속하고 있고, Task 테이블은 story_id를 FK를 가지면서 특정 스토리에 속하는 설계입니다. 

 

즉, 싱글테이블 전략 대신 JPA에서의 조인 테이블 상속 전략을 사용한 설계였고, 과거에는 정규화를 최대한 진행해서 데이터 중복과 이상현상을 방지하고, 불필요한 null값을 줄이기 위해 팀원과 회의를 하면서 조인형태의 구조를 선택했습니다.

 

이 방식은 객체지향적으로 봤을 때 상속 구조처럼 보이지만, 실제 운영에서는 복잡성을 증가시켰습니다. Join이 많아 성능 이슈 발생이 많았고 (예를들면, 프로젝트에 속한 에픽 통계 구하는 API는 한 애픽에 속한 스토리들의 상태가 필요하기 때문에 Issue, Epic, Story 테이블의 조인이 필요했습니다.), 가장 큰 문제점은 테이블이 분리되어 있어 INSERT/UPDATE가 복잡하여 테스트코드를 작성할때 개발자가 코드상에서 상속 구조를 항상 신경 써야하는 문제점이 있었습니다.  

 

그래서 이 문제를 풀기 위해 단일 테이블 전략으로 바꿔, epic, story, task 테이블을 issue 하나로 통합하는 것이 목표였습니다. 그렇게 하기 위해선 다양한 고려사항이 있었습니다. 

 

기존 테이블을 바로 삭제해버리고 차근차근 시작하면 매우 위험하다고 판단했습니다. 왜냐하면 이슈 테이블에는 약 200만건의 이슈데이터가 있었고, 운영상황이라고 가정했을 때 사용자 경험을 해치지 않으면서 안전하게 마이그레이션 하는게 중요하다고 생각했습니다. 

 

데이터 마이그레이션은 어떻게 해야 할까?

마이그레이션을 수행하는 전략은 3가지가 존재합니다. (실제론 더 많을 수도 있습니다) 

 

빅뱅 마이그레이션

 

빅뱅 마이그레이션은 하나의 소스 시스템에서 대상 데이터베이스로의 일회성 데이터 전송입니다. 사용자가 적게 이용하는 일반적으로 주말 또는 미리 정의된 가동 중지 시간 동안 수행됩니다. 이 기술은 더 빠르고 덜 복잡하다는 장점이 있어 데이터가 적은 소규모 기업에 더 적합합니다. 그러나 단점은 다운타임으로 인해 시스템을 24/7 운영하는 기업에게는 불편합니다.

 

트리클 마이그레이션

 

트리클 마이그레이션(증분 또는 단계적 마이그레이션이라고도 함)은 데이터를 작은 단위로 나누어 점진적으로 이전하는 전략입니다. 모든 데이터를 한 번에 옮기는 빅뱅 방식과 달리, 트리클 마이그레이션은 데이터를 여러 단계로 나누어 천천히 이동시킵니다. 이 접근법은 리스크를 분산시키고 문제 발생 시 영향 범위를 최소화할 수 있습니다. 단점으로는 구현이 복잡하고 전체 마이그레이션 기간이 길어질 수 있으며, 일정 기간 동안 두 시스템을 동시에 운영해야 하는 부담이 있습니다.

 

다운타임 없는 마이그레이션

 

이 전략은 서비스 중단 없이 원본 데이터베이스에서 대상 데이터베이스로 데이터를 지속적으로 동기화합니다. 마이그레이션 과정 중에도 사용자는 기존 시스템을 계속 이용할 수 있어 비즈니스 연속성을 유지할 수 있습니다. 주요 장점으로는 서비스 중단이 없고, 비즈니스 운영에 미치는 영향을 최소화할 수 있다는 점이 있습니다. 하지만 실시간 동기화를 위한 복잡한 도구와 인프라가 필요하고, 설정 및 관리가 까다로우며, 데이터 무결성을 보장하기 위한 추가적인 검증 작업이 필요합니다.

 

선택한 전략

 

현재 실제 운영 중인 시스템이 아니지만 약 200만 건의 이슈 데이터가 있어 규모가 작지 않고, 혹시 모를 문제에 대비하여 단계적으로 검증하면서 진행하기 위해 트리클 마이그레이션 방식을 선택했습니다. 이 방식은 각 단계마다 데이터 검증이 가능하고, 문제 발생 시 해당 단계만 롤백할 수 있어 전체 마이그레이션의 리스크를 줄일 수 있다는 장점이 있습니다. 또한 데이터 구조 변경이 복잡한 만큼, 점진적으로 진행하면서 각 단계의 결과를 확인하는 것이 안전하다고 판단했습니다.

 

 

트리클 마이그레이션은 어떻게 수행 해야할까?

트리클 마이그레이션을 위해 다음과 같은 단계별 접근법을 사용했습니다.

 

1. 데이터 분할

 

데이터를 논리적인 단위로 분리하기 위해 비즈니스 특성에 맞춰 프로젝트별로 나누는 방식을 선택했습니다. 이슈 수가 적은 프로젝트부터 차례대로 마이그레이션을 진행하여 초기에 발생할 수 있는 문제를 작은 규모에서 발견하고 해결할 수 있도록 했습니다. 이 접근법은 각 프로젝트의 특성에 맞게 마이그레이션을 조정할 수 있다는 장점도 있었습니다.

 

2. 프로젝트별 마이그레이션 윈도우 설정

 

각 프로젝트의 데이터 복제 시에는 해당 프로젝트에 대한 짧은 다운타임이 필요했습니다. 사용 패턴을 분석한 결과, 프로젝트 이슈 작업은 주로 업무 시간에 집중되어 있었습니다. 따라서 사용량이 가장 적은 저녁~밤 시간대에 마이그레이션을 수행하기로 결정했습니다.

 

마이그레이션 진행 중에는 해당 프로젝트의 데이터를 읽기전용(Read-only) 상태로 변경했습니다. 이는 마이그레이션 중 데이터 무결성을 유지하고, 애플리케이션 코드가 실수로 기존 테이블을 수정하지 않도록 방지하기 위함이었습니다. 이 방법으로 데이터 일관성을 보장하면서도 사용자는 여전히 데이터를 조회할 수 있었습니다.

 

3. 새로운 테이블 구조 설계 및 생성

 

기존의 분산된 테이블 구조에서 단일 테이블로 전환하기 위해 새로운 new_issue 테이블을 설계했습니다.

새 테이블의 핵심 변경사항은 다음과 같습니다.

 

  • issue_type ENUM('EPIC', 'STORY', 'TASK') 칼럼 추가: 하나의 테이블에서 다양한 유형의 이슈를 구분할 수 있게 함
  • 기존 epic, story, task 테이블에 있던 고유 필드들을 모두 통합
  • 계층 구조를 표현하기 위한 parent_issue_id 필드 추가: 이 필드를 통해 Epic-Story-Task 간의 관계를 유지. Epic은 null 값을, Story는 연관된 Epic의 ID를, Task는 연관된 Story의 ID를 저장하도록 설계

 

 

이 구조는 다음과 같은 이점을 제공했습니다

 

  1. 조인 연산 없이 단일 테이블에서 모든 이슈 정보 조회 가능
  2. 계층 구조를 유지하면서도 쿼리 복잡도 감소
  3. 필요한 경우 이슈 타입별로 필터링하여 기존 동작 유지 가능

 

4. 프로젝트 ID 별로 배치스크립트 수행

 

약 200만 건의 이슈 데이터를 수동으로 마이그레이션하는 것은 비효율적이고 오류 가능성이 높았습니다. 특히 대량의 데이터가 있는 프로젝트에서는 수동 작업이 현실적으로 불가능했습니다. 따라서 프로젝트별 배치 처리 프로시저를 MySQL에서 구현했습니다. 특히 구현할땐 트랜잭션 기반 처리로 데이터 일관성 보장, 검증 로직 구현, 문제 발생 시 롤백 및 로그 기록을 하도록 했습니다.

 

 

5. 애플리케이션 코드 단계적 변경

 

데이터 구조 변경 후, 애플리케이션 코드를 새로운 테이블 구조에 맞게 수정했습니다. 

(Domain -> Repository -> Service -> DTO 순으로 계층 수정) 

 

 

리팩토링을 통한 성과

코드 구조 단순화와 중복 제거

 

기존에는 에픽, 스토리, 태스크의 부모를 가져오기 위해 유형은 다르지만 비슷한 로직을 중복해서 구현하고 있었습니다. 단일 이슈 테이블로 통합한 후에는 관련 클래스 수를 4개에서 1개로 줄일 수 있었습니다. 이는 코드의 가독성과 유지보수성을 크게 향상시켰습니다.

 

객체지향 원칙 적용과 다형성 활용

 

개선 전 코드에서는 각 이슈 타입(Epic, Story, Task)마다 별도의 생성 메서드를 구현하고 있었습니다. 추상 클래스인 Issue를 사용함에도 불구하고, Issue 타입으로 객체를 받으면 다른 로직을 수행할 때 정확한 타입을 확인하기 위해 instanceof로 클래스 타입을 체크하고 불필요한 타입 변환까지 발생했습니다. 이는 다형성을 제대로 활용하지 못하는 안티패턴이었습니다.

 

마이그레이션 후에는 Issue 클래스로 통일하고 타입을 내부 속성으로 관리함으로써 이러한 불필요한 타입 체크와 변환 과정이 해소되었습니다. 결과적으로 코드는 더 명확하고 객체지향적인 설계를 갖게 되었습니다.

 

 

테스트 커버리지 개선 24% -> 42%

 

리팩토링 이전에는 테스트 코드의 라인 커버리지가 24%에 불과했습니다. 개발 초기에는 규모가 작아 기능을 개발하고 Swagger로 빠르게 테스트하는 방식으로 진행했기 때문에 테스트 코드 작성에 충분한 시간을 투자하지 못했습니다.

 

이번 리팩토링에서는 코드 변경과 함께 다음과 같은 고민을 했습니다.

 

  1. Repository에 작성한 네이티브 쿼리가 제대로 작성되었는지 정확히 확인하기 위해 TestContainer를 활용하여 실제 MySQL DB 환경에서 검증했습니다.
  2. 통합 테스트를 할 때 공통적인 더미 데이터가 자주 필요했는데, 이때 @SQL 어노테이션을 사용했습니다. 하지만 테스트 시간이 너무 길어지므로 꼭 필요할 때만 사용했습니다.
  3. 그 외에도 @SQL 없이 공통 더미 데이터는 팩토리 클래스를 통해 테스트마다 유연하게 사용할 수 있도록 했습니다.
  4. 비즈니스 로직만 확인할 때는 단위 테스트에서 Mock을 활용했습니다.

그 결과, 테스트 커버리지를 42%까지 높일 수 있었습니다. 아직 목표인 80%에는 미치지 못하지만, 앞으로 지속적인 리팩토링과 테스트 코드 작성을 통해 점진적으로 개선해 나가고 있습니다. 

 

Jacoco 커버리지 결과

 

 

수정 전 DB 구조 

 

개선된 DB 구조