결산자료 중 계정항목별로 예산현액과 월계/누계 및 하위거래내역을 아래의 양식으로 출력해주는 기능을 개발했었다.
JSON 데이터 추출예시
[
...,
{
"mock_code": "DC2022_I080101",
"mock_name": "이자수입",
"current_budg_amount": 29972,
"input_tran_amount": 221,
"output_tran_amount": 0,
"input_accumulated_amount": 249
"output_accumulated_amount": 0,
"balance": 29751,
"generalLedgerDetails": [
{
"tran_date": "05/31",
"tran_input_price": 200,
"tran_output_price": 0,
"tran_jukyo": "이자",
"tran_proof_numb": "I2022050001"
},
{
"tran_date": "06/25",
"tran_input_price": 21,
"tran_output_price": 0,
"tran_jukyo": "결산이자",
"tran_proof_numb": "I2022060006"
}
],
},
...
]
기존안 (Nested Results for Collection)
JSON 데이터 추출예시처럼 계정항목정보와 거래내역리스트를 갖고 있는 형식으로 데이터를 추출하기 위해 MyBatis Collection을 사용하였다.
Collection은 여러가지 방법으로 사용할 수 있는데, 상위(generalLedgerDetails를 제외한 정보), 하위(generalLedgerDetails) 데이터를 한번의 쿼리로 통합하여 조회한 후 하위데이터를 그룹핑해주는 방법과 상위 데이터를 먼저 조회한 후 각 행마다 하위 데이터를 조회하는 방식 등이 있다.
기존에는 데이터를 한번의 쿼리로 조회하는 전자의 방식을 선택해서 개발하였다.
(공식문서 명칭에 따라 전자를 Nested Results, 후자를 Nested Select 방식이라고 칭하겠다.)
Nested Results 을 사용한 코드는 아래와 같다. (Select 사용 X)
<resultMap id="generalLedgerResultMap" type="hashmap">
<id property="mock_code" column="mock_code"/>
<result property="mock_name" column="mock_name"/>
<result property="current_budg_amount" column="current_budg_amount"/>
<result property="input_tran_amount" column="input_tran_amount"/>
<result property="output_tran_amount" column="output_tran_amount"/>
<result property="input_accumulated_amount" column="input_accumulated_amount"/>
<result property="output_accumulated_amount" column="output_accumulated_amount"/>
<result property="balance" column="balance"/>
<collection property="generalLedgerDetails" ofType="hashmap" javaType="list">
<result property="tran_date" column="tran_date"/>
<result property="tran_proof_numb" column="tran_proof_numb"/>
<result property="tran_jukyo" column="tran_jukyo"/>
<result property="tran_input_price" column="tran_input_price"/>
<result property="tran_output_price" column="tran_output_price"/>
</collection>
</resultMap>
<select id="generalLedger" parameterType="hmap" resultMap="generalLedgerResultMap">
<![CDATA[
SELECT DATE_FORMAT(TRAN_DATE, '%m/%d') AS TRAN_DATE
, sfProofNumberFormat(TRAN_DATE, TRAN_IO_TYPE, TRAN_PROOF_NUMB) AS TRAN_PROOF_NUMB
, TRAN_JUKYO
, IF(TRAN_IO_TYPE = 'I', TRAN_PRICE, 0) AS TRAN_INPUT_PRICE
, IF(TRAN_IO_TYPE = 'O', TRAN_PRICE, 0) AS TRAN_OUTPUT_PRICE
, MOCK_CODE
, MOCK_NAME
, BUDG_AMOUNT - PREV_ACCUMULATED_AMOUNT AS CURRENT_BUDG_AMOUNT
, IF(TRAN_IO_TYPE = 'I', TRAN_AMOUNT, 0) AS INPUT_TRAN_AMOUNT
, IF(TRAN_IO_TYPE = 'O', TRAN_AMOUNT, 0) AS OUTPUT_TRAN_AMOUNT
, IF(TRAN_IO_TYPE = 'I', PREV_ACCUMULATED_AMOUNT + TRAN_AMOUNT, 0) AS INPUT_ACCUMULATED_AMOUNT
, IF(TRAN_IO_TYPE = 'O', PREV_ACCUMULATED_AMOUNT + TRAN_AMOUNT, 0) AS OUTPUT_ACCUMULATED_AMOUNT
, BUDG_AMOUNT - PREV_ACCUMULATED_AMOUNT - TRAN_AMOUNT AS BALANCE
FROM (
SELECT *
, IFNULL(
(SELECT SUM(CAST((BGDT_PRICE * BGDT_QTY * BGDT_MONTH * (BGDT_PER / 100)) AS SIGNED INTEGER))
FROM AC_BUDG_INFO
JOIN AC_BUDG_DETAIL USING (COMP_CODE, BSNS_CODE, BUDG_CODE)
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND BUDG_YEAR = #{session_year}
AND BUDG_TYPE = (SELECT MAX(BUDG_TYPE)
FROM AC_BUDG_INFO
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND BUDG_YEAR = #{session_year})
AND MOCK_CODE = ATL.MOCK_CODE
), 0
) AS BUDG_AMOUNT
, IFNULL(
(SELECT SUM(TRAN_PRICE)
FROM AC_TRAN_LIST
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND TRAN_DATE >= #{session_start_date}
AND TRAN_DATE < #{start_date}
AND MOCK_CODE = ATL.MOCK_CODE
GROUP BY MOCK_CODE
), 0
) AS PREV_ACCUMULATED_AMOUNT
, IFNULL(
(SELECT SUM(TRAN_PRICE)
FROM AC_TRAN_LIST
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND TRAN_DATE >= #{start_date}
AND TRAN_DATE < #{end_date}
AND MOCK_CODE = ATL.MOCK_CODE
GROUP BY MOCK_CODE
), 0
) AS TRAN_AMOUNT
FROM AC_TRAN_LIST ATL
JOIN SYS_MOCK_INFO SMI USING (MOCK_CODE)
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND TRAN_DATE >= #{start_date}
AND TRAN_DATE < #{end_date}
) T
ORDER BY MOCK_CODE, TRAN_DATE
]]>
</select>
위의 쿼리를 실행하면 아래처럼 데이터가 조회되는데 MyBatis가 알아서 resultMap의 id로 설정해둔 계정항목코드(mock_code)를 기준으로 하위 데이터를 그룹핑하여 원하던 JSON 형태로 데이터를 반환해준다.
한번의 쿼리로 결과가 반환된다는 장점이 있지만, 한번에 조회를 해오려다 보니 계정항목별로 한번씩만 조회하면 될 예산현액이나 거래누계액 같은 부분이 거래건별로 서브쿼리로 조회되다보니 이게 좋은 방식인가 하는 의문이 들었다.
그래서 계정항목별로 예산현액 및 누계를 조회하고 계정항목정보를 기준으로 하위 데이터를 조회해오는 방식으로 변경을 고려해보았다.
변경안 (Nested Select for Collection)
상위 데이터를 조회 후 하위 데이터를 조회하는 Nested Select 를 사용한 코드는 아래와 같다.
<resultMap id="generalLedgerResultMap" type="GeneralLedger">
<result property="comp_code" column="comp_code"/>
<result property="bsns_code" column="bsns_code"/>
<result property="start_date" column="start_date"/>
<result property="end_date" column="end_date"/>
<result property="mock_code" column="mock_code"/>
<collection property="generalLedgerDetails"
column="{comp_code=COMP_CODE,bsns_code=BSNS_CODE,start_date=START_DATE,end_date=END_DATE,mock_code=MOCK_CODE}"
javaType="java.util.ArrayList" ofType="GeneralLedgerDetails" select="getGeneralLedgerDetails"/>
</resultMap>
<select id="generalLedger" parameterType="hmap" resultMap="generalLedgerResultMap">
<![CDATA[
SELECT #{comp_code} AS COMP_CODE
, #{bsns_code} AS BSNS_CODE
, #{start_date} AS START_DATE
, #{end_date} AS END_DATE
, MOCK_CODE
, MOCK_NAME
, BUDG_AMOUNT - ACCUMULATED_AMOUNT AS CURRENT_BUDG_AMOUNT
, IF(TRAN_IO_TYPE = 'I', TRAN_AMOUNT, 0) AS INPUT_TRAN_AMOUNT
, IF(TRAN_IO_TYPE = 'O', TRAN_AMOUNT, 0) AS OUTPUT_TRAN_AMOUNT
, IF(TRAN_IO_TYPE = 'I', ACCUMULATED_AMOUNT + TRAN_AMOUNT, 0) AS INPUT_ACCUMULATED_AMOUNT
, IF(TRAN_IO_TYPE = 'O', ACCUMULATED_AMOUNT + TRAN_AMOUNT, 0) AS OUTPUT_ACCUMULATED_AMOUNT
, BUDG_AMOUNT - ACCUMULATED_AMOUNT - TRAN_AMOUNT AS BALANCE
FROM (SELECT MOCK_CODE
, TRAN_IO_TYPE
, IFNULL(SUM(TRAN_PRICE), 0) AS TRAN_AMOUNT
, IFNULL(
(SELECT SUM(CAST((BGDT_PRICE * BGDT_QTY * BGDT_MONTH * (BGDT_PER / 100)) AS SIGNED INTEGER))
FROM AC_BUDG_DETAIL
JOIN AC_BUDG_INFO USING (COMP_CODE, BSNS_CODE, BUDG_CODE)
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND MOCK_CODE = ATL.MOCK_CODE
AND BUDG_TYPE = (SELECT MAX(BUDG_TYPE)
FROM AC_BUDG_INFO
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND BUDG_YEAR = #{session_year})
), 0
) AS BUDG_AMOUNT
, (SELECT IFNULL(SUM(TRAN_PRICE), 0)
FROM AC_TRAN_LIST
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND TRAN_DATE >= #{session_start_date}
AND TRAN_DATE < #{start_date}
AND MOCK_CODE = ATL.MOCK_CODE
) AS ACCUMULATED_AMOUNT
FROM AC_TRAN_LIST ATL
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND TRAN_DATE >= #{start_date}
AND TRAN_DATE < #{end_date}
GROUP BY MOCK_CODE
) T
JOIN SYS_MOCK_INFO SMI USING (MOCK_CODE)
ORDER BY MOCK_CODE ASC
]]>
</select>
<select id="getGeneralLedgerDetails" parameterType="hmap" resultType="GeneralLedgerDetails">
<![CDATA[
SELECT DATE_FORMAT(TRAN_DATE, '%m/%d') AS TRAN_DATE
, sfProofNumberFormat(TRAN_DATE, TRAN_IO_TYPE, TRAN_PROOF_NUMB) AS TRAN_PROOF_NUMB
, TRAN_JUKYO
, IF(TRAN_IO_TYPE = 'I', TRAN_PRICE, 0) AS TRAN_INPUT_PRICE
, IF(TRAN_IO_TYPE = 'O', TRAN_PRICE, 0) AS TRAN_OUTPUT_PRICE
FROM AC_TRAN_LIST
WHERE COMP_CODE = #{comp_code}
AND BSNS_CODE = #{bsns_code}
AND TRAN_DATE >= #{start_date}
AND TRAN_DATE < #{end_date}
AND MOCK_CODE = #{mock_code}
ORDER BY TRAN_DATE ASC, TRAN_CODE ASC
]]>
</select>
변경하며 굉장히 불편하다고 느낀 부분이 있었는데 하위 데이터 조회를 위한 변수를 넘겨주기 위해 상위데이터 조회할 때 굳이 필요하지 않은 정보들을 조회컬럼으로 추가를 해줘야한다는 점이었다.
간단하게 컬럼 하나정도만 전달하여 하위데이터를 조회하는 경우는 번거롭지 않겠지만 여러변수들을 전달해줘야하는 경우에는 SELECT할 필요가 없는 컬럼까지 추가해줘야 해서 굉장히 번거롭게 느껴지며 여기서부터 뭔가 잘못됐다는 걸 살짝 직감했다..ㅎ
일단 생각했던 계정항목별로 한번만 조회한다는 목표는 이루었기에 상위항목에 대한 정보가 명확한 것 같아서 좋다고 생각했으나 로그를 보니 select 부분 로그가 엄청 찍히는 걸 보고 기존안보다 속도가 더 낫긴 한걸까 싶어 두가지 속도를 비교해보았다.
속도비교
계정항목 15개에 거래내역 35개 정도로 두가지 방안을 돌려보며 실행을 해보니 Nested Results은 평균적으로 80ms 정도이고, Nested Select은 그 2배가 넘는 200 ms 정도가 나왔다.
Nested Select은 흔히 말하는 N+1 문제가 되어 실쿼리를 더 많이 날리다보니 속도가 더 느려진 것같다.
짧게 생각하고 방법을 바꿔보았지만 더 안좋은 결과가 되어 다시 되돌렸다..ㅎ
JPA와 같이 따라다니는 N+1 문제라는 것이 있다는 것은 알았지만 JPA를 잘 몰라서 N+1 문제에 대해 읽어도 무슨 얘기인지 이해가 안됐었는데, MyBatis를 통해 N+1문제를 겪어보고 collection을 효율적으로 쓰는 방법을 알게 되었다.
기존안의 서브쿼리 보완하기
그럼 기존안의 문제라고 생각했던 서브쿼리는 어떻게 보안하면 좋을까?
SELECT 절에서 서브쿼리를 사용해 조회하는 방법이 지금은 성능이 나쁘지 않다.
하지만 결과 건수만큼 반복 수행되기 때문에 조회되는 데이터의 갯수가 많아지만 성능이 현저히 떨어질 수 있다.
스칼라 서브쿼리(SELECT 절에 포함된 서브쿼리) 사용 시 서브쿼리의 결과값이 같은 경우가 많거나 매전 동일한 결과값을 반환한다면 스칼라 서브쿼리 캐싱 효과로 인해 성능을 높일 수 있다고 하나, 위 경우에는 조회조건인 날짜 값이 매번 바뀔 수 있기 때문에 성능이 좋지 않을 것으로 예상된다.
SELECT 절에서 서브쿼리로 조회하는 것이 아니라 FROM 절에서 조회하여 JOIN 하는 방식으로 변경해보면 좋을 것 같다.
그럼에도 불구하고 Nested Select 방식을 쓰면 좋은 경우는 언제일까?
Select 방식이 쿼리가 직관적이고 접근 방식이 간단하다는 장점이 있지만 N+1 문제로 인해 성능저하가 발생하는 것이 너무 명백하기 때문에 아마 없을 것 같다 😂
MyBatis 공식 문서에도 Nested Select 방식에 N+1 문제를 지적하고 더 나은 방안으로 Nested Results 방식을 권장하고 있다. (해당 공식문서 내용은 여기 참고)
결론
N+1 문제를 피하기 위해 Nested Select 방식을 지양하고 Nested Results 방식으로 개발하는 것이 좋을 것 같다.
📖 참고자료
[DevEric] Mybatis <collection> 태그 N+1 문제 없이 사용하기
[로티.log] 스칼라란 무엇인가 그리고 성능 및 성능 문제 간단한 해결방안
'SPRING' 카테고리의 다른 글
Error running 'spring':Unable to open debugger port (127.0.0.1:56487): java.net.SocketException "socket closed" (0) | 2020.02.15 |
---|