Member 에 updatedAt LocalDateTime 프로퍼티와 member.updated_at TIMESTAMP 컬럼을 추가하고, updateGrade 가 등급 변경과 함께 updated_at = CURRENT_TIMESTAMP 를 기록하도록 바꾸세요. 그리고 낙관적 잠금을 구현하세요.
version INT 컬럼을 두고 UPDATE ... SET version = version + 1 WHERE id = #{id} AND version = #{version} 으로 갱신하며, 영향 행이 0 이면 OptimisticLockException 을 던지는 서비스 메서드를 작성하세요.
두 세션이 같은 회원을 동시에 수정하는 시나리오를 재현하세요.
// Schema: ALTER TABLE member ADD COLUMN version INT DEFAULT 0, ADD COLUMN updated_at TIMESTAMP
// Member: private int version; private LocalDateTime updatedAt; + getter/setter
// MemberMapper 추가
@Update("""
UPDATE member SET grade = #{grade}, version = version + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = #{id} AND version = #{version}
""")
int updateGradeOptimistic(@Param("id") long id, @Param("grade") Member.Grade grade, @Param("version") int version);
// 서비스
public class MemberService {
public static class OptimisticLockException extends RuntimeException {
public OptimisticLockException(String m) { super(m); }
}
private final SqlSessionFactory factory;
public MemberService(SqlSessionFactory factory) { this.factory = factory; }
public void changeGrade(Member loaded, Member.Grade grade) {
try (SqlSession s = factory.openSession()) {
int n = s.getMapper(MemberMapper.class).updateGradeOptimistic(loaded.getId(), grade, loaded.getVersion());
if (n == 0) throw new OptimisticLockException("다른 사용자가 먼저 수정했습니다: member=" + loaded.getId() + " version=" + loaded.getVersion());
s.commit();
loaded.setVersion(loaded.getVersion() + 1);
}
}
}
// 시나리오: 화면 A 와 화면 B 가 같은 회원을 읽고, A 가 먼저 저장, B 가 나중에 저장
Member a, b;
try (SqlSession s = factory.openSession()) { a = s.getMapper(MemberMapper.class).findById(1); }
try (SqlSession s = factory.openSession()) { b = s.getMapper(MemberMapper.class).findById(1); }
System.out.println("A version=" + a.getVersion() + ", B version=" + b.getVersion()); // 둘 다 0
svc.changeGrade(a, Member.Grade.SILVER); // version 0 → 1 성공
try { svc.changeGrade(b, Member.Grade.BRONZE); } // WHERE version = 0 → 영향 행 0
catch (MemberService.OptimisticLockException e) { System.out.println(e.getMessage()); }
// 출력:
// A version=0, B version=0
// 다른 사용자가 먼저 수정했습니다: member=1 version=0SELECT ... FOR UPDATE(예제 6 의 lockPrice)는 비관적 잠금으로, 잠근 동안 다른 트랜잭션이 대기합니다. 화면에서 읽고 몇 분 뒤 저장하는 흐름에서는 그동안 잠글 수 없으므로 낙관적 잠금이 맞습니다. 핵심은 검사와 갱신을 UPDATE 한 문장의 WHERE 로 합치는 것이고, 이는 예제 6 의 decreaseStock ... WHERE stock >= qty 와 같은 원리입니다.
OrderMapper 에 조건 검색을 추가하세요. 조건은 회원 ID(선택), 상태 목록(선택), 주문일 범위(선택), 최소 금액(선택)이고, 정렬 컬럼(id, amount, ordered_at 중 하나)과 방향, 페이지 크기·번호를 받습니다.
<script> 버전과 Provider 버전을 둘 다 작성하고, 같은 조건에 대해 두 결과가 일치하는지 검증하는 코드를 쓰세요. 페이지 번호가 0 미만이거나 크기가 500 을 넘으면 거부하세요.
// 조건 객체
public record OrderSearch(Long memberId, List<String> statuses, LocalDateTime from, LocalDateTime to, BigDecimal minAmount,
String sortCol, boolean desc, int size, int page) {
public OrderSearch {
if (page < 0) throw new IllegalArgumentException("page >= 0");
if (size < 1 || size > 500) throw new IllegalArgumentException("1 <= size <= 500");
}
public int offset() { return page * size; }
}
// 방법 1: <script>
@Select("""
<script>
SELECT id, member_id, product_id, qty, amount, status, ordered_at FROM orders
<where>
<if test="c.memberId != null">AND member_id = #{c.memberId}</if>
<if test="c.statuses != null and !c.statuses.isEmpty()">
AND status IN <foreach collection="c.statuses" item="s" open="(" separator="," close=")">#{s}</foreach>
</if>
<if test="c.from != null">AND ordered_at >= #{c.from}</if>
<if test="c.to != null">AND ordered_at <= #{c.to}</if>
<if test="c.minAmount != null">AND amount >= #{c.minAmount}</if>
</where>
ORDER BY ${sortCol} <if test="c.desc">DESC</if><if test="!c.desc">ASC</if>
LIMIT #{c.size} OFFSET #{c.offset}
</script>
""")
List<Order> searchScript(@Param("c") OrderSearch c, @Param("sortCol") String sortCol); // sortCol 은 호출 측이 검증해서 넘김
// 방법 2: Provider
public class OrderSqlProvider {
static final Set<String> SORTABLE = Set.of("id", "amount", "ordered_at");
public String search(OrderSearch c) {
String sort = SORTABLE.contains(c.sortCol()) ? c.sortCol() : "id";
SQL sql = new SQL().SELECT("id, member_id, product_id, qty, amount, status, ordered_at").FROM("orders");
if (c.memberId() != null) sql.WHERE("member_id = #{memberId}");
if (c.statuses() != null && !c.statuses().isEmpty()) {
StringBuilder in = new StringBuilder();
for (int i = 0; i < c.statuses().size(); i++) in.append(i > 0 ? ", " : "").append("#{statuses[").append(i).append("]}");
sql.WHERE("status IN (" + in + ")");
}
if (c.from() != null) sql.WHERE("ordered_at >= #{from}");
if (c.to() != null) sql.WHERE("ordered_at <= #{to}");
if (c.minAmount() != null) sql.WHERE("amount >= #{minAmount}");
return sql.ORDER_BY(sort + (c.desc() ? " DESC" : " ASC")).LIMIT("#{size}").OFFSET("#{offset}").toString();
}
}
@SelectProvider(type = OrderSqlProvider.class, method = "search")
List<Order> search(OrderSearch c);
// 검증: 두 방식 결과 일치
OrderSearch c = new OrderSearch(null, List.of("PAID"), null, null, new BigDecimal("25000"), "amount", true, 50, 0);
String sort = OrderSqlProvider.SORTABLE.contains(c.sortCol()) ? c.sortCol() : "id";
List<Order> byScript = mapper.searchScript(c, sort);
List<Order> byProvider = mapper.search(c);
System.out.println("script " + byScript.size() + "건, provider " + byProvider.size() + "건, 일치: " + byScript.equals(byProvider));
try { new OrderSearch(null, null, null, null, null, "id", false, 1000, 0); }
catch (IllegalArgumentException e) { System.out.println("거부: " + e.getMessage()); }
// 출력:
// script 253건 중 50건, provider 50건, 일치: true
// 거부: 1 <= size <= 500<script> 버전은 파라미터가 조건 객체 하나면 #{memberId} 로 접근할 수 있지만, 정렬 컬럼을 따로 검증해 넘기느라 @Param("c") 를 붙여 #{c.memberId} 가 됐습니다. Provider 버전은 검증이 SQL 조립과 같은 곳에 있어 더 안전합니다.
record 의 컴팩트 생성자에서 페이지·크기를 검증하면 잘못된 값이 SQL 근처에도 못 옵니다. Order 가 record 라 equals 가 컴포넌트 비교로 정의되어 두 리스트를 바로 비교할 수 있었습니다.
예제 8 의 SettlementBatch 는 청크마다 MERGE 가 덮어써서 마지막 청크의 합계만 남습니다(1,325,000 = 53건 × 25,000. 정답은 253건 × 25,000 = 6,325,000). 정확하고 멱등한 정산으로 고치세요. 방법은 두 가지 중 택일입니다.
(a) 배치 시작 시 해당 settle_date 의 정산 행을 삭제한 뒤, MERGE 를 덮어쓰기가 아닌 누적으로 바꿔 청크 구조를 유지, (b) 청크를 없애고 회원 단위 전체 집계를 SQL GROUP BY 로 한 번에 수행. 두 방법의 트랜잭션 경계 차이와 장단점을 설명하세요.
// (a) 삭제 후 재계산: 기존 청크 구조 유지
@Delete("DELETE FROM settlement WHERE settle_date = #{date}")
int deleteSettlement(LocalDate date);
public Result run(LocalDate settleDate, int chunkSize, int failAtChunk) {
try (SqlSession s = factory.openSession()) { // 삭제는 별도 트랜잭션으로 먼저 확정
s.getMapper(OrderMapper.class).deleteSettlement(settleDate);
s.commit();
}
// 이후 예제 8 과 동일. 단, 같은 회원이 여러 청크에 걸치므로 MERGE 는 덮어쓰기가 아니라 기존 값에 더해야 한다.
// 삭제를 먼저 했으니 첫 청크는 INSERT, 이후 청크는 UPDATE 누적이 되어 최종 합계가 정확해진다:
// MERGE INTO settlement s USING (VALUES(#{memberId}, #{date}, #{cnt}, #{total})) v(member_id, settle_date, cnt, total)
// ON s.member_id = v.member_id AND s.settle_date = v.settle_date
// WHEN MATCHED THEN UPDATE SET order_cnt = s.order_cnt + v.cnt, total = s.total + v.total
// WHEN NOT MATCHED THEN INSERT VALUES (v.member_id, v.settle_date, v.cnt, v.total)
...
}
// (b) SQL 집계 한 방: 청크 없이 DB 가 계산
@Insert("""
MERGE INTO settlement(member_id, settle_date, order_cnt, total) KEY(member_id, settle_date)
SELECT member_id, #{date}, COUNT(*), SUM(amount)
FROM orders
WHERE status = 'PAID' AND ordered_at >= #{date} AND ordered_at < #{nextDate}
GROUP BY member_id
""")
int settleByQuery(@Param("date") LocalDate date, @Param("nextDate") LocalDate nextDate);
public int runByQuery(LocalDate date) {
try (SqlSession s = factory.openSession()) {
int n = s.getMapper(OrderMapper.class).settleByQuery(date, date.plusDays(1));
s.commit(); // 트랜잭션 하나. 전부 성공 또는 전부 실패
return n;
}
}
// 두 번 실행해도 settlement 행=4, 합계=6,325,000 (253건 × 25,000) 으로 동일| (a) 삭제 후 청크 재계산 | (b) GROUP BY 한 방 | |
|---|---|---|
| 트랜잭션 경계 | 삭제 1개 + 청크마다 1개. 중간 실패 시 일부 회원만 정산된 상태가 남음 | 1개. 실패하면 아무것도 안 바뀜 |
| 멱등성 | 삭제가 커밋된 뒤 청크가 실패하면 그 회원 정산이 비어 있음. 재실행하면 복구 | 완전 멱등. MERGE 가 덮어쓰기 |
| 메모리·시간 | 자바가 주문을 청크로 읽어 집계. 주문 1억 건이면 느리다 | DB 가 인덱스로 집계. 대부분 훨씬 빠름 |
| 장점 | 건별 검증·스킵·로그 등 자바 로직을 끼울 수 있다 | 코드 10줄, 빠름, 원자적 |
| 단점 | 복잡, 반쪽 상태 가능 | 집계 중 건별 규칙(예: 특정 회원 제외, 외부 API 조회)을 넣기 어렵다 |
원칙은 "DB 가 할 수 있는 집계는 DB 에게". 배치 레슨에서 청크·스킵·재시도를 배운 것은 건별로 자바 로직이 필요할 때를 위한 것이고, 단순 합계는 SQL 한 문장이 정답입니다. 실무 정산은 보통 (b) 로 1차 집계를 만들고, 예외 처리가 필요한 건만 (a) 방식의 후처리 배치로 다룹니다.