업무 중 약 15만건의 데이터를 하루 한번 insert 해야 하는 일이 생겼다.
받아온 데이터를 그대로 DB에 저장하는게 아니라 후속 처리도 있고 연관 관계까지 신경쓰니 성능이 문제가 되었다.
성능을 줄이기 위해 여러 방법을 적용해봤는데 이를 블로깅해두면 좋을거 같아 정리해봤다.
라이브러리
spring boot data jpa
lombok
mybatis spring boot starter
postgresql
데이터로는 공공데이터포털에서 REST API 제공하는 것 중 하나를 선택했다.
예전에 실습했떤 따릉이 충전소는 open API 정보가 없어 전기차 충전소 정보로 진행했다.
https://www.data.go.kr/data/15076352/openapi.do
요청 & 응답 코드는 생략하겠다.
1. 응답 값 그대로 Entity로 save
응답 값 1만개를 그대로 entity로 받아 save 해주기로 한다.
ChargerStation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.persistence.Entity;
import javax.persistence.Id;
@JsonIgnoreProperties(ignoreUnknown = true)
@Entity
public class ChargerStation {
@Id
@JsonProperty("statId")
private String statId;
@JsonProperty("statNm")
private String statNm;
@JsonProperty("chgerId")
private String chgerId;
@JsonProperty("chgerType")
private String chgerType;
@JsonProperty("addr")
private String addr;
(..)
}
|
cs |
응답 값이 Json이기때문에 @JsonIgnoreProperties, @JsonPropery 처리도 해줬다.
ChargerStationService
1
2
3
4
5
6
7
8
9
10
11
|
public int normalInsertEntity() {
JsonNode jsonNode = requestOpenApi();
ObjectMapper om = new ObjectMapper();
ChargerStation[] dtos = om.convertValue(jsonNode.get("items").get("item"), ChargerStation[].class);
for (ChargerStation d : dtos) {
repository.save(d);
}
return dtos.length;
}
|
cs |
응답받은 Json을 엔티티인 ChargerStation으로 변환해
각각 save 했다. repository는 바로 아래 ChargerStationRepository이다.
ChargerStationRepository
1
2
3
4
5
6
7
8
|
import com.spring.batchtest.domain.ChargerStation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ChargerStationRepository extends JpaRepository<ChargerStation, String> {
}
|
cs |
🛑 결과
9999개에 8분 48초가 걸렸다. 너무 오래 걸린다.
2. 응답 값 그대로 Entity로 saveAll
모두 위와 같지만 Entity를 List로 만들어 SaveAll해주기로 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public int normalInsertEntityAll() {
JsonNode jsonNode = requestOpenApi();
ObjectMapper om = new ObjectMapper();
ChargerStation[] dtos = om.convertValue(jsonNode.get("items").get("item"), ChargerStation[].class);
List<ChargerStation> list = new ArrayList<>();
for (ChargerStation d : dtos) {
list.add(d);
}
repository.saveAll(list);
return list.size();
}
|
cs |
🛑 결과
1분 40초가 걸렸다.
확실히 saveAll이 빠르다. 하지만 10만개라고 한다면 16분, 빠르다고 볼 수 없다.
3. 응답 값 Dto로 받아 Entity로 변환 후 SaveAll (Builder 패턴)
ChargerStation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
@Getter
@RequiredArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@Entity
public class ChargerStation {
@Id
private String statId;
private String statNm; private String chgerId; (...)
@Builder
public ChargerStation(String statId, String statNm, String chgerId, String chgerType, String addr, String location, String useTime, Double lat, Double lng, String busiId, String bnm, String busiNm, String busiCall, Integer output, String zcode, String zscode, String kind, String kindDetail, String parkingFree, String note, String limitYn, String limitDetail, String delYn, String delDetail) {
this.statId = statId;
this.statNm = statNm;
this.chgerId = chgerId;
this.chgerType = chgerType;
this.addr = addr;
this.location = location;
this.useTime = useTime;
this.lat = lat;
this.lng = lng;
this.busiId = busiId;
this.bnm = bnm;
this.busiNm = busiNm;
this.busiCall = busiCall;
this.output = output;
this.zcode = zcode;
this.zscode = zscode;
this.kind = kind;
this.kindDetail = kindDetail;
this.parkingFree = parkingFree;
this.note = note;
this.limitYn = limitYn;
this.limitDetail = limitDetail;
this.delYn = delYn;
this.delDetail = delDetail;
}
}
|
cs |
엔티티를 엔티티답게 관리하기 위해 평소 잘 쓰는 빌더 패턴으로 확인해본다.
빌더를 추가해줬고 Dto를 작성해줬다.
ResponseDto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true)
public class ResponseDto {
@JsonProperty("statId")
private String statId;
@JsonProperty("statNm")
private String statNm;
@JsonProperty("chgerId")
private String chgerId;
@JsonProperty("chgerType")
private String chgerType;
@JsonProperty("addr")
private String addr;
(...)
public ChargerStation toEntity() {
return ChargerStation.builder()
.statId(statId)
.statNm(statNm)
.chgerId(chgerId)
.chgerType(chgerType)
.addr(addr)
.location(location)
.useTime(useTime)
.lat(lat)
.lng(lng)
.busiId(busiId)
.bnm(bnm)
.busiNm(busiNm)
.busiCall(busiCall)
.output(output)
.zcode(zcode)
.zscode(zscode)
.kind(kind)
.kindDetail(kindDetail)
.parkingFree(parkingFree)
.note(note)
.limitYn(limitYn)
.limitDetail(limitDetail)
.delYn(delYn)
.delDetail(delDetail)
.build();
}
}
|
cs |
@JsonIgnoreProperties, @JsonPropery 처리를 해줬고 toEntity 메소드도 작성해줬다.
받아온 데이터에 대한 후속 처리가 있다면 여기서 작성해주면 된다.
ChargerStationService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public int normalInsertDtoAll() {
JsonNode jsonNode = requestOpenApi();
ObjectMapper om = new ObjectMapper();
ResponseDto[] dtos = om.convertValue(jsonNode.get("items").get("item"), ResponseDto[].class);
List<ChargerStation> list = new ArrayList<>();
for (ResponseDto d : dtos) {
list.add(d.toEntity());
}
repository.saveAll(list);
return list.size();
}
|
cs |
응답 Json을 ResponseDto로 받아 Entity로 변환해 saveAll 해줬다.
🛑 결과
1분 27초가 걸렸다. 비슷하다. 만약 후속 작업이나 연관관계가 있었다면 더 걸렸을 수도 있겠다.
4. Dto + JPA Batch Insert
JPA batch insert는 SQL문을 그룹으로 묶어 DB로 한번에 보냄으로 성능을 최적화 하는 방식이다.
설정 자체도 굉장히 쉽다.
application.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
spring:
datasource:
url: [url]
username: [username]
password: [password]
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
jdbc:
batch_size: 1000
order_inserts: true
order_updates: true
|
cs |
- batch_size : batch 처리할때 한번에 모을 구문의 양 설정
- order_inserts : insert시 비슷한 구문끼리 모아줌
- order_updates : update시 비슷한 구문끼리 모아줌
이 설정만 해주고 위의 normalInsertDtoAll 을 해줬다.
🛑 결과
38초가 걸렸다.
확실히 빨랐다.
하지만 로그로는 모든 insert가 찍혀 의문이 들었다.
batch 처리가 잘 되는지 궁금해 구글링해본 결과 김영한님의 답변이 있었다.
먼저 하이버네이트가 제공하는 벌크 insert는 말씀하신 것 처럼 쿼리문장을 multi value 하나로 만들어서 보내는 방식이 아닙니다. 여러 쿼리를 모아서 한번에 보내는 방식입니다. (따라서 쿼리 문장이 변하지는 않습니다. 로그 레벨을 낮추면 로그에서는 벌크로 입력했다고 나타납니다.)
https://www.inflearn.com/questions/90502
결론은 잘 되는게 맞다..
🌞 그리고 가장 중요한 건 JPA batch에서 기본키 생성 전략으로 IDENTITY를 사용할 수 없다는 것이다.
IDENTITY 전략의 기본키 생성 방식은 엔티티가 생성되고 영속성 컨텍스트에서 만들어지다가 DB로 저장되어 낭비가 될 수 있기 때문이라고 한다. 하지만 IDENTITY 전략을 제일 많이 쓰기 때문에 다른 방법을 찾을 수 밖에 없었다.
5. Dto + JDBCTemplate Batch Insert
이번에는 JDBCTemplate로 진행해보겠다.
JPA는 JDBCTemplate를 내부에 가지고 있기 때문에 라이브러리를 추가해주거나 할 필요가 없다.
🎫 이전에 블로깅한 JDBC vs JPA
2022.09.26 - [Backend] - JDBC VS JPA (JPA 입문 / Spring Data JPA)
ChargerStationBatchRepository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
import com.spring.batchtest.domain.ChargerStation;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
@Repository
public class ChargerStationBatchRepository {
private final JdbcTemplate jdbcTemplate;
public ChargerStationBatchRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void batchInsert(List<ChargerStation> list) {
jdbcTemplate.batchUpdate(
"INSERT INTO _sample_test (stat_id, addr, bnm, busi_call, busi_id, busi_nm, chger_id, chger_type, del_detail, del_yn, kind, kind_detail, lat, limit_detail, limit_yn, lng, \"location\", note, \"output\", parking_free, stat_nm, use_time, zcode, zscode) " +
"VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) on conflict (stat_id) do nothing",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, list.get(i).getStatId());
ps.setString(2, list.get(i).getAddr());
ps.setString(3, list.get(i).getBnm());
(...)
}
@Override
public int getBatchSize() {
return list.size();
}
});
}
}
|
cs |
JdbcTemplate의 batchUpdate를 사용하면 된다.
반복할 sql문과 sql에 넣은 데이터를 정의해주면 된다.
ChargerStationService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public int batchInsertJdbc() {
JsonNode jsonNode = requestOpenApi(1);
ObjectMapper om = new ObjectMapper();
ResponseDto[] dtos = om.convertValue(jsonNode.get("items").get("item"), ResponseDto[].class);
List<ChargerStation> list = new ArrayList<>();
for (ResponseDto d : dtos) {
list.add(d.toEntity());
}
batchRepository.batchInsert(list);
return list.size();
}
|
cs |
🛑 결과
13초가 걸렸다.
6. Dto + Mybatis Batch Insert
mybatis를 사용하기 위해서는 라이브러리를 추가해줘야하고 또 다른 설정을 해줘야 한다.
🎫 Mybatis 설정
2021.04.05 - [Backend] - Mybatis ( Mybatis 실습 / Java DB 연결 / 웹개발 / 웹독학 / 백엔드 개발자 / 프로그래밍)
Mapper.java
1
2
3
4
5
6
7
8
9
|
import com.spring.batchtest.domain.ChargerStation;
import java.util.List;
@org.apache.ibatis.annotations.Mapper
public interface Mapper {
void insertAll(List<ChargerStation> list);
}
|
cs |
mapper.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.spring.batchtest.mybatis.Mapper">
<insert id="insertAll">
INSERT INTO charger_station (stat_id, addr, bnm, busi_call, busi_id, busi_nm, chger_id, chger_type, del_detail, del_yn, kind, kind_detail, lat, limit_detail, limit_yn, lng, location, note, output, parking_free, stat_nm, use_time, zcode, zscode) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.statId}, #{item.addr}, #{item.bnm}, #{item.busiCall}, #{item.busiId}, #{item.busiNm}, #{item.chgerId}, #{item.chgerType}, #{item.delDetail}, #{item.delYn}, #{item.kind}, #{item.kindDetail}, #{item.lat}, #{item.limitDetail}, #{item.limitYn}, #{item.lng}, #{item.location}, #{item.note}, #{item.output}, #{item.parkingFree}, #{item.statNm}, #{item.useTime}, #{item.zcode}, #{item.zscode})
</foreach>
ON conflict (stat_id) do nothing
</insert>
</mapper>
|
cs |
mybatis 에서 제공하는 foreach문으로 List<ChargerStation> 만큼 담아주면 된다.
ChargerStationService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
public int batchInsertMybatis() {
JsonNode jsonNode = requestOpenApi(1);
ObjectMapper om = new ObjectMapper();
ResponseDto[] dtos = om.convertValue(jsonNode.get("items").get("item"), ResponseDto[].class);
List<ChargerStation> list = new ArrayList<>();
for (ResponseDto d : dtos) {
list.add(d.toEntity());
}
batchSize(list);
return list.size();
}
private void batchSize(List<ChargerStation> list) {
int skip = 0;
int limit = 1000;
while (skip < list.size()) {
final List<ChargerStation> perRequests = list
.stream()
.skip(skip)
.limit(limit)
.collect(toList());
skip += limit;
mapper.insertAll(perRequests);
}
}
|
cs |
여기서 유의해야할 부분이 batchSize를 직접 지정한 부분이다.
postgresql에서는 요청할 수 있는 sql 구문에 한계가 있다.
그래서 1만건을 한번에 insert하지 않고 1000개씩 잘라서 넣어주기로 했다.
🛑 결과
12초가 걸렸다.
JDBCTemplate과 Mybatis 간의 차이가 거의 나지 않아 10만개의 데이터로 진행해봤다.
🛑 결과
JDBCTemplate
48초
Mybatis
1분 40초
JDBCTemplate이 더 빠르다.
나도 JDBCTemplate을 업무에 적용했고 1시간 걸리는 요청을 1분 30초대로 줄였다.
JDBCTemplate이 JPA 안에 있어 따로 추가할 필요도 없으니 JDBCTemplate을 쓰는 편이 좋을 거 같다.
'Backend' 카테고리의 다른 글
JDBC VS JPA (JPA 입문 / Spring Data JPA) (0) | 2022.09.26 |
---|---|
[Rest API] 415 Unsupported Media Type 요류 해결 (0) | 2021.08.06 |
[알라딘 API] 도서 데이터 검색하기_실습 실패..(이클립스 / 서블릿 / 오픈 API / 알라딘 / 도서데이터 / 백엔드 / 웹개발) (3) | 2021.05.11 |
[FullCalendar] 캘린더 API 사용하기 (이클립스 / servlet / 오라클 / 백엔드) (10) | 2021.05.10 |
MVC 웹 프로젝트 만드는 방법 두가지 (STS / MVC 패턴 / 스프링 입문 / 백엔드 / 웹개발) (0) | 2021.04.18 |
댓글