[SpringBoot/DB] JPA로 복합키 생성하기

다은·2025년 5월 31일

SpringBoot

목록 보기
7/14

유학생을 위한 한국어 학습 서비스, LearnMate 개발을 하면서 마주한 복합키 개념!
DB의 주요 개념인 Key, 그 중 복합키에 대해 알아보고 SpringBoot에서 복합키를 생성하는 방법에 대해 알아봅시다.



1. Key

DB에서 다루는 key에는 여러 종류가 있습니다. 다음 그림을 참고하여 간략하게 살펴봅시다!

Student Table

studentIdnamenationalIdaddressphone
1james2seoul01011110000
2joy3busan01000001111

1-1. Super Key

  • 유일성 O
  • 최소성 X
  • Table의 Column 집합 중에서 해당 로우를 식별하는 기능, 즉 유일성을 가진 칼럼의 집합을 나타냄
  • 위와 같은 Student table에서 super key는 다음과 같음
    • (studentId), (studentId, name), (studentId, name, address),
    • (nationalId), (nationalId, name), (nationalId, address, phone),
    • (studentId, nationalId), (studentId, nationalId, name), (studentId, nationalId, address, phone) , …… 등이 있다.

1-2. Candidate Key

  • 유일성 O
  • 최소성 O
  • super key의 집합 중 최소성을 만족하는 집합을 지칭함
  • 위와 같은 Student table에서 candidate key는 다음과 같음
    • (studentId), (nationalId)
  • (studentId), (studentId, name), (studentId, name, address) 등의 super key에서는 name, address 칼럼 정보를 고려하지 않고도 studentId만 가지고도 로우를 식별할 수 있으므로, (studentId)는 최소성을 만족한다고 할 수 있음

1-3. Primary Key

  • 유일성 O
  • 최소성 O
  • candidate key 집합 중에서 선택 받은 key로, 로우를 식별하는 기능을 수행함
  • 하나의 table에는 반드시 한 개의 PK만 존재함
  • PK는 절대 Null이 되어서는 안되며, 이는 개체 무결성과 연관됨

1-4. Alternate Key

  • 유일성 O
  • 최소성 O
  • candidate key 집합 중 PK로 선택되지 않은 나머지 key를 지칭함



2. Composite Key

앞서 Candidate Key는 유일성과 최소성을 보장하는 키 집합이라고 했다. 칼럼 중에서 Id값으로 지칭할만한 칼럼이 존재하지 않는다면, 2개 이상의 칼럼을 key로 지정하여 로우를 식별할 수 있고, 이를 복합키라고 부릅니다.

예시로, (studentId, nationalId)를 컬럼으로 가지는 table을 만들고, 두 칼럼을 key로 지정하면, 별도의 id를 만들지 않고도 PK를 생성할 수 있습니다.

Create table professor (
	name varchar(20) not null,
	deptName varchar(20),
	salary numeric(8,2),
	primary key (name, deptName),
	foreign key (deptName) references department
);
Create table student_info (
	studentId char(5) not null,
	nationalId char(5) not null,
	primary key (studentId, nationalId)
);



3. Composite Key를 가지는 Entity 만들기

LearnMate 개발 중 다음과 같은 상황을 마주했습니다.

  • quiz_type : 한국어 학습 퀴즈 관련 정보를 담고 있는 ENUM (step 정보, 퀴즈 내용 등)
  • step_progress : 유저의 step 학습 정보를 담은 Entity
  • user_quiz_answer : 유저가 각 step에서 풀이한 퀴즈에 대한 정보를 담은 Entity

퀴즈에 대한 정보는 모두 ENUM으로 관리하기에, 유저와 퀴즈 사이의 정보는 step_progress, user_quiz_answer entity를 이용해 관리했습니다.

유저가 각 step에서 어떤 문제를 풀었고, 학습률은 어떻게 되는지 등의 조회 기능을 인덱스를 이용해 빠르게 구현하고자 step_progress entity에는 고유 id를 사용했습니다.

그러나, user_quiz_answer entity의 경우, 어떤 step에서 어떤 quiz를 풀었는지를 나타내는 것이 주 목적이었습니다. step별로 각 사용자가 풀이한 문제를 명확하게 추적하기 위해 고유 id보다는 복합키를 이용해 관리하는 방법을 선택했습니다.

create table user_quiz_answer (
    step_progress_id bigint not null,           
    quiz_type varchar(50) not null,             
    selected_option_idx int not null,           
    is_correct boolean not null,                
    primary key (step_progress_id, quiz_type)   
);

3-1. @EmbeddedId

위의 SQL문을 JPA에서 다음과 같이 나타냈습니다. 해당 key는 재사용하지 않을 것이기 때문에, 보다 객체지향적이고 명확한 설계를 위해 해당 방법을 선택했습니다.

  • @Embeddable을 이용해 Id를 객체로 취급하기 때문에 쉽게 관리할 수 있음
  • 복합키 내부 칼럼에 userQuizAnswerId.stepProgressId 으로 접근하기 때문에 코드가 명확하고 유지보수에 용이함
  • 객체지향적인 방식으로, 명확하고 직관적으로 복합키를 관리할 수 있음
  • key의 재사용은 어려울 수 있음
@Embeddable
data class UserQuizAnswerId(

    val stepProgressId: Long,
    
    @Enumerated(EnumType.STRING)
    val quizType: QuizType

) : Serializable {
    constructor() : this(0L, QuizType.Quiz1_1_1)
}
@Entity
@Table(name = "user_quiz_answer")
data class UserQuizAnswer(

    @EmbeddedId
    val quizAnswerId: UserQuizAnswerId,

    @Column(nullable = false)
    val selectedOptionIdx: Int,

    @Column(nullable = false)
    val isCorrect: Boolean

) : BaseEntity()
// save userQuizAnswer
        quizAnswerRepository.save(
            UserQuizAnswer(
                quizAnswerId = UserQuizAnswerId(stepProgressId, quiz),
                selectedOptionIdx = selectedIdx,
                isCorrect = isCorrect
            )
        )

3-2. @IdClass

위의 방식과 다르게, @IdClass를 이용해서도 복합키를 생성할 수 있습니다.

  • Id로 사용할 class 생성 후 @IdClass를 이용해 해당 class를 id로 지정
  • class를 이용해 key를 외부에서 관리하기 때문에 key 재사용 가능하나 오히려 관리가 어려울 수도 있음
  • JPA는 Id를 직렬화하여 사용하기 때문에, 이를 위해 id class는 Serializable 클래스를 구현해야 함
data class UserQuizAnswerId(

    val stepProgressId: Long,
    val quizType: QuizType

) : Serializable {
    constructor() : this(0L, QuizType.Quiz1_1_1)
}
@Entity
@IdClass(UserQuizAnswerId::class)
@Table(name = "user_quiz_answer")
data class UserQuizAnswer(

    @Id
    @Column(nullable = false)
    val stepProgressId: Long,

    @Id
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    val quizType: QuizType,

    @Column(nullable = false)
    val selectedOptionIdx: Int,

    @Column(nullable = false)
    val isCorrect: Boolean

) : BaseEntity()

entity를 저장할 때는 저장하고자 하는 각 칼럼의 값을 다음과 같이 명시해주면 됩니다.

 // save userQuizAnswer
        quizAnswerRepository.save(
            UserQuizAnswer(
                stepProgressId = stepProgressId,
                quizType = quiz,
                selectedOptionIdx = selectedIdx,
                isCorrect = isCorrect
            )
        )
profile
CS 마스터를 향해 ..

0개의 댓글