(JVM) Garbage Collection Advanced
들어가기에 앞서
이 글은 이일웅 님께서 번역하신 자바 최적화란 책을 읽던 도중 공부한 내용을 정리한 글입니다.
절대 해당 책의 홍보는 아니며 좋은 책을 써준 사람과 번역해주신 분께 진심으로 감사하는 마음에 썼습니다.
이 글을 읽어보시기 전에 Garbage Collection Basic 편을 읽어보시면 더 도움이 될 것입니다 :)
이 글은 이일웅 님께서 번역하신 자바 최적화란 책을 읽던 도중 공부한 내용을 정리한 글입니다.
절대 해당 책의 홍보는 아니며 좋은 책을 써준 사람과 번역해주신 분께 진심으로 감사하는 마음에 썼습니다.
이 글을 읽어보시기 전에 Garbage Collection Basic 편을 읽어보시면 더 도움이 될 것입니다 :)
Spring Data JPA를 이용하다보면 종종 org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags
이란 메세지를 보게 된다.
우선 어떤 상황에 나타나는지 한 번 살펴보자.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41@Entity
@Getter
@NoArgsConstructor
public class Mother {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "mother", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<Daughter> daughters = new ArrayList<>();
@OneToMany(mappedBy = "mother", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List<Son> sons = new ArrayList<>();
@Builder
public Mother(final Long id, final List<Daughter> daughters, final List<Son> sons) {
this.id = id;
if(daughters == null) this.daughters = new ArrayList<>();
else {
daughters.forEach(daughter -> daughter.setMother(this));
this.daughters = daughters;
}
if(sons == null) this.sons = new ArrayList<>();
else {
sons.forEach(son -> son.setMother(this));
this.sons = sons;
}
}
public void bearDaughters(final List<Daughter> babyDaughters) {
babyDaughters.forEach(daughter -> daughter.setMother(this));
daughters.addAll(babyDaughters);
}
public void bearSons(final List<Son> babySons) {
babySons.forEach(son -> son.setMother(this));
sons.addAll(babySons);
}
}
엄마가 있고, 아들/딸들이 있는데 아들/딸들을 EAGER로 fetch해 올 때 발생한다.
(즉, OneToMany, ManyToMany인 Bag 두 개 이상을 EAGER로 fetch할 때 발생한다.)
EAGER로 땡겨오면 N+1 쿼리 문제가 존재하기 때문에 fetchType을 전부 LAZY로 바꾼 후 한 방 쿼리로 불러와도 문제는 재발한다.
오랜만에 Spring Data JPA를 가지고 뭔가 뻘뻘 대보고 있었다.
하지만 내 의도대로 동작하지 않았다.
아래 코드를 보자.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16@RunWith(SpringRunner.class)
@DataJpaTest
public class MotherTest {
@Autowired
private SomeEntityRepository repository;
@Before
public void setup() {
repository.save(new SomeEntity());
}
@Test
public void test() {
repository.findById(1L);
}
}
우선 테스트를 돌릴 때마다 DB를 초기화했다. (인메모리 DB인 H2를 사용했다.)
따라서 테스트 할 데이터를 setup 메서드를 통해 데이터를 DB에 밀어넣고 있었다.
그리고 테스트 케이스에서 해당 엔터티를 불러오는 간단한 코드인데 나는 select 쿼리가 날아갈 줄 알았다.
하지만 insert 쿼리만 날아가고, 이거 가지고 코드를 이리저리 바꿔보며 온갖 삽질을 한 것 같다.
왜 select 쿼리가 찍히지 않을까… 한 2시간 가까이를 이거 때문에 계속 삽질하고 있었다.
그리고 스프링 관련 커뮤니티에 질문하려고 아마 SomeEntity 엔터티가 생성되면서 ID 값이 어딘가에 저장돼서 동일한...
까지 딱 치고 있는데
어딘가 저장에 딱 꽂혀서 아! 맞다! 하고 그동안 JPA를 안 쓴 지 오래돼서 까먹었구나… 하고 한참동안 너무 허무했었다.
어느 날 서비스가 갑자기 다운되는 사례가 발생했다.
다행히 서버를 이중화시켜놓아서 장애가 발생하진 않았지만 그래도 왜 다운된 건지 원인 분석을 해야했다.
나의 실수로 인해 WAS 로그는 제대로 남겨져있지 않았고, CTO 님께서 힙 덤프 같은 거라도 떠져있나 보라고 하셔서 지푸라기라도 잪는 심정으로 기대를 했는데 희망을 저버리지 않았다.1
2-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=./jvm.hprof
위 옵션으로 인해 OOME(Out of Memory Exception) 발생 시 힙 덤프를 뜨게 해놓았는데 다행히 힙 덤프가 존재했다.
여기서 힙 덤프는 힙 메모리의 내용을 그대로 떠놓은 파일이다.
따라서 힙 메모리에 어떤 객체들로 가득 채워져있었는지 분석할 수 있게 되었다.
여기서 흥분해서 서버에서 vi 등등을 이용해 힙 덤프 파일을 열면 안 된다. (용량이 큰 로그 및 다른 파일도 물론 서버에서 절대 열면 안 된다.)
여는 순간 힙 메모리 사이즈만큼 서버의 메모리를 사용하게 돼서 서버가 다운될 수도 있다.
무조건 scp 등등의 명령어를 통해 로컬로 복사한 후에 열어보는 습관을 가지자.
얼마 전에 서버를 새롭게 이전했다.
기존에 있던 local의 upstream server 대신에 새로운 서버로 업스트림을 걸어놨다.1
2
3
4
5
6
7http {
server {
location / {
proxy_pass http://elb-dns.ap-northeast-2.elb.amazonaws.com;
}
}
}
LB에 바로 도메인을 붙여도 되지만 롤백을 최대한 빨리하기 위해 기존 서버에서 LB로 업스트림 걸어놓았다.
실제로 우리가 간단하게 서버를 배포하는 시나리오를 생각해보자.
만약 서버가 뜨는데 30초가 걸린다고 하면 최소 30+@초만큼 다운타임(유저에게 서비스가 불가능한 시간)이 발생한다.
현대의 어플리케이션이라면 유저에게 최상의 경험을 제공해주기위해 이런 다운타임이 없는 무중단 배포를 지원해야한다.
이 글에서 Docker와 Spring Boot, Gradle에 대한 기본적인 지식은 있다고 판단하고 설명한다.
프로젝트는 spring-boot-docker-demo 저장소에서 단계별로 브랜치를 확인해보면 된다.
이해를 돕기 위해 docker image tag 단위로 branch를 땄다.
프로젝트의 build.gradle은 아래와 같다.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21plugins {
id 'org.springframework.boot' version '2.1.4.RELEASE'
id 'java'
}
apply plugin: 'io.spring.dependency-management'
archivesBaseName = 'demo'
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
jcenter()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.projectreactor:reactor-test'
}
기본적으로 SpringMVC에서 많이 사용하는 WAS인 Tomcat의 경우에는 기본적으로 쓰레드 풀의 갯수가 200개이고,
Jetty의 경우에는 기본적으로 minimum 8개에서 maximum 200개로 설정돼있다.
쓰레드 생성 비용은 비싸므로(오래 걸리므로) 미리 생성해서 ThreadPool에 쌓아놓는 것이다.
여기서 말하는 Thread는 Green Thread vs Native Thread에서 얘기하다 싶이 Native Thread(OS에서 관리하는 Thread)이다.
이 말은 동시에 요청을 최대 200개까지 처리 가능하단 얘기이다.
그에 반해 Webflux는 core * 2의 Thread만을 생성한다.
SpringMVC에 비해 턱없이 모자란 쓰레드 갯수이고 그럼 싱글 코어의 경우에는 동시에 2개의 요청밖에 처리하지 못할 것처럼 보인다.