[GO] GO Web with Redis

타키탸키·2022년 11월 30일
0

GO-WEB

목록 보기
9/11
post-thumbnail
post-custom-banner

지난 포스팅에 이어 이번에는 Go 웹 애플리케이션에서 Redis를 활용하는 방법에 대해 알아보고자 한다.

다음 내용은 ALEX EDWARDS의 포스팅에 기반하여 작성되었음을 알린다

redigo에 대해서 꼭 알아야 할 내용 중 하나는 Dial() 함수에 의해 반환되는 Conn 객체가 동시성에 있어 안전하지 않다는 점이다.

웹 애플리케이션과 같이 여러 goroutine들로부터 하나의 Redis server에 접속하고자 할 때, 반드시 Redis connection들에 대한 pool을 생성해야 한다. 또한, 그 pool로부터 가져온 connection을 사용할 때마다 명령어를 실행하여 pool과 함께 반환해야 한다.

위와 같은 내용을 웹 애플리케이션 예제를 작성하며 직접 알아보도록 하자. 지난 번과 같이 온라인 음반 쇼핑몰을 가정해 볼 것이다. 해당 애플리케이션에는 다음과 같은 세 가지 함수가 필요하다.

실습을 위한 준비

Redis에 초점을 두기 위해 아주 단순한 형태의 웹 애플리케이션을 활용하여 실습을 진행해 보려 한다.

애플리케이션을 위한 기본 디렉토리 구조는 다음과 같다.

프로젝트 디렉토리에서 main.goalbum.go 파일을 추가하고 다음 명령어를 실행해보자.

$ go mod init example.com/recordstore

위 명령어는 현재 패키지의 종속성 정보를 담고 있는 go.mod 파일을 생성한다.

다음으로, 아래 명령어들을 입력해서 좋아요 수가 담긴 새로운 sorted set과 함께 Redis CLI에 album들을 추가하자.

HMSET album:1 title "Electric Ladyland" artist "Jimi Hendrix" price 4.95 likes 8
HMSET album:2 title "Back in Black" artist "AC/DC" price 5.95 likes 3
HMSET album:3 title "Rumours" artist "Fleetwood Mac" price 7.95 likes 12
HMSET album:4 title "Nevermind" artist "Nirvana" price 5.95 likes 8
ZADD likes 8 1 3 2 12 3 8 4

ZADD는 score와 함께 집합(sorted set)에 data를 추가하는 명령어이다. 이때, score는 반드시 숫자여야 한다. member에는 앨범 각각의 id를 넘겨주었다.

  • ZADD key score member
    • score와 함게 sorted set에 data 추가하기

이 sorted set은 웹 애플리케이션의 GET /popular route에서 좋아요 수를 가장 많이 받은 앨범들의 id를 쉽고 빠르게 가져오기 위해 필요하다.

GET 메서드로 data 조회하기

albums.go에서 Redis connection pool을 담을 전역 변수를 정의하고 앞서 작성했던 코드를 용도에 맞게 수정해서 HTTP handler로 사용할 FindAlbum() 함수도 함께 정의해보자.

<albums.go>

package main

import (
	"errors"

	"github.com/gomodule/redigo/redis"
)

// Redis connection들을 담을 pool 변수를 선언한다
var pool *redis.Pool

var ErrNoAlbum = errors.New("no album found")

// Album data를 담을 custom struct를 정의한다
type Album struct {
	Title  string  `redis:"title"`
	Artist string  `redis:"artist"`
	Price  float64 `redis:"price"`
	Likes  int     `redis:"likes"`
}

func FindAlbum(id string) (*Album, error) {
	// pool에서 Redis connection 하나를 가져오기 위해 connection pool의 Get 메서드를 사용한다
	conn := pool.Get()

	// 반드시 defer와 connection의 Close() 함수를 사용해서
	// FindAlbum() 함수가 종료되기 전에 connection이 항상 pool을 반환하도록 해야 한다
	defer conn.Close()

	// 특정 앨범의 상세 정보를 가져온다
	// 주어진 id로 특정 앨범을 찾을 수 없으면, redis.Values에 의해 반환되는
    // []interface{} slice의 길이가 0이 된다
	// 따라서, 길이를 확인해보고 필요하다면 ErrNoAlbum 에러를 반환하도록 한다
	values, err := redis.Values(conn.Do("HGETALL", "album:"+id))
	if err != nil {
		return nil, err
	} else if len(values) == 0 {
		return nil, ErrNoAlbum
	}

	var album Album

	err = redis.ScanStruct(values, &album)
	if err != nil {
		return nil, err
	}

	return &album, nil
}

