ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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

    댓글

Designed by Tistory.