1차 성능 테스트(1)

간단한 채팅 앱의 프레임에 대하여 백엔드와 프론트엔드를 구축하고, 백엔드에 대한 성능 테스트를 진행함으로써 성능 개선에 대한 근거를 확보해야겠다고 생각했다. 프로젝트에서의 기술 스택 적용에 있어 단순히 개념적으로만 알고 있는 내용을 직접 눈으로 확인하고 이해하기 위해서였다.

정리하자면, 개념적으로 이해만 하고 있던 MSA 방식의 장점 및 모놀리식에서의 전환의 근거를 확립하는 것이 1차 성능 테스트의 목적이다.

1. 테스트 수단

테스트를 생각한 시점에서 내가 생각한 테스트 툴은 크게 2가지였다.

1) Locust

  • Python 기반의 스크립트 테스트 툴
  • 코드 기반으로 테스트를 동작시키므로 개발자의 의도를 좀 더 유연하고 명확히 반영 가능

2) JMeter

  • GUI 기반의 스레드 테스트 툴
  • 필요시, 플러그인 확장을 통해 Java나 JavaScript로 스크립트 추가 작성 가능

물론, 둘 다 능숙하게 다루는 편은 아니긴 하지만 알고리즘 문제풀이를 하면서 파이썬 문법을 몇 번 접했었고, 각자의 특성을 최대한 활용해보고 싶어서 둘 다를 테스트 수단으로 선택하게 됐다.

1) 테스트 툴 비교

항목LocustJMeter
테스트 방식코드 기반 (파이썬)GUI 기반
유연성높음 (파이썬으로 로직 작성)중간 (GUI로 시나리오 구성)
확장성높음 (대규모 트래픽 테스트 적합)낮음 (스레드 기반 리소스 소모)
지원 프로토콜HTTP, WebSocketHTTP, FTP, JDBC, SOAP 등 다양한 프로토콜
진입 장벽파이썬 코딩 필요GUI로 비교적 쉬움
복잡한 시나리오 작성파이썬으로 세밀한 테스트 구성다양한 설정과 플러그인으로 복잡한 시나리오 구성
대규모 트래픽효율적 비동기 처리리소스 많이 소모

2) 결론

현재 내 상태는, Python 문법을 완벽하게 숙지하지 못한 상태고 Locust 수준의 유연성은 아니지만, JMeter의 풍부한 플러그인을 통해 WebSocket 대용량 테스트도 가능한 것으로 확인했다.

단편적인 API에 대해서는 Locust, 세부 시나리오에 대해서는 JMeter로 테스트를 수행하기로 결정됐다.

2. Locust Test

HTTP API 를 구축한 것은 회원 인증 절차(회원가입, 로그인 등) 수단 정도여서 쉽게 파이썬으로 코드를 작성하고 테스트를 수행할 수 있었다. 다만, 여기서 문제는 로그인을 하려면 계정이 있어야 하고, 계정이 있으려면 회원가입이 필요하다. 우선 그 과정을 밟아볼 예정.

1) 회원가입 절차를 통한 계정 정보 마련

현재 내 앱에서 회원가입을 하려면, 이메일, 패스워드, 전화번호, 권한, 이렇게 4개를 입력한다. 고유값을 가져야 하는 필드는 이메일이어서, 나머지 3개는 전부 같게 두고 회원가입을 반복 수행하면 계정을 확보시킬 수 있다.

class UserBehavior(TaskSet):
    @task
    def signup(self):
        # n값을 3부터 1000 사이의 값으로 설정
        n = random.randint(3, 1000)

        # 이메일 주소 생성
        email = f"test{n}@test.com"

        # 회원가입에 사용할 데이터 정의
        user_data = {
            "email": email,
            "password": "test",
            "phone": "000-000-0000",
            "role": "USER"
        }

        # POST 요청으로 회원가입 엔드포인트 호출
        with self.client.post("/api/users/signup", json=user_data, catch_response=True) as response:
            if response.status_code == 200:
                response.success()
            else:
                response.failure(f"Failed to sign up with email {email}. Status code: {response.status_code}")

이미 가입된 계정 2개 외에 추가로 해서 총 1000개의 계정을 생성시키는 테스트를 수행한다. 생성하는 과정에 있어 별도의 램프업 타임을 고려하지는 않았다. 테스트 수행 결과, 동시성 이슈가 발생했다. 즉, 이메일의 고유값이 지켜지지 않았다.

