Golang + Lambda로 신규 유저 정보 이메일로 보내기 삽질기

Golang + Lambda로 신규 유저 정보 이메일로 보내기 삽질기

이번 글은 실패한 사례를 공유해두려고 한다. 실패라기 보다는 몰랐던 사실 때문에 방법을 수정하게 되었다. 결론적으로 말하자면, EC2에 스케줄링 하는 방식으로 수정되었다. 다만 람다에 배포하고 CloudWatch Event를 사용해 스케줄링 하는 과정까지는 진행했고, 해당 과정을 담았다.

서비스 중인 앱 중에서는 회원가입 신청한 유저의 신원을 직접 확인한 후 Activate를 해줘야 하는 부분이 있다. 회원 가입 후, 비개발인력이 데이터베이스에서 새롭게 가입한 유저를 확인하고, 몇 가지 확인과 등록 절차를 통해 유저를 등록시켜야 하는데, 비개발 인력이 하기 어려운 작업이라 매일 오전 9시에 전날 새로 가입한 유저 정보를 CSV로 만들고 메일로 보내는 스케줄링 작업을 Go로 만들어보려고 한다. 먼저 데이터베이스에서 내용을 가져와 CSV로 만들어내는 부분을 만든 다음, 메일 보내기 작업을 한 다음 RDS에 연결한 Lambda 배포까지 진행해보려고 한다.

서비스 코드 작성하기

우선 Go 언어 사용이 생소하기 때문에 디렉토리 구조나 개발 환경에 대한 고민을 많이 했다. 가장 상위 src 디렉토리 아래, 테이블을 정의하는 models 디렉토리, ORM 로직을 담는 orm 디렉토리, 서비스 로직을 담는 services, 실행되는 main.go 형태로 구성했다. 먼저 만든 부분은 ORM에서 조건에 맞춰 쿼리를 넣는 작업이다.

데이터베이스는 PostgreSQL을 사용하고 있었고, 우리에게 필요한 부분은 전날 가입한 사람들이다. 먼저 이미 데이터베이스에 정의된 모델을 아래와 같이 가져오도록 정의했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/models/models.go
package models

// Pharm pharm model
type Pharm struct {
ID int `gorm:"Column:id" json:"id"`
Name string `gorm:"Column:name" json:"name"`
Username string `gorm:"Column:username" json:"username"`
JibunAddr string `gorm:"Column:jibunAddr" json:"jibunAddr"`
RoadAddr string `gorm:"Column:roadAddr" json:"roadAddr"`
DetailAddr string `gorm:"Column:detailAddr" json:"detailAddr"`
Location string `gorm:"Column:location" json:"location"`
PharmacistName string `gorm:"Column:pharmacistName" json:"pharmacistName"`
Tel string `gorm:"Column:tel" json:"tel"`
Phone string `gorm:"Column:phone" json:"phone"`
}

// TableName pharm table name
func (Pharm) TableName() string {
return "pharm"
}

Go의 ORM으로 잘 알려진 gorm을 사용하고 있다. 우리에게 필요한 모델을 설정했으니, 이제 쿼리를 작성해보자. 우선 데이터베이스를 연결하고, 연결된 데이터베이스를 넘겨주는 함수를 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/orm/layer.go

package orm

import (
"github.com/jinzhu/gorm"
// orm driver
_ "github.com/jinzhu/gorm/dialects/postgres"
)

// DBORM orm
type DBORM struct {
*gorm.DB
}

// NewORM get new orm setting
func NewORM(config string) (*DBORM, error) {

db, err := gorm.Open("postgres", config)
return &DBORM{
DB: db,
}, err
}

로직은 메인함수에서 진행이 되도록 작성했다.

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
// src/main.go
package main

import (
"bdygMailScheduler/src/orm"
"fmt"
"log"
"os"

"github.com/joho/godotenv"
)

func main() {

godotenv.Load()

DB_HOST := os.Getenv("DB_HOST")
DB_USER := os.Getenv("DB_USER")
DB_NAME := os.Getenv("DB_NAME")
DB_PASSWORD := os.Getenv("DB_PASSWORD")

ormConfig := fmt.Sprintf("host=%s port=5432 user=%s dbname=%s password=%s", DB_HOST, DB_USER, DB_NAME, DB_PASSWORD)

db, err := orm.NewORM(ormConfig)
if err != nil {
panic(err)
}
defer db.Close()

pharms, err := orm.GetYesterdayNewbie(db)

log.Print(pharms, err)
}

