개요
최근, 이전에 개발했던 사이드 프로젝트를 리팩토링하고 있다. 이것저것 다양한 시도를 해보면서 생각보다 코드가 많이 바뀌었는데, 그 과정에서 멀쩡히 돌아가던 기능이 갑자기 실패하는 이슈가 발생했다 😇 🔫
다른 방법으로 해결할 수도 있었지만 그러면 진 느낌이 들어서 붙잡고 끙끙댔던 코드.. 겨우 찾은 원인은 생각보다 간단했지만, 내가 사용한 어노테이션과 영속성 컨텍스트 동작 원리를 모르면 또 제대로 이해하기 어려운 이슈라고 생각되어 포스팅해본다. (지금도 분해서 앓고 있음)
문제의 코드
코드를 보기 전에 간단히 도메인 컨텍스트를 설명하면 다음과 같다.
- 카테고리는 하위에 여러 개의 북마크를 가질 수 있고, 북마크는 무조건 하나의 카테고리에 속해야 한다. (즉 일대다 관계)
- 카테고리를 삭제하면 하위 북마크들도 모두 삭제되어야 한다.
- 카테고리와 북마크를 삭제할 때는 Soft delete를 적용한다. (deleted_at에 현재 시간을 할당하는 방식)
이를 구현한 코드가 바로 아래의 코드이다.
// CategoryFacade.java : 카테고리에 대한 요청이지만 다른 도메인도 얽혀있어 Facade를 두었다.
@Transactional
public void delete(Long categoryId) {
Category category = categoryReadService.findById(categoryId);
categoryWriteService.delete(category);
bookmarkWriteService.deleteByCategory(category);
}
// CategoryWriteService.java : Category 도메인에 대한 write 요청을 관리한다.
@Transactional
public void delete(Category category) {
category.delete();
}
// BookmarkWriteService.java : Bookmark 도메인에 대한 write 요청을 관리한다.
@Transactional
public void deleteByCategory(final Category category) {
Member author = category.getMember();
LocalDateTime now = TimezoneHandler.getNowInTimezone(author.getTimezone());
bookmarkRepository.deleteByCategory(category.getId(), now);
}
// BookmarkRepository.java : Bookmark 도메인에 대한 JpaRepository
@Modifying(clearAutomatically = true)
@Query("update Bookmark b set b.deletedAt = :deletedAt WHERE b.category.id = :categoryId")
void deleteByCategory(
@Param("categoryId") Long categoryId, @Param("deletedAt") LocalDateTime deletedAt
);
사실 이 코드는 한 번 리팩토링이 된 코드로, 원래 초기 코드는 Facade 계층이 없었다. CategoryService에서 CategoryRepository와 BookmarkService를 직접 호출하는 식! 그런데 기능 개발을 하다보니 Service 레이어에서 타 도메인의 Service에 직접 의존하는 것에서 문제를 느꼈고, 이를 개선하기 위해 Facade 계층과 CQS 패턴을 추가하여 리팩토링을 하게 되었다.
💁♀️ 왜 문제를 느꼈는지 궁금하다면 더보기 클릭!
1. CategoryService도 BookmarkService가 필요하고, BookmarkService도 CategoryService가 필요해 순환 참조가 발생하는 구조였다.
2. 그래서 하위 도메인 (Bookmark)는 상위 도메인 (Category)의 Service가 아닌 Repository를 직접 호출하게 했는데, 이 과정에서 예외처리를 도메인 레이어마다 중복해서 작성해야 했다.
3. 동일한 코드를 여러번 작성하는 것이 번거롭기도 했지만, 뭣보다 문제는 동일한 이유로 변경될 수 있는 코드가 사방 팔방에 흩어지는 것이다. 예를 들어, Category의 findById에서 throw하는 Custom Exception을 한번 수정하려면, 해당 코드를 직접 사용하는 모든 메소드를 전부 수정해주어야 한다. 즉 Category 도메인의 변경이 Bookmark 컨텍스트까지 퍼진 것!
4. 동시에 기능이 많아지면서, 한 Service 클래스에 너무 많은 메서드가 추가되어 가독성이 떨어졌다. 메서드 하나를 찾기 위해 스크롤을 위아래로 돌려대는 것이 번거로웠고, 코드를 보기 힘들다고 판단했다.
해서 리팩토링된 Facade 코드를 다시 한번 살펴보면 다음과 같다.
@Transactional
public void delete(Long categoryId) {
Category category = categoryReadService.findById(categoryId);
categoryWriteService.delete(category);
bookmarkWriteService.deleteByCategory(category);
}
- 만약 Bookmark의 soft delete에 실패하면 Category의 삭제를 롤백해야 한다. 따라서 Facade의 메소드에도 @Transactional을 선언해, 이 메소드 안에서 실행되는 쿼리들은 모두 같은 오퍼레이션임을 선언했다.
- 삭제 요청을 한 Category가 실제 DB에 존재하는 값인지, findById를 이용해 확인했다.
- findById를 이용해 Category 엔티티 객체가 영속성 컨텍스트에 올라왔으므로, 이를 categoryWriteService에 넘겨주어 Dirty Checking으로 삭제했다.
- Category 삭제에 성공했다면, 하위 Bookmark를 삭제하기 위해 bookmarkWriteService의 deleteByCategory 메소드를 호출한다.
- 이때, category 하위에 있는 모든 Bookmark를 삭제해야 하는데, 여기서 Dirty Checking을 쓰면 update 쿼리가 북마크 수만큼 발생해 디스크 I/O가 많아져 성능이 저하된다고 생각했다.
- 따라서 bookmark 삭제는 Dirty Checking이 아니라, @Query를 이용해 직접 bulk update 해주었다. (개인적으로 지금도 거슬리는 코드..)
💁♀️ 왜 existsById 말고 findById를 이용했는지, 왜 Dirty Checking을 이용했는지 궁금하다면 더보기 클릭!
- existsById로 존재 확인만 하지 않고, findById로 엔티티 객체를 불러온 이유
- existsById로 존재 여부만 확인할 경우, BookmarkWriteService.deleteByCategory를 호출할 때도 categoryId만을 넘겨주어야 한다.
- 지금은 deleteByCategory 메소드가 Category의 delete 메소드 안에서만 호출되지만, 나중엔 다른 메소드에서도 호출될 수 있다고 판단했다.
- 이 경우, deleteByCategory를 호출하는 곳에서 Category.existsById를 했는지 안했는지 확신할 수 없으므로 개발자는 무조건 호출부에 방어 코드를 작성하거나, 혹은 deleteByCategory 내부에 Category의 존재 여부를 체크하는 코드를 작성해야 한다. 전자는 생산성이 떨어지고, 후자는 도메인 의존성이 다시 강화된다. 또 단위 테스트 할 때도 불편하다고 판단했다.
- 따라서 deleteByCategory 메서드에 category 객체를 넘겨주면 앞에서 발생하는 문제가 해결할 수 있다고 생각했고, 이를 위해 findById를 이용했다.
- 직접 update 쿼리를 쓰지 않고, Dirty Checking을 사용한 이유
- 해당 프로젝트의 모든 도메인에는 soft delete가 적용된 상태였고, 따라서 모든 엔티티는 deleted_at 필드를 가진다.
- id, created_at, updated_at, deleted_at이 모든 도메인에 중복되므로, 이를 BaseEntity로 뽑아서 추상화했다.
- 따라서 soft delete 코드를 BaseEntity 내부에 적어두면, 도메인마다 굳이 삭제 코드를 새로 작성할 필요가 없었다.
- 만약 삭제 로직이 변경된다면 BaseEntity 메소드 하나만 수정해주는 것이 이득이기에, BaseEntity와 Dirty Checking을 이용하게 되었다.
너무나 간단한 코드라서 당연히 성공할 것이라고 생각했다. 하지만? 그랬다면 이렇게 블로그를 쓰고 있진 않았을 것... 🤦♀️
findById도, deleteByCategory도 제대로 쿼리가 실행되는데 카테고리 삭제, 즉 카테고리 Drity Checking만 동작하지 않았다. 위에는 생략되었지만, 로그를 찍었을 때 CategoryWriteSerivce.delete()까지는 잘 호출이 되었는데 정말 더티 체킹에만 실패한 것!
하지만 정말 웃긴 점은, CategoryFacade.delete에서 아래와 같이 @Transactional 어노테이션을 지워주면 아주 잘 동작한다 🤬
public void delete(Long categoryId) {
Category category = categoryReadService.findById(categoryId);
categoryWriteService.delete(category);
bookmarkWriteService.deleteByCategory(category);
}
ㅋㅋㅋ 정말 이해가 되지 않았다.. 하위 메소드에 @Transactional이 새로 걸려있긴 하지만, 어차피 해당 어노테이션의 기본 전파 속성은 REQUIRED이라 CategoryFacade와 CategoryWriteService, BookmarkWriteService는 모두 같은 트랜잭션을 탄다. 따라서 같은 영속성 컨텍스트를 공유하고 있기에 Category 객체를 넘겨주면서 준영속으로 바뀌어 발생한 문제도 아니라 이말이다ㅠㅠ
@Transactional을 지우면 멀쩡히 동작하지만, 이를 지우면 카테고리 삭제와 북마크 삭제가 별개의 트랜잭션으로 나뉘기에 롤백처리를 할 수 없다. @Transactional을 유지하면서 Category도 쿼리 메소드를 이용해 직접 update해주는 방법도 있지만, 그러면 진 기분이라 그러기 싫었다ㅋㅋ 일정도 넉넉하고, 다른 크리티컬한 태스크가 있는 것도 아니었기에 바로 다른 방법으로 구현하기보단 트러블 슈팅을 통해 원인을 찾아보자고 생각했다.
고민해본 이유들
가설 | 결과 |
쿼리는 나갔는데 실행에 실패한 거다 | 로그를 통해 update 문 안나가는 것 확인 |
메소드를 경유하며 Category가 Detached 상태가 되었다 | Facade에서 직접 Dirty checking 수행해도 실패하는 것 확인 |
N+1을 방지하기 위해 JPA가 기본 제공해주는 findById를 오버라이드해서 사용했는데, 이 과정에서 엔티티가 영속성 컨텍스트에 올라가지 않았다 | @Transactional을 지우면 더티체킹이 잘 동작하므로 findById에는 문제가 없다는 것 확인 |
상속받은 BaseEntity는 Dirty Checking의 대상이 아니다 | Category 안에 직접 deleted_at을 넣어줘도 실패하는 것 확인 |
readOnly = true 옵션을 사용했다 | 전부 default인 false 상태인 것을 확인 |
알고보니 findById 결과가 없었나?! | 그랬다면 findById에서 exception을 throw 했을 것이다. 그리고 로그를 찍어보니 제대로 일치하는 객체를 조회했음을 확인했다. |
중간부턴 너무 약올라서 무지성으로 아무거나 막 시도해봤다..ㅎ 상식적으로 @Transactional이 있어야 동작을 하는게 당연한데, 오히려 있으면 실패하고 없으면 성공한다는 점에서 뭔가 내가 몰랐던 어떤 새로운 이유가 있을 것 같았다 ^.ㅠ
그리고 위에 시도해본 것들을 보면 전부 Category 관련 코드만 확인해봤다. 왜냐하면 로그에서 Bookmark는 제대로 쿼리 날아갔고, 실제 DB에도 값이 잘 적용된 것을 확인했기 때문에 그쪽에서 실패해서 롤백이 된 것도 아니라고 생각했다. (애초에 롤백이면 Category update 쿼리가 날아가기는 했어야 하고!)
지금와서 회고해보면, 살짝 이 당시엔 > 클래스 & 메소드를 분리하면서 생긴 문제 < 라고 생각하고 관련된 시도만 해본 것 같다.
하지만 정답은?
그러나 정답은 Bookmark 삭제 코드에 있었다 ⌒⌒! 북마크 삭제와 관련된 코드를 다시 흝어보자.
// BookmarkWriteService
@Transactional
public void deleteByCategory(final Category category) {
...
bookmarkRepository.deleteByCategory(category.getId(), now);
}
// BookmarkRepository
@Modifying(clearAutomatically = true)
@Query("update Bookmark b set b.deletedAt = :deletedAt WHERE b.category.id = :categoryId")
void deleteByCategory(
@Param("categoryId") Long categoryId, @Param("deletedAt") LocalDateTime deletedAt
);
- BookmarkWriteService.deleteByCategory는 그냥 BookmarkRepository를 호출하는 친구이니 설명 패스
- 카테고리 하나에 여러 북마크가 속하므로, deleteByCategory는 벌크성 수정 쿼리가 된다.
- 따라서 Spring Data JPA에서 벌크성 수정 쿼리를 이용하기 위해 @Modifying을 선언했다.
- 동시에, 북마크 삭제는 실제로는 update이긴 하지만, 어쨌든 soft delete 된 북마크들은 더이상 제공되면 안되기에 영속성 컨텍스트를 초기화하기 위해 clearAutomatically = true 옵션을 추가했다.
여러분은 똑똑하니까 여기서 문제를 이미 찾으신 분도 계실 것 같다. 그렇다... clearAutomatically = true 옵션이 이 궈텅의 근원이였다.
@Transactional와 Dirty Checking
clearAutomatically가 왜 문제인지를 파악하려면 Dirty Checking (변경 감지)의 동작 과정을 알아야 한다.
JPA는 영속성 컨텍스트에 엔티티를 보관할 때, 최초 상태를 저장하여 스냅샷으로 저장한다. 그리고 flush 시점에 스냅샷과 엔티티 상태를 비교해 변경된 사항을 찾는다. 조금 더 자세히 살펴보면 다음과 같이 동작한다.
- 트랜잭션이 commit 될 때 EntityManager 내부에서 flush 호출
- 엔티티와 스냅샷을 비교해 변경점 확인
- 변경 사항이 있다면 수정 쿼리 생성 -> 영속성 컨텍스트 내부의 쓰기 지연 SQL 저장소에 저장
- 모아둔 쓰기 지연 저장소의 SQL들을 DB로 보낸다
- DB 트랜잭션을 커밋한다
여기서 주의할 점은, 변경 감지 (Dirth Checking)은 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용되며, 이 영속성 컨텍스트의 범위는 @Transactional으로 선언된다는 점이다. 즉 메소드가 줄줄히 호출되는 구조라면, 맨 처음 @Transactional로 선언된 메소드가 트랜잭션의 시작이고, 영속성 컨텍스트의 시작점이라는 것!
따라서 내가 작성한 코드는, CategoryFacade가 트랜잭션의 시작이며, 그 이후에 작성된 모든 메소드는 default 전파 속성인 Required에 의해 CategoryFacade와 같은 트랜잭션을 공유하며, 결과적으로 같은 영속성 컨텍스트를 사용하게 되는 것이다.
그럼... 대충 원인이 감이 온다. clearAutomatically = true 옵션은 영속성 컨텍스트를 초기화한다고 했다. 그럼 내가 짠 코드는 이렇게 동작하게 된다.
- CategoryFacade에서 트랜잭션 시작
- findById로 Category를 영속 상태로 만든다 :: 스냅샷 생성
- categoryWriteService에서 엔티티에 변경사항이 발생한다.
- bookmarkWriteService에서 bookmarkRepository.deleteByCategory 쿼리를 실행한다 -> clearAutomatically가 일한다.
- 쓰기 지연 저장소에 저장되어 있던 category의 스냅샷이 사라진다.
- 트랜잭션 커밋 시점(= CategoryFacade의 delete 메서드가 종료될 때)에, category 스냅샷과 현재 category를 비교하지만, 스냅샷이 clear 되었으므로 변경감지에 실패, 수정 쿼리를 생성하지 않는다.
- 결과적으로 category update 쿼리가 생성되지 않았고, 쓰기 지연 저장소에 아무 쿼리도 없으므로 그대로 트랜잭션을 종료한다.
^_______^ 애초에 엔티티의 변경을 감지하지 못했으므로 수정 쿼리를 만들지 않은거다!! 실제로 clearAutomatically = true 옵션을 지우면 (default인 false로 바꾸면) 아주 멀쩡하게 category update 쿼리가 나간다.
로그를 보면 bookmark update 쿼리가 먼저 나가고, 이후 category update 쿼리가 나간다. 메소드 실행 순서와 쿼리 순서가 다르지만, 이것도 더티 체킹 동작 과정을 생각하면 납득이 된다.
@Query로 생성된 쿼리는 바로 저장되지만, Dirty Checking으로 인한 category update 쿼리는 트랜잭션 커밋 시점 (= CategoryFacade.delete 메소드가 종료될 때) 생성되므로 순서가 더 늦어지는 것!
CategoryFacade에서 @Transactional 어노테이션을 지우면, category 삭제와 bookmark 삭제가 독립된 트랜잭션으로 분리되므로, bookmark 삭제에서 clearAutomatically가 실행되어서 영속성 컨텍스트가 초기화되어도 아무 영향이 없는거다. categoryWriteService.delete가 종료될 때 이미 category update 쿼리는 DB에 날아갔을테니까!
이번 이슈에서 내 실수는?
문제가 잘 해결된 것과 별개로, 어쩌다 이런 문제가 발생했고, 왜 빠르게 캐치하지 못한 건지 회고해봤다.
- Dirty Checking의 정확한 동작 원리를 모른채로 사용했다.
- clerAutomatically = true가 필요하지 않은 상황이었는데 무지성하게 갖다 썼고, 그로 인한 영향을 파악하지 못했다.
- 문제가 발생했을 때 관련된 모든 코드를 살펴봐야 하는데, 북마크 쪽에는 문제가 없을 것이라고 단정짓고 엉뚱한 곳만 뒤졌다.
코드의 정확한 내부 동작을 모른 채로 기능을 구현한 것이 제일 큰 패착이었다 😇
마무리
요즘 주니어 -> 미들로 성장하기 위해 어떤 역량이 필요한지에 대해 고민이 많다. 여러 사람들의 조언을 들어본 결과, 지금의 나는 화려한 기술 (MSA, WebFlux, 등...)에 욕심내기보다는 기본기를 탄탄하게 다지는게 더 중요하다고 생각해서 각종 이론들부터 다시 공부하고 있는데, 이번 경험을 통해 방향을 잘 잡았다고 다시금 생각하게 되었다. (ㅋㅋㅋ그리고 조금 부끄럽다.. 돌멩이야 그냥)
역시 모든 기술은 동작 원리부터 파악하는게 참 중요한 것 같다. 이번 기회를 발판삼아, 정확히 설명할 수 없는 개념들을 쭉 다시 공부해봐야겠다 🔥🔥🔥
레퍼런스
- 자바 ORM 표준 JPA 프로그래밍 : https://www.yes24.com/Product/Goods/19040233
'Develop > Spring' 카테고리의 다른 글
[Spring] 캐시란? + Spring Boot 내장 캐시 사용하기 (0) | 2024.07.29 |
---|---|
[Spring Boot] AOP와 Spring AOP를 뜯어보자 (feat. Proxy, @Transactional) (0) | 2023.10.29 |
[Spring Boot] 외부 API 호출 도구 OpenFeign : RestTemplate, WebClient와 비교까지 (0) | 2023.09.20 |
[JPA] 영속성 전이 (CASCADE)와 고아 객체에 대하여 (0) | 2023.09.16 |
[Spring Boot] Auto Increment PK Id를 노출하지 않으면서 API에 활용하는 방법 (4) | 2023.06.28 |