본문 바로가기

Back-End

네이버 - 콘퍼런스 참가 기능 개발기 ( 대규모 참가 서비스 개발 )

네이버와 같이 대규모 서비스 개발기를 감사하게 공유해주어 해당글을 보고 재 작성한 블로그입니다.
https://d2.naver.com/helloworld/5048491

 

개발에 사용한 도구

  • nGrinder : 성능 테스트 도구
  • Pinpoint: 성능 모니터링 도구
  • nbase-src : Redis 기반의 분산 메모리 저장소

성능 테스트는 어떻게? 어떠한 목표로?

나처럼 대규모 서비스에 있어보지 않은 사람은 성능 테스트를 피부로 느껴보지 못한다. 그렇다면 대규모 서비스에서 성능 테스트와 목표는 어떻게 잡을까?

 

성능 테스트하면 모호해 보이지만 항상 정확한 수치를 기반으로 목표를 가져아 한다 

몇 초안에 XX,XXX 번의 요청이 들어올 수 있다고 가정하고, 1초에 X,XXX건 요청을 처리하고 서버당 XXX TPS(transaction per second)를 처리할 수 있으면 만족스러운 성능이다!

목표를 정했으니 성능 테스트를 위해 nGrinder로 웹 API의 성능 테스트를 시작했다. 

테스트 결과를 통해 2가지 문제를 발견하였다고 한다

  • 불규칙적으로 요청이 실패하는 현상 -> ?
  • 메모리 누수 현상 -> Pinpoint를 통해 메모리 사용량 그래프를 통해 확인

불규칙적인 실패의 원인 - 데이터베이스 교착 상태

org.springframework.orm.jpa.JpaSystemException:  
    org.hibernate.exception.LockAcquisitionException: could not execute statement; nested exception is javax.persistence.PersistenceException: 
        org.hibernate.exception.LockAcquisitionException: could not execute statement
        at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:415)
        ... 

해당 교착상태를 발생시킨 SQL은 아래와 같다고 한다

UPDATE 일차별_참가신청 SET 현재_사람수 = 현재_사람수 + 1  
WHERE 행사일차 = ? AND 현재_사람수 < 정원  
AND NOT EXISTS (SELECT 1 FROM 참가자 WHERE 행사일차 = ? AND email = ?)  

UPDATE 문의 WHERE 절에 복합적인 조건 때문에 deadlock 발생하여 쿼리를 아래와 같이 수정을 하니 문제가 해결 되었다

UPDATE 일차별_참가신청 SET SET 현재_사람수 = 현재_사람수 + 1  
WHERE 행사일차 = ?  

하지만 위와 같이 수정을 하면 데이터베이스 격리 수준 (isolation level)이 보장되지 않는다. 즉, 소수의 인원은 정원이 넘친 상태에서 신청을 하게 된다

 

메모리 누수 확인하기 - HeapAnalyzer

메모리 누수 문제를 해결하기 위해 IBM HeapAnalyzer와 MAT를 이용하였다.

 

위의 로그를 보면 commons-dbcp2 와 관련된 객체가 과도하게 생성된 것을 확인할 수 있다. 실제로 관련 이슈가 있어 버전을 수정하였더니 문제가 해결되었다. 

참가 기능 실제 서비스 1일차

신청자 수를 UPDATE 쿼리를 수행하는 구간에서 심각한 병목현상을 발견할 수 있었다. Pinpoint를 통해 발견해보니 20초의 응답시간도 보였다.

정원 초과 문제가 실제로 발생하였는데 트랜잭션의 시간이 길어지면서 READ_COMMITED 격리수준에서는 동시에 실행된 다른 트랜잭션에서 현재 신청자수를 집계할 수 없었던 것으로 보인다.

 

신청자수를 이렇게 UPDATE로 수행하는게 효과적일까?

  • 변경되는 정보 - 신청자 수는 글로벌 캐시 방식을 적용
  • 신청시간, 정원 등 변경되지 않을 정보에는 로컬 캐시 방식을 적용

하여 성능을 개선하고 하였다. 글로벌 캐시 저장소로 nbase-src를 적용하기러 하였다

 

개선 1 - 캐시 적용

Spring의 @Cacheable을 이용하여 로컬 캐시 저장소를 적용하였다. 로컬 캐시 저장소는 Guava cache를 적용하였다.

@Cacheable("dayList")
public List<ConferenceDay> find(Integer conferenceId) {  
    ...
}

글로벌 캐시 저장소인 nbase-src 연결이 제대로 되는지 테스트코드도 작성하였다

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = RedisConfig.class)
public class RedisConfigTest {  
    @Autowired
    RedisTemplate<String, Long> redisClient;

    @Test
    public void shouldIncrement() {
        ValueOperations<String, Long> operation = redisClient.opsForValue();
        redisClient.delete("testCount");
        operation.increment("testCount", 1L);
        int count = operation.increment("testCount", 1L).intValue();
        assertThat(count, is(2));
        redisClient.delete("testCount");
    }
}
public int increment(Long 신청일차) {  
    String key = KEY_PREFIX + 신청일차;
    ValueOperations<String, String> operation = redisClient.opsForValue();
    return operation.increment(key, 1L).intValue();
}

해당 수정을 적용한 후 2일차에서는 안정적인 지표를 확인할 수 있었다.

관련해서 공부하면 좋은 포스팅도 남겨놓고 나중에 공부해야겠다

https://injae-kim.github.io/dev/2020/07/09/how-to-check-single-server-load-average.html