객체를 만들어서 이것저것 데이터를 담았다. 그런데 이걸 파일에 저장하거나 네트워크로 보내려면? 객체는 메모리에만 존재하는데, 메모리 밖으로 꺼내려면 어떻게 해야 할까? 이 질문에서 출발하면 "직렬화"가 왜 필요한지 자연스럽게 이해된다.

직렬화가 뭔가

직렬화(Serialization) 란 객체를 바이트 스트림으로 변환하는 걸 말해요. 반대로 바이트 스트림을 다시 객체로 복원하는 것을 역직렬화(Deserialization) 라고 합니다.

왜 필요한지 생각해볼까요? Java 객체는 힙 메모리에 살아 있습니다. 프로그램이 종료되면 사라지죠. 그런데 다음과 같은 상황이 있어요.

  • 객체를 파일에 저장 해서 나중에 다시 쓰고 싶다
  • 객체를 네트워크로 전송 해서 다른 서버에서 쓰고 싶다
  • 객체를 캐시(Redis 등) 에 넣어두고 싶다

이럴 때 객체를 바이트 배열이나 문자열 같은 "전송 가능한 형태"로 바꿔야 합니다. 이것이 직렬화예요.

PLAINTEXT
[Java 객체] --직렬화--> [바이트 스트림] --저장/전송--> [파일/네트워크/캐시]
                                                           |
[Java 객체] <--역직렬화-- [바이트 스트림] <--읽기----------+

Serializable 인터페이스

Java에서 직렬화를 가장 기본적으로 지원하는 방법은 Serializable 인터페이스를 구현하는 것입니다.

JAVA
import java.io.Serializable;

// Serializable을 구현하면 직렬화 가능
public class User implements Serializable {
    private String name;
    private int age;
    private String email;

    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    @Override
    public String toString() {
        return "User{name='" + name + "', age=" + age + ", email='" + email + "'}";
    }
}

Serializable마커 인터페이스(marker interface) 입니다. 메서드가 하나도 없어요. "이 클래스는 직렬화해도 됩니다"라고 JVM에 알려주는 역할만 합니다.

ObjectOutputStream으로 직렬화하기

JAVA
import java.io.*;

public class SerializeExample {
    public static void main(String[] args) {
        User user = new User("김철수", 25, "kim@example.com");

        // 객체를 파일에 직렬화
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new FileOutputStream("user.ser"))) {
            oos.writeObject(user);
            System.out.println("직렬화 완료: " + user);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

ObjectInputStream으로 역직렬화하기

JAVA
public class DeserializeExample {
    public static void main(String[] args) {
        // 파일에서 객체를 역직렬화
        try (ObjectInputStream ois = new ObjectInputStream(
                new FileInputStream("user.ser"))) {
            User user = (User) ois.readObject();
            System.out.println("역직렬화 완료: " + user);
            // User{name='김철수', age=25, email='kim@example.com'}
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }
    }
}

readObject()Object 타입을 반환하므로 캐스팅이 필요합니다. 그리고 ClassNotFoundException을 처리해야 한다는 점도 주의해야 해요. 직렬화된 클래스가 클래스패스에 없으면 복원할 수 없기 때문입니다.

transient 키워드

직렬화할 때 모든 필드를 저장하고 싶지 않을 수도 있어요. 비밀번호 같은 민감한 정보는 파일에 그대로 쓰면 안 되겠죠. 이때 transient 키워드를 씁니다.

JAVA
public class Account implements Serializable {
    private String username;
    private transient String password; // 직렬화에서 제외
    private transient int loginCount; // 이것도 제외

    public Account(String username, String password) {
        this.username = username;
        this.password = password;
        this.loginCount = 0;
    }

    @Override
    public String toString() {
        return "Account{username='" + username +
               "', password='" + password +
               "', loginCount=" + loginCount + "}";
    }
}
JAVA
// 직렬화
Account account = new Account("admin", "secret123");
try (ObjectOutputStream oos = new ObjectOutputStream(
        new FileOutputStream("account.ser"))) {
    oos.writeObject(account);
}

// 역직렬화
try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("account.ser"))) {
    Account restored = (Account) ois.readObject();
    System.out.println(restored);
    // Account{username='admin', password='null', loginCount=0}
    // transient 필드는 기본값으로 초기화됨
}

