728x90
반응형
SMALL

테스트 코드 작성할 때, 비슷비슷해 보이는 것들이 살짝 살짝 다른 차이점을 가지고 있는데, 더이상 헷갈리지 않게 정리를 해보고자 한다.

 

Stub

  • 미리 정해진 값을 리턴하는 고정된 대답 머신이다.
  • 진짜 구현을 대체해서 정해진 상황을 흉내만 내는 더미 객체이다.
  • 행동 검증보단 상태 기반 테스트에 사용된다.
// 예시: 이메일 서비스가 항상 성공하는 것처럼 흉내내기
when(emailService.send(any())).thenReturn(true);
  • "얘한테 뭘 줘도 항상 true를 반환할거야!" 이럴 때 쓰는 용도! (로직 테스트에 집중)

Mock

  • 행동 검증용 객체
  • 진짜 기능은 없고, "누가 나 호출했어? 몇 번 했어? 어떤 파라미터로 불렸어?" 이런 것을 추적한다.
  • 그래서 상호작용 기반 테스트에 자주 사용된다.
verify(emailService, times(1)).send(any());
  • "이메일 서비스의 send() 메서드 진짜 호출됐는지 확인하고 싶어!" 이런 상황에 적합하다.
  • 여기서 더 나아가, 1번만 호출했고, 파라미터는 어떤것인지도 지정가능하다.
  • 위 예시 코드에서는 한번 호출을 검증하고, send(...) 파라미터 any()는 어떤것이든 상관없다는 것을 말한다.

Spy

  • 진짜 객체를 감시하고, 원래 메서드는 진짜로 호출되지만, 특정 메서드를 조작할 수 있다.
  • 기본은 진짜 동작을 따르고, 필요한 부분만 Stub처럼 바꾸는 것.
BookRepository bookRepository = mock(BookRepository.class);
PushService pushService = mock(PushService.class);

LibraryService libraryService = spy(new LibraryService(bookRepository, pushService));

doReturn(Optional.of("Override spy")).when(libraryService).borrowBook("1234");
  • "대부분은 원래대로 동작하되, 이 부분만 살짝 바꿀게!" 할 때 딱이다.

 

스프링 부트 테스트

스프링 부트 프로젝트에서 Mockito를 사용할땐 두 가지 방법이 있다.

 

@SpringBootTest + @MockitoBean

실제로, Spring Context를 띄우고, Context에 빈으로 등록하고 기존 빈을 대체하려면 @MockitoBean을 사용하면 된다.

@SpringBootTest
class LibraryServiceTest {

    @MockitoBean
    BookRepository bookRepository;

    @MockitoBean
    PushService pushService;

    @Autowired
    LibraryService libraryService;

    @Test
    @DisplayName("도서 이용 가능 여부 확인")
    void isAvailable() {
        when(bookRepository.findBookByIsbn(eq("1234")))
                .thenReturn(Optional.of(new Book("1234", "title", true)));

        boolean available = libraryService.isAvailable("1234");
        assertThat(available).isTrue();
    }
}
  • LibraryServiceBookRepository, PushService를 주입받아야 한다.
  • 이때, 실제 빈을 주입해도 되지만, Mock 객체를 주입할수도 있는데 이럴때 위 코드와 같이 주입 대상에 @MockitoBean 애노테이션을 달아버리면 된다.

만약, Mock 객체말고, Spy 객체를 넣고 싶으면 이렇게 하면 된다.

@SpringBootTest
class LibraryServiceTest {

    @MockitoSpyBean
    BookRepository bookRepository;

    @MockitoBean
    PushService pushService;

    @Autowired
    LibraryService libraryService;

    @Test
    @DisplayName("도서 이용 가능 여부 확인")
    void isAvailable() {
        when(bookRepository.findBookByIsbn(eq("1234")))
                .thenReturn(Optional.of(new Book("1234", "title", true)));

        boolean available = libraryService.isAvailable("1234");
        assertThat(available).isTrue();
    }
}
  • BookRepository는 실제 빈이지만, 내가 원하는 부분을 부분 조작할 수 있다.

 

@ExtendWith(MockitoExtension.class) + @Mock

스프링 부트 프로젝트이지만, 스프링 부트를 띄우지 않고 단순 Mock 객체만을 사용해서 테스트하고 싶을때도 있다.

@ExtendWith(MockitoExtension.class)
class LibraryServiceTest {

    @Mock
    BookRepository bookRepository;

    @Mock
    PushService pushService;

    @InjectMocks
    LibraryService libraryService;

    @Test
    @DisplayName("도서 이용 가능 여부 확인")
    void isAvailable() {
        when(bookRepository.findBookByIsbn(eq("1234")))
                .thenReturn(Optional.of(new Book("1234", "title", true)));

        boolean available = libraryService.isAvailable("1234");
        assertThat(available).isTrue();
    }
}
  • @ExtendWith(MockitoExtension.class) 애노테이션만 달고 @SpringBootTest 애노테이션은 떼어버린다.
  • 그럼 Spring Context가 없고 정말 Pure Java 소스 검증에 사용한다. 
  • 대신, LibraryServiceBookRepository, PushService를 의존하고 있으니 각 객체를 Mock 객체로 선언하고 해당 Mock 객체를 주입받는다는 표현으로 @InjectMocks 애노테이션을 달아주면 된다.

 

이 두 가지 방법에는 무슨 차이가 있을까?

방식 상황
@SpringBootTest + @MockitoBean 통합 테스트 / 스프링 빈 DI까지 검증하고 싶다
@ExtendWith(MocitoExtension.class) + @Mock + @InjectMocks 빠른 단위 테스트 (스프링을 띄우지 않기 때문에 매우 빠름)

 

 

그리고, 한가지 헷갈릴 요소가 있는데, 다음과 같이 Mock 객체를 생성하면

@MockitoBean
BookRepository bookRepository;
  • Mock 객체니까, 위에서 정리한대로 호출됐는지? 몇 번 호출됐는지? 어떤 파라미터로 호출됐는지?를 검증하는것은 당연히 가능하다. 
  • 그럼, Stub은 안되냐?하면 Stub도 가능하다. Mock 객체로 Stub을 한다고 생각하면 된다.
@Test
@DisplayName("도서 이용 가능 여부 확인")
void isAvailable() {
    when(bookRepository.findBookByIsbn(eq("1234")))
            .thenReturn(Optional.of(new Book("1234", "title", true)));

    boolean available = libraryService.isAvailable("1234");
    assertThat(available).isTrue();
}
  • Mock 객체(BookRepository)로 Stub 처리를 한 모습이다. 
728x90
반응형
LIST
728x90
반응형
SMALL

Redis Sentinel은 Master - Slave 가 있고, Master가 다운되면 Sentinel이 자동으로 이를 인지하고 모니터링해서, Slave를 Master로 변경해서 서비스의 가용성을 높여주는 구조이다.

 

바로 시작해보자. 우선, 가장 먼저 EC2 인스턴스 두개를 만들어준다.

Redis Master EC2

  • 이름 → 'redis-master'
  • Image → Ubuntu

  • 인스턴스 타입 → 프리티어
  • 키 페어 → 기존 사용하는 게 있다면 그걸 사용해도 되고 새로 만들어도 된다.

  • VPC → 적절히 만든다
  • Subnet → 적절히 선택한다
  • Public IP → 활성화해서 내 로컬에서 SSH로 접속가능하게 한다
  • Security Group → 적절히 만들고 그것으로 선택한다

Redis 전용 Security Group

  • Security Group을 만들고, 인바운드 규칙을 두가지 설정하자.
  • SSH 접속을 내 IP만 허용
  • 6379 포트로 들어오는 트래픽을 허용하는데 그 Source는 마찬가지로 이 Redis Security Group으로 설정한다. 
  • 그래서 이 Security Group을 선택한 레디스 용 인스턴스끼리 서로 접근이 가능하게 하면 된다.

 

  • 이렇게 redis-master 인스턴스가 하나 생성됐으면, 이 녀석안으로 들어가자. SSH 접속으로 들어가면 된다.

Redis 설치 및 설정

apt-get update

apt-get install redis-server
  • 위 두 명령어를 입력하면 레디스가 설치된다.
  • 기본 레디스 설정 파일 경로는 `/etc/redis/redis.conf` 이다.

