Remote Dictionary Server
- 메모리에 데이터 저장: 매우 빠른 RW
- Key-Value 기반: NoSQL
대상:식별자:용도 형태로 작성 (예: product:1:name)key *)가 뒤의 모든 명령을 지연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;
}
}
요청 예(저장): POST /redis/strings?key=product:1:name&value=keyboard
| 저장 | 조회 |
|---|---|
![]() | ![]() |
| 수정 | 수정 후 조회 |
|---|---|
![]() | ![]() |
| 삭제 | 삭제 후 조회 |
|---|---|
![]() | ![]() |
| 숫자 올리기 1 | 숫자 올리기 2 |
|---|---|
![]() | ![]() |
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 구조)
요청 예(저장): POST /redis/hashes?key=user:1:profile&field=nickname&value=alice
| 저장 요청 후 DB 확인 시(2개 필드 저장) | GET 요청 시(전체 조회) | GET 요청 시(필드 조회) |
|---|---|---|
![]() | ![]() | ![]() |
| 수정 | 수정 후 조회(해당 필드만 변경됨) |
|---|---|
![]() | ![]() |
key마다 붙는다.일반적인 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);
}
}
중복이 없고 순서도 없는 자료구조이다.
순서가 없기 때문에, 특정 값을 수정하는 것이 불가능하다. (인덱스 접근이란 개념이 없다) 대신 지우고 새로 넣는 방식을 써야 한다.
@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);
}
}
값마다 점수를 붙이고, 점수 순서대로 정렬하는 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);
}
}
채널에 메시지를 보내면(Publish) 그 채널을 구독(Subscribe) 중인 모든 연결이 받는 방식

아래 예제코드는 동작 설명을 위해 간소화된 코드로, 그대로 사용하면 안된다!
컨트롤러 코드
@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} (아래에 발행 코드 참고)news:{category} 채널을 구독 중인 모든 리스너에게 메시지를 브로드캐스팅onMessage() 호출, 열려있는 SseEmitter.send()를 통해 연결된 브라우저로 실시간 스트리밍SSE(Server-Sent Events)
연결을 끊지 않고 열어둔 채 서버가 계속 이벤트를 보내는 방식
서비스 메서드
@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)는 뜻이다.@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();
}
}
}