도메인, 도메인 모델?

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


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



도메인 모델 패턴

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



표현 영역 : 사용자(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)를 명확히 지키기 위함

기본적으로 엔티티의 특정 필드만 값이 수정되어도 전체 필드에 대한 update 쿼리가 날라간다.

@DynamicUpdate를 사용하면 변경된 필드에 한해서만 update 쿼리가 발생한다.

그렇다면 항상 @DynamicUpdate를 사용하는 게 무조건적으로 옳을까?

 

JDBC로 쿼리를 생성할 때 preparedStatement는 ? 위치에 들어갈 값을 동적으로 할당한다.

마찬가지로 @DynamicUpdate는 어떤 필드가 변경되었는지를 실행 시점에 판단하고 동적으로 쿼리를 생성한다.

 

하지만 JPA는 애플리케이션 시작 시점에 JPQL/SQL을 컴파일하고 캐시한다.

예를 들어, 엔티티의 전체 필드를 update하는 쿼리를 항상 같기 때문에 SQL 문장을 캐싱해서 재사용한다.

 

따라서 @DynamicUpdate를 사용하면 JPA는 쿼리를 매번 새로 생성해야 하기 때문에 캐시의 이점이 줄어든다.

 


 

그렇다면 어떤 상황에서 @DynamicUpdate 어노테이션을 사용하면 좋을까?

1. 컬럼 수가 많을 때

하나의 컬럼만 변경되어도 모든 컬럼에 대해 update 쿼리가 발생하기 때문에, 컬럼이 너무 많으면 @DynamicUpdate를 사용하는 것이 좋다.

 

2. 인덱스 컬럼이 많을 때

인덱스 컬럼에 대해 update 쿼리가 발생하면, 실제 값이 변경되지 않아도 인덱스 재정렬이 발생한다.

@DynamicUpdate는 실제 바뀐 값만 update하기 때문에 인덱스 재정렬을 최소화할 수 있다.

 

3. 컬럼 단위 락이나 버저닝을 지원하는 DB를 쓸 때

YugabyteDB 같은 분산형 DB는 컬럼 단위 락을 지원한다.

이 경우 @DynamicUpdate는 컬럼 충돌 방지 및 성능 최적화에 실질적 효과가 있다.

(다만 MySQL, PostgreSQL, Oracle 등 대중적인 RDBMS는 해당 기능을 지원하지 않음)

 

 

 

참고자료

https://multifrontgarden.tistory.com/299

트러블슈팅 1: 소켓 연결 종료 시 대기열에서 사용자 제거

문제

좌석 선택 시스템에서 사용자의 소켓 연결이 끊겨도 대기열(ZSet)에는 해당 사용자의 데이터가 남아 있어, 다음 사용자로의 진행이 지연됨. TTL 만료를 기다리는 동안 좌석 선택이 진행되지 않아 서비스 흐름에 문제가 발생.

원인

  • Redis 대기열(ZSet)은 TTL 기반으로 일정 시간이 지나야 자동 제거됨.
  • 소켓 연결이 끊긴 사용자를 서버에서 즉시 인식하지 못함.
  • 사용자 정보를 알 수 없으니, Redis에서 해당 사용자를 직접 제거하는 것도 불가능.

해결

WebSocket 연결 시 Interceptor에서 사용자 정보를 attributes에 저장하고, DisconnectEvent에서 해당 정보를 추출해 Redis에서 제거

  1. WebSocketInterceptor 구현:
    • Handshake 시 tokenscheduleId를 attributes에 저장
    • token을 통해 username을 추출 (직접 username 전달 불필요)
  2. WebSocketDisconnectListener 구현:
    • Disconnect 이벤트 발생 시 attributes에서 usernamescheduleId 추출
    • 해당 정보를 이용해 Redis 대기열에서 사용자 제거
  3. 보안 측면 고려:
    • 민감한 정보인 token은 URL이 아닌 WebSocket 헤더에 포함하도록 변경
    • HTTP 환경이므로 URL에 포함하는 것보다 헤더 사용이 더 안전

이 구조를 통해 연결 종료 시 실시간으로 대기열 정리가 가능해지고, 다음 순위 사용자가 즉시 좌석 선택 단계로 넘어갈 수 있게 됨.


트러블슈팅 2: 좌석 선택 가능한 시점과 TTL 만료 시점 불일치 문제

문제

인기 공연의 경우, 대기열에만 오래 머무르다가 TTL 만료로 좌석 선택 기회를 얻지 못하는 상황 발생.

예를 들어, 사용자가 15분 동안 대기하다가 겨우 순서가 되었는데, TTL이 만료되어 Redis에서 자동 삭제됨 → 좌석 선택 불가.

원인

  • 기존 로직에서는 모든 유저가 하나의 대기열 큐(ZSet)에 포함됨.
  • 좌석 선택 가능 여부는 ZSet 순위와 수용 가능 인원 비교로 판단.