transient가 붙은 필드는 직렬화 과정에서 무시됩니다. 역직렬화하면 해당 필드는 타입의 기본값으로 채워져요.

  • 참조 타입: null
  • int: 0
  • boolean: false

여기서 헷갈리기 쉬운 부분이 있는데요. static 필드도 직렬화되지 않습니다. static은 인스턴스가 아니라 클래스에 속하기 때문이에요. 하지만 statictransient를 붙이는 건 의미가 없습니다. 어차피 직렬화 대상이 아니기 때문이에요.

serialVersionUID

Serializable을 구현하면 IDE에서 이런 경고를 본 적이 있을 거예요.

The serializable class User does not declare a static final serialVersionUID field of type long

serialVersionUID직렬화된 클래스의 버전 번호 입니다. 이걸 왜 명시해야 하는지 예제로 확인해볼게요.

버전 불일치 문제

JAVA
// v1: 처음에 이렇게 만들었다
public class User implements Serializable {
    private String name;
    private int age;
}

이 클래스로 객체를 직렬화해서 파일에 저장했습니다. 그 후에 클래스를 수정했어요.

JAVA
// v2: 필드를 추가했다
public class User implements Serializable {
    private String name;
    private int age;
    private String email; // 새로 추가
}

이 상태에서 v1으로 저장한 파일을 역직렬화하면 어떻게 될까요? InvalidClassException이 발생합니다. JVM이 자동 생성한 serialVersionUID가 클래스 구조에 따라 달라지기 때문이에요.

serialVersionUID 명시하기

JAVA
public class User implements Serializable {
    // 명시적으로 버전 번호 지정
    private static final long serialVersionUID = 1L;

    private String name;
    private int age;
    private String email;
}

이렇게 명시하면 필드를 추가하거나 제거해도 같은 serialVersionUID를 유지할 수 있습니다. 역직렬화할 때 새로 추가된 필드는 기본값으로 채워지고, 삭제된 필드는 무시돼요.

규칙을 정리하면 다음과 같습니다.

  • serialVersionUID가 같으면: 호환 가능한 범위에서 역직렬화 시도
  • serialVersionUID가 다르면: 무조건 InvalidClassException
  • 명시하지 않으면: JVM이 클래스 구조 기반으로 자동 생성 (컴파일러마다 다를 수 있어요!)

주의할 점 — Java 직렬화의 함정

여기까지만 보면 Java 직렬화가 꽤 편해 보이죠? 그런데 실무에서는 거의 쓰지 않습니다. 왜 그럴까요?

1. 보안 취약점

역직렬화는 ** 바이트 스트림에서 객체를 그대로 복원 **합니다. 공격자가 악의적인 바이트 스트림을 만들어서 보내면 임의의 코드가 실행될 수 있어요. 이것을 ** 역직렬화 공격(deserialization attack)**이라고 합니다.

JAVA
// 위험: 신뢰할 수 없는 소스에서 역직렬화
ObjectInputStream ois = new ObjectInputStream(untrustedSource);
Object obj = ois.readObject(); // 무슨 객체가 나올지 모른다!

Apache Commons Collections 라이브러리의 역직렬화 취약점이 유명합니다. 실제로 수많은 Java 애플리케이션이 이 취약점에 영향을 받았어요.

2. 버전 관리의 어려움

serialVersionUID를 관리하는 것은 생각보다 번거롭습니다. 클래스 구조가 크게 바뀌면 하위 호환성을 유지하기 어렵고, 여러 버전의 직렬화된 데이터가 공존하는 상황이 오면 골치가 아파져요.

3. 크기 비효율

Java 직렬화된 바이트 스트림에는 클래스 메타데이터(패키지명, 필드 정보 등)가 포함됩니다. 같은 데이터를 JSON으로 표현하면 훨씬 작아요.