`/etc/redis/redis.conf` 파일 안

  • 우선, bind 설정을 0.0.0.0으로 설정해서 외부에서 이 레디스에 접속을 가능하게 설정해줘야 한다. 
  • 왜냐하면, Slave는 이 Master 인스턴스와는 다른 인스턴스에 설치할 것이고 둘 간 연결을 해야하기 때문이다.
  • 어차피, Security Group으로 허용 가능한 트래픽을 설정했기 때문에 0.0.0.0으로 지정해도 큰 무리가 없다.

  • 쭉 보면, `protected-mode` 값을 no 로 변경해야 한다.

 

이렇게 두 설정을 한 후 Redis를 내렸다가 다시 올린다.

service redis stop
service redis start

 

 

Redis Master EC2 이미지 복사

위에서 설정한 Redis Master의 인스턴스 이미지를 복사해서 편리하게 Slave를 만들자.

  • redis-master 인스턴스를 클릭 후, 위 이미지처럼 Create Image를 클릭해서 이미지를 만든다.

 

Redis Slave EC2

  • 위에서 만든 AMI로 Slave 용 EC2를 하나 더 만든다.
  • 다 만들고 나서, 마찬가지로 SSH로 접속해서 Redis 설정을 진행하자.

Slave Redis 설정

우선, SSH로 인스턴스에 접속한 다음 아래 명령어를 입력한다.

vi /etc/redis/redis.conf

 

그럼 설정 파일에 위에서 한 것처럼 아래 두 가지를 해주자.

bind 0.0.0.0
protected-mode no

 

이제 Redis를 내렸다가 다시 올린다.

service redis stop
service redis start

 

이제 아래 명령어로 redis-cli에 접속하자.

redis-cli

 

그리고 아래 명령어를 입력하자.

replicaof <master-private-ip> 6379
  • 여기서 master-private-ip는 redis-master로 만든 인스턴스의 private ip이다.
  • 이건 명령어로만 딱 치면 인스턴스가 내려가면 해당 내용이 없어지니까 설정 파일(redis.conf)에 지정해두면 좋다.

 

연결이 잘 됐는지 확인하려면, redis-cli에 접속한 상태로 info 명령어를 입력해보자.

127.0.0.1:6379> info

  • 그럼 이렇게 master 정보가 잘 나오고 master_link_status: up 이렇게 보인다면 연결에 성공한 것이다.

 

여기까지 하면, Master - Slave 관계까진 끝났다. 이제 Sentinel을 추가적으로 설치해서 Master가 다운되면 자동적으로 Failover 하는 과정을 살펴보자.

 

Redis Sentinel 설치

Master, Slave 둘 다 다음 명령어로 Sentinel을 설치하자.

apt-get install redis-sentinel

 

설치가 끝나면 이 sentinel 설정 파일도 동일한 경로에 만들어진다.

 

`etc/redis/sentinel.conf` 파일 안

  • 그럼 여기서 중요한 부분이 이 부분인데, 마스터로 선정된 레디스 IP를 지정해줘야 한다. 아까 Master EC2로 만든 녀석의 Private IP를 넣으면 된다.
  • 6379는 포트이다.
  • 2는 다수결이다. 그러니까 세 개가 설치된 상태에서 2개가 다운됐다고 판단하면 마스터가 서비스가 내려갔다고 판단하고 Slave 중에서 하나를 마스터로 승격시키는 기준을 말한다.

이 설정을 Master, Slave EC2 둘 다 해준 다음에 지금은 두개의 인스턴스 밖에 없다. 항상 이런 Failover 작업은 홀수개로 만들어야 한다. 그래서 Sentinel 하나만 설치할 EC2 인스턴스를 하나 더 만든다.

 

홀수개를 채우기 위한 Sentinel EC2 생성

똑같은 이미지로 생성을 한 뒤에, 마찬가지로 redis-sentinel을 설치하고 위 작업을 동일하게 해준다.

그리고 한 가지 더 해줘야 할 작업이 있는데 바로 Security Group에 26379 포트로 들어오는 트래픽을 인바운드 설정을 해줘야한다.

 

 

이렇게 설정하고, 각 3개 인스턴스 모두 Sentinel을 다시 실행한다.

systemctl restart sentinel

 

 

중간 정리

지금까지 한 작업을 정리해보자.

  • Master, Slave, Odd for Sentinel 이렇게 EC2 인스턴스를 세 개 만들었다.
  • Master, Slave 간 연결을 설정했다. (bind 옵션, protected-mode 설정, replicaof 설정)
  • Security Group 생성 후, 내 IP에서 22번 포트로 들어오는 트래픽, Security Group 간 6379, 26379 포트로 들어오는 트래픽을 허용했다.
  • Master, Slave 간 연결을 확인했다.
  • 세 개의 인스턴스에서 모두 redis-sentinel을 설치했다.
  • sentinel.conf 파일에서 마스터 노드의 IP를 지정했다. 
  • Sentinel을 재시작했다.

이제, Sentinel 로그를 확인해보면 이렇게 잘 붙었다는 내용이 나온다.

 

이제, 마스터 노드에서 Redis를 종료해보자. 재밌는 일이 일어난다. 시간이 좀 지나면 Sentinel이 마스터 노드의 레디스가 내려갔다는 것을 판단한 후 Slave 중에서 Master로 승격시킬 노드 하나를 선택한다. 물론, 지금은 딱 하나니까 그 녀석이 Master가 된다.

  • 보면 새로운 에포크를 시작한다고 나오고, 투표를 한다고 나온다.
  • 마스터를 .55에서 .10으로 변경했다고 나온다.

 

아래 이미지처럼, Slave 노드가 Master 노드가 됐다. 

 

내렸던 마스터 노드에서 레디스를 다시 실행하면, 이 녀석은 이제 Slave가 된다.

 

 

이렇게 Sentinel - Master - Slave 구조로 Redis의 고가용성을 구성해봤다.

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

Replication  (0) 2025.04.20
Redis Monitoring  (0) 2025.04.20
Spring Boot + Redis PubSub  (0) 2025.04.20
Spring Boot + Redis Session  (0) 2025.04.20
Keys, Scan  (0) 2025.04.20
728x90
반응형
SMALL

자바에서 가장 간단하게 락을 획득하고 반납하는 작업은 아마 synchronized 키워드 일 것 같다.

굉장히 간단하게 사용할 수 있지만, 이 녀석은 꽤나 여러 치명적인 단점이 있는데 단점이 작용하는 컨디션도 있어서 어떤 경우에 사용해도 되고, 어떤 경우에 사용하면 안되는지를 좀 알아보고 기록하는 포스팅이다.

 

사용하면 안되는 케이스 - 1

  • @Transactional 애노테이션이 달린 메서드 내부에서 사용하는 케이스

스프링과 데이터베이스를 연동해서 사용한다면 당연히 @Transactional 애노테이션을 사용할 것이다. 그런데 이 애노테이션이 달린 메서드 안에서 synchronized를 사용한다면 원하는 대로 동작하지 않을 것이다.

 

그 이유는 매우 간단하다. 다음 흐름을 보자.

--트랜잭션 시작--

1. lock 획득
2. 서비스 코드 실행
3. lock 반납

--트랜잭션 종료--
  • @Transactional 애노테이션이 달린 AOP로 만들어진 프록시 코드는 위 흐름을 타게 된다.
  • 트랜잭션을 시작하고 원본 메서드를 실행할 것이다.
  • 원본 메서드는 synchronized가 달려 있으므로 lock을 획득해야 한다. 
  • 동시에 요청이 들어온다면 lock을 획득한 스레드만 작업이 가능하다.
  • 작업이 모두 끝나면 해당 스레드는 lock을 반납한다. 
  • 트랜잭션이 종료되기 전, lock을 반납하고 lock을 대기하던 다른 스레드가 곧바로 lock을 획득하고 서비스 코드를 실행한다.

바로 저 부분에서 문제가 생긴다. 흐름으로 보면 다음과 같다.

--트랜잭션 시작--

1. lock 획득
2. 서비스 코드 실행 [2번 스레드의 현재 위치]
3. lock 반납

[1번 스레드의 현재 위치]

--트랜잭션 종료--
  • 이러한 상태가 동시 요청이 마구 들어오는 경우 발생할 수 있게 된다.

 

그렇다면 이 1번 케이스의 경우, 어떤 방법으로 해결할 수 있냐? 이런 모양을 만들어주면 된다.

1. lock 획득

--트랜잭션 시작--
2. 서비스 코드 실행
--트랜잭션 종료--

