728x90
모바일 웹 개발을 하다 보면 실제 모바일 환경에서 도메인 접속 테스트가 필요할 때가 있습니다. 하지만 네트워크 환경상 도메인을 강제로 매핑해 테스트하는 것이 어려울 경우, 자체 DNS 서버를 만들어 활용하면 큰 도움이 됩니다. 이번 포스팅에서는 Java로 DNS 서버를 구현하는 방법을 단계별로 소개합니다.
DNS 서버란?
DNS(Domain Name System) 서버는 사용자가 입력한 도메인 이름(www.example.com 등)을 실제 IP 주소(192.0.2.1)로 변환해주는 시스템입니다. Java로 DNS 서비스를 만들기 위해서는 다음 요소들을 이해해야 합니다:
- UDP/TCP 소켓 프로그래밍
- DNS 패킷 구조 (RFC 1035 기반)
- 도메인 ↔ IP 매핑을 위한 Map 또는 데이터베이스
Java DNS 서버 구현 개요
Java를 이용해 DNS 서버를 직접 구현하기 위해, 아래 기능들을 차례로 구현합니다.
1. UDP 기반 DNS 요청 처리
- DatagramSocket을 사용해 53번 포트로 들어오는 DNS 요청을 수신
- DatagramPacket으로 클라이언트에게 응답 전달
- 요청은 스레드 풀(ExecutorService)로 비동기 처리
DatagramSocket socket = new DatagramSocket(53);
DatagramPacket request = new DatagramPacket(buffer, buffer.length);
socket.receive(request);
2. TCP 기반 DNS 요청 처리
- 일부 DNS 클라이언트는 TCP를 사용합니다.
- ServerSocket으로 TCP 접속을 받고, Socket으로 데이터 송수신
ServerSocket serverSocket = new ServerSocket(53);
Socket client = serverSocket.accept();
3. DNS 요청 파싱
- DNS 요청은 헤더(12바이트) + 질의 섹션으로 구성됩니다.
- Java에서 ByteBuffer 또는 배열로 도메인 이름을 추출
private String extractDomainName(byte[] requestData) {
int index = 12;
StringBuilder domainName = new StringBuilder();
while (index < requestData.length) {
int labelLength = requestData[index] & 0xFF;
if (labelLength == 0) {
break;
}
if (domainName.length() > 0) {
domainName.append(".");
}
for (int i = 1; i <= labelLength; i++) {
domainName.append((char) requestData[index + i]);
}
index += labelLength + 1;
}
return domainName.toString();
}
4. 로컬 응답 생성
- 도메인 → IP 주소 매핑은 Map을 사용
- A 레코드(IPv4) 기준으로 응답 패킷 작성
- TTL(Time-To-Live)을 1년 등으로 길게 설정 가능
private byte[] createLocalDNSResponse(byte[] requestData, String domainName) {
try {
ByteArrayOutputStream responseStream = new ByteArrayOutputStream();
DataOutputStream dataOutputStream = new DataOutputStream(responseStream);
// DNS 응답 헤더 작성
dataOutputStream.write(requestData, 0, 2); // ID (질문과 동일)
dataOutputStream.writeShort(0x8180); // 응답 플래그 (QR=1, AA=1, RCODE=0)
dataOutputStream.writeShort(1); // QDCOUNT (질문 개수)
dataOutputStream.writeShort(1); // ANCOUNT (응답 개수)
dataOutputStream.writeShort(0); // NSCOUNT
dataOutputStream.writeShort(0); // ARCOUNT
// 질문 섹션(QNAME, QTYPE, QCLASS) 복사
int index = 12;
while (requestData[index] != 0) {
dataOutputStream.write(requestData[index]);
index++;
}
dataOutputStream.write(0); // QNAME 종료
dataOutputStream.writeShort(0x0001); // QTYPE (A 레코드)
dataOutputStream.writeShort(0x0001); // QCLASS (IN)
// 응답 섹션 (도메인 이름 압축)
dataOutputStream.writeShort(0xC00C); // 압축된 도메인 이름 (포인터)
dataOutputStream.writeShort(0x0001); // TYPE (A 레코드)
dataOutputStream.writeShort(0x0001); // CLASS (IN)
// TTL을 매우 긴 시간으로 설정 (1년 = 31536000초)
dataOutputStream.writeInt(365 * 24 * 60 * 60);
// RDLENGTH (IPv4 주소 길이 = 4바이트)
dataOutputStream.writeShort(4);
// 도메인의 IP 주소 변환 및 추가
String ipAddress = dnsRecords.get(domainName);
for (String part : ipAddress.split("\\.")) {
dataOutputStream.writeByte(Integer.parseInt(part));
}
return responseStream.toByteArray();
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
return new byte[0];
}
}
5. 캐시(Cache) 기능 구현(선택 사항)
- 응답을 메모리에 저장해 중복 요청에 빠르게 대응
- TTL 설정으로 일정 시간 지나면 무효화
Map<String, CachedResponse> cache = new ConcurrentHashMap<>();
6. 외부 DNS(Google 8.8.8.8) 포워딩(선택 사항)
- 로컬에 없는 도메인은 Google Public DNS로 포워딩
- DNSSEC 요청 플래그도 설정 가능
InetAddress google = InetAddress.getByName("8.8.8.8");
실제 테스트를 위한 DNS의 경우 폐쇄망일 가능성이 높아 이런 설정이 필요 없는 경우기 많지만 아닐 경우 여러모로 불편한 점이 발생하여 추가하는 경우도 있습니다.
DNS 보안 고려사항
- DNSSEC(DNS Security Extensions)을 활용하면 위·변조를 방지할 수 있습니다.
- 이 예제에서는 Google DNS에 요청 시 DNSSEC 플래그를 설정해 보안을 강화했습니다.
Java로 자체 DNS 서버 구축하기(예제)
TestDnsSvc.java
package test.svc;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.*;
import java.util.Map;
import java.util.concurrent.*;
@Slf4j
@Service("testDnsSvc")
public class testDnsSvc {
private static final int DNS_PORT = 53;
private static final String GOOGLE_DNS = "8.8.8.8";
private static final int GOOGLE_DNS_PORT = 53;
private static final int CACHE_TTL = 24 * 60 * 60000; // 24 시간(24시간 * 60분 * 60초(60,000미리초))
// 동시 요청 처리를 위한 스레드 풀 (최대 100개 스레드)
private final ExecutorService executorService = Executors.newFixedThreadPool(100);
private static final Map<String, String> dnsRecords = new ConcurrentHashMap<>();
private static final Map<String, CachedResponse> cache = new ConcurrentHashMap<>();
/**
* 서비스 도메인 정보
*/
static {
dnsRecords.put("test.domain.org", "127.0.0.1");
}
/**
* 서버 실행
*/
public void startServer() {
new Thread(this::startUDPServer).start();
new Thread(this::startTCPServer).start();
}
/**
* UDP 서버 실행
*/
private void startUDPServer() {
try (DatagramSocket socket = new DatagramSocket(DNS_PORT)) {
while (true) {
byte[] buffer = new byte[512];
DatagramPacket requestPacket = new DatagramPacket(buffer, buffer.length);
socket.receive(requestPacket);
// UDP 요청을 비동기 실행하여 동시 처리
executorService.submit(() -> handleUDPRequest(socket, requestPacket));
}
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
}
}
/**
* UDP 요청 처리
* @param socket
* @param requestPacket
*/
private void handleUDPRequest(DatagramSocket socket, DatagramPacket requestPacket) {
try {
InetAddress clientAddress = requestPacket.getAddress();
int clientPort = requestPacket.getPort();
byte[] response = handleDNSQuery(requestPacket.getData());
DatagramPacket responsePacket = new DatagramPacket(response, response.length, clientAddress, clientPort);
socket.send(responsePacket);
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
}
}
/**
* TCP 서버 실행
*/
private void startTCPServer() {
try (ServerSocket serverSocket = new ServerSocket(DNS_PORT)) {
log.info("TCP DNS 서버 실행 중... PORT : {}", DNS_PORT);
while (true) {
Socket clientSocket = serverSocket.accept();
// TCP 요청을 스레드 풀에서 비동기 처리
executorService.submit(() -> handleTCPConnection(clientSocket));
}
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
}
}
/**
* TCP 커넥션 처리
* @param clientSocket
*/
private void handleTCPConnection(Socket clientSocket) {
try (InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) {
byte[] lengthBuffer = new byte[2];
inputStream.read(lengthBuffer);
int queryLength = ((lengthBuffer[0] & 0xFF) << 8) | (lengthBuffer[1] & 0xFF);
byte[] queryData = new byte[queryLength];
inputStream.read(queryData);
byte[] responseData = handleDNSQuery(queryData);
byte[] responseLength = { (byte) (responseData.length >> 8), (byte) responseData.length };
outputStream.write(responseLength);
outputStream.write(responseData);
outputStream.flush();
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
}
}
/**
* DNS 요청 처리
* @param requestData
* @return
*/
private byte[] handleDNSQuery(byte[] requestData) {
String domainName = extractDomainName(requestData);
// 요청 도메인이 설정한 도메인인 경우
if (dnsRecords.containsKey(domainName)) {
return createLocalDNSResponse(requestData, domainName);
}
// 요청한 도메인이 캐싱되어 있는 경우
if (cache.containsKey(domainName)) {
CachedResponse cachedResponse = cache.get(domainName);
// 캐시된 정보가 만료 되었는지 확인
if (System.currentTimeMillis() - cachedResponse.timestamp < CACHE_TTL) {
return cachedResponse.responseData;
// 캐시가 만료된 경우
} else {
// 캐시 정보 삭제
cache.remove(domainName);
}
}
// Google DNS 요청을 별도 스레드에서 비동기 처리
Future<byte[]> futureResponse = executorService.submit(() -> forwardToGoogleDNSWithDNSSEC(requestData));
try {
// 결과를 기다림
byte[] responseData = futureResponse.get();
cache.put(domainName, new CachedResponse(responseData, System.currentTimeMillis()));
return responseData;
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
return new byte[0];
}
}
/**
* 요청정보에서 도메인명 추출
* @param requestData
* @return
*/
private String extractDomainName(byte[] requestData) {
int index = 12;
StringBuilder domainName = new StringBuilder();
while (index < requestData.length) {
int labelLength = requestData[index] & 0xFF;
if (labelLength == 0) {
break;
}
if (domainName.length() > 0) {
domainName.append(".");
}
for (int i = 1; i <= labelLength; i++) {
domainName.append((char) requestData[index + i]);
}
index += labelLength + 1;
}
return domainName.toString();
}
/**
* 로컬에 설정된 도메인의 응답값을 생성
* @param requestData
* @param domainName
* @return
*/
private byte[] createLocalDNSResponse(byte[] requestData, String domainName) {
try {
ByteArrayOutputStream responseStream = new ByteArrayOutputStream();
DataOutputStream dataOutputStream = new DataOutputStream(responseStream);
// DNS 응답 헤더 작성
dataOutputStream.write(requestData, 0, 2); // ID (질문과 동일)
dataOutputStream.writeShort(0x8180); // 응답 플래그 (QR=1, AA=1, RCODE=0)
dataOutputStream.writeShort(1); // QDCOUNT (질문 개수)
dataOutputStream.writeShort(1); // ANCOUNT (응답 개수)
dataOutputStream.writeShort(0); // NSCOUNT
dataOutputStream.writeShort(0); // ARCOUNT
// 질문 섹션(QNAME, QTYPE, QCLASS) 복사
int index = 12;
while (requestData[index] != 0) {
dataOutputStream.write(requestData[index]);
index++;
}
dataOutputStream.write(0); // QNAME 종료
dataOutputStream.writeShort(0x0001); // QTYPE (A 레코드)
dataOutputStream.writeShort(0x0001); // QCLASS (IN)
// 응답 섹션 (도메인 이름 압축)
dataOutputStream.writeShort(0xC00C); // 압축된 도메인 이름 (포인터)
dataOutputStream.writeShort(0x0001); // TYPE (A 레코드)
dataOutputStream.writeShort(0x0001); // CLASS (IN)
// TTL을 매우 긴 시간으로 설정 (1년 = 31536000초)
dataOutputStream.writeInt(365 * 24 * 60 * 60);
// RDLENGTH (IPv4 주소 길이 = 4바이트)
dataOutputStream.writeShort(4);
// 도메인의 IP 주소 변환 및 추가
String ipAddress = dnsRecords.get(domainName);
for (String part : ipAddress.split("\\.")) {
dataOutputStream.writeByte(Integer.parseInt(part));
}
return responseStream.toByteArray();
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
return new byte[0];
}
}
/**
* 요청을 google DNS에 전달
* @param requestData
* @return
*/
private byte[] forwardToGoogleDNSWithDNSSEC(byte[] requestData) {
try (DatagramSocket googleSocket = new DatagramSocket()) {
InetAddress googleDnsAddress = InetAddress.getByName(GOOGLE_DNS);
// DNSSEC 요청 설정
requestData[2] |= (byte) 0x10;
DatagramPacket googleRequestPacket = new DatagramPacket(requestData, requestData.length, googleDnsAddress, GOOGLE_DNS_PORT);
googleSocket.send(googleRequestPacket);
byte[] googleResponseBuffer = new byte[512];
DatagramPacket googleResponsePacket = new DatagramPacket(googleResponseBuffer, googleResponseBuffer.length);
googleSocket.receive(googleResponsePacket);
return googleResponsePacket.getData();
} catch (Exception e) {
log.error("{}", e.getMessage(), e);
return new byte[0];
}
}
/**
* 캐시된 리스폰스값을 저장하기 위한 Class
*/
private static class CachedResponse {
byte[] responseData;
long timestamp;
CachedResponse(byte[] responseData, long timestamp) {
this.responseData = responseData;
this.timestamp = timestamp;
}
}
}
SpringBoot에 Service 형태로 구동가능한 샘플 예제 입니다.
728x90
'개발 > 개발 구현' 카테고리의 다른 글
[Spring Boot DDNS 자동 갱신] DuckDNS와 함께하는 DDNS IP 자동화 프로그램 만들기 (0) | 2025.05.01 |
---|---|
Java에서 암호화된 ZIP 파일 푸는 방법 (Zip4j 라이브러리 사용법) (0) | 2025.04.22 |
Java에서 Telegram 봇 만들기 – telegrambots 라이브러리 사용법 & 메시지 수신 및 삭제 예제 (0) | 2025.04.18 |
JAVA에서 XML 전자문서에 디지털 서명하는 방법 (PKCS12 인증서 사용) (0) | 2025.04.16 |
Java Spring – XML 기반 Bean 설정 정리 (0) | 2025.04.11 |