중복되는 이메일 가입 계정이 발생하는 이유는 서버 사이드 레벨에서의 고유값 식별 로직만을 갖췄기 때문이었다. 간략한 상황 설명이지만 트래픽이 몰리는 상황에서 동시다발적인 쿼리문이 발신되고 조회, 검증 작업을 수행한 셈이다. 이를 막기 위해, 데이터베이스 레벨의 고유값 설정을 엔티티 클래스에서 수행했다.

// User Service
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDTO createUser(UserDTO userDto) {
        if (existUserEmail(userDto.getEmail())) throw new IllegalArgumentException("이미 가입된 이메일입니다.");

        String password = passwordEncoder.encode(userDto.getPassword());
        User user = new User(userDto, password);
        userRepository.save(user);

        return UserMapper.toDTO(user);
    }
// User Entity

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    private Long id;

    @Column(name = "email", nullable = false, unique = true)
    private String email;
    
    // ...

테스트의 응답 지연 시간 및 단위 시간당 처리량은 아래와 같다.

2) 로그인 및 로그아웃

# DTO 클래스
class UserDTO:
    class Login:
        def __init__(self, email, password):
            self.email = email
            self.password = password


# 로그인 후, 로그아웃
# 사용자 행동 정의
class UserBehavior(TaskSet):

    def on_start(self):
        # 사용자가 시작할 때 로그인
        self.login()

    def on_stop(self):
        # 사용자가 종료할 때 로그아웃
        self.logout()

    @task(1)
    def login(self):
        # 랜덤 로그인
        n = random.randint(2, 1000)
        email = f"test{n}@test.com"
        password = "test"

        login_data = UserDTO.Login(email=email, password=password)

        # 로그인 시도
        response = self.client.post("/api/users/login", json={
            "email": login_data.email,
            "password": login_data.password
        })

        if response.status_code == 200:
            print(f"로그인 성공 유저: {email}")
            # 쿠키 저장
            self.cookies = response.cookies
        else:
            print(f"로그인 실패 유저: {email}")

    @task(2)
    def logout(self):
        # 로그아웃 시도
        if hasattr(self, 'cookies'):
            # 로그아웃 시도, 로그인 시 받은 쿠키를 포함
            response = self.client.post("/api/users/logout", cookies=self.cookies)

            if response.status_code == 200:
                print("로그아웃 성공")
            else:
                print("로그아웃 실패")
        else:
            print("쿠키 확인 불능 문제 발생")

내 앱은 JWT 인증 체계를 도입하였으며, 로그인을 함과 동시에 엑세스 토큰을 리스폰스 쿠키에 저장시킨다. 회원가입과 로그인 외의 모든 API 요청에 대해서 유효한 엑세스 토큰을 요구한다. 즉, 로그아웃에서도 엑세스 토큰을 요구한다.

그렇기 때문에 로그아웃을 위해 로그인을 함과 동시에 브라우저의 쿠키 환경처럼 리스폰스 쿠키에 저장시켜준다. 로그아웃 절차는 엑세스 토큰을 브라우저 쿠키에서 삭제하고, 리프레시 토큰을 redis에서 삭제하는 과정까지 이뤄져야 성공이다.

테스트 수행 결과, 정리되지 못하는 리프레시 토큰이 다수 존재한다. 다만 의아했던 점은 응답 결과는 정상으로 처리됐다는 것. 로그아웃 과정에서 예외 처리를 하지 않았는지 코드를 점검했다.