3. lock 반납
  • 트랜잭션 시작과 종료는 lock 획득 유무와 상관없어야 한다.
  • lock은 트랜잭션이 시작하기 전에 획득해야 하고, 트랜잭션이 종료되고 반납해야 한다.

 

1번 케이스 해결책

즉, @Transactional 애노테이션이 달린 서비스 코드에서 lock을 거는게 아니라, 서비스를 호출하는 쪽에서 (예를 들면 컨트롤러?) 미리 락을 획득한 상태에서 서비스 코드를 호출하면 된다. 

 

해결책은 해결책이지만, 이 경우에도 문제는 있다.

 

파생되는 문제

  • 락 점유 기간이 길어지고, 길어진 만큼 병목 지점이 많아진다.
  • 병목 지점이 많아지는 만큼 처리량이 줄어든다.

 

사용하면 안되는 케이스 - 2

2번째 케이스는 바로 단일 인스턴스가 아닌 경우이다. synchronized의 스코프는 그 JVM이다. 그런데 단일 인스턴스가 아니라 여러개의 인스턴스를 실행한다면, 그만큼 실제로 여러 JVM 프로세스가 띄워지게 되고, 서로 다른 인스턴스끼리는 서로 다르게 lock을 관리하게 되니 동시성 문제는 여전히 발생할 수 밖에 없다. 

 

앞단에 로드밸런서가 있고, 인스턴스가 3개가 띄워져 있다고 가정해보자. 간단하게 라운드로빈 형태로 요청을 처리하고 3개의 인스턴스 각각에 요청이 분산되고 들어갈 것이다. 각 인스턴스끼리는 동일한 lock을 관리하지 않기 때문에 3개의 인스턴스 모두 동일한 요청을 한번에 처리한다. 다만, 동일한 인스턴스에 요청이 여러번 오는 경우 그 인스턴스에서만 lock 획득, 대기, 반납이 이루어질뿐이다. 

 

이 경우엔, synchronized, ReentrantLock 어떤 것을 사용해도 동일한 문제가 나타난다.

 

2번 케이스 해결책

분산 락을 도입하자. 락은 JVM 내에서 관리하는 것이 아니라, 공유 스토리지에서 모두가 접근 가능한 외부 저장소를 사용하게 하여 인스턴스가 1개든, 3개든, 100개든 다 동일한 lock을 바라보게 하면 된다. 

 

가장 대표적인 것이 애플리케이션 레벨에서는 Redis를 사용하는 것이다.

728x90
반응형
LIST
728x90
반응형
SMALL

레디스에서는 RDB와 같이 복제 기능도 제공한다.

이것도 바로 도커 컴포즈로 실행해보자.

 

version: '3.8'

services:
  redis:
    container_name: pj2-ticketing-redis
    image: redis:7.4
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - pj2-ticketing

  redis-replica:
    container_name: pj2-ticketing-redis-replica
    image: redis:7.4
    ports:
      - "6378:6379"
    networks:
      - pj2-ticketing
    volumes:
      - ./redis/conf:/usr/local/etc/redis/
    command: redis-server /usr/local/etc/redis/redis.conf
    restart: always

networks:
  pj2-ticketing:
    driver: bridge

volumes:
  redis_data:
  • 레디스 마스터와, 레디스 레플리카 두 개를 한번에 띄우자.
  • 추가적으로 해야할 작업은 역시 볼륨 작업이다.

디렉토리 생성

./redis/conf/redis.conf

replicaof redis 6379

 

 

이렇게하고 도커 컴포즈를 띄우면 된다. 레디스 마스터에 접속해보자.

docker-compose -p pj2-ticketing exec redis redis-cli

 

127.0.0.1:6379> info replication
# Replication
role:master
connected_slaves:1
slave0:ip=172.18.0.4,port=6379,state=online,offset=112,lag=1
master_failover_state:no-failover
master_replid:efaffdb4860e34f151f21028e34ae66f2eec59c4
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:112
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:112
127.0.0.1:6379> exit
  • info replication 명령어를 입력하면 다음과 같이 정보들이 노출된다.
  • slave의 개수와 IP, PORT등이 노출된다.

 

MASTER에서 쓰기 작업을 해보자.

 

SLAVE에서 확인해보자.

정상적으로 복제가 되는 것을 확인할 수 있다.

 

 

SLAVE는 READ-ONLY라서 읽기만 가능하고 SLAVE 레디스에 쓰기 작업을 하면 실패한다.

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

AWS 환경에서 Redis Sentinel 환경 구축하기  (0) 2025.04.28
Redis Monitoring  (0) 2025.04.20
Spring Boot + Redis PubSub  (0) 2025.04.20
Spring Boot + Redis Session  (0) 2025.04.20
Keys, Scan  (0) 2025.04.20
728x90
반응형
SMALL

레디스를 모니터링 하는 방법은 다양하게 존재한다.

 

redis-cli monitor

가장 간단하고 실용적인 방법이다. 어떤 명령들이 수행되는지 계속해서 올라온다.

 

 

redis-cli --stat

실시간 통계 모니터링 (ops/sec, hit rate 등) Redis 상태를 실시간으로 보고싶을 때 활용한다.

 

redis-cli --bigkeys

가장 큰 키 탐색 (메모리/요소 수 기준). 메모리 많이 먹는 키가 무엇인지 찾고 싶을 때 사용한다.

 

redis-cli --memkeys

메모리 사용량 순으로 키 정렬을 한다. 어떤 키가 메모리를 많이 차지하는지 분석할 때 사용한다.

 

redis-cli --latency

명령어 응답 지연 시간 분석. 성능 병목, 느려진 원인을 찾고 싶을 때 사용한다.

 

명령어 목적 키 기준? 실시간/스냅샷?
--stat 전체 Redis 통계 실시간
--bigkeys 가장 큰 키 찾기 스냅샷 (한번 돌림)
--memkeys 메모리 많이 쓰는 키 정렬 스냅샷
--latency 응답 지연 분석 실시간

 

그런데, 이 명령어들은 운영 환경에서 지속적인 관찰을 하기엔 어려움이 있다. 따라서 끝판왕이 있다.

 

Prometheus / Grafana

이 두가지 도구를 사용하면, 운영환경에서도 지속적으로 모니터링을 하고 문제를 바로 잡을 수 있다. 

프로메테우스는 메트릭을 수집하는 도구, 그라파나는 수집된 메트릭으로 대시보드로 보여주는 시각화 도구이다.

 

  • Redis exporter 라는 Redis에서 수집할 필요가 있는 모든 데이터를 추출한 후 그 데이터를 Prometheus로 보낼 수가 있다.

  • 수집한 메트릭을 기반으로 그라파나에서 뿌려주면 끝이다.

 

도커 컴포즈로 테스트해보기

직접 테스트해보기 위해 도커 컴포즈를 활용하자.

version: '3.8'

services:
  redis:
    container_name: pj2-ticketing-redis
    image: redis:7.4
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - pj2-ticketing

  prometheus:
    image: prom/prometheus:latest
    container_name: pj2-ticketing-prometheus
    user: root
    volumes:
      - ./prometheus/config:/etc/prometheus
      - ./prometheus/data:/prometheus
    ports:
      - "9090:9090"
    networks:
      - pj2-ticketing
    restart: always

  grafana:
    container_name: pj2-ticketing-grafana
    image: grafana/grafana:latest
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
      - GF_USERS_ALLOW_SIGN_UP=false
    volumes:
      - ./grafana/data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning
    ports:
      - "3000:3000"
    depends_on:
      - prometheus
    networks:
      - pj2-ticketing
    restart: always

  redis-exporter:
    container_name: pj2-ticketing-redis-exporter
    image: oliver006/redis_exporter:latest
    environment:
      - REDIS_ADDR=redis://redis:6379
    ports:
      - "9121:9121"
    depends_on:
      - prometheus
    networks:
      - pj2-ticketing
    restart: always

networks:
  pj2-ticketing:
    driver: bridge

volumes:
  redis_data:
  • Redis, Redis Exporter, Prometheus, Grafana를 실행한다.
  • 추가적으로 필요한 작업은 볼륨 작업인데, 다음과 같이 작업하면 된다.

디렉토리 생성

./prometheus/config/prometheus.yaml

./prometheus/data

./grafana/data

./grafana/provisioning

 

./prometheus/config/prometheus.yaml

global:
  scrape_interval: 1m

scrape_configs:
  - job_name: 'prometheus'
    scrape_interval: 1m
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'redis-exporter'
    scrape_interval: 5s
    static_configs:
      - targets: ['redis-exporter:9121']

 

