Spring 숙련 (Redis, SSE)

KimGwangmin·2026년 9월 16일

Redis

Remote Dictionary Server

  • 메모리에 데이터 저장: 매우 빠른 RW
  • Key-Value 기반: NoSQL

특징

  1. 인메모리
    • 디스크가 아닌 메모리(램)에 데이터 저장
    • 디스크보다 훨씬 빠른 읽고 쓰기
    • 모든 데이터를 담지 않고 자주 쓰는 데이터만(램은 디스크보다 작고 비싸다)
  2. Key-Value
    • 모든 값을 key로 저장하고 접근
    • key 이름은 대상:식별자:용도 형태로 작성 (예: product:1:name)
  3. 싱글 스레드
    • 모든 명령을 한 스레드가 하나씩 차례로 실행
    • 명령 도중에 다른 명령이 끼어들지 않음
    • 데이터 정합성 문제가 발생하지 않음
    • 오래 걸리는 명령 하나(예: key *)가 뒤의 모든 명령을 지연
  4. 다양한 자료구조
    • 문자열, 리스트, 셋, 정렬셋, 해시 등 다양한 자료구조를 값으로 가짐
  5. TTL(Time To Live)
    • 유효 시간이 지난 키는 자동으로 지움
    • Redis는 용량이 매우 작고, 휘발성이기 때문에 TTL 설정이 반드시 필요

Redis 실행

Docker에 Redis 띄우기
docker run -d --name expert-redis -p 127.0.0.1:6379:6379 redis:7

트러블슈팅: 포트 충돌 문제

docker: Error response from daemon: ports are not available: exposing port TCP 127.0.0.1:6379 -> 127.0.0.1:0: listen tcp4 127.0.0.1:6379: bind: An attempt was made to access a socket in a way forbidden by its access permissions.

6379 포트(Redis 기본 포트)에 접근할 수 없다는 에러가 발생한다. 하지만 netstat -ano | findstr :6379을 입력해봐도 해당 포트를 점유 중인 프로세스는 없었다.

구글링해보니 netsh interface ipv4 show excludedportrange protocol=tcp라는 명령어가 있었다. 윈도우가 다른 프로세스에서 사용하지 못하도록 미리 점유한 TCP 포트 범위를 확인하는 명령어이다.

윈도우 시스템, 그 중에서도 주로 WSL2이나 Hyper-V 같은 가상화 기능이 통신을 위해 부팅마다 임의의 수천 개 포트를 예약해두는데, 그 범위에 사용하고자 하는 포트가 걸려서 발생한 문제로 파악된다.

사용하려는 6379 포트가 이 범위에 포함되어 있었다.

우선은 다음과 같이 임시조치를 할 수 있다.

net stop winnat
net start winnat

네트워크 서비스를 껐다 킴으로써 점유하던 포트를 해제하는 방식이다. 이후 다시 제외 포트 범위를 확인해보았다.

포트 범위가 깔끔하게 정리되었다. 이제 다시 Docker로 가면 컨테이너를 띄울 수 있다.

영구적으로 문제가 발생하지 않도록 설정할 수도 있다. 점유 포트 번호를 특정 영역으로 제한하는 것이다.

# 1. Hyper-V 서비스가 포트를 잡고 있지 못하도록 잠시 중단
netstop winnat

# 2. TCP IPv4 동적 포트 시작 범위를 49152번부터 16384개로 지정 (49152 ~ 65535)
netsh int ipv4 set dynamicport tcp start=49152 num=16384

# 3. 중단했던 가상화 네트워크 서비스 다시 시작
net start winnat

이렇게 하면 레지스트리에 동적 포트 시작 범위가 고정되어 사용하려는 포트 번호를 점유하는 문제를 방지할 수 있다.

예제

컨테이너를 띄운 상태라면, IntelliJ에서 데이터 소스 추가를 통해 Redis에 접근할 수 있다. 별도 설정할 것 없이 연결 테스트만 하고 적용을 누르면 된다.

문자열

@Service
@RequiredArgsConstructor
public class RedisStringService {
    private static final Duration TTL = Duration.ofMinutes(5); // TTL 5분
    private final StringRedisTemplate redisTemplate;

    public void set(
            String key,
            String value
    ) {
        redisTemplate.opsForValue().set(key, value, TTL);
    }

    public String get(
            String key
    ) {
        return redisTemplate.opsForValue().get(key);
    }

	// 1씩 증가하는 정수 데이터
    // 사용 예: 조회수
    public Long increment(
            String key
    ) {
        Long count = redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, TTL);
        return count;
    }
}

API 요청 실습

요청 예(저장): POST /redis/strings?key=product:1:name&value=keyboard

저장조회
수정수정 후 조회
삭제삭제 후 조회
숫자 올리기 1숫자 올리기 2

Hash

Hash는 HashMap(혹은 Dictionary)과 같은 자료구조이다.