4. 언어 종속성

Java 직렬화 포맷은 Java에서만 읽을 수 있습니다. Python이나 JavaScript 서버와 데이터를 주고받아야 한다면 사용할 수 없어요.

** 그래서 실무에서는 JSON, Protocol Buffers 같은 범용 포맷을 씁니다.** Java 직렬화의 문제점은 보안, 버전 관리, 크기 비효율, 언어 종속성 이 네 가지로 요약돼요.

JSON 직렬화 — Jackson ObjectMapper

JSON은 텍스트 기반이고, 사람이 읽을 수 있으며, 언어에 독립적인 데이터 포맷입니다. Java에서 JSON을 다루는 라이브러리 중 가장 널리 쓰이는 것이 Jackson 이에요.

의존성 추가

XML
<!-- Maven -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.0</version>
</dependency>
GROOVY
// Gradle
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0'

Spring Boot를 쓰고 있다면 spring-boot-starter-web에 이미 포함되어 있으므로 별도로 추가할 필요가 없습니다.

객체 → JSON (직렬화)

JAVA
ObjectMapper mapper = new ObjectMapper();
User user = new User("김철수", 25, "kim@example.com");

// 객체 → JSON 문자열
String json = mapper.writeValueAsString(user);
// {"name":"김철수","age":25,"email":"kim@example.com"}

// 보기 좋게 출력 (들여쓰기)
String prettyJson = mapper.writerWithDefaultPrettyPrinter()
                          .writeValueAsString(user);

ObjectMapper스레드 안전(thread-safe) 하므로 하나만 만들어서 재사용하는 것이 좋습니다. 매번 새로 생성하면 내부적으로 캐싱된 메타데이터를 활용하지 못해서 성능이 떨어져요.

JSON → 객체 (역직렬화)

JAVA
// JSON 문자열 → 객체
String json = "{\"name\":\"김철수\",\"age\":25,\"email\":\"kim@example.com\"}";

User user = mapper.readValue(json, User.class);
System.out.println(user.getName()); // 김철수
System.out.println(user.getAge());  // 25

역직렬화하려면 클래스에 기본 생성자(no-args constructor) 가 있어야 합니다. Jackson이 먼저 빈 객체를 만들고, setter나 필드를 통해 값을 채우기 때문이에요.

JAVA
// Jackson 역직렬화를 위해 기본 생성자 필요
public class User {
    private String name;
    private int age;
    private String email;

    public User() {} // 기본 생성자

    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    // getter/setter 생략
}

리스트 변환

JAVA
// 리스트 → JSON
List<User> users = List.of(
    new User("김철수", 25, "kim@example.com"),
    new User("이영희", 30, "lee@example.com")
);

String jsonArray = mapper.writeValueAsString(users);
// [{"name":"김철수","age":25,...},{"name":"이영희","age":30,...}]

// JSON → 리스트 (TypeReference 사용)
List<User> restored = mapper.readValue(jsonArray,
    new TypeReference<List<User>>() {});

제네릭 타입을 역직렬화할 때는 TypeReference를 써야 합니다. 타입 소거 때문에 List<User>.class라고 쓸 수 없기 때문이에요. 이 부분은 제네릭 편에서 다룬 타입 소거와 연결되는 포인트입니다.

Jackson 어노테이션

Jackson은 어노테이션으로 직렬화/역직렬화 동작을 세밀하게 제어할 수 있습니다.

@JsonProperty — 프로퍼티 이름 변경

JAVA
public class User {
    @JsonProperty("user_name") // JSON에서는 user_name으로 표현
    private String name;

    @JsonProperty("user_age")
    private int age;

    // getter/setter 생략
}
JAVA
User user = new User("김철수", 25);
String json = mapper.writeValueAsString(user);
// {"user_name":"김철수","user_age":25}

Java는 camelCase를 쓰고 JSON API는 snake_case를 쓰는 경우가 많죠. @JsonProperty로 매핑해줄 수 있습니다. 전체 클래스에 적용하고 싶다면 ObjectMapper 설정으로도 가능해요.

