왜 JPA Convention을 따로 정리했는가
- JPA는 자유도가 너무 높은 도구이다.
- 같은 요구사항도 구현 방식이 5~6가지씩 나올 수 있음
- Fetch Join, EntityGraph, QueryDSL, Lazy Loading 등 선택지가 너무 많음
- 팀/프로젝트가 커질수록 발생하는 문제
- 사람마다 다른 쿼리 스타일
- Repository / Service 책임 경계 붕괴
- 비슷한 페이징 조회 로직의 무한 복사
- 정답 코드보다는 선택 기준이 필요
- 언제 fetch join을 쓰는가
- 언제 QueryDSL로 분리하는가
- 언제 단건 조회를 여러 쿼리로 나누는가
이 Convention의 목표
- 예측 가능한 쿼리 구조 만들기
- (Cursor Rule Vibe Coding)
이 Convention이 다루는 범위
- Repository 계층의 역할과 책임
- Query 종류에 따른 분류 기준
- SELECT / CREATE / UPDATE / DELETE
- 단건 조회에서의 Fetch Join 규칙
- 여러 개의 1:N 관계를 조회해야 할 때의 전략
- 페이징 쿼리를 안전하게 처리하는 패턴
- Cursor 기반 페이징을 위한 Handler 구조
- Batch Insert / Update / Delete에 대한 명확한 허용·금지 기준
Data Fetching Interface Convention
모든 쿼리는 Repository에서 처리되어야 한다.{domain}.repository 패키지 하위에 위치하도록 한다.
1. {domain}Repository
JpaRepository를 상속받은 기본적인 JPA Repository Interface
[예시]
interface ChatroomRepository : JpaRepository<Chatroom, Long>2. {domain}QueryRepository
- QueryDSL 사용을 위한
queryFactory를 주입받은 Custom Repository QClass를 활용하여 쿼리를 처리한다.
[예시]
@Repository
class ChatroomQueryRepository(
private val queryFactory: JPAQueryFactory,
) {
private val chatroom = QChatroom.chatroom
// ...
}Query Processing Convention
최대한 가능한 경우에 대해 모든 Convention을 작성하였고, 해당 작업이 어느 플로우에 속하는지 꼼꼼하게 분석한다.
만약 해당 작업이 어느 플로우에도 속하지 않는다고 판단되면, 코드는 짜지 않고, Convention에 따른 구현 방법을 제시하고, 그렇게 생각한 이유를 구체적으로 명시한다. 이에 대한 Confirm은 후에 개발자가 내리도록 한다.
먼저 데이터베이스를 호출 시 해당 작업이 어느 작업에 속하는지 판단한다.
- 조회 (
SELECT) - 생성 (
CREATE) - 수정 (
UPDATE) - 삭제 (
DELETE)
조회 (SELECT)
- Service 계층 함수에
@Transactional(readOnly = true)필수
[단건 조회]
1. Join이 없는 경우
이 경우에는 해당 도메인의 Repository에 정의된 JpaRepository를 사용한다.
[주의]
Database Convention 상 id는 Long이 아닌, String이다.
따라서 Repository의 기본 제공 함수인 findById(id: Long)를 사용하면 Parameter Type가 맞지 않게 된다.
이 경우에는 아래와 같이 QueryDsl로 작성한다.
fun findUserProfileById(id: String): User? {
return queryFactory
.select(user)
.from(user)
.where(user.id.eq(id))
.fetchOne()
}2. 1:1 관계, 1개의 1:N 관계
[조회 컨벤션]
- JPQL의
join fetch를 사용한다. - 1:1 관계에서는
join fetch을 여러 번 쓸 수 있으므로, 1:1 관계의 Join은 모두join fetch를 한 쿼리 안에서 조회하면 된다. - 1:N은 필드가 하나일 때만
join fetch로의 조회를 허용한다. 예를 들어,Chatroom–ChatroomCharacter로 1:N 필드를 한 개 조회할 때는 JPQL의join fetch를 사용할 수 있다. - 하지만
Chatroom–ChatroomCharacter와Chatroom–ChatroomUser와 같이 1:N 필드 여러 개를 한 번에 조회할 때는 이 방법을 사용하면 안 된다. – 이 방법에 대해서는 아래 후술한다. - 정리하면, 1:1 관계 필드는 개수와 상관없이 모두 Join Fetch 가능, 가져오는 1:N 관계 필드가 1개까지만 Join Fetch 가능
[1:1 예시]
userProfile과userProfileMetadata는 1:1 관계로, JPQL의 Join Fetch를 사용한다.
@Query(
"""
select u
from UserProfile u
left join fetch u.metadata
where u.id = :id
"""
)
fun findUserById(id: String): UserProfile?[1:1 여러 개 예시]
ChatGroup과Chatroom1:1 관계,Chatroom과UserProfile1:1 관계,UserProfile과UserProfileMetadata1:1 관계로, 모두 1:1 관계로만 이루어져 있으므로, JPQL의 Join Fetch를 사용한다.
@Query(
"""
select cg
from ChatGroup cg
left join fetch cg.chatroom cr
left join fetch cr.userProfile ur
left join fetch ur.metadata um
where cg.id = :chatGroupId
"""
)
fun findByChatGroupId(chatGroupId: String): ChatGroup?[1:N 예시]
Chatroom과ChatroomCharacter1:N 관계,ChatroomCharacter과Character는 단일 연관 관계이므로, 1:N 관계가 정확히 1개인 경우의 조건을 만족시킨다.
@Query(
"""
select c
from Chatroom c
left join fetch c.chatroomCharacters cc
left join fetch cc.character
where c.id = :chatroomId
"""
)
fun findChatroomById(id: String): Chatroom?[요약]
- 단일 연관 관계(N:1, 1:1)는 개수 제한 없이 fetch join 가능하다.
- Root 기준 1:N 컬렉션은 최대 1개까지만 fetch join을 허용한다.
- Root 기준 1:N 컬렉션이 2개 이상 필요한 경우, fetch join을 단계별로 분리하여 수행한다. (
4. 여러 개의 1:N 관계가 포함되어 있는 경우참고)
3. N:M 관계
- Convention으로
@ManyToMany는 사용하지 않는다. - 대신 중간 테이블을 만들어, 1:N, N:1 로 나눈다. 예를 들어,
Chatroom과Character는 N:M 관계이나,Chatroom–ChatroomCharacter–Character로 나누어 관리한다. - 따라서
Chatroom단건 조회의 경우,Chatroom–ChatroomCharacter는 1:N, 해당ChatroomCharacter에 연결된Character는 1:1 관계이므로,1:1 관계, 1개의 1:N 관계의 컨벤션 규칙을 적용할 수 있다. - 하지만, 다른 1:N 관계를 가지는 필드를 가져와야 하는 경우에는 1:N 관계의 필드가 두 개 이상이 되어버려
1:1 관계, 1개의 1:N 관계방법을 사용할 수 없다.
4. 여러 개의 1:N 관계가 포함되어 있는 경우
여러 단계의 Fetch Join을 수행해야 하므로, 쿼리 분해와 재사용이 용이한 QueryDSL을 사용한다.
즉, 이 경우 QueryDSL을 사용해서 Multi-Step Fetch Join으로 가져온다.
[예시]
ChatGroup–ChatContentGroup은 1:N 관계,ChatContentGroup–ChatContent는 1:N 관계- 기타 관계로,
ChatGroup–Chatroom,Chatroom–UserProfile,UserProfile–UserProfileMetadata,ChatContent–UserProfile,ChatContent–Character는 모두 1:1 관계이다. - 따라서, 1:1 관계는 Join Fetch로 모두 가져오는 것이 가능하므로 Fetch Join을 어디 사용하는 관계없다.
[쿼리 과정]
- 단건 조회이므로
id로ChatGroup을 조회한다. 이때, 1:N 관계 필드인ChatContentGroup을 Join Fetch로 가져온다. 이때 첫 번째 쿼리의 결과는 실제로 반환하는 데이터이므로distinct를 사용한다. - 같은
id로ChatContentGroup을 조회한다. 이때, 1:N 관계 필드인ChatContent을 Join Fetch로 가져온다. - 1:1 관계 필드는 적절하게 끼워넣는다.
fun findByChatGroupId(chatGroupId: String): ChatGroup? {
val root = queryFactory
.selectFrom(chatGroup)
.distinct()
.leftJoin(chatGroup.chatroom, chatroom).fetchJoin()
.leftJoin(chatGroup.chatContentGroups, chatContentGroup).fetchJoin()
.leftJoin(chatroom.userProfile, userProfile).fetchJoin()
.leftJoin(userProfile.metadata, userProfileMetadata).fetchJoin()
.where(chatGroup.id.eq(chatGroupId))
.fetchOne()
?: return null
queryFactory
.selectFrom(chatContentGroup)
.leftJoin(chatContentGroup.chatGroup, chatGroup)
.leftJoin(chatContentGroup.chatContents, chatContent).fetchJoin()
.leftJoin(chatContent.userProfile, chatContentUserProfile).fetchJoin()
.leftJoin(chatContent.character, character).fetchJoin()
.where(chatGroup.id.eq(chatGroupId))
.fetch()
return root
}- 조회해야 하는 1:N의 필드 개수가 1개 늘어날 수록 쿼리 횟수도 1회 씩 늘어난다.
[요약]
여러 개의 1:N 관계가 포함된 단건 조회의 경우,
Root 기준으로 한 쿼리에서 fetch join 되는 컬렉션은 항상 1개만 유지하고,
나머지 1:N 관계는 별도의 fetch join 쿼리로 단계적으로 로딩한다.
Paging Interface Convention
본 Convention에서는 페이징 쿼리를 쉽게 사용하기 위해 여러 인터페이스를 제공하고 있다.
1. 공통 페이징 요청
페이징 API의 경우, Controller에 BasePagingRequest를 넣어서, 쉽게 페이징 요청을 받을 수 있다.
data class BasePagingRequest(
val limit: Int = 20,
val page: Int? = null,
val cursor: String? = null,
val direction: PagingDirection = PagingDirection.DOWN
)[예시]
아래는 채팅방 내 채팅을 조회하는 API로, @ModelAttribute request: BasePagingRequest로 쉽게 페이징 요청을 만들 수 있다.
@GetMapping("/chatroom/{chatroomId}")
fun findChatroomChatRecord(
@RequestHeader("X-User-Id") userId: String,
@PathVariable chatroomId: String,
@ModelAttribute request: BasePagingRequest,
): BaseResponse<PagingResponse<ChatGroupResponse>> {
return BaseResponse.ok(
data = chatService.findChatGroups(userId, chatroomId, request),
)
}2. 공통 페이징 응답
페이징 응답은 Cursor, Page 등 여러 정보와 함께 일관된 형식으로 페이징 응답을 처리할 수 있다.
data class PagingResponse<T>(
val items: List<T>,
val page: Int?,
val limit: Int,
val nextCursor: String?,
val prevCursor: String?,
val hasNext: Boolean?,
val hasPrev: Boolean?,
val itemCount: Int
)[예시]
Controller 계층에서 BaseResponse<PagingResponse<ChatGroupResponse>>로 응답할 수 있다.
return BaseResponse.ok(
data = chatService.findChatGroups(userId, chatroomId, request),
)3. 페이징 함수
Service 계층에서 페이징을 쉽게 처리할 수 있는 유틸 함수이다.
request: Controller 계층에서 받은 페이징 요청이다.handlers: 응답받을 Entity를 Generic으로 전달하고, 어떤 함수로 페이징을 처리해야 하는지가 정의되어 있다.
fun <T> paginate(
request: BasePagingRequest,
handlers: PagingHandlers<T>
): PagingResponse<T>[예시]
Handlers 정의에 대해서는 후술하겠다.
아래와 같이 사용할 수 있다.
val chatGroups = pagingService.paginate(
request = request,
handlers = handlers,
)4. 요청 Mapping
paginate 함수의 요청은 Entity로 넣는 것을 Convention으로 한다.
하지만 Entity를 Service 계층의 응답으로 돌려주는 것은 Convention 상 금지돼 있고, 반드시 DTO로 변환해서 넘겨야 한다.
map 함수로 BasePagingResponse의 응답을 원하는 Mapper로 변환시킬 수 있다.
val chatGroups = pagingService.paginate(
request = request,
handlers = handlers,
)
return pagingService.map(chatGroups) {
ChatGroupResponse.from(it)
}5. [가장 중요] 페이징 핸들러 구현
{domain}.paging 패키지에 {domain}PagingHandlers 클래스를 구현하면 된다.PagingService에 어떤 함수로 페이징을 처리할지 알려주는 역할을 한다.
PagingHandlers 인터페이스 정의는 아래와 같고, 6개의 함수를 구현해야 한다.
interface PagingHandlers<T> {
fun getCursorFromResponse(data: T): String
fun findById(id: String): T
fun findAllByOrderByIdAsc(pageable: Pageable): List<T>
fun findAllByOrderByIdDesc(pageable: Pageable): List<T>
fun findAllByIdGreaterThanOrderByIdAsc(id: String, pageable: Pageable): List<T>
fun findAllByIdLessThanOrderByIdDesc(id: String, pageable: Pageable): List<T>
}- 위로 조회, 아래로 조회, 커서 기반 조회 등 다양한 페이징 케이스를 처리하기 위해 위 함수를 구현해야 한다. 위 함수의 이름은 기본적으로
JpaRepository의 자동 생성 메소드로 정했으나, 내부 구현은 같은 결과를 내도록 반환하면 된다.
[예시]
아래는 chatGroup.paging.ChatGroupHandlers 예시이다.
특정 채팅방의 채팅을 조회하는 것을 돕는 핸들러이다.
QueryRepository (QueryDSL Custom Repository)를 만들어 사용하는 것을 권장한다.
id를 인자로 요구하는 함수들은 모두 String 타입의 id를 사용한다.
이는 클라이언트에게 내부 식별자인 seq: Long을 노출하지 않기 위한 Convention이다.
seq는 내부 DB 식별자(PK)로 사용되며- 보안 및 내부 구조 은닉을 위해 API 레벨에서는 외부에 공개하지 않는다
따라서 Handler 내부에서는 다음과 같은 전략을 사용한다.
id: String로 단건 조회를 1회 수행한다.- 해당 엔티티의 내부 식별자
seq: Long를 얻는다 - Repository에는
seq: Long을 넘겨 정렬·비교·범위 조건에 활용한다. (정렬, 비교, 페이징 성능 향상)
class ChatGroupPagingHandlers(
private val chatroomSeq: Long,
private val chatGroupQueryRepository: ChatGroupQueryRepository,
) : PagingHandlers<ChatGroup> {
override fun getCursorFromResponse(data: ChatGroup): String {
return data.id
}
override fun findById(id: String): ChatGroup {
return chatGroupQueryRepository.findByChatGroupIdAndChatroomSeq(chatGroupId = id, chatroomSeq = chatroomSeq)
?: throw ChatGroupNotFoundException()
}
override fun findAllByOrderByIdAsc(pageable: Pageable): List<ChatGroup> {
return chatGroupQueryRepository.findAllByChatroomSeqOrderBySeqAsc(chatroomSeq = chatroomSeq, pageable = pageable)
}
override fun findAllByOrderByIdDesc(pageable: Pageable): List<ChatGroup> {
return chatGroupQueryRepository.findAllByChatroomSeqOrderBySeqDesc(chatroomSeq = chatroomSeq, pageable = pageable)
}
override fun findAllByIdGreaterThanOrderByIdAsc(id: String, pageable: Pageable): List<ChatGroup> {
val item = chatGroupQueryRepository.findChatGroupEntityByIdAndChatroomSeq(chatGroupId = id, chatroomSeq = chatroomSeq)
?: throw ChatGroupNotFoundException()
return chatGroupQueryRepository.findAllByChatroomSeqAndSeqGreaterThanOrderBySeqAsc(
chatroomSeq = chatroomSeq,
seq = item.seq,
pageable = pageable,
)
}
override fun findAllByIdLessThanOrderByIdDesc(id: String, pageable: Pageable): List<ChatGroup> {
val item = chatGroupQueryRepository.findChatGroupEntityByIdAndChatroomSeq(chatGroupId = id, chatroomSeq = chatroomSeq)
?: throw ChatGroupNotFoundException()
return chatGroupQueryRepository.findAllByChatroomSeqAndSeqLessThanOrderBySeqDesc(
chatroomSeq = chatroomSeq,
seq = item.seq,
pageable = pageable,
)
}
}[참고]
PagingService에서 Paging 요청에 대한 검증을 하기 때문에,
cursor != null이면 cursor 기반으로 처리direction="BIDIRECTIONAL"이면 cursor 필수- 등등…
Handler의 구현만 잘 하면 내부적으로 알아서 처리된다.
[여러 건 조회 (페이징)]
PagingHandlers에 정의된 6개의 함수들,
getCursorFromResponsefindByIdfindAllByOrderByIdAscfindAllByOrderByIdDescfindAllByIdGreaterThanOrderByIdAscfindAllByIdLessThanOrderByIdDesc
만 잘 정의하면, 아래와 같이PagingService를 통해 페이징을 쉽게 처리할 수 있다.
val pagingResult = pagingService.paginate(
request = request,
handlers = handlers,
)getCursorFromResponse는 보통 아래와 같이 식별자만 리턴하는 함수로 구현할 수 있다.
override fun getCursorFromResponse(data: ChatGroup): String {
return data.id
}findById는 위에서 설명한 [단건 조회]와 같은 방식으로 Repository에 구현하면 된다.- 나머지 4개의 함수는
findAll...계열이므로, QueryDSL을 사용하여 Repository에 구현하면 된다. 이제부터findAll...계열 페이징 함수 구현을 설명하겠다.
Paging에서는 컬렉션(1:N) Fetch Join을 직접 사용하지 않는다.
- 1:N fetch join은 같은 Root 엔티티가 N개의 row로 늘어나므로, 페이징 결과가 왜곡된다. (누락/중복/페이지 간 분산)
아래 잘못된 예시를 보자.
val chatrooms = queryFactory
.selectFrom(chatroom)
.leftJoin(chatroom.chatroomCharacters, chatroomCharacter).fetchJoin()
.offset(pageable.offset)
.limit(pageable.pageSize)
.fetch()Chatroom과ChatroomCharacter는 1:N 관계이어서 Fetch Join을 했는데, Paging(Offset + Limit)과 함께 사용하고 있다. 이렇게 사용하면 안 됨 ❌
그래서 findAll... 계열 조회는 페이징 대상과 연관관계 로딩을 분리한다.
1. 페이징 대상 식별자만 조회
- Join 없이 순수한 페이징 쿼리로 페이징이 적용될 엔티티의 식별자만 조회
- Fetch Join은 이 단계에서 하지 않는다. (애초에 페이징 쿼리라 동작하지도 않는다.)
[예시]
val seqs = queryFactory
.select(chatGroup.seq)
.from(chatGroup)
.where(chatGroup.chatroom.seq.eq(chatroomSeq))
.orderBy(chatGroup.seq.asc())
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.fetch()2. 조회한 식별자를 기준으로 Fetch Join
- 1단계에서 조회한 식별자 목록을
IN절 조건으로 사용하여 연관 엔티티를 Fetch Join으로 한 번에 로딩한다.
[예시]
val query = queryFactory
.selectFrom(chatGroup)
.distinct()
.leftJoin(chatGroup.chatContentGroups, chatContentGroup).fetchJoin()
.where(chatGroup.seq.`in`(seqs))이 시점에서는 이미 조회 대상이 확정되어 있으므로 페이징이 필요 없고 Fetch Join을 사용할 수 있다.
이 전략은 구조적으로 단건 조회를 여러 건으로 확장한 형태이다.
단건 조회에서
- 식별자 대상 쿼리
- 대상 조회를
WHERE절로 단일 조회하던 것을IN절로 페이징 데이터를 조회
하는 것을 제외하고는 실제 엔티티 그래프를 로딩하는 방식은 단건 조회와 완전히 동일하다.
[예시]
특정 채팅방( Chatroom)의 채팅 기록(ChatGroup)을 페이징으로 조회할 때, PagingHandler의findAll... 계열 함수인 findAllByIdGreaterThanOrderByIdAsc의 구현 예시를 보자.
이 함수에서는
- 입력
id보다 큰 데이터 id기준 오름차 순 정렬
을 페이징 쿼리와 함께 처리해야 한다.
- 우선 해당
id를 가지는ChatGroup이 존재하는지 확인한다. - 다음에는
ChatGroupQueryRepository의 함수를 호출한다.
override fun findAllByIdGreaterThanOrderByIdAsc(id: String, pageable: Pageable): List<ChatGroup> {
val item = chatGroupQueryRepository.findChatGroupEntityByIdAndChatroomSeq(chatGroupId = id, chatroomSeq = chatroomSeq)
?: throw ChatGroupNotFoundException()
return chatGroupQueryRepository.findAllByChatroomSeqAndSeqGreaterThanOrderBySeqAsc(
chatroomSeq = chatroomSeq,
seq = item.seq,
pageable = pageable,
)
}아래는 ChatGroupQueryRepository의 findAllByChatroomSeqAndSeqGreaterThanOrderBySeqAsc 구현이다.
- Fetch Join 없이 페이징 대상 식별자만 조회한다.
fun findAllByChatroomSeqAndSeqGreaterThanOrderBySeqAsc(
chatroomSeq: Long,
seq: Long,
pageable: Pageable,
): List<ChatGroup> {
val seqs = queryFactory
.select(chatGroup.seq)
.from(chatGroup)
.where(
chatGroup.chatroom.seq.eq(chatroomSeq),
chatGroup.seq.gt(seq),
)
.orderBy(chatGroup.seq.asc())
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.fetch()
return findChatGroupsBySeqs(seqs, orderAsc = true)
}- [단건 조회]와 비슷한 플로우로, 대상 식별자를
IN절로 Fetch Join과 함께 처리한다.
- 조건이
WHERE절에서IN절로 바뀐 것 뿐이다. - 여러 개의 1:N 필드가 있을 때 조회 전략인
4. 여러 개의 1:N 관계가 포함되어 있는 경우를 참고한, Multi-Step Fetch Join으로 처리하였다.
private fun findChatGroupsBySeqs(
seqs: List<Long>,
orderAsc: Boolean,
): List<ChatGroup> {
if (seqs.isEmpty()) return emptyList()
val query = queryFactory
.selectFrom(chatGroup)
.distinct()
.leftJoin(chatGroup.chatroom, chatroom).fetchJoin()
.leftJoin(chatGroup.chatContentGroups, chatContentGroup).fetchJoin()
.where(chatGroup.seq.`in`(seqs))
val roots = if (orderAsc) {
query.orderBy(chatGroup.seq.asc()).fetch()
} else {
query.orderBy(chatGroup.seq.desc()).fetch()
}
queryFactory
.selectFrom(chatContentGroup)
.leftJoin(chatContentGroup.chatGroup, chatGroup)
.leftJoin(chatContentGroup.chatContents, chatContent).fetchJoin()
.leftJoin(chatContent.userProfile, chatContentUserProfile).fetchJoin()
.leftJoin(chatContent.character, character).fetchJoin()
.where(chatContentGroup.chatGroup.seq.`in`(seqs))
.fetch()
return roots
}[참고]
식별자들을 대상으로 조회하는 함수는
findAllByOrderByIdAscfindAllByOrderByIdDescfindAllByIdGreaterThanOrderByIdAscfindAllByIdLessThanOrderByIdDesc
함수에서도 똑같이 사용되기 때문에private로 Repository 내에서 재사용하는 것을 권장한다.
정리하면,
- 식별자들만을 조회하는 쿼리 1회
- 이후 어떤 데이터를 가져와야 하나(1:1, 1:N 등)는 [단건 조회]와 같은 논리
- 대신,
WHERE절 단건 조회에서IN절 다건 조회로 바뀜
생성 (CREATE)
- Service 계층 함수에
@Transactional필수
[단건 생성]
Entity에 compoanion object로 정적 생성 함수인 create를 정의해서 처리한다.
- Entity 생성 시 필요한 필수 조건과 불변식은 Service가 아닌 Entity 내부에 위치시킨다.
val chatGroup = ChatGroup.create(
chatroom = chatroom,
character = character,
userProfile = userProfile,
message = request.message,
)ChatGroup – ChatContentGroup – ChatContent와 같이 같이 생성되어야 하는 Entity의 경우, 생성 책임을 분명히 하지 않기 위해 재귀적으로 처리하지 않고 Root Entity에서 생성 책임을 모두 맡는다.
- 하위 Entity가 자신의 하위 Entity를 다시 생성하는 구조는 지양한다.
- 생성 책임은 항상 Aggregate Root에만 존재한다.
이때, 연관관계의 주인 (FK를 가지고 있는 쪽)에서는 cascade에 최소 PERSIST, 최대 ALL이 있어야 repository.save(...) 시 같이 저장된다.
- Cascade는 생성/삭제 책임이 Root에 있을 때만 설정한다.
@OneToMany(
mappedBy = "chatContentGroup",
cascade = [CascadeType.ALL],
orphanRemoval = true,
fetch = FetchType.LAZY
)companion object {
fun create(chatroom: Chatroom, character: Character?, userProfile: UserProfile, message: String): ChatGroup {
val chatGroup = ChatGroup(
chatroom = chatroom,
)
val chatContentGroup = ChatContentGroup(
chatGroup = chatGroup,
)
chatGroup.chatContentGroups.add(chatContentGroup)
val chatContent = ChatContent(
chatContentGroup = chatContentGroup,
message = message,
isCharacter = character != null,
userProfile = userProfile,
character = character
)
chatContentGroup.chatContents.add(chatContent)
return chatGroup
}
}Service 계층에서, Entity를 만든 뒤, repository.save(...)로 저장한다.
val chatGroup = ChatGroup.create(
chatroom = chatroom,
character = character,
userProfile = userProfile,
message = request.message,
)
chatGroupRepository.save(chatGroup)[배치 Insert]
Insert 작업은 되도록 가시성과 예측성을 위해 단건 Insert만 허용하는 것을 권장하나, 배치 Insert는 연관관계를 넣지 않는 Batch Insert의 경우 제한적으로 허용한다.
- 연관관계가 있는 경우에는 JDBC Repository를 만들어서 Batch Insert 하는 것은 Convention 상 금지되어 있으므로, 이때는 Batch Inesrt가 지원되지 않더라도
repository.saveAll(...)로 저장하도록 한다.
{domain}JdbcRepository를 만들어 해결한다.
id생성 전략이IDENTITY이면,repository.saveAll(...)로 배치 처리가 되지 않기 때문에 JDBC로 DB Native Query를 날려야 한다.- JDBC Repository의 메소드 이름은
saveAll을 컨벤션으로 한다. - Private 함수로
insert{domain}s를 컨벤션으로 하여 함수를 구현한다. 자세한 구현은 아래 예시를 참고한다. - 아래 예시는
TodoEntity에 Batch Insert를 처리하는 예시이다.
@Entity
@Table(name = "todos")
class TodoEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "seq")
val seq: Long = 0L,
@Column(name = "id", nullable = false, unique = true, updatable = false, length = 26)
val id: String = generateId(),
@Column(nullable = false)
var title: String,
@Column
var description: String?,
@Column(name = "is_completed", nullable = false)
var isCompleted: Boolean = false,
@Column(name = "is_deleted", nullable = false)
var isDeleted: Boolean = false,
@Column(name = "created_at", nullable = false)
val createdAt: LocalDateTime = LocalDateTime.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = LocalDateTime.now(),
)
@Repository
class TodoJdbcRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional
fun saveAll(todos: List<TodoEntity>) {
if (todos.isEmpty()) return
insertTodos(todos)
}
private fun insertTodos(todos: List<TodoEntity>) {
jdbcTemplate.batchUpdate(
"""
INSERT INTO todos
(id, title, description, is_completed, is_deleted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
object : BatchPreparedStatementSetter {
override fun setValues(ps: PreparedStatement, i: Int) {
val todo = todos[i]
ps.setString(1, todo.id)
ps.setString(2, todo.title)
ps.setString(3, todo.description)
ps.setBoolean(4, todo.isCompleted)
ps.setBoolean(5, todo.isDeleted)
ps.setObject(6, todo.createdAt)
ps.setObject(7, todo.updatedAt)
}
override fun getBatchSize(): Int = todos.size
}
)
}
}[주의]
- JdbcTemplate 기반 Batch Native Query는 영속성 컨텍스트를 전혀 거치지 않으며, 이미 로딩된 Entity 상태와 DB 상태가 불일치할 수 있다.
- 따라서 해당 트랜잭션 내에서는 Entity를 다시 조회하거나 사용하지 않는 것을 원칙으로 한다.
수정 (UPDATE)
- Service 계층 함수에
@Transactional필수
[단건 수정]
수정 작업은 영속성 컨텍스트의 Dirty Checking 메커니즘을 활용하는 방식으로 처리한다.
- 영속 상태의 Entity를 수정하는 경우,
repository.save(...)을 호출하지 않는다.
Entity를 Update 하는 동작은 Service 계층에 작성하지 않고, 단건 Create에서 했던 컨벤션처럼 Entity에 로직을 위임한다.
@Transactional
fun updateTodo(id: String, request: UpdateTodoRequest): TodoResponse{
val todo = todosRepository.findByIdAndIsDeletedFalse(id) ?: throw TodoNotFoundException()
todo.update(
request.title,
request.description,
request.isCompleted,
request.tags
)
return TodoResponse.from(todo, request.tags)
}- 아래는 Entity 내 Update 함수의 구현이다.
- 필드는 Nullable 하게 받아서 업데이트를 해야하는 필드만 바꿔준다.
fun update(
title: String?,
description: String?,
isCompleted: Boolean?,
tags: List<String>?
) {
title?.let { this.title = it }
description?.let { this.description = it }
isCompleted?.let { this.isCompleted = it }
tags?.let {
val tagEntities = it.map { tagName ->
TagEntity(
todo = this,
name = tagName
)
}
this.tags.clear()
this.tags.addAll(tagEntities)
}
this.updatedAt = LocalDateTime.now()
}[Batch Update]
1. id마다 업데이트 되는 필드가 모두 같은 경우
예를 들어, WHERE절이나 IN 절로 조회된 데이터를 모두 같은 데이터로 바꾸는 경우이다.
- 예시 1 :
id리스트의 Todo를 완료처리한다.UPDATE TodoEntity t set t.isCompleted = true where t.id in :ids
- 예시 2 :
id리스트의 Todo를 Soft Deletion 처리한다.UPDATE TodoEntity t set t.isDeleted = true where t.id in :ids
이 경우에는 JPQL로 @Modifying 어노테이션과 함께 Batch Update를 날린다.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
update TodoEntity t
set t.isDeleted = true
where t.id in :ids
"""
)
fun softDeleteByIds(@Param("ids") ids: List<String>): Int2. id마다 업데이트 되는 필드가 모두 다른 경우
예를 들어, 각 id마다 다른 데이터로 바꾸는 경우이다.
- 예시 :
id="a"는title="a"로,id="b"를title="b"로 업데이트
하지만 이 종류의 작업도 Batch Create와 비슷하게 가시성과 예측성을 위해 하지 않는 것을 권장한다.
이 경우 또한 Batch Create와 같은 방식으로 JDBC Native Query로 {domain}JdbcRepository를 만들어 Batch Update를 날려 해결한다.
data class UpdateTodosRequest(
val items: List<UpdateTodoItemRequest> = listOf(),
)
data class UpdateTodoItemRequest(
val id: String,
val description: String
)
fun batchDescriptionUpdate(request: UpdateTodosRequest): Int {
val todos = request.items
if (todos.isEmpty()) return 0
// 시간이 미세하게 달라질 수 있으므로 Loop 밖에 두기
val updatedTime = LocalDateTime.now()
jdbcTemplate.batchUpdate(
"""
UPDATE todos
SET
description = ?,
updated_at = ?
WHERE id = ?
""".trimIndent(),
object : BatchPreparedStatementSetter {
override fun setValues(ps: PreparedStatement, i: Int) {
val todo = todos[i]
ps.setString(1, todo.description)
ps.setObject(2, updatedTime)
ps.setString(3, todo.id)
}
override fun getBatchSize(): Int = todos.size
}
)
return todos.size
}[주의]
- JdbcTemplate 기반 Batch Native Query는 영속성 컨텍스트를 전혀 거치지 않으며, 이미 로딩된 Entity 상태와 DB 상태가 불일치할 수 있다.
- 따라서 해당 트랜잭션 내에서는 Entity를 다시 조회하거나 사용하지 않는 것을 원칙으로 한다.
삭제 (DELETE)
- Service 계층 함수에
@Transactional필수
Soft / Hard Deletion에 따라 전략이 다른데, 아래 순서에 따라 삭제 전략을 지정한다.
1. Entity에 isDeleted 필드가 있다.
- Soft Deletion으로 처리한다.
2. Entity에 isDeleted가 있는데, 개발자가 Hard Deletion을 명령한다.
- Hard Deletion으로 처리한다.
3. Entity에 isDeleted 필드가 없다.
- Hard Deletion으로 처리한다.
Soft Deletion의 경우
Entity의 isDeleted의 필드를 true에서 false로 바꾸는 Update 동작으로 간주한다.
수정(UPDATE)의 플로우를 따라가도록 한다.
- 단일 Soft Deletion 경우,
isDeleted필드에 대한 단건 수정과 같음. - 여러 건 Soft Deletion의 경우,
isDeleted필드에 대한 Batch Update와 같음. - [Batch Update]의
id마다 업데이트 되는 필드가 모두 같은 경우를 참고한다.
Hard Deletion의 경우
JPQL을 활용하여 해결한다.
Return은 Int로, 삭제된 Entity의 개수를 반환한다.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
delete from TodoEntity t
where t.id in :ids
"""
)
fun deleteByIds(@Param("ids") ids: List<String>): Int[요약]
- Soft Delete: UPDATE로 처리 (Dirty Checking or Bulk Update)
- Hard Delete: JPQL delete 사용
- JdbcTemplate 기반 Delete는 사용을 권장하지 않음.

댓글 남기기