공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 05 / 22

MyBatis 어노테이션 매퍼로 쿼리 연동

섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

6. 연습 문제

문제 1

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 을 던지는 서비스 메서드를 작성하세요.

두 세션이 같은 회원을 동시에 수정하는 시나리오를 재현하세요.

정답 보기
java
// 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=0

SELECT ... FOR UPDATE(예제 6 의 lockPrice)는 비관적 잠금으로, 잠근 동안 다른 트랜잭션이 대기합니다. 화면에서 읽고 몇 분 뒤 저장하는 흐름에서는 그동안 잠글 수 없으므로 낙관적 잠금이 맞습니다. 핵심은 검사와 갱신을 UPDATE 한 문장의 WHERE 로 합치는 것이고, 이는 예제 6 의 decreaseStock ... WHERE stock >= qty 와 같은 원리입니다.

문제 2

OrderMapper 에 조건 검색을 추가하세요. 조건은 회원 ID(선택), 상태 목록(선택), 주문일 범위(선택), 최소 금액(선택)이고, 정렬 컬럼(id, amount, ordered_at 중 하나)과 방향, 페이지 크기·번호를 받습니다.

<script> 버전과 Provider 버전을 둘 다 작성하고, 같은 조건에 대해 두 결과가 일치하는지 검증하는 코드를 쓰세요. 페이지 번호가 0 미만이거나 크기가 500 을 넘으면 거부하세요.

정답 보기
java
// 조건 객체
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 &gt;= #{c.from}</if>
        <if test="c.to != null">AND ordered_at &lt;= #{c.to}</if>
        <if test="c.minAmount != null">AND amount &gt;= #{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 가 컴포넌트 비교로 정의되어 두 리스트를 바로 비교할 수 있었습니다.

문제 3

예제 8 의 SettlementBatch 는 청크마다 MERGE 가 덮어써서 마지막 청크의 합계만 남습니다(1,325,000 = 53건 × 25,000. 정답은 253건 × 25,000 = 6,325,000). 정확하고 멱등한 정산으로 고치세요. 방법은 두 가지 중 택일입니다.

(a) 배치 시작 시 해당 settle_date 의 정산 행을 삭제한 뒤, MERGE 를 덮어쓰기가 아닌 누적으로 바꿔 청크 구조를 유지, (b) 청크를 없애고 회원 단위 전체 집계를 SQL GROUP BY 로 한 번에 수행. 두 방법의 트랜잭션 경계 차이와 장단점을 설명하세요.

정답 보기
java
// (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) 방식의 후처리 배치로 다룹니다.

연습 문제
  • 문제 1
  • 문제 2
  • 문제 3
이전 섹션5 자주 하는 실수 (Tip)6 / 7다음 섹션7 정리