이제, main.go에서 connection pool을 초기화하고 간단한 웹 서버를 구축한뒤, GET /album route를 위한 HTTP Handler를 추가해보자.

<main.go>

package main

import (
	"fmt"
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/gomodule/redigo/redis"
)

func main() {
	// connection pool을 초기화하고 전역 변수 pool에 할당한다
	pool = &redis.Pool{
		MaxIdle:     10,
		IdleTimeout: 240 * time.Second,
		Dial: func() (redis.Conn, error) {
			return redis.Dial("tcp", "localhost:6379")
		},
	}

	// 웹 서버를 생성한다
	mux := http.NewServeMux()
	mux.HandleFunc("/album", showAlbum)
	log.Print("Listening on :4000...")
	
    http.ListenAndServe(":4000", mux)
}

func showAlbum(w http.ResponseWriter, r *http.Request) {
	// GET method를 사용하는 request가 아니면,
    // 405 'Method Not Allowed' 응답을 반환한다
    // 405 에러는 코드 번호가 아닌 실제 메시지를 인자로 넘긴다
	if r.Method != http.MethodGet {
		w.Header().Set("Allow", http.MethodGet)
		http.Error(w, http.StatusText(http.StatusMethodNotAllowed),
        http.StatusMethodNotAllowed)
		return
	}

	// Request URL 쿼리 스트링에서 id를 가져온다
    // 쿼리 스트링에 id key가 없으면, Get()은 빈 문자열을 반환하는데
    // 이러한 경우에는 400 Bad Request 응답을 반환한다
	id := r.URL.Query().Get("id")
	if id == "" {
		http.Error(w, http.StatusText(400), 400)
		return
	}
    
	// id가 유효한 정수인지 확인하고 형 변환에 실패하면 400 Bad Request를 반환한다
	if _, err := strconv.Atoi(id); err != nil {
		http.Error(w, http.StatusText(400), 400)
		return
	}

	// 사용자가 제공한 id를 전달하여 FindAlbum 함수를 호출한다
    // 맞는 앨범이 없으면, 404 Not Found 응답을 반환한다
    // 이 외에 다른 에러가 발생하면, 500 Internal Server Error 응답을 반환한다
	bk, err := FindAlbum(id)
	if err == ErrNoAlbum {
		http.NotFound(w, r)
		return
	} else if err != nil {
		http.Error(w, http.StatusText(500), 500)
		return
	}

	//  Client에게 앨범 상세 정보를 전송한다
	fmt.Fprintf(w, "%s by %s: £%.2f [%d likes] \n", bk.Title, bk.Artist,
    bk.Price, bk.Likes)
}

위 코드에서 MaxIdle의 크기를 10으로 명시했는데, 이는 단순히 pool에서 대기하고 있는 connection의 수를 10으로 제한한 것이다. 만약, pool.Get() 함수가 새로 호출되었을 때 10개의 connection이 모두 사용 중이면, 새로운 connection이 그때그때 알아서 생성된다.

또한, IdleTimeout 설정이 240초로 설정되어 있는데, 이는 해당 시간보다 더 긴 시간동안 활용되지 않는 connection들은 pool에서 제거된다는 것을 의미한다.

아래 명령어로 Go 프로그램을 실행한다.

$ go run .

2022/11/30 15:04:57 Listening on :4000...

❗ 패키지에 있는 go 프로그램을 실행하므로 run의 인자로 main.go가 아닌 현재 디렉토리를 의미하는 점(.)을 넘겨야 한다.

다음으로, 브라우저의 주소 창에 다음과 같이 입력하여 원하는 앨범의 상세 정보를 가져오는 요청을 전송한다. 이때, 쿼리 스트링으로 원하는 앨범의 id를 넘겨야 한다는 사실을 잊지 말자. 이를 넘기지 않으면, Bad Request 에러가 나타난다.

이렇게 하면, db에 저장된 두 번째 앨범에 대한 상세 정보가 브라우저 상에 출력된다.

POST 메서드로 data 가져오기

두 번째 route Post /likes를 살펴보자.

사용자가 한 앨범에 대해 like를 누를 때, 두 개의 명령어가 필요하다. 하나는 앨범 hash의 likes 필드의 수를 증가시키기 위한 HINCRBY이고, 다른 하나는 sorted set의 likes의 score 수를 증가시키기 위한 ZINCRBY이다.