모든 준비가 끝났으면 도커 컴포즈를 띄운다. 참고로 컴포즈 이름은 원하는대로 하면 된다.

docker-compose -p pj2-ticketing up -d

 

 

우선, 프로메테우스로 들어가보자. 

http://localhost:9090

  • 쿼리에 `redis_commands_total` 이 녀석을 추가했다. 이 녀석은 Redis Exporter가 수집하는 메트릭이다.

 

이제 그라파나로 들어가보자.

http://localhost:3000

  • 먼저, 새로운 DataSource를 추가해야한다.
  • 커넥션 정보로는 도커 컴포즈를 활용했으니 서비스명인 `prometheus`를 사용해서 http://prometheus:9090을 입력한다.
  • 아래로 내리면 저장 버튼이 있다.

 

그 다음, 대시보드로 가서 사람들이 잘 만들어놓은 것을 임포트해서 사용하면 된다. 나는 이 대시보드 템플릿을 사용했다.

 

Redis Exporter Quickstart and Dashboard | Grafana Labs

To use this dashboard, please follow the Redis Exporter Quickstart. This quickstart helps you monitor your Redis server by setting up the Prometheus Redis exporter with preconfigured dashboards, alerting rules, and recording rules. This dashboard includes

grafana.com

들어가면 오른쪽에 [Copy ID to Clipboard] 버튼이 있는데 ID를 복사하면 된다.

그 다음, 그라파나로 다시 돌아가서 Import Dashboard - 복사한 ID 집어넣고 저장하면 끝!

 

이렇게 아름다운 대시보드를 볼 수 있다.

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

AWS 환경에서 Redis Sentinel 환경 구축하기  (0) 2025.04.28
Replication  (0) 2025.04.20
Spring Boot + Redis PubSub  (0) 2025.04.20
Spring Boot + Redis Session  (0) 2025.04.20
Keys, Scan  (0) 2025.04.20
728x90
반응형
SMALL

Redis에서도 Publish - Subscribe 구조를 구현할 수 있다.

 

말 그대로, 특정 서버는 데이터를 Publish하면, 특정 서버는 데이터를 Subscribe 한 상태로 Publish한 데이터를 받을 수가 있다.

 

CLI로 확인해보기

  • 레디스에서 SUBSCRIBE 명령어로 특정 키에 대해 구독하겠다는 명령을 날리면 다음과 같이 메시지를 기다린다.

이 상태에서 다른곳에서 이 키에 데이터를 PUBLISH 해보자.

  • 당연히 같은 레디스를 바라보고 있는 클라이언트에서 이렇게 PUBLISH 명령을 날리면 된다.

 

그럼 SUBSCRIBE 하고 있는 클라이언트는 이렇게 데이터를 받을 수 있다.

 

 

PUBSUB

PUBSUB 명령어는 채널 관련 메타데이터 명령어이다. 예를 들어, 아래와 같이 입력하면 Publish - Subscribe 하고 있는 채널을 전부 보여준다.

 

PUBSUB numsub users:register 라고 입력하면 `users:register` 라는 채널을 구독하고 있는 클라이언트의 수를 반환한다.

 

PSUBSCRIBE

이건 Parttern SUBSCRIBE 라는 의미로, 특정 패턴에 일치하는 모든 채널을 구독하는 명령어이다.

예를 들면, 아래와 같이 입력해보면,

  • `users:*` 패턴에 일치하는 모든 채널을 구독한다.
  • users:register, users:unregister 채널에 보내는 모든 메시지가 다 전달된다는 의미이다.

 

스프링 부트 + Redis PubSub

구독과 발행자를 스프링 부트 클라이언트로 만들수도 있다.

구독하려는 특정 모듈에서 이렇게 설정한다.

 

스프링 부트에서 메시지 SUBSCRIBE 하기

package cwchoiit.rediscache.config;

import cwchoiit.rediscache.service.MessageListenService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;

@Configuration
public class RedisMessageSubscribeConfig {

    @Bean
    public MessageListenerAdapter listenerAdapter() {
        return new MessageListenerAdapter(new MessageListenService());
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory,
                                                                       MessageListenerAdapter listenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, ChannelTopic.of("users:unregister"));
        return container;
    }
}
  • MessageListenerAdapter는 메세지를 받기로 지정한 객체를 지정한다. 참고로 그 객체는 org.springframework.data.redis.connection.MessageListener 라는 인터페이스를 구현해야 한다.
  • RedisMessageListenerContainer는 메세지 리스너를 등록하는 녀석이다. 이 과정에서 위 코드와 같이 채널(users:unregister)을 등록해준다.

 

package cwchoiit.rediscache.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class MessageListenService implements MessageListener {

    @Override
    public void onMessage(Message message, byte[] pattern) {
        log.info("[onMessage] Received message = {}, channel = {}", new String(message.getBody()), new String(message.getChannel()));
    }
}
  • 위에서 등록한 어댑터인 MessageListenService에는 이렇게 onMessage()를 구현해야 한다. 간단하게 어떤 메시지가 들어오는지 출력하기만 해보자.

 

이제, 어디선가 다음과 같이 해당 채널에 메시지를 PUBLISH 하자.

 

메시지를 구독하고 있는 이 스프링 부트에서 메시지를 잘 받는 모습이다.

 

 

스프링 부트에서 PUBLISH 하기

이번엔 메시지를 구독하는 것 말고 직접 PUBLISH도 가능하다. 간단하게 작성해보자.

 

package cwchoiit.rediscache.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class MessagePublishController {

    private final StringRedisTemplate stringRedisTemplate;

    @PostMapping("/publish")
    public void publishMessage() {
        stringRedisTemplate.convertAndSend(
                "users:unregister",
                "message from SPRING BOOT"
        );
    }
}
  • 이 진입점을 사용해서 설정된 레디스에 특정 채널에 메시지를 보낼 수 있다.
  • 다음과 같이 채널을 구독하고 있는 다른 클라이언트들이 메시지를 잘 받는 모습이다.

 

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

Replication  (0) 2025.04.20
Redis Monitoring  (0) 2025.04.20
Spring Boot + Redis Session  (0) 2025.04.20
Keys, Scan  (0) 2025.04.20
Transaction  (0) 2025.04.20
728x90
반응형
SMALL

스프링 부트 프로젝트에서 레디스를 같이 사용할 때 스프링 부트의 Session Store를 레디스로 설정할 수 있다.

 

이건 엄청난 이점을 불러일으키는데 바로 인스턴스가 1개든, 10개든, 100개든 요청한 곳이 동일하다면 세션도 동일한 값을 유지할 수 있기 때문이다. 

 

만약, MSA에서 유저 서비스, 상품 서비스, 카테고리 서비스가 각각이 모두 모듈로 분리된 상태고 각 서비스는 인스턴스가 10개씩 띄워져 있다고 가정해보자. 

 

문제가 되는 부분을 시나리오로 작성해보자면,

  • 유저 서비스에서 로그인을 했다. 유저 서비스는 독립된 모듈이고 스프링 부트의 세션 스토어를 사용한다고 가정한다.
  • 유저 서비스에 유저 정보를 요청했는데 로그인 한 유저 서비스의 인스턴스는 A이고 유저 정보를 요청한 인스턴스는 안타깝게도 B 인스턴스였다.
  • 로그인 정보를 B 인스턴스는 알 수 없다. 

또 다른 예시로는,

  • 유저 서비스에서 로그인 했다. 유저 서비스는 독립된 모듈이고 스프링 부트의 세션 스토어를 사용한다고 가정한다.
  • 상품 서비스에서 상품을 조회한다. 이때, 로그인 유저만 조회 가능하도록 설정했다면 로그인 한 유저 정보를 세션에서 가져와야 하지만 유저 서비스와 상품 서비스는 독립된 모듈이기 때문에 로그인 정보를 알지 못한다.

 

자, 이때 세션 스토어를 Redis로 설정한다면 문제가 해결된다.

우선, 스프링 부트의 세션 스토어를 Redis로 설정하려면 다음 두가지 의존성이 필요하다.

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

 

그 다음, 각 서비스 마다 동일한 레디스를 바라보게 설정한다.

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379

 

 

그리고 다음과 같은 구조가 된다면, 서비스가 달라지더라도, 인스턴스가 달라지더라도 동일한 세션을 유지할 수가 있다.