// security config

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        
        // ...

        http.logout(logout -> {
                    logout
                            .logoutUrl("/api/users/logout") // 로그아웃 API 엔드포인트
                            .addLogoutHandler(customLogoutHandler)
                            .logoutSuccessHandler((req, res, auth) -> {
                                res.setStatus(HttpServletResponse.SC_OK);  // 로그아웃 성공 시 200 OK 응답
                            })
                            .deleteCookies(COOKIE_AUTH_HEADER);
        });
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomLogoutHandler implements LogoutHandler {

    private final RedisTemplate<String, String> authTemplate;
    private final JwtTokenService jwtTokenService;

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        log.info("로그아웃 핸들러 작동");
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return;
        }

        for (Cookie cookie : cookies) {
            if (cookie.getName().equals(COOKIE_AUTH_HEADER)) {
                String decodedToken = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8);
                try {
                    log.info("정상 토큰에서의 로그아웃 처리");
                    String email = jwtTokenService.getUserFromToken(decodedToken.substring(7)).getEmail();
                    authTemplate.delete(REDIS_REFRESH_KEY + email);
                } catch (ExpiredJwtException e) {
                    log.warn("만료 토큰에서의 로그아웃 처리");
                    String email = jwtTokenService.getUsernameFromExpiredJwt(e);
                    authTemplate.delete(REDIS_REFRESH_KEY + email);
                } catch (Exception e) {
                    log.error("Redis에서 리프레시 토큰 삭제 중 오류 발생", e);
                    throw e;
                }
                break;
            }
        }
    }
}

로그아웃 처리는 스프링 시큐리티의 로그아웃 핸들러를 통해 처리된다. 순서는 리퀘스트의 쿠키를 기반으로 엑세스 토큰을 조회한 다음, 그것을 파싱해서 얻은 이메일을 바탕으로 redis에서 리프레시 토큰을 정리한 다음, 핸들러 다음 절차로 쿠키를 삭제함으로써 마무리한다. 브라우저 쿠키 삭제는 혹시 몰라서 클라이언트 사이드에서도 이중으로 구축했다.

catch 구문에서의 예외 혹은 JWT와 관련된 예외가 발생하면 400대의 상태 코드를 반환시키기 때문에 리스폰스의 상태 코드가 반환됨이 옳다. 즉, 모든 로직은 try 구문으로 흘러들어갔으나 redis의 템플릿 로직이 작동하지 않은 셈이 된다.

이에 대해 개인적으로 회고하며 정리를 해봤다.

1. 테스트는 로그인과 로그아웃이 동시에 이뤄진다. 로그인의 리소스 소모가 많이 드는 이유는 암호화 알고리즘(BCrypt) 때문이다.

2. 로그인 후 일련적으로 로그아웃이 이어 수행되기 때문에 리소스가 일부 소모된 상태에서 로그아웃 단계로 진입한다.

3. 로그아웃 핸들러 이전에 시큐리티 필터 체인 설정으로 마련한 필터 단계의 엑세스 토큰 검증이 또 이뤄지게 된다. 이 과정 역시 리소스 소모에 일부 기여한다.

4. 예외가 전파되지 않는 것은 예외가 발생하지 않았다는 뜻이다. 즉, 입출력 성능이 좋은 redis여도 서버 사이드에서 리소스 소모가 많이 되면서 트래픽 제어가 되지 않고 있다.

이렇게 정리하며 나는 아래의 고려 사항을 결론으로 내렸다.

JWT 인증과 스프링 시큐리티의 결합도가 너무 높음

이를 통해 서버 사이드의 서비스 비즈니스 로직의 분리 작업이 필요할 것으로 생각했다. 테스트 결과는 아래와 같다.

3) 채팅방 생성 및 조회

채팅방 생성 자체는 HTTP API로 이뤄진다. 이는, 채팅방 자체의 구조적인 엔티티 인스턴스를 생성함과 동시에 클라이언트에게는 채팅방 구독 경로의 접근점 제시 역할을 맡는다.

# DTO 클래스
class UserDTO:
    class Login:
        def __init__(self, email, password):
            self.email = email
            self.password = password


class OpenChatDTO:
    def __init__(self, title, max_personnel):
        self.title = title
        self.max_personnel = max_personnel