해결

“대기열 큐”와 “좌석 선택 큐”를 분리하여 TTL을 좌석 선택 시점부터 관리

  1. ZSet 2개 사용
    • 대기열 큐 (waitingQueue) → 대기번호 관리
    • 좌석 선택 큐 (selectingQueue) → TTL 관리
  2. 사용자 순서가 되면
    • 대기열 큐에서 사용자 제거
    • 좌석 선택 큐에 사용자 추가 + TTL 설정 (예: 15분)
  3. 검증 방식 변경
    • 기존: 대기열 큐에서 rank 비교로 좌석 선택 가능 여부 판단
    • 변경: 좌석 선택 큐에 사용자가 존재하는지만 검사

이로써 TTL은 좌석 선택을 시작한 시점부터 적용되고, 불합리한 만료 문제 없이 사용자 흐름이 자연스럽게 유지됨.

 


 

사용 기술

- WebSocket STOMP

- Redis

 

구현 목록

- WebSocketConfig

- HandShakeInterceptor

- DisconnectEventListener

- QueueController

- QueueService

 


build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-websocket'

 

application.yml

spring:
  application:
    name: siljeun
  data:
    redis:
      host: localhost
      port: 6379

 

WebSocketConfig

클라이언트 <-> 서버 간 통신에 사용되는 엔드포인트 설정

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

  private final JwtHandShakeInterceptor jwtHandShakeInterceptor;

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/ws")
        .addInterceptors(jwtHandShakeInterceptor)
        .setAllowedOriginPatterns("*");
  }

  @Override
  public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker("/topic");
    registry.setApplicationDestinationPrefixes("/app");
  }
}
더보기

@EnableWebSocketMessageBroker

- STOMP 기반 WebSocket 메시지 브로커 활성화

 

implements WebSocketMessageBrokerConfigurer

- WebSocket 설정 커스터마이징 (엔드포인트 등록, 브로커 설정)

 

@Override

public void registerStompEndpoints(StompEndpointRegistry registry){}

- 클라이언트가 WebSocket에 접속할 수 있는 엔드포인트 경로 설정

- 클라이언트는 여기에 등록한 경로를 url에 입력하여 소켓 연결 요청

- `ws://localhost:8080/ws`

 

.setAllowedOriginPatterns("*");

- WebSocket 통신 시 CORS(Cross-Origin Resource Sharing) 정책을 설정하는 메서드

- CORS : 다른 출처(Origin)의 웹 애플리케이션이 자원을 요청할 때 브라우저가 이를 제한하는 보안 정책

- *로 설정하면 모든 인증되지 않은 도메인에서 접근 가능

- 실서비스에서는 아래처럼 사용

.setAllowedOriginPatterns("https://your-frontend.com", "https://admin.your-frontend.com")

 

@Override

public void configureMessageBroker(MessageBrokerRegistry registry){}

- 메시지 브로커 설정 메서드

- 서버 <-> 클라이언트 간의 메시지 라우팅 경로 구성

 

registry.enableSimpleBroker("/topic");

- 내장 메시지 브로커 활성화

- 서버가 클라이언트로 메시지를 publish(발행)할 때 사용하는 경로 prefix

- 메시지 destination : `/topic/queue/schedule1/user1`

- 위 경로로 특정 구독자에게 메시지 전달 가능(scheduleId가 schedule1인 대기열의 user1에게 메시지 전달)

 

registry.setApplicationDestinationPrefixes("/app");

- 클라이언트가 서버로 메시지를 send(전송)할 때 사용하는 경로 prefix

- 메시지 destination : `/app/addQueue`

- 서버는 @MessageMapping("/addQueue")로 메시지 수신 가능

 

HandShakeInterceptor

- 클라이언트가 소켓 연결을 요청하면, 요청한 사용자의 토큰 검증 & 티켓팅하려는 schedule(공연 회차)의 유효성 검증

- 서버에 전달할 데이터를 attributes에 저장하여, 서버에서 어느 시점이든 꺼내쓸 수 있도록 처리

WebSocket 세션은 쿠키나 세션으로 상태를 유지하는 HTTP와 달리 상태가 없기 때문에 attributes로 상태를 유지할 수 있다. 

@Component
@RequiredArgsConstructor
public class JwtHandShakeInterceptor implements HandshakeInterceptor {

  private final JwtUtil jwtUtil;
  private final ScheduleRepository scheduleRepository;

  @Override
  public boolean beforeHandshake(
      ServerHttpRequest request,
      ServerHttpResponse response,
      WebSocketHandler wsHandler,
      Map<String, Object> attributes) throws Exception {

    HttpHeaders headers = request.getHeaders();
    URI uri = request.getURI();
    MultiValueMap<String, String> params = UriComponentsBuilder.fromUri(uri).build()
        .getQueryParams();

    String token = headers.getFirst("Authorization");
    String scheduleId = params.getFirst("scheduleId");

    if (!jwtUtil.validateToken(token)) {
      return false;
    }

    if (StringUtils.isBlank(scheduleId) || !scheduleRepository.existsById(
        Long.valueOf(scheduleId))) {
      return false;
    }

    String username = jwtUtil.getUsername(token);
    attributes.put("username", username);
    attributes.put("scheduleId", scheduleId);

    return true;
  }

