리팩터링 미션을 수행하던 중 하나의 리팩터링 지점을 여러 방식으로 리팩터링 할 수 있다는 생각을 하게 되었고, 이 방식들의 차이가 프로그램 실행 속도에 얼마나 영향을 미치는지 궁금증이 들었다.
궁금증이 발생한 부분은 다음과 같다.
1. 객체 상태를 변경해야 할 때 단순 setter를 사용하는 것과 새로운 객체를 만드는 방식에서 속도 차이가 얼마나 발생하는 가.
2. 디비 조회를 요구하는 검증 로직과 디비 조회가 발생하지 않는 검증 로직을 모두 수행할 때, 검증 로직 순서를 바꾸는 것만으로 로직 실행 시간을 줄일 수 있나.
비교에 사용될 코드
이번 실험(?)에서 사용될 코드는 위에서 언급한 리팩터링 미션에 사용된 코드이다. 상품을 주문할 수 있는 테이블이 있을 때, 해당 테이블에 게스트 존재 여부를 바꿔주는 로직이다. 해당 코드는 다음과 같다.
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
|
@Service
public class TableService {
@Transactional
public OrderTableDto changeEmpty(final Long orderTableId, final Boolean emptyStatus) {
// orderTable id로 orderTable 검색
final OrderTable savedOrderTable = orderTableDao.findById(orderTableId)
.orElseThrow(IllegalArgumentException::new);
// orderTable의 empty 여부 변경 가능 검증
canChangeEmptyStatus(savedOrderTable);
// empty 상태만 변경된 새로운 orderTable 생성
final OrderTable orderTable = new OrderTable(savedOrderTable.getId(), savedOrderTable.getTableGroupId(),
savedOrderTable.getNumberOfGuests(), emptyStatus);
// orderTable의 empty 상태 변경
return OrderTableDto.from(orderTableDao.save(orderTable));
}
private void canChangeEmptyStatus(final OrderTable orderTable) {
// orderTable id로 order 조회
final Optional<Order> order = orderDao.findByTableId(orderTable.getId());
// orderTable이 테이블 그룹에 속해있거나, 주문이 완료 상태가 아니면 테이블 상태 변경 불가
if (orderTable.isPartOfTableGroup() || (order.isPresent() && !order.get().isInCompletionStatus())) {
throw new IllegalArgumentException();
}
}
}
|
cs |
코드 1
위 서비스 로직을 사용하는 api 로직을 대상으로 시간을 측정하였다. 모든 테스트는 api 1천 건 호출 시간을 측정하였다.
시간 측정에 사용된 코드는 다음과 같다. api 호출을 위해 인수 테스트를 살짝 변경하였다.
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
|
@Test
void changeEmpty() {
final OrderTableResponse orderTable = OrderTableHttpCommunication.create(RequestBody.NON_EMPTY_TABLE)
.getResponseBodyAsObject(OrderTableResponse.class);
final ProductResponse product = ProductHttpCommunication.create(RequestBody.PRODUCT)
.getResponseBodyAsObject(ProductResponse.class);
final MenuGroupResponse menuGroup = MenuGroupHttpCommunication.create(RequestBody.MENU_GROUP)
.getResponseBodyAsObject(MenuGroupResponse.class);
final MenuResponse menu = MenuHttpCommunication.create(
RequestBody.getMenuProductFixture(product.getId(), menuGroup.getId()))
.getResponseBodyAsObject(MenuResponse.class);
final OrderResponse order = OrderHttpCommunication.create(
RequestBody.getOrder(menu.getId(), orderTable.getId()))
.getResponseBodyAsObject(OrderResponse.class);
OrderHttpCommunication.changeOrderStatus(order.getId(), Map.of("orderStatus", "COMPLETION"));
for (int i = 0; i < 10000; i++) {
OrderTableHttpCommunication.changeEmpty(orderTable.getId(),
RequestBody.NON_EMPTY_TABLE)
.getResponseBodyAsObject(OrderTableResponse.class);
}
}
|
cs |
코드 2
상태 변경 방식에 따른 속도 차이
우선 상태 변경을 하기 위한 방식에 따른 속도 차이를 비교해보자. 비교에 사용될 케이스는 다음과 같다.
1. 상태 변경이 발생했을 때 새로운 인스턴스를 생성하는 경우.
2. 상태를 변경하기 위해 setter를 사용하는 경우.
추측
setter를 사용하는 방식이 속도 측면에서는 이점이 있을 것이다. 상태 변경이 발생했을 때 새로운 인스턴스를 생성한다면 메모리 할당, 해제에 시간이 소요되기 때문이다.
1. 상태 변경이 발생했을 때 새로운 인스턴스를 생성하는 경우
테스트에 사용될 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
|
@Transactional
public OrderTableDto changeEmpty(final Long orderTableId, final Boolean emptyStatus) {
final OrderTable savedOrderTable = orderTableDao.findById(orderTableId)
.orElseThrow(IllegalArgumentException::new);
canChangeEmptyStatus(savedOrderTable);
// 인자로 받은 emptyStatus를 상태로 갖는 새로운 인스턴스
final OrderTable orderTable = new OrderTable(savedOrderTable.getId(), savedOrderTable.getTableGroupId(),
savedOrderTable.getNumberOfGuests(), emptyStatus);
return OrderTableDto.from(orderTableDao.save(orderTable));
}
|
cs |
코드 3
이 코드를 실행하면 약 57 sec 718ms 가 나온 것을 볼 수 있다.
2. 상태를 변경하기 위해 setter를 사용하는 경우.
테스트에 사용될 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
|
@Transactional
public OrderTableDto changeEmpty(final Long orderTableId, final Boolean emptyStatus) {
final OrderTable savedOrderTable = orderTableDao.findById(orderTableId)
.orElseThrow(IllegalArgumentException::new);
canChangeEmptyStatus(savedOrderTable);
// setter 로 empty 상태를 변경한다.
savedOrderTable.setEmpty(emptyStatus);
return OrderTableDto.from(orderTableDao.save(savedOrderTable));
}
|
cs |
코드 4
이 코드를 실행하면 50 sec 72ms가 나온 것을 볼 수 있다.
결론
예상했던대로 setter를 사용하는 것이 더 적은 시간을 소요했다. 그럼에도 setter를 사용하면 의도치 않은 상태 변경이 발생할 수 있다는 점이 걸린다. 만약 상태 변경이 자주 발생하는 케이스의 경우에만 setter에 적절한 검증 로직을 추가해 setter를 사용하는 것이 적절하다고 판단된다.
검증 로직 순서에 따른 속도 차이
위 검증에서 사용한 코드는 리팩터링 미션에서 사용한 코드이며 H2 디비를 사용하고 있다. 하지만 실제 서비스는 통상적으로 in-memory DB에 모든 데이터를 저장하지 않는다. 또 한, 본 속도 차이 비교에서는 디비 조회 로직이 사용되므로 리팩터링 미션 코드가 아닌, mysql을 사용하는 다른 코드를 사용하겠다.
이번 비교에서 사용할 검증 로직은 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@Service
public class StatusService {
@Autowired
private StatusDao statusDao;
public void insertStatus(final Status status) {
validateStatus(status);
statusDao.save(status);
}
private void validateStatus(final Status status) {
// 검증 항목:
// - status2가 true 인가
// - statu1과 같은 상태를 가진 데이터가 DB에
if(status.isStatus2() || statusDao.existsByStatus1In(status.getStatus1())) {
throw new IllegalArgumentException();
}
}
}
|
cs |
코드 5
이 두가지 항목을 다음과 같은 로직들로 변경해 각 로직들의 실행시간 차이를 비교해 보자. 편의를 위해 순수 애플리케이션 로직을 사용한 검증을 "검증 1", 디비 조회를 모두 사용한 검증을 "검증 2"라 하겠다. 또 한, 각 케이스마다 3번의 시간을 측정해 평균값을 내는 방식으로 시간을 측정하겠다.
검증할 케이스들은 다음과 같다.
1. "검증1"과 "검증 2"를 OR 연산으로 묶은 경우
2. "검증1"을 우선 검증하고 "검증 2"를 검증한 경우
3. "검증2"를 우선 검증하고 "검증 1"을 검증한 경우
테스트 코드
위 코드에 실질적인 요청을 보내 시간을 측정하는 데 사용하는 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@Test
void statusTest() {
final Status successCase = new Status(1L, false, "success");
final Status applicationLogicFailCase = new Status(1L, true, "success");
final Status dbLogicFailCase = new Status(0L, false, "fail");
final List<Status> statuses = List.of(successCase, applicationLogicFailCase, dbLogicFailCase);
for (int i = 1; i <= 999; i++) {
final int index = i % 3;
ExtractableResponse<Response> response = RestAssured
.given().log().all()
.contentType(MediaType.APPLICATION_JSON_VALUE)
.body(statuses.get(index))
.when().post("/")
.then().log().all()
.extract();
}
}
|
cs |
API를 보내는 횟수는 999번이며, 공정한 테스트를 위해 모든 검증을 통과하고 디비에 저장되는 케이스, "검증 1"에서 예외가 터지는 케이스, "검증 2"에서 예외가 터지는 케이스를 각각 333번으로 통일시켰다.
추측
OR 연산을 사용한 로직은 좌변이 거짓이어도 우변을 검증하기 때문에 위 케이스들 중 1이 가장 오래 걸릴 것이다. 나머지 케이스 중에서는 디비 조회가 나중에 발생하는 케이스 2 가장 빠를 것이다.
1. "검증 1"과 "검증 2"를 OR 연산으로 묶은 경우
1
2
3
4
5
|
private void validateStatus(final Status status) {
if(status.isStatus2() || statusDao.existsByStatus1In(status.getStatus1())) {
throw new IllegalArgumentException();
}
}
|
cs |
코드 6
측정 결과: 평균 20.092 sec
(22466ms + 17362ms + 20450ms) / 3 = 20092ms = 20.092 sec
2. "검증 1"을 우선 검증하고 "검증 2"를 검증한 경우
1
2
3
4
5
6
7
8
9
|
private void validateStatus(final Status status) {
if (status.isStatus2()) {
throw new IllegalArgumentException();
}
if(statusDao.existsByStatus1In(status.getStatus1())) {
throw new IllegalArgumentException();
}
}
|
cs |
코드 7
측정 결과: 평균 20.168 sec
(21877+22116+16513) / 3 = 20168ms 20.168 sec
3. "검증 2"를 우선 검증하고 "검증 1"을 검증한 경우
1
2
3
4
5
6
7
8
9
10
|
private void validateStatus(final Status status) {
if(statusDao.existsByStatus1In(status.getStatus1())) {
throw new IllegalArgumentException();
}
if (status.isStatus2()) {
throw new IllegalArgumentException();
}
}
|
cs |
코드 8
측정 결과: 평균 23.093 sec
(27397+20924+20959)/3 = 23093ms = 23.093 sec
결론
1. "검증 1"과 "검증 2"를 OR 연산으로 묶은 경우 - avg: 20.092 sec
2. "검증 1"을 우선 검증하고 "검증 2"를 검증한 경우 - avg: 20.168 sec
3. "검증2"를 우선 검증하고 "검증 1"을 검증한 경우 - avg: 23.093 sec
OR 연산을 굳이 풀어쓴다고 해서 큰 차이는 없었다. 다만 DB검증을 우선 하는 경우 약 3초 정도의 시간이 더 걸리는 것을 볼 수 있다. 따라서 최적화를 위해 굳이 OR을 사용하지 않을 이유는 없는 거 같다.
번외
이런 비교를 하다 보니 ArrayList capacity 재할당으로 인한 속도 차이가 얼마나 날지도 갑자기 궁금해졌다.
ArrayList 내부를 보면 Object [] elementData라는 필드가 존재하고 여기에 실질적인 데이터가 저장된다. 또 한, 이 배열의 기본 capacity는 10이며 capacity가 다 찰 때마다 현재 capacity의 절반에 해당하는 만큼 capacity가 늘어나게 된다. capacity를 늘리는 메서드 내부 구현은 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
private int newCapacity(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return minCapacity;
}
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity
: hugeCapacity(minCapacity);
}
|
cs |
코드 9
만약 ArrayList 생성 시 capacity를 정해 준다면 정한 capacity만큼의 길이가 elementData에 할당된다. 초기화할 capacity 크기를 인자로 받는 생성자는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
|
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
|
cs |
코드 10
따라서 ArrayList 선언 시 해당 ArrayList에 들어갈 데이터의 길이를 미리 안다면, capacity 재할당이 이루어지지 않으므로 속도가 더 빠를 것이다. 얼마 큼의 속도 차이가 있는지 한번 측정해보자. 총 1만 건의 데이터를 ArrayList에 add 했을 때 걸리는 시간을 측정해보자.
초기 capacity를 할당하지 않은 경우
1
2
3
4
5
6
7
|
@Test
void test() {
final List<Integer> numbers = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
numbers.add(i);
}
}
|
cs |
코드 11
측정 결과: 평균 20ms
(22+20+18)/3 = 20ms
초기 capacity를 10000으로 할당한 경우
1
2
3
4
5
6
7
|
@Test
void test() {
final List<Integer> numbers = new ArrayList<>(10000);
for (int i = 0; i < 10000; i++) {
numbers.add(i);
}
}
|
cs |
코드 12
측정 결과: 평균 19.3ms
(20+20+18)/3 = 19.3 ms
결론
초기 capacity를 할당하지 않은 경우 - avg: 20ms
초기 capacity를 10000으로 할당한 경우 - avg: 19.3 ms
별 차이 없다, 그냥 쓰자. 다만, 초기 capacity를 설정한 테스트 결과가 조금 더 균일하게 나왔다는 것을 볼 수 있다.