[JPA] 회원탈퇴 기능 구현으로 알아보는 연관관계 매핑

Doyeon·2023년 2월 22일
2
post-thumbnail

회원탈퇴 기능을 구현하고자 한다. 다대일 단방향, 양방향으로 연관관계가 매핑되어 있는 상황에서 무결성 제약조건에 걸리지 않고 참조된 값들을 차례로 삭제하여 회원탈퇴 기능이 정상적으로 동작하도록 구현해보자!
먼저, 데이터를 삭제할 때 해당 데이터를 참조하는 다른 데이터들을 지울 수 있는 옵션들을 알아보자.

cascade(CascadeType.remove) vs @OnDelete(action = OnDeleteAction.CASCADE)

역할 : 기본키 데이터를 삭제할 경우, 외래키 데이터도 전부 삭제된다.

  • cascadeType.REMOVE
    • 양방향 매핑에서 연관관계 주인이 아닌 쪽에 설정할 수 있다.
      • ex) @OneToMany(mappedby=”board”, cascade = cascadeType.REMOVE)
    • JPA 쪽에서 외래키를 쫓아가며 알아서 삭제해준다.
    • 참조하는 레코드 수만큼 delete 쿼리가 나간다.
    • 런타임 도중에 JPA에 의해 실행된다.
  • @OnDelete(action = OnDeleteAction.CASCADE)
    • 양방향, 단방향 어디에든 설정할 수 있다.
    • JPA가 생성하는 DDL에 ON DELETE CASCADE 를 붙여 생성한다.
    • 프로그램이 올라오기 전에 DB 테이블에 접근해서 ON DELETE CASCADE를 붙여준다.
    • DB단에서 처리된다.
    • on delete cascade에 의해 어떠한 레코드의 참조 레코드까지 연쇄적으로 삭제해버리는 실수할 여지가 있다.
    • JPA는 @OnDelete에 표준화되어 있지 않다. JPA 자체가 데이터베이스 저장소에 구애받지 않고(storage-agnostic) 사용하기 위한 기술이다.

[참고]
https://stir.tistory.com/163
https://giron.tistory.com/130


회원탈퇴 기능 구현

현재 연관관계

  • Board - User : 다대일 단방향
  • Comment - User : 다대일 단방향
  • Likes - User : 다대일 단방향
  • Comment - Board : 다대일 양방향
  • Likes - Board : 다대일 양방향
  • Comment - Likes : 다대일 양방향

연관관계 매핑과 옵션

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이 작성한 댓글

      • (O) 게시글 delete하면 해당 게시글 댓글도 삭제된다.
    • case 2) user1이 작성한 게시글 + user2가 작성한 댓글

      • (O) 게시글 delete하면 해당 게시글 댓글도 삭제된다.

    → 양방향 매핑에 cascade 옵션을 걸었기 때문에, 게시글을 지우면 댓글도 같이 지워진다.

  • 사용자가 탈퇴할 경우, 게시글이 자동으로 지워지는가?

    • case 1) user1이 작성한 게시글 + 댓글 없음

      • (O) 사용자를 지우면 사용자가 작성한 게시글도 삭제된다.
    • case 2) user1이 작성한 게시글 + user1이 작성한 댓글

      • (x) 사용자를 지우면 Error 발생
      • DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint ["FKLIJ9OOR1NAV89JEAT35S6KBP1: PUBLIC.COMMENT FOREIGN KEY(BOARD_ID) REFERENCES PUBLIC.BOARD(ID) (CAST(1 AS BIGINT))";
    • case 3) user1이 작성한 게시글 + user2가 작성한 댓글

      • (x) 사용자를 지우면 Error 발생
      • DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint ["FKLIJ9OOR1NAV89JEAT35S6KBP1: PUBLIC.COMMENT FOREIGN KEY(BOARD_ID) REFERENCES PUBLIC.BOARD(ID) (CAST(1 AS BIGINT))";

      → 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이 작성한 댓글

      • (O) 게시글 delete하면 해당 게시글 댓글도 삭제된다.
    • case 2) user1이 작성한 게시글 + user2가 작성한 댓글

      • (O) 게시글 delete하면 해당 게시글 댓글도 삭제된다.

      → 양방향 매핑에 cascade 옵션을 걸었기 때문에, 게시글을 지우면 댓글도 같이 지워진다.

  • 사용자가 탈퇴할 경우, 게시글이 자동으로 지워지는가?
    • case 1) user1이 작성한 게시글 + 댓글 없음

      • (O) 사용자를 지우면 사용자가 작성한 게시글도 삭제된다.
    • case 2) user1이 작성한 게시글 + user1이 작성한 댓글

      • (O) 사용자를 지우면 사용자가 작성한 게시글도 삭제된다.
    • 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이 작성한 댓글

      • (O) 게시글 delete하면 해당 게시글 댓글도 삭제된다.
    • case 2) user1이 작성한 게시글 + user2가 작성한 댓글

      • (O) 게시글 delete하면 해당 게시글 댓글도 삭제된다.

      → 연관관계 매핑으로 참조하는 경우, DDL에서 ON DELETE CASCADE가 제약조건으로 걸려있기 때문에, DB단에서 게시글 삭제시 게시글을 참조하는 댓글도 함께 지운다.

  • 사용자가 탈퇴할 경우, 게시글이 자동으로 지워지는가?
    • case 1) user1이 작성한 게시글 + 댓글 없음

      • (O) 사용자를 지우면 사용자가 작성한 게시글도 삭제된다.
    • case 2) user1이 작성한 게시글 + user1이 작성한 댓글

      • (O) 사용자를 지우면 사용자가 작성한 게시글도 삭제된다.
    • case 3) user1이 작성한 게시글 + user2가 작성한 댓글

      • (O) 사용자를 지우면 사용자가 작성한 게시글도 삭제된다.

      → 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 옵션을 설정한다.
    • 양방향일 경우에는 delete 관련 설정을 하지 않는다.
  • 사용자를 삭제하기 전에, 사용자가 작성한 Board, Comment, Likes를 먼저 지우는 로직을 구현한다.
    • 사용자가 작성한 게시글, 댓글, 좋아요를 지울 때, 양방향 매핑 cascade에 의해 게시글에 달려있는 댓글, 좋아요 등은 자동으로 삭제된다.
@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, "회원탈퇴 완료"));
}
  • 주의할 점 : @Transactional을 반드시 붙인다.
    • JPA에서 remove 동작을 하려면 EntityManager를 이용해야 하는데 TRANSACTION 설정이 기본값이다. 트랜잭션이 없으면 에러가 난다.
      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
    • JPA에서 기본적으로 제공해주는 쿼리메서드는 트랜잭션이 설정되어 있지만, deleteAllByUser 처럼 개발자가 만든 쿼리메서드에는 트랜잭션이 따로 안붙는다.
profile
🔥

0개의 댓글