  @Override
  public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
      WebSocketHandler wsHandler, Exception exception) {

  }
}
더보기

implements HandshakeInterceptor

- Spring WebSocket에서 제공하는 인터셉터 인터페이스로, 웹소켓 연결 초기 handshake 과정에서 동작

 

@Override
public boolean beforeHandshake(){}

- 웹소켓 연결 요청이 들어왔을 때 가장 먼저 실행

- 내부 로직에서 반환값이 false이면 연결 거부

- attributes는 WebSocket 세션에 저장되어 이후 @MessageMapping에서 참조 가능

 

@Override
public void afterHandshake(){}

- handshake 끝난 후 호출

 

DisconnectEventListener

- 소켓 연결이 끊어졌을 때 해당 사용자를 큐에서 제거 (다음 사용자가 TTL을 기다리지 않고 바로 대기를 끝낼 수 있도록)

- 인터셉터에서 attributes에 저장한 데이터를 꺼내서 사용함으로써, 소켓 연결이 끊어진 시점에서도 어떤 사용자와의 연결이 끊어졌는지 파악 가능

@Component
@RequiredArgsConstructor
public class StompDisconnectEventListener {

  private final WaitingQueueService waitingQueueService;

  @EventListener
  public void handleDisconnect(SessionDisconnectEvent event) {
    StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());

    String username = (String) accessor.getSessionAttributes().get("username");
    Long scheduleId = Long.valueOf((String) accessor.getSessionAttributes().get("scheduleId"));

    if (username != null && scheduleId != null) {
      waitingQueueService.deleteWaitingUser(scheduleId, username);
      waitingQueueService.deleteSelectingUser(scheduleId, username);
    }
  }
}
더보기

@EventListener

public void handleDisconnect(SessionDisconnectEvent event) {}

- @EventListener 어노테이션으로 SessionDisconnectEvent가 감지될 때 메서드를 자동 호출

- STOMP 연결을 담당하는 StompSubProtocolHandler는 WebSocketSession이 종료되면 SessionDisconnectEvent를 생성해 Spring에 발행

- WebSocketSession이 종료되는 경우는 클라이언트가 창을 닫거나, 네트워크가 끊기는 경우에 해당

 

StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());

- 이벤트 객체에서 STOMP 메시지를 꺼내고, 헤더나 세션 정보를 쉽게 추출할 수 있도록 도와주는 유틸

- StompHeaderAccessor를 통해 소켓 연결 시에 데이터를 저장했던 attributes에 쉽게 접근할 수 있다.

 

StompHeaderAccessor를 사용하지 않고 attributes에서 데이터를 추출하려면 아래와 같은 복잡한 과정이 필요하다.

코드 길이는 비슷해보이지만 simpSessionAttributes와 같은 헤더 키를 알아야 하고, 형변환이 복잡하다는 단점이 있다.

MessageHeaders headers = event.getMessage().getHeaders();
Map<String, Object> sessionAttributes = (Map<String, Object>) headers.get("simpSessionAttributes");
String username = (String) sessionAttributes.get("username");

 

QueueController

- 소켓 연결 후 메시지 수신할 때 사용하는 컨트롤러

@RestController
@RequiredArgsConstructor
public class WaitingQueueController {

  private final WaitingQueueService waitingQueueService;

  @MessageMapping("/addQueue")
  public void addQueue(Message<?> message) {
    SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.wrap(message);

    String username = (String) accessor.getSessionAttributes().get("username");
    Long scheduleId = Long.valueOf((String) accessor.getSessionAttributes().get("scheduleId"));
    
    waitingQueueService.addWaitingQueue(scheduleId, username);
    System.out.println("연결 성공");
  }
}
더보기

@MessageMapping("/addQueue")

- 클라이언트가 `/app/addQueue` 경로로 STOMP 메시지를 보내면 메서드가 실행된다.(HTTP 프로토콜과 유사한 방식)

 

public void addQueue(Message<?> message) {}

- 메시지의 헤더 정보를 활용하기 위해 Message<?> message 타입으로 파라미터를 전달받는다.

- Message<?>는 body뿐만 아니라 세션과 헤더의 전체 정보를 감싼 래퍼 객체이고, 파라미터에 이렇게 타입을 명시해주면 스프링이 내부에서 Message<?> 객체로 감싸서 전달해준다.

- 만약 addQueue 메서드 안에서 세션 정보(attributes) 필요없이 메시지 본문만 필요하다면 아래와 같은 방식으로 파라미터를 전달받을 수 있다.

