-
Flyway 이용기🧑🏻💻 프로젝트/motimo 2025. 6. 15. 15:18
✍️ 상황
최근 진행 중인 프로젝트에서 TodoEntity와 TodoResultEntity 간의 관계를 어떻게 표현할지에 대해 고민할 일이 있었습니다.
TodoResultEntity는 TodoEntity와 1:1 관계를 가지고 있지만 ORM 상에서는 연관관계 매핑을 하지 않고 단순히 todoId를 저장하는 방식을 택했습니다.
@Column(name = "todo_id", nullable = false) private UUID todoId;
- 연관관계를 명시적으로 선언하는 대신 ID를 직접 관리하면서 느슨하게 연결하는 구조로 설계
❗ 팀원 리뷰에서 지적된 문제
이 설계에 대해 팀원으로부터 다음과 같은 리뷰를 받았습니다.
TodoResult와 Todo는 연관 관계를 맺어주는 게 좋지 않을까요?
ID만 저장하는 방식으로 간다면 실제 DB에서는 외래 키(FK)를 설정하실 예정이신가요?
데이터 무결성과 안정성 확보 측면에서 FK가 중요하다고 생각합니다.
🤔 고민: 객체의 느슨한 연결 vs 데이터의 강한 무결성
저는 현재 도메인 모델과 ORM 엔티티를 분리해 관리하고 있고 도메인 모델에서는 해당 관계를 명시적으로 표현하고 있기 때문에 ORM에서는 ID만 저장하는 형태로도 충분하다고 판단했습니다.
- TodoResult는 Todo 없이도 존재하지 않는 것은 맞지만 항상 생성되는 값이 아니라 생명주기를 완전히 공유하지 않는다 생각
- TodoResult는 나중에 따로 생성/수정/삭제될 수 있음.
- 연관관계 매핑(@OneToOne 등)을 사용하면 서비스/테스트 로직에서 불필요한 결합이 생길 수 있다고 생각
- 도메인 모델에서 이미 관계를 명시적으로 표현(개발자가 직접 관리) 하고 있으므로 ORM에서는 불필요하게 JPA가 연관 객체를 관리할 필요가 없다고 생각했습니다.
하지만, 리뷰처럼 ID만 저장하는 방식은 데이터 무결성을 완전히 보장해주지 못할 수 있다는 점도 중요하다고 생각을 하게 되었습니다.
즉, 코드 상에서는 연관관계를 맺지 않아도 되지만
- 존재하지 않는 Todo ID를 가진 TodoResult가 DB에 들어가는 것을 방지
- ORM이 아닌 SQL로 직접 조작하는 경우에도 무결성을 보호
- DB 레벨에서는 Todo가 삭제된 후에도 TodoResult가 남아있는 비정상 상태를 막을 수 있어야 합니다.
따라서 FK 제약조건은 필요하다는 결론을 냈습니다.
😎 Flyway를 통한 FK 제약조건 추가
코드 상에서는 Id(UUID)만 저장하는 구조를 계속 사용하고 실제 DB에는 FK를 설정하여 무결성을 보장하기로 했습니다.
이 작업은 Flyway를 통해 적용하기로 결정했습니다.
Flyway는 DB 마이그레이션 도구로 스키마 버전 관리와 제약조건 추가를 안전하게 적용할 수 있게 해줍니다.
적용한 마이그레이션 스크립트 예시
CREATE TABLE todo ( id UUID PRIMARY KEY, // .... is_deleted BOOLEAN DEFAULT FALSE ); CREATE TABLE todo_result ( id UUID PRIMARY KEY, todo_id UUID NOT NULL, // ... is_deleted BOOLEAN DEFAULT FALSE, FOREIGN KEY (todo_id) REFERENCES todo (id) ON DELETE CASCADE; );
- ON DELETE CASCADE를 통해 관련 Todo가 삭제될 경우 연결된 TodoResult도 함께 삭제되도록 설정했습니다.
이 스크립트는 V2__{....}.sql 등의 이름으로 resources/db/migration 디렉토리에 추가해 서버 시작 시 자동으로 적용되도록 했습니다.
🤹 Flyway 외의 다른 방법은?
1. JPA @JoinColumn + @OneToOne 연관관계로 자동 FK 생성
ORM 상에서 연관관계를 명시하면 JPA가 테이블 생성 시 자동으로 FK 제약조건을 생성해줍니다.
@OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "todo_id", foreignKey = @ForeignKey(name = "fk_todo_result_todo_id")) private TodoEntity todo;
✅ 장점
- 객체지향적으로 자연스러운 설계
- 자동으로 FK 생성 가능
⚠️ 단점
- 때때로 필요 이상의 join이 발생해 성능 문제 유발 가능
- 도메인 모델과 ORM을 분리하고 싶은 경우 부적절하다 생각
2. DB 초기 스키마 작성 시 DDL 직접 작성
초기 프로젝트 세팅 시 SQL로 직접 FK를 포함한 테이블 생성 스크립트를 작성할 수도 있습니다.
CREATE TABLE todo_result ( id UUID PRIMARY KEY, todo_id UUID NOT NULL, ... FOREIGN KEY (todo_id) REFERENCES todo(id) );
✅ 장점
- 단순하고 명시적
⚠️ 단점
- 변경 이력 관리가 어렵고 협업 시 충돌 가능성 있음
- 이후 변경에 수동 대응 필요
3. Liquibase 사용
Flyway와 유사한 도구로 XML/YAML/JSON 등 다양한 방식으로 마이그레이션을 작성할 수 있는 도구입니다.
✅ 장점
- 보다 세밀한 제어 가능 (조건부 실행 등)
- GUI 도구와 연동 등 다양한 옵션 제공
⚠️ 단점
- 러닝 커브가 존재
- Flyway보다 복잡한 설정이 요구될 수 있음
📌 그래서 왜 Flyway를 선택했을까?
- SQL 기반 마이그레이션이 명시적이라 관리가 용이
- DB 제약조건 변경 작업을 코드 배포와 함께 안정적으로 진행
😅 Soft Delete 환경에서의 또 다른 문제
이렇게 ON DELETE CASCADE를 통해 Todo가 삭제되면 연결된 TodoResult도 함께 삭제되도록 설계했었습니다.
하지만 여기서 문제가 발생했습니다.
저희는 실제 삭제가 아닌 Soft Delete, 즉 is_deleted 값을 true로 변경하는 방식으로 삭제를 처리하고 있었습니다.
❌ 문제점: ON DELETE CASCADE는 Soft Delete에서 작동하지 않는다
-- Soft Delete 예시 UPDATE todo SET is_deleted = true WHERE id = ?;
이 방식에서는 실제로 DELETE 쿼리가 실행되지 않아 DB 차원에서 연관된 todo_result를 자동으로 삭제해주는 ON DELETE CASCADE는 작동하지 않습니다.
🔄 해결 방안: 연관된 Soft Delete는 애플리케이션에서 직접 처리
이에 따라 Todo 삭제 시 연관된 TodoResult도 서비스 로직에서 함께 Soft Delete 처리하도록 구현하기로 했습니다.
예시(최종 코드 ❌)
@Transactional public void deleteTodo(UUID todoId) { TodoEntity todo = todoRepository.findById(todoId) .orElseThrow(...); todo.softDelete(); List<TodoResultEntity> results = todoResultRepository.findByTodoId(todoId); for (TodoResultEntity result : results) { result.softDelete(); } }
👍 장점
- Soft Delete 정책과 일관성 유지
- 복잡한 트리거나 DB 내 조건부 삭제 없이 서비스 단에서 명확히 제어 가능
- 도메인 로직이 명시적이고 테스트 가능함
💡 그렇다면 FK는 왜 유지할까?
Soft Delete 환경에서도 FK는 여전히 중요!!
데이터 무결성을 보장하고 존재하지 않는 Todo에 TodoResult를 삽입하는 등의 실수를 방지할 수 있기 때문입니다.
따라서, 삭제 시점의 연쇄 동작만 직접 처리!!
✅ 정리
- ORM 설계는 기존처럼 단순한 UUID 필드로 유지하면서도
- 데이터베이스에서는 FK를 통해 데이터 무결성과 안정성을 확보할 수 있었습니다.
🔗 참고
'🧑🏻💻 프로젝트 > motimo' 카테고리의 다른 글
UUID v4 / UUID v7 (0) 2025.05.30 CQS / CQRS 패턴 적용기 (1) 2025.05.25