현재 진행 중인 프로젝트 알림 로직의 특징
지금 구현되어 있는 알림은 한 메인 기능의 주도적인 로직이 포함된 상태가 아니라, 상품 주문이 끝나면 상품 주문 완료 알림을 보내는 등의 보조적인 기능으로 자리잡아 있습니다. 지금 구현되어 있는 알림 기능을 간단히 나열해보겠습니다.
- 주문 완료 시 주문 완료 확인 알림 전송
- 환불 완료 시 환불 완료 확인 알림 전송
- 2시간마다 한번씩 전송되는 광고성 알림
- 서비스 관리자가 원할 때 전송할 수 있는 서비스 공지 알림
단순한 알림 구조로, 1:1, N:1 형태의 알림이 존재합니다. 이전에 SSE 구독에 대해서 부하 테스트를 진행하면서 OS와 톰캣 등의 WAS를 튜닝했었는데요, 히카리 커넥션 사이즈를 늘린다 해도 DB 성능에서 병목이 생기면 근본적인 해결이 아닐 것입니다.
이제는 DB에 대한 성능 개선을 목표로 4번 기능, 즉 1개의 요청으로 모든 사용자에게 요청을 보내는 API를 사용하여 성능을 측정하고 개선을 해보도록 하겠습니다.
@Service
@RequiredArgsConstructor
public class NotificationServiceImpl implements NotificationService {
...
@Transactional(readOnly = false)
public void sendToAllMembers(Long templateId, Map<String, String> variables) {
// MemberNotification 객체 생성
List<Long> receiverIds = memberRepository.findAllIds();
List<MemberNotification> notifications = notificationHistoryService.createMemberNotification(receiverIds, templateId, variables);
// DB Insert
memberNotificationRepository.saveAll(notifications);
// Network message 생성
List<NetworkMessageDto> eventData = notifications.stream()
.map(NetworkMessageDto::of)
.collect(Collectors.toList());
// 커밋이 된 뒤에 SSE event 수행
eventPublisher.publishEvent(new NotificationSavedEvent(eventData));
}
...
}
DB insert를 진행하면 SSE event를 전송해서 실시간 알림을 보내게 됩니다. 이때 DB I/O는 Disk I/O의 영역이고 SSE 전송은 CPU & 네트워크 I/O 영역이기 때문에 부하 테스트의 주제를 단순화하기 위해서 SSE의 영역은 주석 처리하고 테스트를 진행하겠습니다.
공지 알림 전송 요청 시 예상되는 병목 지점
예상되는 병목 구간은 다음과 같습니다.
- DB에 사용자 수 만큼 하나의 테이블에 N개의 tuple을 삽입해야합니다. 여기서 너무 많은 insert가 발생할 경우 Lock 문제가 발생할 수 있습니다.
- 하나의 트랜잭션에서 커밋을 진행할때 N개의 Undo/Redo 로그를 기록할 텐데, 이때 disk에 작성하므로 I/O 비용이 증가할 것입니다.
- pk, 즉 인덱스가 걸려있을 텐데, Insert 1회 당 데이터 저장 + 인덱스 저장 시간이 듭니다. 인덱스 트리 구조를 개선 중에 다른 유저가 알림을 조회하려고 하면 인덱스가 수정 중에 있기 때문에 해당 읽기 유저기 느려지는 등의 병목이 발생할 수 있습니다.
- 전체 유저를 조회하고 메모리에 N명의 유저에 대한 객체를 로딩한 뒤에 N개의 알림을 생성할 것인데, 전부 힙 메모리, 특히 Eden 영역에 메모리 사용량이 급증할 것입니다. 너무 많은 건에 대해서 한번에 요청이 몰리면 OOM이 발생할 것입니다.
- 구현에 따라서 다르겠지만, 지금은 API 요청이 1개이고 워커 스레드 1개에 배정될테니, 히카리CP가 1개가 붙을 것입니다. 성능 개선을 통해서 parallelStream 등을 사용하게 된다면 여러 개의 히카리를 오랫동안 붙들고 있을 수 있습니다.
테스트 환경
K6 + Grafana + InfluxDB + Prometheus 로 구성해서 진행합니다.
공지 알림 스트레스 테스트
export const options = {
tags: {
application: 'notification-service',
},
scenarios: {
admin_notice_once: {
executor: 'per-vu-iterations',
vus: 1,
iterations: 1,
maxDuration: '5m',
},
},
thresholds: {
'http_req_duration': ['p(100)<2000'],
},
};
1초가 넘어가면 히카리CP 커넥션 회전률에 문제가 생길 수 있으므로 다른 로직에 피해가 갈 것입니다. 따라서 어떤 경우는 DB에 쓰는 시간의 상한선을 1000ms로 진행하겠습니다. 물론, 목표하는 지점은 그것보다 낮게 설정해놓고 말이죠. 500ms가 나왔으면 좋겠네요.
스트레스 테스트의 기대 결과는 "모든 유저의 올바른 알림을 1000ms 안에 DB에 기록한다." 입니다.
시나리오 1: 100명의 유저에게 동시에 공지 알림을 전송한다.
최초의 서버 상태

