들어가며
현재 요즘카페 서비스는 조회하지 않은 카페 데이터를 저장하고 있다. 이를 통해서 사용자에게 중복되지 않은 랜덤한 이미지를 전달해준다.
사용자가 조회를 진행하면서, 조회하지 않은 카페 데이터가 특정 수 이하가 되면 새롭게 카페를 삽입해준다. 사용자가 많아짐에 따라 이런 과정은 빈번하게 발생하여 서비스의 성능저하를 일으켰다.
기존 코드
기존에는 데이터 삽입 시 JPA의 변경 감지를 이용하여 요청을 단 건으로 처리하였다. 그래서 데이터가 늘어남에 따라 단일 삽입쿼리도 늘어나고 실행되는 시간도 길어졌다.
그래서 이를 개선하고자 데이터 삽입 할 때 Batch Insert를 하기로 하였다.
현재와 같은 구조인데 100여개의 카페 삽입 로직 때문에 Query Count가 높게 발생하는 것을 볼 수 있다.
해결 방법
1. JPA Batch Insert
JPA 의 Batch Insert를 사용하여 Batch Insert를 사용하려고 하였다.
spring:
jpa:
properties:
hibernate:
batch_size: 100
하지만 정상적으로 작동하지 않았다.
Hibernate 공식 문서에서는
만약 id가 identity방식으로 관리된다면 hibernate에서는 batch insert를 비활성화 한다 라고 되어있었다.
원래의 의도는 BatchSize만큼의 쿼리를 persist하고 있다가 flush 하는 방법으로 batch insert를 처리하려고 하였다. batch insert는 쓰기 지연을 사용해서 동작하는데, 이는 db에 저장된 뒤 id가 할당되는 방식인 identity를 사용할 수 없다는 의미이다.
‘요즘카페’서비스는 ID가 IDENTITY 방식으로 관리되고 있기 때문에 이를 사용할 수 없었던 것이다.
그럼 ID 생성 방식을 바꾸는 건?
id 생성 방식을 Sequence, Table 방식으로 생성한다면 batch insert를 사용할 수 있다.
다만 SEQUENCE, TABLE 방식을 통해 진행해도 무조건 빠르다고 할 수 없다. identity 방식에 비해 id를 관리하는 성능이 안좋기 때문에 insert 자체의 효율이 늘어날지 언정 최종 성능이 낮을 수 있다.
N IDENTITY AUTO(TABLE)
10 | 0.022 | 0.044 |
100 | 0.1 | 0.25 |
1000 | 1 | 1.4 |
10000 | 42 | 66 |
N개의 데이터를 삽입할 때 IDENTITY 방식과 AUTO 방식의 소요 시간이다. insert 자체의 효율이 안좋은 것을 확인할 수 있다.
보듯이 id 관리 방식을 바꿔 batch insert가 가능해지더라도 성능이 나을 거라는 보장이 없다.
ID 채번을 Batch로
SET autocommit=0
select next_val as id_val from hibernate_sequence for update
update hibernate_sequence set next_val= 2 where next_val=1
commit
autocommit=1
다음과 같이 한개의 id를 채번해서 가져오는 것과 다르게
id 채번 자체를 batch로 해서 개선할 수 있다.
SET autocommit=0
select next_val as id_val from hibernate_sequence for update
update hibernate_sequence set next_val= 101 where next_val=1
commit
SET autocommit=1
1개씩 채번하는 것이 아닌 N개씩 채번을 함으로써 개선할 수 있을 것이다.
하지만 이 같은 경우에 채번 크기와 Batch Insert 배치 크기를 모두 관리해줘야하는 단점이 있다. 개발자 실수로 두 값이 달라질 수 있다는 문제점이 발생할 수 있다.
JPA 결론
실제로 확인해본 결과 채번 부하가 상당히 커서 BatchInsert가 된다는 이유만으로 ID 관리 방식을 다르게 가져갈 수 없었다. 성능 개선이 보장되지 않았기 때문이다. 이와 함께 JPA를 이용해서 BatchInsert를 해야하는가? 에 대한 고민을 진행했다.
변경감지와 영속성 관리하는 JPA의 기능이 Batch Insert 시 오히려 성능 저하를 일으킬 수 있을 것 같았다. 네트워크 비용이나 변경상태를 계속해서 추적해야되기 때문에라고 생각한다. 그래서 Batch Insert를 하는 방법으로 JPA를 포기하게 되었다.
2. Spring Batch
Spring Batch 로깅/추적, 트랜잭션 관리, 작업 처리 통계, 작업 재시작, 건너뛰기, 리소스 관리 등 대용량 레코드 처리에 필수적인 기능 등등을 제공한다.
이는 대용량의 비즈니스 데이터를 복잡한 작업으로 처리하거나 특정 시점에 스케줄러를 통해 자동화된 작업이 필요한 경우 사용할 수 있다.
Spring Batch 결론
사용해보진 않았지만, 대용량 처리 편의를 위한 배치 프레임워크로 알고 있었다. 우리 프로젝트는 BatchInsert가 수반되어야 하지만 그 크기가 크지 않고 복잡하지 않으며 배치 작업의 스케줄링이 필요하지 않다고 판단되어 적용하지 않았다.
3. JDBC
IDENTITY 방식으로 ID를 생성하면서 Batch Insert를 하려는 방법으로 JDBC를 사용하기로 하였다. 추가적인 의존성이 필요하지 않고, 우테코를 진행하면서 레벨1,2 동안 많이 사용해봤기 때문에 따로 학습의 필요성도 없다. 또한 위의 방법들 대비 성능 향상이 유의미하다고 판단되어 확인하고 사용하게 되었다.
JDBC BatchUpdate
jdbc의 BatchUpdate를 사용하여 DB와의 통신 횟수를 줄임으로써 성능을 개선할 수 있다.
https://www.baeldung.com/spring-jdbc-batch-inserts
Some databases such as Postgres, MySQL, and SQL Server support multi-value inserts.
다음과 같은 방식으로 처리가 된다.
-- REGULAR INSERTS TO INSERT 4 RECORDS
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test1', LOCALTIMESTAMP, 100.10);
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test2', LOCALTIMESTAMP, 101.10);
-- EQUIVALENT MULTI-VALUE INSERT
INSERT INTO PRODUCT
(TITLE, CREATED_TS, PRICE)
VALUES
('test1', LOCALTIMESTAMP, 100.10),
('test2', LOCALTIMESTAMP, 101.10);
mySql, jdbc 드라이버를 사용할 경우 다음과 같은 방법으로 batch insert를 진행할 수 있다.
url: jdbc:mysql://localhost:20000/yozm-cafe?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=UTC&rewriteBatchedStatements=true
이런 옵션을 설정해줌으로써 multi-row로 sql문이 재작성되어 출력된다.
여기서는 ID 채번이 어떻게 될까 ?
Bulk Insert가 되는 것을 확인했다. 그럼 지금과 같은 방식에서는 id 채번을 어떻게 할까? 이를 채번하는 것을 최적화 시켜줄까 ?
“Bulk inserts”
Statements for which the number of rows to be inserted (and the number of required auto-increment values) is not known in advance. This includes INSERT ... SELECT, REPLACE ... SELECT, and LOAD DATA statements, but not plain INSERT. InnoDB assigns new values for the AUTO_INCREMENT column one at a time as each row is processed.
https://dev.mysql.com/doc/refman/5.7/en/innodb-auto-increment-handling.html
공식문서에 따르면 mysql도 각각의 row처리마다 id를 생성해서 처리한다고 한다.
그래도 쿼리 개수, db 통신 횟수를 줄였으니 성능 향상이 되었을 것이다.
innodb_autoinc_lock_mode
이렇게 auto_increment id가 생성될 때 Lock을 거는 방식을 설정함으로써 최적화를 할 수 있다.
0(tradiational)모드에서는 테이블 락을 걸어 insert시 다른 insert작업은 기다리기.
1(consecutive)모드에서는 id를 생성하는 작업에만 mutex를 통해 lock을 걸기에 여러 insert작업을 더 병렬적으로 가능하다. 다만 bulk insert시에는 여전히 테이블락이 걸림.
2(interleaved)모드에서는 bulk insert까지도 AUTO_INCREMENT id값들을 생성 시에만 mutex를 통한 락이 걸린다.
bulk insert 작업시에 innodb_autoinc_lock_mode도 설정해줘야한다. 우리 서비스에서는 bulk insert가 동시적으로 일어나야 할 정도로 자주 일어나지 않고, replication을 고려하여 ID 생성하는 작업에만 mutex를 통해 처리 가능하도록 consecutive모드로 설정하였다.
이 때문에 bulk insert시에는 innodb_autoinc_lock_mode설정도 신경써야 하고, 우리 서비스에서는 bulk insert가 동시적으로 일어나야 할 정도로 자주 일어나진 않고, replication까지 고려하여 1로 설정했다.
테스트 해보기
아래처럼 jdbcTmeplate을 통해 batchupdate하는 repository를 만들었다. 실험해보자
30000개의 카페 데이터를 실험으로 넣었다.
@Repository
public class CafeJdbcRepository {
private final JdbcTemplate jdbcTemplate;
public CafeJdbcRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void saveUnviewedCafes(List<Cafe> cafes, Member member) {
int batchSize = 60;
int batchCount = 0;
List<Cafe> subItems = new ArrayList<>();
for (int i = 0; i < cafes.size(); i++) {
subItems.add(cafes.get(i));
if ((i + 1) % batchSize == 0) {
batchCount = batchInsert(batchCount, subItems, member.getId());
}
}
if (!subItems.isEmpty()) {
batchCount = batchInsert(batchCount, subItems, member.getId());
}
System.out.println("batchCount: " + batchCount);
}
private int batchInsert(int batchCount, List<Cafe> subItems, String memberId) {
String sql = "INSERT INTO un_viewed_cafe (`cafe_id`, `member_id`) VALUES (?, ?)";
jdbcTemplate.batchUpdate(sql,
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setLong(1, subItems.get(i).getId());
ps.setString(2, memberId);
}
@Override
public int getBatchSize() {
return subItems.size();
}
}
);
subItems.clear();
batchCount++;
return batchCount;
}
}
기존처럼 변경감지로 할 때
16초 작업이 걸리는 것을 확인할 수 있다.
jdbc로 batch 작업할때
로그에서 나오는 queryCount개수는 hibernate의 inspector를 의존한 것이기 때문에 jdbc를 통해 작업한 쿼리의 개수는 포함되지 않는다. 로그의 queryCount개수는 무시해도 좋다.
jdbcTemplate.batchupdate를 통한 성능향상도 있지만 더 큰 개선을 기대할 수 있는 부분은 rewriteBatchedStatements옵션을 통해서이다. 하나씩 날아가던 insert문을 아래처럼 insert into values..문으로 한번에 여러개를 실행할 수 있다.
rewrite옵션 안 건 경우
16초에서 11초로 성능 개선이 있었다.
rewriteBatchedStatement옵션까지 건 경우
성능 개선 결과
과거에는 0.13 초의 Latency가 발생했다.
현재는 같은 작업을 진행하는데 있어서 0.061초의 Latency가 발생한다.
50% 의 성능 개선 효과를 경험할 수 있었다.
나가며
Batch Insert를 하기 위해 다양한 방법들에 대해 고민해보는 좋은 경험이었다. 현재 우리프로젝트에 어떤 것이 더 적절한지에 대해 고민할 수 있었고 무작정 좋다는 기술을 사용하진 않았다. 오히려 더 안좋다고 생각했던 기술을 사용하기 까지 했다. 하지만 성능면에서는 훨씬 월등했고 더 좋았다.
각각의 기술의 문제를 익힐 수 있었고 성능 확인을 통해 제일 나은 선택을 할 수 있었습니다. 확실한 이유와 함께 기술을 선택하는 것을 좋아하기 때문에 프로젝트에 적절한 기술을 채택하는데 고민을 많이 하였다.
'코코코딩공부' 카테고리의 다른 글
이미지 리사이징 (0) | 2023.11.07 |
---|---|
공간데이터와 공간인덱스로 지도 개선하기 (0) | 2023.11.03 |
필터를 사용해 API 성능 로그 만들기 (2) | 2023.10.31 |
테스트를 더 빠르게 진행시켜보자 (0) | 2023.10.21 |
TestContainer 사용기 & 테스트 격리 (1) | 2023.10.16 |