본문 바로가기
Dev/[프로젝트] 2022 Winter Dev Camp

2022 스마일게이트 윈터데브캠프를 통해 성장하기 7 - 성능 측정, 성능 향상을 위한 리팩토링, 수정과 보완

by javapp 자바앱 2023. 3. 5.
728x90

 

 

 

설계때 정의한 기능들을 우선 구현하고 

모바일과 연동을 위해 AWS EC2에 배포를 하였습니다.

이후에는 리팩토링과 성능 향상, 수정과 보완을 하는 시간을 가져보려고 합니다!

 

 

 

 

1. 성능 테스트

  • 서비스가 얼마나 빠른지 time
  • 일정시간 동안 얼마나 많이 처리할 수 있는지 TPS
  • 얼마나 많은 사람들이 동시에 사용할 수 있는지 Users
  • 서버 메모리 CPU Usage

 

1.1. nGrinder 사용

💡 성능 측정 목적으로 개발된 오픈소스 프로젝트로 NHN 사단이 개발했다고 합니다.
부하를 줄 수 있는 인터페이스와 테스트 결과를 통계로 제공하며
Groovy 스크립트로 테스트 시나리오를 작성합니다.

 

JUnit 과 유사한 그루비 언어로 시나리오에 따라 스크립트 작성 가능, 오픈소스에 레퍼런스 많기 때문에 사용하였습니다.

agent: 서버당 하나

가상사용자 : agent하나당 가상사용자

가상사용자 = 10 = 프로세스(독립된 agent) * 쓰레드

 

 

1.2. 측정환경

 

nGrinder 실행

controller 실행

$ cd C:\\main\\dev\\ngrinder
$ java -jar ngrinder-controller-3.5.8.war --port=8300

agent

$ cd C:\\main\\dev\\ngrinder\\ngrinder-agent

윈도우에서는 ./run_agent.bat을 실행하면 된다.
./run_agent.sh 는 리눅스에서 실행하면 된다.

내 로컬 pc가 agent가 되고 더 늘릴려면

ec2와 같은 서버 환경이 필요하다.

 

 

1.2.1. Groovy언어로 작성한 테스트 스크립트

import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager
import HTTPClient.NVPair
import net.grinder.plugin.http.HTTPPluginControl

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {
	public static GTest test

    public static HTTPRequest request
    public static NVPair[] headers = []
    public static NVPair[] params = []

    @BeforeProcess
    public static void beforeProcess() {
        HTTPPluginControl.getConnectionDefaults().timeout = 60000
        test = new GTest(1, "GET /friend")

        request = new HTTPRequest()
  
        // Set headers
        List<NVPair> headerList = new ArrayList<NVPair>()
        headerList.add(new NVPair("Content-Type", "application/json"))
   
        // This is a Authorization header for the namespace, whisk.system.
        headerList.add(new NVPair("Authorization", "Bearer eyJhbGciOiJIUzUxMiJ9.eyJuaWNrbmFtZSI6IjEyMzQiLCJleHAiOjE2NzcxMTAzMTcsInVzZXJJZCI6IjEyMzQiLCJlbWFpbCI6ImFxc3dlZnJAbmF2ZXIuY29tIn0.IorPPCaO83NusVgqlYR5ZLz40WKYdTsxAIVc-bl8elfHFbcUPHGBFvijYZcBZcanERXuTZDRLLxhiblj8nkWDw"))
		
        headers = headerList.toArray()
        grinder.logger.info("before process.");
    }
  
    @BeforeThread
    public void beforeThread() {
        test.record(this, "test")


        grinder.statistics.delayReports=true;
        grinder.logger.info("before thread.");
    }
  
    @Before
    public void before() {
        request.setHeaders(headers)       
        grinder.logger.info("before thread. init headers and cookies");
    }
  
	@Test
    public void test(){
		String url1 = "http://3.39.130.186:8000/user/v1/friend";

		HTTPResponse response = request.GET(url1, params)
  
		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
    }

	/*
    @Test
    public void test(){
		String url1 = "http://127.0.0.1:8011/chatting/room/v1/my-rooms";

		HTTPResponse response = request.GET(url1, params)
  
		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
    }
	
	@Test
	public void test2(){
		// /history-message/{roomid}
		String roomId = "1543bdb8-9f40-4b3b-93ab-42cc5391abc9"; 
		String url2 = "http://127.0.0.1:8011/chatting/v1/history-message/"+roomId;
		HTTPResponse response = request.GET(url2, params)
		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
	
	
	@Test
	public void test3(){
		// /history-message/{roomid}
		String roomId = "1543bdb8-9f40-4b3b-93ab-42cc5391abc9"; 
		String url2 = "http://127.0.0.1:8011/chatting/v1/history?roomid="+roomId+"&page=0";
		HTTPResponse response = request.GET(url2, params)
		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
	
	
	// 재입장 rooms -> /new-message/{roomid}/{readMsgId}
	@Test
	public void rooms(){
		String url1 = "http://3.39.130.186:8000/chatting/room/v1/my-rooms";
		HTTPResponse response = request.GET(url1, params)
		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
	@Test
	public void new_message(){
		// /new-message/{roomid}/{readMsgId}
		String roomid = "002ecace-ac94-46ec-a53f-aecb7dba3686"
		String readMsgId = "63ec7370e44be10adb4c9df3";
		String url2 = "http://3.39.130.186:8000/chatting/v1/new-message/"+roomid+"/"+readMsgId;
		
		HTTPResponse response = request.GET(url2, params)
		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}*/
}

 