테스트 결과



아름답습니다.
힙이 약 3퍼센트 정도 올랐고, 응답 속도도 준수한 것 같습니다. 660ms로 나왔으니까, 1개 insert 작업에 대한 DB 작업은
660ms/100 = 6.6ms 이네요. 혹은 100/660ms = 0.1515...인데 s로 바꾸면 151.5...니까 초당 151명을 처리할 수 있겠습니다.
히카리 풀은 아무 변화 없이 평화로웠습니다.
바로 천건으로 넘어가겠습니다.
시나리오 2: 1000명의 유저에게 동시에 공지 알림을 전송한다.
최초의 서버 상태는 위와 동일합니다.
테스트 결과



Heap이 5.X% 정도가 올랐습니다.
DB에 대한 처리는 970ms로 1000ms가 간당간당하게 안됐습니다. Eden 영역의 메모리도 기존에 미리 할당된 양까지 거의 근접했습니다. 다시금 1개의 insert에 대한 처리 속도를 간단하게 계산해보면
970ms/1000 = 0.97ms 입니다. 1000/970ms*1000 = 1030s로, 초당 1030개의 insert를 처리했습니다.
속도에 대한 최적화가 필요한 것 같아 보입니다만, 일단은 최초에 설정한 기준치에 부합하니까 넘어가고, 1만 건으로 가보겠습니다.
시나리오 3: 10000명의 유저에게 동시에 공지 알림을 전송한다.
최초의 서버 상태는 위와 동일합니다.
테스트 결과



드디어 할 일이 생겼군요. 응답속도가 9.3s입니다. 9300ms/10000 = 0.93ms 입니다. 초당 1075건의 데이터를 insert 했습니다.
메모리 사용량은 생각보다 그렇게까지 크게 커지지는 않네요. 크게 신경쓰이지는 않습니다. Eden이 꽉차서 한번 minor GC가 일어나서 전체적인 시간에 영향을 미친 것 같긴 합니다. Old는 뭐 신경쓸게 없네요.
*다시 한번 돌려봤더니 6960ms로 줄었습니다. 다음 날 돌려보니 이렇네요. 네트워크 환경 문제인 것 같습니다.
테스트 결과 분석
이제 문제를 해결해보겠습니다.
public void send(List<Long> receiverIds, Long templateId, Map<String, String> variables) {
memberNotificationRepository.saveAll(notifications);
...
}
public class MemberNotification extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
}
현재 코드의 구성입니다. JPA의 saveAll을 호출하고 있고, 해당 Entity의 pk값은 auto increment입니다. saveAll이 어떤 식으로 구현돼있는지 실제 구현돼있는 코드를 보고 살펴보겠습니다.
을 참조해서 코드를 확인했습니다.
@Override
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, ENTITIES_MUST_NOT_BE_NULL);
List<S> result = new ArrayList<>();
for (S entity : entities) {
result.add(save(entity));
}
return result;
}
리스트를 새로 하나 선언해서, 거기에 save 메서드를 entity 하나를 파라미터로 넣어서 만들고 담고 반환합니다. 즉, for 루프를 돌아서 save를 계속 호출하는 형태인 것입니다.

