결국에는 JpaRepository를 상속받은 인터페이스명 + Impl을 붙인 클래스는 Spring이 자동으로 커스텀 구현체로 감지한다는 것이었고,
BoardJpaRepository프록시를 생성하려고 하니, 커스텀 구현체(BoardJpaRepositoryImpl)를 생성해야 하고, BoardJpaRepositoryImpl는 생성자에서 BoardJpaRepository를 주입받도록 정의되어있으니까, 생성 -> 주입 -> 생성 -> ... 의 무한 루프에 빠지게 되는 것이었습니다.. 단순히 BoardJpaRepositoryImpl 같은 네이밍이 아닌 다른 이름, 저는 BoardJpaCustomRepositoryImpl 로 바꾸어 해결했습니다.
굳이 BoardJpaRepositoryImpl를 써야겠다고 하면 공식문서에는 @EnableJpaRepositories 의 repositoryImplementationPostfix 옵션을 바꾸거나, @Bean 팩토리 메서드를 직접 만들라고 합니다.
이전 프로젝트에서 자기소개서 목록을 페이징하는 로직을 Page 인터페이스로 구현했는데, 데이터가 대량일 경우 성능이 떨어진다는 것을 발견했고 리팩토링할 필요가 있었습니다.
1.1 기존 구현 코드
Controller
@GetMapping("/essays") // 전체 자소서 목록 불러오기
public ResponseEntity<?> showEssays(@AuthenticationPrincipal CustomMemberDetails member, Pageable pageable) {
Page<EssayDto> list = essayService.findEssayByUserId(member.getUsername(), pageable);
return BaseResponse.okWithData(HttpStatus.OK, "에세이 목록 불러오기 성공", list);
}
Service
@Service
public class EssayService {
@Override
public Page<EssayDto> findEssayByUserId(String userId, Pageable pageable) {
Member member = memberRepository.findByUserId(userId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저"));
return essayRepository.findByMember(member, pageable).map(EssayDto::from);
}
}
key 에서 사용하는 인덱스는 동일한데, Extra를 보면 Using Index가 추가됐습니다.
인덱스만을 이용해서 데이터를 뽑았다는 거죠. 이렇게 인덱스의 데이터만으로 조회할 수 있는 인덱스를 Covering Index라고 합니다.
3. 최적화 방안: Covering Index 활용
3.1 Covering Index란?
인덱스 자체만으로 쿼리의 모든 데이터를 처리할 수 있는 인덱스.
데이터(Clustered Index)를 읽지 않고, 인덱스(Secondary Index)가 포함된 정보만으로 쿼리가 가능한 인덱스
3.2 최적화 적용
Covering Index 적용 전후 비교
-- 기존 방식
SELECT * FROM essay
WHERE user_id = 1
ORDER BY id DESC
LIMIT 30 OFFSET 1499970;
실행 속도: 7.6초
-- Covering Index 활용
SELECT * FROM (
SELECT id FROM essay
WHERE user_id = 1
ORDER BY id DESC
LIMIT 30 OFFSET 1499970
) t LEFT JOIN essay ON t.id = essay.id;
서브쿼리를 실행하는 과정에서 파생 테이블(Derived)이 생기지만 그 과정에서 Using Index로 Covering Index가 사용됐습니다. 작은 규모의 파생 테이블과 조인하여 N건에 대해서만 Clustered Index에서 가져오기 때문에 빠르게 처리하는 것입니다.
3.3 성능 개선 결과
방식
실행 시간
기존 방식
7.6초
Covering Index 사용
0.32초
성능 향상률
약 95.79%
커버링 인덱스를 사용하면 불필요한 PK Lookup을 최소화하여 성능이 크게 향상됨.
4. 성능 개선 원리
Secondary Index를 활용하여 인덱스 스캔 수행
서브쿼리를 활용해 Covering Index를 통해 필요한 id 목록만 가져오고, 이후 조인하여 데이터를 조회
5. 결론
기존 OFFSET 기반 페이징 방식은 데이터가 많아질수록 비효율적.
Covering Index를 활용하여 성능을 최적화 가능.
결과적으로 기존보다 95.79% 성능 향상 효과를 볼 수 있음.
대량 데이터 페이징 시 Covering Index를 적극 활용하자.
6. 정말 끝일까?
이제 50,000번 페이지를 조회해도 빠르게 처리가 잘 됩니다.
정상적으로 서비스를 하는데 무리도 없어보이죠.
근데 정말 이게 끝일까요?
대부분의 경우에서 별 문제가 없을 거긴 합니다.
만약에 50,000번 페이지가 아닌 300,000번 페이지를 조회한다면?
Secondary Index만 탄다고 해도, 결국 offset 만큼 Index Scan이 필요하게 됩니다.
데이터에 접근하지 않더라도 offset이 늘어날 수록 느려질 수 밖에 없는 것입니다.
해결 방법은 다양하지만 아래와 같은 해결 방법이 있습니다.
데이터를 한 번 더 분리한다
1년 단위로 테이블 분리
개별 테이블의 크기를 작게 만들고
각 단위에 대해 전체 게시글 수를 관리한다.
offset을 인덱스 페이지 단위로 skip하는 것이 아니라 1년 동안 작성된 게시글 수 단위로 즉시 skip
조회하고자 하는 offset이 1년 동안 작성된 게시글 수보다 크다면
해당 개수만큼 즉시 skip하기
더 큰 단위로 skip 수행 가능
Application에서 코드 처리 필요함
30만번째 페이지를 조회하는 것이 정상적인 사용자일까?
정책으로 막아버리기
게시글 목록 조회는 50,000번 까지만 가능
시간 범위 또는 텍스트 검색 기능
더 작은 데이터 집합 안에서 페이징 수행
무한 스크롤
페이지 번호 방식에서는 동작 특성 상, 뒷 페이지로 갈수록 속도가 느려질 수밖에 없습니다.
인프런 널널한 개발자 강사님의 기초 탄탄! 독하게 시작하는 Java - Part2 : OOP와 JVM의 강의 중 JVM내용 정리
Class Loader는 우리가 직접 만들수도 있다고 합니다. C/C++로 구현하지 않고 JAVA로도 직접 구현해서 사용할 수 있다고 하네요.
Class Loader에 대해 이해하기 위해 .class 파일과 바이트 코드에 대해 짚고 넘어갑니다.
.class 파일은 .java파일을 컴파일 했을때 생성되는 자바 바이트 코드입니다.
이것을 Class Loader가 Loading하는 것입니다. 윈도우로 치면 .exe, .dll 같은 확장자를 가진 실행파일(PE)이 JVM에서는 .class파일인 겁니다.
class 파일의 구조는 아래와 같다고 합니다.
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
위에서 부터
u4는 unsigned 캐릭터 아마 4바이트
밑으로 minor_version, major_version 정보가 나와있고
그 밑에 중요한 constant_pool_count 얘기가 나옵니다. 아무튼 이것저것 this class, super class, field, method, 이런정보가 담겨져 있구나 라는걸 알 수 있씁니다.
사진은 대표적인 Hello World! 코드를 컴파일한 .class 을 16진수 편집기로 열어본 것입니다.
맨 앞의 CA FE BA BE 이 4바이트가 위에서 u4에 해당하는 거고, 그 뒤로 major/minor 버전, constant pool 등등 정보가 담겨져 있는 것입니다. 근데 뭐 저거만 보고 리버스 엔지니어링을 하는 것은 힘들기 때문에 보통 도구를 이용해서
.class 파일을 분석하기도 한다고 합니다.
IntelliJ에서도 바이트코드를 볼 수가 있는데
바이트 코드 보는법은 생략하고 저런식으로 더 쉬운 형태로 변환하여 .class 파일을 분석할 수 잇다고 합니다.
.class 파일에 들어있는 형태인 자바 바이트 코드의 스트림 형태로 변환이 되어서 로딩되고 적재된 다음에 어쩌구저쩌구 쭈욱 가는겁니다.
자바 바이트코드의 실행의 근거가 되어주는 것들은 JVM에서의 명령들입니다. 근데 JVM 자체가 가상 머신이지 진짜 머신(CPU)는 아닙니다. 그래서 결과적으로 그것들(바이트코드들)을 CPU가 인식할 수 있는 Native코드로 바꾸어 주어야 합니다. 그래서 그 과정에서 사용되는 컴파일러가 등장합니다. 그래서 컴파일러가 두 번 등장하는데
첫 번째로, .java파일을 .class 파일로 바꾸는 컴파일러.
두 번째로, .class파일을 CPU가 이해할 수 잇는 Native 코드로 바꾸는 컴파일러
인프런 널널한 개발자 강사님의 기초 탄탄! 독하게 시작하는 Java - Part2 : OOP와 JVM의 강의 중 JVM내용 정리
개발을 하고 서비스를 하면서 장애가 났을때에 다양한 원인이 있겠지만, 해결을 하려할 때 JVM에 대한 깊은 이해가 없을경우 해결을 못하는 경우가 있습니다.
JVM에 대한 이해를 쌓고자 강의를 들으면서 알게 된 것들과 중요한 내용을 지금부터 정리해보겠습니다
JVM은 결과적으로 보면 프로세스이다. java.exe 실행 파일을 실행시켜서 그 위에서 내가 만든 어플리케이션을 돌리는 것이다.
JVM은 기본적으로 Virtual Machine이다.
여담
널널한 개발자님 강의를 보다 보면 설명을 하시면서 물리적/논리적 개념으로 나누는데
물리적인 부분 == H/W 논리적인 부분 == S/W 으로 봐도 된다.
또한 논리적이다 라는 말은 가상이라는 개념으로 봐도 된다고 종종 말씀하신다. logical 을 IT쪽에서는 Virtual이라고 한다. 그리고 컴퓨터에서 CPU를 machine이라고도 한다고 하는데 그렇다면 Vircual Machine이다 라고 하면 CPU를 논리적으로 구현한것이다 라고 해석할 수 있다.
강사님께서 정말 많이 자주 그리시는 컴퓨터의 형태이다.
하드웨어 영역과 소프트웨어 영역으로 나누어져 있고, 소프트웨어영역은 Application영역과 System영역이 있다. System 영역의 가장 대표적인게 운영체제인데, 운영체제의 핵심적인 알맹이를 커널이라고 한다.
커널은 그림에서 Kernel 영역에서 돌아가는 것이고
User Mode Application 영역이 있는데, 이것은 이제 위 그림에서 User 영역에서 돌아가는것이다.
우리가 소위 Native라고 부르는 영역은 H/W부터 Kernel까지 포함하는 구간이다.
그래서 그 부분을 개발하는것은 C/C++과 같이 Native언어로 개발을 하는데, C/C++코드가 CPU가 이해할 수 있는 언어로 직접 번역이 되어 실행합니다.
그렇기떄문에 H/W나 OS에 의존성이 있고, 의존성이 있다는 말은 Window에서 개발한 C/C++코드가 있으면 그 코드는 리눅스에서 제대로 동작하지 않을 수 있다는 것입니다.
처음에 말했듯 VM이라는 것은 컴퓨터를 소프트웨어적으로 구현한 것이다. 머신은 머신인데 이 머신이 인식할 수 있는 코드가 자바 바이트 코드라면 그걸 JVM 이라고 한다는 것이다.
결과적으로 JVM이라는것을 말하면 소프트웨어로 CPU도 구현돼있고, RAM도 구현돼있고, SSD도 구현돼있는 것이다. 따라서 JVM은 User Mode Applicatio 프로세스 임과 동시에 가상 메모리 공간을 RAM이나 보조기억장치처럼 활용 하면서 동시에 머신으로 연산도 해주는기능을 모두 포함하고 추가로 운영체제가 제공하는 기능 일부까지 다 들어있습니다.
따라서 JVM은 CS론에서 "OS/컴퓨터구조 를 전부 섞어서 C나 C++로 구현" 한게 JVM이 된다는 것이다.
그래서 위에서 잠깐 설명한 네이티브영역을 개발하는 언어들과 다르게 JVM만 있다면 내가 만든 자바코드는 JVM이 돌려주기 때문에 OS에 영향이 없이 JVM만 있다면 돌릴 수 있는 것이다.
Ready상태에 2건이 있는걸 볼 수 있는데 이건 Queue에 들어가긴 했는데, Consumer가 어떤 이유때문에 처리를 못 한 경우입니다. 큐를 생성할때 durable을 true로 설정했기 때문에 다시 서버를 시작하더라도 Queue에는 처리되지 않은 메시지들이 남아있을거에요. 그래서 처리될 때까지 기다릴 겁니다.
그래서 에러가 나면 빠르게 프로그램을 수정해서 재처리가 되게 하거나, Purge를 통해 메시지를 날려버려야 합니다..
생성자의 인자로 두 가지를 받는데 첫 번째는 QUEUE_NAME, 다음은 boolean타입의 duration 입니다.큐네임은 말그대로 메시지가 쌓이고 처리될 큐의 이름을 지정하는 것이고duration은 큐가 휘발성인지, 영속성인지 여부를 정하는 옵션입니다.false로 설정하면 서버가 종료되거나 재시작 할 때 큐의 메시지가 사라집니다.
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory){
return new RabbitTemplate(connectionFactory);
}
JDBCTemplate과 비슷하게 RabbitMQ와 상호작용하기 위한 간단한 API를 제공합니다. 주로 메시지 전송을 담당합니다. ConnectionFactory는 RabbitMQ와의 연결을 관리하는 객체로 rabbitTemplate에 주입하여 메시지를 전송할 때 사용할 Connection을 제공합니다. 메시지를 전송하는 Sender가 rabbitTemplate.convertAndSend() 메서드를 사용해 큐에 메시지를 넣는데 사용합니다
@Bean
public SimpleMessageListenerContainer container(ConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter){
SimpleMessageListenerContainer container = new SimpleMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.setQueueNames(QUEUE_NAME);
container.setMessageListener(listenerAdapter);
return container;
}
RabbitMQ 메시지를 비동기적으로 수신하기 위해 SimpleMessageListenerContainer 를 생성, 이 컨테이너가 특정 큐를 지속적으로 모니터링하고 메시지를 수신하면 지정된 리스너(MessageListenerAdapter)를 통 해 처리합니다. ConnectionFactory는 RabbitMQ와 연결을 유지하며, 수신하는 메시지를 이 연결 을 통해 가져옵니다. setQueueNames(QUEUE_NAME) 메서드는 특정 큐 이름을 설정. 이 컨테이너는 코드에서 설명한 큐 네임인 helloQueue에서 수신되는 메시지를 모니터링합니다. setMessageListener(listenerAdapter)는 listenerAdapter를 설정하여, 메시지가 수신될 때 호출할 리스너를 지정합니다.
@Bean
public MessageListenerAdapter listenerAdapter(Receiver receiver){
return new MessageListenerAdapter(receiver, "receiveMessage");
}
receiver 객체는 메시지를 처리하는 역할을 하는 Bean이며, receiveMessage 메서드 를 호출합니다. MessageListenerAdapter는 RabbitMQ에서 수신된 메시지를 특정 메서드에 전달 할 수 있도록 해줍니다 이 경우, receiveMessage 메서드가 자동으로 호출되며, 메시지 내용을 인자로 받습니다 Receiver 클래스의 receiveMessage 메서드가 메시지를 수신하여 처리할 수 있도 록 설정합니다. (RabbitMQ에서 수신된 메시지가 receiver.receiveMessage(String message) 메서드로 전달합니다.)
6. Sender 구현
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
@Component
public class Sender { // 메시지가 전달이 됐을때 RabbitTemplate을 통해 큐에 전달하는
private final RabbitTemplate rabbitTemplate;
public Sender(RabbitTemplate rabbitTemplate) {
this.rabbitTemplate = rabbitTemplate;
}
public void send(String message){
rabbitTemplate.convertAndSend(RabbitMQConfig.QUEUE_NAME, message);
System.out.println("[#] Sent : " + message);
}
}
RabbitTemplate을 주입받고 메서드를 사용합니다.
7. Receiver 구현
import org.springframework.stereotype.Component;
@Component
public class Receiver { // Consumer 역할
public void receiveMessage(String message){
System.out.println("[#] Received : " + message);
}
}
Receiver는 Consumer 역할을 하고, 메서드 이름을 Config에서
@Bean
public MessageListenerAdapter listenerAdapter(Receiver receiver){
return new MessageListenerAdapter(receiver, "receiveMessage");
}
어댑터에 적어준 이름과 같아야 합니다.
8. 테스트
적당하게 Controller 하나 만들어 줍니다.
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/message")
public class MessageController {
private final Sender sender;
public MessageController(Sender sender) {
this.sender = sender;
}
@PostMapping("/send")
public String sendMessage(@RequestBody String message){
sender.send(message);
return "[#] Message sent successfully " + message;
}
}
저는 postman을 이용해서 테스트 햇씁니다.
이렇게 날려보면?
이런 응답을 받았고 콘솔을 확인해보면?
잘 주고받은것 같습니다 이제 rabbitmq 모니터링 페이지에 가서 Queues and Streams 탭에 가보면
코드에서 설정한 QUEUE_NAME의 큐 이름으로 큐가 생겨있고 눌러서 들어가보면 위 화면처럼 트래픽을 확인할 수 있고