1. 문제상황

프로그램이 게시되면 푸시알림을 발송할 수 있습니다. 이때 요청량 스파이크가 발생하는데 근래 일주일 간 계속 접속 에러가 발생했습니다.

image-20240618135838977

주로 위와 같은 에러였는데, JDBC 커넥션은 get 하지 못했다는 에러입니다.

1.1 원인 판단

당연히 원인은 요청량이 많아져서 입니다. 추가적으로는 리소스가 부족해서 그렇습니다. 현재 스펙을 말씀드리면,

  • 서버: t3.micro 2대
    • db 와 커넥션 각각 40개
    • connection timeout 10초
  • DB : db.t3.micro 1대

    서버 설정에 따라 connection 을 10초 간 얻지 못하면 에러가 발생합니다.

2. 해결

해결 방법은 여러가지를 적용했습니다.

  1. 서버 스펙 늘리기
  2. 커넥션 개수 늘리기
  3. 캐싱 적용
  4. 필요없는 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 접근입니다.

  1. insepection 테이블에서 서버가 점검 중인지 확인
  2. api_request_log 로 api 호출 정보 저장
  3. 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 에 저장합니다. 예상 개선 포인트는 아래와 같습니다.

  1. 요청마다 connection 을 가져가는게 아니기 때문에 커넥션풀에 여유가 생김
  2. lambda 는 최대 10개 실행될 수 있으므로, 요청이 많아진다고 하더라도 db 에는 일정속도, 일정부하로 저장됨

3. 마무리

결론적으로 4가지 방법을 통해 스파이크는 해결했습니다만, 아쉬운 점은 있습니다.

  1. 4가지 방법이 모두 유효했나?

    • 사실 당장 서비스에 지장이 없도록 가능한 모든 방법을 며칠만에 다 적용했기 때문에 어느 것이 큰 영향을 미쳤고 어느 것은 별 도움이 되지 않았다, 라는 걸 알 수 없습니다.
    • 특히 서버 용량을 두 배로 늘린게 제일 유효하지 않았을까 하는데 그걸 측정하겠다고 다시 내릴 수는 없습니다.
    • 곧 dev 서버에서 부하테스트가 예정되어있기 때문에 그때 확인해야 합니다.
  2. 캐시 관리가 힘들어짐

    • program 을 조회하는데 총 5개의 정보를 가져오고, 각 정보는 각각의 캐시키를 갖습니다. 특히 해당정보가 업데이트되면 해당 캐시를 만료시키거나 업데이트해야 하는데, 해당정보가 어디에서 업데이트되는지 잘 찾아야 합니다.
    • 캐싱을 적용하고 얼마 지나지 않아서 프로그램 업데이트가 안되는 현상이 발생했는데, 제가 실수로 프로그램 수정 시 캐싱 만료를 시키지 않았기 때문입니다.

댓글남기기