@MessageMapping("/reserveSeat")
public void reserveSeat(@Payload SeatRequest seatRequest) {
    // 메시지 본문(JSON)을 자동으로 매핑해줌
}

 

QueueService_기본 틀

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class WaitingQueueService {

  private final StringRedisTemplate redisTemplate;
  private final SimpMessagingTemplate messagingTemplate;
  private final ScheduleRepository scheduleRepository;

  private static final long ttlMillis = 900000L; // ttl 15분
  private static final long acceptedRank = 1000L; // 좌석 선택 최대 수용 인원 1000명
  public static final String prefixKeyForWaitingQueue = "waiting:schedule:";
  public static final String prefixKeyForSelecingQueue = "selecting:schedule:";
  
  ...

}
더보기

StringRedisTemplate

  • RedisTemplate<String, String>의 구현체
  • Redis에 문자열(key-value) 기반으로 데이터를 저장/조회할 때 사용
  • 내부적으로 StringSerializer를 기본으로 사용하여 데이터를 직렬화/역직렬화하지 않고 문자 그대로 처리하므로, 모든 key와 value가 String 타입인 경우 사용

SimpMessagingTemplate

  • Spring에서 서버 → 클라이언트(STOMP) 로 메시지를 보낼 때 사용하는 템플릿 클래스
  • STOMP 주소로 클라이언트에게 메시지 전송
messagingTemplate.convertAndSend("/topic/room/" + roomId, messageDto);

 

QueueService_Redis 연결 확인

  @PostConstruct
  public void testRedisConnection() {
    String pong = redisTemplate.getConnectionFactory().getConnection().ping();
    log.info("Redis 연결 상태: {}", pong);
  }

 

QueueService_addQueue

컨트롤러에서 호출하는 메서드로, 파라미터로 전달받은 사용자를 대기열에 저장

// 예매 대기 시작
public void addWaitingQueue(Long scheduleId, String username) {
  ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();

  Schedule schedule = scheduleRepository.findById(scheduleId)
      .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_SCHEDULE));

  if (LocalDateTime.now().isBefore(schedule.getTicketingStartTime())) {
    throw new CustomException(ErrorCode.NOT_TICKETING_TIME);
  }

  String key = prefixKeyForWaitingQueue + scheduleId;
  long createdAt = System.currentTimeMillis();

  if (zSet.score(key, username) == null) {
    zSet.add(key, username, createdAt);
  }

  sendWaitingNumber(key, username, scheduleId);
}
더보기

ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();

- Redis의 Sorted Set(ZSet) 자료구조를 다루기 위한 객체 생성

- ZSet은 (value, score) 형태로 저장되며, score에 따라 자동 정렬

 

String key = prefixKeyForWaitingQueue + scheduleId;

- (key, value)를 (waiting:schedule:1, username)으로 설정하여, scheduleId가 1인 공연의 대기열임을 key로 나타낸다.

 

if (zSet.score(key, username) == null) {
  zSet.add(key, username, createdAt);
}

- zSet.score(key, username)이 null이면 아직 등록되지 않은 상태, 즉 대기열에 들어온 적 없는 사용자이므로 이 경우에만 대기열에 등록하는 로직인 zSet.add()를 수행

- zSet에 등록할 때는 key, value, score를 파라미터로 데이터를 넣어준다.

- 입장 요청 시각(createdAt)을 score로 저장해 대기 순번 관리에 활용

 

 

QueueService_deleteUser

파라미터로 전달받은 사용자를 대기열에서 삭제

// 대기 끝 or 소켓 연결 해제되면 대기열에서 삭제
  public void deleteWaitingUser(Long scheduleId, String username) {
    String key = prefixKeyForWaitingQueue + scheduleId;
    redisTemplate.opsForZSet().remove(key, username);
  }
더보기

redisTemplate.opsForZSet().remove(key, username);

- 해당 ZSet의 key에 저장된 값 중 username을 삭제

 

QueueService_sendWaitingNumber

// 대기중인 특정 사용자에게 랭킹 및 대기번호 전송
  public void sendWaitingNumber(String key, String username, Long scheduleId) {
    ZSetOperations<String, String> zSet = redisTemplate.opsForZSet();

    Long rank = zSet.rank(key, username);

    if (rank == null) {
      throw new CustomException(ErrorCode.QUEUE_INSERT_FAIL);
    }

    rank = rank + 1;

    // 내 순위와 현재 좌석 선택 중인 사용자 수의 합이 수용 인원보다 적으면 대기 X
    Long selectingQueueSize = zSet.size(prefixKeyForSelecingQueue + scheduleId);
    selectingQueueSize = (selectingQueueSize == null) ? 0 : selectingQueueSize;

    if (rank + selectingQueueSize <= acceptedRank) {
      String destination = "/topic/queue/" + scheduleId + "/" + username;
      MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank,
          true);
      messagingTemplate.convertAndSend(destination, response);

      addSelectingQueue(scheduleId, username);
      deleteWaitingUser(scheduleId, username);

      return;
    }

    String destination = "/topic/queue/" + scheduleId + "/" + username;
    MyQueueInfoResponse response = new MyQueueInfoResponse(scheduleId, username, rank,
        false);
    messagingTemplate.convertAndSend(destination, response);
  }