JAVA
// 전체적으로 camelCase → snake_case 변환
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);

@JsonIgnore — 필드 제외

JAVA
public class User {
    private String name;
    private int age;

    @JsonIgnore // JSON 직렬화/역직렬화에서 제외
    private String password;

    // getter/setter 생략
}
JAVA
User user = new User("김철수", 25);
user.setPassword("secret");

String json = mapper.writeValueAsString(user);
// {"name":"김철수","age":25}
// password는 포함되지 않는다

Java 직렬화의 transient와 비슷한 역할이에요.

@JsonFormat — 날짜 형식 지정

JAVA
import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;

public class Event {
    private String title;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime startTime;

    // getter/setter 생략
}
JAVA
// JavaTimeModule 등록 필요
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

Event event = new Event("회의", LocalDateTime.of(2026, 3, 19, 14, 30));
String json = mapper.writeValueAsString(event);
// {"title":"회의","startTime":"2026-03-19 14:30:00"}

LocalDateTime 같은 Java 8+ 날짜 타입을 쓰려면 jackson-datatype-jsr310 모듈을 추가하고 JavaTimeModule을 등록해야 합니다.

@JsonInclude — null 필드 제외

JAVA
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User {
    private String name;
    private String email; // null이면 JSON에서 제외

    // getter/setter 생략
}
JAVA
User user = new User("김철수", null);
String json = mapper.writeValueAsString(user);
// {"name":"김철수"}
// email이 null이므로 아예 포함되지 않는다

record와 JSON

Java 16에서 도입된 record는 불변 데이터 객체를 간결하게 정의할 수 있는 문법입니다. Jackson은 record도 잘 지원해요.

JAVA
// record 정의 — getter, equals, hashCode, toString 자동 생성
public record UserDto(
    String name,
    int age,
    String email
) {}
JAVA
ObjectMapper mapper = new ObjectMapper();

// record → JSON
UserDto user = new UserDto("김철수", 25, "kim@example.com");
String json = mapper.writeValueAsString(user);
// {"name":"김철수","age":25,"email":"kim@example.com"}

// JSON → record
UserDto restored = mapper.readValue(json, UserDto.class);
System.out.println(restored.name()); // 김철수

record는 기본 생성자가 없지만, Jackson 2.12+에서는 record의 정식 생성자(canonical constructor)를 자동으로 인식해서 역직렬화할 수 있습니다.

record에 Jackson 어노테이션 적용

JAVA
public record UserDto(
    @JsonProperty("user_name") String name,
    int age,
    @JsonIgnore String internalId
) {}
JAVA
UserDto user = new UserDto("김철수", 25, "INTERNAL-001");
String json = mapper.writeValueAsString(user);
// {"user_name":"김철수","age":25}
// internalId는 제외됨

record는 DTO(Data Transfer Object) 용도로 쓰기에 적합합니다. 불변이고 간결하며, JSON 변환도 매끄럽죠. Spring Boot 3.x 프로젝트에서 요청/응답 DTO를 record로 정의하는 패턴이 점점 많아지고 있어요.

대안들 — Gson, Protocol Buffers

Jackson이 가장 널리 쓰이지만, 상황에 따라 다른 선택지도 있습니다.

Gson

Google에서 만든 JSON 라이브러리입니다. Jackson보다 가볍고 API가 단순해요.

JAVA
import com.google.gson.Gson;

Gson gson = new Gson();

// 객체 → JSON
String json = gson.toJson(user);

// JSON → 객체
User restored = gson.fromJson(json, User.class);

Jackson과 비교하면 이렇습니다.

항목JacksonGson
성능대체로 더 빠름약간 느림
기능어노테이션, 모듈 등 풍부심플하고 가벼움
Spring 연동기본 내장별도 설정 필요
커뮤니티매우 활발활발

Spring을 쓴다면 Jackson이 기본이므로 Jackson을 쓰면 됩니다. Android 개발에서는 Gson을 많이 쓰다가, 최근에는 kotlinx.serialization이나 Moshi로 넘어가는 추세예요.

Protocol Buffers (Protobuf)

