퇴근 후 어제에 이어 사이드 프로젝트에서의 회원 언급 기능에 대해서 작업을 진행하려고 했다.
(사이드 프로젝트는 이력서를 올리고, 이력서의 타입이 public하다면 Hub에, private하면 Mentioned 카테고리에 들어가고, Mentioned 카테고리에서는 이력서에 언급된 회원들만 조회 할 수 있는 기능을 기획하였다.)
오늘은 어제의 테스트케이스를 바탕으로 end-point를 구현하려고 하였으나
문득, 이력서가 없는데 어떻게 멘션을 하지? 라는 생각이 들었고, 테스트 케이스를 작성하며 생각했던건 언급을 단 건으로만 추가를 하는 로직으로 작성을 하고 있었다. 하지만 선행 조건으로는 이력서가 존재해야 멘션이 가능하였기 때문에, 단건 추가가 아닌 이력서 작성시 멘션을 추가하는 API를 목표로 구현을 했어야 했기 때문에 서비스 수정을 진행하였다.
개요
앞선 상황 속, end-point 부분은 프론트엔드 친구와 협의를 진행 후 작업을 진행하겠지만 다건의 insert service 구현은 필요해 보였다.
처음엔 List에 데이터를 담아 insert를 for 문을 통해 진행하려 하였으나, 이렇게 하면 언급의 경우 데이터가 많진 않아도 트랜잭션이 낭비되는 것 같아 saveAll을 사용하고자 하였다.
하지만, saveAll을 적용해도 Batch Insert가 나가지 않고, 개별적으로 insert 쿼리가 나갔다.
조금 검색해보니 JPA에서 MySQL을 사용하는경우, Entity의 GeneratedValue가 IDENTITY로 설정이 되어있는경우 saveAll의 경우 Batch Insert가 적용이 되지 않는다는것이였다.
이유는 save 로직을 수행시, ID 값을 명시하지않고, Spring Data JPA에서 제공하는 JpaRepository.save(T);의 내부 동작 방식에 의해 자동으로 처리되기 때문이다.
이러한 방식 때문에, Hibernate는 JDBC 수준에서 Batch Insert를 비활성화 시켰다고 한다.
본문
따라서, JDBC를 직접 이용하여 Batch Insert를 구현하기로 하였다.
우선, MySQL에서 JDBC를 이용해 Batch Insert를 하기 위해서는, application.yml을 수정을 해줘야 했다.
이유는 MySQL의 rewriteBatchedStatements 옵션이 true 인 경우에만 해당 Batch Insert가 수행이 되고,
MySQL 버전이 8 미만인경우, 해당 옵션이 default로 false가 되어있다고 한다. (8 이상부터는 default true)
spring: datasource: url: jdbc:mysql://{주소:3306}/{DB명}?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999
해당 옵션을 주어 쿼리까지 확인을 해보자. 옵션별 설명은 아래와 같다.
- postfileSQL = true : Driver에 전송하는 쿼리를 출력합니다.
- logger=Slf4JLogger : Driver에서 쿼리 출력 시 사용할 로거를 설정합니다.
- MySQL 드라이버 : 기본값은 System.err로 출력하도록 설정되어 있기 때문에 필수로 지정해 줘야 합니다.
- MariaDB 드라이버 : Slf4j 를 이용하여 로그를 출력하기 때문에 설정할 필요가 없습니다.
- maxQuerySizeToLog=999999 : 출력할 쿼리 길이
- MySQL 드라이버 : 기본값이 0으로 지정되어 있어 값을 설정하지 않을 경우 아래처럼 쿼리가 출력되지 않습니다.
- MariaDB 드라이버 : 기본값이 1024로 지정되어 있습니다. MySQL 드라이버와는 달리 0으로 지정 시 쿼리의 글자 제한이 무제한으로 설정됩니다.
출처 : https://dkswnkk.tistory.com/682
이렇게 설정을 한 이후, JdbcRepository를 생성하여 코드를 작성했다.
Mention Entity
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Mention {
// 멘션 ID
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "MENTION_ID")
private Long id;
// 언급된 이력서
@ManyToOne
@JoinColumn(name = "RESUME_ID")
private Resume resume;
// 언급된 회원
@ManyToOne
@JoinColumn(name = "MEMBERS_ID")
private Members members;
// 언급시간
private LocalDateTime regTime;
@Builder
public Mention(Resume resume, Members members){
this.resume = resume;
this.members = members;
this.regTime = LocalDateTime.now();
this.resume.getMentionList().add(this);
this.members.getMentionedList().add(this);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if(!(o instanceof Mention)){
return false;
}
Mention mention = (Mention) o;
return Objects.equals(id,mention.getId());
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
MentionJdbcRepository
@Repository
@RequiredArgsConstructor
public class MentionJdbcRepository {
private final JdbcTemplate jdbcTemplate;
@Transactional
public void saveAll(List<Mention> mentionList) {
jdbcTemplate.batchUpdate(
"INSERT INTO mention (`members_id`, `resume_id`,`reg_time`) VALUES (?, ?, ?)",
mentionList, // insert할 데이터 리스트
20, // 1회에 진행할 배치 사이즈(10만개 데이터라면 만 개 씩 10번 돌아감)
(ps, mention) -> {
ps.setLong(1, mention.getMembers().getId());
ps.setLong(2, mention.getResume().getId());
ps.setTimestamp(3,java.sql.Timestamp.valueOf(java.time.LocalDateTime.now()));
});
}
}
위와같이 JDBC Repository를 생성하여, saveAll 메서드를 작성하였고 위 메서드를 이용해서 서비스를 작성하였다.
위의 ps는 PrepareStatement를 의미하고, 각 필드별 Type을 맞춰주어야 한다.
나는 regTime 필드가 LocalDateTime이였기 때문에 setTimestamp를 사용했다.
참고 url : https://blogshine.tistory.com/281
// 이력서에 멘션을 추가하는 메서드 , 선행 조건으로 resume이 있어야함
@Transactional
public void addMention(AddMentionRequestDto addMentionRequestDto){
Resume resume = resumeRepository.findById(addMentionRequestDto.getResumeId()).orElseThrow(
() -> new ApplicationException(ErrorMessage.NOT_EXIST_RESUME_ID_EXCEPTION));
// 공개 이력서인데 언급을 시도하는경우
// TODO 목요일 회의시 이야기해보기
if(ResumeType.PUBLIC.equals(resume.getResumeType())){
throw new ApplicationException(ErrorMessage.CAN_NOT_MENTION_PUBLIC_RESUME_EXCEPTION);
}
// 멘션하려는이가 게시물의 주인이 아닌경우
if(!addMentionRequestDto.getMembers().equals(resume.getMembers())){
throw new ApplicationException(ErrorMessage.PERMITION_DENIED_TO_MENTION_EXCEPTION);
}
List<Mention> mentionList = new ArrayList<>();
for(long membersId : addMentionRequestDto.getMembersIdList()){
Members members = membersRepository.findById(membersId).orElseThrow(
() -> new ApplicationException(ErrorMessage.NOT_EXIST_MEMBER_EXCEPTION));
// 게시물의 작성자가 자기 자신을 언급하는경우
if(members.equals(resume.getMembers())){
throw new ApplicationException(ErrorMessage.CAN_NOT_MENTION_ONESELF_EXCEPTION);
}
Mention mention = Mention.builder()
.resume(resume)
.members(members)
.build();
mentionList.add(mention);
}
mentionJdbcRepository.saveAll(mentionList);
}
이렇게 서비스 코드를 작성을 한 이후, 테스트 코드를 돌렸더니 쿼리가 다음과 같이 나갔다.
생각했던대로 Batch Insert가 잘 나갔다,
하지만 추가한 membersId만큼 select 쿼리가 나가고 있었다.^^
기존 Mention 단건 언급 서비스에서 사용하다보니 본의아니게 N(select)+1(insert)개의 쿼리가 나가고 있었던 것이다 !
그래서 membersRepository에 findByIdIn 이라는 네이밍 메서드를 만들어
membersId도 List에 담아 한 번에 조회하게끔 코드를 수정했다.
// 이력서에 멘션을 추가하는 메서드 , 선행 조건으로 resume이 있어야함
@Transactional
public void addMention(AddMentionRequestDto addMentionRequestDto){
Resume resume = resumeRepository.findById(addMentionRequestDto.getResumeId()).orElseThrow(
() -> new ApplicationException(ErrorMessage.NOT_EXIST_RESUME_ID_EXCEPTION));
// 공개 이력서인데 언급을 시도하는경우
// TODO 목요일 회의시 이야기해보기
if(ResumeType.PUBLIC.equals(resume.getResumeType())){
throw new ApplicationException(ErrorMessage.CAN_NOT_MENTION_PUBLIC_RESUME_EXCEPTION);
}
// 멘션하려는이가 게시물의 주인이 아닌경우
if(!addMentionRequestDto.getMembers().equals(resume.getMembers())){
throw new ApplicationException(ErrorMessage.PERMITION_DENIED_TO_MENTION_EXCEPTION);
}
List<Mention> mentionList = new ArrayList<>();
// in 조건으로 한 번에 membersList를 조회해옴
List<Members> membersList = membersRepository.findByIdIn(addMentionRequestDto.getMembersIdList());
for(Members members : membersList){
// 게시물의 작성자가 자기 자신을 언급하는경우
if(members.equals(resume.getMembers())){
throw new ApplicationException(ErrorMessage.CAN_NOT_MENTION_ONESELF_EXCEPTION);
}
Mention mention = Mention.builder()
.resume(resume)
.members(members)
.build();
mentionList.add(mention);
}
mentionJdbcRepository.saveAll(mentionList);
}
이후 다시 테스트코드를 돌려보니 다음과 같이 쿼리가 총 3방이 나간것을 확인 할 수 있었다! (resume조회, membersList 조회, mention 등록)
결론
- insert 할 데이터가 많아질수록 insert를 개별로 치는 것 보단 Batch Insert를 이용하는것이 성능이 더 좋다.
- MySQL을 사용하며 JPA의 Entity를 GeneratedValue의 strategy를 IDENTITY로 설정하는경우, saveAll은 원하는대로 Batch Insert가 되지 않는다. (Hibernate가 JDBC 레벨에서 비활성화시킴)
- 따라서 JDBC Repository 중 Batch Insert를 직접 구현하여 사용하자.
- 또한 Batch Insert를 MySQL 버전 8 밑에서 사용하기 위해서는 rewriteBatchedStatements 옵션을 true로 설정해야 한다.
'💻Spring' 카테고리의 다른 글
Spring Security 사용자 권한에 따른 API 접근 설정하기 (0) | 2023.09.14 |
---|---|
Spring Security 401,403 Custom Response 적용하기 (0) | 2023.08.24 |
JPA 프록시 객체 equals의 Override (0) | 2023.08.08 |
Spring OAuth 로그인 및 회원가입 RestTemplate > WebClient 리팩토링 (0) | 2023.06.28 |
Response 규격화 Swagger 처리 (1) | 2023.06.10 |