Files
sb-backend/src/main/resources/mapper/AccountEntryMapper.xml
T
ByungCheol 190fbe835f perf(account): 내역 목록 태그 조회 N+1 → 배치 단일 쿼리로 개선
list() 에서 항목마다 findTagNamesByEntryId() 를 개별 호출하던 N+1 을
findTagsByEntryIds() 배치 쿼리 1회로 대체.
항목 수가 많을 때 타임아웃/과부하 방지.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 01:47:08 +09:00

250 lines
11 KiB
XML

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.account.mapper.AccountEntryMapper">
<resultMap id="entryResultMap" type="com.sb.web.account.domain.AccountEntry">
<id property="id" column="id"/>
<result property="memberId" column="member_id"/>
<result property="entryDate" column="entry_date"/>
<result property="type" column="type"/>
<result property="category" column="category"/>
<result property="amount" column="amount"/>
<result property="memo" column="memo"/>
<result property="walletId" column="wallet_id"/>
<result property="walletName" column="wallet_name"/>
<result property="toWalletId" column="to_wallet_id"/>
<result property="toWalletName" column="to_wallet_name"/>
<result property="installmentMonths" column="installment_months"/>
<result property="pending" column="pending"/>
<result property="notifKey" column="notif_key"/>
<result property="currency" column="currency"/>
<result property="originalAmount" column="original_amount"/>
<result property="exchangeRate" column="exchange_rate"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<sql id="periodFilter">
<if test="year != null">AND YEAR(entry_date) = #{year}</if>
<if test="month != null">AND MONTH(entry_date) = #{month}</if>
</sql>
<sql id="entryCols">
e.id, e.member_id, e.entry_date, e.type, e.category, e.amount, e.memo,
e.wallet_id, w.name AS wallet_name,
e.to_wallet_id, tw.name AS to_wallet_name,
e.installment_months, e.pending, e.notif_key,
e.currency, e.original_amount, e.exchange_rate,
e.created_at, e.updated_at
</sql>
<sql id="entryJoins">
FROM account_entry e
LEFT JOIN wallet w ON w.id = e.wallet_id
LEFT JOIN wallet tw ON tw.id = e.to_wallet_id
</sql>
<select id="findByMember" resultMap="entryResultMap">
SELECT <include refid="entryCols"/>
<include refid="entryJoins"/>
WHERE e.member_id = #{memberId}
<if test="year != null">AND YEAR(e.entry_date) = #{year}</if>
<if test="month != null">AND MONTH(e.entry_date) = #{month}</if>
<if test="type != null and type != ''">AND e.type = #{type}</if>
<if test="category != null and category != ''">AND e.category = #{category}</if>
<if test="walletId != null">AND (e.wallet_id = #{walletId} OR e.to_wallet_id = #{walletId})</if>
<if test="keyword != null and keyword != ''">
AND (e.memo LIKE CONCAT('%', #{keyword}, '%') OR e.category LIKE CONCAT('%', #{keyword}, '%'))
</if>
<if test="tagId != null">
AND EXISTS (SELECT 1 FROM account_entry_tag aet WHERE aet.entry_id = e.id AND aet.tag_id = #{tagId})
</if>
ORDER BY e.entry_date DESC, e.id DESC
</select>
<select id="findByWalletAndMember" resultMap="entryResultMap">
SELECT <include refid="entryCols"/>
<include refid="entryJoins"/>
WHERE e.member_id = #{memberId}
AND (e.wallet_id = #{walletId} OR e.to_wallet_id = #{walletId})
ORDER BY e.entry_date DESC, e.id DESC
</select>
<select id="findByIdAndMember" resultMap="entryResultMap">
SELECT <include refid="entryCols"/>
<include refid="entryJoins"/>
WHERE e.id = #{id} AND e.member_id = #{memberId}
</select>
<select id="sumByType" resultType="map">
SELECT type AS type, COALESCE(SUM(amount), 0) AS total
FROM account_entry
WHERE member_id = #{memberId}
<include refid="periodFilter"/>
GROUP BY type
</select>
<select id="sumExpenseByCategory" resultType="map">
SELECT COALESCE(category, '') AS category, COALESCE(SUM(amount), 0) AS total
FROM account_entry
WHERE member_id = #{memberId} AND type = 'EXPENSE'
<include refid="periodFilter"/>
GROUP BY category
</select>
<select id="sumByCategory" resultType="map">
SELECT COALESCE(NULLIF(category, ''), '미분류') AS category, COALESCE(SUM(amount), 0) AS total
FROM account_entry
WHERE member_id = #{memberId} AND type = #{type}
<include refid="periodFilter"/>
GROUP BY COALESCE(NULLIF(category, ''), '미분류')
ORDER BY total DESC
</select>
<select id="monthlyWalletFlow" resultType="map">
SELECT DATE_FORMAT(entry_date, '%Y-%m') AS ym,
COALESCE(SUM(CASE WHEN type = 'INCOME' THEN amount
WHEN type = 'EXPENSE' THEN -amount
ELSE 0 END), 0) AS net
FROM account_entry
WHERE member_id = #{memberId} AND wallet_id IS NOT NULL AND type IN ('INCOME', 'EXPENSE')
GROUP BY ym
ORDER BY ym
</select>
<select id="weeklyWalletFlow" resultType="map">
SELECT DATE_FORMAT(DATE_SUB(entry_date, INTERVAL WEEKDAY(entry_date) DAY), '%Y-%m-%d') AS wk,
COALESCE(SUM(CASE WHEN type = 'INCOME' THEN amount
WHEN type = 'EXPENSE' THEN -amount
ELSE 0 END), 0) AS net
FROM account_entry
WHERE member_id = #{memberId} AND wallet_id IS NOT NULL AND type IN ('INCOME', 'EXPENSE')
GROUP BY wk
ORDER BY wk
</select>
<select id="statsByPeriod" resultType="map">
SELECT
<choose>
<when test="unit == 'DAY'">DAY(entry_date)</when>
<when test="unit == 'WEEK'">FLOOR((DAY(entry_date) - 1) / 7) + 1</when>
<when test="unit == 'YEAR'">YEAR(entry_date)</when>
<otherwise>MONTH(entry_date)</otherwise>
</choose> AS bucket,
COALESCE(SUM(CASE WHEN type = 'INCOME' THEN amount ELSE 0 END), 0) AS income,
COALESCE(SUM(CASE WHEN type = 'EXPENSE' THEN amount ELSE 0 END), 0) AS expense
FROM account_entry
WHERE member_id = #{memberId}
<if test="unit == 'DAY' or unit == 'WEEK' or unit == 'MONTH'">AND YEAR(entry_date) = #{year}</if>
<if test="unit == 'DAY' or unit == 'WEEK'">AND MONTH(entry_date) = #{month}</if>
GROUP BY bucket
ORDER BY bucket
</select>
<insert id="insert" parameterType="com.sb.web.account.domain.AccountEntry"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo,
wallet_id, to_wallet_id, installment_months, pending, notif_key,
currency, original_amount, exchange_rate,
created_at, updated_at)
VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
#{walletId}, #{toWalletId}, #{installmentMonths},
COALESCE(#{pending}, 0), #{notifKey},
#{currency}, #{originalAmount}, #{exchangeRate}, NOW(), NOW())
</insert>
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
UPDATE account_entry
SET entry_date = #{entryDate}, type = #{type}, category = #{category},
amount = #{amount}, memo = #{memo}, wallet_id = #{walletId}, to_wallet_id = #{toWalletId},
installment_months = #{installmentMonths},
currency = #{currency}, original_amount = #{originalAmount}, exchange_rate = #{exchangeRate}
WHERE id = #{id} AND member_id = #{memberId}
</update>
<delete id="delete">
DELETE FROM account_entry WHERE id = #{id} AND member_id = #{memberId}
</delete>
<!-- ===== 알림 자동인식(미확인) ===== -->
<select id="findByMemberAndNotifKey" resultMap="entryResultMap">
SELECT <include refid="entryCols"/>
<include refid="entryJoins"/>
WHERE e.member_id = #{memberId} AND e.notif_key = #{notifKey}
LIMIT 1
</select>
<select id="countPending" resultType="int">
SELECT COUNT(*) FROM account_entry WHERE member_id = #{memberId} AND pending = 1
</select>
<!-- 자동인식 분류 학습: 같은 메모 키워드로 과거에 확정된 분류 중 가장 많이 쓴 1건.
메모가 서로 포함관계여도(예: '스타벅스' ↔ '스타벅스 강남점') 매칭. -->
<select id="findLearnedCategory" resultType="string">
SELECT category
FROM account_entry
WHERE member_id = #{memberId}
AND type = #{type}
AND pending = 0
AND category IS NOT NULL AND category != ''
AND memo IS NOT NULL AND memo != ''
AND (memo = #{memo}
OR memo LIKE CONCAT('%', #{memo}, '%')
OR #{memo} LIKE CONCAT('%', memo, '%'))
GROUP BY category
ORDER BY COUNT(*) DESC, MAX(entry_date) DESC, MAX(id) DESC
LIMIT 1
</select>
<update id="confirm">
UPDATE account_entry
SET pending = 0,
<if test="category != null">category = #{category},</if>
<if test="walletId != null">wallet_id = #{walletId},</if>
updated_at = NOW()
WHERE id = #{id} AND member_id = #{memberId} AND pending = 1
</update>
<select id="findDistinctCategories" resultType="string">
SELECT DISTINCT category FROM account_entry
WHERE member_id = #{memberId} AND type = #{type}
AND category IS NOT NULL AND category != ''
ORDER BY category
</select>
<update id="renameCategory">
UPDATE account_entry SET category = #{newName}
WHERE member_id = #{memberId} AND type = #{type} AND category = #{oldName}
</update>
<!-- ===== 항목-태그 ===== -->
<insert id="insertEntryTag">
INSERT INTO account_entry_tag (entry_id, tag_id) VALUES (#{entryId}, #{tagId})
</insert>
<delete id="deleteEntryTagsByEntryId" parameterType="long">
DELETE FROM account_entry_tag WHERE entry_id = #{entryId}
</delete>
<select id="findTagNamesByEntryId" parameterType="long" resultType="string">
SELECT t.name
FROM account_tag t
JOIN account_entry_tag aet ON aet.tag_id = t.id
WHERE aet.entry_id = #{entryId}
ORDER BY t.name
</select>
<!-- 복수 항목의 태그를 한 번에 조회 — {entry_id, tag_name} 행 반환 -->
<select id="findTagsByEntryIds" resultType="map">
SELECT aet.entry_id AS entryId, t.name AS tagName
FROM account_tag t
JOIN account_entry_tag aet ON aet.tag_id = t.id
WHERE aet.entry_id IN
<foreach item="id" collection="entryIds" open="(" separator="," close=")">
#{id}
</foreach>
ORDER BY aet.entry_id, t.name
</select>
</mapper>