Google에서 만든 바이너리 직렬화 포맷 입니다. JSON보다 훨씬 빠르고 크기가 작아요.

PROTOBUF
// user.proto — 스키마 정의 파일
syntax = "proto3";

message User {
    string name = 1;
    int32 age = 2;
    string email = 3;
}
JAVA
// Protobuf 사용 예시
User user = User.newBuilder()
    .setName("김철수")
    .setAge(25)
    .setEmail("kim@example.com")
    .build();

// 직렬화 (바이너리)
byte[] bytes = user.toByteArray();

// 역직렬화
User restored = User.parseFrom(bytes);

Protobuf는 스키마를 .proto 파일에 미리 정의하고, 코드를 자동 생성하는 방식입니다. gRPC와 함께 쓰는 경우가 많아요. JSON보다 복잡하지만, 마이크로서비스 간 통신처럼 성능이 중요한 곳 에서 씁니다.

실전 예제 — REST API에서 JSON 변환

실무에서 가장 흔한 시나리오는 REST API에서 요청/응답을 JSON으로 주고받는 것입니다.

Spring Boot에서의 자동 변환

JAVA
// Spring Boot — @RestController에서는 Jackson이 자동으로 동작
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public UserDto getUser(@PathVariable Long id) {
        // 반환 객체가 자동으로 JSON 변환됨
        return new UserDto("김철수", 25, "kim@example.com");
    }

    @PostMapping
    public UserDto createUser(@RequestBody UserDto request) {
        // 요청 JSON이 자동으로 UserDto로 변환됨
        System.out.println("생성 요청: " + request.name());
        return request;
    }
}
JAVA
// DTO를 record로 정의
public record UserDto(
    String name,
    int age,
    @JsonFormat(pattern = "yyyy-MM-dd")
    LocalDate birthDate
) {}

Spring Boot의 @RestController에서는 Jackson이 자동으로 동작합니다. @ResponseBody가 포함되어 있어서, 반환 객체를 JSON으로 변환해주고, @RequestBody로 들어오는 JSON을 객체로 변환해줘요.

ObjectMapper 직접 사용 (Spring 밖에서)

JAVA
// ObjectMapper 설정 — 보통 싱글턴으로 관리
public class JsonUtil {
    private static final ObjectMapper MAPPER = new ObjectMapper()
        .registerModule(new JavaTimeModule())
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
        .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    public static String toJson(Object obj) { /* writeValueAsString 호출 */ }
    public static <T> T fromJson(String json, Class<T> clazz) { /* readValue 호출 */ }
}

FAIL_ON_UNKNOWN_PROPERTIESfalse로 설정하면 JSON에 클래스에 없는 필드가 있어도 에러가 나지 않습니다. API 버전이 다른 서비스와 통신할 때 유용해요.

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

정리

개념핵심
직렬화객체를 바이트 스트림(또는 문자열)으로 변환. 저장·전송에 필요
Serializable마커 인터페이스. 구현하면 Java 직렬화 가능
transient직렬화에서 제외할 필드에 붙이는 키워드
serialVersionUID클래스 버전 번호. 명시하지 않으면 버전 불일치 위험
Java 직렬화의 한계보안 취약점, 버전 관리, 크기 비효율, 언어 종속
Jackson ObjectMapperwriteValueAsString(직렬화), readValue(역직렬화)
Jackson 어노테이션@JsonProperty, @JsonIgnore, @JsonFormat 등
record + JSONJava 16+ record를 DTO로 활용. Jackson 2.12+에서 지원
Protobuf바이너리 직렬화. JSON보다 빠르고 작지만 복잡

Java 직렬화(Serializable)는 보안과 호환성 문제가 있어서 실무에서는 JSON을 쓰는 것이 일반적이다. ObjectMapper는 스레드 안전하므로 하나만 만들어서 재사용해야 한다.


다음 글에서는 ** 네트워킹 **을 다룹니다. Socket 통신부터 HttpClient까지, Java로 네트워크 프로그래밍하는 법을 알아볼게요.

댓글 로딩 중...