지난 글에서는 메일의 봉투를 만들었다.
SMTP 서버와 인사를 나눈 뒤, 클라이언트는 먼저 봉투 발신자를 말했다.
C: MAIL FROM:<alice@example.com>
S: 250 OK
그리고 봉투 수신자를 말했다.
C: RCPT TO:<bob@example.net>
S: 250 OK
이때까지 우리는 아직 메일 본문을 보내지 않았다.
제목도 없었다.
내용도 없었다.
사용자가 실제로 읽을 수 있는 문장도 없었다.
하지만 서버는 이미 중요한 정보를 알고 있었다.
이 메일은 누구에게서 왔는가
이 메일은 누구에게 배달되어야 하는가
이제 남은 것은 편지 내용이다.
메일 화면에서 우리가 보는 것들.
From:, To:, Subject: 같은 헤더.
그리고 실제 본문.
이제 드디어 그것을 서버에게 건네볼 차례다.
그런데 여기서 새로운 질문이 생긴다.
본문은 언제 끝났다고 말할까?
DATA는 편지지를 건네겠다는 말이다
SMTP에서 메일 본문을 보내기 위해 사용하는 명령은 DATA다.
C: DATA
처음 이 명령을 보면 조금 심심하다.
발신자 주소처럼 인자가 붙지도 않는다.
MAIL FROM:<alice@example.com>
수신자 주소처럼 목적지가 붙지도 않는다.
RCPT TO:<bob@example.net>
그저 이렇게 말한다.
DATA
하지만 이 짧은 명령은 SMTP 대화의 분위기를 바꾼다.
지금까지는 클라이언트가 한 줄 명령을 보내고, 서버가 한 줄 응답을 주는 식이었다.
C: MAIL FROM:<alice@example.com>
S: 250 OK
C: RCPT TO:<bob@example.net>
S: 250 OK
그런데 DATA 이후에는 클라이언트가 여러 줄의 메일 내용을 보낼 수 있다.
서버는 DATA 명령을 받으면 보통 이렇게 답한다.
S: 354 Start mail input; end with <CRLF>.<CRLF>
여기서 354는 성공이 아니다.
아직 메일을 다 받았다는 뜻도 아니다.
오히려 이런 뜻에 가깝다.
좋다.
이제 본문을 보내라.
본문은 <CRLF>.<CRLF>로 끝내라.
RFC 5321에서도 SMTP 메일 트랜잭션은 MAIL, 하나 이상의 RCPT, 그리고 DATA 순서로 진행되며, DATA가 수락되면 서버가 354 응답을 반환하고 이후 줄들을 메일 데이터로 처리한다고 설명한다. 메일 데이터는 마침표 하나만 있는 줄로 종료된다.
오늘 구현할 것은 바로 이 부분이다.
EHLO
↓
MAIL FROM
↓
RCPT TO
↓
DATA
↓
메일 헤더와 본문
↓
.
↓
250 OK
354는 “계속 보내도 된다”는 신호다
SMTP 응답 코드를 처음 보면 250 OK가 가장 익숙하다.
성공했다는 뜻이기 때문이다.
S: 250 OK
하지만 DATA 명령 뒤에는 바로 250 OK가 오지 않는다.
먼저 354가 온다.
C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>
이 응답은 중간 응답이다.
서버가 이렇게 말하는 것이다.
DATA 명령은 받았다.
하지만 아직 메일을 받은 것은 아니다.
이제 내용을 보내라.
이 차이가 중요하다.
354는 “본문 입력을 시작하라”는 신호다.
250은 “본문까지 다 받았다”는 신호다.
그래서 DATA 흐름은 이렇게 나뉜다.
C: DATA
S: 354 Start mail input
C: From: Alice <alice@example.com>
C: To: Bob <bob@example.net>
C: Subject: Hello
C:
C: Hello, SMTP.
C: .
S: 250 OK
서버가 354를 보낸 뒤에는 클라이언트가 메일 내용을 보낸다.
그리고 서버는 본문이 끝날 때까지 기다린다.
DATA 이후에는 명령이 잠시 멈춘다
여기서 재미있는 점이 하나 있다.
DATA 이후에는 클라이언트가 보내는 줄들이 더 이상 SMTP 명령으로 해석되지 않는다.
예를 들어 DATA 입력 중에 이런 줄을 보냈다고 해보자.
QUIT
평소라면 QUIT은 연결 종료 명령이다.
C: QUIT
S: 221 localhost Bye
하지만 DATA 이후라면 다르다.
C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>
C: QUIT
C: .
S: 250 OK
이때 QUIT은 연결 종료 명령이 아니다.
그냥 메일 본문에 들어간 한 줄의 텍스트다.
서버는 DATA 모드에 들어간 뒤부터는 명령어를 찾지 않는다.
본문의 끝을 나타내는 특별한 줄만 찾는다.
그 특별한 줄이 바로 이것이다.
.
마침표 하나만 있는 줄.
이 줄이 나오기 전까지는 MAIL FROM도, RCPT TO도, QUIT도 모두 그냥 본문이다.
이 부분에서 SMTP가 조금 다르게 느껴진다.
프로토콜은 단순히 명령어 목록이 아니다.
같은 문자열도 어느 상태에서 오느냐에 따라 의미가 달라진다.
DATA 이전의 QUIT → 연결 종료 명령
DATA 이후의 QUIT → 메일 본문 한 줄
지난 글에서 상태가 필요하다고 느꼈다면, 이번 글에서는 그 이유가 더 분명해진다.
본문은 마침표 한 줄로 끝난다
SMTP에는 HTTP의 Content-Length 같은 방식이 없다.
본문이 몇 바이트인지 먼저 알려주고, 그 길이만큼 읽는 구조가 아니다.
대신 SMTP는 아주 오래된 방식으로 본문의 끝을 표시한다.
<CRLF>.<CRLF>
사람이 직접 입력할 때는 보통 이렇게 보인다.
.
즉, 한 줄에 마침표 하나만 입력하고 Enter를 누르면 본문이 끝난다.
예를 들어 이런 대화가 있다.
C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>
C: From: Alice <alice@example.com>
C: To: Bob <bob@example.net>
C: Subject: Hello SMTP
C:
C: Hello.
C: This is my first SMTP message.
C: .
S: 250 OK
서버가 저장해야 하는 본문은 여기까지다.
From: Alice <alice@example.com>
To: Bob <bob@example.net>
Subject: Hello SMTP
Hello.
This is my first SMTP message.
마지막의. 은 본문에 포함되지 않는다.
그것은 내용이 아니라 신호다.
본문은 여기서 끝났다.
그럼 진짜 마침표 한 줄을 보내고 싶으면?
여기서 바로 이상한 상황이 생긴다.
메일 본문에 정말로 마침표 하나만 있는 줄을 넣고 싶다면 어떻게 해야 할까?
예를 들어 이런 본문을 보내고 싶다고 해보자.
첫 번째 줄
.
세 번째 줄
하지만 DATA 모드에서 마침표 하나만 있는 줄은 본문 종료 신호다.
그렇다면 본문 안에 있는 마침표 한 줄과, 본문 종료를 나타내는 마침표 한 줄을 어떻게 구분할까?
SMTP는 이 문제를 해결하기 위해 마침표를 하나 더 붙이는 규칙을 사용한다.
클라이언트는 본문 줄이 마침표로 시작하면, 앞에 마침표를 하나 더 붙여서 보낸다.
원래 보내고 싶은 줄: .
실제로 전송하는 줄: ..
서버는 DATA를 읽다가 줄이 마침표 하나만 있으면 본문 종료로 처리한다.
.
하지만 줄이 마침표로 시작하면서 그 뒤에 다른 문자가 있으면, 앞의 마침표 하나를 제거하고 본문으로 저장한다.
수신한 줄: ..
저장할 줄: .
수신한 줄: ..hello
저장할 줄: .hello
이 규칙을 보통 dot-stuffing, 또는 투명성 처리라고 부른다고 한다.
이름은 조금 낯설지만 목적은 단순하다.
본문 안에 어떤 텍스트가 오더라도, 종료 신호와 충돌하지 않게 하려는 것이다.
RFC 5321도 메일 데이터 종료 표시로 인해 사용자의 텍스트가 방해받지 않도록, 클라이언트가 마침표로 시작하는 줄 앞에 마침표를 하나 더 붙이고 서버가 이를 다시 제거하는 투명성 절차를 설명한다.
이번 구현은 발송하지 않고 로그로 확인한다
이번 단계에서는 메일을 실제로 다른 서버로 보내지 않는다.
메일박스에 저장하지도 않는다.
파일로 남기는 것도 아직 하지 않는다.
대신 서버가 받은 내용을 로그에 찍어본다.
이유는 단순하다.
아직 우리가 확인하고 싶은 것은 “메일이 실제로 배달되는가”가 아니다.
이번 글에서 확인하고 싶은 것은 이것이다.
DATA 명령을 받으면 354를 응답하는가
그 이후의 줄들을 본문으로 읽는가
마침표 하나만 있는 줄에서 본문을 끝내는가
본문이 끝난 뒤 250 OK를 반환하는가
즉, 이번 구현의 목표는 배달이 아니라 관찰이다.
작업 지침서에서도 Phase 1의 DATA 처리 요구사항을 RCPT TO 이후에만 사용 가능하도록 하고, 354 응답 후 본문을 수신하며, <CRLF>.<CRLF>로 종료를 감지하고, 마침표로 시작하는 줄에 대한 투명성 처리를 수행하는 흐름으로 정리하고 있다.
핵심 구조는 이런 모습이다.
case "DATA":
if state != StateRcpt {
if err := writeLine("503 Bad sequence of commands"); err != nil {
return
}
continue
}
if err := writeLine("354 Start mail input; end with <CRLF>.<CRLF>"); err != nil {
return
}
// 메시지 수신 루프
// 250 OK를 받으면 메일 데이터 전송 모드로 전환됨
// 메일 데이터는 "." 하나만 있는 줄을 만날 때까지 읽는다
msgLines := []string{}
for {
// 한 줄씩 읽는다
line, err := reader.ReadString('\n')
if err != nil {
log.Println("error reading data", err)
if err := writeLine("554 Error on DATA read"); err != nil {
return
}
return
}
line = strings.TrimRight(line, "\r\n")
if line == "." {
break
}
line = strings.TrimPrefix(line, ".")
msgLines = append(msgLines, line)
// 현재는 단순히 로그에 기록
log.Printf("DATA: %s", line)
}
message = strings.Join(msgLines, "\n")
log.Println("=== MESSAGE RECEIVED ===")
log.Printf("MAIL FROM: %s", from)
log.Printf("RCPT TO: %v", rcpt)
log.Printf("MESSAGE:\n%s", message)
log.Printf("메일 수신 완료:\n%s", message)
resetSession()
if err := writeLine("250 OK"); err != nil {
return
}
여기서 중요한 부분은 이 줄이다.
if dataLine == "." {
break
}
본문 종료 신호를 만난 것이다.
이 줄을 만나기 전까지는 계속 본문을 읽는다.
그리고 이 부분은 마침표 투명성 처리다.
line = strings.TrimPrefix(line, ".")
마침표 하나만 있는 줄은 종료 신호다.
하지만 마침표로 시작하면서 다른 내용이 있는 줄은 본문이다.
그래서 앞에 붙은 마침표 하나를 제거하고 저장한다.
마지막으로 로그에 찍는다.
log.Printf("MAIL FROM: %s", from)
log.Printf("RCPT TO: %v", rcpt)
log.Printf("MESSAGE:\n%s", message)
아직 배달은 하지 않는다.
하지만 이제 서버는 메일 한 통의 봉투와 내용을 모두 받아볼 수 있게 되었다.
직접 대화해보기
서버를 실행한다.
go run main.go
다른 터미널에서 접속한다.
telnet localhost 2525
서버가 먼저 인사한다.
S: 220 localhost Simple Mail Transfer Service Ready
클라이언트가 인사한다.
C: EHLO client.local
S: 250-localhost greets client.local
S: 250 HELP
봉투를 만든다.
C: MAIL FROM:<alice@example.com>
S: 250 OK
C: RCPT TO:<bob@example.net>
S: 250 OK
이제 본문을 보낸다.
C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>
C: From: Alice <alice@example.com>
C: To: Bob <bob@example.net>
C: Subject: Hello SMTP
C:
C: Hello Bob.
C: This message was received by my SMTP server.
C: .
S: 250 OK
마지막으로 연결을 종료한다.
C: QUIT
S: 221 localhost Bye
전체 대화는 이렇게 된다.