- JPA의 save를 호출하면 먼저 영속성 컨텍스트를 거칩니다. 이때 처음 들어온 객체, 즉 Transient 상태의 객체는 Map에 등록이 필요합니다.
- 이때 Map에 등록을 하려고 하면 구분자(PK)가 필요합니다. Map에는 key value 형태로 저장되는데, key는 PK여야 하기 때문입니다.
- auto increment는 이전 pk에 1을 더한 값이 PK가 되는 편리한 기능입니다. 이때 이전 PK 값을 알아야하기 때문에 DB를 조회해봐야 ID를 알 수 있습니다.
- JPA의 쓰기 지연이 실행이 안되고 INSERT 쿼리를 날리고, Mysql는 데이터를 저장하고 내부적으로 Auto increment 카운터를 올려서 id를 만들어 냅니다. JPA는 JDBC 드라이버의 Statement.getGeneratedKeys()를 통해서 방금 생성도니 구분자를 받아옵니다.
- 이제 영속성 컨텍스트에 key, value 형태로 객체를 등록합니다.
- save는 N-1번 남았습니다. 이때 객체를 영속성 컨텍스트에 등록할 때 또 위와 같은 일들을 반복합니다.
따라서 SaveAll과 auto increment를 사용하면 영속성 컨텍스트의 쓰기 지연이 실행이 되지 않고, 결과적으로 느려질 수 밖에 없습니다.
SimpleRepository.saveAll()
SaveAll을 사용할 때 auto increment가 DB를 조회해야 다음 저장할 tuple의 구분자를 알 수 있는 상황이 문제가 되는 것이라면, 영속성 컨텍스트를 거칠 때 미리 자바에서 구분자를 생성하고 그것을 밀어넣으면 쓰기 지연이 활성화되면서 성능이 개선될 것입니다. DB를 알지 못하는 상태에서 어떤 식으로 구분자를 생성하면 tuple 간 구분이 보장될 지는, 아래와 같은 방법들이 있습니다.
- SEQUENCE 전략
- JPA가 DB한테 미리 allocationSize를 달라고 요청하고, DB가 PK를 주면 JPA가 메모리에서 미리 ID를 할당해서 쓰기 지연을 활성화 합니다.
- Oracle이나 PostgreSQL이 주로 이 방식을 사용합니다. MySQL은 이 Sequence 객체를 지원하지 않습니다.
- UUID 전략
- UUID.randomUUID()로 UUID를 생성해서 key값으로 사용하고 이를 DB에 밀어넣는 방식입니다. 이 경우 자바에서 PK를 생성하기 때문에 쓰기 지연이 활성화됩니다.
- UUID는 128비트로 생성하는데 연산량이 생각보다 많습니다. 또한 MySQL의 PK는 정렬이 되어있어야 빠릅니다(B-Tree Index). 이러한 종류의 무작위 값이 PK로 들어오게 되면 인덱스 페이지가 깨지고(Fragementation) 재정렬하는데 시간이 많이 걸립니다. 또한 Long 대비 용량이 4배 큽니다.
- TSID / ULID 전략
- TSID(Time-Sorted Identifier)는 UUID처럼 자바에서 직접 생성할 수 있는 값이고, 시간 순으로 정렬되는 숫자의 형태입니다.
- 자바에서 만드니 쓰기 지연이 가능하고, 정렬이 되어있으니 Mysql의 인덱스의 성능을 해치지 않습니다. 또한 Long 타입에 맞춰 넣을 수 있는 라이브러리도 있습니다.
다만 saveAll을 취하면 어쨌든 N개의 객체를 힙 메모리에 올려서 스냅 샷을 떠놓고 DB에 작성합니다. 즉 한 꺼번에 보내는 개수가 늘면 늘수록 영속성 컨텍스트에 저장되는 객체가 많아지고, 메모리를 쓰게 됩니다. 근데 이런 변경할 일이 없는 알림 데이터의 경우에는 더티 체킹을 할 일이 없는데 메모리에 올라가 있으면 낭비가 될수 있습니다. 따라서 메모리 누수의 측면이 있는 셈입니다. 그러면 영속성 컨텍스트를 거치지 않고 바로 DB에 작성할 수 있다면, 메모리 누수를 방지할 수 있을 것입니다.
JdbcTemplate()
JDBC(Java Database Connectivity)를 사용하기 편하게 감싸놓은 클래스입니다.
JPA: Application --> [영속성 컨텍스트] --> Hibernate --> JDBC --> DB
Jdbc Template: Application --> JDBC --> DB
Jdbc Template의 jdbcTemplate.batchUpdate()는 일반적으로 3단계를 거칩니다.
- 준비 (Prepare)
- INSERT INTO ~ (...) VALUES (?, ?)라는 SQL 문장을 딱 한 번 만듭니다.
- 적재 (Add Batch)
- 리스트를 루프 돌면서 ?에 값을 채워 넣습니다.
- DB로 보내지 않고 JDBC 드라비어 내부 메모리 버퍼에 쌓습니다. 이때 영속성 컨텍스트 개념이 없으므로 ID를 몰라도 상관없습니다.
- 실행 (Execute Batch)
- 버퍼에 N개가 차면 DB로 패킷을 하나 보냅니다.
- DB는 이 하나의 패킷을 받아서 처리합니다.
가설 (1)
위에서 언급했 듯이 알림 정보는 서버 실행 중에 다른 서비스에 의해서 수정될 일이 없습니다. 따라서 영속성 컨텍스트에 넣을 필요가 없습니다. 즉 영속성 컨텍스트에 넣어두면 메모리 낭비라고 할 수 있습니다. 따라서 JdbcTemplate()의 batch insert를 사용해서 알림 내용을 넣는 것으로 수정하면 결과가 크게 개선될 것입니다.
가설 (1) 개선 시도
public class MemberNotificationJdbcRepository {
...
@Transactional
public void batchInsert(List<MemberNotification> notifications) {
String sql = "INSERT INTO member_notification " +
"(member, notification, isRead, metaData, created_at, updated_at) " +
"VALUES (?, ?, false, ?, NOW(), null)";
jdbcTemplate.batchUpdate(sql,
notifications,
1000,
(PreparedStatement ps, MemberNotification notification) -> {
ps.setLong(1, notification.getMember().getId());
ps.setLong(2, notification.getNotification().getId());
ps.setString(3, notification.getMetaData());
});
}
...
}
코드를 위와 같이 작성하고 batchInsert 메서드를 실행해서 insert 하도록 코드를 수정했습니다.
가설 (1) 개선 결과