1.3. API 에 대한 부하테스트

 Vuser : 300

 

결과 : TPS 20.8

 

 

1.4. @Transactional(readOnly=”true”)

💡 Transactional 작업 처리 위해 많은 리소스사용 특히 Create Update Delete 와 달리 select문 에서는 이전 상태와 변화가 없으므로 → 트랜잭션에 lock을 적용할 필요가 없습니다.
그렇기 때문에, (readOnly = true) 옵션, 읽기 전용이라는 것을 명시하고, 영속성 컨텍스트에 관리 , 트랜잭션 동작을 최적화 - 성능 향상

 

 

결과 TPS 20.8 -> 58.4

 

 

1.5. 톰캣 설정

acceptCount : 일반적으로 대기 큐에 요청이 쌓였다는 것은 이미 서비스가 장애상황이라는 것을 의미 장애 상황에서는 빠르게 클라이언트에게 에러 메세지를 주는 것이 중요하므로 작게 설정

maxConnections : 기본값 8192

maxThreads : 서버 사양에 따라 가장 많이 좌우되는 설정값 직접 성능 테스트를 실행하여 최적의 값을 찾을 필요가 있다.

   Tomcat은 다중 요청을 처리하기 위해서, 부팅할 때 스레드의 컬렉션인 Thread Pool을 생성합니다 유저 요청  (HttpServletRequest)가 들어오면 Thread Pool에서 하나씩 Thread를 할당합니다. 해당 Thread에서 스프링부트에서 작성  한 Dispatcher Servlet을 거쳐 유저 요청을 처리합니다. 작업을 모두 수행하고 나면 스레드는 스레드풀로 반환됩니다.

 

DataSource란 DB처럼 (파일과 같은 물리적 데이터 소스에) 연결할 때 사용하는 인터페이스

HikariCP : 기본 DataSource

maximumPoolSize

    Connection Pool 이 가질 수 있는 최대 커넥션 개수, 유휴한 커넥션과 사용중인 커넥션을 모두 포함한 개수

minmumIdle 설정하지않으면 == maximumPoolSize

    minimumIdle < maximumPoolSize

    최소 유휴 커넥션수가 최대풀 사이즈보다 작을 때 설정

 

 

Spring boot 기본값

# application.yml (적어놓은 값은 default)
server:
  tomcat:
    threads:
      max: 200 # 생성할 수 있는 thread의 총 개수
      min-spare: 10 # 항상 활성화 되어있는(idle) thread의 개수
    max-connections: 8192 # 수립가능한 connection의 총 개수
    accept-count: 100 # 작업큐의 사이즈
    connection-timeout: 20000 # timeout 판단 기준 시간, 20초

보통 쓰레드수, db인덱스, 쿼리 등을 수정

 

1.6. 스레드

스레드는 많으면 너무 많은 스레드가 cpu의 자원을 두고 경합하게 되므로 처리속도가 느려질 수 있고, 적으면 cpu자원을 최적으로 활용하지 못하여 마찬가지로 처리속도가 느려질 수 있습니다. 스레드는 적절한 수로 유지되는 것이 가장 좋습니다.

 

 

1.7. 성능 개선

변경사항

@Transactional(readOnly=”true”) 적용

쓰레드 100

히카리풀 20

 

결과 : TPS 20.8 → 116.2

 

 

 

1.8. EC2 서버 

기존의 서버 성능

TPS : 141.2

 

 

 

변경사항

@Transactional(readOnly=”true”) 적용

쓰레드 100

히카리풀 20

오류 확인 결과 일정 시간이후 오류 발생

 

▶Connection Pool을 많이 늘렸기 때문에 기다리다가 연결이 해제되어서 에러가 발생한 것으로 보였습니다.

 

