안녕세계

[JPA] Soft Delete 적용하기 : @Where vs @SQLRestriction 본문

[JPA] Soft Delete 적용하기 : @Where vs @SQLRestriction

Junhong Kim 2025. 3. 15. 13:04
728x90
반응형

JPA에서 논리적 삭제(Soft Delete)를 구현할 때 자주 사용하는 방법으로 Hibnerate의 @Where@SQLRestriction 어노테이션이 있습니다. 두 어노테이션 모두 특정 컬럼 값을 기준으로 데이터를 자동으로 필터링할 수 있도록 지원합니다. 하지만, 각 어노테이션은 Hibernate 버전에 따라 지원 여부와 동작 방식에 차이가 있으며, 특히 Hibernate 5.x의 @Where는 JOIN 연산 시 전역 조건이 제대로 적용되지 않는 한계가 있습니다. 본 글에서는 실제 테스트 사례를 통해 @Where 어노테이션의 한계를 분석하고, 이를 해결할 수 있는 Hibernate 6.x의 @SQLRestriction 어노테이션을 소개합니다.

@Where 어노테이션과 한계

@Where는 엔티티 클래스에 자동으로 조건을 추가하여 데이터를 필터링할 때 사용됩니다. 다음은 논리적 삭제가 적용된 예시 엔티티입니다.

@Where(clause = "deleted = 0") // 논리적 삭제를 위한 전역 조건 설정
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
    @Id
    @GeneratedValue
    private Long id;

    private String name;
    
    @JsonIgnore
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
    private int deleted;
    // ...
}
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    
    private String username;
    
    private int age;
    
    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    // ...
}

위 설정 후 다음 QueryDSL 쿼리를 실행하면, 기본적으로 `deleted`가 0인 데이터만 조회하는 쿼리가 실행됩니다.

queryFactory
    .select(team)
    .from(team)
    .where(team.id.eq(id))
    .fetchOne();

---

select
    team0_.id as id1_2_,
    team0_.deleted as deleted2_2_,
    team0_.name as name3_2_
from team team0_
where (team0_.deleted = 0) and team0_.id=?

하지만, Hibernate 5.x에서 @Where를 적용한 엔티티를 JOIN 연산할 경우 조건이 전역으로 적용되지 않는 문제가 발생합니다.

queryFactory
    .select(member)
    .from(member)
    .join(member.team, team)
    .where(member.id.eq(id))
    .fetchOne();
    
---

select
    member0_.member_id as member_i1_1_,
    member0_.age as age2_1_,
    member0_.team_id as team_id4_1_,
    member0_.username as username3_1_
from member member0_
inner join team team1_ on member0_.team_id=team1_.id // deleted 조건 없음
where member0_.member_id=?

위 처럼, 기대와 달리 JOIN 데이터에는 논리적 삭제 조건(deleted = 0)이 제대로 반영되지 않아, 논리적으로 삭제된 데이터가 조회될 수 있습니다. 이 결과에 대한 내용은 hibernate migration guide에도 작성되어 있는 것을 확인할 수 있었습니다.

@Where annotation

The @Where annotation was always meant as a way of supporting "soft deletes", allowing the records of an entity which do not respect a simple WHERE clause to be completely ignored by Hibernate.
In Hibernate 5.x, this was true only when fetching entities or collections of entities having this annotation. As of Hibernate 6.0, the clause specified by a @Where applied to an entity type is used in all interactions Hibernate has the with that entity type. This includes, for instance, when deleting or updating records using mutation queries (e.g. DELETE FROM MyEntity where id = :id), for which in 5 the annotation’s clause was not applied. This is consistent with the purpose of the annotation, which is to always disregard records not respecting the specified condition.

@SQLRestriction 어노테이션으로 전환

Hibernate 6.3 부터 `@Where` 어노테이션은 Deprecated 되었고, 대체 수단으로 @SQLRestriction 사용이 권장됩니다. @SQLRestriction을 사용하는 엔티티로 전환하면 다음과 같습니다.

@SQLRestriction("deleted = 0") // @Where를 대체합니다.
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
    @Id
    @GeneratedValue
    private Long id;
    
    private String name;
    
    @JsonIgnore
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
    private int deleted;
    // ...
}

