최근 Kotlin 기반 소켓 서버-클라이언트 프로젝트를 리팩토링하면서, 기존의 문자열 기반 패킷 프로토콜(v1)을 JSON 기반 DTO 프로토콜(v2)로 전환했다. 이 과정에서 핵심 역할을 한 것이 바로 Jackson(ObjectMapper / JsonMapper)이다.
이 글은 Jackson의 핵심 원리부터 실제 프로젝트에서 어떻게 적용했는지까지 실제 예시 중심으로 정리한 가이드다.
- GitHub Commit History
- JsomMapper 적용 : proLmpa/ChatApp/Commit `455cc3d`
- Json 로직 테스트 : proLmpa/ChatApp/Commit `6bc39fc`
1. 왜 문자열 기반 프로토콜에서 JSON 기반으로 전환했는가?
처음 채팅 서버를 만들 때 패킷의 본문(body)은 단순 문자열이었다.
PacketType | Response(String)
REGISTER_NAME > "Ryu"
CHAT_MESSAGE > "hello, world"
WHISPER > "hello Ryu"
문제는 기능이 늘어날 수록 다음 같은 문제가 발생한 것이다.
- 문자열 파싱이 복잡해짐
- "Ryu hello bob"을 " " 기준으로 split하면 데이터 손상 위험이 있다.
- 구조화되지 않아 필드를 추가하기 어려움
- 파일 전송, 메타데이터, 상태 값 등을 확장할 수 없다.
- 테스트에서도 문자열 비교만 하니 손상 여부 판단 불가
- 테스트의 신뢰도가 낮아진다.
그래서 패킷을 JSON 기반의 DTO 구조로 바꾸기로 했다.
DTO types | Response(DTO)
RegisterNameDTO > {"name":"Ryu"}
ChatMessageDTO > {"message":"hello world"}
WhisperDTO > {"target":"Ryu","message":"hello"}
이렇게 명확한 스키마를 형성하면
- 서버-클라이언트 모두 안전하게 파싱이 가능하고
- 기능 확장에 유리하며
- 테스트 작성도 확실한 단위 검증이 가능해진다.
2. Jackson의 핵심 동작 방식 (원리 이해)
Jackson의 역할은 단순히 설명하면 직렬화와 역직렬화 두 가지다.
2.1 Serialize (직렬화)
- Kotlin/Java DTO → JSON → ByteArray
ChatMessageDTO("hello")
↓ JSON 변환
{"message":"hello"}
↓ 바이트 배열
[7B 22 6D 65 ...]
직렬화 시, Jackson은 아래 기능들을 수행한다.
- 속성 reflection (Reflection : 런타임 시 프로그램 구조 내부 점검)
- KotlinModule을 통한 non-null constructor 지원
- ByteArray 변환
2.2 Deserialize (역직렬화)
- ByteArray → JSON → Kotlin/Java DTO
[7B 22 6D ...]
↓ Json String 변환
{"message":"hello"}
↓ Jackson 매핑
ChatMessageDTO(message="hello")
역직렬화 시, Jackson은 JSON의 key-value를 DTO 생성자 패러미터에 바인딩한다.
※ Kotlin data class와 100% 호환
Kotlin은 기본적으로 val 기반 불변 객체를 선호하며, Jackson의 Kotlin Module은 이를 자동으로 지원한다.
2.3 JSON 적용 시 데이터 흐름
DTO → JSON → ByteArray → Packet → 네트워크 → ByteArray → JSON → DTO → 로직 처리 (→ 다시 DTO…)
3. 프로젝트에서 적용한 Jackson 구성 (JsonUtil)
3.0 JsonMapper - Gradle 의존성
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0")
3.1 JsonMapper 정의
Jackson 적용 시 가장 중요한 건 Mapper를 최대한 일관성 있게 구성하는 것이었다.
object JsonUtil {
val mapper = JsonMapper.builder()
.findAndAddModules() // kotlin-module, java-time-module 등 자동 등록
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build()
.registerKotlinModule()
// DTO -> JSON ByteArray (Send)
inline fun <reified T> serializeToJsonBytes(obj: T): ByteArray =
mapper.writeValueAsBytes(obj)
// Json ByteArray -> DTO (Receive)
inline fun <reified T> deserializeFromJsonBytes(bytes: ByteArray): T =
try {
mapper.readValue(bytes)
} catch (e: Exception) {
throw IllegalArgumentException(
"JSON deserialization failed for type=${T::class}: ",
e
)
}
}
- findAndAddModules() : 날짜, Kotlin, 기타 모듈 자동 등록
- registerKotlinModule() : Kotlin data class 지원
- FAIL_ON_NULL_FOR_PRIMITIVES
> Int, Long 등 null을 허용하지 않는 primitive 타입에 대한 null 입력 여부 확인 (Java 전용) - JSON 오류 시 커스텀 메시지 제공하여 debugging 성능 향상
※ Kotlin은 Reference 타입과 Primitive 타입을 구별하지 않는다. (JVM에서 구별하는 건 JAVA 뿐)
- Reference (참조) : Int, Long 등 null을 허용하는 힙(Heap) 객체
- Primitive (원시) : int, long, float 등 null을 허용하지 않는 스택/레지스터 값(value)
3.2 사용자 요청 별 DTO 유형 정의
data class ServerInfoDTO(val message: String)
data class RegisterNameDTO(val name: String)
data class UpdateNameDTO(val newName: String)
data class ChatMessageDTO(val message: String)
data class WhisperDTO(val target: String, val message: String)
4. JSON 기반 패킷 구조 설계 (Protocol v2)
v2 패킷은 기존의 Header와 변형된 Json Body로 구성된다.
[4 bytes: 패킷 전체 길이]
[4 bytes: PacketType]
[N bytes: 패킷 body (JSON)]
- Packet data class
data class Packet(
val length: Int,
val type: PacketType?,
val body: ByteArray
) {
inline fun <reified T> toDTO(): T =
JsonUtil.deserializeFromJsonBytes(body)
}
- 변형된 패킷 생성 절차
object Protocol {
/**
* 객체 데이터를 포함한 패킷을 생성합니다.
* @param type 패킷 종류
* @param data 패킷 바디로 직렬화할 객체 (ex: data class)
*/
inline fun <reified T> createPacket(type: PacketType, data: T): ByteArray {
val bodyBytes = JsonUtil.serializeToJsonBytes(data)
val length = 8 + bodyBytes.size
val buffer = ByteBuffer.allocate(length)
buffer.putInt(length)
buffer.putInt(type.code)
buffer.put(bodyBytes)
return buffer.array()
}
...
}
5. 서버(ClientHandler) 적용 예시
// 이름 등록 처리 (v1)
val message = packet.getBodyAsString()
-----------------------------------------
// 이름 등록 처리 (v2)
val dto = packet.toDTO<RegisterNameDTO>()
clientData.name = dto.name
sendPacket(createPacket(PacketType.SERVER_SUCCESS, ServerInfoDTO("Welcome, ${dto.name}!")))
핵심은 모든 로직이 DTO 기반으로 이루어져 문자열 파싱 (ex. split, trim, substring 등)이 사라진다는 것이다.
6. 클라이언트(ClientSession) 적용 예시
// 서버 요청 전송
...
val dto = WhisperDTO("Ryu2", "hello")
sendPacket(PacketType.WHISPER, dto)
...
// 서버 응답 수신
private fun handlePacket(packet: Packet) {
when (packet.type) {
PacketType.SERVER_INFO -> {
val dto = packet.toDTO<ServerInfoDTO>()
println("Info: ${dto.message}")
}
...
}
}
이제 클라이언트 코드 역시 문자열 파싱 로직이 완전히 사라져 유지보수가 매우 쉬워졌다.
7. Jackson 도입 후 변형된 테스트코드
기존의 문자열 프로토콜(v1)에서는 아래처럼 테스트가 단순했다.
String(packetBody).contains("Alice")
하지만 JSON 프로토콜(v2)에서는 패킷이 다음과 같이 구조화되어서 테스트도 변형되었다.
# JSON 프로토콜(v2) 패킷 구조
# [length][type][body(JSON)]
@Test
fun `when REGISTER_NAME sent then writePacket is called`() {
// Given
client = ClientSession(conn, scriptedInput("Alice", "exit"))
// When
client.sendMessageLoop()
// Then
verify(conn, atLeastOnce()).writePacket(
argThat {
val p = decode(this)
p.type == PacketType.REGISTER_NAME && p.toDTO<RegisterNameDTO>().name == "Alice"
}
)
}
JSON을 도입하여 테스트가 더 정확하고 안정적인 형태로 개선되었다.
8. 결론
8.1 장점
Jackson을 도입하여 다음의 이점을 얻었다.
- 로직에서 문자열 파싱이 사라져 유지보수 난이도가 감소했다.
- 구조 변경 시 DTO 하나만 바꾸면 서버-클라이언트에 자동 반영되어 기능 확장에 유리하다.
- PacketType, DTO, JSON 구조까지 모두 검증 가능하여 테스트 품질이 향상되었다.
- Jackson 상세 예외 메시지를 설정하여 디버깅 편의성이 향상되었다.
Jackson을 도입하여 프로젝트의 전체적 안정성과 일관성을 높일 수 있었다.
8.2 단점
JSON을 도입하면 장점만 있는 것 같지만 단점도 존재한다.
- JSON 자체 문자열 파싱 비용이 증가했다.
- binary 기반 프로토콜 대비 느릴 수 있다. (채팅 서버 수준에서는 거의 영향이 없다.)
참고 자료
- https://github.com/FasterXML/jackson/wiki/Jackson-Releases
- https://cowtowncoder.medium.com/jackson-2-17-released-cf2c72996f20
- https://kotlinlang.org/docs/reflection.html
-
Powered By. ChatGPT
'Backend' 카테고리의 다른 글
| [Backend] ShadowJar 플러그인 적용 후 “기본 Manifest 속성이 없습니다” 오류 해결기 (0) | 2025.11.24 |
|---|---|
| [Backend] Kotlin tips (0) | 2025.11.24 |
| [Backend] Slf4J 와 Logback 로깅 도입 가이드 (0) | 2025.11.17 |
| Mockito-inline 5.2.0 설정 시 문제 해결 가이드 (Kotlin + Gradle + JDK17) (0) | 2025.10.30 |
| [Backend] JVM의 의존성 관리와 빌드 도구 분석 (0) | 2025.10.17 |