이번 자동차 경주 웹 미션에서 테이블 구조와 관계를 다음과 같이 정의했습니다.
CREATE TABLE game (
game_id INT NOT NULL AUTO_INCREMENT,
play_count INT NOT NULL,
created_at DATETIME NOT NULL default current_timestamp,
PRIMARY KEY (game_id)
);
CREATE TABLE player (
player_id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
position INT NOT NULL,
is_winner BOOLEAN NOT NULL,
game_id INT NOT NULL,
PRIMARY KEY (player_id),
CONSTRAINT fk_game_id FOREIGN KEY (game_id) REFERENCES game(game_id)
);
위의 DDL를 보시면 알 수 있듯이 game과 player는 1:N의 관계를 가지고 한 game이 저장될 때 여러 player가 저장되어야 했습니다.
처음에는 반복문을 활용해서 player를 저장하는 방식을 택했습니다.
public class PlayerDao {
public void insertPlayer(Player player) {
String sql = "INSERT INTO player(name, position, game_id) VALUES(?, ?, ?, ?)";
jdbcTemplate.update(sql, player.getName(), player.getPosition(), player.isWinner(), player.getGameId());
}
}
@Repository
public class RacingCarRepository {
private final RacingCarGameDao racingCarGameDao;
private final PlayerDao playerDao;
public void save(RacingGameDto racingGameDto, List<PlayerDto> playerDtos) {
Long gameId = racingCarGameDao.insertGameWithKeyHolder(new Game(racingGameDto));
playerDtos.forEach(
playerDto -> playerDao.insertPlayer(
new Player(playerDto.getName(), playerDto.getPosition(), gameId)
)
);
}
}
직관적으로는 괜찮은 방식이라고 생각할 수 있지만 위의 방식의 치명적인 문제점을 가지고 있습니다. 그 문제점은 player 하나하나를 저장할 때마다 쿼리(INSERT INTO player(name, position, game_id) VALUES(?, ?, ?))를 계속 호출해야한다는 것이었습니다.
sql의 query를 실행하면 다음과 같은 과정을 거칩니다.
위의 과정 중에서 네트워크로 쿼리를 보내는 것과 SQL문을 파싱하고 실행하는 것은 오버헤드가 큽니다. 그렇기에 동일한 작업에 대한 반복 sql문을 호출하는 것은 성능에 영향을 미칠 수 있습니다.
이와 같은 문제를 해결하기 위해 JdbcTemplate에서 제공하는 Batch Operation을 활용했습니다.
설명을 간단하게 요약하면 JDBC의 batch 연산은 같은 쿼리문에서 values를 일괄적으로 처리하는 방식을 의미합니다.
values를 일괄적으로 처리한다는 것은 다음과 같습니다.
// 데이터를 하나씩 넣는 과정
INSERT INTO player values (1, '아코', 10, true, 1);
INSERT INTO player values (2, '마코', 9, false, 1);
INSERT INTO player values (3, '히이로', 9, false, 1);
하나의 하나하나 데이터를 처리하던 것을 아래와 같이 values를 한번에 처리하는 것입니다.
// 데이터를 일괄적으로 처리하는 과정
INSERT INTO player values
(1, '아코', 10, true, 1),
(2, '마코', 9, false, 1),
(3, '히이로', 9, false, 1);
이번 미션에서는 여러 batch 연산들 중에서 batchUpdate를 이용해 player를 저장할 때 반복적으로 쿼리를 요청하는 것이 아닌 하나의 요청으로 여러 데이터를 저장했습니다.
public class PlayerDao {
public void insertPlayer(List<Player> players) {
String sql = "INSERT INTO player(name, position, is_winner, game_id) VALUES(?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(final PreparedStatement ps, final int i) throws SQLException {
ps.setString(1, players.get(i).getName());
ps.setInt(2, players.get(i).getPosition());
ps.setBoolean(3, players.get(i).isWinner());
ps.setLong(4, players.get(i).getGameId());
}
@Override
public int getBatchSize() {
return players.size();
}
});
}
}
batchUpdate의 setValues
는 (1, ‘아코’, 10, true, 1)과 같이 저장할 레코드를 지정하는 메소드이고 getBatchSize
는 현재 일괄 처리해야하는 데이터의 크기를 나타냅니다.
일괄적으로 데이터를 처리하는 것이 성능상에서도 큰 효과를 나타내는 것을 테스트를 통해 알 수 있었습니다.
@JdbcTest
class PlayerDBDaoTest {
@Autowired
JdbcTemplate jdbcTemplate;
List<Player> players;
@BeforeEach
void setting() {
...
for(int i = 1; i <= 1000; i++) {
players.add(new Player("아코", 10, true, 1L));
}
@Test
void No_batchUpdate_test() {
PlayerDBDao playerDBDao = new PlayerDBDao(jdbcTemplate);
for (Player player : players) {
playerDBDao.insertPlayerWithNoBatch(player);
}
}
@Test
void batchUpdate_test() {
PlayerDBDao playerDBDao = new PlayerDBDao(jdbcTemplate);
playerDBDao.insertPlayerWithBatch(players);
}
}
다음과 같이 BatchUpdate를 이용하는 것과 사용하지 않는 것에 대해 성능 평가를 하면 다음과 같은 결과가 나옵니다.(1000개의 데이터)
반복되는 쿼리가 발생이 되면 일괄적으로 처리할 수 있는 batch Operation을 이용해서 성능을 향상시키자