데이터 소스인 히카리풀 경우 서버의 CPU등 환경에 따라 달리 적용해야 됨을 알 수 있었습니다.

TPS : 141.2 -> 399.4 로 마쳤습니다.

 

 


 

2. 비동기 처리

결과를 받을 때까지 기다릴 필요가 없을 경우 @Async 애노테이션을 사용하여 처리

@Slf4j
@Service
@Async("push") // 비동기 추가
@RequiredArgsConstructor
public class PushService {
    private final PresenceService presenceService;
    private final PushProxy pushProxy;

    public void pushMessageToUsers(ChatMessageDto chatMessageDto){

        // 유저 접속상태 확인후 미접속일 때 알림서버에 요청
        ResponsePresenceUsers offlineUsers = presenceService.getOfflineUsers(chatMessageDto.getRoom_id());

        if(offlineUsers.getMembers().size() >= 1){
            try
            {
                pushProxy.sendNotification(RequestMessage.builder()
                        .title(chatMessageDto.getSender_id())
                        .body(chatMessageDto.getContent())
                        .roomId(chatMessageDto.getRoom_id())
                        .target(offlineUsers.getMembers())
                        .build());
            }catch (FeignException e){
                log.error("pushProxy.sendNotification, 푸시 서버 연결 안됨");
            }
            log.info("오프라인 유저에게 푸시알림 요청");
        }
    }
}

 

 


 

3. 페이지네이션

1만개 이상의 대용량 처리의 경우

페이지네이션 적용 必

페이지네이션 적용 전 TPS 5.5

 

페이지네이션 적용 후 TPS 73.6

- localhost 서버, MongoDB Atlas 연동

 

 


 

4. Validation을 통해 파라미터 유효성 검사

이를 통해 서비스 단에서 Body로 들어온 값을 검사하는 단계를 줄일 수 있었습니다.

 

private void validateSizeOfGroup(ReqGroupDto reqGroupDto){
    if (reqGroupDto.getMembers().size() <= 1){
        log.error("validateSizeOfGroup, ErrorCode: {}","GROUP_MEMBER_SIZE_ERROR");
        throw new CustomAPIException(ErrorCode.GROUP_MEMBER_SIZE_ERROR, "초대된 멤버가 1명이하입니다.");
    }
}

기존에 해당 메소드를 통해 RequestBody로 들어온 값의 멤버 수를 검사하는 로직이 있었습니다.

생각해보면 @Valid를 사용하여 Controller 단에서 예외처리를 할 수 있었습니다.

 

그래서 해당 메소드를 지우고 컨트롤러에서 다음과 같이 작성하였습니다.

@PostMapping("/v1/group-creation")
public ResponseEntity<RespRoomDto> groupCreation(@RequestHeader("Authorization") String jwt, @Valid @RequestBody ReqGroupDto reqGroupDto){

@Valid ReqGroupDto 객체에 달아주었습니다.

그러면 애노테이션에 의해 해당 객체에 유효성 검사를 하게됩니다.

 

public class ReqGroupDto {
    @Size(min = 2)
    @Schema(description = "요청받는 유저 id 리스트")
    private List<String> members;
    ...
}

@Size(min = 2) : 리스트의 size가 최소 2 이상

 

Validation과 관련된 여러 애노테이션이 있습니다.

@Null  // null만 혀용합니다.
@NotNull  // null을 허용하지 않습니다. "", " "는 허용합니다.
@NotEmpty  // null, ""을 허용하지 않습니다. " "는 허용합니다.
@NotBlank  // null, "", " " 모두 허용하지 않습니다.

@Email  // 이메일 형식을 검사합니다. 다만 ""의 경우를 통과 시킵니다
@Pattern(regexp = )  // 정규식을 검사할 때 사용됩니다.
@Size(min=, max=)  // 길이를 제한할 때 사용됩니다.

@Max(value = )  // value 이하의 값을 받을 때 사용됩니다.
@Min(value = )  // value 이상의 값을 받을 때 사용됩니다.

@Positive  // 값을 양수로 제한합니다.
@PositiveOrZero  // 값을 양수와 0만 가능하도록 제한합니다.

@Negative  // 값을 음수로 제한합니다.
@NegativeOrZero  // 값을 음수와 0만 가능하도록 제한합니다.

@Future  // 현재보다 미래
@Past  // 현재보다 과거

@AssertFalse  // false 여부, null은 체크하지 않습니다.
@AssertTrue  // true 여부, null은 체크하지 않습니다.

 

 

 

 

 

 

https://youtu.be/2CxEYnspLvg 참고

https://youtu.be/U7ZoTKjxt4o 측정환경

댓글