Home 내가 만든 서버 성능 개선하기(아마도)
Post
Cancel

내가 만든 서버 성능 개선하기(아마도)

프로젝트 간단 소개

현재 진행중인 사이드 프로젝트는 롤링페이퍼 서비스이다.
기획자 1명, 프론트 1명, 백엔드 2명이서 진행중이며 내가 맡고 있는 부분은 유저가 롤링페이퍼를 작성하고 있을 때 다른 유저의 작업내용을 실시간으로 확인할 수 있게끔 해주는 서버제작을 담당하고 있다.

웹소켓 서버를 구성하여 같은 롤링페이퍼에 참여중인 유저들끼리 데이터를 주고받을 수 있도록 구현을 하였고 go언어의 fiber 패키지를 활용해 웹소켓 서버를, 디비의 경우 인메모리 디비인 redis를 사용했다.

Websocket과 Redis를 사용하게 된 이유

먼저 웹소켓을 사용하게 된 이유는 두 명 이상의 유저가 같은 롤링페이퍼에 접속하게되었을 때 우리 서비스에서는 실시간으로 다른 사람의 작업내용을 확인할 수 있도록 해줘야 하기 때문에 요청 하나하나가 비교적 무겁고 단발적인 http보단 최초 연결을 맺고 이후 커넥션을 유지하는 웹소켓을 사용하기로 했다.

레디스도 비슷한 이유에서 채택을 하게되었는데 비슷한 데이터가 빈번히 호출되는 서비스이기 때문에 롤링페이퍼의 실시간 데이터를 레디스에서 관리하고 롤링페이퍼 작성 작업이 최종 완료되었을 때 디비에 저장하는 식으로 진행을 하게 되었다.

초기 구현 내용

먼저 요청을 받았을 때 미들웨어에서 해당 요청의 프로토콜이 웹소켓이 맞는지 체크를 해준다.

1
2
3
4
5
6
7
app.Use("/ws", func(ctx *fiber.Ctx) error {
	if websocket.IsWebSocketUpgrade(ctx) {
		return ctx.Next()
	}
	log.Println("protocol needs upgrade to websocket")
	return fiber.ErrUpgradeRequired
})

이후 메인 로직은 아래와 같다.

  • 롤링페이퍼 아이디에 대한 커넥션을 관리하는 객체에 커넥션을 추가해준다.
  • defer 구문을 활용해 연결이 끊길 때 현재 커넥션이 목록에서 제외되도록 보장해준다.
  • 루프를 돌며 커넥션으로 부터 메세지가 들어올 때 까지 대기한다.
    • 메시지가 들어오게되면 수신한 메시지를 레디스에 업데이트 해준다
    • 업데이트 완료 이후 다시 레디스에 해당 롤링페이퍼에 대한 데이터를 요청하여 받아온 데이터를 같은 롤링페이퍼에 속한 유저에게 브로드캐스팅 해준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
app.Get("/ws/:paper_id", websocket.New(func(conn *websocket.Conn) {
	paperID := conn.Params("channel_id")
	subscriber := singleton.GetBroakerInstance()
	// add connection to channel
	subscriber.Add(paperID, conn)

	defer func() {
		// remove connection from channel
		subscriber.Remove(paperID, conn)
		// close websocket connection
		conn.Close()
	}()

	for {
		// wait for new message
		_, msg, err := conn.ReadMessage()
		if err != nil {
			log.Println("read message failed:", err)
			break
		}

		log.Printf("recv: %s\n", msg)

		var payload Payload
		err = json.Unmarshal(msg, &payload)
		if err != nil {
			log.Println("unmarshal failed:", err)
			break
		}

		// push message to redis
		db := redis.GetInstance()
		err = db.GetAndAdd(paperID, string(msg))
		if err != nil {
			log.Println("publish failed:", err)
			break
		}

		strMsg, err := db.Get(paperID)
		if err != nil {
			log.Println("get failed:", err)
			break
		}

		msg = []byte(strMsg)
		go subscriber.Broadcast(paperID, msg)

		log.Printf("send: %s\n", msg)
	}

}))

문제점

벌써 불편한 부분이 너무 많이보인다 ㅋㅋ.. 일단 가장 문제가 커 보이는 부분은 레디스에 업데이트를 완료한 뒤 다시 레디스에서 데이터를 불러와서 유저에게 브로드 캐스팅 해주는 것, 그 외에도 메시지를 수신할 때 마다 레디스 인스턴스를 불러오는 것도 굉장히 불편하다..

아무튼 여기저기 굉장히 비효율적이고 멍청한 방법으로 구현을 해놨다.;.;
하나씩 고쳐보자

개선 방법 / 이유

1. 최초 연결 시 롤링페이퍼 데이터 반환하도록 수정

사실 이 부분은 버그였다고 봐도 무방하다.
유저가 처음 접속하게 되면 당연히 롤링페이퍼를 반환받아와야 하는데 그렇지 못한 부분을 수정했다.

2-1. 레디스에 데이터 저장하는 작업을 비동기로 처리

기존에는 수신받은 데이터를 레디스에 저장하고 다시 레디스에서 데이터를 불러온 후에 유저에게 브로드 캐스팅을 하였는데 이렇게 되면 레디스와 통신하는 동안 유저는 데이터를 받지 못하고 트래픽이 몰리게 되면 이 부분에서 병목지점이 발생할 수 있는 구조이다.
때문에 데이터를 수신하고 브로드캐스팅 하기 까지 대기하지 않아야 병목 지점을 없앨 수 있다고 생각하였고 데이터를 레디스에 저장하는 작업을 go 루틴을 생성하여 비동기로 처리하도록 수정하였다.

