| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 가비지컬렉션
- 코딩테스트
- 자바개발
- 프로그래머스
- 자바
- 메모리관리
- 개발자팁
- 예외처리
- 클린코드
- 알고리즘
- 백준
- 파이썬
- 자료구조
- 프로그래밍기초
- JVM
- 자바공부
- 개발공부
- 정렬
- 멀티스레드
- 알고리즘공부
- 객체지향
- 코딩공부
- 자바기초
- HashMap
- 자바프로그래밍
- 개발자취업
- Java
- 코딩테스트팁
- 코딩테스트준비
- 코딩인터뷰
- Today
- Total
코드 한 줄의 기록
Java 직렬화 완벽 가이드: 직렬화, transient 키워드, 역직렬화 보안까지 한 번에 이해하기 본문
개발을 하다 보면 객체의 상태를 파일에 저장해야 하거나, 네트워크를 통해 다른 시스템으로 객체를 전달해야 할 때가 있습니다. Java에서 이런 작업을 가능하게 해주는 것이 바로 직렬화(Serialization)입니다. 하지만 직렬화는 단순히 데이터를 변환하는 것 이상으로, 보안과 성능, 버전 관리 등 많은 고려사항을 담고 있습니다.
이 글에서는 Java 직렬화의 기본 개념부터 시작해서, transient 키워드의 올바른 사용법, 그리고 역직렬화 과정에서 발생할 수 있는 보안 취약점까지 실무에서 필요한 모든 것을 다루겠습니다. 직렬화를 제대로 이해하고 안전하게 사용하기 위해 함께 공부해봅시다.
Java 직렬화의 기본 개념 이해하기
직렬화(Serialization)란 무엇인가?
프로그램을 실행하면서 생성한 객체는 메모리의 힙(Heap) 영역에 존재합니다. 프로그램이 종료되면 이 객체들은 모두 사라지게 되죠. 만약 이 객체의 상태를 다음 번 프로그램 실행 시에도 유지하고 싶다면 어떻게 해야 할까요?
Java 직렬화는 메모리에 존재하는 객체를 바이트 스트림(byte stream) 형태로 변환하는 과정입니다. 이렇게 변환된 바이트 스트림은 파일에 저장하거나, 네트워크를 통해 다른 시스템으로 전송할 수 있습니다. 그리고 나중에 이 바이트 스트림을 다시 원래의 객체 형태로 복원하는 과정을 역직렬화(Deserialization)라고 합니다.
직렬화를 통해 다음과 같은 것들이 가능해집니다.
- 객체 영속성: 프로그램 실행 중 만든 객체의 상태를 파일에 저장했다가 나중에 복원할 수 있습니다.
- 원격 통신: 객체를 네트워크를 통해 다른 시스템으로 전송할 수 있어서, 분산 시스템 간의 데이터 교환이 가능합니다.
- 데이터 캐싱: 자주 사용되는 객체를 직렬화해서 캐시에 저장했다가 빠르게 복원하여 성능을 향상시킬 수 있습니다.
Serializable 인터페이스와 직렬화 조건
Java에서 어떤 클래스의 객체를 직렬화하려면 반드시 Serializable 인터페이스를 구현해야 합니다. 흥미로운 점은 이 인터페이스가 아무 메서드도 없다는 것입니다. 이는 마커 인터페이스(Marker Interface)라고 불리며, "이 클래스는 직렬화 가능하다"는 표시 역할만 합니다.
public interface Serializable {
// 메서드가 없는 마커 인터페이스
}
Serializable을 구현하지 않은 객체를 직렬화하려고 하면 NotSerializableException이 발생합니다. 실제로 직렬화 가능한 클래스는 어떤 형태일까요? 간단한 예제를 보겠습니다.
import java.io.Serializable;
class Student implements Serializable {
private String name;
private int studentId;
private String major;
public Student(String name, int studentId, String major) {
this.name = name;
this.studentId = studentId;
this.major = major;
}
@Override
public String toString() {
return String.format("Student{name='%s', id=%d, major='%s'}",
name, studentId, major);
}
}
이 Student 클래스는 이제 직렬화가 가능합니다. ObjectOutputStream을 사용해서 객체를 파일에 저장할 수 있고, ObjectInputStream을 사용해서 파일로부터 객체를 복원할 수 있습니다.
import java.io.*;
public class SerializationExample {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 객체 직렬화 (저장)
Student student = new Student("김철수", 20201234, "컴퓨터공학");
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("student.ser"))) {
oos.writeObject(student);
System.out.println("직렬화 완료: " + student);
}
// 역직렬화 (복원)
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("student.ser"))) {
Student restoredStudent = (Student) ois.readObject();
System.out.println("역직렬화 완료: " + restoredStudent);
}
}
}
serialVersionUID: 클래스 버전 관리의 핵심
직렬화 가능한 클래스를 보면 종종 다음과 같은 코드를 만날 수 있습니다.
private static final long serialVersionUID = 1L;
이 serialVersionUID는 무엇일까요? 이것은 직렬화된 객체의 버전을 식별하는 고유한 ID입니다.
상황을 상상해봅시다. A라는 서버에서 Student 클래스의 객체를 직렬화해서 저장했습니다. 시간이 지나 Student 클래스에 새로운 필드인 phoneNumber를 추가했습니다. 이제 저장된 파일을 역직렬화하려고 하면 어떻게 될까요?
클래스의 구조가 변했는데 저장된 객체의 구조는 예전 상태이기 때문에, 이를 감지하기 위해 Java는 serialVersionUID를 비교합니다. 두 값이 다르면 InvalidClassException이 발생하거나, 버전 관리 방식에 따라 호환성을 유지할 수 있습니다.
class Student implements Serializable {
private static final long serialVersionUID = 1L; // 명시적 선언
private String name;
private int studentId;
private String major;
// 이후 필드 추가 시 serialVersionUID는 유지하면서
// readObject/writeObject로 호환성 처리
}
serialVersionUID를 명시적으로 선언하는 것이 중요한 이유는
- 호환성 관리: 클래스 구조가 변경되어도 같은 ID를 유지하면 이전 버전 객체도 역직렬화할 수 있습니다.
- 의도적 버전 관리: 개발자가 언제 버전을 변경할지 직접 제어할 수 있습니다.
- 컴파일 안정성: IDE가 serialVersionUID 선언을 강력히 권장하는 이유도 여기에 있습니다.
만약 serialVersionUID를 선언하지 않으면 Java 컴파일러가 자동으로 생성하는데, 클래스의 구조가 조금이라도 바뀌면 이 값도 함께 변하게 되어 호환성 문제가 발생할 수 있습니다.
transient 키워드로 민감한 정보 보호하기
transient 키워드의 역할
많은 경우 객체의 모든 필드를 직렬화해야 하는 것은 아닙니다. 예를 들어 비밀번호나 API 토큰, 또는 데이터베이스 연결 정보 같은 민감한 정보는 절대 직렬화되면 안 됩니다.
transient 키워드는 "이 필드는 직렬화하지 마"라고 Java에게 지시합니다. transient로 선언된 필드는 직렬화 과정에서 완전히 건너뛰어지며, 역직렬화될 때는 해당 필드 타입의 기본값(null, 0, false 등)으로 초기화됩니다.
다음 예제를 보겠습니다.
import java.io.Serializable;
class User implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private String email;
private transient String password; // 직렬화에서 제외!
private transient String apiToken; // 직렬화에서 제외!
public User(String username, String email, String password, String apiToken) {
this.username = username;
this.email = email;
this.password = password;
this.apiToken = apiToken;
}
@Override
public String toString() {
return String.format("User{username='%s', email='%s', password='%s', apiToken='%s'}",
username, email, password, apiToken);
}
}
이제 이 User 객체를 직렬화해보겠습니다.
import java.io.*;
public class TransientExample {
public static void main(String[] args) throws IOException, ClassNotFoundException {
User user = new User("john_doe", "john@example.com", "securePassword123", "abc-def-ghi-jkl");
System.out.println("직렬화 전:");
System.out.println(user);
// 직렬화
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("user.ser"))) {
oos.writeObject(user);
}
// 역직렬화
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("user.ser"))) {
User restoredUser = (User) ois.readObject();
System.out.println("\n역직렬화 후:");
System.out.println(restoredUser);
// 결과 비교
System.out.println("\n비교:");
System.out.println("username 동일: " + user.username.equals(restoredUser.username));
System.out.println("password 동일: " + (user.password != null &&
user.password.equals(restoredUser.password)));
System.out.println("apiToken 동일: " + (user.apiToken != null &&
user.apiToken.equals(restoredUser.apiToken)));
}
}
}
실행 결과
직렬화 전:
User{username='john_doe', email='john@example.com', password='securePassword123', apiToken='abc-def-ghi-jkl'}
역직렬화 후:
User{username='john_doe', email='john@example.com', password='null', apiToken='null'}
비교:
username 동일: true
password 동일: false
apiToken 동일: false
보시다시피 password와 apiToken은 null로 복원되었습니다. 이것이 transient의 핵심입니다.
transient를 사용하는 실제 사례들
민감한 정보 보호
예를 들어 결제 시스템에서 신용카드 정보를 다루는 Payment 클래스가 있다면
class Payment implements Serializable {
private static final long serialVersionUID = 1L;
private String orderId;
private double amount;
private transient String creditCardNumber; // 직렬화 금지
private transient String cvv; // 직렬화 금지
private String paymentMethod;
// ... 생성자와 메서드 생략
}
신용카드 번호와 CVV는 절대 파일에 저장되거나 네트워크를 통해 전송되면 안 되기 때문에 transient로 선언합니다.
계산 가능한 필드 제외
어떤 필드의 값이 다른 필드들로부터 계산될 수 있다면, 그 필드를 직렬화할 필요가 없습니다.
class Rectangle implements Serializable {
private static final long serialVersionUID = 1L;
private double width;
private double height;
private transient double area; // width * height로 계산 가능
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
this.area = width * height;
}
// 역직렬화 후 area를 다시 계산하려면 readObject 메서드 필요
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject();
this.area = width * height; // 역직렬화 후 다시 계산
}
}
데이터베이스 연결 객체 제외
프로그램에서 데이터베이스 연결을 유지하는 클래스를 생각해봅시다.
import java.sql.Connection;
import java.io.Serializable;
class UserDAO implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private transient Connection dbConnection; // DB 연결은 직렬화 금지
public UserDAO(String userId, Connection connection) {
this.userId = userId;
this.dbConnection = connection;
}
// 역직렬화 후 새로운 DB 연결 설정
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject();
// 역직렬화 후 새로운 DB 연결 생성
this.dbConnection = createNewConnection();
}
private Connection createNewConnection() {
// DB 연결 로직
return null; // 실제 구현 필요
}
}
transient 사용 시 주의사항
transient 키워드를 사용할 때 반드시 알아야 할 사항들이 있습니다.
transient와 static의 조합
class Example implements Serializable {
private static final long serialVersionUID = 1L;
private static transient String staticField; // transient는 효과 없음!
}
static 필드는 객체의 인스턴스와 무관한 클래스 변수이기 때문에 직렬화 대상이 아닙니다. 따라서 transient 키워드를 붙여도 아무 의미가 없습니다.
transient와 final의 조합
class Example implements Serializable {
private static final long serialVersionUID = 1L;
private final transient String finalField; // transient는 거의 효과 없음
}
final 필드의 경우, 객체 생성 시점에 초기화되기 때문에 transient의 효과가 제한적입니다.
역직렬화 후 필드 복원
transient 필드는 역직렬화 후 기본값으로 초기화되므로, 필요한 경우 readObject 메서드에서 별도로 복원해야 합니다.
class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private String sessionId;
private transient long lastAccessTime;
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject();
// 역직렬화 후 현재 시간으로 초기화
this.lastAccessTime = System.currentTimeMillis();
}
}
writeObject와 readObject를 이용한 커스텀 직렬화
기본 직렬화의 한계
지금까지 우리가 봐온 직렬화는 모두 기본 직렬화 방식이었습니다. 하지만 실제 개발에서는 기본 직렬화만으로는 충분하지 않은 경우가 많습니다:
- 특정 필드만 선택적으로 직렬화하고 싶을 때
- 직렬화 과정에서 데이터를 암호화하고 싶을 때
- 역직렬화 후 필드값을 검증하고 싶을 때
- 클래스의 구조가 변경되었을 때 호환성을 유지하고 싶을 때
이런 경우들을 위해 Java는 writeObject와 readObject라는 특별한 메서드를 제공합니다. 이 메서드들을 재정의해서 직렬화 과정을 완전히 제어할 수 있습니다.
writeObject와 readObject 메서드
import java.io.*;
class Account implements Serializable {
private static final long serialVersionUID = 1L;
private String accountNumber;
private double balance;
private transient String pinCode; // 직렬화 제외 필드
public Account(String accountNumber, double balance, String pinCode) {
this.accountNumber = accountNumber;
this.balance = balance;
this.pinCode = pinCode;
}
// 직렬화 과정을 커스터마이즈
private void writeObject(ObjectOutputStream oos) throws IOException {
// 1단계: 기본 직렬화 동작 수행 (transient 필드 제외)
oos.defaultWriteObject();
// 2단계: 추가 커스텀 직렬화
// PIN 코드를 직렬화 하지 않는 대신, 특정 값만 저장
oos.writeBoolean(pinCode != null && !pinCode.isEmpty());
System.out.println("직렬화 중: " + accountNumber);
}
// 역직렬화 과정을 커스터마이즈
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
// 1단계: 기본 역직렬화 동작 수행
ois.defaultReadObject();
// 2단계: 추가 커스텀 역직렬화
boolean hasPinCode = ois.readBoolean();
// 3단계: transient 필드 초기화
if (hasPinCode) {
// 실제로는 외부 시스템에서 PIN을 가져오거나 기본값 설정
this.pinCode = "000000";
} else {
this.pinCode = null;
}
System.out.println("역직렬화 중: " + accountNumber);
}
@Override
public String toString() {
return String.format("Account{number='%s', balance=%.2f, pinCode='%s'}",
accountNumber, balance, pinCode);
}
}
이 Account 클래스를 사용해보면
public class CustomSerializationExample {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Account account = new Account("123-456-789", 50000.0, "1234");
System.out.println("원본 객체: " + account);
// 직렬화
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{number='123-456-789', balance=50000.00, pinCode='1234'}
직렬화 중: 123-456-789
역직렬화 중: 123-456-789
복원된 객체: Account{number='123-456-789', balance=50000.00, pinCode='000000'}
데이터 검증을 포함한 커스텀 직렬화
이제 더 실용적인 예제를 보겠습니다. 역직렬화 과정에서 데이터의 무결성을 검증하는 방식입니다.
class BankAccount implements Serializable {
private static final long serialVersionUID = 2L;
private String accountHolder;
private double balance;
public BankAccount(String accountHolder, double balance) {
this.accountHolder = accountHolder;
setBalance(balance); // 유효성 검사를 통한 설정
}
private void setBalance(double balance) {
if (balance < 0) {
throw new IllegalArgumentException("잔액은 음수가 될 수 없습니다!");
}
this.balance = balance;
}
// 직렬화 (암호화 예시)
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
// 실제 구현에서는 balance를 암호화해서 저장할 수도 있음
System.out.println("[직렬화] 계정: " + accountHolder + ", 잔액: " + balance);
}
// 역직렬화 (유효성 검증)
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject();
// 역직렬화된 데이터가 유효한지 검증
if (balance < 0) {
throw new InvalidObjectException("잘못된 계좌 정보: 음수 잔액!");
}
if (accountHolder == null || accountHolder.trim().isEmpty()) {
throw new InvalidObjectException("잘못된 계좌 정보: 계좌 소유자 없음!");
}
System.out.println("[역직렬화] 계정: " + accountHolder + ", 잔액: " + balance);
}
@Override
public String toString() {
return String.format("BankAccount{holder='%s', balance=%.2f}",
accountHolder, balance);
}
}
역직렬화 보안 취약점과 대응 방안
역직렬화 취약점의 심각성
Java 직렬화의 가장 큰 문제점은 보안입니다. 역직렬화는 단순히 데이터를 객체로 변환하는 것이 아니라, 사실상 "숨겨진 생성자"라고 할 수 있습니다.
일반적으로 객체를 생성할 때 생성자를 통해 입력 검증을 수행합니다. 하지만 역직렬화 과정에서는 생성자를 거치지 않기 때문에, 유효하지 않은 상태의 객체가 만들어질 수 있습니다.
더 심각한 문제는 원격 코드 실행(RCE)입니다. 악의적인 공격자가 특별히 조작된 직렬화 데이터를 만들어서 서버에 보내면, 역직렬화 과정에서 임의의 코드가 실행될 수 있다는 뜻입니다.
역직렬화 취약점 예제
다음은 역직렬화 과정에서 발생할 수 있는 문제를 보여주는 예제입니다:
class InsecureAccount implements Serializable {
private static final long serialVersionUID = 1L;
private String owner;
private double balance;
public InsecureAccount(String owner, double balance) {
if (balance < 0) {
throw new IllegalArgumentException("잔액은 음수가 될 수 없습니다!");
}
this.owner = owner;
this.balance = balance;
}
@Override
public String toString() {
return String.format("Account{owner='%s', balance=%.2f}", owner, balance);
}
}
이제 악의적인 사용자가 이 클래스의 직렬화 데이터를 조작해서 음수 잔액을 가진 객체를 만들 수 있습니다:
import java.io.*;
public class DeserializationVulnerability {
public static void main(String[] args) throws IOException, ClassNotFoundException {
// 정상적인 객체 생성
InsecureAccount account = new InsecureAccount("John Doe", 10000.0);
System.out.println("정상 생성: " + account);
// 직렬화
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(account);
}
byte[] serialized = baos.toByteArray();
// 역직렬화 (정상)
try (ObjectInputStream ois = new ObjectInputStream(
new ByteArrayInputStream(serialized))) {
InsecureAccount restored = (InsecureAccount) ois.readObject();
System.out.println("역직렬화: " + restored);
}
// 여기서는 설명만 하겠습니다.
// 실제로 직렬화된 바이트를 조작해서 balance를 음수로 만들 수 있다면?
// 역직렬화 과정에서 생성자를 거치지 않기 때문에 음수 잔액이 그대로 통과됩니다!
}
}
역직렬화 보안 대응 방안
readObject 메서드를 통한 검증
가장 기본적인 방어 방법은 readObject 메서드에서 역직렬화된 데이터를 검증하는 것입니다:
class SecureAccount implements Serializable {
private static final long serialVersionUID = 1L;
private String owner;
private double balance;
public SecureAccount(String owner, double balance) {
if (balance < 0) {
throw new IllegalArgumentException("잔액은 음수가 될 수 없습니다!");
}
this.owner = owner;
this.balance = balance;
}
// 핵심: readObject에서 검증!
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject();
// 역직렬화된 데이터 검증
if (balance < 0) {
throw new InvalidObjectException("잔액이 음수입니다!");
}
if (owner == null || owner.trim().isEmpty()) {
throw new InvalidObjectException("계좌 소유자가 없습니다!");
}
}
@Override
public String toString() {
return String.format("Account{owner='%s', balance=%.2f}", owner, balance);
}
}
신뢰할 수 있는 소스에서만 역직렬화
네트워크를 통해 받은 데이터는 항상 신뢰할 수 없다고 가정해야 합니다. 신뢰할 수 있는 출처에서만 데이터를 역직렬화해야 합니다:
public class SafeDeserialization {
public static Object deserializeIfTrusted(byte[] data, String trustedSource)
throws IOException, ClassNotFoundException {
// 데이터의 출처를 확인
if (!isFromTrustedSource(trustedSource)) {
throw new SecurityException("신뢰할 수 없는 출처에서 받은 데이터입니다!");
}
// 출처가 신뢰할 수 있을 때만 역직렬화
try (ObjectInputStream ois = new ObjectInputStream(
new ByteArrayInputStream(data))) {
return ois.readObject();
}
}
private static boolean isFromTrustedSource(String source) {
// 신뢰할 수 있는 소스 목록
return "INTERNAL_DB".equals(source) || "BACKUP_SERVER".equals(source);
}
}
객체 입력 필터링 (Java 9+)
Java 9 이상에서는 ObjectInputFilter를 사용해서 역직렬화될 수 있는 클래스를 제한할 수 있습니다:
import java.io.*;
public class FilteredDeserialization {
public static void setupDeserializationFilter() {
// 화이트리스트 방식의 필터 설정
ObjectInputFilter filter = info -> {
// 허용할 클래스들을 명시적으로 지정
String className = info.getClassName();
if (className.equals("java.lang.String") ||
className.equals("com.example.SecureAccount") ||
className.equals("java.util.HashMap")) {
return ObjectInputFilter.Status.ALLOWED;
}
// 허용되지 않는 클래스
return ObjectInputFilter.Status.REJECTED;
};
ObjectInputFilter.setSerialFilter(filter);
}
public static Object safeDeserialize(byte[] data)
throws IOException, ClassNotFoundException {
try (ObjectInputStream ois = new ObjectInputStream(
new ByteArrayInputStream(data))) {
return ois.readObject();
}
}
}
직렬화 데이터의 서명 및 검증
더 강력한 보안을 원한다면 직렬화된 데이터에 디지털 서명을 추가해서, 데이터가 조작되지 않았음을 보증할 수 있습니다:
import java.io.*;
import java.security.*;
public class SignedSerialization {
// 서명과 함께 객체를 직렬화
public static byte[] signAndSerialize(Serializable obj, PrivateKey privateKey)
throws Exception {
// 객체 직렬화
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(obj);
}
byte[] serialized = baos.toByteArray();
// 서명 생성
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(serialized);
byte[] signature = sig.sign();
// 직렬화된 데이터와 서명을 함께 반환
ByteArrayOutputStream result = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(result)) {
oos.writeInt(serialized.length);
oos.write(serialized);
oos.writeInt(signature.length);
oos.write(signature);
}
return result.toByteArray();
}
// 서명을 검증하고 역직렬화
public static Object verifyAndDeserialize(byte[] signedData, PublicKey publicKey)
throws Exception {
ByteArrayInputStream bais = new ByteArrayInputStream(signedData);
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
// 직렬화된 데이터와 서명 읽기
int dataLength = ois.readInt();
byte[] serialized = new byte[dataLength];
ois.readFully(serialized);
int sigLength = ois.readInt();
byte[] signature = new byte[sigLength];
ois.readFully(signature);
// 서명 검증
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(publicKey);
sig.update(serialized);
if (!sig.verify(signature)) {
throw new SecurityException("데이터가 조작되었습니다!");
}
// 검증 완료 후 역직렬화
try (ObjectInputStream dataOis = new ObjectInputStream(
new ByteArrayInputStream(serialized))) {
return dataOis.readObject();
}
}
}
}
JSON이나 XML 같은 안전한 형식 사용
가장 좋은 방법은 Java 직렬화 자체를 피하는 것입니다. JSON이나 XML 같은 오픈 포맷을 사용하면 언어 간 호환성도 좋고 보안도 훨씬 낫습니다:
import com.fasterxml.jackson.databind.ObjectMapper;
class Account {
public String owner;
public double balance;
public Account(String owner, double balance) {
if (balance < 0) {
throw new IllegalArgumentException("잔액은 음수가 될 수 없습니다!");
}
this.owner = owner;
this.balance = balance;
}
}
public class JsonSerialization {
private static final ObjectMapper mapper = new ObjectMapper();
public static void main(String[] args) throws Exception {
Account account = new Account("John Doe", 10000.0);
// JSON으로 직렬화
String json = mapper.writeValueAsString(account);
System.out.println("JSON: " + json);
// JSON에서 역직렬화
Account restored = mapper.readValue(json, Account.class);
System.out.println("복원: " + restored.owner + ", " + restored.balance);
}
}
실제 프로젝트에서의 직렬화 활용 사례
캐싱 시스템에서의 직렬화
import java.io.*;
import java.util.*;
class CachedUser implements Serializable {
private static final long serialVersionUID = 1L;
private int userId;
private String username;
private String email;
private long cacheTime;
public CachedUser(int userId, String username, String email) {
this.userId = userId;
this.username = username;
this.email = email;
this.cacheTime = System.currentTimeMillis();
}
public boolean isExpired(long maxAge) {
return System.currentTimeMillis() - cacheTime > maxAge;
}
}
class UserCache {
private static final String CACHE_DIR = "./cache/";
private static final long CACHE_MAX_AGE = 3600000; // 1시간
public void cacheUser(CachedUser user) throws IOException {
File dir = new File(CACHE_DIR);
if (!dir.exists()) {
dir.mkdirs();
}
String filename = CACHE_DIR + "user_" + user.userId + ".cache";
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream(filename))) {
oos.writeObject(user);
}
}
public CachedUser getCachedUser(int userId) throws IOException, ClassNotFoundException {
String filename = CACHE_DIR + "user_" + userId + ".cache";
File file = new File(filename);
if (!file.exists()) {
return null;
}
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream(filename))) {
CachedUser user = (CachedUser) ois.readObject();
if (user.isExpired(CACHE_MAX_AGE)) {
file.delete();
return null;
}
return user;
}
}
}
분산 시스템에서의 객체 전송
import java.io.*;
import java.net.*;
class NetworkMessage implements Serializable {
private static final long serialVersionUID = 1L;
private String messageId;
private String content;
private long timestamp;
public NetworkMessage(String messageId, String content) {
this.messageId = messageId;
this.content = content;
this.timestamp = System.currentTimeMillis();
}
}
class MessageSender {
public void sendMessage(String host, int port, NetworkMessage message)
throws IOException {
try (Socket socket = new Socket(host, port);
ObjectOutputStream oos = new ObjectOutputStream(socket.getOutputStream())) {
oos.writeObject(message);
oos.flush();
}
}
}
class MessageReceiver {
public void receiveMessage(int port) throws IOException, ClassNotFoundException {
try (ServerSocket serverSocket = new ServerSocket(port)) {
Socket socket = serverSocket.accept();
try (ObjectInputStream ois = new ObjectInputStream(socket.getInputStream())) {
NetworkMessage message = (NetworkMessage) ois.readObject();
System.out.println("수신: " + message.messageId + " - " + message.content);
}
}
}
}
Java 직렬화를 올바르게 사용하기 위한 체크리스트
Java 직렬화는 강력한 기능이지만, 제대로 사용하지 않으면 보안 문제와 성능 문제를 야기할 수 있습니다. 프로젝트에서 직렬화를 사용할 때는 다음 체크리스트를 참고하세요.
- Serializable 인터페이스는 신중하게: 직렬화가 정말 필요한지 먼저 확인하세요. JSON이나 XML 같은 대안이 더 나을 수도 있습니다.
- serialVersionUID는 명시적으로: 클래스에 serialVersionUID를 명시적으로 선언하고, 버전 관리 전략을 수립하세요.
- 민감한 정보는 transient로: 비밀번호, API 토큰, 개인정보 등은 반드시 transient로 선언하세요.
- 역직렬화 시에는 검증: readObject 메서드에서 데이터의 유효성을 철저히 검증하세요.
- 신뢰할 수 없는 소스는 역직렬화하지 말기: 네트워크에서 받은 데이터나 사용자 입력은 항상 의심하세요.
- Java 9 이상이면 ObjectInputFilter 사용: 역직렬화될 수 있는 클래스를 명시적으로 제한하세요.
- 보안 감사 진행: 프로젝트의 직렬화 코드를 정기적으로 보안 감시하세요.
Java 직렬화를 이해하고 올바르게 사용하면, 안전하고 효율적인 데이터 전달 시스템을 구축할 수 있습니다. 이 글이 여러분의 개발에 도움이 되길 바랍니다!
Java 파일 유틸리티 개발 시 성능, 자원, 예외처리를 모두 고려하는 방법
개발을 하다 보면 파일을 다루는 상황이 생각보다 자주 발생합니다. 단순해 보이는 파일 읽고 쓰기 작업이 실제 운영 환경에서는 성능 병목이 되거나, 예상치 못한 예외로 인해 서비스가 중단되
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| Java Synchronized·Volatile·원자성 완벽 이해하기 (0) | 2025.11.18 |
|---|---|
| Java 스레드 라이프사이클과 Runnable/Thread 완벽 이해하기 (0) | 2025.11.16 |
| Java 파일 유틸리티 개발 시 성능, 자원, 예외처리를 모두 고려하는 방법 (0) | 2025.11.10 |
| Java I/O 완벽 가이드: 바이트와 문자 스트림의 차이를 정확히 이해하기 (0) | 2025.11.09 |
| Java 소켓과 HTTP 통신: 기초부터 클라이언트 구현까지 완벽 정리 (0) | 2025.11.08 |