위 설정 후 다음 JOIN 연산할 경우 조건이 전역으로 적용되지 않는 문제가 해결됩니다. 즉, JOIN 연산시에도 기본적으로 `deleted = 0`인 데이터만 조회하는 쿼리가 실행됩니다.

queryFactory
   .select(member)
   .from(member)
   .join(member.team, team)
   .where(member.id.eq(id))
   .fetchOne();

----

SELECT m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
FROM member m1_0
JOIN team t1_0
    ON t1_0.id=m1_0.team_id
        AND (t1_0.deleted = 0) // JOIN 시에도 deleted 적용 됨
WHERE m1_0.member_id=?

@SQLRestriction 어노테이션은 JOIN 연산 시에도 필터 조건이 정상적으로 적용되며, 복잡하거나 더 정교한 조건을 설정할 수 있는 유연성을 제공합니다. 또한, Hibernate 6.x에서 권장되는 방법입니다.

@Where 와 @SQLRestriction 의 SQL 실행결과 테스트

앞선 예시에서 간단한 케이스들에 대해 테스트해보았지만, 혹시 모를 예외 케이스를 확인하기 위해 Hibernate 5.x와 Hibernate 6.x 에서 다양한 사용 사례를 테스트해 보았습니다. 참고로 엔티티 코드는 사용된 어노테이션 에 따라 앞선 예시 코드와 같습니다.

순번 Hibernate 5.4.22 + @Where 실제 실행된 SQL deleted 적용 여부
 from(team)
1 .select(team)
.from(team)
.where(team.id.eq(id))
.fetchOne();
SELECT team0_.id AS id1_2_,
         team0_.deleted AS deleted2_2_,
         team0_.name AS name3_2_
FROM team team0_
WHERE (team0_.deleted = 0)
        AND team0_.id=?
2 .select(team)
.from(team)
.join(team.members, member)
.where(team.id.eq(id))
.fetchOne();
SELECT team0_.id AS id1_2_,
         team0_.deleted AS deleted2_2_,
         team0_.name AS name3_2_
FROM team team0_
INNER JOIN member members1_
    ON team0_.id=members1_.team_id
WHERE (team0_.deleted = 0)
        AND team0_.id=?
3 .select(new QMemberTeamDto(
    member.id.as("memberId"),
    member.username,
    member.age,
    team.id.as("teamId"),
    team.name.as("teamName")
))
.from(team)
.join(team.members, member)
SELECT members1_.member_id AS col_0_0_,
         members1_.username AS col_1_0_,
         members1_.age AS col_2_0_,
         team0_.id AS col_3_0_,
         team0_.name AS col_4_0_
FROM team team0_
INNER JOIN member members1_
    ON team0_.id=members1_.team_id
WHERE (team0_.deleted = 0)
        AND team0_.id=?
 from(member)