# 사용자 행동 정의
class UserBehavior(TaskSet):
    def on_start(self):
        # 사용자가 시작할 때 로그인
        self.login()

    def on_stop(self):
        # 사용자가 종료할 때 로그아웃
        self.logout()

    @task(1)
    def login(self):
        # 랜덤 로그인
        n = random.randint(2, 1000)
        email = f"test{n}@test.com"
        password = "test"

        login_data = UserDTO.Login(email=email, password=password)

        # 로그인 시도
        response = self.client.post("/api/users/login", json={
            "email": login_data.email,
            "password": login_data.password
        })

        if response.status_code == 200:
            print(f"로그인 성공 유저: {email}")
            # 쿠키 저장
            self.cookies = response.cookies
            # 로그인 후 채팅방 생성
            self.create_open_chat()
            # 채팅방 생성 후 채팅방 리스트 조회
            self.get_all_open_chats()
        else:
            print(f"로그인 실패 유저: {email}")

    def create_open_chat(self):
        # OpenChat 생성
        chat_data = OpenChatDTO(title="title", max_personnel=10)
        response = self.client.post("/api/open-chats/create", json={
            "title": chat_data.title,
            "maxPersonnel": chat_data.max_personnel
        }, cookies=self.cookies)

        if response.status_code == 200:
            print(f"채팅 생성 성공: {response.json()}")
        else:
            print(f"채팅 생성 실패: {response.text}")

    def get_all_open_chats(self):
        # OpenChat 조회
        response = self.client.get("/api/open-chats", cookies=self.cookies)

        if response.status_code == 200:
            print(f"채팅 조회 성공: {response.json()}")
        else:
            print(f"채팅 조회 실패: {response.text}")

    def logout(self):
        # 로그아웃 시도
        if hasattr(self, 'cookies'):
            # 로그아웃 시도, 로그인 시 받은 쿠키를 포함
            response = self.client.post("/api/users/logout", cookies=self.cookies)

            if response.status_code == 200:
                print("로그아웃 성공")
            else:
                print("로그아웃 실패")
        else:
            print("쿠키 확인 불능 문제 발생")

위에서 로그아웃 문제를 확인했기 때문에 좀 더 리소스 소모가 더한 상황에서 어떻게 결과가 반환될지 확인하기 위해 테스트 순서를 로그인, 채팅방 생성, 채팅 조회, 로그아웃으로 테스트 스크립트를 작성했다.

터미널 Bash에서 출력되는 API 별 테스트 성공, 실패 등의 결과는 아래와 같았다.

Type     Name                                                                          # reqs      # fails |    Avg     Min     Max    Med |   req/s  failures/s
--------|----------------------------------------------------------------------------|-------|-------------|-------|-------|-------|-------|--------|-----------
GET      /api/open-chats                                                                 5053 2078(41.12%) |  11041      12   33292  11000 |   15.33        6.31
POST     /api/open-chats/create                                                          5094   447(8.78%) |  11065      12   32839  11000 |   15.46        1.36
POST     /api/users/login                                                                6000  906(15.10%) |  19750     474   81003  18000 |   18.21        2.75
POST     /api/users/logout                                                                999     0(0.00%) |  28001   26780   28865  28000 |    3.03        0.00

Error report
# occurrences      Error                                                                                               
------------------|---------------------------------------------------------------------------------------------------------------------------------------------
2078               GET /api/open-chats: OSError(57, 'Socket is not connected')                                         
906                POST /api/users/login: OSError(57, 'Socket is not connected')                                       
447                POST /api/open-chats/create: OSError(57, 'Socket is not connected')                                 
------------------|---------------------------------------------------------------------------------------------------------------------------------------------

예상했던 로그인, 채팅창 조회에서 많은 에러를 내보냈으며 로그아웃 성공률이 높은 이유는 로그인이나 채팅창 등에서 예외가 전파되면 해당 인스턴스의 로직은 강제 종료되거나, 리소스 소모가 더 적은 상태로 로그아웃 상태에 진입하기 때문에 높게 출력된다.

특히 채팅창 조회가 에러율이 많은 이유는, 페이징 처리 등의 대안 없이 데이터베이스로부터 모든 값들을 조회 및 출력시키기 때문에 리소스 소모가 상당히 많이 들 수밖에 없다.

이에 대한 대안책으로는 오프셋 페이징 처리 등을 통해 분할적으로 조회 결과를 출력시키는 것이다. 이는 클라이언트에서의 무한 스크롤 등으로 나타내질 수 있다.

테스트 결과는 아래와 같다. 램프업 타임을 조정하며 테스트를 진행했기 때문에 테스트 종료 이후에도 진행 시간이 길게 출력돼서 중후반부 그래프의 의미는 크게 없다.


다음 포스트에서는 웹소켓 테스트를 위한 JMeter 세팅 및 스크립트 작성, 웹소켓 테스트를 진행할 예정

profile
scientia est potentia / 벨로그 이사 예정...

0개의 댓글