더보기

Long rank = zSet.rank(key, username);

- key에 저장된 username의 순위(0부터 시작) 조회

 

Long selectingQueueSize = zSet.size(prefixKeyForSelecingQueue + scheduleId);

- ZSet에 몇 명(몇 개의 데이터)가 있는지 반환하는 함수

 

String destination = "/topic/queue/" + scheduleId + "/" + username;

- 서버에서 메시지를 보낼 목적지 경로 설정

 

messagingTemplate.convertAndSend(destination, response);

- destination에 response 전송

 

스프링 환경에서 웹소켓을 사용할 때 Spring WebSocket, SockJS, STOMP 등 기술을 사용할 수 있다.

이 세 가지 기술은 계층별로 목적이 다르고 상황에 따라 조합하여 사용할 수 있다.

 

1. Spring WebSocket

WebSocket만으로 충분할 때 쓰는 최소 구성

항목 설명
목적 WebSocket 프로토콜 자체를 지원하는 Spring의 기본 모듈
전송 방식 순수 WebSocket (기본 연결)
장점 단순하고 빠르며, STOMP 없이도 사용 가능
단점 클라이언트 구현이 복잡할 수 있음 (JS에서 직접 send/receive 다룸)
사용 예 클라이언트와 서버 간에 JSON 메시지를 직접 처리할 때

 

2. SockJS

WebSocket이 실패할 경우를 대비한 백업 수단

항목 설명
목적 WebSocket이 불가능한 환경(브라우저, 방화벽 등)을 보완하는 폴백(fallback) 라이브러리
전송 방식 WebSocket → XHR-streaming → Long polling 등으로 자동 대체
장점 브라우저 호환성 강화, 네트워크 환경 불량 시도
단점 서버와 클라이언트 모두 SockJS 프로토콜에 맞게 구성 필요
사용 예 IE, 구형 브라우저, 보안 장비가 WebSocket을 막을 수 있는 공공/금융 시스템 등

 

3. STOMP (Simple Text Oriented Messaging Protocol)

WebSocket에 채널 구조메시징 기능을 더한 고급 메시징 프로토콜

항목 설명
목적 WebSocket 위에서 동작하는 고수준 메시징 프로토콜 (메시지 주제 기반 publish/subscribe)
특징 /topic, /queue, 구독/발행 모델, 헤더, Ack 지원
장점 구조적 메시징 가능 (채널, 인증, 사용자별 큐 등), 대규모 시스템에 적합
단점 복잡도 증가 (프레임 구조, 클라이언트 지원 필요)
사용 예 실시간 채팅, 알림, 대기열 번호 전파 등 publish/subscribe 패턴 기반 기능

 

ex. 대기열 기능은 서버에서 클라이언트로 응답메세지를 보낸 이후에도 대기번호를 실시간으로 전송해줘야 하기 때문에(지속적인 연결 필요) pub/sub 기능을 갖춘 STOMP 방식이 적합

 

STOMP를 사용하는 이유

이유 설명
1. 메시지 구조화 및 구분 WebSocket은 기본적으로 단순한 텍스트/바이너리 전송만 제공함 → STOMP는 메시지를 헤더 + 바디 구조로 보내며, 이벤트 유형, 목적지, 구독 채널 등을 명확히 구분 가능
2. subscribe/send 등 고수준 명령 지원 단순 문자열 대신 SUBSCRIBE, SEND, ACK, DISCONNECT 등의 표준화된 명령을 사용 가능함
3. 여러 채널 구독 지원 클라이언트가 다양한 목적지(/topic, /queue)에 메시지를 보내고, 여러 채널을 동시에 구독할 수 있음
4. 서버 메시지 라우팅이 쉬워짐 Spring에서 @MessageMapping, @SendTo, @SubscribeMapping 등을 통해 라우팅과 응답이 쉬움
5. ACK/NACK 기능 (신뢰성 향상) 메시지를 받았는지 확인하는 ACK 기능으로 전송 성공 여부 추적 가능
6. Spring과의 통합성 Spring WebSocket 모듈은 STOMP를 내장 지원함. 설정이 명확하고, 메서드 단위 메시지 핸들링이 가능함.

 

즉, Redis Pub/Sub은 브로드캐스트지만, STOMP는 수신 대상을 쉽게 명시할 수 있다.

Redis란?

Remote Dictionary Server의 약자로, 인메모리 데이터 저장소이다.

주로 캐시, 세션 저장소, 메시지 브로커 등으로 사용된다.

 

- In-memory 기반