4 .select(member)
.from(member)
.join(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT member0_.member_id AS member_i1_1_,
         member0_.age AS age2_1_,
         member0_.team_id AS team_id4_1_,
         member0_.username AS username3_1_
FROM member member0_
INNER JOIN team team1_
    ON member0_.team_id=team1_.id
WHERE member0_.member_id=?
5 .select(
    new QMemberTeamDto(
        member.id.as("memberId"),
        member.username,
        member.age,
        team.id.as("teamId"),
        team.name.as("teamName")
    )
)
.from(member)
.join(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT member0_.member_id AS col_0_0_,
         member0_.username AS col_1_0_,
         member0_.age AS col_2_0_,
         team1_.id AS col_3_0_,
         team1_.name AS col_4_0_
FROM member member0_
INNER JOIN team team1_
    ON member0_.team_id=team1_.id
WHERE member0_.member_id=?
6 .select(member)
.from(member)
.leftJoin(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT member0_.member_id AS member_i1_1_,
         member0_.age AS age2_1_,
         member0_.team_id AS team_id4_1_,
         member0_.username AS username3_1_
FROM member member0_ left outer
JOIN team team1_
    ON member0_.team_id=team1_.id
WHERE member0_.member_id=?
7 .select(
    new QMemberTeamDto(
        member.id.as("memberId"),
        member.username,
        member.age,
        team.id.as("teamId"),
        team.name.as("teamName")
    )
)
.from(member)
.leftJoin(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT member0_.member_id AS col_0_0_,
         member0_.username AS col_1_0_,
         member0_.age AS col_2_0_,
         team1_.id AS col_3_0_,
         team1_.name AS col_4_0_
FROM member member0_ left outer
JOIN team team1_
    ON member0_.team_id=team1_.id
WHERE member0_.member_id=?
순번 Hibernate 6.6.8 + @Where 실제 실행된 SQL deleted 적용 여부
 from(team)
1 .select(team)
.from(team)
.where(team.id.eq(id))
.fetchOne();
SELECT t1_0.id,
        t1_0.deleted,
        t1_0.name
FROM team t1_0
WHERE (t1_0.deleted = 0)
        AND t1_0.id=?
2 .select(team)
.from(team)
.join(team.members, member)
.where(team.id.eq(id))
.fetchOne();
SELECT t1_0.id,
        t1_0.deleted,
        t1_0.name
FROM team t1_0
JOIN member m1_0
    ON t1_0.id=m1_0.team_id
WHERE (t1_0.deleted = 0)
        AND t1_0.id=?
3 .select(new QMemberTeamDto(
    member.id.as("memberId"),
    member.username,
    member.age,
    team.id.as("teamId"),
    team.name.as("teamName")
))
.from(team)
.join(team.members, member)
SELECT m1_0.member_id,
        m1_0.username,
        m1_0.age,
        t1_0.id,
        t1_0.name
FROM team t1_0
JOIN member m1_0
    ON t1_0.id=m1_0.team_id
WHERE (t1_0.deleted = 0)
        AND t1_0.id=?
 from(member)
4 .select(member)
.from(member)
.join(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
FROM member m1_0
JOIN team t1_0
    ON t1_0.id=m1_0.team_id
        AND (t1_0.deleted = 0)
WHERE m1_0.member_id=?
5 .select(
    new QMemberTeamDto(
        member.id.as("memberId"),
        member.username,
        member.age,
        team.id.as("teamId"),
        team.name.as("teamName")
    )
)
.from(member)
.join(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.username,
        m1_0.age,
        t1_0.id,
        t1_0.name
FROM member m1_0
JOIN team t1_0
    ON t1_0.id=m1_0.team_id
        AND (t1_0.deleted = 0)
WHERE m1_0.member_id=?
6 .select(member)
.from(member)
.leftJoin(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
FROM member m1_0
WHERE m1_0.member_id=?
⚠️
7 .select(
    new QMemberTeamDto(
        member.id.as("memberId"),
        member.username,
        member.age,
        team.id.as("teamId"),
        team.name.as("teamName")
    )
)
.from(member)
.leftJoin(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.username,
        m1_0.age,
        t1_0.id,
        t1_0.name
FROM member m1_0
LEFT JOIN team t1_0
    ON t1_0.id=m1_0.team_id
        AND (t1_0.deleted = 0)
WHERE m1_0.member_id=?
순번 Hibernate 6.6.8 + @SQLRestriction 실제 실행된 SQL deleted 적용 여부
 from(team)
1 .select(team)
.from(team)
.where(team.id.eq(id))
.fetchOne();
SELECT t1_0.id,
        t1_0.deleted,
        t1_0.name
FROM team t1_0
WHERE (t1_0.deleted = 0)
        AND t1_0.id=?
2 .select(team)
.from(team)
.join(team.members, member)
.where(team.id.eq(id))
.fetchOne();
SELECT t1_0.id,
        t1_0.deleted,
        t1_0.name
FROM team t1_0
JOIN member m1_0
    ON t1_0.id=m1_0.team_id
WHERE (t1_0.deleted = 0)
        AND t1_0.id=?
3 .select(new QMemberTeamDto(
    member.id.as("memberId"),
    member.username,
    member.age,
    team.id.as("teamId"),
    team.name.as("teamName")
))
.from(team)
.join(team.members, member)
SELECT m1_0.member_id,
        m1_0.username,
        m1_0.age,
        t1_0.id,
        t1_0.name
FROM team t1_0
JOIN member m1_0
    ON t1_0.id=m1_0.team_id
WHERE (t1_0.deleted = 0)
        AND t1_0.id=?
 from(member)
4 .select(member)
.from(member)
.join(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
FROM member m1_0
JOIN team t1_0
    ON t1_0.id=m1_0.team_id
        AND (t1_0.deleted = 0)
WHERE m1_0.member_id=?
5 .select(
    new QMemberTeamDto(
        member.id.as("memberId"),
        member.username,
        member.age,
        team.id.as("teamId"),
        team.name.as("teamName")
    ) 
)

.from(member)
.join(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.username,
        m1_0.age,
        t1_0.id,
        t1_0.name
FROM member m1_0
JOIN team t1_0
    ON t1_0.id=m1_0.team_id
        AND (t1_0.deleted = 0)
WHERE m1_0.member_id=?
6 .select(member)
.from(member)
.leftJoin(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username
FROM member m1_0
WHERE m1_0.member_id=?
⚠️
7 .select(
    new QMemberTeamDto(
        member.id.as("memberId"),
        member.username,
        member.age,
        team.id.as("teamId"),
        team.name.as("teamName")
    )
)
.from(member)
.leftJoin(member.team, team)
.where(member.id.eq(id))
.fetchOne();
SELECT m1_0.member_id,
        m1_0.username,
        m1_0.age,
        t1_0.id,
        t1_0.name
FROM member m1_0
LEFT JOIN team t1_0
    ON t1_0.id=m1_0.team_id
        AND (t1_0.deleted = 0)
WHERE m1_0.member_id=?

 

테스트를 통해 알 수 있던 사실은 Hibernate 6.x 버전으로 올라오면, @Where 어노테이션도 JOIN 연산시 전역 조건이 정상적으로 적용된다는 것입니다. 하지만, 앞서 이야기한 것 처럼 Hibernate 6.3 버전 부터는 @Where 어노테이션이 deprecated 되었기 때문에, @SQLRestriction 어노테이션을 사용할 것을 권장드립니다.

 

그리고 테스트시 예상 결과와 다른 것이 있었는데, 테스트 순번6에서 실행된 쿼리입니다. Hibernate 5.x 버전에서는 left join이 추가된 쿼리가 실행되었는데, Hibernate 6.x 버전에서는 left join이 제거된 쿼리가 실행되었습니다. 이는 Hibernate 6.x 버전으로 업데이트되면서 일부 쿼리 생성 로직이 변경되지 않았을까 추측됩니다. 이 경우 Hibernate가 쿼리 실행 결과에 불필요한 left join을 제거한 것으로 보입니다.

Hibernate 6.x 업그레이드시 고려 사항

Hibernate 6.x는 Spring Boot 3.x 이상에서 지원됩니다. 따라서 기존에 Spring Boot 2.x를 사용하는 경우 다음과 같은 업그레이드 과정을 거쳐야 합니다. (이외 추가로 고려해야할 것이 많지만, 본 포스팅에서 논외이므로 주요 내용만 설명합니다)

  • Spring Boot 3.x로 업그레이드 필요
    • Hibernated 6.x는 Spring Boot 3.x에 기본 포함됩니다.
  • Java 17 이상으로 업그레이드 필요
    • Spring Boot 3.x는 Java 17+ 버전이 필요합니다.
  • Jakarata EE 기반으로 패키지 변경 필요
    • 기존 javax 패키지에서 jakarta로 변경이 필요합니다.
      • 예: javax.persistence.Entity -> jakarata.persistence.Entity

요약

본 포스팅에 대해 요약하자면 다음과 같습니다. Hibernate 5.x의 @Where는 JOIN 연산 시 전역 조건이 적용되지 않는 한계가 있습니다. 따라서, Hibernate 6.3 이상부터는 @SQLRestriction을 사용하는 것이 권장됩니다. 전역 설정을 통해 Soft Delete 기능을 안정적으로 제공하려면 Hibernate 6.x 및 Spring Boot 3.x로의 업그레이드 후 @SQLRestriciton 사용해야합니다. 따라서, JPA에서 Soft Delete 전역 조건을 제대로 활용하고자 한다면 Hibernate 6.x 버전의 @SQLRestriction 사용할 것을 고려해주세요.

참고

https://github.com/hibernate/hibernate-orm/blob/6.0/migration-guide.adoc#where-annotation

728x90
반응형

'Server > JPA' 카테고리의 다른 글

[JPA] 성능 최적화  (0) 2023.05.06
Comments