Browser → api.myapp.com (Gateway)
                   ├──> user-service (동일 Redis)
                   └──> product-service (동일 Redis)
  • 동일한 도메인으로 게이트웨이 - 여러 서비스들이 배포된 상태이다.
  • 사용자가 요청을 할 때, 최초 요청을 받는 쪽은 API Gateway가 된다.
  • 이 API Gateway가 요청에 같이 들어온 쿠키에 JSESSIONID 값을 각 서비스에 요청 위임 시, 같이 전달한다.
  • 어떤 서비스든지 해당 세션을 가지고 Redis에서 세션을 조회한다.

 

물론, 제약이 있다. 

  • MSA는 독립된 배포/확장이 핵심이지만, Redis 세션 공유는 서비스간 커플링이 생긴다.
  • 도메인이 다르다면 쿠키 공유는 현실적으로 매우 어려움
  • Redis에 부하가 생길 경우, 전체 서비스에 영향을 줄 수 있다.

 

그래서, MSA에선 이러한 여러 제약과 문제 가능성 때문에 세션 대신 JWT + Stateless 구조로 로그인을 처리하는 게 일반적인 것이다.

그럼에도 불구하고, 고민할 거리가 된다. 아키텍처 크기에 따라 JWT 대신 세션 공유를 통해 로그인 처리도 가능하다는 것을.

 

 

스프링 부트에서 직접 확인해보기

package cwchoiit.rediscache.controller;

import cwchoiit.rediscache.service.UserService;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    ....

    @GetMapping
    public Map<String, String> home(HttpSession session) {
        Integer visitCount = (Integer) session.getAttribute("visits");
        if (visitCount == null) {
            visitCount = 0;
        }

        session.setAttribute("visits", ++visitCount);
        return Map.of(
                "session_id", session.getId(),
                "visits", visitCount.toString()
        );
    }
}
  • 스프링 부트에서 컨트롤러 진입점을 하나 만들고, 세션을 가져온다.
  • 세션에 visits 이라는 키로 값을 저장한다.
  • 반환은 간단하게 세션 ID와 visits 값을 반환한다.

redis-cli monitor

모니터 명령을 통해, 저 요청이 들어올때 레디스에서 무슨 작업이 일어나는지 확인해보자.

  • 해당 경로로 요청을 날렸다.
  • 세션 ID 값은 6bd0으로 시작한다.

  • Redis에서 자동으로 spring:session:sessions:6bd0... 으로 세션을 저장하는 것을 볼 수 있다.

 

포트를 달리하여 인스턴스를 새로 띄워서 요청을 날려도 동일한 세션으로 visits을 공유할 것이다. 요청한 지점만 같다면 말이다.

 

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

Redis Monitoring  (0) 2025.04.20
Spring Boot + Redis PubSub  (0) 2025.04.20
Keys, Scan  (0) 2025.04.20
Transaction  (0) 2025.04.20
Redis 데이터 타입  (0) 2025.04.19
728x90
반응형
SMALL

레디스에서 Keys 명령어는 되도록 사용하면 안된다. 특히 운영환경에서는 더더욱말이다.

 

일단 레디스는 기본이 단일 스레드로 실행된다. 그래서 만약, Keys * 같은 명령어를 수행하면 레디스에 저장된 모든 키들을 가져오는데 이 작업을 단일 스레드가 수행하기 때문에 만약, 키가 매우매우 많은 경우 잠깐의 병목현상이 발생한다. 병목현상이 발생하는 순간에 다른 요청이 들어오면 그만큼 지연현상이 일어난다. 키가 많아지면 많아질수록 더더욱 부담스러운 명령이 된다. 

 

대신, SCAN 명령어를 사용하면 된다. 이 명령어는 커서와 원하는 개수를 지정한다. 어떻게 보면 페이징 처리를 하는 것과 유사하다.

package cwchoiit.redis.warning;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.params.ScanParams;
import redis.clients.jedis.resps.ScanResult;

import java.util.Set;

/**
 * 실제 운영중인 환경에서는 Keys * 이런 명령어 쓰면 안된다!
 * 왜냐하면, Redis 는 기본이 Single Thread 로 동작하고 Keys * 이 명령어는 전체 키를 가져오는 커맨드이기 때문에
 * 키가 매우매우 많다면 그만큼 시간이 오래걸리게 되는 O(n) 시간복잡도를 가진다.
 * <p>
 * 그래서 저런 명령어 대신 SCAN 이라는 명령어가 있다.
 * SCAN 0 MATCH * COUNT 100 이렇게 하면 0번부터 100개까지 모든 패턴에 대한 키를 가져오는 것이다.
 * 이 명령어를 수행하면 매우 빠르게 값을 가져올 수 있어서 Keys 명령어 대신 반드시 이 명령어를 사용해야 하고,
 * SCAN 명령어를 사용하면 첫번째 값으로는 다음 Cursor 값[1]을 주고 두번째 값으로는 패턴에 매칭된 키 정보들을 보여주는데
 * 다음 커서값은 저 [1]을 사용하면 된다.
 */
public class KeysScanMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            Set<String> keys = jedis.keys("*");

            // SCAN
            String cursor = ScanParams.SCAN_POINTER_START;
            ScanResult<String> scanned = jedis.scan(cursor,
                    new ScanParams()
                            .match("*")
                            .count(1000)
            );

            cursor = scanned.getCursor();
            ScanResult<String> scanned2 = jedis.scan(cursor,
                    new ScanParams()
                            .match("*")
                            .count(1000)
            );

            // ...
        }
    }
}
  • SCAN 명령어는 필수로 받아야하는 값이 CURSOR 값이다. 이 값은 0부터 시작하는데 키의 인덱스를 나타내는 것으로 생각하면 된다. 

CLI 명령어로는 이렇게 사용한다.

> scan 0 MATCH * COUNT 10
1) "9"
2) 1) "a"
   2) "redishash-user:email:cwchoiit_1@cwchoiit.com"
   3) "users:300:age"
   4) "redishash-user"
   5) "users:400:name"
   6) "users:300:email"
   7) "users:400:age"
   8) "users:400:email"
   9) "users:500:follow"
   10) "counter"
  • 1) 값(9)이 다음 커서 값이된다.
  • 2) 값들이 10개의 Key가 된다. 

 

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

Redis Monitoring  (0) 2025.04.20
Spring Boot + Redis PubSub  (0) 2025.04.20
Spring Boot + Redis Session  (0) 2025.04.20
Transaction  (0) 2025.04.20
Redis 데이터 타입  (0) 2025.04.19
728x90
반응형
SMALL

Redis Transaction

레디스에서도 트랜잭션 개념을 지원한다. RDB에서 트랜잭션과 유사한 기능이라고 생각하면 된다.

바로 자바 코드로 알아보자.

 

package cwchoiit.redis.transaction;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Transaction;

import java.util.List;

/**
 * Redis 에서도 트랜잭션 개념을 지원한다.
 * DB 트랜잭션이랑 비슷한 개념이다.
 *
 * Redis 에서는 트랜잭션을 시작할때, MULTI 명령어로 시작하고
 * 트랜잭션을 종료할때 EXEC (전부 실행) 또는 DISCARD (전부 버림) 를 사용해서 트랜잭션을 종료한다.
 *
 * WATCH, UNWATCH 라는 개념도 있는데, 트랜잭션을 열기 전에 WATCH 로 나 아닌 누군가가 값을 변경하는지 체크를 시작한 상태에서
 * 트랜잭션을 열고 그 값을 변경을 잘하고 트랜잭션을 끝내려고 EXEC 호출하기 전에 다른 누군가 그 값을 변경하면
 * 이 트랜잭션 전체가 실패한다.
 */