데이터를 메모리에 저장하여 매우 빠른 읽기/쓰기 성능을 제공한다.

 

- Key-Value 구조

데이터를 key와 다양한 value 타입(String, List, Set, Sorted Set, Hash 등)으로 저장한다.

 

- Persistence 지원

메모리 기반이지만 데이터 복구용으로 AOF(Append Only File), RDB 방식으로 디스크에 저장할 수 있다.

 

- Atomic 연산

명령어 단위로 원자성을 보장하여 동시성 문제를 해결한다.

 

- 다양한 데이터 구조 지원

일반적인 Key-Value 외에, List, Set, Hash, Sorted Set, Bitmaps, HyperLogLogs, Streams 등을 제공한다.

 

- 분산 처리 지원 (Cluster 모드)

데이터 샤딩과 복제를 통해 수평 확장이 가능하다.

 

- TTL(Time To Live)

Key에 유효시간 설정이 가능하여 캐시로 사용하기 유용하다.

 

사용 예시

  • 세션 저장소 : 로그인 정보 임시 저장
  • 캐싱 시스템 : 자주 조회되는 데이터 캐싱
  • 메시지 큐 : pub/sub 기능을 활용하여 간단한 메시지 브로커로 활용
  • 순위 시스템 : Sorted Set 구조를 이용하여 랭킹 구현
  • Rate Limiting : 사용자별 요청 횟수 제한

메시지 브로커란 ? (+ 메시지 큐)

 


Redis와 Docker

Redis와 Docker는 별개의 기술이지만 Redis를 사용하는 경우를 보면 주로 Docker를 함께 사용하는 걸 볼 수 있어서 둘의 관계를 정리하게 되었다.

Redis는 인메모리 데이터 저장소이고, Docker는 컨테이너 기반 가상화 플랫폼으로 애플리케이션의 배포/실행 환경을 제공한다.

Docker위에 Redis 컨테이너를 올려 빠르게 실행하고 관리할 수 있다.

Docker는 Redis 관련 명령어를 쉽게 실행할 수 있고, 멀티 환경 테스트, 버전 관리, 설정 격리 등 실무 시나리오에 적합하다.

 

예를 들어, Redis만 사용하면 한 번에 하나에 프로젝트만 실행할 수 있지만, Docker와 Redis를 함께 실행하면 한 번에 여러 프로젝트에 대해 Redis를 실행시킬 수 있다.

그리고 각 프로젝트(컨테이너)에 대해 각각 다른 버전으로 Redis를 관리할 수 있다.

 

Redis를 Docker와 사용할 때, AWS와 사용할 때 어떤 차이가 있을까?

Docker + Redis 조합은 주로 개발, 테스트 목적이고,

AWS + Redis 조합은 주로 운영, 서비스 목적이다.

구분 Docker + Redis AWS + Redis (ElastiCache)
주 사용 목적 로컬 개발, 기능 테스트, 학습 실서비스 운영, 고가용성 캐시
사용 환경 로컬 PC or 임시 서버 클라우드 서비스 환경
설치 방식 도커 컨테이너로 간단히 실행 AWS 콘솔 or CLI로 생성
가용성 / 장애 복구 직접 설정 필요 (거의 없음) 자동 장애 조치(Failover), 백업
보안 설정 본인이 직접 설정 (방화벽, 인증 등) VPC, IAM, TLS 등 보안 내장
비용 무료 (개인 PC) 사용량 기반 과금 (유료)
운영 관리 본인 직접 관리 AWS가 운영 관리 (Managed Service)

 


Redis를 선택한 이유

진행 중인 프로젝트에서 서비스를 요청하는 사용자들이 몰려 대용량 트래픽이 발생하는 상황을 대비해 메시지 큐를 이용하여 수용 인원 이상의 사용자를 대기시키는 기능을 구현하고자 했다.

 

이때 메시지 큐 방식을 선택한 기준은 

1. 양방향 전송이 가능할 것 

사용자도 서버에 요청을 보내야 하고, 서버도 사용자에게 대기번호를 보내야 한다.

2. 실시간 대기번호 조회가 가능할 것

사용자는 현재 나의 대기 번호가 몇 번인지 실시간으로 알 수 있어야 한다.

 

1번의 경우 STOMP 방식의 웹소켓을 사용하여 양방향 통신을 진행하고 STOMP로 채팅, 대기번호 전송 등의 메시징 프로토콜을 수행하도록 한다.

2번의 경우는 후보로 Redis, Kafka, RabbitMQ가 있었는데, 단순히 성능과 기능에 대해 비교하면 아래와 같다.

