회원탈퇴 기능을 구현하고자 한다. 다대일 단방향, 양방향으로 연관관계가 매핑되어 있는 상황에서 무결성 제약조건에 걸리지 않고 참조된 값들을 차례로 삭제하여 회원탈퇴 기능이 정상적으로 동작하도록 구현해보자!
먼저, 데이터를 삭제할 때 해당 데이터를 참조하는 다른 데이터들을 지울 수 있는 옵션들을 알아보자.
역할 : 기본키 데이터를 삭제할 경우, 외래키 데이터도 전부 삭제된다.
[참고]
https://stir.tistory.com/163
https://giron.tistory.com/130
Case 1
- 단방향 매핑에는 @ManyToOne에
@OnDelete(action=OnDeleteAction.CASCADE)
- 양방향 매핑에서 @OneToMany에는 모두
cascade = cascadeType.REMOVE
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, length = 65535)
private String contents;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@OneToMany(mappedBy = "board", cascade = CascadeType.REMOVE)
private List<Comment> commentList = new ArrayList<>();
@OneToMany(mappedBy = "board", cascade = CascadeType.REMOVE)
private List<Likes> likesList = new ArrayList<>();
// ...
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Likes {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;
// ...
}
@Getter
@Entity(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 20)
private String username;
@Column(nullable = false)
private String password;
// ...
}
게시글을 지울 경우, 댓글이 자동으로 지워지는가?
case 1) user1이 작성한 게시글 + user1이 작성한 댓글
case 2) user1이 작성한 게시글 + user2가 작성한 댓글
→ 양방향 매핑에 cascade 옵션을 걸었기 때문에, 게시글을 지우면 댓글도 같이 지워진다.
사용자가 탈퇴할 경우, 게시글이 자동으로 지워지는가?
case 1) user1이 작성한 게시글 + 댓글 없음
case 2) user1이 작성한 게시글 + user1이 작성한 댓글
case 3) user1이 작성한 게시글 + user2가 작성한 댓글
→ User 객체를 지울 때, @OnDelete 옵션이 있어서 User를 참조하는 Board, Comment, Likes 는 삭제된다.(case 1)
→ User를 참조하는 Board를 지울 때, Board에 cascade 옵션이 걸려있어서 Board에 작성된 Comment가 자동으로 지워질 것 같지만, Comment가 Board를 참조하고 있어 삭제할 수 없다는 에러메시지가 뜬다.
나의 생각 : @OnDelete 은 DDL 테이블 만들 때 제약조건을 걸어서, DB단에서 참조된 데이터의 값을 지운다. cascade 옵션은 JPA가 해당 객체의 참조된 곳을 찾아 하나씩 delete 쿼리를 날린다. User를 삭제시킬 때는 DB단에 걸린 제약조건으로 참조된 Board, Comment, Likes를 지우지만, DB단에서는 Board에 cascade 옵션이 걸려있는 것을 모르기 때문에, Comment가 Board를 참조하고 있어 Board를 지울 수 없다는 에러를 보이는 것 같다.
Case2
- 모두 양방향 매핑으로 바꾸고 모든 @OneToMany에
cascade = cascadeType.REMOVE
- 단방향 매핑은 없고,
@OnDelete
는 사용하지 않는다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, length = 65535)
private String contents;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@OneToMany(mappedBy = "board", cascade = CascadeType.REMOVE)
private List<Comment> commentList = new ArrayList<>();
@OneToMany(mappedBy = "board", cascade = CascadeType.REMOVE)
private List<Likes> likesList = new ArrayList<>();
// ...
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Likes {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;
// ...
}
@Getter
@Entity(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 20)
private String username;
@Column(nullable = false)
private String password;
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Board> boardList = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Board> boardList = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Board> boardList = new ArrayList<>();
// ...
}
case 1) user1이 작성한 게시글 + user1이 작성한 댓글
case 2) user1이 작성한 게시글 + user2가 작성한 댓글
→ 양방향 매핑에 cascade 옵션을 걸었기 때문에, 게시글을 지우면 댓글도 같이 지워진다.
case 1) user1이 작성한 게시글 + 댓글 없음
case 2) user1이 작성한 게시글 + user1이 작성한 댓글
case 3) user1이 작성한 게시글 + user2가 작성한 댓글
- (O) 사용자를 지우면 사용자가 작성한 게시글도 삭제된다.
→ 양방향 매핑에 cascade 옵션을 걸었기 때문에, 사용자를 지우면 게시글도 지워지고, 게시글이 지워지면 그 안에 있는 댓글도 모두 삭제된다.
나의 생각 : 모든 엔티티의 연관관계가 양방향으로 매핑되어 있고, cascade 옵션이 걸려있으므로 JPA가 User를 삭제할 때 User를 외래키로 갖는 Board, Comment, Likes를 먼저 지우려고 할테고, Board, Comment를 지우려고 보니 Board, Comment를 외래키로 갖는 Comment, Likes가 있어서 이것부터 delete 쿼리를 날릴 것이다. 따라서 참조하는 외래키를 찾아 더 이상 참조하는 값이 없는 데이터부터 지워나가기 때문에 회원탈퇴시 정상적으로 기능이 동작한다. 하지만, 단지 cascade 옵션을 걸기 위해 모든 관계를 양방향으로 정하는 것은 바람직하지 않다고 생각한다. 연관된 객체가 서로 참조를 빈번하게 하는 등 양방향으로 처리하는 것이 확실히 나을 경우에만 양방향 매핑을 하는 것이 좋다고 생각한다.
Case3
- 단방향 매핑, 양방향 매핑 모두 @ManyToOne에
@OnDelete(action=OnDeleteAction.CASCADE)
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, length = 65535)
private String contents;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@OneToMany(mappedBy = "board", cascade = CascadeType.REMOVE)
private List<Comment> commentList = new ArrayList<>();
@OneToMany(mappedBy = "board", cascade = CascadeType.REMOVE)
private List<Likes> likesList = new ArrayList<>();
// ...
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Likes {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id")
private Board board;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;
// ...
}
@Getter
@Entity(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 20)
private String username;
@Column(nullable = false)
private String password;
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Board> boardList = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Board> boardList = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE)
private List<Board> boardList = new ArrayList<>();
// ...
}
case 1) user1이 작성한 게시글 + user1이 작성한 댓글
case 2) user1이 작성한 게시글 + user2가 작성한 댓글
→ 연관관계 매핑으로 참조하는 경우, DDL에서 ON DELETE CASCADE가 제약조건으로 걸려있기 때문에, DB단에서 게시글 삭제시 게시글을 참조하는 댓글도 함께 지운다.
case 1) user1이 작성한 게시글 + 댓글 없음
case 2) user1이 작성한 게시글 + user1이 작성한 댓글
case 3) user1이 작성한 게시글 + user2가 작성한 댓글
→ DDL에서 ON DELETE CASCADE가 제약조건으로 걸려있기 때문에, DB단에서 사용자 삭제시, 참조되어 있는 Board, Comment, Likes를 지우려고 할것이다. Board, Comment도 ON DELETE CASCADE 제약조건이 걸려있기 때문에 DB단에서 Board, Comment를 참조하는 데이터를 먼저 지울 것이다. User를 삭제할 때 참조되어 있는 데이터들이 연쇄적으로 삭제될 것이므로 회원탈퇴 구현이 가능하다.
나의 생각 : @OnDelete는 DB 테이블 자체에 on delete cascade가 걸리게 되므로, 어떠한 레코드의 참조 레코드까지 연쇄적으로 삭제해버릴 수 있는 여지가 존재한다는 단점이 있다. 또한 JPA는 @OnDelete에 표준화되어 있지 않다. 모든 연관관계에 @OnDelete를 걸면 회원탈퇴 기능은 구현할 수 있지만, 연관관계가 복잡해질수록 지워져서는 안될 데이터까지 지워질 위험이 있으니 @OnDelete 사용은 지양하고자 한다.
cascade = cascadeType.REMOVE
옵션을 설정한다.@Transactional
public ResponseEntity<MessageResponseDto> signout(LoginRequestDto requestDto, User user) {
// 비밀번호 확인
String password = requestDto.getPassword();
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new RestApiException(ErrorType.NOT_MATCHING_PASSWORD);
}
boardRepository.deleteAllByUser(user);
commentRepository.deleteAllByUser(user);
likesRepository.deleteAllByUser(user);
userRepository.delete(user);
return ResponseEntity.ok(MessageResponseDto.of(HttpStatus.OK, "회원탈퇴 완료"));
}
2023-02-21 15:59:18.071 ERROR 82133 --- [nio-8080-exec-8] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call; nested exception is javax.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call] with root cause
javax.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call
deleteAllByUser
처럼 개발자가 만든 쿼리메서드에는 트랜잭션이 따로 안붙는다.