❗ 그런데 이 명령어들을 사용하기에 앞서 고려해야 할 문제가 있다. 통상, 하나의 action만으로 동시에 두 key의 값이 증가하는 상황을 가정하지만 실제로는 이러한 경우에 경합 조건(Race Condition)이 유발될 수 있다. 경합 조건이란 쉽게 말해, 두 스레드가 같은 자원에 접근하기 위해 경쟁하는 상황을 의미한다. 이러한 경쟁 상황에서는 자원으로의 접근 순서가 중요한 의미를 지닌다.

이에 대한 해결 방안은 여러 명령어들을 단일 그룹으로 함께 실행하는 Redis 트랜잭션을 사용하는 것이다. 이를 위해 HINCRBYZINCRBY 명령어 앞에 트랜잭션을 시작하는 MULTI 명령어를 추가해야 한다. 그 후에는 EXEC 명령어를 통해 동일 그룹으로 묶인 두 개의 명령어들을 실행해야 한다.

albums.go 파일에 위 절차를 진행하는 IncrementLikes() 함수를 추가해보자.

<albums.go>

...

func IncrementLikes(id string) error {
	conn := pool.Get()
	defer conn.Close()

	// 먼저, id와 일치하는 앨범이 있는지 확인한다
    // EXISTS 명령어는 특정 key가 존재하면 1을, 존재하지 않으면 0을 반환한다
	exists, err := redis.Int(conn.Do("EXISTS", "album:"+id))
	if err != nil {
		return err
	} else if exists == 0 {
		return ErrNoAlbum
	}

	// MULTI 명령어로 Redis에 새로운 트랜잭션의 시작을 알린다
    // conn.Send() 메서드는 이름과 달리 connection의 출력 버퍼에 
    // 명령어를 작성만 하고 전송 하지는 않는다
	err = conn.Send("MULTI")
	if err != nil {
		return err
	}

	// 앨범 hash의 좋아요 수를 1 증가한다
    // MULTI 명령어를 따르므로, HINCRBY 명령어는 실행되지 않고
    // 트랙잭션의 한 부분으로 Queue에 저장된다
    // 응답의 Err 필드에 명령어 Queuing과 관련된 문제가 없는지 확인한다
	err = conn.Send("HINCRBY", "album:"+id, "likes", 1)
	if err != nil {
		return err
	}
    
	// sorted set에도 똑같이 진행한다
	err = conn.Send("ZINCRBY", "likes", 1, id)
	if err != nil {
		return err
	}

	// 트랜잭션의 두 명령어가 하나의 그룹으로 함께 실행된다
    // EXEC은 두 명령어의 응답을 반환하지만
    // 이 예시에서는 다루지 않을 것이므로 단순히 에러를 확인하는 것만으로 충분하다
    // conn.Do 메서드는 connection 출력 버퍼에서 이전 명령어들을 흘려 보내고(flush)
    // Redis 서버에 전송한다
	_, err = conn.Do("EXEC")
	if err != nil {
		return err
	}

	return nil
}

이에 맞게 main.go 파일도 갱신해야 한다. route를 위해 addLike() handler를 추가하자.

<main.go>

package main

import (
	"fmt"
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/gomodule/redigo/redis"
)