ORM 로직을 아래와 같이 작성했다. 전날 가입한 유저의 정보를 가져오는 로직이다.

1
2
3
4
5
6
7
8
9
10
// src/orm/orm.go

package orm

import "bdygMailScheduler/src/models"

// GetYesterdayNewbie 어제 가입한 약사 유저 리스트
func GetYesterdayNewbie(db *DBORM) (pharms []models.Pharm, err error) {
return pharms, db.Select([]string{`name`, `username`, `"jibunAddr"`, `"roadAddr"`, `"detailAddr"`, `"pharmacistName"`, `location`, `tel`, `phone`, `"createdAt"`}).Find(&pharms, `date("createdAt") = current_date - 1`).Error
}

위와 같이 메인함수와 ORM로직을 작성한 다음, 서비스 코드를 작성했다. 핵심적인 서비스를 다루는 로직은 src/services 아래 두었다. csv를 만드는 서비스와 메일을 보내는 서비스를 따로 분리했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/services/csvService.go
package services

import (
"bdygMailScheduler/src/models"
"os"

"github.com/gocarina/gocsv"
)

// GetCsv create csv data
func GetCsv(newbieList []models.Pharm) string {
key := "/tmp/newbie.csv"
file, err := os.Create(key)
if err != nil {
panic(err)
}

if err := gocsv.MarshalFile(newbieList, file); err != nil {
panic(err)
}

return key
}

위에서 한 가지 삽질했던 것이 있는데, 람다에서는 /tmp 임시적으로 파일을 생성할 수 있다고 한다.

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
// src/services/mailService.go
package services

import (
"fmt"
"time"

"gopkg.in/gomail.v2"
)

// From email sender
type From struct {
Email string
Password string
}

// SendMail send data
func SendMail(from From, to []string, attachment string) {
current := time.Now().Local()
subject := fmt.Sprintf("%s 새로운 약국 가입자", current.Format("2006-01-02"))

m := gomail.NewMessage()
m.SetHeader("From", from.Email)
m.SetHeader("To", to...)
m.SetHeader("Subject", subject)
m.Attach(attachment)

d := gomail.NewDialer("smtp.gmail.com", 587, from.Email, from.Password)

if err := d.DialAndSend(m); err != nil {
panic(err)
}
}

서비스 코드를 작성했으니, 메인 함수에서 동작하게 호출해줬다.

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
// src/main.go
package main

import (
"bdygMailScheduler/src/orm"
"bdygMailScheduler/src/services"
"fmt"
"os"
)


func main() {
DB_HOST := os.Getenv("DB_HOST")
DB_USER := os.Getenv("DB_USER")
DB_NAME := os.Getenv("DB_NAME")
DB_PASSWORD := os.Getenv("DB_PASSWORD")
EMAIL := os.Getenv("EMAIL")
PASSWORD := os.Getenv("PASSWORD")

ormConfig := fmt.Sprintf("host=%s port=5432 user=%s dbname=%s password=%s", DB_HOST, DB_USER, DB_NAME, DB_PASSWORD)

db, err := orm.NewORM(ormConfig)
if err != nil {
panic(err)
}
defer db.Close()

pharms, err := orm.GetYesterdayNewbie(db)
if err != nil {
panic(err)
}

count := len(pharms)
if count == 0 {
return
}

csvKey := services.GetCsv(pharms)
defer os.Remove(csvKey)
emailSender := services.From{Email: EMAIL, Password: PASSWORD}
to := []string{EMAIL}

services.SendMail(emailSender, to, csvKey)
}

이 정도로 작성한 뒤, 로컬에서 동작시켜보니 올바르게 작동했다. 이제 람다에 올리고 CloudWatch Event에 스케줄링을 해두면 되겠다고 생각했다.

Gsuite의 보안 수준이 낮은 앱에서 로그인이 안되는 문제가 있었지만 2단계 인증 이후 앱 비밀번호?를 설정해 해당 비밀번호를 사용하게 했다.

