본문 바로가기

Back-End

Learn Golang - Chat Service

728x90

Preview

최근에 Golang을 공부하기 시작하면서 사이드 프로젝트를 시작하기에 앞서 잘 만들어진 소스코드를 분석해 보기로 했다. 실무에서 채팅 서비스를 개발하고 있기 때문에 채팅 프로젝트에 관심이 생겼다. 단순 분석뿐만 아니라 개선점을 찾아보고 중요한 개념들에 대해 기록을 남기려고 한다. 그리고 프로그램을 분석하며 발견한 이슈들을 좀 해결해보려고 한다.

ethan-well/go-chat

[GitHub - ethan-well/go-chat: Golang 高并发聊天程序/go chat room

Golang 高并发聊天程序/go chat room. Contribute to ethan-well/go-chat development by creating an account on GitHub.

github.com](https://github.com/ethan-well/go-chat)

중국인들에게 인기가 많은 golang 답게 이 repo도 중국인 개발자가 개발했다. (왜 중국인들이 좋아하는지는 의문이다..)

디렉터리 구조는 아래와 같다.

├── README.md
├── client
│   ├── logger
│   │   └── logger.go
│   ├── main.go
│   ├── model
│   │   └── user.go
│   ├── process
│   │   ├── messageProcess.go
│   │   ├── serverProcess.go
│   │   └── userProcess.go
│   └── utils
│       └── utils.go
├── common
│   └── message
│       └── message.go
├── config
│   ├── config.go
│   └── config.json
└── server
    ├── main
    │   ├── main.go
    │   └── redis.go
    ├── model
    │   ├── clientConn.go
    │   ├── error.go
    │   ├── user.go
    │   └── userDao.go
    ├── process
    │   ├── groupMessageProcess.go
    │   ├── onlineInfoProcess.go
    │   ├── pointToPointMessageProcess.go
    │   ├── processor.go
    │   └── userProcess.go
    └── utils
        └── utils.go

프로그램의 주요한 기능은 다음과 같다.

  1. User register (사용자 등록)
  2. User login (사용자 로그인)
  3. User send group message (전체 메시지 전송)
  4. Show online user list (접속 사용자 조회)
  5. Point-to-point communication (개인 메시지 전송)

프로그램이 어떻게 동작하는지는 github를 통해 참고하길 바란다.

Methodology

Config

config 폴더에는 config.goconfig.json 파일이 있다.

config.json는 redis 설정 파일이며 config.go는 이 설정 파일을 읽어 Configuration 구조체를 정의하는 역할을 한다.

한 가지 알아둬야 할 점은

func init(){}

Init 함수는 호출하는 부분이 없어도 실행이 된다는 것과 main function 이 있어도 init 이 먼저 실행된다는 것이다.

config.json파일을 읽어 만든 Configuration 구조체는 다음과 같다.

Configuration = {
    ServerInfo: {
        Host: "127.0.0.1:8888"
    },
    RedisInfo: {
        Host: "127.0.0.1:6379",
        MaxIdle:     16,
        MaxActive:   0,
        IdleTimeout: 300
    }
}

Server

Server 디렉터리에는 main, model, process, utils 디렉토리가 있다.

main 에는 실행점(entry point)인 main.go와 redis 실행파일인 redis.go가 있다.

main

main 디렉토리에는 entry point인 main.go 와 db연결을 담당하는 redis.go 가 있다.

main.go

func init() {
    redisInfo := config.Configuration.RedisInfo
    fmt.Println("redisInfo", redisInfo)
    initRedisPool(redisInfo.MaxIdle, redisInfo.MaxActive, time.Second*(redisInfo.IdleTimeout), redisInfo.Host)

    model.CurrentUserDao = model.InitUserDao(pool)
}

init 함수에서는 redis.go에 선언되어있는 initRedisPool 함수를 호출해 redis db에 연결을 시작하고 해당 redis pool을./model/userDao.go₩ 에 선언되어 있는 UserDao 구조체에 담는다.

redis.go

func initRedisPool(maxIdle, maxActive int, idleTimeout time.Duration, host string) {
    pool = &redis.Pool{
        MaxIdle:     maxIdle,
        MaxActive:   maxActive,
        IdleTimeout: idleTimeout,
        Dial: func() (redis.Conn, error) {
            return redis.Dial("tcp", host)
        },
    }
}

model

model 디렉토리에는 clientConn.go, error.go, user.go, userDao.go 파일이 있다.

clientConn.go

clientConn.go 파일에는 tcp 통신을 통해 접속한 사용자의 id [int]를 Key, ConnInfo {net.Conn, name}을 value로 같은 Map구조체를 정의해 접속한 사용자의 목록 정보를 가지고 있다.

func (cc ClientConn) Save(userID int, name string, userConn net.Conn) {
    ClientConnsMap[userID] = ConnInfo{userConn, name}
}

func (cc ClientConn) Del(userConn net.Conn) {
    for id, connInfo := range ClientConnsMap {
        if userConn == connInfo.Conn {
            delete(ClientConnsMap, id)
        }
    }
}

func (cc ClientConn) SearchByUserName(userName string) (connInfo net.Conn, err error) {
    user, err := CurrentUserDao.GetUserByUserName(userName)
    if err != nil {
        return
    }

    connInfo = ClientConnsMap[user.ID].Conn
    return
}

따라서 이처럼 새로운 사용자를 담을 수도, 제거할 수도, userName 검색할 수도 있다.

error.go

error.go에는 error 명세서가 정의되어 있다.

user.go

user.go에는 사용자 구조체(User) 가 정의되어 있다.

userDao.go

userDao.go 에는 redis pool 객체를 사용해 사용자의 생성, 검색, 로그인 등의 기능을 구현했다.

여기서 중요한 개념은 Dao인데 Dao는 Data Access Object로 주로 데이터베이스 연산을 처리하거나 데이터베이스에서 데이터를 가져오는 등의 작업을 수행하는 데 사용된다. 따라서 model 내의 다른 파일들과는 다르게 직접 데이터베이스와 통신하는 개념(3단계 아키텍처에서의 repository 역할)의 파일이다.

위에선 3단계 아키텍처의 repository 역할이라 했지만 이 둘의 특징을 자세히 살펴보면 아래와 같다.

DAO(Data Access Object)와 Repository는 모두 데이터 액세스를 추상화하는 데 사용되는 디자인 패턴이며, 비슷한 목적을 가지고 있습니다. Repository 패턴은 일반적으로 DAO 패턴을 확장하거나 대체하는 방식으로 사용될 수 있습니다.

Repository 패턴은 비즈니스 로직과 데이터베이스 간의 결합도를 줄이고 코드의 재사용성과 테스트 용이성을 향상하는 데 목적이 있습니다. 이를 위해 Repository는 데이터 액세스 코드를 추상화하고, 비즈니스 로직에서 데이터베이스에 대한 직접적인 의존성을 제거합니다.

chatGPT의 답변.

DAO와 Repository의 주요 차이점은 다음과 같습니다:
책임 범위:
DAO는 주로 데이터베이스 액세스 코드를 추상화하는 데 중점을 둡니다.
Repository는 데이터 액세스 코드뿐만 아니라 데이터의 영속성과 관련된 로직을 포함하는 데 중점을 둡니다.
명명 규칙:
DAO는 주로 데이터베이스와 직접적으로 상호작용하는 객체를 나타냅니다.
Repository는 데이터 액세스 및 영속성 관리에 대한 논리를 추상화하는 객체를 나타냅니다.
사용 범위:
DAO는 주로 객체 지향 프로그래밍 언어에서 사용됩니다.
Repository는 주로 객체 지향 언어와 함께 도메인 주도 설계(DDD)에서 사용됩니다.
따라서 Repository는 DAO와 유사한 목적을 가지고 있으며, 일부 상황에서는 DAO를 Repository로 대체하여 사용할 수 있습니다.

process

process 디렉토리에는 groupMessageProcess, onlineInfoProcess, pointToPointMessageProcess, processor.go, userProcess.go 파일이 있다.

파일 이름에서도 알 수 있다시피 초반에 기재했던 이 프로그램의 핵심 기능을 구현한 파일들이다.

processor.go

main.go 의 main 함수에선 사용자로부터 데이터가 수신되면 새로운 고 루틴을 만들고 해당 고 루틴에서는 processor.go의 MainProcess()를 호출한다.

//main.go
func dialogue(conn net.Conn) {
    defer conn.Close()
    processor := process.Processor{Conn: conn}
    processor.MainProcess()
}

func main() {
    serverInfo := config.Configuration.ServerInfo
    listener, err := net.Listen("tcp", serverInfo.Host)
    defer listener.Close()
    if err != nil {
        fmt.Printf("some error when run server, error: %v", err)
    }

    for {
        fmt.Printf("Waiting for client...\n")

        conn, err := listener.Accept()
        if err != nil {
            fmt.Printf("some error when accept server, error: %v", err)
        }
        go dialogue(conn)
    }
}

processor.go의 MainProcess 함수는 utils.go의 ReadData 메서드를 사용해 사용자(client)로부터 받은 버퍼 데이터를 파싱 한다. 파싱 한 데이터는 messageProcess 메서드를 사용해 process내의 각 기능(사용자 생성, 로그인, 사용자 목록 조회 등)을 담당하는 파일들로 전송한다.

groupMessageProcess.go

사용자가 전체 메시지를 전송한 경우 ClientConnsMap구조체에 담긴 사용자들에게 메시지를 전송한다.

onlineInfoProcess.go

ClientConnsMap구조체에 담긴 사용자들을 조회한다.

pointToPointMessageProcess.go

사용자 name을 입력받아 해당 사용자의 고 루틴에 메시지를 전송한다.

userProcess.go

userDao.go 에 있는 유저 서비스 register, Login 메서드를 호출해 로그인과 계정 생성을 처리한다.

utils

utils.go

버퍼데이터를 읽고 쓰는 역할을 하는 메서드 ReadData, WriteData 가 구현되어 있다.

common

common에는 message 디렉터리 안에 message.go라는 파일이 있는 해당 파일은 메시지 관련 구조체들이 정의되어 있다. DTO가 정의된 파일이라고 볼 수 있다.

client

client 디렉토리에는 logging을 담당하는 logger/logger.go, user 구조체가 정의되어 있는 model/user.go, server로 부터 송수신되는 버퍼 데이터를 읽고 전송하는 역할을 하는 utils/utils.go, entry point 인 main.go 가 있다.

main.go


func main() {
    var (
        key             int
        loop            = true
        userName        string
        password        string
        passwordConfirm string
    )

    for loop {
        logger.Info("\n----------------Welcome to the chat room--------------\n")
        logger.Info("\t\tSelect the options:\n")
        logger.Info("\t\t\t 1、Sign in\n")
        logger.Info("\t\t\t 2、Sign up\n")
        logger.Info("\t\t\t 3、Exit the system\n")

        // get user input
        fmt.Scanf("%d\n", &key)
        switch key {
        case 1:
            logger.Info("sign In Please\r\n")
            logger.Notice("Username:\n")
            fmt.Scanf("%s\n", &userName)
            logger.Notice("Password:\n")
            fmt.Scanf("%s\n", &password)

            // err := login(userName, password)
            up := process.UserProcess{}
            err := up.Login(userName, password)

            if err != nil {
                logger.Error("Login failed: %v\r\n", err)
            } else {
                logger.Success("Login succeed!\r\n")
            }
        case 2:
            logger.Info("Create account\n")
            logger.Notice("user name:\n")
            fmt.Scanf("%s\n", &userName)
            logger.Notice("password:\n")
            fmt.Scanf("%s\n", &password)
            logger.Notice("password confirm:\n")
            fmt.Scanf("%s\n", &passwordConfirm)

            up := process.UserProcess{}
            err := up.Register(userName, password, passwordConfirm)
            if err != nil {
                logger.Error("Create account failed: %v\n", err)
            }
        case 3:
            logger.Warn("Exit...\n")
            loop = false // this is equal to 'os.Exit(0)'
        default:
            logger.Error("Select is invalid!\n")
        }
    }
}

프로그램의 진입점인 main.go 에는 사용자와 상호작용할 수 있는 cli가 구현되어 있다. 사용자가 로그인 혹은 계정 생성을 선택하면 /process/userProcess.go의 메서드가 호출되고 userProcess.go 에서는 서버로 데이터를 버퍼로 변경해 전송한다. 이때 고 루틴을 생성해 동시성을 지원하고 각 고 루틴에서 수신한 데이터는 /process/serverProcess.go 에서 파싱한 다음 처리한다.

Issue

문제점은 다음과 같다.

  1. 접속하지 않은 사용자에게 메시지를 보내면 프로그램이 종료된다.

프로그램은 아래와 같은 핵심 기능을 제공하는데, 그중 5번 기능을 테스트하던 중 DB에는 존재하는 유저이지만 접속하지 않은 유저에게 메시지를 전송하면 프로그램이 종료된다. 이에 대한 대응이 되어있지 않은 듯했다.

 

User register (사용자 등록)

User login (사용자 로그인)
User send group message (전체 메시지 전송)
Show online user list (접속 사용자 조회)
Point-to-point communication (개인 메시지 전송)

 

server/process/pointToPointMessageProcess.go의 sendMessageToTargetUser 메서드는 다음과 같다.

func (this PointToPointMessageProcess) sendMessageToTargetUser(message string) (err error) {
    var pointToPointMessage common.PointToPointMessage
    err = json.Unmarshal([]byte(message), &pointToPointMessage)
    if err != nil {
        return
    }
    clientConn := model.ClientConn{}
    conn, err := clientConn.SearchByUserName(pointToPointMessage.TargetUserName)
    if err != nil {
        return
    }

    var responseMessage common.ResponseMessage
    responseMessage.Type = common.PointToPointMessageType

    var responseMessageData = common.PointToPointMessage{
        SourceUserName: pointToPointMessage.SourceUserName,
        TargetUserName: pointToPointMessage.TargetUserName,
        Content:        pointToPointMessage.Content,
    }

    data, err := json.Marshal(responseMessageData)
    if err != nil {
        return
    }
    responseMessage.Data = string(data)

    responseMessage.Code = 200

    responseData, err := json.Marshal(responseMessage)
    if err != nil {
        return
    }

    dispatcher := utils.Dispatcher{Conn: conn}
    err = dispatcher.WriteData(responseData)

    return
}

SearchByUserName 메서드는 redis db에서 userName 기준으로 검색한다. db에 존재하는 이름을 입력하면 에러가 발생하지 않지만 db엔 있지만 연결된 유저목록 구조체(ClientConnsMap)에 존재하지 않으면 애러가 발생한다.

따라서 아래와 같이 접속한 유저 목록 중에 전송하려는 유저를 찾는 과정이 필요하다.

///pointToPointMessageProcess.go
isExist := clientConn.IsExist(pointToPointMessage.TargetUserName)
if !isExist {
    err = fmt.Errorf("not logined user")
    return
}
//clientConn.go
func (cc ClientConn) IsExist(userName string) (bool){
    for _, connInfo := range ClientConnsMap{
        if userName == connInfo.UserName {
            return true
        }
    }
    return false
}
  1. 중복 로그인이 가능했다. 이미 접속해 있는 동일한 유저가 있어도 접속이 됐다.
//errors.go
var (
    ERROR_USER_DOES_NOT_EXIST = errors.New("User does not exist!")
    ERROR_USER_PWD            = errors.New("Password is invalid!")
    ERROR_USER_EXIST_ONLINE = errors.New("User is already Exists online")
)

error를 추가해 준 다음 로그인 중에 이미 접속한 유저를 찾는 코드를 삽입한다.

//userDao.go
func (this *UserDao) Login(userName, password string) (user User, err error) {
    user, err = this.GetUserByUserName(userName)
    if err != nil {
        fmt.Printf("get user by id error: %v\n", err)
        return
    }

    if user.Password != password {
        err = ERROR_USER_PWD
        return
    }
    for _, connInfo := range ClientConnsMap{
        if userName == connInfo.UserName{
            err = ERROR_USER_ALREADY_EXISTS
        }
    }
    return
}

그리고 해당 에러에 대한 코드를 405번으로 정의하고 클라이트 측에 메시지를 전송한다.

//userProcess.go
func (this *UserProcess) UserLogin(message string) (err error) {
    ---
    switch err {
    case nil:
        code = common.LoginSucceed
        // save user conn status
        clientConn := model.ClientConn{}
        clientConn.Save(user.ID, user.Name, this.Conn)

        userInfo := common.UserInfo{ID: user.ID, UserName: user.Name}
        info, _ := json.Marshal(userInfo)
        data = string(info)
    case model.ERROR_USER_DOES_NOT_EXIST:
        code = 404
    case model.ERROR_USER_PWD:
        code = 403
    case model.ERROR_USER_ALREADY_EXISTS:
        code = 405
    default:
        code = 500
    }
    this.responseClient(common.LoginResponseMessageType, code, data, err)
    return
    ---
}

클라이언트는 로그인 함수에서 405 코드를 수신하면 에러를 띄운다.

//serverProcess.go
func dealLoginResponse(responseMsg common.ResponseMessage) (err error) {
    switch responseMsg.Code {
    case 200:
        // 解析当前用户信息
        var userInfo common.UserInfo
        err = json.Unmarshal([]byte(responseMsg.Data), &userInfo)
        if err != nil {
            return
        }

        // 初始化 CurrentUser
        user := model.User{}
        err = user.InitCurrentUser(userInfo.ID, userInfo.UserName)
        logger.Success("Login succeed!\n")
        logger.Notice("Current user, id: %d, name: %v\n", model.CurrentUser.UserID, model.CurrentUser.UserName)
        if err != nil {
            return
        }
    case 500:
        err = errors.New("Server error!")
    case 404:
        err = errors.New("User does not exist!")
    case 403:
        err = errors.New("Password invalid!")
    case 405:
        err = errors.New("User already exists on online")
    default:
        err = errors.New("Some error!")
    }
    return
}

 

1번 문제는 수정이 간단해서 깃허브 레포에 Issue를 등록해 놓은 상태다. (아직 답장은 없지만..)

 

The End

소스코드 분석을 통해 배운 점 몇 가지를 말해보자면,

golang에서는 무한루프가 프로그램 종료를 유발하지 않는다. NodeJs에서는 무한루프의 끝을 정해주지 않으면 프로그램이 종료된다. 이는 Nodejs 가 외부 자원을 통해 넌블럭킹을 지원한다지만 싱글 스레드 특성상 끝이 없는 무한루프는 프로그램을 종료되게 만든다.

또한, 메서드의 반환값이 여러 개인 점이 매력 있다. 정상적인 반환값과 에러가 발생했을 때는 에러를 반환해 주어 메서드를 호출 시엔 에러체크가 필수적으로 진행되게 강제한다. 따라서 더 튼튼하고 안정적인 프로그램을 만들 수 있다.