# BerkInsert를 사용한 쿼리 개선
목차
문제가 발견된 코드
//post 생성@Transactionalpublic PostResponse createPost( PostRequest postRequest, HttpServletRequest request, Long boardId ){ //토큰 생성 String token = jwtUtils.validateTokenOrThrow(request); //userId 추출해서 Long으로 변경 Long tokenUserId = Long.parseLong(jwtUtils.getUserIdFromToken(token)); String username = userRepository.findById(tokenUserId) .orElseThrow(() -> new ApplicationException("없는 유저", HttpStatus.NOT_FOUND)) .getUsername(); //post 객체 생성 Post post = new Post(postRequest.getTitle(),postRequest.getContent(),tokenUserId,boardId); postRepository.save(post); // 저장 후 ID가 생성되었는지 확인 if (post.getId() == null) { throw new ApplicationException("Failed to save the post, ID is null!", HttpStatus.INTERNAL_SERVER_ERROR); } //알림 생성 //*********************중요*************************************// notificationDomainService.giveNotification(boardId,post.getId()); //**************************************************************//
//postResponse DTO 생성 PostResponse postResponse = new PostResponse(post.getId(),post.getTitle(),post.getContent(),tokenUserId,username,post.getCreatedAt(),post.getViews()); //postResponse DTO 반환 return postResponse; }쿼리개선을 하는 중에 진짜 심각한 문제를 발견했다..
바로 저기 중요라고 주석처리된 코드이다. 포스트가 작성되면 같은 보드에 있는 사람들에게 “user”가 게시글을 작성했습니다 라고 알림을 보내주는 기능을 하는 코드이다.
문제를 한번 보자
createPost를 실행한 결과
2024-07-02 18:11:21.097 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :selectuser0_.id as id1_4_0_,user0_.email as email2_4_0_,user0_.password as password3_4_0_,user0_.username as username4_4_0_fromuser user0_whereuser0_.id=?Hibernate:selectuser0_.id as id1_4_0_,user0_.email as email2_4_0_,user0_.password as password3_4_0_,user0_.username as username4_4_0_fromuser user0_whereuser0_.id=?-- ------------------------------ 1회 실행 ------------------------------2024-07-02 18:11:21.191 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.post.entity.Post/ insertintopost(created_at, updated_at, board_id, content, title, user_id, views)values(?, ?, ?, ?, ?, ?, ?)Hibernate:/ insert org.example.creww.post.entity.Post*/ insertintopost(created_at, updated_at, board_id, content, title, user_id, views)values(?, ?, ?, ?, ?, ?, ?)-- ------------------------------ 1회 실행 ------------------------------2024-07-02 18:11:21.224 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* SELECTnew org.example.creww.post.dto.PostWithUser(p.id,p.title,u.id,u.username)FROMPost pJOINUser uON p.userId = u.idWHEREp.id = :postId / selectpost0_.id as col_0_0_,post0_.title as col_1_0_,user1_.id as col_2_0_,user1_.username as col_3_0_frompost post0_inner joinuser user1_on (post0_.user_id=user1_.id)wherepost0_.id=?Hibernate:/ SELECTnew org.example.creww.post.dto.PostWithUser(p.id,p.title,u.id,u.username)FROMPost pJOINUser uON p.userId = u.idWHEREp.id = :postId */ selectpost0_.id as col_0_0_,post0_.title as col_1_0_,user1_.id as col_2_0_,user1_.username as col_3_0_frompost post0_inner joinuser user1_on (post0_.user_id=user1_.id)wherepost0_.id=?-- ------------------------------ 1회 실행 ------------------------------2024-07-02 18:11:21.232 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* SELECTub.userIdFROMUserBoard ubWHEREub.boardId = :boardIdAND ub.isExited = false / selectuserboard0_.user_id as col_0_0_fromuser_board userboard0_whereuserboard0_.board_id=?and userboard0_.is_exited=0Hibernate:/ SELECTub.userIdFROMUserBoard ubWHEREub.boardId = :boardIdAND ub.isExited = false */ selectuserboard0_.user_id as col_0_0_fromuser_board userboard0_whereuserboard0_.board_id=?and userboard0_.is_exited=0-- ------------------------------ 1회 실행 ------------------------------2024-07-02 18:11:21.238 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.notification.entity.Notification/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)Hibernate:/ insert org.example.creww.notification.entity.Notification*/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)-- ------------------------------ 1회 실행 ------------------------------2024-07-02 18:11:21.243 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.notification.entity.Notification/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)Hibernate:/ insert org.example.creww.notification.entity.Notification*/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)-- ------------------------------ 2회 실행 ------------------------------2024-07-02 18:11:21.244 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.notification.entity.Notification/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)Hibernate:/ insert org.example.creww.notification.entity.Notification*/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)-- ------------------------------ 3회 실행 ------------------------------2024-07-02 18:11:21.246 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.notification.entity.Notification/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)Hibernate:/ insert org.example.creww.notification.entity.Notification*/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)-- ------------------------------ 4회 실행 ------------------------------2024-07-02 18:11:21.247 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.notification.entity.Notification/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)Hibernate:/ insert org.example.creww.notification.entity.Notification*/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)-- ------------------------------ 5회 실행 ------------------------------2024-07-02 18:11:21.248 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.notification.entity.Notification/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)Hibernate:/ insert org.example.creww.notification.entity.Notification*/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)-- ------------------------------ 6회 실행 ------------------------------2024-07-02 18:11:21.249 DEBUG 5771 --- [nio-8080-exec-2] org.hibernate.SQL :/* insert org.example.creww.notification.entity.Notification/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)Hibernate:/ insert org.example.creww.notification.entity.Notification*/ insertintonotification(created_at, is_read, message, user_id)values(?, ?, ?, ?)-------------------------------- 7회 실행 -------------------------------
쿼리를 분석해보면 다른 부분들도 최적화가 필요하다 하지만 notification에 알림을 insert 하는 쿼리가 7번이나 실행되는 것을 볼 수 있다.
-
UserBoard 테이블을 보면 이유를 알 수 있다.
스크린샷 대신 마크다운 테이블 표로 대신하겠습니다..
userId
| boardId | |
|---|---|
| 1 | 1 |
| 2 | 1 |
| 3 | 1 |
| 4 | 1 |
| 5 | 1 |
| 6 | 1 |
| 7 | 1 |
board_id 가 1인 board에 유저가 7명이 존재해서 알림을 7개나 한개씩 생성하는 것이다.
어떻게 해결 할 것인가?
1. 처음에 Batch 기법을 생각했다.
- 하지만 사용하지 못했다. 왜냐하면 작동이 되질 않았다.
2. Batch를 사용하지 못한 이유
-
사용하지 못한 이유를 알아야 할 것 같아서 열심히 찾아봤는데 이유는 다음과 같았다.
-
영속성 컨텍스트를 사용하여 엔티티 객체를 저장한 후에 Batch 작업이 실행됨
-
하지만 어제 시도했던 방식에서는 직접 레포지토리에서 savaAll() 메서드를 호출하여 Batch 작업을 수행하려 함
-
하지만 레포지토리의 saveAll 메서드는 내부적으로 각각의 엔티티 객체를 개별적으로 저장하는 방식으로 동작함
-
3. BerkInsert를 사용한 이유
-
일단 왜 Batch가 사용이 안됐는지보다 빨리 해결을 하고싶은 마음에 다른 방법을 찾았는데 그것이 바로 BerkInsert 방법이라 바로 적용했다.
-
팀 프로젝트를 진행했을 당시에 회의를 하면서 다른 팀원이 BerkInsert를 적용한 코드를 공유해줬었는데 그 경험이 떠올랐다.
해결한 과정
-
NotificationRepositoryCustom 인터페이스 정의
- NotificationRepositoryCustom 인터페이스를 정의하고, 그 안에 BulkInsert 메서드를 선언했다. 이 인터페이스는 JPA 레포지토리에서 지원하지 않는 커스텀 메서드를 정의할 때 사용한다.
-
NotificationRepositoryImpl에서 NotificationRepositoryCustom 구현
- NotificationRepositoryImpl 클래스를 생성하고, NotificationRepositoryCustom 인터페이스를 구현했다. 이 클래스에서 BulkInsert 메서드를 실제로 구현하는데, JdbcTemplate의 batchUpdate 메서드를 사용하여 INSERT 쿼리를 작성하고, BatchPreparedStatementSetter를 사용하여 파라미터를 설정했다.
public interface NotificationRepository extends JpaRepository <Notification,Long> ,NotificationRepositoryCustom { List<Notification> findByUserIdAndIsReadFalseOrderByCreatedAtDesc(Long userId);}
public interface NotificationRepositoryCustom { void bulkInsert(List<Notification> notifications);}-
BulkInsert 메서드 호출
- 기존 코드에서 반복문으로 개별 알림을 저장하던 부분을 BulkInsert 메서드 호출로 대체했다. 이제 Notification 엔티티 리스트를 BulkInsert 메서드에 전달하여 한 번에 INSERT 작업을 수행할 수 있게 되었다.
public class NotificationRepositoryImpl implements NotificationRepositoryCustom{
private final JdbcTemplate jdbcTemplate; private final EntityManager entityManager;
@Autowired public NotificationRepositoryImpl(JdbcTemplate jdbcTemplate, EntityManager entityManager) { this.jdbcTemplate = jdbcTemplate; this.entityManager = entityManager; }
@Override public void bulkInsert(List<Notification> notifications) { String sql = "INSERT INTO notification (user_id, message, created_at, is_read) VALUES (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { Notification notification = notifications.get(i); ps.setLong(1, notification.getUserId()); ps.setString(2, notification.getMessage()); ps.setTimestamp(3, Timestamp.valueOf(notification.getCreatedAt())); ps.setBoolean(4, notification.isRead()); }
@Override public int getBatchSize() { return notifications.size(); } }); }}BerkInsert 적용 전
public void giveNotification(Long boardId, Long postId) { List<UserBoard> userList = userBoardRepository.findByBoardIdAndIsExitedFalse(boardId); Post post = postRepository.findById(postId) .orElseThrow(() -> new ApplicationException("존재하지 않는 post", HttpStatus.NOT_FOUND)); String username = userRepository.findById(post.getUserId()) .orElseThrow(() -> new ApplicationException("존재하지 않는 user", HttpStatus.NOT_FOUND)) .getUsername(); for (UserBoard user : userList) { notificationService.createNotification(user.getUserId(),username + "님이 " + post.getTitle() + " 게시글을 작성 하셨습니다."); } }BerkInsert 적용 후
@Transactional public void giveNotification(Long boardId, Long postId) { PostWithUser postWithUser = postRepository.findPostWithUserById(postId) .orElseThrow(() -> new ApplicationException("게시글 없음", HttpStatus.NOT_FOUND)); String message = postWithUser.getUsername() + "님이 " + postWithUser.getPostTitle() + " 게시글을 작성하셨습니다."; List<Long> userIds = userBoardRepository.findUserIdsByBoardIdAndIsExitedFalse(boardId); List<Notification> notifications = userIds.stream().map(userId -> new Notification(userId, message)) .collect(Collectors.toList()); //==================== 중요 ======================// notificationRepository.bulkInsert(notifications); //==================== 중요 ======================// }해결된 후 쿼리 실행 결과
Hibernate: select user0_.id as id1_40, user0_.email as email2_40, user0_.password as password3_40, user0_.username as username4_40 from user user0 where user0.id=?Hibernate: /* insert org.example.creww.post.entity.Post / insert into post (created_at, updated_at, board_id, content, title, user_id, views) values (?, ?, ?, ?, ?, ?, ?)Hibernate: / SELECT new org.example.creww.post.dto.PostWithUser(p.id, p.title, u.id, u.username) FROM Post p JOIN User u ON p.userId = u.id WHERE p.id = :postId / select post0_.id as col_00, post0_.title as col_10, user1_.id as col_20, user1_.username as col_30 from post post0 inner join user user1 on ( post0_.userid=user1.id ) where post0_.id=?Hibernate: / SELECT ub.userId FROM UserBoard ub WHERE ub.boardId = :boardId AND ub.isExited = false */ select userboard0_.user_id as col_00 from userboard userboard0 where userboard0_.boardid=? and userboard0.is_exited=0JDBC 템플릿을 사용해서 위에는 표시되지 않았지만 로그를 찍어서 추가를 해야겠다
후기: JDBC 템플릿은 두번째 사용해봤다. 복잡하긴 해도 장점이 많은 것 같다.