public class TransactionMain {
    public static void main(String[] args) {

        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            // -- 정상 실행 -- //
            Transaction transaction = jedis.multi();

            transaction.set("key", "100"); // QUEUED
            transaction.set("key2", "200"); // QUEUED

            transaction.exec();

            // -- 전부 버림 -- //

            Transaction transaction2 = jedis.multi();

            transaction2.set("key", "100"); // QUEUED
            transaction2.set("key2", "200"); // QUEUED

            transaction2.discard();

            // -- 예외 발생 시 -- //

            Transaction transaction3 = jedis.multi();

            try {
                transaction3.set("key", "100"); // QUEUED
                // int i = 1 / 0; << 예외 발생!
                transaction3.set("key2", "200"); // QUEUED
            } catch (Exception e) {
                transaction3.discard();
            }

            // -- WATCH -- //

            jedis.watch("key2");

            Transaction transaction4 = jedis.multi();
            transaction4.set("key", "100");
            transaction4.set("key2", "200");

            // 이 사이에 누군가 key2 의 값을 바꾼다면, 이 트랜잭션 전체가 버려짐.

            transaction4.exec();



            // -- WATCH, UNWATCH -- //

            String key = "balance";

            jedis.set(key, "100"); // 초기값 설정

            jedis.watch(key); // 👀 변경 감시 시작

            int balance = Integer.parseInt(jedis.get(key));
            int withdrawAmount = 30;

            if (balance >= withdrawAmount) {
                Transaction t = jedis.multi(); // 트랜잭션 시작
                t.set(key, String.valueOf(balance - withdrawAmount)); // 출금 처리

                List<Object> result = t.exec(); // 트랜잭션 커밋

                if (result == null) {
                    System.out.println("트랜잭션 실패! 다른 클라이언트가 값을 바꿨어!");
                } else {
                    System.out.println("출금 성공! 새로운 잔액: " + (balance - withdrawAmount));
                }
            } else {
                jedis.unwatch(); // 💡 출금 불가니까 감시 해제
                System.out.println("잔액 부족");
            }


        }
    }
}

 

MULTI

레디스에서 트랜잭션을 시작하려면 MULTI 명령어를 입력하면 된다.

이 명령어를 입력하면 트랜잭션이 시작되고, 트랜잭션 안에서 발생한 작업들은 트랜잭션이 종료되기 전까지 다른 세션에서 참조할 수 없다.

 

EXEC, DISCARD

트랜잭션을 정상 종료하는 명령어는 EXEC이다. 트랜잭션 과정에서 어떠한 문제도 없었다면 모든 작업이 전부 정상적으로 수행된다.

트랜잭션을 버리는 명령어는 DISCARD이다. 이 명령어는 트랜잭션 과정에서 행했던 모든 작업을 전부 버린다.

 

 

트랜잭션 과정에서 에러가 발생한 경우

트랜잭션을 열고 어떤 작업을 수행했을때, 문제가 발생한 경우엔 EXEC 명령어를 수행하면 트랜잭션 안에서 작업한 모든 내용이 정상 처리되지 않고 에러가 발생한다.  

 

다만, 예외 케이스가 있다. 만약 아래와 같은 작업을 했다고 가정해보자.

> multi
"OK"

> set a 10
"QUEUED"

> set a 10 20
"QUEUED"

> set a 30
"QUEUED"

> exec
  • 트랜잭션을 MULTI 명령어로 시작햇다.
  • SET a 10 으로 문자열 타입의 데이터를 a 라는 키에 저장한다.
  • SET a 10 20 으로 문자열 타입의 데이터를 a 라는 키에 저장할 때 파라미터를 잘못 넘기게 된다. 하나만 입력해야 하는데 두가지를 입력했다.
  • SET a 30 명령어로 30을 저장한다.
  • EXEC 명령어로 트랜잭션을 종료한다.

이 경우에는 정상적으로 입력한 명령들에 대해서는 정상 처리가 되고, 잘못 입력한 SET a 10 20만 에러 처리되고 버려진다.

1) "OK"
2) "ReplyError: ERR syntax error"
3) "OK"

 

일반적인 케이스는 다음과 같다.

> multi
"OK"

> ag
"ERR unknown command 'ag', with args beginning with: "

> set a 30
"QUEUED"

> set a 50
"QUEUED"

> exec
"EXECABORT Transaction discarded because of previous errors."
  • 트랜잭션을 시작하고 아예 잘못된 명령을 수행하면 트랜잭션에서 수행한 모든 작업이 정상 처리되지 않고 버려진다.

 

WATCH, UNWATCH

WATCH 명령어는 트랜잭션을 시작하기 전에 특정 키에 대해 검사를 하겠다는 명령어이다. 

> watch a
"OK"

> multi
"OK"
  • 위와 같이 WATCH a 를 실행한 상태에서 트랜잭션을 열었다.
  • 이 트랜잭션에서 a 에 대한 작업을 진행하는데 만약 다른 누군가 a에 대해 변경 작업을 수행한다면 이 트랜잭션이 종료될 때 에러가 발생한다. 
  • 말 그대로 특정 키를 주시하고 있다가 트랜잭션 안에서 트랜잭션을 종료할 때 내가 주시하기로 시작한 지점의 값과 다른 값이라면 트랜잭션을 적용하지 않는 키워드이다.

 

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

Redis Monitoring  (0) 2025.04.20
Spring Boot + Redis PubSub  (0) 2025.04.20
Spring Boot + Redis Session  (0) 2025.04.20
Keys, Scan  (0) 2025.04.20
Redis 데이터 타입  (0) 2025.04.19
728x90
반응형
SMALL

Redis Data types

아래와 같은 타입들이 있다.

  • Bitmap
  • Geospatial
  • Hash
  • List
  • Set
  • Sorted Set
  • String

Java + Redis

자바에서 Redis를 순수하게 사용해보면서 각 데이터 타입별로 사용 방법을 익혀보자.

우선, 자바에서 Redis를 사용하려면, Jedis 라는 라이브러리를 내려받으면 된다.

implementation 'redis.clients:jedis:5.2.0'

 

String

package cwchoiit.redis.datatypes.string;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.List;

public class SetGetMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            jedis.set("users:300:email", "kim@noreply.com");
            jedis.set("users:300:name", "kim");
            jedis.set("users:300:age", "30");

            String userEmail = jedis.get("users:300:email");

            System.out.println(userEmail);

            // MGET : 여러개 데이터를 한번에 조회
            List<String> userInfo = jedis.mget(
                    "users:300:email",
                    "users:300:name",
                    "users:300:age"
            );
            userInfo.forEach(System.out::println);
        }
    }
}
  • 단순 문자열을 다룰때 Redis에서는 SET, GET 명령어를 사용하면 된다. 
  • SET Key Value 형식으로 데이터를 넣을 수 있고, GET Key 명령어로 데이터를 조회할 수 있다.
  • MGETMultiple GET의 약자로 여러 데이터를 한번에 조회할 때 사용하는 명령어이다.
  • 당연히 MSET도 있다.

실행 결과

kim@noreply.com
kim@noreply.com
kim
30

 

INCR, INCRBY, DECR, DECRBY

이건, 특정 Key에 대해 값을 증가하는 명령어이다. 다음 코드를 보자.

package cwchoiit.redis.datatypes.string;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class IncrDecrMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            long counter1 = jedis.incr("counter"); // counter 에 1 증가
            System.out.println("counter1 = " + counter1);

            long counter2 = jedis.incrBy("counter", 10L); // counter 에 10 증가
            System.out.println("counter2 = " + counter2);

            long counter3 = jedis.decr("counter"); // counter 에 1 감소
            System.out.println("counter3 = " + counter3);

            long counter4 = jedis.decrBy("counter", 10L); // counter 에 10 감소
            System.out.println("counter4 = " + counter4);
        }
    }
}
  • 뒤에 BY가 붙으면 한번에 증가 또는 감소시킬 값을 지정할 수 있다.

실행 결과

counter1 = 1
counter2 = 11
counter3 = 10
counter4 = 0

 

Pipeline

파이프라인은 말 그대로, 이어 실행할 수 있는, 즉 세 개의 요청이 있다면 세 개의 요청을 단건으로 실행하는 게 아니라 모아서 실행할 수 있는 방법이다.

package cwchoiit.redis.datatypes.string;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Pipeline;

import java.util.List;

public class PipelinedMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            Pipeline pipelined = jedis.pipelined(); // 요청을 한번에 처리할 수 있는 파이프라인

            pipelined.set("users:400:email", "greg@np.com");
            pipelined.set("users:400:name", "greg");
            pipelined.set("users:400:age", "40");

            List<Object> objects = pipelined.syncAndReturnAll();
            objects.forEach(System.out::println);
        }
    }
}
  • 먼저 Pipeline 객체를 받은 후에 이 객체를 통해 어떤 작업을 수행한다. 이건 문자열 타입뿐 아니라 어떤 타입이든 가능하다.
  • 그리고 Sync 명령어를 날리면, 이 파이프라인에서 지정한 명령들을 한번에 보낸다.

실행 결과

OK
OK
OK

 

Set

Set은 자바에서 Set 자료구조와 같은 느낌으로 생각하면 된다.

package cwchoiit.redis.datatypes.set;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.Set;

