본문 바로가기

@MapsId : Derived Identifier

@silver-w2025. 12. 22. 21:02

1) 테이블 구조 


 

2) 엔티티 생명주기


  • 생성흐름
- 생성 : OAuthUserInfo(불변 DTO) → userInfo 생성 → user 생성

외부에서 회원의 개인정보를 전달받고,
내부 식별자인 user_key(UUID)를 생성하여 UserInfo 엔티티를 만든다.

이후 해당 user_key를 이용해 User 엔티티에서도 PK로 그대로 사용하는 구조다.

 

즉,
UserInfo의 PK 값이 곧 User의 FK이자 PK가 되는 구조다.

이 로직을 User 엔티티의 생성자나 팩토리 메서드에서 직접 구현하기보다는,
JPA에 이 책임을 위임하는 편이 PK 공유 구조 자체로 User는 UserInfo 없이는 저장될 수 없게 되고,
모델링 레벨에서 1:1 관계가 더 강하게 강제된다고 판단했다.

 

이때 선택한 해법이 바로 @MapsId 애노테이션이다.

 

3-1) UserInfo 엔티티(부모)


■ 먼저 생성되는 UserInfo는 PK를 평소처럼 선언한다.

@Entity
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserInfo {

    @Id
    @Column(name="user_key")
    private String userKey;

    private String username;

    @Embedded
    @NaturalId
    private Email email;

    @Enumerated(EnumType.STRING)
    private ProviderType providerType;
	
    ...(생략)...

}

 

3-2) User 엔티티(자식, @MapsId 적용)


User는 UserInfo의 PK 컬럼을 그대로 사용한다는 선언이 필요하다.

핵심 포인트는 다음 두 가지다.

  • PK는 값 타입으로 평소처럼 선언
  • @MapsId를 적용할 연관 객체를 별도로 선언
    @Id
    @Column(name="user_key")
    private String userKey;

    @MapsId
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="user_key")
    private UserInfo userInfo;

그리고 User를 persist() 하기 전,
비어 있는 userKey에 UserInfo를 연결하기 위한 메서드를 별도로 둔다.

public void linkUserInfo(UserInfo userInfo) {
    this.userInfo = requireNonNull(userInfo);
}

 

4) 저장흐름 정리


 

주의해야할 저장흐름은 아래와같다.

// 서비스 레이어 로직
@Override
public User registerFromOAuth(OAuthUserInfo oAuthUserInfo) {	-- 외부API 응답

    LocalDateTime now =  LocalDateTime.now();
    UserInfo userInfo = OAuthUserInfo.of(oAuthUserInfo);	-- userInfo 객체 생성

    log.info("userKey = {}",  userInfo.getUserKey());

    User user = User.register(now);	-- userKey없는 user 객체 생성( transient )
    user.linkUserInfo(userInfo);	-- 연관관계 연결

    userRepository.save(user);	-- db 반영 ( persist )
    log.info("user = {}",  user.getUserKey());

    return user;
}

// Hibernate 결과
Hibernate: 
    /* insert for
        dev.es.myasset.domain.user.UserInfo */insert 
    into
        user_info (email, provider_type, username, user_key) 
    values
        (?, ?, ?, ?)
Hibernate: 
    /* insert for
        dev.es.myasset.domain.user.User */insert 
    into
        user (created_at, last_login_at, role, status, withdraw_req_at, user_key) 
    values
        (?, ?, ?, ?, ?, ?)

 

Hibernate 내부에서는 다음 순서로 동작한다.

  1. @MapsId 확인
  2. userInfo.userKey 조회
  3. 해당 값을 User.userKey에 복사
  4. INSERT 수행

 

 

  • § PK가 Null이라서 문제되지 않나??

PK가 null인 것이 문제 되는 시점은 persist 시점부터다.
즉, 엔티티가 Transient 상태일 때는 PK가 null이어도 괜찮다.

“the entity has just been instantiated and is not associated with a persistence context. It has no persistent representation in the database and typically no identifier value has been assigned (unless the assigned generator was used).” - Hibernate reference - 6. Persistence Context


§  @PrimaryKeyJoinColumn

참고로 @PrimaryKeyJoinColumn 역시 Derived Identifier의 한 형태로 볼 수 있고, 두 엔티티가 같은 PK를 공유하는 구조에서 사용할 수 있다는 점에서는 나름 매력적인 선택지이기도 하다.

다만 이 애노테이션은 주로 레거시 DB 구조를 매핑할 때 사용되는 경우가 많고, 새로 도메인을 설계하는 상황에서는 굳이 선택되지 않는 편이라고 한다.

실제로 Vlad Mihalcea의 코멘트에서도 @MapsId 사용을 권고하고 있기 때문에, @PrimaryKeyJoinColumn은 레거시 시스템에서 마주쳤을 때 그때 가서 살펴봐도 늦지 않은 애노테이션 정도로 이해해도 무방할 것 같다.

 

 

출처:

 Hibernate reference - 3.7.16. Derived Identifiers

 Vlad Mihalcea Comment

silver-w
@silver-w :: silver-w 님의 블로그

silver-w 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차