java.lang.NullPointerException: Cannot invoke "reactor.core.publisher.Mono.flatMap(java.util.function.Function)" because the return value of "com.example.webflux1.service.UserService.findById(java.lang.Long)" is null
at com.example.webflux1.controller.UserController.deleteUserById(UserController.java:98) ~[main/:na]
Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
Error has been observed at the following site(s):
*__checkpoint ⇢ HTTP DELETE "/users/1" [ExceptionHandlingWebHandler]
Original Stack Trace:
at com.example.webflux1.controller.UserController.deleteUserById(UserController.java:98) ~[main/:na]
findById에서 null을 리턴하여 flatMap 메서드를 호출할 수 없음
→ Id 기준으로 회원 조회 시, 없을 경우, Mono.empty()가 아닌 null을 반환하는 것이 문제
❓ReactiveCrudRepository<User, Long>를 구현하는 UserR2dbcRepository는 findById에 해당하는 회원이 없을 경우 Mono.empty()를 반환
3. 원인
@DeleteMapping("/{id}")
public Mono<ResponseEntity<Object>> deleteUserById(@PathVariable Long id) {
// user x: 404, Not Found
// user o: 204, No Content
return userService.findById(id)
.flatMap(user -> userService.deleteById(id)
.then(Mono.just(ResponseEntity.noContent().build())))
.switchIfEmpty(Mono.just(ResponseEntity.notFound().build()));
}
실제 컨트롤러에서 findById 호출 후, deleteById를 실행
→ findById에 대한 모킹이 없어서 null 반환
@MockBean으로 만든 mock은 모킹하지 않은 메소드에 대해: * primitive 타입은 기본값 (0, false 등) * 객체 타입은 null * Mono/Flux 타입도 null을 반환 (🚨Mono.empty()가 아님)
⭐ ReactiveCrudRepository 동작 방식과 무관하게 MockBean에서 모킹하지 않은 메서드를 어떤 방식으로 처리하는가에 대한 문제
JMeter와 함께하는 부하테스트,, 노트북이 버티지 못할까 조마조마한 마음으로 진행을 해보았습니다,,
다행이 docker 내부의 mysql이 CPU를 100% 넘기면서까지 노력해줘서 잘 마무리를 했습니다,,🌹
간단하게, JMeter 설치와 부하테스트 결과를 정리해 보겠습니다,,
기본 환경 - OS: Windows 11 Home - Language: Java - DB: MySQL, Redis - IDE: IntelliJ
0. 사전 지식 🥸
기준
Spring MVC
Spring WebFlux
처리 방식
동기/블로킹
비동기/논블로킹
서버 지원
Tomcat (Servlet 기반)
Netty, Undertow, Tomcat 등 지원
성능 (대규모 트래픽)
스레드 수에 따라 성능 제한
높은 동시성 처리 및 확장성 우수
디버깅
용이
어려움
주요 사용 사례
전통적인 웹 앱, 관리 시스템
실시간 데이터, 채팅, IoT, 스트리밍 서비스
1. JMeter 설치
The Apache JMeter™ application is open source software, a 100% pure Java application designed to load test functional behavior and measure performance. It was originally designed for testing Web Applications but has since expanded to other test functions. Apache JMeter™ 애플리케이션은 기능 동작을 로드하고 성능을 측정하도록 설계된 100% 순수 Java 애플리케이션인 오픈 소스 소프트웨어입니다. 원래 웹 애플리케이션을 테스트하기 위해 설계되었지만 이후 다른 테스트 기능으로 확장되었습니다.
⭐ JMeter는 순수 자바 애플리케이션으로, Java 8 문법을 내부적으로 사용하기 때문에 자바 8 버전 이상이 설치되어 있어야합니다.
저도 정리할 수 있지만,, 이미 잘 정리된 글이 있다면,, 공유하는 게 더 효율적이라고 생각합니다^,,^
dependencies{
// r2dbc 설정
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc' // JDBC 대신 비동기 방식으로 DB와 통신할 수 있도록 지원
implementation 'io.asyncer:r2dbc-mysql:1.0.2' // R2DBC를 이용해 MySQL과 연결하기 위한 드라이버
// 비동기/리액티브 방식으로 MySQL과 데이터를 주고받을 수 있도록 하는 설정
// 리액티브(Reactive) 방식: 비동기(Asynchronous) + 논블로킹(Non-blocking) 프로그래밍 모델
}
@Component
@RequiredArgsConstructor
@Slf4j
@EnableR2dbcRepositories // 리액티브 리포지토리(ReactiveCrudRepository 등)를 자동으로 스캔하고, R2DBC를 통해 사용할 수 있도록 설정
@EnableR2dbcAuditing // R2DBC 엔터티의 자동 감사(Auditing) 기능을 활성화, createdAt, updatedAt 같은 값을 자동으로 입력
public class R2dbcConfig {
private final DatabaseClient databaseClient;
// MySQL 연결 시, 객체 생성은 하지만 Connection까지는 보장하지 않음
// application.properties 파일에서 비밀번호 틀려도 Spring 연결 O
}
4-1. Custom Repository 만들기 - @Query 버전
public interface UserR2dbcRepository extends ReactiveCrudRepository<User, Long> {
@Modifying
// @Query: 읽기 전용(SELECT 쿼리)으로 동작
// CREATE, UPDATE, DELETE 같은 데이터를 변경하는 쿼리를 실행하려면 @Modifying을 추가
@Query("DELETE FROM users WHERE name = :name")
Mono<Integer> deleteByName(String name);
}
4-2. Custom Repository 만들기 - DatabaseClient
@Repository
@RequiredArgsConstructor
public class PostR2dbcCustomRepositoryImpl implements PostR2dbcCustomRepository {
private final DatabaseClient databaseClient;
@Override
public Flux<Post> findAllByUserId(Long userId) {
String sql = """
SELECT p.id as pid, p.user_id as userId, p.title, p.content, p.created_at as pcreatedAt, p.updated_at as pupdatedAt,
u.id as uid, u.name, u.email, u.created_at as ucreatedAt, u.updated_at as uupdatedAt
FROM posts p
LEFT JOIN users u ON p.user_id = u.id
WHERE p.user_id = :userId
""";
return databaseClient.sql(sql)
.bind("userId", userId)
// :userId에 매핑 -> 오류나는 부분 IntelliJ 문제
.fetch()
// fetch().all()을 쓰면 Flux, fetch().one()을 쓰면 Mono를 반환
.all()
.map(row -> Post.builder()
.id((Long) row.get("pid"))
.userId((Long) row.get("userId"))
.title((String) row.get("title"))
.content((String) row.get("content"))
.user(User.builder()
.id((Long) row.get("uid"))
.name((String) row.get("name"))
.email((String) row.get("email"))
.createdAt(((ZonedDateTime) row.get("ucreatedAt")).toLocalDateTime())
.updatedAt(((ZonedDateTime) row.get("uupdatedAt")).toLocalDateTime())
.build())
.createdAt(((ZonedDateTime) row.get("pcreatedAt")).toLocalDateTime())
.updatedAt(((ZonedDateTime) row.get("pupdatedAt")).toLocalDateTime())
.build());
}
}
5. Custom Repository별 장단점 정리
비교 항목
ReactiveCrudRepository
DatabaseClient
특징
ReactiveCrudRepository 기본 메서드 (save, findById, deleteById 등) 사용 가능
replication의 개념과 docker-compose를 이용한 replica 생성 방법까지 간단히 정리해 보고자 합니다.
1. Replication의 정의
At the base of Redis replication (excluding the high availability features provided as an additional layer by Redis Cluster or Redis Sentinel) there is a leader follower (master-replica) replication that is simple to use and configure. It allows replica Redis instances to be exact copies of master instances. The replica will automatically reconnect to the master every time the link breaks, and will attempt to be an exact copy of it regardless of what happens to the master. Redis 복제의 기본(Redis Cluster 또는 Redis Sentinel에서 추가 계층으로 제공하는 고가용성 기능 제외)에는 사용 및 구성이 간단한 리더 팔로워(마스터-복제본) 복제가 있습니다. 이를 통해 복제 Redis 인스턴스는 마스터 인스턴스의 정확한 복사본이 될 수 있습니다. 복제본은 링크가 끊어질 때마다 자동으로 마스터에 다시 연결되며, 마스터에 어떤 일이 발생하더라도 마스터의 정확한 복사본이 되려고 시도합니다.
2. 왜 Replication이 필요한가
안정성
Replication을 사용하면 마스터 서버에서 발생한 데이터 변경이 슬레이브 서버로 자동으로 복제됩니다. 이는 데이터 손실을 방지하고, 마스터 서버가 장애를 겪었을 때 슬레이브 서버를 통해 데이터가 유지될 수 있도록 합니다.
가용성
Redis의 Replication을 통해 여러 서버에 데이터를 복제함으로써, 마스터 서버가 다운되더라도 슬레이브 서버를 통해 읽기 작업을 처리할 수 있습니다.
읽기 부하 분산
마스터 서버에 대한 읽기 요청을 슬레이브 서버로 분산시킬 수 있습니다. 여러 개의 슬레이브 서버를 운영하여 읽기 부하를 분산시킴으로써 성능을 개선할 수 있습니다. 이를 통해 대규모 트래픽을 처리할 수도 있습니다.
3. Dokcer-Compose 파일 준비!
networks: #네트워크 설정으로, 서비스 간 통신을 가능하게 함
replica: #네트워크 이름
driver: bridge #네트워크 드라이버로 bridge를 사용, 같은 호스트 내에서 여러 컨테이너가 서로 통신할 수 있도록 설정
services: #services 아래에 실행할 컨테이너를 정의
redis: #service 이름
container_name: redis #container 이름
image: redis:6.2 #docker hub 내 이미지(repository:verson_tag)
ports: #port 매핑(호스트 6379 포트:컨테이너 6379 포트)
- 6379:6379
networks:
- replica
#연결할 네트워크
#docker-compose.yml에서 networks를 정의하고, 각 컨테이너가 이를 사용하도록 설정하면 같은 네트워크를 공유하는 컨테이너끼리 이름(container_name)을 통해 접근할 수 있음
#예: redis://redis:6379
restart: always #컨테이너 중단 시, 동작 설정
imreplica:
container_name: imreplica
image: redis:6.2
ports:
- 6378:6379
#동일한 호스트에서 여러 개의 Redis 인스턴스를 실행하려면 각 인스턴스가 사용하는 포트를 다르게 설정해야 함
networks:
- replica
volumes:
#호스트(내 컴퓨터)와 Docker 컨테이너의 특정 폴더를 연결하는 설정
#컨테이너 내부에서 생성된 파일이 호스트에 저장되거나, 호스트의 파일이 컨테이너에서 사용될 수 있게 됨
#데이터를 영구적으로 저장하거나 공유하기 위해 사용
#volumes를 통해 데이터를 외부(호스트)에 저장하면 컨테이너를 삭제해도 데이터가 보존
- ./conf:/usr/local/etc/redis/
command: redis-server /usr/local/etc/redis/redis.conf
#컨테이너가 실행될 때 수행할 명령어
#redis-server: Redis Server 실행
#Redis 서버는 기본적으로 설정 파일인 redis.conf를 통해 설정을 읽고 실행
restart: always
imduplica:
container_name: imduplica
image: redis:6.2
ports:
- 6377:6379
networks:
- replica
volumes:
- ./conf:/usr/local/etc/redis/
command: redis-server /usr/local/etc/redis/redis.conf
restart: always
언제 그 기술이 익숙해졌다는 생각이 드시나요,,☺️
전 강의와 다르게 이름을 지을 때 입니다,,☺️
imreplica,,🤖 imduplica,,🤖
❓순간 왜 로컬 포트를 도커에 연결하고 있나,,라는 생각이 들 때, 읽어보면 좋은 글
Docker 컨테이너에 호스트 포트를 연결하는 이유는 외부 시스템(클라이언트나 다른 서버 등)이 컨테이너 내부의 서비스에 접근할 수 있게 만들기 위함입니다. Docker는 컨테이너화된 애플리케이션을 실행하는 환경으로, 기본적으로 컨테이너는 호스트 시스템과 격리된 네트워크를 사용합니다. 따라서 호스트 포트를 연결해야만 호스트 시스템 외부에서 컨테이너의 서비스를 이용할 수 있습니다.
4. Replication을 위한 conf 파일 준비!
replicaof redis 6379
#Redis의 복제(replication) 기능을 설정
#replicaof: 현재 Redis 인스턴스를 복제본(replica)으로 설정하는데 사용
#지정된 마스터 Redis 서버에서 데이터를 복제하도록 설정
#redis: 복제할 마스터 Redis 서버의 서비스 이름(호스트명 또는 IP 주소)
#6379: 마스터 Redis 서버가 사용하는 포트 번호
이 때는 분명됐는데, 이번부터는 docker-compose.yml 파일에 version을 추가하면 오류는 아니고, 경고가 발생합니다,,🚨
the attribute `version` is obsolete,
it will be ignored,
please remove it to avoid potential confusion
top-level에서 version을 지정하는 환경이 obsolete되었기에 이제는 쓰지 않길 원한다는 것입니다.
version을 제거하면 경고 문구 없이 잘 동작합니다.
➕ Deprecated와 Obsolete
Deprecated - 여전히 작동하며 당분간 지원 대상이나 향후 지원이 중단될 예정 Obsolete - 더 이상 지원되지 않거나 사용되지 않으며, 대체 기술이나 방법으로 완전히 전환된 상태
5. Replication 테스트!
# 원본(Master) 인스턴스
docker-compose exec redis redis-cli
info replication
# Replication
# role:master, 현재 Redis 인스턴스의 역할
# connected_slaves:2, 현재 이 마스터 Redis 인스턴스와 연결된 슬레이브(복제본) 인스턴스의 수
# slave0:ip=172.18.0.3,port=6379,state=online,offset=941,lag=0
# offset: 마스터와 슬레이브 간 동기화 상태를 나타내는 데이터의 위치
# 마스터와 슬레이브의 오프셋이 같으면 동기화가 완료된 상태
# lag: 슬레이브가 마스터와 동기화할 때의 지연 시간
# slave1:ip=172.18.0.2,port=6379,state=online,offset=941,lag=0
# master_failover_state:no-failover
# 마스터가 장애가 발생했을 때 슬레이브 중 하나를 새로운 마스터로 승격시키는 과정
set key1 1
# 복제본에서도 실시간으로 데이터 확인 가능
# 복제본(Slave, imreplica&imduplica) 인스턴스
docker-compose exec imreplica redis-cli
set key1 1
# READONLY You can't write against a read only replica.
💡 Master Redis 인스턴스 Stop 후, Start할 경우,
기존 마스터가 중단된 후, 슬레이브 중 하나가 failover로 인해 마스터로 승격
기존 마스터를 다시 시작해도, 기존 마스터는 슬레이브 역할로 동작하며 데이터 일관성을 유지
Master를 대리했던 인스턴스가 새로운 정보를 Write 했을 가능성이 존재
# Stop 전
# Replication
role:master
connected_slaves:2
slave0:ip=172.18.0.3,port=6379,state=online,offset=1000,lag=0
slave1:ip=172.18.0.2,port=6379,state=online,offset=1000,lag=0
# Stop 후, Start 시
# Replication
role:slave
master_host:172.18.0.3
master_port:6379
master_link_status:up
slave_repl_offset:0
Docker 단일 컨테이너를 관리하는 도구로, 애플리케이션과 그 애플리케이션이 실행되는 데 필요한 모든 것을 컨테이너라는 단위로 패키징하고 실행
Docker Compose 여러 컨테이너를 한 번에 관리할 수 있는 도구로, 애플리케이션이 여러 컨테이너로 구성된 경우(예: 웹 서버, 데이터베이스, 캐시), 이를 하나의 설정 파일(docker-compose.yml)로 정의하고 실행
Redis, Redis Exporter, Prometheus, Grafana로 구성된 총 4개의 컨테이너를 관리할 예정이므로 Docker Compose를 사용할 예정입니다.
Docker Desktop을 설치했을 경우, Docker 및 Docker Compose 모두 설치되므로 따로 설치 작업은 필요 없습니다.
2. docker-compose 설정 파일 준비
docker-compose.yaml 여러 개의 Docker 컨테이너를 정의하고 관리하기 위해 사용되는 설정 파일
⭐💡학습 목표: Redis 데이터를 기반으로 Prometheus가 모니터링 데이터를 수집하고, Grafana가 이를 시각화 💡⭐
전체 데이터 흐름
Redis
데이터베이스로 동작하며, 현재 상태와 메트릭(예: 메모리 사용량, 키 개수 등)을 제공
Redis Exporter
Redis의 상태 정보를 수집하여 Prometheus가 이해할 수 있는 형식으로 변환
Redis와 연결(여기선 REDIS_ADDR=redis://redis:6379)하고 메트릭 데이터를 Prometheus로 전달
Prometheus
Redis Exporter가 제공한 메트릭 데이터를 주기적으로 수집하고 저장
데이터베이스 역할을 하며, 요청 시 데이터를 반환
Grafana
Prometheus에 저장된 데이터를 가져와 사용자 정의 대시보드를 통해 시각화
사용자는 웹 인터페이스로 그래프, 차트 등을 확인할 수 있음
version: '3.8' #Docker Compose 파일 버전
networks: #네트워크 설정으로, 서비스 간 통신을 가능하게 함
monitor:
driver: bridge #네트워크 드라이버로 bridge를 사용
services: #services 아래에 실행할 컨테이너를 정의
redis:
container_name: redis #container 이름 지정
image: redis:6.2 #Image 지정
ports: #port 매핑
- 6379:6379
networks:
- monitor
#연결할 네트워크
#Docker 네트워크는 컨테이너 간의 통신을 가능하게 하는 가상의 네트워크
#docker-compose.yml에서 networks를 정의하고, 각 컨테이너가 이를 사용하도록 설정하면 같은 네트워크를 공유하는 컨테이너끼리 이름(container_name)을 통해 접근할 수 있음
#예: redis://redis:6379
restart: #컨테이너 중단 시, 동작 설정
always
prometheus: #모니터링 및 경보 시스템
image: prom/prometheus:latest
user: root
volumes:
#호스트(내 컴퓨터)와 Docker 컨테이너의 특정 폴더를 연결하는 설정
#컨테이너 내부에서 생성된 파일이 호스트에 저장되거나, 호스트의 파일이 컨테이너에서 사용될 수 있게 됨
#데이터를 영구적으로 저장하거나 공유하기 위해 사용
#volumes를 통해 데이터를 외부(호스트)에 저장하면 컨테이너를 삭제해도 데이터가 보존
- ./prometheus/config:/etc/prometheus
#호스트의 ./prometheus/config에 있는 설정 파일이 컨테이너 내부에서 사용
#컨테이너 내부에서 설정을 수정하면 그 내용이 호스트의 ./prometheus/config에 그대로 저장
- ./prometheus/data:/prometheus
#호스트의 폴더 ./prometheus/data → 컨테이너 내부의 폴더 /prometheus와 연결
#Prometheus가 수집한 데이터가 컨테이너 내부의 /prometheus에 저장되는데, 이 데이터가 호스트의 ./prometheus/data에 동기화
#컨테이너를 삭제해도 데이터는 호스트의 ./prometheus/data 폴더에 남아 있음
#요약: Prometheus의 설정 파일과 데이터를 호스트의 특정 폴더에 저장하고 컨테이너가 이를 읽고 쓰도록 연결하는 작업
ports:
- 9090:9090
networks:
- monitor
restart: always
grafana: #Prometheus의 데이터를 시각화해주는 툴
container_name: grafana
image: grafana/grafana:latest
environment:
- GF_SECURITY_ADMIN_USER=admin #관리자 ID를 admin으로 설정
- GF_SECURITY_ADMIN_PASSWORD=password #비밀번호를 password로 지정
- GF_USERS_ALLOW_SIGN_UP=false #사용자가 직접 가입하지 못하도록 설정
volumes:
- ./grafana/data:/var/lib/grafana #Grafana 데이터 저장소
- ./grafana/provisioning:/etc/grafana/provisioning #Grafana의 초기 설정 파일들이 들어 있는 디렉터리
ports:
- 3000:3000
depends_on: #Prometheus가 먼저 실행되어야 함
- prometheus
networks:
- monitor
restart: always
redis-exporter: #Redis 데이터를 Prometheus에 전송해주는 툴
container_name: redis-exporter
image: oliver006/redis_exporter:latest
environment:
- REDIS_ADDR=redis://redis:6379 #Redis 컨테이너와 연결
#redis:// Redis 프로토콜(통신 방식)
#redis Redis 컨테이너의 이름 (DNS처럼 사용)
ports:
- 9121:9121
depends_on:
- prometheus #Prometheus가 먼저 실행되어야 함
networks:
- monitor
restart: always
3. 의존성의 기반에 되는 prometheus 설정 파일 만들기
prometheus.yml Prometheus가 어디에서 데이터를 수집할지(스크래핑) 설정하는 파일
global:
scrape_interval: 1m
#모든 스크래핑 기본 간격 설정
#기본적으로 1분마다(targets에서 지정한 서버에서) 데이터를 수집
scrape_configs: #스크래핑 대상 정의
- job_name: 'prometheus'
scrape_interval: 1m
# scrape_configs.scrape_interval이 global.scrape_interval보다 우선 적용
static_configs: #고정된 타겟 리스트
- targets: ['localhost:9090'] #Prometheus 자체를 스크래핑
- job_name: 'redis-exporter'
scrape_interval: 5s
static_configs:
- targets: ['redis-exporter:9121'] #Redis Exporter에서 메트릭 수집
4. 가자, Docker-compose!
docker-compose -f -d docker-compose.yml up
# Docker Compose는 기본적으로 커맨드가 실행하는 디렉토리에 있는 docker-compose.yml 또는 docker-compose.yaml을 설정 파일로 사용
# -f: 다른 이름이나 경로의 파일을 Docker Compose 설정 파일로 사용
# : 여러 개의 설정 파일 사용
# up: Docker Compose에 정의되어 있는 모든 서비스 컨테이너를 한 번에 생성하고 실행하기 위해서 사용
# -d: 백그라운드에서 컨테이너 실행
docker-compose start redis
# 내려가 있는 있는 특정 서비스 컨테이너를 올리기 위해서 사용
# 서비스에 대한 기존 컨테이너를 시작
# docker-compose.yml 파일에서 서비스 이름을 기준으로 사용
docker-compose ps
# Docker Compose에 정의되어 있는 모든 서비스 컨테이너 목록을 조회할 때 사용
5. 실행 화면
Redis-exporter(127.0.0.1:9121)
Prometheus(127.0.0.1:9090)
Grafana(127.0.0.1:3000) (3000 포트... 이제 React만의 것이 아닙니다..🍂)
+ Grafana에 Prometheus 연결
⚙️설정 → Data Sources → Add new data source: Prometheus 선택
🚨url 입력 시, localhost가 아닌 localhost가 아니라 서비스 이름(prometheus)을 사용해야함
* Prometheus 컨테이너는 기본적으로 localhost:9090에서 동작하지만, 이는 Prometheus 컨테이너 내부에서만 접근 가능한 주소
* Grafana 컨테이너는 네트워크 상에서 Prometheus 컨테이너에 접근해야 하며, 이 경우 localhost가 아니라 서비스 이름(prometheus)을 사용해야 함(Docker Compose는 서비스 이름을 DNS 이름으로 자동 해석)
+ Grafana Redis Dashboard 설정
* 해당 Dashboard Template의 Copy ID to clipboard 또는 Download JSON 클릭
* Dashboards → Import → Import via grafana.com 또는 Upload dashboard JSON file → Import 클릭
docker-compose down
# Docker Compose에 정의되어 있는 모든 서비스 컨테이너를 한 번에 정지시키고 삭제
docker-compose stop redis
# 돌아기고 있는 특정 서비스 컨테이너를 정지시키기 위해서 사용
# 컨테이너를 제거하지 않고 실행중인 컨테이너를 중지
# docker-compose.yaml 파일에서 서비스 이름을 기준으로 사용