📕 목차
1. 개요
2. Function Aspect
3. Architecture Aspect
4. 결론
1. 개요
📌 @SQLRestriction
@SQLRestriction("deleted_at IS NULL")
@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?")
public class User {
...
}
기존의 @Where 어노테이션이 deprecated 되고, 그 대안책으로 사용되는 것이 @SQLRestriction이다.
Entity에 해당 어노테이션이 선언되어 있으면, search 쿼리에 자동으로 where 절이 추가된다.
예를 들어, @SQLRestriction("deleted_at IS NULL")이라고 선언해놓고 User Entity를 findById로 탐색하면 다음과 같은 쿼리가 나간다. (조금 다르긴 한데, 의미는 같다.)
SELECT * FROM user WHERE id=? AND (deleted_at IS NULL)
🤔 이 방식이 문제가 없을까?
너무 편리한 방법이라는 점엔 동의하지만, '조회 시 언제나 @SQLRestriction의 조건절이 붙는 게 위험하지 않을까?'라는 의문이 지속적으로 떠올랐다.
그리고 실제로 해당 어노테이션을 사용함으로써 발생했던 장/단점들을 분석해보았다.
매우 주관적인 내용이므로 참고로만 봐주세요 🤗
2. Function Aspect
📌 장점1. 중복 코드 제거
매우 단순한 케이스부터 살펴보자.
UserService의 findById()와 findByEmail() 그리고 findAll() 메서드는 언제나 delete된 entity의 정보는 무시해야 한다고 가정하자.
그러면 코드는 다음과 같이 구현될 것이다.
@Service
@RequiredArgsConstructor
class UserService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public User findById(Long userId) {
Optional<User> user = userRepository.findById(userId);
if (user.isPresent() && user.isDeleted()) // 데이터가 존재는 하는데, 삭제 정보가 있음
return Optional.empty();
return user.orElseThrow();
}
@Transactional(readOnly = true)
public User findByEmail(String email) {
Optional<User> user = userRepository.findByEmail(email);
if (user.isPresent() && user.isDeleted())
return Optional.empty();
return user.orElseThrow();
}
@Transactional(readOnly = true)
public List<User> findAll() {
List<User> users = userRepository.findAll();
return users.stream().filter(user -> user.isDeleted() == true).toList();
}
}
(이해를 돕기 위해 deletedAt이 아니라 boolean 타입의 deleted 필드로 표현)
User를 조회할 때마다 delete된 정보들을 필터링 하기 위한 조건절이 필요할 뿐 아니라,
애초에 메모리에 적재할 필요가 없었던 데이터들도 모두 가져오게 되므로 명백히 낭비다.
하지만 @SQLRestriction을 사용하면 메서드가 다음과 같이 축약된다.
@Service
@RequiredArgsConstructor
class UserService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public User findById(Long userId) {
return userRepository.findById(userId);
}
@Transactional(readOnly = true)
public User findByEmail(String email) {
return userRepository.findByEmail(email);
}
@Transactional(readOnly = true)
public List<User> findAll() {
return userRepository.findAll();
}
}
어차피 DB에서 가져올 때 delete된 정보들을 모두 걸러냈는데 추가로 필터링할 이유가 없다.
애초에 Application은 삭제된 정보의 존재 자체를 모르며, 메모리에 적재되는 데이터가 적으므로 훨씬 효율적이다.
📌 장점2. 단순한 메서드와 @Query 의존성 제거
여전히 @SQLRestriction의 아름다움에 의문을 품는 사람들은 다음과 같이 코드를 작성할 수도 있을 것이다.
@Transactional
public void findById(Long id) {
return userRepository.findByIdAndDeletedFalse(id);
}
혹은 @Query를 사용하면 이런 방법도 가능하다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u WHERE u.deleted = true")
List<User> findDeletedUsers();
}
Application은 Soft Delete 정책의 존재를 알게 되긴 하지만, Service나 Repository의 호출자에게선 해당 사실을 숨길 수 있다.
하지만 그럼 모든 조회문마다 DeletedFalse를 추가하거나 @Query를 선언해줘야 한다는 오버헤드가 발생한다.
📌 단점1. 제공해야 할 메서드의 증가
매우 작은 규모의 서비스를 제공하고 끝낼 거라면 @SQLRestriction을 사용하는 것은 매우 현명한 해결책일 수 있다.
하지만 작은 규모로 시작하더라도 지속적인 운영을 이어나갈 생각이라면 굉장히 머리 아파지는 상황이 발생한다.
바로 백 오피스 기능이다.
관리자가 사용자 전체를 조회할 때는 일반적으로 soft delete 처리 된 사용자 정보도 조회하고 싶은 경우가 많다.
그렇데 @SQLRestriction으로 매번 자동으로 query가 추가되어버리니 findAll() 메서드를 활용할 수 없다.
그렇다면 관리자용 조회 메서드를 별도로 추가해야 한다.
EntityManager를 직접 사용하면 @SQLRestriction으로 인해 자동으로 조건절이 추가되는 것을 우회할 수 있다.
그럼 관리자를 위한 모든 메서드를 UserCustomeRepository에서 정의해주어야 하는 오버헤드가 발생한다.
📌 단점2. 삭제 취소의 어려움
이건 정책이 잘못 됐다고 볼 수도 있는데, 내가 이야기하고 싶은 경우는 이런 경우다.
블로그 포스트나 피드 정보같은 데이터는 일반적으로 사용자가 복구할 수도 없고, 하더라도 관리자에게 요청해야 하므로 크게 신경쓰지 않아도 된다. (만약 휴지통같은 괴랄한 기능을 지원한다면 모를까..)
그런데 사용자 관점이 아닌 시스템 자체적으로 빈번한 삭제와 생성을 막기 위해 Soft Delete로 처리하는 경우도 분명히 존재한다.
(좋아요 정보를 사용자가 누를 때마다 DB에 저장하고 삭제하기를 반복할 것인가?)
Like 테이블에 SoftDelete 정책을 사용하기로 했다고 가정하자.
처음 좋아요 요청에는 테이블에 정보가 추가 되겠지만, 그 다음 취소 요청에는 정보가 삭제되지 않고 "삭제되었다"라는 기록을 남길 것이다.
그리고 사용자가 다시 좋아요 요청을 하면 이 삭제 기록을 취소시켜야 한다.
그런데 웬걸? @SQLRestriction 때문에 기존 좋아요 데이터가 존재하는지 여부를 알 수가 없다.
설령 방법이 있다고 하더라도 신규 추가와 수정을 구분해주어야 하므로 서비스 분기 로직이 과도하게 복잡해질 우려가 존재한다.
📌 단점3. 테스트의 어려움
이전 포스팅에서 테스트 케이스를 작성하면서 느낀점은 테스트가 굉장히 힘들어진다는 점이었다.
단순히 삭제가 됐는지만 판단하고 끝낼 것이라면 문제가 없지만, 삭제된 정보를 불러와서 검증이 필요한 경우 entity manager를 사용해서 호출해야 한다.
문제는 통합 테스트 환경에서 JpaRepository와 EntityManager를 혼용했더니 별도의 DB Connection을 차지하여 DeadLock에 빠지는 경우도 허다했다.
3. Architecture Aspect
📌 관심사 분리의 실패 (`24.05.14 내용 오류 마지막에 증명)
서비스 규모가 어느정도 커지면 대부분 Service의 메서드 하나에 모든 로직을 넣는 게 얼마나 비효율적인지 알게 된다.
그래서 Service Layer를 분리하여 책임을 나누기 시작할 텐데, Repository 메서드를 호출하는 단순한 CRUD 기능은 DomainService 로직을 처리하는 서비스가 담당하게 될 것이다.
핵심 비지니스 로직은 상위 수준의 Service가 담당하게 되었으므로, UserDomainService는 DAO를 외부에 노출시키는 것을 막고, 도메인 비지니스 로직을 처리하는 관심사만을 가져야 마땅하다.
여기서 문제가 발생하는 점은 UserDomainService를 주입받는 Service가 얼마나 많을 지 알 수 없으며, 정보 조회를 하는 목적 또한 천차만별이라는 점이다.
이전 예시에서 언급했던대로 서비스를 사용하는 사용자 정보를 조회하는 AdminService가 나타난다면?
UserDomainService가 soft delete된 사용자를 멋대로 필터링 해버리므로 일반적인 방식으론 목적을 충족할 수 없게 된다.
이건 단순히 Soft Delete를 무시하는 메서드를 추가하냐 마냐의 문제가 아니다.
서비스가 Soft Delete라는 "정책"을 채용하고 있다는 것을 Domain Service가 알고 있다는 아키텍처 상의 설계 오류다.
Domain Service는 업무 규칙에 관심도 없으며, 알 수도 없어야 한다.
그런데 @SQLRestriction에 의해 핵심 정책에 매우 관심이 많은 아이가 되어버렸으니 이런 문제가 발생하는 것이다.
📌 멀티 모듈에서의 문제
새로운 문제가 발생한다기 보단 아까와 마찬가지로 관리자 api가 생성되는 경우를 생각해보자.
이게 왜 관심사 분리의 실패라고 주장하는지를 보다 명확하게 이해할 수 있다.
Domain 모듈을 External-Api만 사용하고 있는 개발 초기 당시에는 @SQLRestriction 존재 여부가 크게 상관 없을 수 있다.
내부 시스템을 어떻게 설계하냐에 따라 다르겠지만, 일반적으로 처음 개발 시점부터 엄청 복잡한 기능이 나타나진 않을테니 soft delete 조회를 위한 메서드를 조금 추가해주는 게 낫다고 볼 수 있다.
자, External-Api 모듈을 REST API 방식으로 개발을 끝내고 백 오피스 기능을 개발해보도록 하자.
백 오피스 기능은 Server Side Rendering 방식으로 반환하고 싶기에 모듈을 하나 더 만들어주기로 했다.
Admin-Api 모듈 또한 DB에서 정보를 조회하기 위해서는 Domain 모듈의 Service가 제공하는 메서드를 사용해야 한다.
(Repository를 Application 계층의 모듈이 직접 활용하는 것은 금지한다.)
그런데 Domain 모듈이 External-api 맞춤형으로 테라포밍 되어 있기 때문에 Admin-Api는 원하는 정보를 조회하지 못한다.
🙋♂️ : 이것도 Domain 모듈 내의 Service가 메서드를 추가해주면 되는 문제 아닌가요?
🙅♂️ : 되긴 하지만..그게 정말 옳은 설계 방식일까요?
💡 의존성 역전 원칙: 고수준 정책을 포함하는 코드는 저수준 세부사항을 구현하는 코드에 의존해선 안 된다.
Soft Delete 정책을 준수하다가 효용 가치가 없다고 판단하여 Hard Delete 정책으로 전환하기로 결정했다고 치자.
(Entity에 선언된 @SQLRestriction과 @SQLDelete 어노테이션을 제거해버리면 끝난다.)
그럼 이제 Domain Service에 의존하던 상위 서비스에서 난데없이 테스트 케이스가 죄다 실패하는 현상을 볼 수 있을 것이다.
이는 Domain Service가 핵심 정책에 관심이 너무 많기 때문에 발생하는 일이다.
즉, Domain Service가 Soft Delete 정책을 반영하기로 결정하는 순간 고수준 서비스가 저수준 서비스의 결과값에 의존하게 되는 문제가 발생한 것이다.
📌 (`24.05.14) 그런데 이게 정말 관심사 분리의 실패라 볼 수 있을까?
이 글을 작성할 때까지만 하더라도, 이건 "관심사 분리의 실패로 인한 이슈야!"라고 주장했었다.
그런데 좀 더 지나고 보니 이게 맞나...싶은 게, 관심사 분리의 실패라고 보기는 힘들지 않을까? 라는 생각이 들었다.
Soft Delete를 사용하는 것은 비지니스 로직이 아니라, 도메인 로직. 즉, 도메인 영역의 정책 수준에 해당한다고 생각한다.
그렇다면 위의 사례는 그저 적절하지 못 한 상황에서 @SQLRestriction을 사용한 게 문제지, 관심사 분리의 실패라고 보는 것은 억지가 아니었을까 싶다.
4. 결론
📌 사용하지 않는 게 최선인가?
@SQLRestriction을 절대 사용해선 안 된다고 하기엔, 해당 어노테이션이 주는 이점 또한 분명하다.
만약 삭제된 후에 상태가 변경될 필요가 없는 데이터라고 한다면 합리적인 방식이라고 생각한다.
하지만 위의 예시에서 들었던 좋아요 테이블처럼 사용하기 위한 목적이라면, 차라리 @SQLRestriction을 제거하고 핵심 비지니스 로직에서 처리하는 것이 나을 수도 있다.
조회해야 할 데이터가 너무 많을 것 같다면, 차라리 Domain Service에 Where 절을 추가하여 조회하는 메서드를 하나 더 정의해주자.
모든 방법에는 트레이드 오프가 존재한다.
내가 낸 결론 또한 주관적인 의견일 뿐이고, 어쩌면 틀렸을 수도 있지만 무지성으로 @SQLRestriction을 선언해버리면 분명 피를 보게 될 것이다. (나처럼..)