F-LAB JAVA · 1주차 · Phase 7 · 외부 세계와의 통신 · 1주차 마지막 Unit
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
Serializable이 마커 인터페이스(메서드 0개)인 이유는?serialVersionUID를 명시하지 않으면 언제 무슨 일이 터지는가?transient는 정확히 무엇을 막는가?writeObject / readObject 후킹 메서드는 어떻게 호출되나?Java 직렬화 = 객체를 바이트 스트림으로 변환하는 JVM 내장 메커니즘 (Java 1.1+)
— 강력하지만 보안·성능·호환성 면에서 현대적 대안(JSON, Protobuf)에 모두 진다.
Effective Java는 명시적으로 "새 코드에서는 사용하지 말 것"을 권한다.
| 시대 | 모델 | 비유 |
|---|---|---|
| 객체 (메모리) | RAM의 살아있는 객체 | 갓 만든 따끈한 밥상 |
| 직렬화 | 바이트 스트림으로 변환 | 진공 포장 — 우편 발송 가능 |
| 역직렬화 | 바이트에서 객체 복원 | 진공 포장 해체 → 다시 밥상 |
| transient | 직렬화 제외 필드 | "이 반찬은 빼고 포장" |
그러나 함정: 진공 포장지 안에 폭발물(악성 객체)이 들어있으면, 푸는 순간 터진다. 이게 역직렬화 공격.
1. 직렬화의 탄생 — 왜 객체를 바이트로 만들어야 했나
2. Serializable 인터페이스 — 마커, 마법, 그리고 함정
3. serialVersionUID — 명시 안 하면 터지는 시한폭탄
4. transient의 두 얼굴 — 보안과 효율
5. 직렬화 메커니즘 내부 — writeObject · readObject 후킹
6. 역직렬화 공격 — Java의 최대 보안 사고
7. 현대적 대안 — JSON · Protobuf · Externalizable
8. ILIC 실무 코드 — Redis 세션 · JPA Entity · 캐시
9. 면접 질문 + 자기 점검 + 1주차 졸업 시험
Shipment s = new Shipment("BL-2024-001", "SEOUL", "TOKYO");
// s는 Heap의 어딘가에 있다. JVM이 꺼지면 사라진다.
세 가지 시나리오에서 객체를 메모리 밖으로 꺼내야 한다.
// 프로세스 재시작 후에도 객체를 복원하고 싶다
saveToFile(shipment, "shipment.dat");
// ... 나중에
Shipment restored = loadFromFile("shipment.dat");
// RMI, EJB 시대: 다른 JVM에 객체를 보내고 싶다
remoteService.processShipment(shipment);
// 객체가 어떻게 TCP 패킷으로 변하는가?
// HttpSession을 Redis에 저장하고 싶다 (스케일 아웃)
redisTemplate.opsForValue().set("session:abc", userSession);
1997년, Java 1.1에서 java.io.Serializable 도입.
"클래스에 이 인터페이스만 붙이면 JVM이 알아서 바이트로 만들어 준다."
public class Shipment implements Serializable {
private String blNo;
private String origin;
private String destination;
// getter/setter
}
// 직렬화
try (ObjectOutputStream out = new ObjectOutputStream(
new FileOutputStream("shipment.dat"))) {
out.writeObject(shipment);
}
// 역직렬화
try (ObjectInputStream in = new ObjectInputStream(
new FileInputStream("shipment.dat"))) {
Shipment restored = (Shipment) in.readObject();
}
당시 (1997) 의 매력:
implements Serializable)로 영속화현재의 시각: 이게 다 함정이었다. 이후 25년간 Java의 가장 큰 보안 부채.
public interface Serializable {
// 메서드 없음. 진짜 비어있다.
}
마커 인터페이스(Marker Interface) — 메서드 없이 타입에 의미만 부여.
컴파일러와 JVM에게 "이 클래스는 직렬화해도 됨"을 알려준다.
public class Shipment implements Serializable {
private String blNo; // ✓ Serializable
private LocalDate eta; // ✓ Serializable
private BigDecimal freight; // ✓ Serializable
private List<Cargo> cargoes; // ✓ Serializable (List 구현체에 따라)
private DataSource dataSource; // ❌ NotSerializableException
}
기본 규칙:
Serializable이 아니면 → NotSerializableExceptionstatic 필드는 직렬화 안 됨 (클래스 단위라서)transient 필드는 직렬화 안 됨public class Member implements Serializable {
private String password; // private — 외부에서 못 봐야 함
private String email;
}
// 직렬화 → 바이트로 변환
byte[] data = serialize(member);
// 바이트를 열어보면? password 평문이 그대로 보임
// (HEX 에디터로 확인 가능)
→ private도 안전하지 않다. 직렬화는 캡슐화를 우회.
public class Shipment implements Serializable {
private String blNo;
}
이 클래스의 바이트 형식이 곧 API다. 필드명 한 줄만 바꿔도 기존 직렬화 데이터를 못 읽는다.
// 변경 후
public class Shipment implements Serializable {
private String billOfLadingNo; // ❌ 이전 데이터 읽기 불가
}
→ 직렬화 결정 = 수십 년의 API 유지보수 부담.
public class Shipment implements Serializable {
private String blNo;
private LocalDate createdAt;
public Shipment(String blNo) {
this.blNo = blNo;
this.createdAt = LocalDate.now(); // ❌ 역직렬화 시 실행 안 됨
validate(); // ❌ 검증 우회
}
}
역직렬화는 객체를 메모리에 그냥 만들어낸다 — 생성자, 검증, 불변식 모두 우회.
이게 보안 사고의 근본 원인.
public class Shipment implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
직렬화된 바이트의 버전 식별자. 역직렬화 시 클래스의 serialVersionUID와 비교.
직렬화 데이터의 UID vs 현재 클래스의 UID
달라? → InvalidClassException
JVM이 클래스 구조(필드명, 타입, 메서드 시그니처)로 자동 계산한다.
public class Shipment implements Serializable {
// serialVersionUID 명시 안 함
private String blNo;
}
계산 방식: SHA-1 기반 해시 → -5847248139482917658L 같은 값.
문제:
public class Shipment implements Serializable {
private static final long serialVersionUID = 1L; // ✓ 명시
// 또는
@Serial
private static final long serialVersionUID = 1L; // Java 14+, IDE 친화적
private String blNo;
}
IDE 설정: IntelliJ → Settings → Editor → Inspections → "Serializable class without 'serialVersionUID'" 활성화.
// v1
public class Shipment implements Serializable {
private static final long serialVersionUID = 1L;
private String blNo;
}
// v2 — 호환 가능한 변경 (필드 추가)
public class Shipment implements Serializable {
private static final long serialVersionUID = 1L; // ✓ 유지
private String blNo;
private String carrier; // 새 필드 — 역직렬화 시 null
}
// v3 — 호환 불가능한 변경 (필드 타입 변경)
public class Shipment implements Serializable {
private static final long serialVersionUID = 2L; // ✓ 올림
private Long blNo; // String → Long
}
호환 가능 변경: 필드 추가, transient 추가, 접근 제어자 변경
호환 불가 변경: 필드 삭제, 타입 변경, 클래스 계층 변경
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private String name;
private transient String password; // ✓ 직렬화 X
private transient String authToken; // ✓
private transient HttpServletRequest req; // ✓ Serializable 아님
}
비밀번호, 토큰, 비밀키, 신용카드 번호 등은 절대 직렬화돼선 안 된다.
public class PaymentRequest implements Serializable {
private String orderId;
private BigDecimal amount;
// ❌ 직렬화되면 디스크/Redis에 평문 노출
private String cardNumber;
// ✅
private transient String cardNumber;
}
실제 사고 시나리오 (ILIC):
public class CachedShipment implements Serializable {
private String blNo;
private transient DataSource dataSource; // 직렬화 불가
private transient EntityManager em; // 직렬화 불가
private transient Logger log = LoggerFactory.getLogger(getClass());
}
transient 안 붙이면: NotSerializableException: javax.sql.DataSource → 직렬화 자체가 실패.
public class UserSession implements Serializable {
private String userId;
private transient String name;
}
// 1) 직렬화 시점
session.userId = "user-001";
session.name = "박승제";
serialize(session);
// 바이트에는 userId만 들어감
// 2) 역직렬화 후
UserSession restored = deserialize(bytes);
restored.userId; // "user-001"
restored.name; // null ← 기본값
기본값 룰:
nullint, long: 0boolean: falsedouble: 0.0→ null 가능성을 항상 고려하거나, readObject에서 초기화 (5장 참조).
public class CachedQuote implements Serializable {
private final String key;
private final transient BigDecimal computed; // ❌ 역직렬화 후 null
}
final transient 필드는 역직렬화 시 초기화할 방법이 없다 (생성자 안 호출). → null이 영원히 박힘.
해결: final을 빼거나, readResolve에서 새 객체 반환.
직렬화 동작을 가로채는 메서드들. 이름이 정확해야 JVM이 인식한다 (오타 = 그냥 무시됨).
public class Shipment implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String blNo;
private transient BigDecimal lazyFreight;
// 1) 직렬화 직전 호출
@Serial
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // 기본 필드 처리
out.writeObject(encryptedFreight()); // 추가 처리
}
// 2) 역직렬화 직후 호출
@Serial
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 기본 필드 복원
this.lazyFreight = decryptFreight((byte[]) in.readObject());
// ✓ 검증을 여기서 수행 (생성자가 호출되지 않으므로)
if (blNo == null || blNo.isBlank()) {
throw new InvalidObjectException("blNo 필수");
}
}
// 3) 직렬화 시 자기 자신 대신 다른 객체를 직렬화하고 싶을 때
@Serial
private Object writeReplace() {
return new ShipmentProxy(this); // Proxy 패턴
}
// 4) 역직렬화 후 다른 객체를 반환하고 싶을 때 (싱글톤 핵심)
@Serial
private Object readResolve() {
return Cache.instance(this.blNo);
}
}
생성자가 호출되지 않으므로, 불변식 검증을 여기서 해야 한다.
@Serial
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 비즈니스 규칙 검증
if (freight != null && freight.signum() < 0) {
throw new InvalidObjectException("운임은 음수 불가");
}
if (cargoes == null) {
cargoes = new ArrayList<>(); // null 방지
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
이걸 빼먹으면? 악의적으로 조작된 바이트로 불변식이 깨진 객체를 만들 수 있다 → 보안 사고.
public class Currency implements Serializable {
public static final Currency KRW = new Currency("KRW");
public static final Currency USD = new Currency("USD");
private final String code;
private Currency(String code) { this.code = code; }
@Serial
private Object readResolve() {
return switch (code) {
case "KRW" -> KRW;
case "USD" -> USD;
default -> throw new InvalidObjectException("알 수 없는 통화");
};
}
}
// 없으면?
Currency krw1 = Currency.KRW;
Currency krw2 = deserialize(serialize(Currency.KRW));
krw1 == krw2; // ❌ false — 싱글톤 깨짐!
→ enum을 쓰면 자동으로 안전 (Java가 보장). 싱글톤은 enum이 Effective Java의 권장.
public class FastShipment implements Externalizable {
private String blNo;
private long timestamp;
public FastShipment() {} // public 무인자 생성자 필수!
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(blNo);
out.writeLong(timestamp);
}
@Override
public void readExternal(ObjectInput in) throws IOException {
this.blNo = in.readUTF();
this.timestamp = in.readLong();
}
}
Serializable vs Externalizable:
| 항목 | Serializable | Externalizable |
|---|---|---|
| 인터페이스 메서드 | 0개 (마커) | writeExternal / readExternal 강제 |
| 자동 처리 | ✓ 모든 필드 | ❌ 직접 다 써야 함 |
| 생성자 호출 | ❌ | ✓ public no-arg 생성자 호출 |
| 성능 | 느림 (리플렉션) | 빠름 |
| 호환성 관리 | UID 자동/수동 | 직접 |
| 실무 사용 | 거의 X | 거의 X (JSON/Protobuf로 대체) |
Externalizable은 학문적 관심 정도. 실무에선 어차피 Java 직렬화 자체를 안 쓴다.
역직렬화 = 바이트로부터 객체를 만들어내는 것.
그런데 객체는 그냥 데이터가 아니다. 메서드를 가지고 있고, readObject 후킹으로 임의 코드를 실행할 수 있다.
class EvilGadget implements Serializable {
@Serial
private void readObject(ObjectInputStream in) throws IOException {
Runtime.getRuntime().exec("rm -rf /"); // ❌ 역직렬화만 해도 실행됨
}
}
공격자가 EvilGadget의 바이트를 만들어 보내면, readObject()만 호출해도 RCE(원격 코드 실행).
가장 유명한 사례. Apache Commons Collections 라이브러리의 InvokerTransformer를 활용한 가젯 체인.
// 공격자가 만드는 페이로드 (개념적)
Transformer chain = new ChainedTransformer(new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", ...),
new InvokerTransformer("invoke", ...),
new InvokerTransformer("exec", new Object[] {"calc.exe"})
});
// 이걸 LazyMap으로 감싸서 HashMap에 넣고 직렬화
// → 서버에서 역직렬화하면 calc.exe 실행
피해 범위:
A08:2021 — Software and Data Integrity Failures
(이전: A8:2017 — Insecure Deserialization)
"역직렬화는 가능하면 모두 피하라. 어쩔 수 없이 써야 하면 untrusted source에서는 절대 하지 말라."
가장 강력한 방어: 외부에서 받은 바이트를 Java 직렬화로 풀지 않는다.
어쩔 수 없이 Java 직렬화를 써야 한다면, 허용 목록(allow-list) 으로 차단.
ObjectInputStream in = new ObjectInputStream(input);
// 클래스 화이트리스트
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.ilic.shipment.*;" + // 우리 도메인만 허용
"java.lang.String;" +
"java.util.ArrayList;" +
"!*" // 나머지 모두 거부
);
in.setObjectInputFilter(filter);
Object obj = in.readObject(); // 화이트리스트 외 클래스 → 즉시 차단
전역 설정 (Java 9+):
java -Djdk.serialFilter="com.ilic.*;java.base/*;!*" -jar app.jar
더 정교한 컨텍스트 기반 필터링. 일반 애플리케이션에서는 #2까지로 충분.
"역직렬화는 임의 코드 실행과 동등하다고 가정하라."
— Java Secure Coding Guidelines
| 형식 | 사람이 읽기 | 크기 | 속도 | 언어 독립 | 보안 | 실무 |
|---|---|---|---|---|---|---|
| Java Serialization | ❌ | 큼 | 느림 | ❌ | ❌❌ | 레거시만 |
| JSON (Jackson) | ✓ | 보통 | 보통 | ✓ | ✓ | 압도적 1위 |
| Protobuf | ❌ | 작음 | 빠름 | ✓ | ✓ | gRPC, 성능 critical |
| MessagePack | ❌ | 작음 | 빠름 | ✓ | ✓ | JSON의 바이너리 버전 |
| Avro | ❌ | 작음 | 빠름 | ✓ | ✓ | Kafka, 스키마 진화 |
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()); // LocalDate 지원
// 직렬화
String json = mapper.writeValueAsString(shipment);
// {"blNo":"BL-2024-001","origin":"SEOUL","destination":"TOKYO"}
// 역직렬화
Shipment restored = mapper.readValue(json, Shipment.class);
장점:
Serializable 불필요단점:
public record ShipmentDto(
String blNo,
String origin,
String destination,
LocalDate eta
) {}
// Jackson이 자동 처리 (별도 설정 불필요)
ShipmentDto dto = mapper.readValue(json, ShipmentDto.class);
→ HashMap Unit에서 봤듯이 불변 DTO는 Record. JSON 직렬화에 완벽 적합.
// shipment.proto
syntax = "proto3";
message Shipment {
string bl_no = 1;
string origin = 2;
string destination = 3;
int64 eta_epoch_day = 4;
}
// 컴파일러가 Java 클래스 생성
Shipment s = Shipment.newBuilder()
.setBlNo("BL-2024-001")
.setOrigin("SEOUL")
.build();
byte[] bytes = s.toByteArray(); // 직렬화
Shipment restored = Shipment.parseFrom(bytes); // 역직렬화
언제 쓰나:
ILIC 시나리오: 운송사 API와 대량 데이터 교환 (수십만 건 화물 정보) 시 검토 가치.
| 시나리오 | 형식 | 이유 |
|---|---|---|
| HTTP REST API | JSON | 표준, 언어 독립, 디버깅 쉬움 |
| Redis 세션 (Spring Session) | JSON (또는 Java) | 보안상 JSON 권장 |
Redis 캐시 (@Cacheable) | JSON | 가독성, 다른 언어 접근 가능 |
| JPA Entity 영속화 | DB (직렬화 무관) | 컬럼별 매핑 |
| 외부 메시지 큐 (Kafka) | JSON 또는 Avro | 스키마 진화 |
| gRPC 내부 통신 | Protobuf | 성능 |
| 로컬 파일 캐시 | JSON | 사람이 열어볼 수 있음 |
| 절대 안 씀 | Java Serialization | 보안, 성능, 호환성 모두 열등 |
@Configuration
@EnableRedisHttpSession
public class SessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
// ❌ Java 직렬화 (Spring Session 기본값 — 위험)
// return new JdkSerializationRedisSerializer();
// ✅ JSON
return new GenericJackson2JsonRedisSerializer();
}
}
왜 바꿔야 하나:
serialVersionUID 문제 회피 (배포 후 세션 깨짐)@Entity
public class Shipment {
@Id @GeneratedValue
private Long id;
private String blNo;
@Transient // ⚠ JPA 어노테이션 (@javax.persistence.Transient)
private BigDecimal calculatedFreight;
transient String tempCache; // ⚠ Java 키워드 (직렬화용)
}
두 transient의 차이 — 핵심:
| 종류 | 역할 | 영향 |
|---|---|---|
@Transient (JPA) | DB에 저장 X | Hibernate가 컬럼 매핑 안 함 |
transient (Java 키워드) | 직렬화 X | ObjectOutputStream 무시 |
둘은 별개. 같이 쓸 수도 있다:
@Transient // DB 저장 X
private transient Logger log; // 직렬화도 X
⚠️ 면접 단골: "엔티티의
@Transient와transient차이는?"
@Service
public class FareCache {
@Cacheable(value = "fares", key = "#routeKey")
public BigDecimal getFare(String routeKey) {
return fareCalculator.compute(routeKey);
}
}
Redis 캐시 시:
RedisCacheConfiguration에서 JSON serializer 명시BigDecimal 같은 타입은 Jackson 설정으로 처리 (WRITE_BIGDECIMAL_AS_PLAIN)public record ShipmentResponse(
Long id,
String blNo,
String origin,
String destination,
LocalDate eta,
BigDecimal freight
) {
public static ShipmentResponse from(Shipment entity) {
return new ShipmentResponse(
entity.getId(),
entity.getBlNo(),
entity.getOrigin(),
entity.getDestination(),
entity.getEta(),
entity.getFreight()
);
}
}
Serializable 키워드 한 글자도 안 씀 → 직렬화 공격 면역public class CarrierCredential {
private String carrierId;
// ❌ 캐시에 평문으로 박힘
private String apiToken;
// ✅
@JsonIgnore // Jackson: JSON 직렬화 제외
private transient String apiToken; // Java 직렬화도 제외 (혹시 모르니)
// 토큰은 호출 시점에 KMS/Vault에서 fetch
public String getApiToken() {
return secretManager.fetch(carrierId);
}
}
다중 방어:
@JsonIgnore — Jackson이 JSON에 안 넣음transient — Java 직렬화에도 안 들어감| Q | 핵심 답변 |
|---|---|
| Serializable이 마커 인터페이스인 이유? | JVM에게 "직렬화 허용" 신호. 메서드 없이 타입에 의미만 부여 |
| serialVersionUID 명시 안 하면? | 클래스 변경 시 자동 계산값 변화 → InvalidClassException. 운영 사고 |
| transient의 두 가지 용도? | 보안(비밀번호) + 직렬화 불가 타입(DataSource) |
@Transient vs transient 차이? | JPA의 DB 매핑 제외 vs Java 직렬화 제외. 별개 |
| Java 직렬화의 보안 위험? | readObject 후킹으로 임의 코드 실행 가능 (RCE) |
| 역직렬화 공격 방어? | 1) untrusted 데이터 직렬화 X 2) ObjectInputFilter 화이트리스트 |
| readObject에서 검증? | 생성자가 호출 안 되므로 불변식 검증을 여기서 |
| Externalizable이 Serializable과 다른 점? | 메서드 강제, no-arg 생성자 호출, 수동 제어 |
| 싱글톤 직렬화 시 깨지지 않게? | readResolve 또는 enum 사용 |
| 현대 대안? 무엇 선택? | JSON(범용) / Protobuf(성능) / Avro(스키마 진화) |
@Transient와 transient의 차이를 설명할 수 있다1. Java 직렬화는 "있지만 쓰지 말 것"
Serializable 안 쓰면 직렬화 공격 자체가 불가능2. 어쩔 수 없이 쓴다면 — 다중 방어
serialVersionUID 항상 명시transientreadObject에서 검증ObjectInputFilter로 화이트리스트readResolve로 싱글톤 보장3. 실무는 JSON · Protobuf
막힘없이 답할 수 있다면 1주차 합격.
super(...)를 명시적으로 호출해야 하는 경우는?Member m = new Member()에서 m과 객체 본체는 각각 어디에?String a = "abc"; String b = "abc";일 때 a == b가 true인 이유는?transient를 비밀번호에 안 붙이면 어떤 보안 사고가 가능한가?File보다 Files/Path를 권장하는 두 가지 이유는?