서버 로그에는 대략 이런 내용이 남는다.

이 로그를 보는 순간, SMTP가 조금 더 구체적으로 보인다.
메일은 어느 날 갑자기 완성된 객체로 서버에 도착하는 것이 아니었다.
먼저 서버와 인사한다.
그다음 봉투를 만든다.
그리고 본문 입력을 시작한다.
마지막으로 마침표 한 줄을 보내서 본문이 끝났다고 말한다.
마침표 규칙도 확인해 보기
이번에는 본문 안에 마침표로 시작하는 줄을 넣어보자.
C: DATA
S: 354 Start mail input; end with <CRLF>.<CRLF>
C: From: Alice <alice@example.com>
C: To: Bob <bob@example.net>
C: Subject: Dot Test
C:
C: This line is normal.
C: ..This line starts with a dot.
C: .
S: 250 OK
클라이언트가 보낸 줄은 이것이다.
..This line starts with a dot.
하지만 서버가 저장해야 하는 줄은 이것이다.
.This line starts with a dot.

앞의 마침표 하나는 DATA 종료 신호와 충돌하지 않기 위해 추가된 것이다.
서버는 그 마침표 하나를 제거하고 저장한다.
이 규칙이 없으면 본문 안에 마침표 하나만 있는 줄을 안전하게 보낼 방법이 없다.
처음에는 사소해 보인다.
하지만 프로토콜을 구현하다 보면 이런 사소한 약속들이 계속 나온다.
줄은 어떻게 끝나는가.
명령은 언제 명령으로 해석되는가.
본문은 어디서 끝나는가.
본문 안의 특수한 줄은 어떻게 표현하는가.
SMTP 구현은 이런 약속들을 하나씩 직접 확인하는 과정이다.
DATA를 너무 일찍 보내면 어떻게 될까?
이번에는 순서를 어겨보자.
MAIL FROM과 RCPT TO 없이 바로 DATA를 보내면 어떻게 될까?
S: 220 localhost Simple Mail Transfer Service Ready
C: EHLO client.local
S: 250-localhost greets client.local
S: 250 HELP
C: DATA
S: 503 Bad sequence of commands
서버는 503을 반환한다.
본문을 보내려면 먼저 봉투가 있어야 한다.
서버 입장에서는 아직 이 메일이 누구에게 가야 하는지 모른다.
그러니 본문을 받을 수 없다.
MAIL FROM 없음
RCPT TO 없음
DATA 불가
이 흐름은 편지에 비유하면 자연스럽다.
편지지를 아무리 정성껏 써도, 봉투에 받을 사람이 없으면 배달할 수 없다.
SMTP에서도 마찬가지다.
본문보다 먼저 봉투가 필요하다.
250 OK의 무게
이번 구현에서는 DATA로 받은 메시지를 로그에만 찍는다.
그러니 지금의 250 OK는 실험용 응답에 가깝다.
하지만 실제 SMTP 서버에서 DATA 종료 후 보내는 250 OK는 꽤 무거운 의미를 가진다.
서버가 본문 종료 표시를 받고, 메시지를 처리한 뒤 250 OK를 반환하면, 그 서버는 메시지를 받아들인 것이다.
RFC 5321은 DATA 종료 후 긍정적인 완료 응답을 보내면 서버가 메시지에 대한 책임을 수락한다고 설명한다. 이후 배달에 실패하면 적절한 방식으로 실패를 보고해야 한다.
지금은 이 책임을 전부 구현하지 않는다.
아직 우리는 메시지를 저장하지도 않고, 다른 서버로 전달하지도 않고, 실패 알림을 만들지도 않는다.
다만 이 사실은 기억해 둘 필요가 있다.
354는 본문을 보내라는 말
250은 본문을 받았다는 말
지금은 로그에 찍는 것으로 대신하지만, 언젠가 이 서버가 실제로 메시지를 저장하거나 전달하게 된다면 250 OK를 보내는 시점은 더 신중하게 다뤄야 한다.
오늘 만든 것은 무엇인가
오늘 만든 서버는 아직 메일을 배달하지 않는다.
사용자의 받은 편지함도 없다.
파일로 저장하지도 않는다.
하지만 이제 이 서버는 SMTP 메일 트랜잭션의 핵심 흐름을 따라갈 수 있다.
S: 220 Service Ready
C: EHLO client.local
S: 250 OK
C: MAIL FROM:<alice@example.com>
S: 250 OK
C: RCPT TO:<bob@example.net>
S: 250 OK
C: DATA
S: 354 Start mail input
C: From: Alice <alice@example.com>
C: To: Bob <bob@example.net>
C: Subject: Hello
C:
C: Hello.
C: .
S: 250 OK
이제 메일에는 봉투와 내용이 모두 생겼다.
봉투는 MAIL FROM과 RCPT TO로 만들었다.
내용은 DATA 이후에 보냈다.
그리고 마침표 한 줄로 본문의 끝을 알렸다.
봉투
├─ MAIL FROM
└─ RCPT TO
내용
├─ From:
├─ To:
├─ Subject:
└─ Body
종료 신호
└─ .
이전까지 이메일은 화면에서 보는 결과에 가까웠다.
이제는 그 결과가 만들어지는 과정을 조금씩 보고 있다.
서버가 먼저 인사했다.
클라이언트가 자신을 소개했다.
봉투를 건넸다.
그리고 이제 편지 내용까지 건넸다.
이번 글의 끝에서 남는 질문
오늘 확인한 것은 본문의 경계다.
SMTP에서 본문은 그냥 연결이 끊길 때 끝나는 것이 아니었다.
길이를 미리 알려주는 것도 아니었다.
서버는 마침표 하나만 있는 줄을 기다린다.
.
그 줄을 만났을 때, 서버는 비로소 이렇게 말할 수 있다.
250 OK
이제 다음 질문이 남는다.
로그에 찍힌 이 메시지는 어디로 가야 할까?
메일을 받았다는 것은 단순히 화면에 출력했다는 뜻일까?
아니면 어딘가에 저장되어야 할까?
다음 글에서는 지금까지 받은 봉투와 본문을 하나의 메일 객체로 다뤄보고, 로그로만 흘려보내던 메시지를 조금 더 서버다운 방식으로 남겨보려 한다. 그전에! 나머지 명령어인 RSET NOOP를 구현할 것이다.
'Deep Dive > 기타' 카테고리의 다른 글
| [SMTP] 4. 봉투와 편지는 다르다. (1) | 2026.05.07 |
|---|---|
| [SMTP] 3. SMTP 서버와 첫 대화: 서버가 먼저 인사한다 (0) | 2026.05.07 |
| [SMTP] 2. SMTP 지도 그리기: 메일은 어디에서 어디로 가는가 (0) | 2026.05.07 |
| [SMTP] 1. SMTP를 직접 구현해보기로 했다. (0) | 2026.05.07 |