public class SetMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            long insertCount = jedis.sadd("users:500:follow", "100", "200", "300");
            System.out.println("insertCount = " + insertCount);

            // Set 자료구조이기 때문에 아무리 동일한 값을 여러번 넣어도 더 들어가지 않는다.
            jedis.sadd("users:500:follow", "100", "200", "300");
            jedis.sadd("users:500:follow", "100", "200", "300");
            jedis.sadd("users:500:follow", "100", "200", "300");

            Set<String> members = jedis.smembers("users:500:follow");
            members.forEach(System.out::println);

            long totalCount = jedis.scard("users:500:follow");
            System.out.println("totalCount = " + totalCount);

            boolean isContains = jedis.sismember("users:500:follow", "100");
            System.out.println("isContains = " + isContains);

            boolean isContains2 = jedis.sismember("users:500:follow", "600");
            System.out.println("isContains2 = " + isContains2);

            // --- SINTER 확인해보기 --- //
            jedis.sadd("users:600:follow", "100", "400", "500");

            // SINTER = 두 Set 자료구조가 공통으로 가지고 있는 값을 가져옴
            Set<String> sInter = jedis.sinter("users:600:follow", "users:500:follow");
            sInter.forEach(System.out::println);

            jedis.srem("users:600:follow", "100");
        }
    }
}
  • SADD 명령어는 Set 자료 구조에 값을 넣는 명령어이다. SADD Key ...Values 형식으로 사용하면 된다.
  • Set 자료 구조이기 때문에 동일한 값을 넣는다고 중복으로 들어가지 않는다.
  • Set 자료 구조이기 때문에 순서를 보장하지도 않는다.
  • SMEMBERS 명령어는 특정 키에 포함된 값들을 전부 가져오는 명령어이다.
  • SCARD 명령어는 Set Cardinality 의 약자로, 해당 키에 속한 값들의 개수를 가져온다.
  • SISMEMBER는 특정 키에 지정한 값이 포함됐는지 판단하는 명령어이다.
  • SINTER는 두 Set 자료구조에 공통으로 가지고 있는 값들을 반환한다.
  • SREM은 지정한 키에 지정한 값을 제거한다.

실행 결과

insertCount = 3
100
200
300
totalCount = 3
isContains = true
isContains2 = false
100

 

 

List

List 자료구조는 굉장히 유연함을 가지고 있는데, Stack, Queue, BlockQueue, BlockStack 어떤 형태로든 사용이 가능하다.

package cwchoiit.redis.datatypes.list;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.List;

public class StackMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            jedis.rpush("stack1", "aaaa");
            jedis.rpush("stack1", "bbbb");
            jedis.rpush("stack1", "cccc");

            List<String> stack1 = jedis.lrange("stack1", 0, -1);

            stack1.forEach(System.out::println);
            System.out.println();

            System.out.println(jedis.rpop("stack1"));
            System.out.println(jedis.rpop("stack1"));
            System.out.println(jedis.rpop("stack1"));
        }
    }
}
  • Stack은 가장 마지막에 넣은 데이터가 가장 먼저 나오는 구조이다. 그래서 Redis 에서는 Stack 형태의 자료구조를 List로 구현할 수 있는데 이때 RPUSH, RPOP을 사용하면 된다.
  • RPUSH 명령어는 리스트의 오른쪽에 값을 넣는다는 의미이다. [1, 2] 이런 리스트가 있을 때 RPUSH 리스트 3을 하게 되면 [1, 2, 3]이 된다.
  • RPOP은 리스트의 오른쪽에서 꺼낸다는 의미가 된다. [1, 2, 3]에서 RPOP을 하면 3이 나오고 리스트는 [1, 2]가 된다.
  • LRANGE는 특정 리스트의 범위를 지정해서 값을 가져오는 것이다. LRANGE Key Start Stop 형태로 사용할 수 있고, 위에서 사용한것처럼 0, -1을 범위로 지정하면 처음부터 끝까지를 의미한다.

실행 결과

aaaa
bbbb
cccc

cccc
bbbb
aaaa

 

package cwchoiit.redis.datatypes.list;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.List;

public class QueueMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            jedis.rpush("queue1", "aaaa");
            jedis.rpush("queue1", "bbbb");
            jedis.rpush("queue1", "cccc");

            List<String> queue1 = jedis.lrange("queue1", 0, -1);

            queue1.forEach(System.out::println);
            System.out.println();

            System.out.println(jedis.lpop("queue1"));
            System.out.println(jedis.lpop("queue1"));
            System.out.println(jedis.lpop("queue1"));
        }
    }
}
  • Queue는 가장 먼저 넣은 데이터가 가장 빨리 나오는 구조이다. 이 또한 RedisList 자료구조로 구현이 가능하다.
  • RPUSH로 순차적으로 값을 넣고, LPOP으로 값을 꺼내면 된다.

실행 결과

aaaa
bbbb
cccc

aaaa
bbbb
cccc

 

package cwchoiit.redis.datatypes.list;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.List;

public class BlockQueueOrStackMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            // BlockLeftPop 은 지정한 키의 데이터가 있는 경우, 바로 왼쪽(가장 오래된 것 = Queue)에서 데이터를 꺼내오고,
            // 데이터가 없는 경우 주어진 timeout 만큼 대기한다. 대기하는 시간이 지나도 없는 경우 nil 을 반환한다.
            List<String> blockedLeft = jedis.blpop(3, "queue:blocking");
            if (blockedLeft != null) {
                blockedLeft.forEach(System.out::println);
            }

            // BlockRightPop 도 같은 맥락으로 오른쪽(가장 최신것 = Stack)에서 꺼낸다고 보면 된다.
            List<String> blockedRight = jedis.brpop(3, "queue:blocking");
            if (blockedRight != null) {
                blockedRight.forEach(System.out::println);
            }
        }
    }
}
  • BLPOP, BRPOP은 왼쪽에서 값을 꺼내거나 오른쪽에서 값을 꺼내는데, 값이 있다면 바로 가져오고 값이 없다면 값을 정해진 시간만큼 기다린 후 가져오거나 실패한다. 
  • 말 그대로 Blocking 이다.

 

Hash

Hash는 자바에서 HashMap을 생각하면 된다.

package cwchoiit.redis.datatypes.hash;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.Map;

// Redis Hash 는 자바의 HashMap 으로 생각하면 된다.
public class HashMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            // 단일값 추가
            jedis.hset("users:2:info", "visits", "0");

            // Map 전체 추가
            jedis.hset("users:2:info", Map.of(
                    "name", "moon",
                    "phone", "010-1234-5678",
                    "email", "moon@cwchoiit.com")
            );

            // 전체값 가져오기
            Map<String, String> user2Info = jedis.hgetAll("users:2:info");
            System.out.println(user2Info);

            // 특정 필드 삭제
            jedis.hdel("users:2:info", "visits");
            Map<String, String> user2Info2 = jedis.hgetAll("users:2:info");
            System.out.println(user2Info2);

            // 단일값 가져오기
            String email = jedis.hget("users:2:info", "email");
            System.out.println("email = " + email);

            jedis.hset("users:2:info", "visits", "0");

            // 단일값 카운트 증가
            jedis.hincrBy("users:2:info", "visits", 1);
            String visits = jedis.hget("users:2:info", "visits");
            System.out.println("visits = " + visits);
        }
    }
}
  • HSETHash 자료 구조에 값을 추가하는 것이다. HashMap을 생각하면 된다고 했으니 값을 넣을때 Key:Value를 넣으면 된다.
  • HGETALL은 특정 키로 만들어진 Hash 자료구조의 모든 값을 가져오는 명령어이다.
  • HDEL은 특정 키로 만들어진 Hash 자료구조의 특정 Field를 삭제하는 명령어이다.

 

Sorted Sets

Sorted Set은 가장 쉽게 생각하려면, 인기글이나 게임에서 랭킹 순위같은 것을 생각해보면 된다.

package cwchoiit.redis.datatypes.sortedset;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.resps.Tuple;

import java.util.List;
import java.util.Map;

/**
 * Sorted Set - 인기글 순위, 게임 랭킹 순위 등 점수와 해당 점수에 대한 유저와 같이 score 저장이 가능한 자료구조
 *     Key         Score        Member
 * ------------|-----------|-----------|
 *             |    10     |   user1   |
 *             |    20     |   user2   |
 * game1:scores|    70     |   user3   |
 *             |    100    |   user4   |
 *             |    1      |   user5   |
 */
