1. 사용자가 오픈런 게시글 목록 조회 api 호출

→ 공개중인 게시글과 공개예정 게시글 한 번에 보여줌

=> api 호출로 목록을 화면에 먼저 띄우고, 이후에 바로 소켓 연결을 하는 게 합리적
(클라이언트 측에서 동기적으로 수행)

 

 

2. 클라이언트 : 소켓 연결 & 해당 페이지의 게시글들을 sub

=> 페이지에 해당하는 모든 게시글을 한 번에 subscribe 할 수 없음
클라이언트 측에서 반복문을 돌면서 하나씩 subscribe 요청 필요
더보기

왜냐면!

 

특정 토픽 경로를 구독하는 건 서버가 아니라 클라이언트가 할 일

한 유저가 여러 게시글에 대해 구독하려면 이렇게 반복문을 돌면서 하나씩 구독 요청을 보내야 함

boardIds.forEach(id => {
  stompClient.subscribe(`/topic/openrun/board/${id}`, message => {
    // 메시지 수신 처리
  });
});

한 번에 여러 게시글을 구독하려면?

payload에 여러개의 게시글 id를 담아서 메세지를 전송

→ 게시글 중 하나의 게시글이 상태가 변경되면 아래와 같이 어떤 boardId에 대해 값이 변경되었는지 확인 필요

→ 모든 게시글이 하나의 topic 경로로 메세지를 전송하기 때문에 메세지 수신이 빈번하게 발생

→ topic 경로가 하나이기 때문에 내가 구독하지 않은 게시글의 변경 상태도 수신될 수 있음(모든 유저가 하나의 경로를 사용하므로)

ex. A유저가 게시글 1,2,3 구독 / B유저가 게시글 4,5,6 구독 ⇒ A유저와 B유저는 1~6 게시글 상태 모두 수신

stompClient.subscribe("/topic/openrun/board", message => {
  const { boardId, message } = JSON.parse(message.body);
});

//수신하는 json 예시
{
  "boardId": 3,
  "remainingSlot": 5
}

topic 경로에 user 정보를 포함하여 사용자마다 다른 경로로 메세지를 수신한다면?

  1. 클라이언트가 /topic/openrun/board/{userId} 경로를 구독하고 payload에 구독할 게시글들의 id를 담아서 전송
  2. 서버는 userId, boardId를 Redis나 Map에 저장
  3. 게시글 상태가 바뀌면 Redis나 Map에서 해당 게시글을 구독한 userId를 찾아 /topic/openrun/board/{userId} 경로로 메세지 전송