항목 Redis Pub/Sub Kafka RabbitMQ
타입 인메모리 pub/sub 분산 로그 기반 메시징 전통적인 메시지 브로커
메시지 저장 ❌ 비영속 (메모리 기반) ✅ 영속 저장 (디스크) ✅ 옵션 (디스크 or 메모리)
메시지 수신 실패 시 메시지 유실 재처리 가능 재전송 가능
처리 순서 보장 ❌ 보장 안됨 ✅ 파티션 내 순서 보장 ✅ 큐 내 순서 보장
소비자 그룹 처리 ❌ 없음 ✅ 있음 ✅ 있음
실시간 적합도 ✅ 빠름 △ 약간 느림 △ 약간 느림
구성/운영 난이도 매우 쉬움 복잡 보통
사용 목적 단순, 초고속 pub/sub 대용량 로그/이벤트 처리 일반 메시징/작업 큐

 

위의 표만 봤을 때는 Kafka나 RabbitMQ가 구성하기엔 어려워도 더 사용하기 좋아보이지만, 

대기열 기능에 적용하기엔 Redis가 가장 적합했다.

 

Redis는 속도가 가장 빠르고, 간단하게 구성가능해서 실시간으로 대기번호 상태를 사용자에게 전달하기에 좋다.

사용자의 네트워크가 끊겨서 그 당시의 대기번호를 전달해주는 메시지가 유실되는 상황이 발생해도 유저가 새로고침하면 순번은 다시 조회가 가능하고 일정 시간 간격마다 대기 번호를 업데이트해서 전송해주는 시스템에서는 큰 문제가 아니다.

 

Kafka의 경우는 대규모 로깅 작업이나 이벤트에 적합해서 대체로 초당 몇십만 이상의 요청이 들어오는 대규모 트래픽에 적절하다.

즉 최대 처리량에 최적화된 기술이라, 최대 처리량이 비교적 많지 않고 실시간성이 중요한 대기열 기능의 경우에는 Kafka가 과한 선택일 수 있다.

반면 좌석 선택의 경우는 동시성과 순서 보장이 중요하기 때문에, 누가 먼저 선택했는지 정확히 기록하고, 나중 사람은 블로킹할 수 있는 카프카가 적합하다.

 

RabbitMQ는 순차적으로 대기표 출력 요청을 처리하는 등의 단순 비동기 작업을 처리하는 상황에 적절하고, 실시간성이 Redis보다 떨어진다.

 

 

날짜 데이터 연산하기

 

Java에서는 `LocalDateTime - LocalDateTime` 같은 직접적인 `-` 연산은 지원하지 않는다.

`LocalDateTime`끼리 뺄셈(차이 계산)은 직접 두 값을 비교해서 `Duration` 또는 `ChronoUnit`을 사용하는 방식으로 처리한다.

`Duration.between(start, end)` 사용 (시간 단위 차이)

import java.time.Duration;
import java.time.LocalDateTime;

LocalDateTime start = LocalDateTime.of(2024, 5, 1, 10, 0);
LocalDateTime end = LocalDateTime.of(2024, 5, 1, 12, 30);

// Duration 객체
Duration duration = Duration.between(start, end);

// 초 단위 차이
long seconds = duration.getSeconds();

// 분 단위 차이
long minutes = duration.toMinutes();

// 시간 단위 차이
long hours = duration.toHours();

 

`ChronoUnit` 사용 (정확한 단위로 차이 계산)

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

LocalDateTime start = LocalDateTime.of(2024, 5, 1, 10, 0);
LocalDateTime end = LocalDateTime.of(2024, 5, 2, 10, 0);

long hours = ChronoUnit.HOURS.between(start, end);     // 24
long days = ChronoUnit.DAYS.between(start, end);       // 1
long minutes = ChronoUnit.MINUTES.between(start, end); // 1440

 

`LocalDateTime` 차이를 구할 때 `Duration.between()` 또는 `ChronoUnit.between()`은 매개변수 순서에 따라 부호가 바뀐다.

`between(start, end)` 형식일 때 start가 이전 시점이고 end가 이후 시점이면 양수가 반환되고, 파라미터 순서가 반대면 음수가 반환된다.


스케줄러 사용하기

스케줄러를 사용하면 특정 시간이나 일정 시간 간격으로 메서드를 호출할 수 있다.

@Scheduled 사용하기

@EnableJpaAuditing
@EnableScheduling // 애플리케이션 클래스에 어노테이션 추가
@SpringBootApplication
public class SiljeunApplication {
  public static void main(String[] args) {
    SpringApplication.run(SiljeunApplication.class, args);
  }
}
// 스케줄러 클래스
@Scheduled(fixedRate = 60000) // 1분마다 실행
  public void returnSeat() {
    LocalDateTime now = LocalDateTime.now();

    reservationRepository.findByStatus(ReservationStatus.PENDING).stream()
        .filter(reservation -> ChronoUnit.MINUTES.between(reservation.getCreated_at(), now) >= 7)
        .forEach(reservation -> reservationService.delete(reservation.getId()));
  }

 

 

@Scheduled 속성

1. fixedRate : 이전 작업을 시작한 시간에서 설정한 시간이 지난 뒤에 함수를 호출한다.

