일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
- 구글 플레이 비공개 테스트
- 운영체제 #CS지식
- git
- 객체지향설계
- 객체지향
- 구글 플레이 스토어 배포 방법
- 기능명세서
- 구글 비공개 테스트 20명
- 플레이 스토어 20명
- 설계
- 프리코스
- 우테코
- 플레이스토어 비공개 테스트
- 커밋 메시지
- 클린코드
GYUD-TECH
[해피에이징] User 데이터 삭제 본문
해피에이징 프로젝트에서 User 데이터를 삭제했을 경우의 API를 구현하면서 많은 고민을 하였다.
이전 토이 프로젝트의 경우에는 User 삭제 기능 구현시 cascadeType.Remove 옵션을 사용하여 데이터를 삭제하였다.
하지만 회사의 입장에서 설문조사 데이터를 활용하여 낙상 사고 데이터를 분석하기 때문에 삭제된 User가 생성한 Senior 데이터도 데이터로써의 가치가 있었다.
일반적인 서비스에서도 데이터를 삭제하면 30일 간은 데이터 복구가 가능하는 기능을 제공했던 것이 떠올랐다.
그래서 cascade 옵션을 사용하지 않고, 어떻게 유저 데이터만 삭제하여 다른 기능을 사용할 수 없게 만들지 고민하였다.
참조 관계를 끊기
첫번째로 떠올린 방법은 senior에서 참조하는 user를 null로 바꾼 후에 user를 삭제하는 것이다.
@Transactional
public void deleteUser(Long userId) {
User user = findUserById(userId);
seniorRepository.findByUser(user).stream()
.forEach(seniorService::deleteSenior);
userRepository.delete(user);
}
코드의 동작 흐름은 아래와 같다.
1. userId 에 해당하는 user를 찾는다.
2. user와 연결된 모든 senior의 user 참조값을 null로 바꿔준다.
3. user를 삭제한다.
코드를 작성하면서 영속성 컨텍스트의 동작에 대해 의문점이 생겼다.
영속성 컨텍스트는 key-value 형태로 데이터의 변경사항을 저장하기 때문에 순서를 보장할 수 없다.
하지만 위 예시에서는 senior의 참조값 변경이 user 삭제 쿼리보다 반드시 먼저 수행되어야 한다.
그렇지 않으면 참조 무결성 제약조건에 의해서 쿼리가 반영되지 않는다.
그래서 flush() 를 통해서 데이터베이스에 먼저 senior update 쿼리를 날리고, 이후 Transaction이 종료될 때 추가적으로 user 데이터를 삭제하는 쿼리를 날리도록 코드를 작성하였다.
@Transactional
public void deleteUser(Long userId) {
seniorRepository.findByUserId(userId).stream()
.forEach(seniorService::deleteSenior);
entityManager.flush();
User user = findUserById(userId);
userRepository.delete(user);
}
하지만 flush를 하지 않은 이전의 코드도 항상 senior 삭제 쿼리를 먼저 날리고, user 삭제 쿼리를 날리는 것을 로그로 확인하였다.
추가적인 자료를 찾아보고 인프런 게시판에 문의를 해본 결과 JPA 프로바이더가 데이터 무결성을 지키기 위해 쿼리의 순서를 보장해준다는 것을 알게되었다.
이러한 이유로 flush()를 호출하지 않아도 참조 무결성을 지키기 위해 쿼리의 순서가 보장되는 것을 알 수 있었다.
삭제된 user 정보를 저장하는 테이블을 추가
위 방식으로 User 데이터만을 삭제할 수 있었지만, user를 참조하지 않는 senior가 존재하는 것이 이상하게 느껴졌다.
senior는 user에 의해서만 생성 가능한데, user가 없는 senior가 존재하는 것은 이해하기 어려웠다.
user 정보를 유지하여 senior와의 관계를 유지하면서, 삭제되었다는 것을 나타내기 위해 삭제된 유저 테이블을 추가로 생성하는 방식을 떠올렸다.
이를 위해 먼저 User를 상속받는 ActiveUser와 DeActiveUser 클래스를 생성한다.
User 삭제 요청이 전달되면, ActiveUser의 데이터를 DeActiveUser로 복사하고, Senior가 새로운 DeActiveUser를 참조하도록 한다.
이후 ActiveUser 객체를 삭제하면 참조 관계는 유지한 채, 삭제 테이블을 따로 관리할 수 있다.
이렇게 하면 탈퇴하지 않은 User는 ActiveUser에서 조회하고, 탈퇴한 사용자는 DeActiveUser에서 조회가 가능하다고 생각하였다.
하지만 데이터베이스에서는 상속을 지원하지 않기 때문에 ActiveUser와 DeActiveUser를 따로 나누기 위해서는 User 테이블도 생성하여 Join연산으로 데이터에 접근해야만 했다.
위 구조에서는 단일 유저 데이터를 조회하더라도 조인 연산이 발생하기 때문에 시간이 오래 걸리게 된다.
해피에이징 서비스는 삭제된 유저와 활동중인 유저를 각각 조회하는 경우보다 한명의 유저 정보를 조회하는 경우가 훨씬 많아서 위 방식은 비효율적일 것이라고 생각하였다.
deletedAt 필드 추가
삭제된 User도 가끔 조회해야 한다면, User 객체에 deletedAt 필드를 추가하여 삭제 여부를 판단하는 방법을 적용하였다.
public class User {
...
@Column
private LocalDate deletedAt;
public void delete() {
this.name = DELETED_USER_NAME;
this.email = createUniqueDeletedEmail();
this.phoneNumber = DELETED_PHONE_NUMBER;
this.deletedAt = LocalDate.now();
}
}
데이터 조회 시 isDeleted 필드를 확인하여 삭제하지 않은 데이터만 가져오도록 하는 처리가 필요하긴 하지만, 데이터베이스 조인이 발생하지 않아서 성능이 좋다는 장점이 있다.
따라서 기존의 첫번째 로직에서 deletedAt 로직을 사용하는 것으로 변경하였다.
요즘 오브젝트 책을 읽으면서 특정 필드에 따라 객체의 구현을 결정하는 것은 좋지않은 설계라는 내용을 읽었다.
같은 객체에서 isDeleted 값에 따라서 메시지 응답에 대한 결과가 달라지는 것은 분리할 수 있다고 생각하였다.
하지만, User를 추상클래스로 선언하고 이를 분리하더라도, 데이터베이스에서는 조회 성능을 위해서 한개의 User 테이블에 저장하는 Single Table 방식을 선택해야한다.
결국 데이터베이스에서는 dType이라는 필드를 통해서 isDeleted 필드와 같은 형태로 데이터가 저장된다.
분리를 하더라도 DeActiveUser는 DeActiveUser만 수행하는 특별한 행동이 없을 뿐더러, isDeleted 필드를 제외한 모든 필드가 같기 때문에 분리할 필요성을 느끼지 못했다.
객체지향 설계를 열심히 공부하고 있지만 트레이드오프를 고려하여 내 서비스에 적용하는 능력은 아직 부족한 것 같다.
적용 능력을 기르기 위해 오브젝트 책을 다 읽고 난 후 해피에이징 프로젝트에 적용하며 설계를 변경할 계획이다.
막히는 부분이 있다면, 스터디원들과 함께 이야기하며 좋은 객체지향 설계자로 성잘할 것이다.
'프로젝트' 카테고리의 다른 글
[뉴젯] Redis 도입과 분산 락을 활용한 동시성 문제 해결 (0) | 2025.02.01 |
---|---|
[뉴젯] 도커를 활용한 CI 구축 (1) | 2025.01.15 |
[뉴젯] 유저 지표에서 시작된 쿼리 성능 최적화 (1) | 2025.01.06 |
[뉴젯] 메일 수신 처리 속도 및 AWS Throttling 지표 개선 (1) | 2025.01.03 |
[해피에이징] 수평적 권한 상승 문제 (0) | 2024.01.11 |