public class SortedSetMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            // 단일값 저장
            jedis.zadd("game2:scores", 10.0, "user1");

            // 여러값 한번에 저장
            jedis.zadd("game2:scores", Map.of(
                    "user2", 50.0,
                    "user3", 100.0,
                    "user4", 2.0,
                    "user5", 15.0)
            );

            // 기본 오름차순 - 즉, 점수 가장 낮은 놈이 젤 먼저 나옴
            List<String> zRange = jedis.zrange("game2:scores", 0, Long.MAX_VALUE);
            zRange.forEach(System.out::println);

            // Score 같이 가져오기
            List<Tuple> tuples = jedis.zrangeWithScores("game2:scores", 0, Long.MAX_VALUE);
            tuples.forEach(System.out::println);

            // 전체 개수
            System.out.println(jedis.zcard("game2:scores"));

            // 특정 멤버의 Score 변경
            jedis.zincrby("game2:scores", 100.0, "user1");

            List<Tuple> tuples2 = jedis.zrangeWithScores("game2:scores", 0, Long.MAX_VALUE);
            tuples2.forEach(System.out::println);

            System.out.println();

            // 내림차순
            List<Tuple> tuple3 = jedis.zrevrangeByScoreWithScores("game2:scores", Long.MAX_VALUE, 0);
            tuple3.forEach(System.out::println);
        }
    }
}
  • 특정 키에 ScoreMember를 집어넣으면 해당 키에 점수를 가지는 각 멤버를 관리할 수 있다.
  • Sorted SetZADD, ZRANGE, ZCARD 와 같이 앞에 Z를 붙인다.
  • ZADD는 특정 키에 Score, Member를 추가하는 명령어이다.
  • ZRANGE는 특정 키에서 주어진 범위만큼의 데이터를 가져온다. 기본은 오름차순이다.
  • ZRANGE만 사용하면 멤버만 가져오고 점수까지 가져오고 싶으면 WITHSCORES 명령어를 추가할 수 있다.
  • ZCARD는 전체 개수를 가져오는 명령어이다.
  • Sorted SetINCR, INCRBY, DECR, DECRBY가 가능하다. 어떤 타입이든 다 가능하다. 

 

Geospatial

Geospatial은 말 그대로, GEO 정보를 저장하는 자료구조이다. Latitude, Longitude를 저장할 수 있다.

package cwchoiit.redis.datatypes.geospatial;

import redis.clients.jedis.GeoCoordinate;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.args.GeoUnit;
import redis.clients.jedis.params.GeoSearchParam;
import redis.clients.jedis.resps.GeoRadiusResponse;

import java.util.List;

public class GeoMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            // GEO ADD
            jedis.geoadd("stores2:geo", 127.020123124123, 37.488888991241, "store1");
            jedis.geoadd("stores2:geo", 127.020123124529, 37.488998991245, "store2");

            // GEO DIST (두 지점간 거리 (단위:M))
            Double geoDist = jedis.geodist("stores2:geo", "store1", "store2", GeoUnit.M);
            System.out.println("geoDist = " + geoDist);

            // GEO SEARCH (주어진 LON, LAT 안에 store2:geo 키에 저장된 장소가 반경 100M 안에 있는지)
            List<GeoRadiusResponse> geoSearch = jedis.geosearch(
                    "stores2:geo",
                    new GeoCoordinate(127.0201, 37.4889),
                    500,
                    GeoUnit.M
            );
            geoSearch.forEach(geoRadiusResponse -> System.out.println("geoRadiusResponse.getMemberByString() = " + geoRadiusResponse.getMemberByString()));

            // GEO SEARCH (요 녀석은 결과로 나온 녀석들의 Coordinate 정보나 Distance 정보 이런것들도 다 가져올 수 있는 방식)
            List<GeoRadiusResponse> geoSearch2 = jedis.geosearch("stores2:geo",
                    new GeoSearchParam()
                            .fromLonLat(new GeoCoordinate(127.0201, 37.4889))
                            .byRadius(500, GeoUnit.M)
                            .withCoord()
                            .withDist()
            );
            geoSearch2.forEach(geoRes -> {
                System.out.println("geoRes.getMemberByString() = " + geoRes.getMemberByString());
                System.out.println("geoRes.getCoordinate().getLatitude() = " + geoRes.getCoordinate().getLatitude());
                System.out.println("geoRes.getCoordinate().getLongitude() = " + geoRes.getCoordinate().getLongitude());
                System.out.println("geoRes.getDistance() = " + geoRes.getDistance());
            });

            // unlink 는 del 과 같이 삭제하는 명령이지만 비동기적으로 수행되는 방법
            jedis.unlink("stores2:geo");
        }
    }
}
  • 이게 꽤 재밌는 타입인게, 두 지점간 거리나 주어진 범위 안에 특정 값이 존재하는지 등 여러 Geo 정보를 구할 수 있다.
  • GEOADD는 값을 추가하는 명령어이다.
  • GEODIST는 특정 키에 존재하는 장소들간 거리를 구해준다. 
  • GEOSEARCH는 주어진 LON, LAT 안에 특정 키안에 저장된 장소들이 있다면 그 장소들을 가져와준다.
  • UNLINKDEL과 유사한 삭제 명령어인데 UNLINKDEL과 달리 비동기적으로 수행된다.

 

Bitmap

Bitmap은 오로지 01로만 이루어진 자료구조이다.

package cwchoiit.redis.datatypes.bitmap;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Pipeline;

import java.util.stream.IntStream;

public class BitMapMain {
    public static void main(String[] args) {
        try (JedisPool jedisPool = new JedisPool("127.0.0.1", 6379)) {
            Jedis jedis = jedisPool.getResource();

            // bitmap 은 0 또는 1 만 저장하는 자료구조인데, 어디에 쓰이냐? 대표적으로 특정 페이지에 어떤 사용자가 방문을 했냐?와 같은 정보를 저장할때 유용하다.
            // offset 100 이 유저 ID 100을 의미한다고 가정하고 true 로 설정하면 이 유저가 해당 페이지에 방문을 했다고 판단할 수 있다.
            // 근데 Set DataType 으로도 그냥 사용가능한데 왜 이걸 쓰냐? 메모리 사용 측면에서 이 bitmap 이 훨씬 더 유리
            jedis.setbit("request-somepage2-20230305", 100, true);
            jedis.setbit("request-somepage2-20230305", 200, true);
            jedis.setbit("request-somepage2-20230305", 300, true);

            System.out.println(jedis.getbit("request-somepage2-20230305", 100)); // true
            System.out.println(jedis.getbit("request-somepage2-20230305", 50)); // false

            System.out.println(jedis.bitcount("request-somepage2-20230305")); // 3

            // bitmap vs Set
            Pipeline pipelined = jedis.pipelined();

            IntStream.range(0, 1000000)
                    .forEach(i -> {
                        pipelined.sadd("request-somepage-set-20250306", String.valueOf(i));
                        pipelined.setbit("request-somepage-bit-20250306", i, true);

                        if (i % 1000 == 0) {
                            pipelined.sync();
                        }
                    });
            pipelined.sync();

            // 이렇게 값을 집어넣은 다음에, redis-cli 에서 "memory usage request-somepage-set-20250306", "memory usage request-somepage-bit-20250306"
            // 각각 실행해보면 차이를 알 수 있다.
            // 나의 경우, Set = 40388736 | Bit = 131144
            System.out.println("Set Memory USAGE: " + jedis.memoryUsage("request-somepage-set-20250306"));
            System.out.println("Bit Memory USAGE: " + jedis.memoryUsage("request-somepage-bit-20250306"));
        }
    }
}
  • 어디에 사용될까한다면, 특정 페이지에 누군가 접속을 했는지와 같은 정보를 저장하는데 나름 유용하다.
  • SETBIT, GETBIT으로 저장하고 가져올 수 있다.
  • 근데 이런 정보는 Set 자료구조로도 가능한데 왜 Bitmap을 사용할까? Set도 특정 키에 유저 정보를 저장해서 해당 키(페이지)에 접속했는지 알 수 있을텐데 말이다. 그 이유는 성능적으로 꽤나 차이가 있기 때문이다.

 

728x90
반응형
LIST

'Redis' 카테고리의 다른 글

Redis Monitoring  (0) 2025.04.20
Spring Boot + Redis PubSub  (0) 2025.04.20
Spring Boot + Redis Session  (0) 2025.04.20
Keys, Scan  (0) 2025.04.20
Transaction  (0) 2025.04.20

+ Recent posts