사용하는 힙 사이즈도 줄었고, Eden 영역 사용량이 2.8GB를 찍었던 것에 비해서 1GB도 안되는 사용양으로 확 줄었음을 확인할 수 있었습니다. 또한 http_req_duration도 9300ms가 3740ms로 줄었습니다. 1/3 가량 줄었습니다만, 여전히 목표치에는 못 미치는 수준입니다. 서버 로그를 확인해보니 이상한 점이 있었습니다.
1 + N Problem

저는 MemberNotificationRepository를 조회할 때 member와 notification을 각각 한번씩 조회해서 인자로 들어온 member와 notification이 실제로 DB에 있는지 체크하고, 그 다음에 insert하는 로직을 구현했습니다. 아래는 Member의 Entity 코드의 일부입니다.
public class Member extends BaseEntity {
...
@OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private Cart cart;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Wishlist> wishlists = new ArrayList<>();
...
}
여기서 JPA 테이블 간 연관관계에 따라서 해당 테이블을 조회할 때 연관된 정보를 함께 가져올지(EAGER), 필요할 때 가져올 지(LAZY) 옵션을 정할 수 있습니다. 기본적으로 설정되어 있는 옵션은 다음과 같습니다.
| 어노테이션 | 관계 | 기본 전략 |
| @OneToOne | 1:1 | EAGER (즉시 로딩) |
| @ManyToOne | N:1 | EAGER (즉시 로딩) |
| @OneToMany | 1:N | LAZY (지연 로딩) |
| @ManyToMany | N:M | LAZY (지연 로딩) |
일반적으로 ...ToOne의 경우에는 연관된 데이터가 리스트가 아니라 단일 객체이므로 조인해서 같이 가져와도 성능에 관계없다고 생각해서 기본 값이 EAGER 입니다. 반면에 ...ToMany는 리스트를 가져와야 하므로 성능에 영향이 클 것이라고 생각해서 기본 값이 LAZY입니다.
제 코드에서는 Cart 객체가 OneToOne 관계로 설정되어 있어서 EAGER 값이 기본입니다. 제가 보낼 사용자를 조회하기 위해서 N개의 사용자를 조회할 경우, 최초에 한 번의 쿼리를 날릴 것입니다.
SELECT * FROM member WHERE id IN (...) --> (a)
이때 JPA가 영속성 컨텍스트에 N명의 사용자 정보를 저장할 때, Cart가 EAGER로 되어있으므로 해당 정보도 같이 영속성 컨텍스트에 올려야 합니다. 이 영속성 컨텍스트에 올리는 작업을 진행할 때는 DB에서 반환한 N개의 Row를 자바의 객체 형태로 바꾸기 시작하는데, 여기서 for loop를 사용해서 하나하나 바꿉니다. 여기서 하나하나 바꿀 때 EAGER Cart 를 발견하고 Cart에 대한 정보를 얻기 위해서 쿼리를 날립니다.
SELECT * FROM cart WHERE member_id = id --> (b)
이렇게 EAGER 처리된 관계의 정보까지 다 포함해서 하나의 객체를 만들어서 영속성 컨텍스트에 저장하면 그 다음 Row를 객체로 만듭니다. 이렇게 되면 (b) 쿼리가 N번 나가게 되어서, 총 1 + N번의 쿼리가 발생하게 되는 것입니다.
물론 EAGER가 설정된 Cart 객체를 미리 판단해서 N개를 배치 형태로 한 번에 가져오는 기능을 JPA에서 제공하고 있습니다. for loop을 돌 때 첫 번째 Row를 처리할 적에 EAGER 속성의 관계를 발견할 경우 뒤의 Row도 전부 필요할 것이므로 미리 메모리에 다 올려놓는 전략입니다.
if ( spring.jpa.properties.hibernate.default_batch_fetch_size=100 option on )
SELECT * FROM cart WHERE member_id IN (1, 2, 3, ... N)
가설 (2)
저의 경우에는 Member 객체에서 Cart를 LAZY로 바꿔도 효과가 없습니다. Member 객체를 DB에서 조회할 때 cart 필드를 채울 때 JPA는 Proxy로 채워 넣을 지, null 값을 넣을 지 선택합니다. 그런데 mappedBy = "member"라는 것은 Member에 cart_id와 같은 외래키가 없다는 것을 의미합니다. 즉, null 값인지 아닌지 판별할 때 Member 테이블만 봐서는 판단할 수 없다는 의미입니다. 따라서 쿼리를 날려서 카트를 조회할 수 밖에 없습니다. Cart 기능을 개발하신 분과 소통한 결과 Member를 이용해서 cart를 조회하는 로직이 존재하므로, 컬럼도 삭제할 수 없습니다. 따라서 Cart를 가져오되 Batch size=K를 켜서 쿼리 횟수를 N개에서 N/K로 줄이는 방법으로 개선하면, 좀 더 효율적으로 Select 쿼리의 성능을 개선할 수 있을 것입니다.
가설 (2) 개선 시도
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
이제 1만명을 조회할 경우에는 1만 번의 쿼리가 아니라 10번의 쿼리가 나가게 될 것입니다.
가설 (2) 개선 결과

