woowacourse/service-apply

회원 탈퇴 기능을 기획하고 구현한다.

Opened this issue · 3 comments

  • 마이페이지에 회원 탈퇴 버튼을 추가한다.
  • 탈퇴한 회원의 경우 UI에 데이터를 노출하는 방법을 고려한다.
  • 와이어프레임

관련 이슈: #359

  • 회원과 회원 정보는 구조적으로 함께 묶여 있지만 별도의 테이블로 분리하면 회원 정보를 훨씬 쉽게 관리할 수 있다.
  • 데이터베이스에는 반영되어 있지 않지만, 회원 정보(MemberInformation)는 이미 회원(Member)과 모델이 분리되어 있다.
  • 회원이 탈퇴할 때 모든 회원 데이터를 지우는 대신 회원 정보만 지우면 회원 ID를 참조하는 여러 모델에서 일관성을 유지할 수 있다.
  • 회원 정보가 없는 회원을 탈퇴 회원으로 정의하면 탈퇴 회원에 대한 통계 확인 등 다양한 확장이 가능하다.
  • 탈퇴 회원을 회원 테이블에 유지하는 것이 테이블 용량을 차지한다는 점에서 단점이 될 수 있지만, 그 수가 많지 않고 용량도 그리 크지 않을 것이다.

엔티티 하나에 여러 테이블 매핑

  • @SecondaryTable 또는 @OneToOne을 사용하는 방법이 있다.
  • @SecondaryTable의 경우 @Columntable 요소를 지정하여 해당 컬럼이 저장될 테이블을 지정할 수 있다.1
  • 컴포넌트(@Embeddable가 지정된 객체)의 경우 @AttributeOverride를 사용하여 저장될 테이블을 지정할 수 있다.2
    • 컴포넌트의 재사용이 용이하다.
@SecondaryTable(
    name = "member_information",
    pkJoinColumns = [PrimaryKeyJoinColumn(name = "member_id")],
)
@Entity
class Member(
    @Embedded
    var information: MemberInformation,
    id: Long = 0L,
) : BaseRootEntity<Member>(id)

@Embeddable
data class MemberInformation(
    @Column(nullable = false, table = "member_information")
    val email: String,

    @Column(nullable = false, table = "member_information", length = 30)
    val name: String,
)
@SecondaryTable(
    name = "member_information",
    pkJoinColumns = [PrimaryKeyJoinColumn(name = "member_id")],
)
@Entity
class Member(
    @AttributeOverrides(
        AttributeOverride(name = "email", column = Column(nullable = false, table = "member_information")),
        AttributeOverride(name = "name", column = Column(nullable = false, table = "member_information", length = 30)),
    )
    @Embedded
    var information: MemberInformation,
    id: Long = 0L,
) : BaseRootEntity<Member>(id)

@Embeddable
data class MemberInformation(
    @Column(nullable = false)
    val email: String,

    @Column(nullable = false, length = 30)
    val name: String,
)
  • 단, 하나의 컴포넌트는 하나의 테이블에 매핑될 수 있다.
    • org.hibernate.AnnotationException: A component cannot hold properties split into 2 different tables: apply.domain.member.Member.information

  • ComponentPropertyHolder 코드를 보면, 컴포넌트에 속한 프로퍼티를 이름순으로 정렬할 때 첫 번째 프로퍼티의 table 요소가 해당 컴포넌트에 매핑되는 테이블로 지정된다.
    • 이후, 해당 컴포넌트에 속한 프로퍼티는 해당 컴포넌트에 매핑된 테이블과 다른 테이블에 속해 있어서는 안 된다.
    • table 요소가 생략되면 컴포넌트의 첫 번째 프로퍼티의 table 요소를 따른다.
      • 첫 번째 프로퍼티만 올바르게 설정하면 된다.

Good

@Embeddable
data class MemberInformation(
    @Column(nullable = false, table = "member_information")
    val email: String,

    @Column(nullable = false, length = 30)
    val name: String,
)

Bad

@Embeddable
data class MemberInformation(
    @Column(nullable = false)
    val email: String,

    @Column(nullable = false, table = "member_information", length = 30)
    val name: String,
)

ComponentPropertyHolder.java#L280-L298

/*
* Check table matches between the component and the columns
* if not, change the component table if no properties are set
* if a property is set already the core cannot support that
*/
if (columns != null) {
    Table table = columns[0].getTable();
    if ( !table.equals( component.getTable() ) ) {
        if ( component.getPropertySpan() == 0 ) {
            component.setTable( table );
        }
        else {
            throw new AnnotationException(
                "A component cannot hold properties split into 2 different tables: "
                    + this.getPath()
            );
        }
    }
}
  • foreignKey 요소를 사용하여 보조 테이블에 생성되는 FK키의 이름을 지정할 수 있다.
@SecondaryTable(
    name = "member_information",
    pkJoinColumns = [PrimaryKeyJoinColumn(name = "member_id")],
    foreignKey = ForeignKey(name = "fk_member_information_member_id_ref_member_id"),
)
  • 보조 테이블을 사용할 때 기본 조인 전략은 LEFT JOIN이다.
  • @org.hibernate.annotations.Table을 사용하여 INNER JOIN을 사용하도록 변경할 수 있다.
@org.hibernate.annotations.Table(appliesTo = "member_information", optional = false)

참고 자료

@SecondaryTable -> @OnetoOne

  • 단일 컬럼을 조회할 때 보조 테이블이 포함되지 않는 Hibernate의 버그가 있다.
    • https://github.com/spring-projects/spring-data-jpa/issues/2209
    • https://hibernate.atlassian.net/browse/HHH-14590
    • 따라서 Spring Data JPA의 쿼리 메소드로 생성된 쿼리 중 exists 명령어는 조인을 수행하지 않는다.
      • could not prepare statement; SQL [select member0_.id as col_0_0_ from member member0_ where member0_2_.email=? limit ?]; nested exception is org.hibernate.exception.SQLGrammarException: could not prepare statement

  • 자바 ORM 표준 JPA 프로그래밍에 따르면
    • 참고로 @SecondaryTable을 사용해서 두 테이블을 하나의 엔티티에 매핑하는 방법보다는 테이블당 엔티티를 각각 만들어서 일대일 매핑하는 것을 권장한다. 이 방법은 항상 두 테이블을 조회하므로 최적화하기 어렵다. 반면에 일대일 매핑은 원하는 부분만 조회할 수 있고 필요하면 둘을 함께 조회하면 된다.