2. fixedDelay : 이전 작업이 종료된 시간에서 설정한 시간이 지난 뒤에 함수를 호출한다.

3. initialDelay : initialDelay 만큼 지연된 후 실행한다.

@Scheduled(fixedRate = 2000, initialDelay = 1000)
public void test() {
   System.out.println("이전 작업 시작 후 1초가 지연되고 2초 마다 실행");
}

 

4. cron : cron 표현식을 통해 속성을 설정할 수 있다.

*은 순서대로 초, 분, 시간, 일, 월 , 요일 을 나타낸다.

  • * : 모두 허용 (매초, 매분, 매일, 매주, 매월)
  • ? : 설정 값 없음
  • - : 범위 설정
  • , : 여러 값을 설정
  • / : 증가하는 값 설정 (초에서 0/10이라면 0초 부터 10초 씩 증가)
  • L : 마지막 (날짜와 요일에서만 사용가능하며 날짜일 경우 마지막 날짜, 요일일 경우 마지막 요일)
  • W : 가장 가까운 평일
    @Scheduled(cron = "0 0 18 * * *")
    public void test() {
        System.out.println("매일 오후 18시에 실행");
    }
    
    @Scheduled(cron = "0 0 12 15,30 * ?")
    public void test() {
        System.out.println("매달 15일,30일 12시에 실행(요일을 비워야 15, 30에만 실행)");
    }
    
    @Scheduled(cron = "0 0 0/1 * * *")
    public void test() {
        System.out.println("1시간 마다 실행");
    }
    
    @Scheduled(cron = "0 0/10 6-12 * * *")
    public void test() {
        System.out.println("매일 6시 부터 12시 까지 10분 마다 실행");
    }
    
    @Scheduled(cron = "0 30 17 1 * *")
    public void test() {
        System.out.println("매달 1일 17시 30분에 실행");
    }

 

자료 출처 : https://filltheemptyspace.tistory.com/15

 


 

 

DB에서의 성능 최적화

1. 인덱스 적용

  • where, join, order by에 자주 사용되는 컬럼에 인덱스를 적용한다.
  • 인덱스가 너무 많으면 성능이 저하된다.
  • 고유한 값에 인덱스를 적용하는 것이 효율이 좋다.

> MySQL 기준 인덱스 적용하기

-- 단일 컬럼 인덱스
CREATE INDEX index_name ON table_name (column_name);

-- 다중 컬럼(복합 인덱스)
CREATE INDEX index_name ON table_name (column1, column2);

 

복합 인덱스의 경우 왼쪽부터 차례대로 사용되는 경우에만 활용되며 왼쪽 정렬 원칙(Left-most Prefix Rule) 이라고 부른다.

 

> JPA에서 인덱스 적용하기

@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_user_name", columnList = "name"), // 인덱스명 생략 가능
    @Index(name = "idx_user_name_age", columnList = "name, age")
})
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private Integer age;
}
@Column(unique = true) // unique 설정한 필드는 자동 인덱스 생성
private String email;
spring.jpa.hibernate.ddl-auto=create  # 또는 update

 

 

2. 정규화 ↔ 비정규화 판단

  • 조회 성능이 더 중요하다면 부분 비정규화 고려
  • ex. 조인 줄이기 위해 중복 데이터 허용
  • 데이터 무결성이 중요하면 정규화 유지

3. CQRS + Eventual Consistency

  • 쓰기 DB와 읽기 DB를 분리 → 확장성과 성능 개선
  • 이벤트 기반 비동기 동기화로 최신성 유지

4. Batch Insert / Update 처리

  • 대량 쓰기 작업 시, 루프보다 batch 처리로 성능 향상

5. Partitioning / Sharding

  • 테이블이 너무 커질 경우, 수평 파티셔닝 또는 DB 샤딩 적용

6. 데이터 보관 주기 및 이력 분리

  • 오래된 데이터는 히스토리 테이블로 분리하여 조회 성능 확보

 


서비스에서의 성능 최적화

1. 캐싱

  • 조회 빈도 높은 데이터는 Redis 등 인메모리 캐시로
  • 캐시 무효화 정책 설계 중요 (TTL, 수동 삭제 등)

2. 조회 쿼리 튜닝

  • N+1 문제 방지 (JPA fetch join, DTO projection, query DSL 등)
  • 페이징 처리 시 count() 최적화 고려

3. 비동기 처리 / 메시지 큐 활용

  • 처리 시간이 오래 걸리는 작업은 MQ 또는 비동기 처리
    • 예: Kafka, RabbitMQ

4. 서비스 로직 단순화

  • 복잡한 계산 로직은 별도 비동기 처리 또는 저장된 결과 사용

5. 지속적인 모니터링과 병목 분석

  • APM 도구(NewRelic, Pinpoint, Datadog 등) 사용해 병목 추적
  • 쿼리 슬로우 로그, GC 로그 분석 병행