3740ms에서 2880ms로 900ms 가량 줄었습니다. 쿼리 1만번을 10번으로 줄이면 1초 정도 줄어듦을 확인했습니다. 아직은 기준치에 미치지 못하고 있군요.
서버 로그를 보니 cart 조회가 줄어들긴 했으나, 아직도 꽤 많이 보여서, 적어도 10번의 하이버네이트 쿼리는 아닌 것으로 보입니다. Insert 로직을 다시 한번 살펴보겠습니다.
public List<MemberNotification> createMemberNotification(List<Long> receiverIds, Long templateId, Map<String, String> variables) {
Notification template = notificationRepository.findById(templateId)
.orElseThrow(() -> new EntityNotFoundException("해당 타입의 알림 템플릿이 없습니다."));
String jsonMetaData = convertToJson(variables);
List<Member> receivers = memberRepository.findAllById(receiverIds);
if (receivers.size() != receiverIds.size()) {
throw new EntityNotFoundException("해당하는 멤버가 없습니다.");
}
return receivers.stream()
.map(receiver -> new MemberNotification(receiver, template, jsonMetaData))
.toList();
}
이 코드는 DB에 insert 하기 직전에 넣을 Tuple에 대응하는 객체를 만드는 메서드입니다. 여기서 memberRepository.findAllById()를 이용해서 MemberNotification을 만들 때 넣을 객체를 만들고 있습니다. 근데 MemberNotification에서 사용하는 Member 객체의 정보는 memberId 하나입니다. 즉, memberId 하나를 사용하자고 모든 Member 정보를 다 불러오고 있는 셈입니다. 이러면 불필요한 정보가 과하게 select돼서 성능이 저하될 것입니다. member의 컬럼 개수가 k개라면, N명의 데이터를 불러오는데 K-1 배의 데이터를 더 가져오고 있는 셈입니다. column이 8개 이므로 7배의 불필요한 데이터를 가져오고 있었네요.
Proxy 객체
JPA에서 em.getReference()를 호출하거나 LAZY 로딩을 하게 되면, 하이버네이트가 사용자가 정의한 클래스를 상속 받은 가짜 클래스를 만듭니다.
아래의 코드는 실제 Hibernate에서 구현된 코드를 발췌한 부분입니다.
// Hibernate.internal.SessionImpl.java
@Override
public <T> T getReference(Class<T> entityClass, Object primaryKey) {
// LoadEvent 생성 (여기서 옵션으로 INTERNAL_LOAD_EAGER가 아니라 LOAD를 씁니다)
// Load로 설정해야 추후에 프록시로 설정할 지, 실제로 가져올 지에 대한 분기문에서 판단 가능
LoadEvent event = new LoadEvent(
primaryKey,
entityClass.getName(),
false, // isAssociationFetch
this
);
fireLoad( event, LoadEventListener.LOAD ); -> 아래의 onLoad 호출
...
}
// org.hibernate.event.internal.DefaultLoadEventListener.java
public void onLoad(LoadEvent event, LoadType type) {
...
EntityKey keyToLoad = event.getSession().generateEntityKey( event.getEntityId(), persister );
Object entity = session.getPersistenceContext().getEntity( keyToLoad ); // 영속성 컨텍스트에 이미 있는지 확인
if ( entity != null ) {
event.setResult( entity ); // 있으면 캐시된 값 리턴
} else {
// 없으면 프록시 만들까, DB 조회를 할지 선택
// getReference()는 type.isAllowProxyCreation()이 true입니다.
proxyOrLoad( event, persister, keyToLoad, type == LoadEventListener.LOAD );
}
}
private void proxyOrLoad(
LoadEvent event,
EntityPersister persister,
EntityKey keyToLoad,
LoadType options) {
if ( traceEnabled ) LOG.tracev( "Loading entity: {0}", MessageHelper.infoString( persister, event.getEntityId(), event.getSession().getFactory() ) );
// options.isAllowProxyCreation()이 true이면 프록시를 만듦.
if ( options.isAllowProxyCreation() ) {
// 프록시 객체 생성 (ByteBuddy 등의 라이브러리 사용)
Object proxy = persister.createProxy( event.getEntityId(), event.getSession() );
// 영속성 컨텍스트에 프록시로 만들었다고 등록
event.getSession().getPersistenceContext().addProxy( keyToLoad, proxy );
// 결과 반환
event.setResult( proxy );
} else {
// find() 였으면 여기로 와서 DB 조회 쿼리 날림 (doLoad)
doLoad( event, persister, keyToLoad, options );
}
}
코드를 살펴보면, getReference()를 선언하면 isAllowProxyCreation()이 true값인 상태로 onLoad 메서드가 실행됩니다. 그리고 영속성 컨텍스트 내에 해당 정보가 있으면 그 값을 반환하고, 없으면 proxyOrLoad 메서드를 호출해서 실제로 DB를 조회해서 가져올 건지, 아니면 프록시 객체를 만들 것인지 isAllowProxyCreation() 값을 보고 판단합니다. 즉, getReference()를 사용하면 영속성 컨텍스트 안에 없으면 더미 객체를 반환한다는 것이죠.
가설 (3)
멤버 객체 전체를 조회하지 말고, id값만 가지고있는 더미인 프록시 객체를 할당해서 넣으면 조회 성능이 개선되면서 전체 성능이 좋아질 것입니다.
가설 (3) 개선 시도
public List<MemberNotification> createMemberNotification(List<Long> receiverIds, Long templateId, Map<String, String> variables) {
Notification template = notificationRepository.findById(templateId)
.orElseThrow(() -> new EntityNotFoundException("해당 타입의 알림 템플릿이 없습니다."));
String jsonMetaData = convertToJson(variables);
return receiverIds.stream()
.map(memberId -> {
Member proxyMember = memberRepository.getReferenceById(memberId);
return new MemberNotification(proxyMember, template, jsonMetaData);
})
.toList();
}
이렇게 코드를 수정해서 다시 한번 결과를 보겠습니다.
가설 (3) 개선 결과