이를 위해서는 한 가지 수정이 더 필요했는데 2-2 이다.
이를 통해 유저가 서버로 데이터를 전송했을 때 서버는 특정 작업을 대기하지 않고 바로 브로드 캐스팅을 할 수 있게 되었다.

2-2. 롤링페이퍼 전체 데이터가 아닌 수신한 메시지만 브로드캐스팅 하도록 수정

실시간 데이터 동기화 (브로드캐스팅) 작업의 주기가 1초라고 가정해보자.
그렇게 되면 1초마다 (현재 롤링페이퍼에 접속중인 유저) * (롤링페이퍼 전체 데이터) 만큼의 트래픽이 발생하게 된다.
굉장히 비효율 적이고 유지 비용이 커질 수 밖에 없는 구조이다.

뿐만 아니라 프론트에서는 현재 작업중인 유저의 데이터를 보내주게 되는데 유저 입장에서 연결 맺을 때 전체 데이터를 한번 받고, 이후에는 개별 유저들의 수정 사항에 대해서만 데이터를 받아도 실시간 업데이트가 가능한 부분이기 때문에 굳이 전체 데이터를 보내줄 필요도 없게 되었다.
그래서 수신한 데이터를 바로바로 브로드 캐스팅 해주면서 유저 응답시간도 줄이고 네트워크 트래픽도 줄일 수 있었다.

뭐 이 외에도 프로젝트 구조를 Go Clean Architecture 기반으로 변경한다던가, 클래스 구조, 명을 변경한다던가 하는 리팩토링 작업이 있었지만 성능적인 개선 사항을 위주로 어떤 작업을 진행했는지 남기고 싶었다.
성능 개선 이전, 이후 구조에서 부하 테스트를 통해 얼마만큼의 개선이 이루어 졌는지 알 수 있었다면 좋았겠지만,, 그 부분은 아직 진행하지 못하였고.. 기회가 되면 체크해보려고 한다. 아마 부하 테스트를 하게 된다면 k6를 사용하지 않을까 싶다.

결과

그렇게 리팩토링된 코드다. 변경된 부분의 양이 많지는 않지만 코드 라인 수에 비해 적지 않은 성능 개선을 얻었다고 생각한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func (m *wsHandler) WebsocketConnection(conn *websocket.Conn) {
	paperID := conn.Params("channel_id")
	log.Printf("new websocket connection: %s\n")
	m.paperUsecase.Subscribe(paperID, conn)

	// remove connection from channel when connection is closed
	defer func() {
		m.paperUsecase.Remove(paperID, conn)
		conn.Close()
	}()

	// 최초 연결 시 프로젝트 데이터 수신
	project, err := m.paperUsecase.GetProject(paperID)
	if err != nil {
		log.Println("get project failed:", err)
		return
	}

	// send project data to client
	err = conn.WriteMessage(websocket.TextMessage, []byte(project))

	var payload domain.Payload
	for {
		// wait for new message
		_, msg, err := conn.ReadMessage()
		if err != nil {
			log.Println("read message failed:", err)
			break
		}
		log.Println("receive message:", string(msg))
		
		// go routine에서 채널을 통해 값을 반환해주지 않으면 fire & forget으로 동작한다.
		go m.paperUsecase.PushData(payload) // fire and forget
		m.paperUsecase.BroadcastMessage(paperID, string(msg))
		log.Printf("broadcast message: %s\n", msg)
	}
	return
}

이후에 고민의 여지가 있는 포인트들?

로드 밸런싱

만약 이후에 스케일 아웃을 고려해 여러 대의 서버를 띄우고 앞단에 로드 밸런서를 두어 서버에 대한 부하를 분산하게 되었을 때 발생 가능한 문제는.. 각 서버가 서로 다른 롤링페이퍼-커넥션 목록을 가질 수 있다는 점이다.
이 경우에는 아마도.. 각 서버끼리 공유 가능한 레디스를 통해 목록을 관리하는 방향으로 수정이 필요하지 않을까 싶다.
혹은 로드 밸런서 대신 미들웨어를 두고 같은 롤링페이퍼에 속하는 유저들은 같은 서버로 접속하게끔 해주는 서버를 두는 방법도 있을 순 있겠다.

레디스 구성

현재 레디스는 standalone으로 배포되어있는데 가용성을 위해 센티넬 구조, 클러스터 구조를 고려해볼 수 있겠다.
샤딩이 필요한가? 라고 한다면.. 롤링페이퍼 데이터는 롤링페이퍼가 작성 완료되면 폐기되기 때문에 굳이 싶긴하다.
그래서 레디스 가용성을 보장하기 위해 센티넬 구조로 변경할 수 있겠다.

wss서버, 레디스 configuration 최적화

서버의 스레드 수를 조정한다던가, 타임아웃 설정을 한다던가, 레디스의 config를 수정하여 더 롤링페이퍼 서비스에 적절한 값을 찾는다던가 하는 작업을 진행해보는 것도 좋겠다.

마치며..

오랜만에 글을 쓰다보니 어떻게 끝을 맺어야 할지, 글 내용은 좀 볼만했는지 잘 모르겠다.
그동안 회사 업무땜에 넘 바빠서;; 글을 많이 작성하지 못했는데 다시 열심히 글을 쓰기로 노력하기로 했다.

아무튼 오늘 글을 여기까지. 끗.

This post is licensed under CC BY 4.0 by the author.

나의 첫 회고록, 2022년을 돌아보자

High Availability in REDIS (1) - Sentinel