func main() {
	pool = &redis.Pool{
		MaxIdle:     10,
		IdleTimeout: 240 * time.Second,
		Dial: func() (redis.Conn, error) {
			return redis.Dial("tcp", "localhost:6379")
		},
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/album", showAlbum)
    // POST를 위한 route를 추가한다
	mux.HandleFunc("/like", addLike)
	log.Print("Listening on :4000...")
    
	http.ListenAndServe(":4000", mux)
}

...

func addLike(w http.ResponseWriter, r *http.Request) {
	// request가 POST 메서드를 사용하지 않으면, Method Not Allowed를 반환한다
    // 405 에러는 코드 번호가 아닌 실제 메시지를 인자로 넘긴다
	if r.Method != http.MethodPost {
		w.Header().Set("Allow", http.MethodPost)
		http.Error(w, http.StatusText(http.StatusMethodNotAllowed),
        http.StatusMethodNotAllowed) // 405
		return
	}

	// POST request body에서 id를 가져온다
    // request body에 id라는 파라미터가 없으면,
    // PostFormValue() 함수가 빈 문자열을 반환한다
    // 400 Bad Request 응답을 통해 확인할 수 있다
	id := r.PostFormValue("id")
	if id == "" {
		http.Error(w, http.StatusText(400), 400)
		return
	}
    
	// id가 유효한 정수인지 확인한다
    // 형 변환에 실패하면 400 Bad Request 응답이 반환된다
	if _, err := strconv.Atoi(id); err != nil {
		http.Error(w, http.StatusText(400), 400)
		return
	}

	// 사용자가 제공한 id를 넘겨 IncrementLikes 함수를 호출한다
    // id에 해당하는 앨범이 없으면, 404 Not Found 응답을 반환한다
    // 그 외 에러가 발생하면, 500 Internal Server Error 응답을 반환한다
	err := IncrementLikes(id)
	if err == ErrNoAlbum {
		http.NotFound(w, r)
		return
	} else if err != nil {
		http.Error(w, http.StatusText(500), 500)
		return
	}

	// 사용자가 좋아요를 눌렀다는 것을 확인할 수 있도록
    // GET /album route로 리다이렉트한다
	http.Redirect(w, r, "/album?id="+id, http.StatusSeeOther)
}

command line으로 URL에 data를 전송할 수 있는 도구인 curl을 사용하여 특정 앨범에 like에 대한 POST 요청을 날려보자. -i는 서버의 응답 Header를 가져오는 옵션이고 -L은 서버 링크가 다른 URL로 redirect 되어 있는 경우에 그 URL까지 접속하는 옵션이며, -d는 쿼리 스트링 형태로 인자를 전달하는 옵션이다.

$ curl -i -L -d "id=2" localhost:4000/like

❗ Windows에서 curl을 사용하는 방법은 이 포스트에서 확인 가능하고 더 많은 curl 옵션을 확인하고 싶으면 이 포스트를 참고하면 된다.

CMD 창에서 위 명령어를 실행하면, 다음과 같은 결과를 얻을 수 있다.

Header를 통해 알 수 있는 정보는 /like에서 /album?id=2로 redirect 되었다는 것과 두번째 앨범의 likes 수가 1 증가했다는 것이다.

Redis에서 ZSCORE 명령어를 사용하면, sorted set의 like 수도 4로 증가했음을 확인할 수 있다. 앞서 member를 id로 지정했으므로, id를 통해 접근한다.

  • ZSCORE key member
    • sorted set에 추가된 data의 score 확인하기

GET 메서드로 data 조회하기 2

마지막으로 GET /popular route를 살펴보자. 이 route는 좋아요 수를 가장 많이 받은 top 3 album들의 상세 정보를 보여줄 것이다. 이를 위해, FindTopThree() 함수를 albums.go 파일에 추가해야 한다. 이 함수에는 다음과 같은 기능이 필요하다.

  1. ZREVRANGE 명령어를 사용하여 likes sorted set에서 가장 높은 score와 함께 세 개의 앨범 id를 가져온다
  2. 반환된 id를 따라 루프를 돌면서 HGETALL 명령어를 통해 각각의 앨범에 대한 상세 정보를 가져오고 []*Album slice에 추가한다

여기에서도 경합 조건이 발생할 수 있다. 만약 두번째 client가 정확히 ZREVRANGE 명령어와 HGETALL 명령어 사이에 album의 like를 누르면, 사용자들은 잘못된 data를 받게 된다.

해결 방법은 트랜잭션과 함께 Redis의 WATCH 명령어를 사용하는 것이다. WATCH는 Redis에 특정 key에 대한 변화를 모니터링 하도록 지시한다. 만약 다른 client나 connection이 WATCH와 그 다음 트랜잭션의 EXEC 사이에 모니터링 되고 있는 key를 변경하면, 해당 트랜잭션은 실패하고 nil 응답을 반환한다. 만약 EXEC 전에 아무도 값을 변경하지 않으면, 트랜잭션이 성공적으로 끝난다. 트랜잭션이 성공할 때까지 루프 안에서 코드를 실행할 수 있다.

<albums.go>

...

func FindTopThree() ([]*Album, error) {
	conn := pool.Get()
	defer conn.Close()

	// 무한 루프를 실행한다
    // 실제 애플리케이션에서는 루프 횟수를 제한하고
    // 트랜잭션이 성공적으로 끝나지 않았을 때 에러를 반환하도록 할 수 있다
	for {
		// Redis에 likes sorted set에 변화가 없는지 모니터링하도록 지시한다
		_, err := conn.Do("WATCH", "likes")
		if err != nil {
			return nil, err
		}

		// ZREVRANGE 명령어를 사용하여 likes sorted set에서 
        // 가장 높은 score와 함께 id를 가져온다
        // ZREVRANGE의 인덱스에는 0과 2를 넘겨 top 3로 응답 범위를 제한한다 
		ids, err := redis.Strings(conn.Do("ZREVRANGE", "likes", 0, 2))
		if err != nil {
			return nil, err
		}

		// MULTI 명령어를 사용하여 Redis에 새로운 트랙잭션의 시작을 알린다
		err = conn.Send("MULTI")
		if err != nil {
			return nil, err
		}

		// ZREVRANGE가 반환한 id를 따라 루프를 돈다
        // 이때, HGETALL 명령어를 queue처럼 쌓아
        // 앨범 각각의 상세 정보를 가져온다
		for _, id := range ids {
			err := conn.Send("HGETALL", "album:"+id)
			if err != nil {
				return nil, err
			}
		}

		// 트랜잭션을 실행한다
        // redis.ErrNil 타입을 사용해서 EXEC의 응답이
        // nil인지 아닌지를 꼭 확인해야 한다
        // 만약 nil이면, 다른 client가 모니터링 되고 있는 likes sorted set을
        // 변경하고 있다는 것을 의미하므로
        // 이어지는 명령어를 사용해서 루프를 다시 실행해야 한다
		replies, err := redis.Values(conn.Do("EXEC"))
		if err == redis.ErrNil {
			log.Print("trying again")
			continue
		} else if err != nil {
			return nil, err
		}

		// 새로운 slice를 생성하여 앨범 상세 정보를 저장한다
		albums := make([]*Album, 3)

		// 응답 객체 배열을 순회하며
		// ScanStruct() 함수를 사용해서 Album struct에 data를 할당한다
		for i, reply := range replies {
			var album Album
			err = redis.ScanStruct(reply.([]interface{}), &album)
			if err != nil {
				return nil, err
			}

			albums[i] = &album
		}

		return albums, nil
	}
}

main.go에서 위 코드를 사용해보자.

<main.go>

package main

import (
	"fmt"
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/gomodule/redigo/redis"
)

func main() {

	...
    
	mux := http.NewServeMux()
	mux.HandleFunc("/album", showAlbum)
	mux.HandleFunc("/like", addLike)
    // /popular route를 위한 handler
	mux.HandleFunc("/popular", listPopular)

	log.Print("Listening on :4000...")
	http.ListenAndServe(":4000", mux)
}

...

func listPopular(w http.ResponseWriter, r *http.Request) {
	// GET 메서드 요청이 아니면, 405 Method Not Allowed 응답을 반환한다
	if r.Method != http.MethodGet {
		w.Header().Set("Allow", http.MethodGet)
		http.Error(w, http.StatusText(http.StatusMethodNotAllowed), 
        http.StatusMethodNotAllowed)
		return
	}

	// FindTopThree()를 호출한다
    // 에러가 발생하면 500 Internal Server Error 응답을 반환한다
	albums, err := FindTopThree()
	if err != nil {
		http.Error(w, http.StatusText(500), 500)
		return
	}

	// 루프를 돌며, 브라우저에 상세 정보를 출력한다
	for i, ab := range albums {
		fmt.Fprintf(w, "%d) %s by %s: £%.2f [%d likes] \n", i+1, ab.Title,
        ab.Artist, ab.Price, ab.Likes)
	}
}

WATCH에 대해 한 가지 알아 두어야 할 점은 트랜잭션을 EXEC(혹은 DISCARD)하거나 수동으로 UNMATCH를 호출할 때까지 key가 모니터링 된다는 것이다. 따라서, 예시와 같이 EXEC을 호출하고 likes sorted set을 자동으로 UNMATCH 해야 한다.

서버 프로그램을 실행하고 curl을 통해 GET /popular 요청을 생성하면, 다음과 같은 결과가 나온다.

$ go run .

// 새로운 cmd 창 열고 입력
$ curl -i localhost:4000/popular


Header와 가장 많은 좋아요 수를 가진 세 개의 앨범에 대한 상세 정보가 순서대로 출력된다.

profile
There's Only One Thing To Do: Learn All We Can
post-custom-banner

0개의 댓글