직렬화와 JSON — 객체를 저장하고 전송하기
객체를 만들어서 이것저것 데이터를 담았다. 그런데 이걸 파일에 저장하거나 네트워크로 보내려면? 객체는 메모리에만 존재하는데, 메모리 밖으로 꺼내려면 어떻게 해야 할까? 이 질문에서 출발하면 "직렬화"가 왜 필요한지 자연스럽게 이해된다.
직렬화가 뭔가
**직렬화(Serialization)**란 객체를 바이트 스트림으로 변환하는 것이다. 반대로 바이트 스트림을 다시 객체로 복원하는 것을 **역직렬화(Deserialization)**라고 한다.
왜 필요한지 생각해보자. Java 객체는 힙 메모리에 살아 있다. 프로그램이 종료되면 사라진다. 그런데 다음과 같은 상황이 있다.
- 객체를 파일에 저장해서 나중에 다시 쓰고 싶다
- 객체를 네트워크로 전송해서 다른 서버에서 쓰고 싶다
- 객체를 **캐시(Redis 등)**에 넣어두고 싶다
이럴 때 객체를 바이트 배열이나 문자열 같은 "전송 가능한 형태"로 바꿔야 한다. 이것이 직렬화다.
[Java 객체] --직렬화--> [바이트 스트림] --저장/전송--> [파일/네트워크/캐시]
|
[Java 객체] <--역직렬화-- [바이트 스트림] <--읽기----------+
Serializable 인터페이스
Java에서 직렬화를 가장 기본적으로 지원하는 방법은 Serializable 인터페이스를 구현하는 것이다.
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으로 직렬화하기
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으로 역직렬화하기
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 키워드를 쓴다.
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 + "}";
}
}
// 직렬화
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:0boolean:false
공부하다 보니 여기서 헷갈렸던 점이 있다. static 필드도 직렬화되지 않는다. static은 인스턴스가 아니라 클래스에 속하기 때문이다. 하지만 static에 transient를 붙이는 건 의미가 없다. 어차피 직렬화 대상이 아니기 때문이다.
serialVersionUID
Serializable을 구현하면 IDE에서 이런 경고를 본 적이 있을 것이다.
The serializable class User does not declare a static final serialVersionUID field of type long
serialVersionUID는 직렬화된 클래스의 버전 번호다. 이걸 왜 명시해야 하는지 예제로 확인해보자.
버전 불일치 문제
// v1: 처음에 이렇게 만들었다
public class User implements Serializable {
private String name;
private int age;
}
이 클래스로 객체를 직렬화해서 파일에 저장했다. 그 후에 클래스를 수정했다.
// v2: 필드를 추가했다
public class User implements Serializable {
private String name;
private int age;
private String email; // 새로 추가
}
이 상태에서 v1으로 저장한 파일을 역직렬화하면? InvalidClassException이 발생한다. JVM이 자동 생성한 serialVersionUID가 클래스 구조에 따라 달라지기 때문이다.
serialVersionUID 명시하기
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)**이라고 한다.
// 위험: 신뢰할 수 없는 소스에서 역직렬화
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이다.
의존성 추가
<!-- Maven -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
// Gradle
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.0'
Spring Boot를 쓰고 있다면 spring-boot-starter-web에 이미 포함되어 있으므로 별도로 추가할 필요가 없다.
객체 → JSON (직렬화)
import com.fasterxml.jackson.databind.ObjectMapper;
public class JacksonExample {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
// 객체 생성
User user = new User("김철수", 25, "kim@example.com");
// 객체 → JSON 문자열
String json = mapper.writeValueAsString(user);
System.out.println(json);
// {"name":"김철수","age":25,"email":"kim@example.com"}
// 보기 좋게 출력 (들여쓰기)
String prettyJson = mapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(user);
System.out.println(prettyJson);
// {
// "name" : "김철수",
// "age" : 25,
// "email" : "kim@example.com"
// }
}
}
ObjectMapper는 **스레드 안전(thread-safe)**하므로 하나만 만들어서 재사용하는 것이 좋다. 매번 새로 생성하면 내부적으로 캐싱된 메타데이터를 활용하지 못해서 성능이 떨어진다.
JSON → 객체 (역직렬화)
// 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나 필드를 통해 값을 채우기 때문이다.
// 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 생략
}
리스트 변환
// 리스트 → 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 — 프로퍼티 이름 변경
public class User {
@JsonProperty("user_name") // JSON에서는 user_name으로 표현
private String name;
@JsonProperty("user_age")
private int age;
// getter/setter 생략
}
User user = new User("김철수", 25);
String json = mapper.writeValueAsString(user);
// {"user_name":"김철수","user_age":25}
Java는 camelCase를 쓰고 JSON API는 snake_case를 쓰는 경우가 많다. @JsonProperty로 매핑해줄 수 있다. 전체 클래스에 적용하고 싶다면 ObjectMapper 설정으로도 가능하다.
// 전체적으로 camelCase → snake_case 변환
mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
@JsonIgnore — 필드 제외
public class User {
private String name;
private int age;
@JsonIgnore // JSON 직렬화/역직렬화에서 제외
private String password;
// getter/setter 생략
}
User user = new User("김철수", 25);
user.setPassword("secret");
String json = mapper.writeValueAsString(user);
// {"name":"김철수","age":25}
// password는 포함되지 않는다
Java 직렬화의 transient와 비슷한 역할이다.
@JsonFormat — 날짜 형식 지정
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 생략
}
// 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 필드 제외
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User {
private String name;
private String email; // null이면 JSON에서 제외
// getter/setter 생략
}
User user = new User("김철수", null);
String json = mapper.writeValueAsString(user);
// {"name":"김철수"}
// email이 null이므로 아예 포함되지 않는다
record와 JSON
Java 16에서 도입된 record는 불변 데이터 객체를 간결하게 정의할 수 있는 문법이다. Jackson은 record도 잘 지원한다.
// record 정의 — getter, equals, hashCode, toString 자동 생성
public record UserDto(
String name,
int age,
String email
) {}
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 어노테이션 적용
public record UserDto(
@JsonProperty("user_name") String name,
int age,
@JsonIgnore String internalId
) {}
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가 단순하다.
import com.google.gson.Gson;
Gson gson = new Gson();
// 객체 → JSON
String json = gson.toJson(user);
// JSON → 객체
User restored = gson.fromJson(json, User.class);
Jackson과 비교하면 이렇다.
| 항목 | Jackson | Gson |
|---|---|---|
| 성능 | 대체로 더 빠름 | 약간 느림 |
| 기능 | 어노테이션, 모듈 등 풍부 | 심플하고 가벼움 |
| Spring 연동 | 기본 내장 | 별도 설정 필요 |
| 커뮤니티 | 매우 활발 | 활발 |
Spring을 쓴다면 Jackson이 기본이므로 Jackson을 쓰면 된다. Android 개발에서는 Gson을 많이 쓰다가, 최근에는 kotlinx.serialization이나 Moshi로 넘어가는 추세다.
Protocol Buffers (Protobuf)
Google에서 만든 바이너리 직렬화 포맷이다. JSON보다 훨씬 빠르고 크기가 작다.
// user.proto — 스키마 정의 파일
syntax = "proto3";
message User {
string name = 1;
int32 age = 2;
string email = 3;
}
// 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에서의 자동 변환
// 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;
}
}
// 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 밖에서)
// 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);
// 객체 → JSON
public static String toJson(Object obj) {
try {
return MAPPER.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 직렬화 실패", e);
}
}
// JSON → 객체
public static <T> T fromJson(String json, Class<T> clazz) {
try {
return MAPPER.readValue(json, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 역직렬화 실패", e);
}
}
}
FAIL_ON_UNKNOWN_PROPERTIES를 false로 설정하면 JSON에 클래스에 없는 필드가 있어도 에러가 나지 않는다. API 버전이 다른 서비스와 통신할 때 유용하다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
이번 글에서 다룬 내용을 정리하면 다음과 같다.
| 개념 | 핵심 |
|---|---|
| 직렬화 | 객체를 바이트 스트림(또는 문자열)으로 변환. 저장·전송에 필요 |
| Serializable | 마커 인터페이스. 구현하면 Java 직렬화 가능 |
| transient | 직렬화에서 제외할 필드에 붙이는 키워드 |
| serialVersionUID | 클래스 버전 번호. 명시하지 않으면 버전 불일치 위험 |
| Java 직렬화의 한계 | 보안 취약점, 버전 관리, 크기 비효율, 언어 종속 |
| Jackson ObjectMapper | writeValueAsString(직렬화), readValue(역직렬화) |
| Jackson 어노테이션 | @JsonProperty, @JsonIgnore, @JsonFormat 등 |
| record + JSON | Java 16+ record를 DTO로 활용. Jackson 2.12+에서 지원 |
| Protobuf | 바이너리 직렬화. JSON보다 빠르고 작지만 복잡 |
기억해야 할 포인트 몇 가지를 남기자면 다음과 같다.
- Java 직렬화(
Serializable)는 보안과 호환성 문제가 있어서 실무에서는 JSON을 쓰는 것이 일반적이다 ObjectMapper는 스레드 안전하므로 하나만 만들어서 재사용하자- record는 불변 DTO로 쓰기에 적합하고, Jackson과의 궁합도 좋다
- 면접에서 "Java 직렬화의 문제점"을 물어보면 보안, 버전 관리, 크기, 언어 종속성 네 가지를 짚어주면 된다
다음 글에서는 네트워킹을 다룬다. Socket 통신부터 HttpClient까지, Java로 네트워크 프로그래밍하는 법을 알아본다.