728x90
💡
현재 회사에서 개발 중인 앱에는 회원 초대 기능이 있다.
회원 초대는 앱과 웹 두 가지 방식으로 이루어진다.
앱에서는 회원의 이름과 전화번호를 일일이 입력하여 초대가 가능하고, 웹에서는 직접 입력 기능에 더하여 별도의 회원 데이터가 있다면 지정된 양식의 엑셀 데이터로 업로드해 초대가 가능하다.
백엔드 서버 회원 초대 api는 내가 만들었는데 초기 개발 당시에는 대용량의 회원 데이터가 업로드될 때의 상황을 고려하지 않았다.
따라서 수백 건의 데이터는 문제없이 업로드가 가능했지만, 천, 만 단위가 넘어가는 상황에서는 매우 느려질 것이 분명했다.
미래에 서비스가 커져 대용량 데이터가 업로드되는 상황이 온다면 분명 문제가 발생할 것이다.
이를 미리 대비하기 위해 공부하고 테스트한 과정을 기록하려고 한다.
📌 Spring Data JPA를 이용해 데이터를 저장하는 방법
- Spring Data JPA에서 제공하는 메서드 중 데이터를 데이터베이스에 저장하는 대표적인 메서드는 save()와 saveAll()이 있다.
- 이 두 메서드는 단일 건에 대한 인서트와 다수의 건에 대한 인서트라는 차이 외에도 꼭 알아야 할 몇 가지 내용이 있다.
📌 SimpleJpaRepository
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- JpaRepository를 상속받게 되면 Spring Data JPA에 의해 SimpleJpaRepository의 구현체가 자동으로 만들어져 기본으로 제공하는 메서드를 사용할 수 있게 된다.
- IntelliJ 기준을 build.gradle에 위 의존성을 추가한 상태에서 JpaRepository를 상속받는 레파지토리를 만들고 Shift를 두 번 눌러 SimpleJpaRepository를 검색하여 들어가면 SimpleJpaRepository 클래스를 확인할 수 있다.
- 이 클래스에서 아래를 내리다 보면 save()와 saveAll() 메서드를 확인할 수 있다.
📜 save() & saveAll()
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}
}
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null");
List<S> result = new ArrayList<>();
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
- saveAll() 메서드를 보면 for 문을 돌면서 save() 메서드를 호출하는 것을 확인할 수 있다.
- 다른 방식으로 한 번에 일괄적으로 insert 한다고 생각했는데 아니었다.(일괄적으로 한 번에 저장하는 것을 bulk insert라고 한다.)
- 그럼 같은 크기의 데이터를 인서트한다고 했을 때 두 메서드의 속도 차이도 없는 걸까?
- saveAll() 메서드에 @Transactional 애너테이션이 있는 걸 보니 하나의 트랜잭션으로 움직일 것이다.
- 따라서, 데이터의 수만큼 트랜잭션이 생성되는 save() 메서드에 비해 하나의 트랜잭션으로 실행되는 saveAll() 메서드가 더 빠를 것이다.
📜 @Transactional
- @Transactional의 default 전파 유형은 REQUIRED이다.
- REQUIRED의 경우 위의 주석 내용과 같이 현재 트랜잭션이 존재하면 해당 트랜잭션에 포함되고, 존재하지 않으면 새로 만든다.
- saveAll()의 경우 메서드에 save()를 계속 호출하는 방식이니 맨 처음 호출된 save() 메서드에서 트랜잭션을 만들고 그 이후에 반복문을 통해 호출되는 메서드는 전부 최초의 트랜잭션에 합류되는 것이다.
- 따라서 수십 건, 수백 건 수준의 데이터라면 매번 save()로 단건 인서트를 날릴 때마다 트랜잭션을 생성하는 것으로는 오버헤드가 크지 않아 유의미한 속도 차이를 보이지 않겠지만 수천, 수만 건 이상의 데이터의 경우에는 오버헤드가 크게 발생해 두 메서드의 속도 차이가 꽤 많이 날 것으로 예상된다. 테스트해 보자.
📜 Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long memberSeq;
@Column(nullable = false, length = 10)
private String name;
@Column(nullable = false)
private String mobile;
@Column
private String memberGrade;
@Column(columnDefinition = "TEXT")
private String etc;
...
}
- 네 개의 필드를 가진 Member 엔티티를 만들었다.
📜 Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}
- JpaRepository를 상속받는 MemberRepository도 만들었다.
🤖 더미 데이터 양식
{"excelData": [["name", "mobile", "memberGrade", "etc" ],
["이름1", "010-0000-0001", "회원", "기타 데이터1"],
["이름2", "010-0000-0002", "회원", "기타 데이터2"],
["이름3", "010-0000-0003", "회원", "기타 데이터3"]]
}
- postman을 통해 테스트할 데이터 양식이다.
📜 Controller
@Slf4j
@RestController
@RequiredArgsConstructor
public class MemberController {
private final StopWatch stopWatch = new StopWatch();
private final MemberRepository memberRepository;
@PostMapping("/save/json")
public ResponseEntity<?> saveJsonData(@RequestBody Dto.Request request) {
List<String> header = request.getExcelData().remove(0);
stopWatch.start();
for (List<String> excelData : request.getExcelData()) {
Member member = Member.crateMemberOf(excelData.get(0), excelData.get(1), excelData.get(2), excelData.get(3));
// 반복문 마다 save() 메서드 호출
memberRepository.save(member);
}
stopWatch.stop();
log.info("save json success, 성능 측정 걸린시간: {}/sec" , stopWatch.getTotalTimeSeconds());
return new ResponseEntity<>("success", HttpStatus.CREATED);
}
@PostMapping("/save/list")
public ResponseEntity<?> saveJsonList(@RequestBody Dto.Request request) {
List<String> header = request.getExcelData().remove(0);
List<Member> memberList = new ArrayList<>();
stopWatch.start();
for (List<String> excelData : request.getExcelData()) {
Member member = Member.crateMemberOf(excelData.get(0), excelData.get(1), excelData.get(2), excelData.get(3));
memberList.add(member);
}
// 반복문이 끝나면 한 번에 saveAll() 메서드 호출
memberRepository.saveAll(memberList);
stopWatch.stop();
log.info("save json list success, 성능 측정 걸린시간: {}/sec" , stopWatch.getTotalTimeSeconds());
return new ResponseEntity<>("success", HttpStatus.CREATED);
}
}
- 편한 테스트를 위해 컨트롤러 안에 비즈니스 로직을 넣었다.
- header를 제거하고, 변수에 담아둔다.
- header를 통해 구분하여 각기 다른 변수에 저장하는 로직이 추가될 수 있어 만들어 두었다.
- 현재 로직에서는 아무 의미 없다.
- saveJsonData() 메서드의 경우 for 문을 돌며 매번 반복될 때마다 save() 메서드를 호출하는 방식이고, saveJsonList의 경우 memberList라는 리스트에 담아둔 후 for 문이 끝나면 한 번에 saveAll 하여 저장하는 방식이다.
- 이제 테스트해 보자.
📜 application.yaml
spring:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
show_sql: true
datasource:
url: jdbc:mysql://localhost:33060/test
username: root
- show_sql을 true로 설정했기 때문에 실제로 나가는 쿼리를 확인할 수 있다.
📜 Postman
- postman에 21629 개의 회원 더미 데이터를 json으로 변경하여 넣어두었다.
💡 save() 테스트 결과
...
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
2024-01-22T23:21:45.062+09:00 INFO 8625 --- [nio-8080-exec-1] c.e.b.controller.MemberController
: save json success, 성능 측정 걸린시간: 145.738353708/sec
- 반복문을 돌면서 매번 save()로 단건 인서트를 할 경우 무려 145초가 걸렸다.
- 그런데, 아마 쿼리문을 출력해서 더 많이 나온 것일 테고 쿼리문을 출력하지 않도록 변경하면 시간이 꽤 많이 줄어들 것이다.
- 이제는 같은 데이터를 saveAll()로 인서트 하는 테스트를 해보자.
💡 saveAll() 테스트 결과
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
Hibernate: insert into member (etc,member_grade,mobile,name) values (?,?,?,?)
2024-01-22T23:25:01.085+09:00 INFO 8677 --- [nio-8080-exec-1] c.e.b.controller.MemberController
: save json list success, 성능 측정 걸린시간: 12.769096833/sec
- 결과는 12초로 단건 인서트보다 훨씬 빠르게 저장되었다.
- 그런데, 왜 saveAll() 메서드에서 bulk insert를 지원하지 않는걸까?
- saveAll() 메서드 역시 많이 느린 편이기 때문에 bulk insert를 사용한다면 수만 개의 쿼리를 하나로 줄여 훨씬 더 빠르게 저장할게 분명한데 말이다.
- 왜 saveAll() 메서드에서 bulk insert를 지원하지 않는지 그 이유를 찾아보고,
- 쿼리문을 줄여 한 번에 저장하는 bulk insert를 도입할 필요가 있어 보인다.
- 다음 글에서 알아보자.
참고
뤼튼
https://www.baeldung.com/spring-data-save-saveall
728x90
'개발 일기' 카테고리의 다른 글
비전공자, 부트캠프 출신 백엔드 개발자의 솔직한 취업 1년 후기 (0) | 2024.04.20 |
---|---|
수만명의 회원 데이터를 데이터베이스에 효과적으로 인서트하기 (2) - bulk & batch insert (1) | 2024.01.25 |
HTTP 메서드 POST VS GET 사용에 대한 고찰 (6) | 2023.10.11 |
표준화되지 않은 엑셀 데이터를 데이터베이스에 저장하는 방식에 대한 고찰 (2) | 2023.08.03 |
JPA를 사용하여 여러 테이블에서 데이터를 가져오는 방법에 대한 고찰(2) - JPA DTO PROJECTION (0) | 2023.07.26 |