SMTP와 메일 서버

SMTP(Simple Mail Transfer Protocol)는 이메일 전송에 있어서 가장 표준적인 프로토콜이다.
현재 메일 시스템들은 SMTP를 쓰거나, 그 변종을 사용한다.

여기에서는 그 동작 방식과 구현 방법을 간단히 다뤄본다.




SMTP 프로토콜의 구조

SMTP는 TCP 기반의 프로토콜이며, 2단계 구조를 가진다.

그림으로 표현하면 대강 다음과 같다.

일단 이메일 전송을 하려고 하면, MX Lookup이라는 과정을 거친다.
그러면 거기에서 개별 메일 서버의 목록을 뿌려주는데, 메일 클라이언트가 그걸 우선순위 기반으로 선택해서 전송하는 것이다.

세부적으로는 HTTP처럼 HELLO 메세지 보내고 그러면서 연결 확인하고 검증하는 과정들이 더 있지만, 흐름 자체는 제법 단순한 편이다.




메일서버 구현해보기 (with Go)

SMTP 서버 구성을 위한 프로토콜 구현체들이 언어마다 있다.
여기서는 Go의 go-smtp 라이브러리를 사용해서 간단하게 구축해보겠다.

package main

import (
	"io"
	"log"
	"strings"
	"time"

	"github.com/emersion/go-smtp"
)

// Backend는 SMTP 서버의 백엔드를 구현합니다
type Backend struct{}

// NewSession은 새로운 SMTP 세션을 생성합니다
func (bkd *Backend) NewSession(_ *smtp.Conn) (smtp.Session, error) {
	return &Session{}, nil
}

// Session은 SMTP 세션을 나타냅니다
type Session struct {
	From string
	To   []string
}

// Mail은 메일 발신자를 설정합니다
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
	log.Printf("메일 발신자: %s\n", from)
	s.From = from
	return nil
}

// Rcpt는 메일 수신자를 추가합니다
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
	log.Printf("메일 수신자: %s\n", to)
	s.To = append(s.To, to)
	return nil
}

// Data는 메일 본문을 읽어들입니다
func (s *Session) Data(r io.Reader) error {
	body, err := io.ReadAll(r)
	if err != nil {
		return err
	}

	log.Printf("=== 메일 수신 ===\n")
	log.Printf("발신자: %s\n", s.From)
	log.Printf("수신자: %v\n", s.To)

	// 수신자 도메인 추출
	for _, recipient := range s.To {
		if idx := strings.Index(recipient, "@"); idx != -1 {
			domain := recipient[idx+1:]
			log.Printf("수신 도메인: %s (전체: %s)\n", domain, recipient)
		}
	}

	log.Printf("본문:\n%s\n", string(body))
	log.Printf("================\n")

	return nil
}

// Reset은 세션을 초기화합니다
func (s *Session) Reset() {
	s.From = ""
	s.To = nil
}

// Logout은 세션을 종료합니다
func (s *Session) Logout() error {
	return nil
}

func main() {
	backend := &Backend{}

	server := smtp.NewServer(backend)
	server.Addr = ":2525" // SMTP 포트 (25번 대신 2525 사용)
	server.Domain = "localhost"
	server.ReadTimeout = 10 * time.Second
	server.WriteTimeout = 10 * time.Second
	server.MaxMessageBytes = 1024 * 1024 // 1MB
	server.MaxRecipients = 50
	server.AllowInsecureAuth = true // 테스트용이므로 비보안 인증 허용

	log.Printf("SMTP 서버 시작: %s\n", server.Addr)
	log.Printf("도메인: %s\n", server.Domain)

	if err := server.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

여기서는 메일을 받아서 로그만 찍는 간단한 로직만 구현했다.
실제 서비스에서는 받은 데이터를 DB로 저장하거나 하는 로직이 추가될 것이다.


저렇게 실행하면, 2525 포트로 서버가 열릴 것이다.
기본 포트는 원래 25포트지만, 권한이 필요하거나 방화벽이 걸린 경우가 많아서 다른 포트로 열었다.

그리고 telnet CLI를 쓰면 간단하게 동작을 확인해볼 수 있다.
실제 이메일 클라이언트를 쓰려면 조건을 몇가지 더 갖춰야해서, 당장 완전한 테스트는 안된다.

telnet localhost 2525

EHLO localhost
MAIL FROM:<sender@example.com>
RCPT TO:<recipient@example.com>
DATA
Subject: test
From: sender@example.com
To: recipient@example.com

testtest
.
QUIT

찔러보면


전송 내역이 찍히는 것을 확인할 수 있을 것이다.




진짜 이메일 서버 구성하기

이제는 저 메일서버를 진짜 메일서버로서 기능하게 만들어보겠다.
일단 도메인이 필요하다. 내 경우에는 AWS Route53을 썼는데, 다른 도메인 제공자도 대부분 가능하다.

먼저 MX라는 메일서버 전용 레코드를 추가해준다.

여기에는 실제 메일서버를 가리키는 도메인을 여러개 넣을 수 있다.
10은 우선순위 값이다. 이메일 클라이언트는 우선순위가 높은 순서대로 시도하면서 메일서버와 연결한다.


등록하고, 이렇게 도메인 조회가 되면 잘 된 것이다.

그럼 이제 방금 만든 Go 메일서버를 공인 IP의 25 포트로 개방해줘야 한다.
내 경우에는 공유기를 통해서 개방해줘야 해서, 포트포워딩으로 해결했다.

EC2처럼 Public IP를 바로 쓸 수 있는 환경이라면 그냥 바로 25로 열고 방화벽만 뚫어서 실행하면 된다.

마지막으로, 방금 열어둔 25포트의 IP를 메일서버 도메인으로 등록해주면 된다.

그러면 MX 도메인 참조 후에 여기로 들어올 것이다.


그렇게 해서 등록이 잘 되었다면, 이제는 준비가 거의 다 된 것이다.

Gmail을 켜서 메일을 전송해보면 된다.
MX 레코드를 등록한 도메인을 사용해서, "아무거나@MX도메인" 의 형태로 수신자를 넣고 전송하면 그만이다.

그렇게 보내보면


실제 메일 데이터들이 잔뜩 들어올 것이다.

이외에도 보안 설정이나 추가로 건드릴 부분은 있지만, 기본 구성 요소는 이 정도다.
gmail 같은 메일서버들도 이런 식으로 만들어진다.



참조
https://aws.amazon.com/ko/what-is/smtp/
https://www.cloudflare.com/ko-kr/learning/email-security/what-is-smtp/