
JPA를 통한 DB설계에서 최적화를 위해 다양한 설정을 하는 것을 확인할 수 있다.
이번에는 다양한 설정을 정리하기 위해 다음과 같은 시나리오를 가정한다.
이 서비스는 길드와 같이 특정 회원들이 모인 그룹이 존재하며,
동일한 길드에 속한 회원들은 해당 길드 내 게시글을 자유롭게 조회할 수 있다.
또한 시스템은 각 회원이 특정 게시글을 읽었는지 여부를 확인할 수 있어야 한다.
이를 기반으로 ERD를 설계하면 다음과 같이 작성할 수 있다.

Entity 설계하기
그리고 JPA를 통한 Entity를 설계해보자.
1. Post Entity
@Entity
@Table(name = "post")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Post extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_id")
private Long id;
@Column(name = "title", nullable = false)
private String title;
@Column(name = "content", nullable = false, length = 300)
private String content;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "writer_id", nullable = false)
private Member writer;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "guild_id")
private Guild guild;
}
- Post는 Member 엔티티를 부모로 가지면서, writer 필드를 통해서 다대일(ManyToOne) 관계를 맺는다(N:1)
- 작성자(Member)가 여러 게시글(Post)를 작성할 수 있으므로, Post 입장에서는 Member가 부모(Owner) 역할을 수행하는 게 맞다!
- 이에대해서는 작성자에 소속되지 않으면 안되기 때문에 optional = false로 설정해준다(선택적이 아니라는 의미)
2. Member Entity
@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Member extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@Column(name = "name")
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "guild_id")
private Guild guild;
// FK의 주인에 맞춰서 작성해줘야함! Post의 필드에 wrtier 적힘
@OneToMany(mappedBy = "writer", fetch = FetchType.LAZY)
private List<Post> postList = new ArrayList<>();
}
- Member 엔티티는 Post 엔티티와 일대다(1:N, OneToMany)의 관계를 가진다. 위의 Post 참조
- Member에서는 mappedBy = "writer" 는 Post 엔티티의 wrtier 필드가 외래키(FK)의 주인임을 의미한다!
- 즉, 외래키는 Post테이블에 존재하며, Member는 참조만 하는 비소유자로 설정하면서 양방향 매핑을 완성한다.
(항상 다대일의 다가 FK의 주인이라는 김영한 강사님의 말씀을 새기자...)
3. Guild Entity
@Entity
@Table(name = "Guild")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Guild extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "guild_id")
private Long id;
@Column(name = "name")
private String name;
// 길드에 소속된 사람들
@OneToMany(mappedBy = "guild", fetch = FetchType.LAZY)
private List<Member> memberList = new ArrayList<>();
}
- Guild 엔티티는 여러 Member와 일대다(OneToMany, 1:N)의 관계를 가진다.
- 이것도 마찬가지로 FK의 주인인 Member에 의해 외래키가 관리되는 것이므로, Guild는 관계의 비소유자인 상태로 mappedBy = "guild"를 설정함으로써 양방향 매핑을 완성한다.
이제 post_read table에 대한 Entity 설계를 해보자.
4. PostRead Entity
@Entity
@Table(name = "letter_read")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Setter
public class PostRead extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "post_read_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id", foreignKey = @ForeignKey(name = "fk_read_post"))
private Post post;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "fk_read_member"))
private Member member;
@Column(name = "read_at")
private LocalDateTime readAt;
}
- PostRead 엔티티는 게시글(Post)과 회원(Member) 간의 읽기 이력(Log) 정보를 관리한
- 두 엔티티(Post, Member)와 각각 ManyToOne으로 단방향 매핑을 해준다. -> 이 때 우리는 위의 Member와 Post에서 OneToMany로 매핑하지 않은 것을 확인할 수 있다. 이유는?
- 이 테이블은 단순히 "조회 로그" 역할을 수행하므로, 양방향 매핑을 의도적으로 생략해주었다. 성능상으로 훨씬 유리하다.
- 양방향 관걔를 추가할 경우에는 불필요한 참조나 연관관계 관리 비용이 발생한다. 특히! 로그성 데이터에서는 N+1 문제 등의 부하 요인이 될 수 있다.
- 따라서 PostRead는 단방향 매핑으로 설계하여 I/O의 부하를 최소화해주었다.
인덱스를 추가해보기
이제 인덱스를 추가해보자.
이번에는 PostRead에 대하여 인덱스를 추가해보자.
서비스 규모가 커질수록 조회 성능 최적화는 필수가 되며, 특히 조회 로그(PostRead) 처럼 대량의 데이터가 누적되는 테이블에서는 인덱스 설계가 중요한 역할을 할 수 있다.
Table에 대하여 다음과 같이 수정한다.
@Entity
@Table(name = "post_read",
uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "member_id"}),
indexes = {
@Index(name = "idx_read_member_time", columnList = "member_id, read_at"),
@Index(name = "idx_read_post", columnList = "post_id")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Setter
public class PostRead extends BaseEntity {
이렇게 설정함으로써 어떤 효과를 볼 수 있는지 확인해보자.
1. @UniqueConstraint
우선은 @UniqueConstraint를 통하여 무결성을 보장할 수 있다.
post_id와 member_id 별로 중복된 읽음에 대한 테이블을 생성해서는 안된다. 두 조합에 대한 유일성(Unique) 을 보장한다.
2. `@Index`
@Index(name = "idx_read_member_time", columnList = "member_id, read_at") 에서 member_id와 read_at 컬럼에 대해 복합 인덱스(Composite Index) 를 생성한다.
특정 사용자의 읽기 이력(member_id)을 시간순(read_at)으로 조회하는 쿼리에서 성능을 크게 향상시킨다.
예를 들어, “특정 사용자가 최근에 읽은 게시글 목록”을 조회하는 시나리오에 최적화되어 있다.
@Index(name = "idx_read_post", columnList = "post_id")에서는 post_id 컬럼에 대한 단일 인덱스로, 특정 게시글을 읽은 사용자의 목록을 빠르게 조회할 수 있도록 돕는다.
“이 게시글을 읽은 사람은 누구인가?”와 같은 쿼리에서 효율적인 검색이 가능하다.
또한 각각의 이름을 기반으로 한 인덱스 테이블이 생성된다.
Hibernate:
create index idx_read_member_time
on post_read (member_id, read_at)
Hibernate:
create index idx_read_post
on post_read (post_id)
위와 같은 로그가 찍히는 것을 확인할 수 있다. Hibernate가 엔티티 매핑 정보에 따라 실제 데이터베이스에 인덱스를 자동 생성했음을 확인할 수 있다.
위는 실제 프로젝트의 설정이 아니기 때문에,
어떤식으로 사용될지에 대하여 GPT의 도움을 받아서 비교 가능한 상황들에 대하여 작성해보았다.
(여기서부터는 정말 단순 기록......)
어떤 방식으로 인덱스가 성능 최적화에 기여하는지만 알아보자!!
예시 시나리오 #1) “특정 사용자의 최근 읽기 이력 20건”
- JPA 예시
// 최근 읽은 순으로 페이징
@Query("""
select pr
from PostRead pr
join fetch pr.post p
where pr.member.id = :memberId
order by pr.readAt desc
""")
List<PostRead> findRecentReads(@Param("memberId") Long memberId, Pageable pageable);
- SQL
SELECT post_id, member_id, read_at, post_read_id
FROM post_read
WHERE member_id = :memberId
ORDER BY read_at DESC
LIMIT 20;
-> 여기서 볼 수 있는 인덱스 효과?
- 사용 인덱스: (member_id, read_at) 복합 인덱스
- 전(無인덱스): WHERE member_id = ? 조건 평가를 위해 대량 스캔 → 정렬 비용 증가
- 후(有인덱스): 인덱스 범위 스캔 + 인덱스 정렬 순서 재사용 → 정렬(External sort) 회피, 상위 20건 빠른 반환
- EXPLAIN ANALYZE 예시
-> Limit: 20 row(s)
-> Index range scan on post_read using idx_read_member_time (member_id = ?)
Rows examined: 20
Using index condition
Using where
Using index for order by
포인트: Using index for order by 문구가 보인다면, 인덱스의 정렬 순서를 그대로 재활용 중임을 의미한다.
MySQL 8.0 이상에서는 역순 스캔(DESC)도 효율적으로 수행되므로, 별도의 DESC 인덱스를 만들 필요가 없다.
예시 시나리오 #2) “이 게시글을 읽은 사용자 목록 페이징”
- JPA 예시
@Query("""
select pr
from PostRead pr
join fetch pr.member m
where pr.post.id = :postId
order by pr.readAt desc
""")
Slice<PostRead> findReaders(@Param("postId") Long postId, Pageable pageable);
- SQL
SELECT member_id, read_at, post_read_id
FROM post_read
WHERE post_id = ?
ORDER BY read_at DESC
LIMIT 50;
-> 여기서 볼 수 있는 인덱스 효과는 어떤 것이 있을까?
- 사용 인덱스: (post_id)
- 전: 테이블 풀스캔 후 정렬
- 후: 인덱스 범위 스캔으로 필터링 비용↓, 필요 시 파일정렬(File sort) 최소화
- EXPLAIN ANALYZE 예시
-> Limit: 50 row(s)
-> Sort: post_read.read_at DESC
-> Index range scan on post_read using idx_read_post (post_id = ?)
포인트: 정렬이 남는다면 (post_id, read_at) 복합 인덱스로 쿼리 패턴 맞춤 최적화를 고려.
단, 인덱스는 쓰기(INSERT/UPDATE) 비용을 증가시키므로 핫 쿼리 중심으로 신중히 적용하는 것이 좋다.
예시 시나리오 #3) Keyset Pagination (딥 페이지 성능 개선)
- 목적
- OFFSET k LIMIT n 는 페이지가 깊어질수록 스캔 비용이 선형 증가.
- read_at < :cursor 조건으로 키셋 페이지네이션 적용 시 성능 향상.
- SQL
SELECT post_id, member_id, read_at
FROM post_read
WHERE member_id = ?
AND read_at < ?
ORDER BY read_at DESC
LIMIT 20;
- 인덱스 (member_id, read_at)가 선행 컬럼(member_id) → 범위조건(read_at) 순으로 최적.
이러면 인덱스 스캔할 때 조건검색 + 정렬순서 재활용 이 동시에 가능하다!
- EXPLAIN ANALYZE 예시
-> Limit: 20 row(s)
-> Index range scan on post_read using idx_read_member_time (member_id = ? and read_at < ?)
Using index condition
Using where
Using index for order by
포인트: (member_id, read_at) 순서가 정확히 일치해야
선행 컬럼 조건(member_id) + 범위 조건(read_at) + 정렬 순서 재활용이 모두 성립한다.
예시 시나리오 #4) 데이터 무결성 검증 — 중복 삽입 방지
INSERT INTO post_read (post_id, member_id, read_at)
VALUES (?, ?, now());
ALTER TABLE post_read ADD CONSTRAINT uq_post_member UNIQUE (post_id, member_id);
테이블에 위와 같은 제약이 추가된다.
MySQL이 Duplicate Entry 오류를 발생시켜 중복에 대한 처리를 한다.
JPA의 save()호출을 해도 DB레벨에서 데이터 무결성을 보장한다.
각 시나리오를 통해 알 수 있듯이, 인덱스는 단순히 "검색 속도를 높이는 도구"를 넘어 정렬 효율성 확보, 디스크 I/O 절감, 데이터 일관성 유지까지 폭넓게 기여한다.
특히 MySQL에서는 인덱스 설계가 쿼리 최적화의 핵심이고,
EXPLAIN ANALYZE를 통해 실제 실행 게획을 지속적으로 검증하는 게 중요하다!