또는 경로에 userId를 넣지 않고도, 인터셉터에서 추출한 userId를 사용해서 특정 유저에게만 전송하는 방법도 있지만 이 방식은 주로 채팅, 알림처럼 1:1 알림이 주된 케이스에서 사용함

  • @SendToUser 어노테이션을 컨트롤러에 적용해서 요청이 들어왔을 때 바로 해당 유저에게 메시지 응답(즉시 응답 필수)
  • @MessageMapping("/openrun/subscribe") @SendToUser("/queue/subscription-result") public SubscriptionResponse handleSubscribe(SubscriptionRequest request, Principal principal) { return new SubscriptionResponse("Subscribed successfully"); }
  • 시간차를 두고, 특정 이벤트가 발생한 시점에 특정 유저에게 메세지를 전송하려면 수동으로 메세지 전송
  • messagingTemplate.convertAndSendToUser( userId, // Principal.getName()과 일치해야 함 "/queue/reply", payload );

→ 경로에 userId를 넣어서 사용하는 방법은 해당 경로를 한 명의 유저가 단독으로 사용하게 되는 시스템인데, 이 방식은 STOMP 송수신에 적합하지 않은 방식(STOMP는 pub/sub 방식을 기반으로 설계됨)

→ Redis나 Map에 userId, boardId를 저장/갱신/추적하는 과정은 불필요한 관리 비용 발생

결론

최상단 방법처럼 클라이언트에서 반복적으로 각 boardId 경로를 구독하는 방식이 가장 효율적

 

3. 서버 : 클라이언트에 잔여시간/인원 값 변경될 때마다 pub

잔여 시간의 경우는 따로 값 변경될때마다 알려주지 말고 openLimit 클라이언트한테 알려주면 클라이언트가 알아서 처리

잔여 인원의 경우 zset에 데이터가 삽입될 때마다 메세지 전송

update/delete등 쓰기 작업시 트랜잭션 어노테이션 적용 필요

modifying 어노테이션 사용하면 트랜잭션 필수 요구됨

 

그렇지 않으면 javax.persistence.TransactionRequiredException 발생 가능

→ 스케줄링할때 하나라도 실패하면 전체 롤백되는 문제 발생

→ 기본적인 스케줄러의 경우는 실패시 실패 지점을 따로 관리하지 않기 때문에, 데이터 정합성을 위해 전체 롤백하는게 맞음

실패 대비 재시도 로직 구현 필요

도메인, 도메인 모델?

소프트웨어로 해결하고자 하는 문제 영역을 의미한다.
만약 책을 구매하는 온라인 서점 서비스라면, 온라인 서점이 도메인이 된다.
이 도메인은 다시 주문, 배송, 결제, 회원 등의 하위 도메인으로 분류된다.


따라서 도메인 모델은 소프트웨어 시스템이 해결하려는 현실 세계의 문제(도메인)를 객체 중심으로 추상화한 구조이다.



도메인 모델 패턴

여기서 말하는 도메인 모델이란, 저자 마틴 파울러의 엔터프라이즈 애플리케이션 아키텍처 패턴(위키북스) 책의 도메인 모델 패턴을 의미한다.



표현 영역 : 사용자(or 외부 시스템)의 요청 처리
응용 영역 : 사용자가 요청한 기능 실행. 업무 로직을 구현하지 않고 도메인 계층을 조합해서 기능 실행
도메인 영역 : 시스템이 제공할 도메인 규칙 구현
인프라스트럭처(infrastructure) 영역 : 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동 처리



도메인 모델 도출

기존 코드

public class Order{
    private OrderState state;

    ...

    private boolean isShiipingChangeable(){
        return state == OrderState.PAYMENT_WAITING || state == OrderState.PREPARING;
    }
}



요구사항에 제약이나 규칙이 다르게 적용되는 경우가 많다.
예를 들어 아래 요구사항을 기존 코드에 반영하면 아래와 같이 변경될 수 있다.

  • 출고를 하면 배송지 정보를 변경할 수 없다.
  • 출고 전에 주문을 취소할 수 없다.
  • 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.

 

요구사항 반영된 코드

public class Order{
    private OrderState state;

    ...

    public void changeShippingInfo(){
        verifyNotYetShipped();
    }

    public void cancel(){
        verifyNotYetShipped();
        this.state = OrderState.CANCELED;
    }

    private void verifyNotYetShipped(){
        if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
            throw new IllegalStateException("already shipped");
    }
}



초기에는 배송지 정보 변경에 대한 제약 조건만 파악했기 때문에 '배송지 정보 변경 가능 여부 확인'을 의미하는 isShippingChangeable이라는 메서드명이 사용됐다.
요구사항을 분석하면서 배송지 정보 변경과 주문 취소 둘 다 '출고 전에 가능하다'는 제약이 있음을 알게 되고, 출고 전이라는 의미를 반영하기 위해 verifyNotYetShipped로 메서드명이 변경된다.

이렇게 요구사항 분석을 통해 만들어진 도메인 모델은 실무에서 업무 이해 공유 + 팀 간 소통 + 유지보수 효율화를 위해 문서화하여 공유해야 한다.

성능 테스트 주요 유형

부하 테스트

  • 시스템이 예상 수준의 부하를 받았을 때, 정상 처리가 가능한지 테스트

스트레스 테스트

  • 시스템이 예상 수준을 넘어선 과도한 부하를 받았을 때, 장애 발생 여부와 최소한의 서비스 유지 여부를 확인하는 테스트
  • ex. 평균 방문자 수가 1000명인 웹사이트에 2000명 동시 접속을 가정하여 테스트 (스케일아웃? 지연로딩 화면 노출? 다시 동접 수가 줄었을 때 정상 수준 동작하는지?)
  • 비용 관리를 위해 평균 수치를 기준으로 서비스 구축

스파이크 테스트

  • 스트레스 테스트와 유사하지만, 스파이크 테스트는 단시간의 급격한 부하 변화에 중점
  • ex. 신제품 출시, 시즌 세일, 인기 공연 예매 오픈 등

지속성 테스트

  • 일정 시간 이상(최소 몇시간) 지속적으로 부하를 유지하면서 시스템의 정상 동작 테스트
  • 메모리 누수, 자원 고갈 문제 관찰

중단점 테스트

  • 동시 사용자 수를 점진적으로 증가시키며 테스트
  • 점진적으로 늘리면서 최대 수용 가능 범위 파악
  • ex. 500명 -> 1000명 ... 점점 트래픽 늘리면서 테스트 (500 -> 2000 -> 1500 이런식으로 와리가리 아님)

성능 테스트 주요 지표

평균 응답 시간

  • 1초 이내 응답 (당연함! 500ms 내외가 일반적이고 100ms 내외가 이상적)
  • 컨트롤러, 서비스, 데이터 액세스 계층 실행 시간을 세분화하여 측정하면 성능 병목 지점을 정확히 파악할 수 있다.

네트워크 대역폭 사용량

  • 데이터 송수신에 사용되는 네트워크 자원 소비량
  • 분산 시스템과 마이크로서비스에서 중요
  • ex. 직렬화, 역직렬화 시간 비용

CPU 및 메모리 사용률

  • CPU, 메모리 사용률이 100%가 되면 앱이 죽음
  • 80% 정도의 알람을 걸어두는 게 일반적

외부 리소스 사용률

  • DB, 캐시 서버 등의 외부 리소스 사용 현황
  • Spring에서는 JPA나 Spring Data를 통한 데이터베이스 상호작용 시 쿼리 성능과 커넥션 풀 관리가 전체 성능에 영향
  • Redis나 Memcached와 같은 캐시 시스템의 히트율과 메모리 사용량을 통해 캐싱 전략의 효율성을 평가
  • 히트율 : 요청 들어온 데이터가 캐시에 존재해, 바로 반환한 비율 (DB 접근 X)

DAO 개념

  • DB에 접근하여 데이터를 조회/저장/삭제/수정하는 객체
  • Repository와 유사한 개념
  • ORM 기반 쿼리 수행

목적

  • DB 접근 로직을 추상화하여 비즈니스 로직과 분리
  • Persistence Layer(영속성 계층)를 담당
  • Persistence Layer : 애플리케이션에서 데이터를 영구적으로 저장하고 불러오는 역할을 담당하는 계층
  • 이전에 Repository Layer로 알고있던 계층을 Persistence Layer로 명칭. 레포지토리는 Persistence Layer의 구현체 역할

@Repository
public class UserDao {
    @PersistenceContext
    private EntityManager em;

    public User findById(Long id) {
        return em.find(User.class, id);
    }

    public void save(User user) {
        em.persist(user);
    }
}


현재까지 진행한 프로젝트에서는 Spring Data JPA를 사용했기 때문에, JpaRepository를 상속받으면 기본 CRUD 메서드가 모두 제공되었다.
하지만 순수 JPA를 사용하게 되면, 유지보수성, 관심사 분리, 테스트 용이성 등을 위해 DAO 구현이 필수적이라고 볼 수 있다.


목적 설명
1. 관심사의 분리 (Separation of Concerns) 비즈니스 로직(Service)와 DB 접근 로직을 분리함으로써 역할을 명확히 구분
2. 유지보수성 향상 DB 쿼리 변경 시 Service 코드는 그대로 두고 DAO만 수정 가능
3. 재사용성 증가 여러 서비스에서 동일한 DAO 메서드 재사용 가능 (findById, save 등)
4. 테스트 용이성 DAO를 Mock/Fake로 대체하여 Service 단위 테스트 가능
5. 추상화 DB 접근 방법 (JPA, JDBC, MyBatis 등)을 숨기고, 상위 계층은 사용만 하게 함
6. 아키텍처 일관성 유지 계층형 구조(Layered Architecture)를 명확히 지키기 위함
public Long tryEnterFcfsQueue(Board board, Long userId) {
		String key = "board:" + board.getId();

		// sorted set의 크기가 open limit을 초과하면 error로 메시지 전달
		Long size = redisService.getZSetSize(key);
		if (size >= board.getOpenLimit()) {
			throw new CustomException(EXCEED_OPEN_LIMIT);
		}

		// 순위가 없는 유저만 sorted set에 유저 id, score(현재시간) 저장
		if (!redisService.hasRankInZSet(key, userId)) {
			redisService.addToZSet(key, userId, System.currentTimeMillis());
		}

		return redisService.getRank(key, userId);
	}

첫번째 if문으로 ZSet에 공개 제한 인원만큼의 데이터가 존재하면 에러를 날려서 추가적인 데이터 삽입을 방지하고 있다.

하지만 size를 확인하고 add 하기까지의 시간 간격 사이에 다른 사용자가 들어올 수 있다 (race condition)

이 동시성 문제를 해결하기 위해

  1. Lua Script로 크기를 확인하고 add하는 과정을 하나의 작업으로 묶어서 수행하거나,
  2. 일단 add 시킨 뒤 공개 제한 인원 수를 넘은 데이터를 삭제하고 error을 날리는 방법이 있다.

하지만 이 메서드를 호출하는 로직에서

		// 선착순 공개 게시글이면 순위 검증 메서드 호출
		if (board.getStatus() == BoardStatus.FCFS && tryEnterFcfsQueue(board, userId) >= board.getOpenLimit()) {
			throw new CustomException(EXCEED_OPEN_LIMIT);
		}

if문으로 한번더 순위를 검증하고 있기 때문에 동시성 문제가 큰 영향은 없다.

대신 zset에 필요 이상의 데이터가 저장될 수 있기 때문에

  1. 순위가 openLimit보다 크거나 같은 경우는 zset에서 데이터를 삭제한 뒤에 throw 한다.
  2. 게시글마다 TTL을 설정하여 불필요하게 남아있는 데이터를 삭제한다.

사용자가 해당 게시글을 삭제하는 게 아니라면 선착순 순위에 든 유저들이 항상 게시글을 조회할 수 있도록 하고 싶기 때문에

2번 방법은 적절하지 않다.

⇒ 1번 방법을 적용해서 zset에 불필요한 데이터가 남지 않도록 하고, 추후 성능 최적화 기간에 lua script를 사용해서 원자적으로 처리하는 방법을 적용해보려고 한다.


mvp 구현(성능 최적화 전) 결과

// 선착순 공개 게시글이면 순위 검증 메서드 호출if (board.getStatus() == BoardStatus.*FCFS*) { tryEnterFcfsQueue(board, userId);}

// 선착순 공개 게시글이면 순위 검증 메서드 호출
		if (board.getStatus() == BoardStatus.FCFS) {
			tryEnterFcfsQueue(board, userId);
		}
public void tryEnterFcfsQueue(Board board, Long userId) {
		String key = "board:" + board.getId();

		// ZSet 크기가 open limit을 초과하면 error로 메시지 전달
		Long size = redisService.getZSetSize(key);
		if (size >= board.getOpenLimit()) {
			throw new CustomException(EXCEED_OPEN_LIMIT);
		}

		// 순위가 없는 유저만 ZSet에 insert
		if (!redisService.hasRankInZSet(key, userId)) {
			redisService.addToZSet(key, userId, System.currentTimeMillis());
		}

		// 동시성 문제로 openLimit 보다 초과 저장된 데이터 삭제
		if (redisService.getRank(key, userId) >= board.getOpenLimit()) {
			redisService.removeFromZSet(key, userId);
			throw new CustomException(EXCEED_OPEN_LIMIT);
		}
	}

책임 분리를 위해 게시글 조회 메서드에서는 tryEnterFcfsQueue() 메서드만 호출

tryEnterFcfsQueue() 내부에서 동시성 문제로 발생한 초과 데이터 삭제 및 예외처리

  • 스케줄러 누락/지연 위험
  • 비동기 타이밍 이슈 발생 가능
  • → 예: 사용자가 게시글 클릭하는 순간 스케줄러 동작 중이면 엣지 케이스 발생 가능

스케줄러 누락/지연이 발생하는 경우

원인 설명

애플리케이션 중단/재시작 시점 서버가 다운되거나 배포 중이면 예약된 시간이 지나도 실행 안 됨
스케줄러 스레드 문제 @Scheduled 동작은 스레드 풀에 의존. 예외 발생 시 해당 스케줄러가 영구 중지될 수 있음
서버 시간 오차 (Timezone) 서버의 시스템 시간이 잘못 설정되면 지정한 시간이 어긋날 수 있음
장기 GC 또는 서버 부하 GC, CPU 스파이크 등으로 JVM 응답이 지연되면 실행 타이밍 밀림
분산 서버 환경에서 동기화 누락 스케줄러 실행이 한 서버에만 정의되어 있고 해당 서버가 죽었을 때 백업 실행이 없음

비동기 타이밍 이슈란?

  • 예시 상황:
    1. 게시글 공개 시간이 10:00:00
    2. 10:00:00에 스케줄러가 해당 게시글을 CLOSED로 변경 중
    3. 동시에 어떤 사용자가 게시글 상세조회 API를 호출함
    4. DB에 아직 status = OPEN으로 남아 있어 사용자에게 노출됨
    5. 이후 스케줄러 완료 → 상태 CLOSED 됨

즉, 스케줄러 실행 직전/직후에 동시에 접근하는 사용자에게 일관성 문제 발생


비동기 타이밍 이슈 방지 방법

방법 1. 조회 시점에 추가 검증 로직

if (LocalDateTime.now().isAfter(board.getOpenTime())) {
    board.setStatus(BoardStatus.CLOSED);
    // 또는 throw new CustomException(CLOSED_BOARD);
}

→ 스케줄러와 별개로 안전망 역할. 스케줄러는 주기적으로 정리하는 역할로만 사용.

방법 2. 트랜잭션 경합 제어 (낙관적/비관적 락)

  • 동시 접근 시 @Version 또는 DB 락 사용
  • 다만 조회 로직엔 성능 이슈가 있으므로 일반적으론 추천 X

방법 3. 읽기 전용 캐시 사용

  • 게시글 리스트나 메인에 노출되는 정보는 캐시에 저장하고, 일정 주기로만 갱신
  • 공개 시간과 상태 불일치가 발생하더라도, 캐시가 갱신되면 해결됨
  • 유저 경험 기준 일관성 유지 가능

방법 4. 분산 스케줄링 체계 도입 (예: Quartz + DB Job 관리)

  • 단일 서버에 의존하지 않고 DB에 Job 등록
  • Job 상태 추적 가능
  • 실행 보장성 높음

결론

목적 권장 방식

일관성 최우선 조회 시점 검증 추가
성능과 간결성 우선 스케줄러 + 정기 정리
높은 트래픽, 멀티 서버 분산 스케줄러 + 캐시 + 이중검증

실무에서는 스케줄러와 실시간 검증 로직이 함께 있어야 안전


방법1. 기존 방식대로

  • type - N, O
  • status - open, closed, fcfs, timeattack

오픈런 게시글 생성할 때

case 1. 타임어택 게시글

  • type - O
  • status - timeattack

case 2. 선착순 게시글

  • type - O
  • status - fcfs

오픈런 게시글 open 할 때

status를 open으로 변경하면 추후에 타임어택, 선착순 게시글을 구분 못함

→ 게시글 조회할 때부터 open, fcfs, timeattack일 때의 조회 로직 각각 사용해서 조회해야 함

오픈런 게시글 close 할 때

case 1. 선착순 게시글

→ 순위 비교하는 로직에서 redistemplate에 담긴 boardId로 직접 DB에서 해당 게시글 close 가능

⇒ close하면 선착순 순위에 든 유저도 더이상 게시글에 접근이 불가능하므로 상태값 close하지 않고 그대로 유지

case 2. 타임어택 게시글

→ 스케줄러로 opentime + openlimit과 현재 시간 비교 필요

장점

open할 때 스케줄러 사용 안 함

단점

open할 때 각 게시글 타입별로 조회 로직을 따로 구현 필요


방법2. 선착순, 타임어택 게시글의 type(enum) 분리

  • type - N, O_FCFS, O_TIMEATTACK
  • status - open, closed

오픈런 게시글 생성할 때

case 1. 타임어택 게시글

  • type - O_TIMEATTACK
  • status - closed

case 2. 선착순 게시글

  • type - O_FCFS
  • status - closed

오픈런 게시글 open 할 때

스케줄러로 opentime이 현재시각인 게시글의 status를 open으로 변경

오픈런 게시글 close 할 때

동일함

장점

open하는 로직이 간단함

단점

open할때 스케줄러 사용


+06/17

기존 방식대로 하되, status라는 네이밍이 enum에 대해 적절하지 않아보여서 네이밍을 status가 아니라 accessPolicy로 변경함

  1. 메서드 이름으로 쿼리 표현이 가능하면 JPA 메서드 사용
    • ex. findById …
  2. 조건식이 복잡해서 메서드로 표현하기 어렵거나 동적 쿼리가 필요한 경우
    • queryDSL or JPQL
    • queryDSL은 컴파일이 가능하기 때문에 코드 안정성을 위해 queryDSL 권장
  3. 자바 쿼리에서 사용하는 연산을 SQL 함수로 변환하기 어려운 경우는 native query 사용
    • JPQL/QueryDSL은 자바 코드와 DB 쿼리 사이의 추상화 계층
    • → 자바 내부 연산을 DB에서 이해할 수 있는 SQL 함수나 표현식으로 변환하는 표현식 변환 기능이 제한적

이번 프로젝트에서 적용한 사례

@Modifying(clearAutomatically = true)
@Query("UPDATE User u SET u.postingCount = 0")
  • 조건문 없이, 전체 레코드를 update하는 쿼리라서 컴파일이 중요하지 않다고 판단하고 간편하게 JPQL 사용함
@Modifying(clearAutomatically = true)
@Query(value = """
		    UPDATE board
				SET status = 'CLOSED'
		    WHERE status = 'TIMEATTACK'
		    AND DATE_ADD(open_time, INTERVAL open_limit MINUTE) <= NOW()
		""", nativeQuery = true)
  • 자바의 날짜 연산을 SQL 함수나 표현식으로 변환하기에는 어렵다고 판단하여 JPQL/queryDSL을 사용하지 않고 native query 사용
    • JPQL이나 queryDSL을 사용하려면
    • TIMEATTACK에 해당하는 데이터를 모두 조회 → 자바 코드로 날짜연산 → 연산한 값을 활용한 update 쿼리 생성
    • ⇒ 결국 DB 접근이 2번 발생

AND DATE_ADD(open_time, INTERVAL open_limit MINUTE) <= NOW()


+06/16 추가

튜터님 피드백

entity와 그와 관계된 단순한 조회일 경우 → JPA

그 외의 JPQL이 필요한 쿼리 → QueryDSL이나 jooq 사용

통계 등의 집약적인 로직이 필요한 쿼리 → mapper나 jooq 사용

JOOQ?

Java Object Oriented Querying 자바 코드 기반 SQL 빌더

  • 타입 안정성 : SQL을 자바 코드로 작성함으로써 컴파일 시점에 문법 오류 확인 가능
  • SQL 지향적 : JPQL이 아닌 SQL을 자바 코드로 표현
  • 복잡한 쿼리에 유리 : 서브쿼리, 윈도우 함수, 집계 등 복잡한 쿼리 작성 용이
  • 코드 자동 생성 : DB 스키마 기반으로 테이블/컬럼 클래스 자동 생성
  • Native 성능 접근 : DB 벤더별 고급 기능 접근 가능

⇒ native query, JPQL은 컴파일 시점에 문법 오류 확인이 불가능하지만, JOOQ는 자바 코드로 SQL 쿼리를 표현하여 컴파일 시점에 문법 오류 확인이 가능하면서도 기존의 native query의 장점인 복잡한 쿼리 구현이 가능하다.

DSLContext dsl = DSL.using(configuration);
Result<Record2<String, Integer>> result = dsl
    .select(USER.NAME, DSL.count(ORDER.ID))
    .from(USER)
    .leftJoin(ORDER).on(USER.ID.eq(ORDER.USER_ID))
    .groupBy(USER.NAME)
    .fetch();

총정리

native query

  • SQL 쿼리 사용
  • SQL의 모든 함수 사용 가능
  • 복잡한 쿼리 표현 용이함
  • 따라서 특정 DB에 종속적
  • 컴파일 시점에 문법 오류 확인 불가
  • DB 레벨에서 쿼리를 실행하므로 DB에 책임 위임

Mapper (MyBatis)

  • XML 또는 어노테이션에 SQL 문자열 생성
  • DB 종속적

JPA 메서드

  • JPA, Hibernate를 통해 메서드 이름으로 DB 접근 가능
  • 간단한 조회/삭제/저장만 가능

JPQL

  • JPA 위에서 동작
  • JPA가 자바 코드를 SQL로 변환
  • 추상화된 객체 중심 쿼리(즉 쿼리 표현에 한계 있을 수 있음)
  • 자바 엔티티, 필드를 파라미터로 받아, SQL 쿼리에 사용 가능

QueryDSL (JPQL의 타입 안정성 보완 - 객체 중심)

  • 동적 쿼리를 생성하는데에 최적화
  • JPQL 빌더
  • 추상화된 객체 중심 쿼리(즉 쿼리 표현에 한계 있을 수 있음)
  • 자바 코드로 쿼리 생성
  • Q클래스를 통해 엔티티 기반의 쿼리 생성 가능
  • function화를 통해 재사용성 향상
  • 컴파일 단계에서 문법 오류 확인 가능
  • DB에 존재하는 모든 값들이 채워지지 않았을 경우 Null 값으로 치환됨 (ex. dto를 그대로 사용하면 내부의 데이터는 모두 null로 들어감)

JOOQ (SQL의 자바 표현 - 테이블/컬럼 중심)

  • JPQL과 다르게 완전한 SQL 기반 동작 (JDBC 위에서 동작)
  • 추상화된 SQL 문법
  • JPA 영속성 컨텍스트를 사용하지 않으므로 사용 즉시 DB 반영(스프링에서는 트랜잭션으로 묶어주도록 하는게 보통)
  • SQL 문법을 완벽히 사용할 수 있음
  • 따라서 DB 종속적
  • native query처럼 SQL 문법을 그대로 사용하기 때문에 복잡한 쿼리 표현 가능
  • 자바 코드로 쿼리 생성
  • 컴파일 단계에서 문법 오류 확인 가능(SQL 레벨에서 컴파일 타임 검증)
  • Mapper와는 다르게 통제된 상태에서 SQL 쿼리 생성(테이블 스키마, 컬럼을 기반으로 자바 클래스를 자동 생성해서 제공)

⇒ 집약적 로직으로 인해 SQL 사용이 필수적일 때, XML이 아닌 IDE를 통해 컴파일 타임에 검증 가능한 코드로 작성하는 방식

동일한 쿼리를 QueryDSL, JOOQ로 각각 표현

// Q클래스: QUser user = QUser.user;

public void updateUserName(Long id, String name) {
    queryFactory.update(QUser.user)
        .set(QUser.user.name, name)
        .where(QUser.user.id.eq(id))
        .execute();
}
// Jooq DSLContext 주입받은 경우
@Autowired
private DSLContext dsl;

public void updateUserName(Long id, String name) {
    dsl.update(USER) // USER는 Jooq가 생성한 테이블 객체
        .set(USER.NAME, name)
        .where(USER.ID.eq(id))
        .execute();
}

 

 + 

 

→ postgresql로 바꾸니까 위 메서드 제대로 동작 안 함!

jooq는 dialect에 민감

DATE_ADD(..., INTERVAL ...) 문법은 MySQL 전용

sql 쿼리에 직접 의존하면 생기는 불편함 체험함

  • Bulk Operation은 일반적으로 다수의 레코드를 한 번에 수정하거나 삭제하는 작업을 의미
  • 이는 SQL, JDBC, MyBatis, JPA, Hibernate 등 모든 데이터 액세스 기술 전반에서 쓰이는 일반적인 용어

JPA에서 Bulk Update/Delete가 특별히 언급되는 이유

JPA는 일반적으로 엔티티를 조회 → 변경 → 저장하는 방식으로 동작한다.

하지만 Bulk 쿼리는 이 과정을 건너뛰고 직접 DB에 UPDATE/DELETE 쿼리를 날림

반복 루프를 수행하지 않고도 다수의 데이터를 update/delete 할 수 있다.

주의

  • JPA는 select 쿼리만 실행하도록 설계되어서, update 쿼리를 실행시키려면 @Modifying 어노테이션을 붙여줘야 런타임에러가 발생하지 않는다.
  • 영속성 컨텍스트의 캐시를 무시하고 DB에 직접 쿼리를 실행하기 때문에 영속성 컨텍스트(캐시)와 DB의 데이터가 일치하지 않을 수 있으므로, 직접 em.clear()를 실행하거나, clearAutomatically = true 설정을 적용한다.
@Modifying(clearAutomatically = true)
@Query("UPDATE User u SET u.count = 0") // update 쿼리 한 번 발생
void resetUserCount();