람다에 올리는 과정은 공식 문서를 참고 했는데, 람다 함수의 진입점으로 설정해줘야 하는 부분이 있어서 아래와 같이 메인함수를 수정해줬다.

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
// src/main.go
package main

import (
"bdygMailScheduler/src/orm"
"bdygMailScheduler/src/services"
"fmt"
"os"

"github.com/aws/aws-lambda-go/lambda"
)

// HandleRequest Go lambda function
func HandleRequest() {

DB_HOST := os.Getenv("DB_HOST")
DB_USER := os.Getenv("DB_USER")
DB_NAME := os.Getenv("DB_NAME")
DB_PASSWORD := os.Getenv("DB_PASSWORD")
EMAIL := os.Getenv("EMAIL")
PASSWORD := os.Getenv("PASSWORD")

ormConfig := fmt.Sprintf("host=%s port=5432 user=%s dbname=%s password=%s", DB_HOST, DB_USER, DB_NAME, DB_PASSWORD)

db, err := orm.NewORM(ormConfig)
if err != nil {
panic(err)
}
defer db.Close()

pharms, err := orm.GetYesterdayNewbie(db)
if err != nil {
panic(err)
}

count := len(pharms)
if count == 0 {
return
}

csvKey := services.GetCsv(pharms)
defer os.Remove(csvKey)
emailSender := services.From{Email: EMAIL, Password: PASSWORD}
to := []string{EMAIL}

services.SendMail(emailSender, to, csvKey)
}

func main() {
lambda.Start(HandleRequest)
}

람다에 배포하기

서버리스 프레임워크도 Golang을 지원하지만, 이번에는 간단하기도 하고 하나의 함수만 넣는 작업이기 때문에, 직접 콘솔을 사용했다. 함수는 zip으로 압축한 다음, 콘솔에서 직접 업로드 했다.

1
2
GOOS=linux go build src/main.go
zip function.zip main

일반적으로 람다를 만들듯 함수 생성하기를 통해 함수를 만든 다음, RDS를 사용하기 위해서 VPC 내부로 설정을 해줬다. VPC로 연결하기 위해서는 다음과 같은 권한을 요구로 한다. (역할 정책에 다음과 같은 권한을 추가해줘야 한다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeNetworkInterfaces",
"ec2:CreateNetworkInterface",
"ec2:DeleteNetworkInterface",
"ec2:DescribeInstances",
"ec2:AttachNetworkInterface"
],
"Resource": "*"
}
]
}

EventBridge를 통해 크론잡을 추가해준다. 크론잡에 대해서는 이 글에서 한 번 다룬 적이 있다. 우리는 매일 오전 아홉시에 해당 이메일이 오길 원하기 때문에 아래와 같은 표현식을 사용했다.

1
cron(0 18 * * ? *)

발생한 문제

문제가 발생한 곳은 외부 네트워크로 요청을 보내는 것에 있었다. 메일을 보내는 부분에서 timeout이 발생했는데, VPC의 Public subnet에 위치시켜도 마찬가지였다. 친절하기로 소문난 AWSKRUG 슬랙 채널에 질문을 드린 결과 VPC 내부에 위치시켰을 때 Public, Private 상관 없이 외부 네트워크 요청을 할 때 IGW를 통하지 못한다는 답변을 받게 되었다. 그래서 해결 방안으로는 NAT Gateway를 만들거나, VPC Endpoint를 사용해서 SES를 쓰는 것이다. (또는 이번 태스크에서 선택한 람다를 포기하는 것…)

결론

아쉽게도 람다를 포기했다. 이번 태스크에서 사용하기 위해 Go와 GORM을 공부하면서 시간 소비가 좀 있기도 했고, NAT Gateway로 간단하게 해결하자니 추가되는 금액을 부담할만한 사항이 아닌 것 같고… 해서 EC2에서 스케줄링을 넣기로 했다.

후기

좋은 삽질이었다. Go언어로 만든 첫 프로젝트라서 재미도 느꼈다. 다른 프로젝트도 Go를 사용해보고 싶다.

Reference

# ,

댓글

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×