509ms로 개선되었습니다! 최초의 목적인 1000ms 아래로 유지됨으로써 기준치에도 부합합니다. 결과 로그도 살펴보면,
Hibernate:
select
m1_0.id
from
member m1_0
Hibernate:
select
n1_0.id,
n1_0.created_at,
n1_0.link,
n1_0.message,
n1_0.title,
n1_0.type,
n1_0.updated_at
from
notification n1_0
where
n1_0.id=?
1. 멤버의 ID 목록을 전부 긁어오고, 2. 템플릿 1개를 조회하였습니다. 3. member 객체는 프록시로 대체했기 때문에 불러올 필요가 없어서 하이버네이트 로그가 사라졌습니다.
가설 2가 아쉽게도 가설 3에 의해서 개선 결과가 무용지물이 됐지만, 그래도 여러 방면으로 어떻게 최적화할 것인가의 방향성을 찾게해준 삽질(ㅠㅠ)이었던 것 같습니다...
이로써 1만명에 대한 DB 알림 기록은 목표치 달성에 성공했습니다. 다음으로는 더 높은 수치인 5만명, 10만명에 대한 알림 전송으로 늘려서 스트레스 테스트를 진행해보겠습니다!
❓영속성 컨텍스트는 왜 거쳐야 하는거야?
영속성 컨텍스트는 자바 객체와 DB 사이의 완충 지대 역할이자 메모리장입니다. 버퍼와 캐시의 역할을 하는 것이죠.
[Java 애플리케이션] <---> [영속성 컨텍스트] <---> [데이터베이스]
이 영속성 컨텍스트가 DB에 넣기 전에 데이터를 잠깐 데리고 있으면서 관리를 해줍니다. 이 것을 거치면 얻는 효과들은 다음과 같습니다.
- Dirty Checking (변경 감지)
- 영속성 컨텍스트가 없으면 데이터 하나 수정할 때마다 SQL을 짜야 합니다.
- Dirty Checking이 없을 경우 (MyBatis, JDBC ...)
- 객체 값 수정 (user.setName("A"))
- 이 경우에는 개발자가 UPDATE 쿼리를 작성해서 실행해줘야 합니다.
- Dirty Checking이 있을 경우 (JPA)
- 영속성 컨텍스트에 있는 객체 값 수정 (user.setName("A"))
- 트랜잭션이 끝날 때 영속성 컨텍스트가 알아서 변경된 것을 감지하고 UPDATE 쿼리를 날립니다.
- L1 Cache
- 같은 트랜잭션 안에서 user(id:1)을 N번 조회하는 상황입니다.
- 영속성 컨텍스트가 없을 경우
- DB에 SELECT 쿼리를 N번 날립니다.
- 영속성 컨텍스트가 있을 경우
- 처음에만 DB에 가고, 나머지는 영속성 컨텍스트에 저장된 값을 가져옵니다. 즉 쿼리가 1 번 날라갑니다.
- 쓰기 지연 (Transactional Write-Behind)
- INSERT, UPDATE 쿼리를 바로 안 보내고 창고에 쌓아뒀다가 커밋할 때 한 번에 쏘는 기능입니다.
- 네트워크 비용이 절약되기 때문에 성능이 개선됩니다
❓@Transient & Transient State? JPA의 객체 상태
JPA 엔티티 생명주기 4단계
1. 비영속(Transient State / New)
JPA의 생명주기 상태인 Transient State는 비영속 상태를 의미합니다.
JPA는 객체의 상태를 4가지로 구분하는데, 그 중에 갓 태어난 상태를 Transient라고 부릅니다.
즉, new로 생성은 했으나 아직 영속성 컨텍스트가 알지 못하고, DB 식별자도 없는 상태입니다. JPA랑 전혀 상관없는 상태인 거죠.
User user = new Member("user1"); // 자바 객체. JPA가 모르고, ID도 없음.
repository.save(user); // 영속성 컨텍스트가 관리함.
2. 영속 (Managed / Persistent)
영속성 컨텍스트에 들어가서 Map에 등록된 상태입니다. 이때부터 JPA의 모든 동작이 적용됩니다.
Id를 가지고 있어야 맵에 등록할 수 있습니다. 트랜잭션 커밋 시점에 DB와 동기화됩니다.
em.persis(user) // 저장
em.find(id) // DB 조회
3. 준영속 (Detached)
이전에는 영속 상태였다가 영속성 컨텍스트에서 나온 상태입니다.
이전에 영속 상태였으므로 식별자가 존재합니다. 다만 관리 대상이 아니므로 dirty checking이 일어나지 않습니다.
트랜잭션이 끝나서 영속성 컨텍스트가 닫힐 때 자동으로 detached 상태가 됩니다.
em.detach(member);
4. 삭제 (Removed)
엔티티를 삭제하기로 하고 영속성 컨텍스트에 삭제 표시를 해놓은 상태입니다.
트랜잭션 커밋 시점에 실제 DB (DELETE) 쿼리가 날라갑니다.
em.remove(member);
@Transient
Entity에서 DB 컬럼이랑 매핑하고 싶지 않고, 객체 안에서만 임시로 쓸 변수에 선언할 수 있는 어노테이션입니다.
JPA Persistence API의 @Transient와 Spring Data 규격의 @Transient가 있고, 구분이 필요합니다.
@Entity
public class Member {
@Id
Long id;
String name; // DB에 저장됨
@Transient
boolean isLogin; // DB에 저장 안 됨 (메모리에서만 사용)
}
참고 문헌
[JPA] @Transient와 @Transient? (Spring Data와 JPA의 관계에 대해서)
프로젝트 개발하면서 @Transient Annotation과 관련하여 고생한 경험을 바탕으로, 깨달은 점을 정리해보려고 합니다.@TransientSpecifies that the property or field is not persistent. It is used to annotate a property or field
dogfood.tistory.com
'Test' 카테고리의 다른 글
| [Test] 점진적 부하테스트: 10,000명 유저의 SSE 메시지 전송 (1) | 2026.02.19 |
|---|---|
| [Test] 점진적 부하테스트: 1,000,000명 유저의 알림 전송 요청 - 스레드 (1) | 2026.02.10 |
| [Test] 점진적 부하테스트: 30,000명 유저의 SSE 구독 요청 - TCP (0) | 2026.02.04 |
| [Test] 점진적 부하테스트: 10,000명 유저의 SSE 구독 요청 - 톰캣 (0) | 2026.02.03 |
| [Test] 500명 유저의 동시 로그인 요청 처리 (0) | 2026.02.02 |