[문제해결] 푸시알림 전송 이후 스파이크 해결
1. 문제상황
프로그램이 게시되면 푸시알림을 발송할 수 있습니다. 이때 요청량 스파이크가 발생하는데 근래 일주일 간 계속 접속 에러가 발생했습니다.
주로 위와 같은 에러였는데, JDBC 커넥션은 get 하지 못했다는 에러입니다.
1.1 원인 판단
당연히 원인은 요청량이 많아져서 입니다. 추가적으로는 리소스가 부족해서 그렇습니다. 현재 스펙을 말씀드리면,
- 서버: t3.micro 2대
- db 와 커넥션 각각 40개
- connection timeout 10초
-
DB : db.t3.micro 1대
서버 설정에 따라 connection 을 10초 간 얻지 못하면 에러가 발생합니다.
2. 해결
해결 방법은 여러가지를 적용했습니다.
- 서버 스펙 늘리기
- 커넥션 개수 늘리기
- 캐싱 적용
- 필요없는 DB 접근 최소화
이중 제가 맡은 작업은 3, 4번이었습니다. 서버 스펙은 t3.small 2대로 늘렸고, 커넥션은 서버당 80개입니다. db 파라미터값으로 최대 커넥션을 200개로 했습니다.
2.1 캐싱 적용
먼저 푸시알림 전송 후 들어왔을 때 부르는 api 목록을 프론트 개발자에게 요청했습니다.
GET: /programs/{programId}
: 프로그램 정보GET: /codes
: 앱 초기화에 필요함. Enum 값 정보-
GET: /programs/{programId:[0-9]+}/my-info
: program 에 대한 my-info (북마크, 알림전송 여부)위 세 개의 api 중 모든 사용자에게 공통되는 프로그램 정보와 Enum 값 정보를 캐싱하기로 했습니다. 캐시는 freetier 범위인 Redis 로 했으며
@Cacheable
어노테이션과@CacheEvict
어노테이션을 이용했습니다.하지만 Program 디테일 정보를 캐싱할 때 몇가지 문제가 있었습니다.
2.1.1 Program 캐싱 문제점 1: 반환값에 프로그램뿐만 아니라 추가적인 정보(지원자, 선정된 사람, 댓글 개수, 지원금 받은 사람) 이 포함됨
GET: /programs/{programId}
API 에는 크게 아래 5개의 정보가 조회되어 반환됩니다.
- 프로그램 상세 정보
- 프로그램 댓글 개수
- 프로그램에 선정된 사람 목록
- 프로그램 지원자 목록
- 프로그램에서 지원금을 받은 사람 목록
해당 정보는 Service 클래스에서 LazyLoading 으로 모두 가져와서 한번에 Controller 클래스로 반환하기 때문에 @Cacheable 어노테이션을 사용하면 모든 정보가 하나의 키에 한꺼번에 저장됩니다. 그렇게 되면 5개의 정보 중 하나만 변경되어도 모든 캐시를 무효화시켜야 합니다. (캐시값을 수정할 수도 있지만 최대한 간단하게 구현하고 싶었습니다.)
아래는 문제가 되는 ProgramService 입니다.
@Cacheable(
key = '#programId'
)
fun getPrograms(programId: Long): ProgramResponse {
val program = programRepository.findById(programId)
val reponse = mapper.map(program, ProgramReponse::class.java) //여기에서 ProgramReponse 에 필요한 댓글개수, 선정된 사람, 지원자 목록 등등이 lazyLoading 됨
return response
}
따라서 Servie 클래스에서 ProgramReponse 을 가져올 때는 프로그램 상세 정보만 반환하도록 하고, 나머지 4개의 정보도 각각 서비스 클래스에서 메서드를 만들었습니다. 그리고 그 정보를 Controller 에서 조합했습니다. 아래는 변경된 ProgramController 입니다.
@GetMapping("/programs/{programId}")
fun getPrograms(
@PathVariable programId: Long
): ProgramResponse {
//program 정보
val programResponse = programService.getByProgramIdWithMe(programId)
//댓글 개수
programResponse.countComment = commentService.getCommentCount(programId, POST_TYPE.PROGRAM)
//지원자 리스트
programResponse.applicants = applicantService.getApplicant(programId)
//선정된 사람 리스트
programResponse.selectedUsers = selectedUserService.getSelectedUsers(programId)
//지원금 받은 사람 리스트
programResponse.subsidies = recordService.getReceivedSubsidy(programId)
return response
}
이렇게 함으로써 다음과 같은 효과가 있습니다.
- 기존 : 5개의 정보 중 하나가 변경되더라도 캐싱을 모두 무효화해야 함. 그리고 5번 DB 접근을 다시 해야 함
- 개선 : 5개의 정보가 각각 캐싱됨. 하나가 변경되더라도 그 하나만 무효화되기 때문에 DB 접근을 1번만 하면 됨
2.1.2 Program 캐싱 문제점 2: 알림을 받고 들어오면 캐시가 없음
캐싱을 하는 이유 중 가장 큰 이유가 “알림을 받고 들어왔을 때” 입니다. 하지만 100명이 “동시에” 들어왔을 때, 첫 요청이 결과를 캐싱하기 전에 100명을 처리해야 하므로 모든 요청이 DB 를 접근할 것입니다.
이를 해결하기 위해서 알림이 보내지기 전 해당 Program 에 관한 5개 정보를 모두 캐싱하고 푸시알림을 보냅니다.
2.2 DB 접근 최소화
요청이 들어올 때 필터, 인터셉터 레벨에서 무조건 호출되는 DB 접근입니다.
- insepection 테이블에서 서버가 점검 중인지 확인
- api_request_log 로 api 호출 정보 저장
- user 의 late_request_at 업데이트
먼저 1번은 아예 없앴습니다. 지금 당장 사용하지 않는 기능이거든요. 만약 사용한다면 서버에 inspection 정보를 저장하고 30분마다 업데이트하는 식으로 진행할 것 같습니다.
그리고 3번도 없앴는데, 그 이유는 아래 설명할 api log 에 userId 가 포함되어있기 때문에 User 테이블에 최근요청시간을 저장하는 건 중복 데이터라고 생각했기 떄문입니다.
2.2.1 api log 수정
2번은 서버에서 직접 db 에 저장하는 게 아니라 sqs 와 lambda 를 활용하는 것으로 개선했습니다. 서버에서 api 정보를 sqs 에 보내면, lambda 에서 sqs 를 읽어서 db 에 저장합니다. 예상 개선 포인트는 아래와 같습니다.
- 요청마다 connection 을 가져가는게 아니기 때문에 커넥션풀에 여유가 생김
- lambda 는 최대 10개 실행될 수 있으므로, 요청이 많아진다고 하더라도 db 에는 일정속도, 일정부하로 저장됨
3. 마무리
결론적으로 4가지 방법을 통해 스파이크는 해결했습니다만, 아쉬운 점은 있습니다.
-
4가지 방법이 모두 유효했나?
- 사실 당장 서비스에 지장이 없도록 가능한 모든 방법을 며칠만에 다 적용했기 때문에 어느 것이 큰 영향을 미쳤고 어느 것은 별 도움이 되지 않았다, 라는 걸 알 수 없습니다.
- 특히 서버 용량을 두 배로 늘린게 제일 유효하지 않았을까 하는데 그걸 측정하겠다고 다시 내릴 수는 없습니다.
- 곧 dev 서버에서 부하테스트가 예정되어있기 때문에 그때 확인해야 합니다.
-
캐시 관리가 힘들어짐
- program 을 조회하는데 총 5개의 정보를 가져오고, 각 정보는 각각의 캐시키를 갖습니다. 특히 해당정보가 업데이트되면 해당 캐시를 만료시키거나 업데이트해야 하는데, 해당정보가 어디에서 업데이트되는지 잘 찾아야 합니다.
- 캐싱을 적용하고 얼마 지나지 않아서 프로그램 업데이트가 안되는 현상이 발생했는데, 제가 실수로 프로그램 수정 시 캐싱 만료를 시키지 않았기 때문입니다.
댓글남기기