지난 글에서는 이메일이 어디에서 어디로 이동하는지 대략적인 지도를 그려봤다. 그리고 gmail과의 통신도 짧게나마 살펴보았다.
처음에는 단순히 이렇게 생각했다.
보내는 사람 → 받는 사람
하지만 조금만 안쪽으로 들어가보니 그 사이에는 여러 역할이 있었다. 메일을 작성하는 프로그램이 있고, 메일을 전달하는 서버가 있고, 중간에서 다시 전달하는 서버가 있고, 최종적으로 사용자의 메일함에 저장하는 서버가 있었다.
그리고 그 흐름 어딘가에서 SMTP가 등장했다.
SMTP는 메일을 읽는 기술도 아니고, 메일 화면을 보여주는 기술도 아니었다. SMTP는 메일을 다른 곳으로 전송하기 위한 약속에 가까웠다.
그렇다면 이제 다음 질문으로 넘어갈 수 있다.
SMTP 서버와 실제로 대화하려면 무엇부터 해야 할까?
메일을 보내려면 먼저 발신자 주소를 알려줘야 할까?
수신자 주소를 알려줘야 할까?
본문을 먼저 보내야 할까?
아니면 클라이언트가 먼저 “안녕하세요”라고 말해야 할까?
의외로 첫 번째 말은 클라이언트가 하지 않는다.
서버가 먼저 인사한다.
문을 열면, 서버가 말한다
웹 개발에 익숙하면 보통 클라이언트가 먼저 요청을 보낸다고 생각하기 쉽다.
브라우저가 서버에 HTTP 요청을 보낸다.
GET / HTTP/1.1
Host: example.com
그러면 서버가 응답한다.
HTTP/1.1 200 OK
그래서 자연스럽게 네트워크 통신은 클라이언트가 먼저 말하고, 서버가 대답하는 구조라고 생각하게 된다.
하지만 SMTP의 첫 장면은 조금 다르다.
클라이언트가 SMTP 서버에 TCP 연결을 열면, 서버가 먼저 자신이 준비되었다고 말한다. RFC 5321도 SMTP 세션이 클라이언트가 서버에 연결을 열고, 서버가 시작 메시지로 응답하면서 시작된다고 설명한다. 그다음 클라이언트는 보통 EHLO 명령으로 자신을 식별한다.
그 첫 인사는 이런 모양이다.
S: 220 localhost Simple Mail Transfer Service Ready
220 smtp.gmail.com ESMTP d2e1a72fcca58-83965645d74sm6572331b3a.1 - gsmtp
여기서 S:는 서버가 보낸 줄이라는 뜻이다.
아직 메일 주소도 나오지 않았다. 제목도 없고, 본문도 없고, 첨부파일도 없다.
그저 서버가 먼저 이렇게 말한 것이다.
나는 준비되어 있다.
메일 한 통의 긴 여행은 이 짧은 문장 하나에서 시작된다.
SMTP는 TCP 위에서 대화한다
SMTP를 구현한다고 하면 뭔가 특별한 메일 서버 라이브러리부터 떠올리기 쉽다.
하지만 가장 처음에 필요한 것은 훨씬 단순하다.
TCP 서버를 하나 열면 된다.
SMTP는 기본적으로 클라이언트와 서버가 TCP 연결 위에서 한 줄씩 명령과 응답을 주고받는 구조다. RFC 5321은 SMTP가 특정 전송 하위 시스템에 종속되지 않고, 안정적으로 정렬된 데이터 스트림 채널을 필요로 한다고 설명한다. 실제 인터넷 환경에서는 TCP를 통한 전송을 다룬다.
그러니 우리가 처음 만들 것은 거창한 메일 서버가 아니다.
우선은 이것만 하면 된다.
1. TCP 포트를 연다.
2. 클라이언트가 접속한다.
3. 서버가 220 응답을 보낸다.
4. 클라이언트가 EHLO 또는 HELO를 보낸다.
5. 서버가 250 응답을 보낸다.
아직 메일을 저장하지도 않는다.
아직 Gmail이나 Naver로 메일을 보내지도 않는다.
오늘의 목표는 훨씬 작다.
SMTP 서버와 첫 대화를 성공시키는 것.
220: 서버의 첫인사
SMTP 서버가 연결 직후 보내는 220은 “서비스 준비됨”을 의미한다.
예를 들어 이런 식이다.
220 localhost Simple Mail Transfer Service Ready
숫자 220은 사람이 읽기 위한 문장보다 중요하다.
뒤의 문장은 서버마다 조금씩 달라질 수 있다. localhost라고 할 수도 있고, mail.example.com이라고 할 수도 있고, 서버 이름이나 버전 정보를 넣을 수도 있다.
하지만 클라이언트 입장에서 핵심은 앞의 숫자다.
220
이 숫자를 보고 클라이언트는 “서버가 준비되었구나”라고 판단한다.
여기서 재미있는 점은, SMTP 대화가 숫자와 문장이 섞인 형태라는 것이다.
사람은 뒤의 문장을 읽는다.
프로그램은 앞의 숫자를 본다.
220 localhost Simple Mail Transfer Service Ready
│ └─ 사람이 읽는 설명
└───── 프로그램이 판단하는 코드
이 구조는 앞으로 계속 반복된다.
성공하면 250.
본문을 보내라고 하면 354.
종료하겠다고 하면 221.
실패하면 4xx나 5xx.
하지만 지금은 외우지 않아도 된다.
오늘 필요한 숫자는 두 개뿐이다.
220: 서버가 준비됨
250: 요청을 정상적으로 처리함
지금은 그 정도면 충분하다.
EHLO: 클라이언트의 자기소개
서버가 먼저 인사하면, 이제 클라이언트가 대답할 차례다.
현대 SMTP에서는 보통 EHLO를 보낸다.
C: EHLO client.local
여기서 C:는 클라이언트가 보낸 줄이라는 뜻이다.
이 문장은 대략 이렇게 이해할 수 있다.
안녕하세요. 저는 client.local입니다.
그리고 당신이 어떤 기능을 지원하는지도 알고 싶습니다.
EHLO는 Extended Hello라고 생각하면 된다. 단순히 인사만 하는 것이 아니라, 서버가 지원하는 확장 기능 목록을 받을 수 있는 출발점이기도 하다.
서버는 이런 식으로 응답할 수 있다.
S: 250-localhost greets client.local
S: 250 HELP
여기서 처음 보는 모양이 하나 있다.
250-
250
첫 번째 줄은 250-처럼 숫자 뒤에 하이픈이 붙어 있다.
250-localhost greets client.local
이건 “아직 응답이 더 있다”는 뜻이다.
마지막 줄은 250처럼 숫자 뒤에 공백이 온다.
250 HELP
이건 “이 줄이 응답의 끝이다”라는 뜻이다.
즉, 여러 줄 응답은 이렇게 읽을 수 있다.
250-아직 더 있음
250-아직 더 있음
250 마지막 줄
지금은 서버가 특별한 기능을 많이 제공하지 않기 때문에 HELP 정도만 응답해도 충분하다.
나중에 기능이 늘어나면 여기에 SIZE, STARTTLS, AUTH, 8BITMIME 같은 것들이 붙을 수 있다. 하지만 아직은 그 단어들을 이해하지 않아도 된다.
오늘은 그저 서버가 이렇게 말한다는 것만 확인한다.
너의 인사를 받았다.
나는 이런 기능을 지원한다.
이제 다음 말을 해도 된다.
HELO: 오래된 인사도 받아주기
EHLO와 비슷한 명령으로 HELO가 있다.
C: HELO client.local
S: 250 localhost
HELO는 더 오래된 방식의 인사다.
현대 SMTP에서는 보통 EHLO를 먼저 사용하지만, 예전 클라이언트와의 호환성을 위해 서버는 HELO도 처리할 수 있어야 한다. RFC 5321도 클라이언트는 가능하면 EHLO로 세션을 시작해야 하고, 서버는 HELO 명령도 지원해야 한다고 설명한다.
둘의 차이는 지금 단계에서는 이렇게만 생각하면 된다.
EHLO: 안녕하세요. 저는 누구이고, 확장 기능도 알고 싶습니다.
HELO: 안녕하세요. 저는 누구입니다.
그래서 우리가 만들 서버는 둘 다 받아줄 것이다.
다만 앞으로의 구현에서는 EHLO를 기본 흐름으로 사용할 생각이다.
가장 작은 SMTP 서버 만들기
이제 실제로 서버를 만들어보자.
아직 완전한 SMTP 서버는 아니다. 엄밀히 말하면 RFC 5321의 최소 구현도 아니다. 최소 구현으로 가려면 MAIL, RCPT, DATA, RSET, NOOP, QUIT, VRFY 같은 명령도 더 필요하다.
하지만 오늘은 첫 대화만 본다.
서버가 먼저 220으로 인사하고, 클라이언트가 EHLO 또는 HELO로 자기소개를 하면, 서버가 250으로 대답하는 작은 서버를 만든다.
1. 네트워크 프로그래밍에 최적화된 표준 라이브러리
Go의 net 패키지는 TCP 소켓을 다루는 데 필요한 것들이 표준 라이브러리에 다 들어있다. 외부 패키지 없이 지금처럼 바로 서버를 만들 수 있다. Python의 socket 모듈과 비슷하지만 훨씬 직관적이다.
2. goroutine으로 동시 접속 처리가 단순함
go handleConn(conn)
이 한 줄은 클라이언트가 100명 동시 접속해도 각각 독립된 goroutine으로 처리한다. Python에서 같은 걸 하려면 threading이나 asyncio를 써야 하는데 Go는 go 키워드 하나로 끝난다.
3. 정적 타입으로 오류를 컴파일 시점에 잡음
func writeLine(line string) error {
반환 타입이 명확하게 선언되어 있어서 오류 처리를 빠뜨리면 컴파일이 안 된다. 네트워크 프로그래밍은 오류 처리가 중요한데 Go가 강제로 오류를 처리하도록 유도한다.
C 와의 차이도 있다. C는 가비지 컬렉터가 없지만 Go는 카비지 컬렉터가 있어 SMTP 서버처럼 연결이 계속 생기고 끊기는 환경에서 C로 짜면 메모리 누수가 생기기 쉬운데 반면 Go는 신경을 쓰지 않아도 된다.
C로 동시성 처리를 하려면 락을 적용해야 하지만 Go는 goroutine을 사용해 처리한다. goroutine에 대해서는 나중에 따로 설명하는 것이 좋을 것 같다.
이 외에 차이점이 더 있지만 넘어가겠다.
나만의 작은 SMTP 서버
package main
import (
"bufio"
"fmt"
"log"
"net"
"strings"
)
// 서버 도메인 이름 상수
// EHLO/HELO 응답, 220 인사말 등에서 서버를 식별하는 데 사용된다
// 실제 운영 환경에서는 실제 도메인명으로 변경해야 한다 (예: mail.example.com)
const serverName = "localhost"
func main() {
// TCP 소켓을 2525번 포트에 바인딩하여 수신 대기
// 표준 SMTP 포트는 25번이지만 개발 환경에서는 ISP 차단 우려로 2525 사용
listener, err := net.Listen("tcp", ":2525")
if err != nil {
log.Fatal(err)
}
// 함수 종료 시 리스너 자원 반환 (defer는 함수가 끝날 때 실행됨)
defer listener.Close()
log.Println("SMTP Server Started on port 2525")
for {
// 클라이언트 연결 요청을 수락
// Accept()는 연결이 올 때까지 블로킹 상태로 대기한다
conn, err := listener.Accept()
if err != nil {
log.Println("accept:", err)
continue
}
// 새 연결마다 goroutine을 생성하여 동시에 여러 클라이언트를 처리
// go 키워드가 없으면 한 번에 하나의 연결만 처리 가능
go handleConn(conn)
}
}
func handleConn(conn net.Conn) {
// 함수 종료 시 TCP 연결 자원 반환
defer conn.Close()
// bufio.Reader: TCP 스트림을 줄 단위로 읽기 위한 버퍼
// 네트워크 데이터는 바이트 스트림이므로 \n 기준으로 잘라 읽어야 한다
reader := bufio.NewReader(conn)
// bufio.Writer: 응답을 버퍼에 모았다가 Flush() 시점에 한 번에 전송
// 매번 즉시 전송하는 것보다 네트워크 효율이 좋다
writer := bufio.NewWriter(conn)
// writeLine: 응답 전송을 위한 헬퍼 함수
// RFC 5321에서 모든 줄은 반드시 \r\n(CRLF)으로 끝나야 한다
// \n(LF)만 사용하면 일부 클라이언트에서 파싱 오류가 발생할 수 있다
writeLine := func(line string) error {
if _, err := fmt.Fprintf(writer, "%s\r\n", line); err != nil {
return err
}
// Flush()를 호출해야 버퍼에 쌓인 데이터가 실제로 전송된다
return writer.Flush()
}
// RFC 5321 Section 3.1: 연결이 수립되면 서버가 먼저 220을 보내야 한다
// 형식: 220 <도메인> <설명>
if err := writeLine("220 " + serverName + " Simple Mail Transfer Service Ready"); err != nil {
return
}
// 클라이언트가 QUIT을 보내거나 연결이 끊길 때까지 명령을 반복 처리
for {
// \n이 나올 때까지 한 줄을 읽는다
// TCP는 스트림 기반이라 한 번에 전체 명령이 오지 않을 수 있어 ReadString 사용
line, err := reader.ReadString('\n')
if err != nil {
// 클라이언트가 연결을 끊었거나 네트워크 오류 발생
log.Println("connection closed", err)
return
}
// \r\n 또는 \n 제거하여 순수 명령 문자열만 남긴다
line = strings.TrimRight(line, "\r\n")
// 수신한 명령을 서버 로그에 기록
// TODO: log.Println → log.Printf 로 수정 필요 (%s 포맷 지원)
log.Println("C: %s", line)
// 빈 줄 수신 시 오류 응답
// RFC 5321에서 명령은 반드시 동사로 시작해야 한다
if line == "" {
if err := writeLine("500 Empty comamnd"); err != nil {
return
}
continue
}
// 명령어(cmd)와 인수(arg)를 첫 번째 공백 기준으로 분리
// strings.Cut은 구분자가 없으면 전체를 cmd에, arg는 빈 문자열로 반환
// 예) "EHLO test.com" → cmd="EHLO", arg="test.com"
// 예) "QUIT" → cmd="QUIT", arg=""
cmd, arg, _ := strings.Cut(line, " ")
// RFC 5321 Section 2.4: 명령어는 대소문자 구분 없이 처리해야 한다 (MUST)
// ehlo, Ehlo, EHLO 모두 동일하게 처리하기 위해 대문자로 통일
cmd = strings.ToUpper(cmd)
// 인수 앞뒤 공백 제거
arg = strings.TrimSpace(arg)
switch cmd {
case "EHLO":
// RFC 5321 Section 4.1.1.1
// EHLO는 클라이언트가 서버에 신원을 알리고 확장 기능을 협상하는 명령
// 인수(도메인 또는 주소 리터럴)가 없으면 501 반환
if arg == "" {
if err := writeLine("501 Syntax: EHLO hostname"); err != nil {
return
}
continue
}
// TODO: EHLO 수신 시 진행 중인 트랜잭션 상태 초기화 필요
// (MAIL FROM, RCPT TO, DATA 관련 변수들을 초기화해야 함)
// 다중 응답 형식: 마지막 줄 제외는 "250-", 마지막 줄은 "250 "
// 클라이언트는 하이픈을 보면 다음 줄을 계속 읽고, 공백을 보면 응답 끝으로 판단
if err := writeLine("250-" + serverName + " greets " + arg); err != nil {
return
}
// 현재는 HELP만 지원 목록에 포함
// Phase 3에서 8BITMIME, SIZE, STARTTLS 등을 여기에 추가하게 된다
if err := writeLine("250 HELP"); err != nil {
return
}
case "HELO":
// RFC 5321 Section 4.1.1.1
// HELO는 확장 기능을 지원하지 않는 구형 클라이언트용 명령
// EHLO와 달리 지원 기능 목록을 응답하지 않는다
if arg == "" {
if err := writeLine("501 Syntax: HELO hostname"); err != nil {
return
}
continue
}
// HELO 응답은 단순히 서버 도메인만 포함
if err := writeLine("250 " + serverName); err != nil {
return
}
case "HELP":
// RFC 5321 Section 4.1.1.8
// 서버가 지원하는 명령 목록을 사람이 읽을 수 있는 형태로 반환
// 214는 Help message 응답 코드
if err := writeLine("214 Commands supported: EHLO HELO HELP QUIT"); err != nil {
return
}
case "QUIT":
// RFC 5321 Section 4.1.1.10
// 221 응답 후 연결 종료
// 221을 보내기 전에 연결을 먼저 닫으면 안 된다 (MUST NOT)
_ = writeLine("221 " + serverName + " Bye")
return // defer conn.Close()가 실행되어 연결이 닫힌다
default:
// RFC 5321: 알 수 없는 명령에 대해 500 반환 후 연결 유지
// 모르는 명령을 받았다고 연결을 끊으면 RFC 위반이다
if err := writeLine("500 Command unrecognized"); err != nil {
return
}
}
}
}
포트는 2525를 사용했다.
SMTP의 기본 포트는 25번이지만, 로컬에서 실험할 때는 관리자 권한이나 운영체제 설정 문제를 피하기 위해 2525 같은 포트를 사용하는 편이 편하다. 지금 우리가 만들고 있는 것은 실제 인터넷에 공개할 메일 서버가 아니라, SMTP 대화를 관찰하기 위한 실험용 서버다.
한 줄의 끝은 CRLF다
코드에서 응답을 보낼 때 이런 부분이 있다.
fmt.Fprintf(writer, "%s\r\n", line)
여기서 \r\n이 중요하다.
우리는 보통 줄 바꿈을 \n 정도로 생각한다. 하지만 SMTP에서 한 줄은 CRLF, 즉 \r\n으로 끝난다. RFC 5321도 SMTP의 라인은 <CRLF>로 끝난다고 설명하고, 줄 종료자로 다른 문자나 시퀀스를 생성해서는 안 된다고 설명한다.
처음에는 사소해 보인다.
하지만 프로토콜을 직접 구현하다 보면 이런 작은 약속들이 계속 등장한다.
명령 한 줄은 어디서 끝나는가.
응답 한 줄은 어디서 끝나는가.
본문은 어디서 끝나는가.
우리는 아직 메일 본문까지 가지 않았다. 그런데도 벌써 “줄의 끝”이라는 약속을 만났다.
SMTP 구현은 이런 약속들을 하나씩 확인하는 과정이 될 것 같다.
RFC 5321에는 안정성을 위해 크기 제한이나 타임아웃에 대한 내용도 포함되어 있다. 이러한 내용들을 모아 클로드와 작업 지침서를 만들었고 그걸 따라가며 구현을 진행하고 있다.
직접 대화해 보기
서버를 실행한다.
go run main.go
다른 터미널에서 접속한다.
telnet localhost 2525
그러면 서버가 먼저 말한다.
220 localhost Simple Mail Transfer Service Ready
여기서 클라이언트가 EHLO를 입력해 본다.
EHLO client.local
서버는 이렇게 답한다.
250-localhost greets client.local
250 HELP
이번에는 연결을 종료해 본다.
QUIT
서버가 마지막 인사를 한다.
221 localhost Bye
읽기 좋게 C:와 S:를 붙여서 전체 대화를 다시 보면 이렇다.
S: 220 localhost Simple Mail Transfer Service Ready
C: EHLO client.local
S: 250-localhost greets client.local
S: 250 HELP
C: QUIT
S: 221 localhost Bye

아주 짧다.
메일을 보내지도 않았다.
수신자를 지정하지도 않았다.
본문도 없다.
하지만 우리는 처음으로 SMTP 서버와 대화했다.
오늘 만든 것은 아직 메일 서버가 아니다
오늘 만든 프로그램은 아직 메일 서버라고 부르기 어렵다.
메일을 받지 않는다.
메일을 저장하지 않는다.
다른 서버로 전달하지 않는다.
사용자의 받은 편지함도 없다.
그럼에도 이 작은 프로그램은 SMTP 서버의 가장 첫 장면을 재현한다.
클라이언트가 문을 두드린다.
서버가 준비되었다고 말한다.
클라이언트가 자신을 소개한다.
서버가 그 인사를 받아준다.
연결
↓
220
↓
EHLO
↓
250
이 네 줄만으로도 SMTP가 조금 다르게 보이기 시작한다.
이전까지 이메일은 “보내기 버튼을 누르면 어딘가로 가는 것”이었다.
이제는 그 안쪽에 아주 구체적인 대화가 있다는 것을 알게 되었다.
서버는 아무 말 없이 기다리고만 있지 않았다.
오히려 서버가 먼저 말했다.
220 Service Ready
이번 글의 끝에서 남는 질문
서버는 먼저 자신이 준비되었다고 말한다.
클라이언트는 자신이 누구인지 말한다.
서버는 그 인사를 받아준다.
이제 다음 질문이 남는다.
인사를 마친 뒤, 클라이언트는 무엇을 말해야 할까?
메일을 보내려면 가장 먼저 무엇을 알려줘야 할까?
다음 글에서는 드디어 메일의 “봉투”를 만들기 시작한다.
본문보다 먼저, 제목보다 먼저, 서버는 발신자와 수신자를 알아야 한다.
MAIL FROM
RCPT TO
이 두 줄에서 이메일은 조금 더 메일다운 모양을 갖추기 시작한다.
'Deep Dive > 기타' 카테고리의 다른 글
| [SMTP] 4. 봉투와 편지는 다르다. (1) | 2026.05.07 |
|---|---|
| [SMTP] 2. SMTP 지도 그리기: 메일은 어디에서 어디로 가는가 (0) | 2026.05.07 |
| [SMTP] 1. SMTP를 직접 구현해보기로 했다. (0) | 2026.05.07 |