@Service
@RequiredArgsConstructor
public class RedisHashService {
    private static final Duration TTL = Duration.ofMinutes(5);
    private final StringRedisTemplate redisTemplate;

    public void put(
            String key,
            String field,
            String value
    ) {
        redisTemplate.opsForHash().put(key, field, value);
        redisTemplate.expire(key, TTL);
    }

    public Object get(
            String key,
            String field
    ) {
        return redisTemplate.opsForHash().get(key, field);
    }

    public Map<Object, Object> entries(
            String key
    ) {
        return redisTemplate.opsForHash().entries(key);
    }

    public Long delete(
            String key,
            String field
    ) {
        return redisTemplate.opsForHash().delete(key, field);
    }
}

key는 해시 자체를 조회하는 키, field는 해시 내부에서 값을 조회하는 키이다. (이중 Key-Value 구조)

API 요청 실습

요청 예(저장): POST /redis/hashes?key=user:1:profile&field=nickname&value=alice

저장 요청 후 DB 확인 시(2개 필드 저장)GET 요청 시(전체 조회)GET 요청 시(필드 조회)
수정수정 후 조회(해당 필드만 변경됨)
  • 특정 필드에 대해 수정, 삭제 시 해당 필드만 변경된다. 다른 필드는 영향을 받지 않는다.
  • TTL은 필드가 아닌 key마다 붙는다.

List

일반적인 List와는 달리, 양방향 스택처럼 작동한다. (left(right)Push, left(right)Pop을 지원한다)
set(key, index, value)과 같이 인덱스 접근도 가능하다.

@Service
@RequiredArgsConstructor
public class RedisListService {
    private static final Duration TTL = Duration.ofMinutes(5);
    private final StringRedisTemplate redisTemplate;

    public Long leftPush(
            String key,
            String value
    ) {
        Long size = redisTemplate.opsForList().leftPush(key, value);
        redisTemplate.expire(key, TTL);
        return size;
    }

    public Long rightPush(
            String key,
            String value
    ) {
        Long size = redisTemplate.opsForList().rightPush(key, value);
        redisTemplate.expire(key, TTL);
        return size;
    }

    public List<String> range(
            String key
    ) {
        return redisTemplate.opsForList().range(key, 0, -1);
    }

    public void set(
            String key,
            long index,
            String value
    ) {
        redisTemplate.opsForList().set(key, index, value);
        redisTemplate.expire(key, TTL);
    }

    public String leftPop(
            String key
    ) {
        return redisTemplate.opsForList().leftPop(key);
    }

    public Long remove(
            String key,
            String value
    ) {
        return redisTemplate.opsForList().remove(key, 1, value);
    }
}

Set

중복이 없고 순서도 없는 자료구조이다.
순서가 없기 때문에, 특정 값을 수정하는 것이 불가능하다. (인덱스 접근이란 개념이 없다) 대신 지우고 새로 넣는 방식을 써야 한다.

@Service
@RequiredArgsConstructor
public class RedisSetService {
    private static final Duration TTL = Duration.ofMinutes(5);
    private final StringRedisTemplate redisTemplate;

    public Long add(
            String key,
            String value
    ) {
        Long added = redisTemplate.opsForSet().add(key, value); // `add`는 새로 들어간 값의 개수를 돌려준다. 즉 이미 있는 값일 경우 0을 돌려준다.
        redisTemplate.expire(key, TTL);
        return added;
    }

    public Set<String> members(
            String key
    ) {
        return redisTemplate.opsForSet().members(key); // set의 모든 값 반환
    }

    public Boolean contains(
            String key,
            String value
    ) {
        return redisTemplate.opsForSet().isMember(key, value);
    }

    public Long remove(
            String key,
            String value
    ) {
        return redisTemplate.opsForSet().remove(key, value);
    }
}

SortedSet(ZSet)

값마다 점수를 붙이고, 점수 순서대로 정렬하는 Set이다. (중복 없음)
점수가 동일한 경우, 사전순으로 정렬된다.
예를 들어 랭킹을 구현할 때 유용하다.

@Service
@RequiredArgsConstructor
public class RedisSortedSetService {
    private static final Duration TTL = Duration.ofMinutes(5);
    private final StringRedisTemplate redisTemplate;

    public Boolean add(
            String key,
            String member,
            double score
    ) {
        Boolean added = redisTemplate.opsForZSet().add(key, member, score);
        redisTemplate.expire(key, TTL);
        return added;
    }

    public Set<String> ranking(
            String key
    ) {
        return redisTemplate.opsForZSet().reverseRange(key, 0, -1);
    }

    public Double score(
            String key,
            String member
    ) {
        return redisTemplate.opsForZSet().score(key, member);
    }

    public Long remove(
            String key,
            String member
    ) {
        return redisTemplate.opsForZSet().remove(key, member);
    }
}

Pub/Sub

