Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
youngreal committed Feb 21, 2024
2 parents f06f5d9 + 3d5c621 commit e3c392f
Showing 1 changed file with 31 additions and 38 deletions.
69 changes: 31 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## 소개
- 인프런 교육 플랫폼의 질문/스터디 모집 서비스를 모토로 한 개인프로젝트 입니다.
- 현재 단일 인스턴스로 배포되어있지만 scale-out 가능성을 염두하고 단일 인스턴스에서만 해결되는 문제해결 방법은 지양하는 챌린지를 했습니다.
- 부족하지만 **생각해 볼 수 있는 범위 내에서** **잠재적인 문제가 생길수 있는 상황을 만들어보고 해결해보는 능력을 기르기위한 챌린지**로, 엔지니어링 능력을 향상시키는 경험을 해보는게 목적이었습니다.
- 문제 해결에 추가적인 비용(아키텍처 추가, 학습비용 등)이 드는 방법들은 최후의 방법으로 두는 방식으로 진행했습니다.
- 트래픽 관련 정보는 [국내 커뮤니티 트래픽 정보](https://todaybeststory.com/ranking_monthly.html) 를 참고해 대략적으로 계산하였습니다.

Expand All @@ -19,7 +19,7 @@
## 문제 해결
- [ApplicationEventListener와 @Async로 회원가입 시 회원 저장과 이메일 전송의 강결합 + 레이턴시 증가 문제 개선하기](#1-applicationeventlistener와-async로-회원가입-시-회원-저장과-이메일-전송의-강결합--레이턴시-증가-문제-개선하기)
- [외부 서비스(Gmail)의 지연과 장애를 대비한 retry전략과 recover작성](#2-외부-서비스gmail의-지연과-장애를-대비한-retry전략과-recover작성)
- [redis 분산락으로 서버 간 동일한 인기글 리스트 갱신을 보장하기](#3-redis-분산락으로-서버-간-동일한-인기글-리스트-갱신을-보장하기)
- [shedLock으로 서버 간 동일한 인기글 리스트 갱신을 보장하기](#3-shedlock으로-서버-간-동일한-인기글-리스트-갱신을-보장하기)
- [인기글 조회에 트래픽이 몰려 대량의 update 쿼리가 발생하는 상황 해결하기](#4-인기글-조회에-트래픽이-몰려-대량의-update-쿼리가-발생하는-상황-해결하기)
- [LIKE %word%로 게시글 검색 시 full table scan이 발생해 레이턴시가 증가하는 문제를 fulltext-search로 개선하기](#5-like-word로-게시글-검색-시-full-table-scan이-발생해-레이턴시가-증가하는-문제를-fulltext-search로-개선하기)

Expand Down Expand Up @@ -49,7 +49,7 @@ public class MemberService {
}
```

### 요구사항
### 당시 요구사항
- 회원 저장에 성공하는 경우에만 메일 전송해야한다
- 메일은 최대한 가입 후 빨리 받아봐야한다

Expand All @@ -58,7 +58,7 @@ public class MemberService {

**회원가입 API응답 자체가 늦어지는 문제**
- 메일전송 자체가 늦어서 응답이 늦는거라면 즉시 메일을 보내지않고 요청이 들어왔다는 의미를 DB에 저장해두고 스케줄링 서비스로 차례로 메일을 보내주는 방법도 있으나, 인프런 서비스 상 회원가입후 메일인증을 하지않으면 서비스 이용에 제한이 있기때문에 이 방법은 회원가입후 최대한 빨리 메일을 받아야하는 요구사항을 충족시키지 못했습니다.
- 메일 전송을 비동기로 보내는 방법을 고려했습니다. 이중 스레드 풀, 작업을 등록하는 코드를 서비스코드에 침투시키지 않고 편하게 관리해 준다는점에서 @Async를 선택했습니다.
- 메일 전송을 비동기로 보내는 방법을 고려했습니다. 이중 스레드 풀, 작업을 등록하는 코드를 서비스코드에 침투시키지 않고 편하게 관리해 준다는점에서 @Async를 선택했습니다.

**회원과 메일의 결합을 줄이기위한 사고과정**
- 메일전송 이벤트를 생각해 봤을때, 서버간 이벤트를 공유할일이 없으므로 메시징 시스템같은 아키텍처를 추가하는것 보단 비용이 적은 스프링 이벤트 핸들러, AOP를 고려하게 되었습니다.
Expand Down Expand Up @@ -234,7 +234,7 @@ public class MailSentEventHandler {
- 큰 장애가 나서 당분간 복구가 안되는 상황이라면, retry자체가 낭비가 될수있으며 쓸데없는 응답시간이 길어지는 상황이 발생할수있습니다. 이 경우를 고려해야한다면 서킷브레이커나 fallbacak이라는 키워드를 학습해서 해결해 볼수 있습니다.
- Jitter라는 retry의 랜덤 재전송값을 부여하여 순서가 보장되질 않습니다. 예를들어 요청1,2가 각각 43020, 25초에 메일전송 요청이 들어오고, 요청에 실패해 43040초, 43037초에 retry될수 있습니다.

## 3. redis 분산락으로 서버 간 동일한 인기글 리스트 갱신을 보장하기
## 3. shedLock으로 서버 간 동일한 인기글 리스트 갱신을 보장하기

**당시 상황**

Expand All @@ -261,6 +261,7 @@ where ..
![image](https://github.com/youngreal/inflearn/assets/59333182/7829334f-856c-415e-a436-e0472b603670)

- 서버 간 select이 발생하는 사이 중간에 likes 테이블에 insert가 발생하면 **서버 간 서로 다른 인기글 리스트를 select**하는 문제를 발견했습니다.
- 이는 여러 서버에서 @Scheduled로 설정된 스케줄러가 여러번 실행되서 생기는 문제입니다.

### 해결과정

Expand All @@ -269,7 +270,7 @@ where ..


- 현재 게시글 조회 API는 GET /posts/{postId} 로, postId만 받아서 게시글을 조회합니다.
- 캐시 히트에 성공하는 postId인 경우 db에 update 하지않고 캐시에서 업데이트하며, 캐시 미스가 나면 db에 update가 발생합니다.
- 캐시 히트에 성공하는 postId인 경우 db에 update 하지않고 캐시나 메모리에서 업데이트하며, 캐시 미스가 나면 db에 update쿼리가 발생합니다.
```java
@Transactional
public PostDto postDetail(long postId) {
Expand All @@ -284,12 +285,7 @@ where ..
}

private void addViewCount(Post post) {
// 인기글이아니라면(레디스에없다면) 조회수 +1 업데이트, 레디스에있으면 레디스에 조회수 카운팅
if (likeCountRedisRepository.getViewCount(post.getId()) == null) {
post.plusViewCount();
} else {
likeCountRedisRepository.addViewCount(post.getId());
}
// 인기글이아니라면 조회수 +1 업데이트, 캐시메모리에 있다면 조회수 카운팅
}
```

Expand All @@ -303,16 +299,8 @@ where ..
2. **락으로 해결할수 있는가?**
- select for update나 mysql의 네임드락으로 시도해 봤을 때, 결국 서버 1 select -> 좋아요 insert 발생 -> 서버 2 select 순서로 요청이 들어오면 이 문제를 해결해 주지 못한다고 판단하였습니다.

3. **redis 도입**
- redis를 도입해 각 서버에서 여러 번 select 해서 데이터를 일치시키려는 것보다는, 락을걸고 1번만 select 하는 게 쉽게 해결하는 방법이라고 판단하였습니다. 또한 현재 조회수 갱신을 위해 외부 라이브러리인 hyperloglog를 쓰고있었는데 redis에서 자체지원 한다는점도 메리트로 고려했습니다.
- 락을 redis로 관리하게 되면서 레디스에 문제가 생긴다면 데이터 손실 등의 위험이 있지만, 인기글의 조회 수의 손실은 크리티컬 하지않기 때문에 천천히 개선해 보고자 했습니다.

### 잠재적 문제 & 한계
- 락을 얻은 하나의 서버가 만약 인기글 조회를 select하면서 문제가 생기면 다른 서버는 그걸 알방법이 없으며 결국 어떤 서버에서도 인기글을 select하지 못하는 문제가 발생합니다. 다만, 주기적으로 인기글 리스트를 갱신하는 서비스가 크게 중요하지 않다고 판단하여 문제를 잠재적으로만 인식하고있습니다.
### 24.02.15 추가
스케줄러를 Quartz로 변경하고 redis를 철회하는방법 고려
- 여러 서버에서 스케줄러 코드를 여러번 실행하지말고, 한번의 스케줄링 코드만 실행해 결과를 DB에 넣고 각 서버에서 이 결과를 select 하는방법도 고려했습니다. 현재는 redis에서 hyperloglog를 지원하고있어서 사용중인데, 만약 redis가 없다면 외부 라이브러리를 사용해야합니다. Quartz로 해결이 되는 문제라면 아예 Redis를 도입하지 않아도 되기때문에 해당방법을 시도중입니다.

3. **분산 잠금 도입**
- 각 서버에서 여러 번 select 해서 데이터를 일치시키려는 것보다는, 락을걸고 1번만 select 하는 게 쉽게 해결하는 방법이라고 판단하였습니다. 이를해결하기위해 Redis의 분산락이나 분산환경에서 스케줄러에 락을걸수있는 QuartzScheLock을 고려했습니다. 모든방법 전부 추가적인 관리포인트가 생기지만, Redis에 비해 비용이저렴한 ScheLock을 선택했습니다.

## 4. 인기글 조회에 트래픽이 몰려 대량의 update 쿼리가 발생하는 상황 해결하기
### AS-IS
Expand Down Expand Up @@ -354,26 +342,32 @@ where ..
대부분의 요청도 커넥션 풀 대기가 발생하는 상황 발견

**문제 발견 후 사고과정**
1. update가 발생하는 트랜잭션의 쿼리를 확인해 보던가 최적화해볼까?
- 해당 트랜잭션에서 발생하는 쿼리의 실행계획에서는 크게 문제가 없었고, 결국 언젠가 기능이 추가된다면 또다시 직면할 문제라고 판단하였습니다.

2. update 쿼리 발생 자체를 줄여보는 게 좋을 것 같다.
- 조회 수 정합성 맞출 필요도 없기 때문에 정확도를 잃지만 빠른 속도와 적은 메모리(최대 12KB)를 사용하는 hyperloglog의 존재를 알게 되었고 사용해 볼 수 있겠다고 생각했습니다.
- redis의 hash를 사용했을때와 성능 비교/측정 후 조회수 정확도가 조금 떨어지는대신 속도가 빠른것을 확인하였습니다.
- X락으로 인해 여러 조회요청이 몰리는경우 앞의 트랜잭션이 커밋이나 롤백되기전까지 일시적으로 대기하기때문에 인메모리나 캐시에서 카운팅하고 주기적으로 반영해보기로 판단했습니다.
- 조회수 요청마다 이벤트발생 + 비동기 방식으로 처리해주는 방법도 결국 update쿼리가 발생하는것 자체가 문제이기때문에 고려하지않았습니다.


### TO-BE
![image](https://github.com/youngreal/inflearn/assets/59333182/471695a2-5aaf-47ed-9123-dc2ee347cfad)


200vuser / 1sec / 1loop
- **latency 2.9-> 1.7**
- **latency 2.9-> 0.7**

![image](https://github.com/youngreal/inflearn/assets/59333182/a1287f52-577b-4b24-9581-3fd67b6d7dc8)
![image](https://github.com/youngreal/inflearn/assets/59333182/f077eca2-52ca-4f4d-8c73-a725b6c963b1)

1000user / 10sec / 1loop(실제 시나리오와 유사한 테스트)
- **latency 8-> 1.8**
600vuser / 1sec / 1loop (3초미만의 응답속도를 보이는 최대구간)
![image](https://github.com/youngreal/inflearn/assets/59333182/eb427dd4-a72a-4180-a5bf-b8c8641908de)

![image](https://github.com/youngreal/inflearn/assets/59333182/adca967a-84b6-4c2a-bccb-b1df44c737b2)
1000user / 10sec / 1loop
- **latency 8-> 0.5**
![image](https://github.com/youngreal/inflearn/assets/59333182/1486292a-6e30-4bba-a6d0-03812ea620b6)

- 처리량은 모든경우 1.5배 향상
2000vuser / 10sec / 1loop (3초미만의 응답속도를 보이는 최대구간)
![image](https://github.com/youngreal/inflearn/assets/59333182/99dd1c54-9cc1-433d-ac74-bbd030fa95a5)

### 잠재적 문제 & 한계
- redis를 도입해서 인기글테이블의 존재대신, redis에 캐싱하게되면 현재 커넥션 풀 대기상황을 좀더 완화하여 더 많은 트래픽을 처리할 수 있습니다. 하지만 서버를 scale-out해서 더 많은 처리량을 얻어낼수도있습니다.
- 스프링서버 scale-out을 먼저할지 redis를 먼저 도입해볼지 어려웠습니다.

## 5. LIKE %word%로 게시글 검색 시 full table scan이 발생해 레이턴시가 증가하는 문제를 fulltext-search로 개선하기

Expand All @@ -386,9 +380,7 @@ where ..

**문제 발견후 사고과정**
- 서비스 특성상 “자바”, “스프링” 같은 키워드에대한 검색결과가 많을것으로 예상되는데, 이들을 인기검색결과 테이블을 따로 분리하고, like 쿼리를쓰는 방법을 고려하였으나 실제 서비스 하지 않았기 때문에 수치가 어느정도 되는지 알 수 없는 한계가 있었습니다.
- 그러면 위의 방법을 해결하지 못한다고 가정했을 때

- 학습비용, 복잡도를 고려해 mysql의 fulltext-search를 선택하였습니다.
- 위의 방법이 불가능하다고 가정하면 해결방법은 검색엔진과 fulltext-search가 있었는데 학습비용, 복잡도를 고려해 mysql의 fulltext-search를 선택하였습니다.
- like %word% 쿼리와 Full-text search 쿼리의 성능 비교 후 , 검색 결과에 따라 성능이 달라지는 것을 발견했습니다.
- 검색결과가 0건인경우 쿼리응답 속도
- Like%word% : 6.234 sec
Expand All @@ -402,5 +394,6 @@ where ..

### 잠재적 문제 & 한계
- 실제 서비스를 하게 된다면 검색 결과들의 분포가 어떤 특성을 가지게 될지 어려웠습니다. 테이블 크기의 30% 이상의 해당하는 결과를 검색하는 일이 더 잦다면, 오히려 like%word% 방식이 좋을 수도 있습니다.
- 다만, **검색 결과가 0건인 최악의 경우(6초이상)** 보다는 평균적으로 1~2초내에 검색이 가능한 방식이라는 점에서 좀 더 자연스러운 최선의 방법이라 생각하였습니다.
- 다만, **검색 결과가 0건인 최악의 경우(6초이상)** 보다는 평균적으로 1~2초내에 검색이 가능한 방식이라는 점에서 좀 더 자연스러운 최선의 방법이라 생각하였습니다.
- fulltext-search의 결과는 메모리에서 처리됩니다. 테이블 데이터 200만건 기준으로 "FTS query exceeds result cache limit" 문제가 발생해 innodb_ft_result_cache_limit의 최대값을 4GB로 설정해두어 해결했지만, 만약 테이블의 크기가 더 커진다면 어느순간 같은 오류가 발생해 한계점이 올것입니다.
- 테이블 데이터 200만건 기준 검색결과에 해당하는 로우가 절반이 넘어가는 시점부터 like %word% 의 table full scan보다 느린 분기점이 발생합니다.

0 comments on commit e3c392f

Please sign in to comment.