유학생을 위한 한국어 학습 서비스, LearnMate 개발을 하면서 마주한 복합키 개념!
DB의 주요 개념인 Key, 그 중 복합키에 대해 알아보고 SpringBoot에서 복합키를 생성하는 방법에 대해 알아봅시다.
DB에서 다루는 key에는 여러 종류가 있습니다. 다음 그림을 참고하여 간략하게 살펴봅시다!

Student Table
| studentId | name | nationalId | address | phone |
|---|---|---|---|---|
| 1 | james | 2 | seoul | 01011110000 |
| 2 | joy | 3 | busan | 01000001111 |
앞서 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)
);
LearnMate 개발 중 다음과 같은 상황을 마주했습니다.
quiz_type : 한국어 학습 퀴즈 관련 정보를 담고 있는 ENUM (step 정보, 퀴즈 내용 등)step_progress : 유저의 step 학습 정보를 담은 Entityuser_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)
);
위의 SQL문을 JPA에서 다음과 같이 나타냈습니다. 해당 key는 재사용하지 않을 것이기 때문에, 보다 객체지향적이고 명확한 설계를 위해 해당 방법을 선택했습니다.
@Embeddable을 이용해 Id를 객체로 취급하기 때문에 쉽게 관리할 수 있음userQuizAnswerId.stepProgressId 으로 접근하기 때문에 코드가 명확하고 유지보수에 용이함@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
)
)
위의 방식과 다르게, @IdClass를 이용해서도 복합키를 생성할 수 있습니다.
@IdClass를 이용해 해당 class를 id로 지정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
)
)