채널에 메시지를 보내면(Publish) 그 채널을 구독(Subscribe) 중인 모든 연결이 받는 방식

  • 이벤트 발행 개념이다. 발행자는 구독자에 대해 알지 못한 채 특정 채널로 메시지를 보내고, Redis는 같은 이름의 채널을 구독한 모든 연결에 메시지를 전달한다.
  • 메시지는 기본적으로 저장되지 않는다.

예제

아래 예제코드는 동작 설명을 위해 간소화된 코드로, 그대로 사용하면 안된다!

컨트롤러 코드

@RestController
@RequiredArgsConstructor
public class MessageController {
    private final MessagePublishService messagePublishService;
    private final MessageSubscribeService messageSubscribeService;

    @PostMapping("/news/{category}/messages")
    public ResponseEntity<Void> publish(
            @PathVariable String category,
            @RequestBody MessagePublishRequest request
    ) {
        messagePublishService.publish(category, request.getMessage());
        return ResponseEntity.ok().build();
    }

    // 브라우저가 연결하면 news:{category} 채널을 구독하고, 받은 메시지를 이 연결로 보냄
    @GetMapping("/news/{category}/stream")
    public SseEmitter stream(
            @PathVariable String category
    ) {
        return messageSubscribeService.subscribe(category);
    }
}
  • GET /news/{category}/stream: 구독(Sub) 요청
    • 브라우저가 이 주소로 연결 시 해당 카테고리를 구독
    • 서버는 SseEmitter 객체 생성, 응답을 종료하지 않음: Stateful!
    • RedisMessageListenerContainer에 해당 클라이언트용 리스너 MessageSubscriber를 news:{category} 채널에 등록 (아래에 구독 코드 참고)
  • POST /news/{category}/messages: 발행(Pub) 요청
    • 카테고리는 경로로, 메시지는 본문으로 받음
    • 채널 이름은 news:{category} (아래에 발행 코드 참고)
    • 발행 요청은 즉시 종료: Stateless
    • 서버는 news:{category} 채널을 구독 중인 모든 리스너에게 메시지를 브로드캐스팅
    • 리스너의 onMessage() 호출, 열려있는 SseEmitter.send()를 통해 연결된 브라우저로 실시간 스트리밍

SSE(Server-Sent Events)
연결을 끊지 않고 열어둔 채 서버가 계속 이벤트를 보내는 방식

서비스 메서드

  1. 발행
@Service
@RequiredArgsConstructor
public class MessagePublishService {
    private final StringRedisTemplate redisTemplate;

    public void publish(
            String category,
            String message
    ) {
        String channel = "news:" + category;
        redisTemplate.convertAndSend(channel, message);
    }
}
  • 카테고리 앞에 news: 접두어를 붙이고 해당 이름의 채널로 메시지를 발행한다.
    • convertAndSend: 메시지를 바이트로 바꿔(convert) 보낸다(send)는 뜻이다.
    • Redis 통신에선 바이트 데이터가 오고간다
  1. 구독
@Service
@RequiredArgsConstructor
public class MessageSubscribeService {
    private final RedisMessageListenerContainer container; // 구독 관리, 메시지 오면 리스너 실행. 빈이라 스프링이 자동으로 만들어줌

	// synchronized: 여러 연결이 와도 구독을 하나씩 등록
    // 락을 걸어 동시성 문제 해결
    public synchronized SseEmitter subscribe( 
            String category
    ) {
        SseEmitter emitter = new SseEmitter(0L); // 브라우저와의 SSE 연결 하나, 0L은 시간 제한 없음. 기본값은 30초
        MessageSubscriber subscriber = new MessageSubscriber(emitter);
        ChannelTopic topic = new ChannelTopic("news:" + category);
        // 연결이 열리면 이 연결의 리스너로 news:{category} 채널을 구독
        container.addMessageListener(subscriber, topic);
        // 연결이 끊기면 구독 해제
        emitter.onCompletion(() -> container.removeMessageListener(subscriber, topic));
        return emitter;
    }
}

synchronized는 자바에서 동시성 문제를 해결하기 위한 방법 중 하나이다. 여러 요청이 동시에 내부상태를 변경하면 메시지가 두 번 발행되는 등의 문제가 발생할 수 있다.

메시지 리스너

@Slf4j
@RequiredArgsConstructor
public class MessageSubscriber implements MessageListener {
    private final SseEmitter emitter;

    @Override
    public void onMessage( // 메시지가 도착할 때마다 실행되는 메서드
            Message message,
            byte[] pattern // 여러 채널을 한 번에 구독하는 패턴에서 사용
    ) {
     	// Redis는 데이터를 바이트(byte[])로 주므로 문자열로 바꿔줘야 함
        String channel = new String(message.getChannel(), StandardCharsets.UTF_8);
        String body = new String(message.getBody(), StandardCharsets.UTF_8);
        log.info("수신: channel={} message={}", channel, body);
        try {
            emitter.send(new ReceivedMessage(channel, body));
        } catch (IOException | IllegalStateException closed) {
            emitter.complete();
        }
    }
}

0개의 댓글