3주차 Unit 9.4 — Serialization (직렬화)

Psj·2026년 5월 20일

F-lab

목록 보기
115/197

Unit 9.4 — Serialization (직렬화)

F-LAB JAVA · 3주차 · Phase 9 · I/O 강화


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • 직렬화 (Serialization) 의 정의와 목적은?
  • Serializable 인터페이스 의 역할과 마커 인터페이스의 의미는?
  • ObjectInputStream / ObjectOutputStream 의 동작은?
  • 객체 그래프 직렬화의 메커니즘은?
  • transient 키워드 의 의미와 활용은?
  • writeObject / readObject 커스텀 메서드는?
  • 직렬화의 6가지 보안 문제 는?
  • Externalizable vs Serializable 의 차이는?
  • JSON, Protobuf 등 현대 대안은?
  • 언제 자바 직렬화를 써야 하나?

🎯 핵심 한 문장

직렬화 (Serialization) 는 자바 객체를 바이트 스트림으로 변환하여 저장/전송 가능하게 하고, 역직렬화 (Deserialization) 는 다시 객체로 복원하는 기술이다.
Serializable 마커 인터페이스 만 구현하면 ObjectOutputStream.writeObject(obj) 로 객체 그래프 (객체 + 참조 + 참조의 참조 + ...) 가 모두 자동 저장.
transient 키워드는 직렬화에서 제외할 필드를 표시 (비밀번호, 캐시, Thread 등).
자바 직렬화는 6가지 큰 문제 (보안 취약점/성능/호환성/유연성 X/언어 종속/스키마 X) 로 현대에는 JSON, Protobuf, Avro 권장 — Effective Java 도 "사용하지 말라" 권고.
그래도 알아둬야 하는 이유: 레거시 시스템, RMI, JCache, JMS, 일부 프레임워크 에서 여전히 사용.

비유 — 가구 운반

객체 = 조립된 가구 (의자 + 다리 + 쿠션 등)

직렬화:
  가구를 분해해서 박스에 넣기
  - 각 부품 + 조립도
  - 박스가 바이트 스트림

전송/저장:
  박스를 보내거나 보관

역직렬화:
  박스 풀어서 다시 조립
  - 같은 가구 복원
  - 같은 부품 + 같은 구조

transient:
  운반하면 안 되는 부분 (예: 휘발성 표시)
  - 운반 X
  - 도착 후 다시 만들기

마커 인터페이스 (Serializable):
  "이 가구는 분해 가능" 표시
  - 메서드 없음
  - 단순 마크

→ 직렬화 = 객체 분해 + 재조립.


🧭 9개 섹션 로드맵

1. 직렬화의 정의와 목적
2. Serializable 인터페이스
3. ObjectInputStream / ObjectOutputStream
4. 객체 그래프 직렬화
5. transient 키워드
6. writeObject / readObject 커스텀
7. 직렬화의 6가지 문제
8. 현대의 대안 (JSON, Protobuf)
9. 면접 + 자기 점검

1️⃣ 직렬화의 정의와 목적

1.1 직렬화의 정의

직렬화 (Serialization):

  자바 객체 → 바이트 스트림

  메모리의 객체 (참조 그래프) 를
  저장/전송 가능한 형태 (선형 바이트) 로 변환.

역직렬화 (Deserialization):

  바이트 스트림 → 자바 객체
  
  반대 방향.

1.2 왜 필요한가

필요한 시나리오:

1. 파일 저장
   - 객체의 상태 유지
   - 다음 실행 시 복원

2. 네트워크 전송
   - 한 서버 → 다른 서버
   - RPC, RMI

3. 캐시
   - Redis, Memcached
   - 자바 객체 저장

4. 메시지 큐
   - JMS, Kafka 등
   - 객체 메시지

5. JVM 간 통신
   - 같은 JVM 의 객체를 다른 JVM 으로

1.3 객체의 메모리 표현

메모리의 객체:

  Shipment 객체:
    address: 0x1000
    fields:
      id: 1L
      blNo: "ABC123" (참조 → "ABC123" 문자열 객체)
      consignee: Company 객체 (참조 → 0x2000)
      items: ArrayList (참조 → 0x3000)
        → ShipmentItem 객체들
        → 각각 참조...
  
파일로 저장하려면:
  - 모든 참조를 따라가며 모두 저장
  - 객체 그래프 전체 직렬화
  - 자바가 자동으로 해줌 (Serializable 만 있으면)

1.4 단순 예제

// 1. 직렬화 가능 클래스
class Shipment implements Serializable {
    private Long id;
    private String blNo;
    private double weight;
    
    // 생성자, getter, setter
}

// 2. 객체 → 파일
Shipment s = new Shipment(1L, "BL-001", 100.5);
try (FileOutputStream fos = new FileOutputStream("shipment.ser");
     ObjectOutputStream oos = new ObjectOutputStream(fos)) {
    oos.writeObject(s);
}

// 3. 파일 → 객체
try (FileInputStream fis = new FileInputStream("shipment.ser");
     ObjectInputStream ois = new ObjectInputStream(fis)) {
    Shipment loaded = (Shipment) ois.readObject();
    // s 와 같은 데이터
}

1.5 자동 처리되는 것

ObjectOutputStream.writeObject 가 자동으로:

1. 클래스 정보 저장
   - 클래스 이름
   - serialVersionUID

2. 객체의 모든 필드 저장
   - 기본 타입 (int, long 등)
   - 참조 (다른 객체)

3. 참조 그래프 추적
   - 참조의 참조의 참조...
   - 모두 따라가며 직렬화

4. 순환 참조 처리
   - 같은 객체는 한 번만
   - 두 번째부터 참조

5. 메타데이터
   - 필드 이름
   - 타입 정보

1.6 ILIC 활용 (단순)

public class ShipmentSerializer {
    
    public void save(Path path, Shipment s) throws IOException {
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new BufferedOutputStream(
                    new FileOutputStream(path.toFile())))) {
            oos.writeObject(s);
        }
    }
    
    public Shipment load(Path path) throws IOException, ClassNotFoundException {
        try (ObjectInputStream ois = new ObjectInputStream(
                new BufferedInputStream(
                    new FileInputStream(path.toFile())))) {
            return (Shipment) ois.readObject();
        }
    }
}

1.7 자기 점검 답변

직렬화의 정의와 목적은?

:
1. 정의:

  • 객체 ↔ 바이트 스트림
  • 직렬화: 객체 → 바이트
  • 역직렬화: 바이트 → 객체
  1. 목적:

    • 파일 저장 (영속성)
    • 네트워크 전송
    • 캐시 (Redis)
    • 메시지 큐
    • JVM 간 통신
  2. 자동 처리:

    • 객체 그래프 전체
    • 참조 추적
    • 순환 참조 처리
    • 메타데이터
  3. 사용법:

    • Serializable 구현
    • ObjectOutputStream.writeObject
    • ObjectInputStream.readObject

2️⃣ Serializable 인터페이스

2.1 정의

package java.io;

public interface Serializable {
    // 메서드 없음!
}

핵심:

  • 마커 인터페이스 (Marker Interface)
  • 메서드 없음
  • "직렬화 가능" 표시만
  • 자바 1.1 부터

2.2 마커 인터페이스의 의미

마커 인터페이스:

  메서드는 없고
  단순히 "이 클래스는 X 능력 있음" 표시

다른 예:
  - Cloneable (clone 가능)
  - RandomAccess (배열 무작위 접근)

대안:
  - 어노테이션 (@Serializable)
  - 자바 5+ 이후 어노테이션이 더 일반적
  - 단, Serializable 은 역사적 이유로 인터페이스 유지

확인:
  obj instanceof Serializable
  // true / false

2.3 구현 방법

// 1. 단순 구현
public class Shipment implements Serializable {
    private Long id;
    private String blNo;
    // 메서드 추가 필요 X
}

// 2. 상속 시 자동 적용
public class UrgentShipment extends Shipment {
    // 부모가 Serializable → 자식도 가능
}

// 3. 컴포지션
public class ShipmentContainer implements Serializable {
    private List<Shipment> shipments;
    // Shipment 도 Serializable 이어야!
    
    // 만약 Shipment 가 Serializable 아니면:
    // NotSerializableException
}

2.4 Serializable 가 필요한 곳

모두 Serializable 이어야 직렬화 성공:

1. 직접 직렬화하는 객체
   - writeObject(obj) 의 obj

2. 객체가 참조하는 모든 객체
   - 필드의 객체
   - 그 객체의 필드...
   - 전체 그래프

3. 컬렉션의 요소
   - List, Set, Map 의 요소

예외:
  - transient 필드는 제외
  - 기본 타입은 자동
  - String 은 Serializable 구현됨
  - 컬렉션 (ArrayList 등) 도 Serializable

2.5 NotSerializableException

// 필드가 Serializable 아니면
class Container implements Serializable {
    private NonSerializable field;   // ❌
}

class NonSerializable {
    // Serializable 안 함
}

// 시도
oos.writeObject(new Container());
// NotSerializableException: NonSerializable

// 해결:
// 1. NonSerializable 도 Serializable 구현
// 2. transient 로 표시 (직렬화 제외)
// 3. 다른 방식 (writeObject 커스텀)

2.6 표준 클래스의 Serializable

자주 사용하는 Serializable 클래스:

기본 타입 래퍼:
  Integer, Long, Double, Boolean, Character ... ✓

String: ✓
StringBuilder, StringBuffer: ✓

컬렉션:
  ArrayList, LinkedList, HashMap, HashSet, TreeMap, ... ✓

날짜:
  LocalDateTime, Instant, LocalDate, ... ✓
  (java.time 의 클래스들)

기본 Number:
  BigInteger, BigDecimal: ✓

X (Serializable 아님):
  Thread, Socket, FileInputStream, ServletContext
  → 의미 없거나 보안 위험

2.7 ILIC 활용

// 1. 단순한 Serializable Entity
public class Shipment implements Serializable {
    
    private Long id;
    private String blNo;
    private String consignee;
    private BigDecimal weight;
    private LocalDateTime createdAt;
    private ShipmentStatus status;
    
    // 모든 필드가 Serializable
    // 자동 직렬화 OK
}

// 2. 컬렉션 포함
public class ShipmentBatch implements Serializable {
    
    private String batchId;
    private List<Shipment> shipments;   // ArrayList 등
    private Map<String, String> metadata;
    
    // 모두 Serializable
}

// 3. Enum 도 자동
public enum ShipmentStatus implements Serializable {
    PENDING, SHIPPED, DELIVERED, CANCELLED;
    // Enum 은 자동 Serializable
}

2.8 자기 점검 답변

Serializable 인터페이스의 역할은?

:
1. 마커 인터페이스:

  • 메서드 없음
  • "직렬화 가능" 표시
  1. 구현:

    • implements Serializable
    • 메서드 추가 X
  2. 모든 그래프:

    • 직렬화할 객체
    • 참조하는 모든 객체
    • Serializable 이어야
  3. NotSerializableException:

    • 그래프 안에 비 Serializable
    • transient 로 해결
  4. 표준 클래스:

    • 대부분 Serializable
    • Thread, Socket 등은 X

3️⃣ ObjectInputStream / ObjectOutputStream

3.1 정의

public class ObjectOutputStream extends OutputStream 
        implements ObjectOutput, ObjectStreamConstants {
    
    public ObjectOutputStream(OutputStream out);
    
    public final void writeObject(Object obj) throws IOException;
    
    // DataOutput 메서드
    public void writeInt(int val);
    public void writeLong(long val);
    public void writeUTF(String str);
    // ... 기타
}

public class ObjectInputStream extends InputStream 
        implements ObjectInput, ObjectStreamConstants {
    
    public ObjectInputStream(InputStream in);
    
    public final Object readObject() throws IOException, ClassNotFoundException;
    
    // DataInput 메서드
    public int readInt();
    public long readLong();
    public String readUTF();
    // ... 기타
}

핵심:

  • ObjectOutput / ObjectInput 인터페이스
  • DataOutput / DataInput 도 확장
  • 기본 타입 + 객체 모두 처리

3.2 ObjectOutput / ObjectInput 인터페이스

public interface ObjectOutput extends DataOutput, AutoCloseable {
    void writeObject(Object obj) throws IOException;
    
    // DataOutput 상속
    // writeInt, writeLong 등
}

public interface ObjectInput extends DataInput, AutoCloseable {
    Object readObject() throws ClassNotFoundException, IOException;
    
    // DataInput 상속
}

// 인터페이스 계층:
// ObjectOutput extends DataOutput
// DataOutput 의 모든 기본 타입 메서드 사용 가능

3.3 writeObject 의 동작

oos.writeObject(shipment);

// 내부 동작:
// 1. 클래스 정보 쓰기
//    - 클래스 이름
//    - serialVersionUID
//    - 필드 정보 (이름, 타입)

// 2. 객체의 필드 쓰기
//    - 기본 타입: 그대로
//    - String: writeUTF
//    - 참조: 재귀적 writeObject

// 3. 참조 추적
//    - 이미 직렬화된 객체는 참조만
//    - "이 객체는 이전의 #5" 같은 식

// 4. 메타데이터
//    - 그래프 끝 표시

3.4 readObject 의 동작

Object obj = ois.readObject();
Shipment s = (Shipment) obj;

// 내부 동작:
// 1. 클래스 정보 읽기
//    - 클래스 로드
//    - serialVersionUID 검증

// 2. 객체 생성
//    - 생성자 호출 X
//    - JVM 의 내부 메커니즘
//    - 기본값으로 초기화

// 3. 필드 복원
//    - 기본 타입 읽기
//    - 참조 복원 (재귀)

// 4. 캐스팅
//    - Object 반환
//    - 호출자가 캐스팅
//    - ClassCastException 가능

3.5 ClassNotFoundException

// readObject 가 던질 수 있는 예외
try {
    Object obj = ois.readObject();
} catch (ClassNotFoundException e) {
    // 클래스 로드 실패
    // 직렬화 시의 클래스가 현재 classpath 에 없음
}

// 시나리오:
// 1. JVM A 에서 직렬화
//    - com.example.Shipment 클래스
// 2. JVM B 에서 역직렬화 시도
//    - Shipment 클래스 없음
//    - ClassNotFoundException

// 해결:
// 1. 같은 classpath
// 2. 또는 다른 방식 (JSON 등)

3.6 Buffered 와 결합

// 권장 패턴
try (ObjectOutputStream oos = new ObjectOutputStream(
        new BufferedOutputStream(
            new FileOutputStream("data.ser")))) {
    oos.writeObject(shipment);
}

try (ObjectInputStream ois = new ObjectInputStream(
        new BufferedInputStream(
            new FileInputStream("data.ser")))) {
    Shipment s = (Shipment) ois.readObject();
}

3.7 여러 객체 직렬화

// 한 파일에 여러 객체
try (ObjectOutputStream oos = new ObjectOutputStream(
        new FileOutputStream("data.ser"))) {
    oos.writeObject(shipment1);
    oos.writeObject(shipment2);
    oos.writeObject(shipment3);
    
    // 또는 컬렉션 한 번에
    oos.writeObject(List.of(shipment1, shipment2, shipment3));
}

// 읽기 (같은 순서)
try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("data.ser"))) {
    Shipment s1 = (Shipment) ois.readObject();
    Shipment s2 = (Shipment) ois.readObject();
    Shipment s3 = (Shipment) ois.readObject();
    
    // 또는
    @SuppressWarnings("unchecked")
    List<Shipment> list = (List<Shipment>) ois.readObject();
}

// 끝 도달 시:
// EOFException (기본 타입) 또는
// null 또는 OptionalDataException

3.8 ILIC 활용

public class ShipmentObjectSerializer {
    
    public void save(Path path, Shipment s) throws IOException {
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new BufferedOutputStream(
                    new FileOutputStream(path.toFile())))) {
            oos.writeObject(s);
        }
    }
    
    public Shipment load(Path path) throws IOException, ClassNotFoundException {
        try (ObjectInputStream ois = new ObjectInputStream(
                new BufferedInputStream(
                    new FileInputStream(path.toFile())))) {
            return (Shipment) ois.readObject();
        }
    }
    
    // 컬렉션 직렬화
    public void saveAll(Path path, List<Shipment> shipments) throws IOException {
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new BufferedOutputStream(
                    new GZIPOutputStream(   // 압축
                        new FileOutputStream(path.toFile()))))) {
            oos.writeObject(shipments);
        }
    }
    
    @SuppressWarnings("unchecked")
    public List<Shipment> loadAll(Path path) throws IOException, ClassNotFoundException {
        try (ObjectInputStream ois = new ObjectInputStream(
                new BufferedInputStream(
                    new GZIPInputStream(
                        new FileInputStream(path.toFile()))))) {
            return (List<Shipment>) ois.readObject();
        }
    }
}

3.9 자기 점검 답변

ObjectInputStream / ObjectOutputStream 의 동작은?

:
1. 정의:

  • ObjectInput / ObjectOutput 구현
  • DataInput / DataOutput 도 (기본 타입)
  • writeObject / readObject
  1. writeObject:

    • 클래스 정보 + 필드 + 참조 추적
    • 객체 그래프 전체
  2. readObject:

    • 클래스 로드 (ClassNotFoundException 가능)
    • 객체 생성 (생성자 호출 X)
    • 필드 복원
    • Object 반환 (캐스팅 필요)
  3. 권장:

    • BufferedStream 과 결합
    • 압축 결합 가능
  4. 여러 객체:

    • 순서대로 writeObject
    • 같은 순서로 readObject
    • 또는 컬렉션

4️⃣ 객체 그래프 직렬화

4.1 객체 그래프란

객체 그래프:

  객체 A 가 B 를 참조,
  B 가 C 를 참조,
  ...
  연결된 모든 객체.

예:
  Shipment
    ├── consignee: Company
    │     ├── address: Address
    │     └── contact: Person
    ├── items: ArrayList
    │     ├── ShipmentItem 1
    │     ├── ShipmentItem 2
    │     └── ...
    └── status: ShipmentStatus

4.2 자동 추적

class A implements Serializable {
    B b;
}

class B implements Serializable {
    C c;
}

class C implements Serializable {
    String data;
}

A a = new A();
a.b = new B();
a.b.c = new C();
a.b.c.data = "Hello";

oos.writeObject(a);
// 자동으로:
// 1. A 저장
// 2. A.b 의 B 저장
// 3. B.c 의 C 저장
// 4. C.data 의 String 저장
// 모두 자동

4.3 시각화

객체 그래프:

  A (0x1000)
    └─ b → B (0x2000)
           └─ c → C (0x3000)
                  └─ data → "Hello"

직렬화된 바이트 스트림:

  [헤더]
  [A 의 클래스 정보]
  [A 의 필드: b 참조]
    [B 의 클래스 정보]
    [B 의 필드: c 참조]
      [C 의 클래스 정보]
      [C 의 필드: data 참조]
        [String "Hello" 저장]
  [그래프 끝]

역직렬화:
  - 같은 구조 복원
  - 새 주소
  - 같은 데이터

4.4 순환 참조 처리

// 순환 참조
class Node implements Serializable {
    Node next;
    String data;
}

Node n1 = new Node();
n1.data = "1";
Node n2 = new Node();
n2.data = "2";
n1.next = n2;
n2.next = n1;   // 순환!

oos.writeObject(n1);
// 자바가 자동 처리:
// 1. n1 저장
// 2. n1.next → n2 저장
// 3. n2.next → n1 (이미 저장됨, 참조만)

// 즉, 무한 루프 X
// 자동으로 중복 방지

// 역직렬화 후
Node loaded = (Node) ois.readObject();
loaded.next.next == loaded;   // true (순환 복원)

4.5 같은 객체 참조

// 두 곳에서 같은 객체 참조
class Order implements Serializable {
    Customer customer;
}

class Invoice implements Serializable {
    Customer customer;   // 같은 Customer
}

Customer c = new Customer("Alice");
Order o = new Order();
o.customer = c;
Invoice i = new Invoice();
i.customer = c;   // 같은 인스턴스

// 컬렉션에 둘 다 넣어 직렬화
oos.writeObject(List.of(o, i));

// 자바가 자동:
// 1. Order 저장
// 2. Customer "Alice" 저장
// 3. Invoice 저장
// 4. Invoice.customer 는 이미 저장된 Customer 참조

// 역직렬화 후
List<Object> list = (List<Object>) ois.readObject();
Order ro = (Order) list.get(0);
Invoice ri = (Invoice) list.get(1);

ro.customer == ri.customer;   // true (같은 인스턴스 복원)

4.6 객체 그래프의 효과

장점:

1. 자동 처리
   - 사용자가 신경 X
   - 복잡한 구조도 한 줄

2. 참조 무결성
   - 같은 객체는 같은 참조
   - 순환도 OK

3. 깊은 복사
   - 객체 + 그래프 전체
   - 새로운 인스턴스

활용:
  - deep copy
  - 객체 스냅샷
  - 캐시

4.7 함정 — 비 Serializable 참조

class Container implements Serializable {
    transient NonSerializable nonSer;   // ✓ transient 로 제외
    Serializable ser;
    
    // ❌ 만약 transient 없으면
    // NonSerializable field;
    // → NotSerializableException
}

// 전체 그래프 검증 필요:
// - 모든 참조 추적
// - 모든 객체가 Serializable
// - 또는 transient

4.8 깊은 복사 (Deep Copy) 활용

// 직렬화로 깊은 복사
public static <T extends Serializable> T deepCopy(T obj) throws Exception {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
        oos.writeObject(obj);
    }
    
    ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
    try (ObjectInputStream ois = new ObjectInputStream(bais)) {
        @SuppressWarnings("unchecked")
        T copy = (T) ois.readObject();
        return copy;
    }
}

// 사용
Shipment original = new Shipment(...);
Shipment copy = deepCopy(original);

original != copy;   // true (다른 인스턴스)
original.equals(copy);   // true (값은 같음 — equals 구현 시)

// 단점:
// - 성능 ↓ (직렬화/역직렬화 비용)
// - Serializable 필수
// - 일반적 deep copy 보다 느림

4.9 ILIC 활용

public class ShipmentDeepCopier {
    
    @SuppressWarnings("unchecked")
    public <T extends Serializable> T deepCopy(T obj) {
        try {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
                oos.writeObject(obj);
            }
            
            try (ObjectInputStream ois = new ObjectInputStream(
                    new ByteArrayInputStream(baos.toByteArray()))) {
                return (T) ois.readObject();
            }
        } catch (Exception e) {
            throw new RuntimeException("Deep copy failed", e);
        }
    }
    
    // 활용 — 트랜잭션 시작 시 스냅샷
    public void processWithRollback(Shipment original) {
        Shipment backup = deepCopy(original);
        
        try {
            modifyShipment(original);
            commitChanges(original);
        } catch (Exception e) {
            // 롤백 — backup 으로
            restoreFromBackup(backup);
            throw e;
        }
    }
}

4.10 자기 점검 답변

객체 그래프 직렬화의 메커니즘은?

:
1. 자동 추적:

  • 객체 + 모든 참조
  • 재귀적
  1. 순환 참조:

    • 자동 처리
    • 이미 저장된 객체는 참조만
  2. 같은 객체:

    • 한 번만 저장
    • 참조 무결성 유지
  3. 전체 그래프 Serializable:

    • 모든 참조가 Serializable
    • 또는 transient
  4. 활용:

    • 깊은 복사 (deep copy)
    • 스냅샷
    • 캐시

5️⃣ transient 키워드

5.1 transient 의 정의

transient:

  필드를 직렬화에서 제외하는 키워드.

목적:
  1. 의미 없는 필드 (캐시, 임시 계산값)
  2. 직렬화 불가능 (Thread, Socket)
  3. 보안 (비밀번호, 키)
  4. 크기 ↓ (큰 필드 제외)

5.2 사용 예

public class User implements Serializable {
    
    private String username;
    private transient String password;   // ★ 직렬화 X
    
    private transient Thread workerThread;   // 의미 없음
    private transient byte[] cachedData;      // 캐시
    
    private String email;
}

// 직렬화 시:
// - username: 저장
// - password: ★ 저장 X (보안)
// - workerThread: 저장 X
// - cachedData: 저장 X
// - email: 저장

// 역직렬화 시:
// - 저장된 필드 복원
// - transient 필드는 ★ 기본값 (null, 0, false)

5.3 기본값 복원

public class Cache implements Serializable {
    
    private int hitCount;
    private transient Map<String, byte[]> data;   // 큰 캐시
    private long lastModified;
}

// 직렬화
Cache c = new Cache();
c.hitCount = 100;
c.data = ...;   // 큰 데이터
c.lastModified = ...;

oos.writeObject(c);
// data 는 제외 (크기 ↓)

// 역직렬화
Cache loaded = (Cache) ois.readObject();
loaded.hitCount;        // 100 (정상 복원)
loaded.data;             // null! ★ 기본값
loaded.lastModified;    // 정상 복원

// data 재초기화 필요
// readObject 커스텀 활용

5.4 활용 시나리오

// 1. 비밀번호 (보안)
class Credential implements Serializable {
    private String username;
    private transient String password;
    // 직렬화 시 비밀번호 노출 X
}

// 2. 임시 캐시
class Calculator implements Serializable {
    private int input;
    private transient int cachedResult;
    // 캐시는 재계산
}

// 3. 의미 없는 자원
class DatabaseConnection implements Serializable {
    private String connectionUrl;
    private transient Connection conn;
    // Connection 은 직렬화 의미 X
}

// 4. 큰 데이터
class Image implements Serializable {
    private int width;
    private int height;
    private transient byte[] pixels;   // 큰 데이터
    // 필요 시 재로드
}

// 5. 동시성 객체
class Counter implements Serializable {
    private int value;
    private transient Object lock = new Object();
    // Lock 객체는 직렬화 X
}

5.5 static 과 transient

static 필드:
  - 클래스에 속함
  - 객체에 속하지 않음
  - ★ 자동 직렬화 안 됨
  - transient 명시 불필요

transient 필드:
  - 객체에 속함
  - 명시적으로 제외

class A implements Serializable {
    static int classCount;   // 자동 제외
    transient int temp;       // 명시 제외
    int value;                // 직렬화 O
}

5.6 transient 의 재초기화

public class Cache implements Serializable {
    
    private String key;
    private transient byte[] data;
    
    // 역직렬화 후 재초기화
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();   // 일반 필드 복원
        // data 는 null
        
        // 재로드
        data = loadFromDisk(key);
    }
    
    private byte[] loadFromDisk(String key) {
        // ...
    }
}

5.7 ILIC 활용

public class ShipmentCache implements Serializable {
    
    private static final long serialVersionUID = 1L;
    
    // 기본 필드
    private Long shipmentId;
    private LocalDateTime cachedAt;
    
    // 직렬화 X
    private transient Map<String, byte[]> cachedDocuments;   // 큰 캐시
    private transient AtomicLong hitCounter;                  // 동시성 객체
    
    public ShipmentCache(Long shipmentId) {
        this.shipmentId = shipmentId;
        this.cachedAt = LocalDateTime.now();
        this.cachedDocuments = new ConcurrentHashMap<>();
        this.hitCounter = new AtomicLong();
    }
    
    // 역직렬화 후 transient 초기화
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        this.cachedDocuments = new ConcurrentHashMap<>();
        this.hitCounter = new AtomicLong();
    }
}

// 보안 - 비밀번호
public class UserCredential implements Serializable {
    
    private String username;
    private String email;
    
    // 절대 직렬화 X
    private transient String password;
    private transient byte[] apiSecret;
    
    // 역직렬화 후 password 는 null
    // 직접 설정해야 함
}

5.8 자기 점검 답변

transient 키워드의 의미와 활용은?

:
1. 의미:

  • 직렬화에서 제외
  • 역직렬화 시 기본값 (null/0/false)
  1. 활용 5가지:

    • 비밀번호 (보안)
    • 캐시 (재계산)
    • 자원 (Connection, Thread)
    • 큰 데이터 (크기 ↓)
    • 동시성 객체 (Lock 등)
  2. static 과:

    • static 은 자동 제외
    • transient 명시 X
  3. 재초기화:

    • readObject 커스텀
    • defaultReadObject + 추가 작업

6️⃣ writeObject / readObject 커스텀

6.1 커스텀의 필요성

자동 직렬화의 한계:

1. 모든 필드 자동
   - 일부만 직렬화하고 싶을 때

2. 형식 제어 X
   - 자바 표준 형식

3. transient 의 재초기화
   - 자동으로 안 됨

4. 보안 검증
   - 역직렬화 시 검증 필요

5. 호환성
   - 옛 버전 → 새 버전

6.2 writeObject / readObject 메서드

class MyClass implements Serializable {
    
    // 직렬화 커스텀 메서드 — private 필수
    private void writeObject(ObjectOutputStream oos) throws IOException {
        // 1. 기본 필드 처리
        oos.defaultWriteObject();
        
        // 2. 추가 데이터
        oos.writeInt(customValue);
    }
    
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        // 1. 기본 필드 복원
        ois.defaultReadObject();
        
        // 2. 추가 데이터 읽기
        int customValue = ois.readInt();
        
        // 3. transient 재초기화
        this.cachedData = recompute();
    }
}

6.3 시그니처의 정확성

// ★ 정확한 시그니처
private void writeObject(ObjectOutputStream out) throws IOException;
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException;

// 잘못된 시그니처:
public void writeObject(...) — ❌ private 이어야
void writeObject(...) — ❌ private 이어야

// JVM 이 reflection 으로 호출
// private 만 받음

6.4 활용 — 보안

class SecureData implements Serializable {
    
    private String userId;
    private transient String encryptionKey;   // 직렬화 X
    private byte[] encryptedData;
    
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
        // encryptionKey 는 transient 라 저장 X
    }
    
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        
        // 검증
        if (userId == null || encryptedData == null) {
            throw new InvalidObjectException("Required fields missing");
        }
        
        if (encryptedData.length > MAX_SIZE) {
            throw new InvalidObjectException("Data too large");
        }
        
        // 키 재로드 (외부에서)
        // encryptionKey = loadKey(userId);
    }
}

6.5 활용 — 호환성

class Person implements Serializable {
    
    private static final long serialVersionUID = 2L;
    
    private String name;
    private int age;
    private String email;   // V2 에서 추가
    
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        
        // V1 → V2 호환
        if (email == null) {
            email = "unknown@example.com";   // 기본값
        }
    }
}

// V1 파일을 V2 코드로 읽기
// - name, age 는 정상 복원
// - email 은 V1 파일에 없음 → null
// - readObject 에서 기본값 처리

6.6 활용 — 컬렉션 커스텀

class CustomMap implements Serializable {
    
    private transient Map<String, Object> map;
    
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
        
        // 크기 + 각 엔트리
        oos.writeInt(map.size());
        for (Map.Entry<String, Object> entry : map.entrySet()) {
            oos.writeUTF(entry.getKey());
            oos.writeObject(entry.getValue());
        }
    }
    
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        
        int size = ois.readInt();
        map = new HashMap<>(size);
        for (int i = 0; i < size; i++) {
            String key = ois.readUTF();
            Object value = ois.readObject();
            map.put(key, value);
        }
    }
}

6.7 다른 커스텀 메서드

class MyClass implements Serializable {
    
    // 1. writeReplace — 직렬화 시 다른 객체로 교체
    private Object writeReplace() throws ObjectStreamException {
        return new ProxyClass(this);
    }
    
    // 2. readResolve — 역직렬화 후 다른 객체로 교체
    private Object readResolve() throws ObjectStreamException {
        return INSTANCE;   // Singleton 보호
    }
    
    // 3. writeObject / readObject (위에서 봄)
    
    // 4. readObjectNoData — 다른 버전 호환
    private void readObjectNoData() throws ObjectStreamException {
        // V1 객체를 V2 로 읽을 때 데이터 없는 필드 처리
    }
}

6.8 ILIC 활용

public class ShipmentWithCustomSerialization implements Serializable {
    
    private static final long serialVersionUID = 1L;
    
    private Long id;
    private String blNo;
    private List<ShipmentItem> items;
    private transient Map<String, byte[]> cachedDocs;
    
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject();
        
        // 추가 메타데이터
        oos.writeUTF("ILIC-v1.0");
        oos.writeLong(System.currentTimeMillis());
    }
    
    private void readObject(ObjectInputStream ois) 
            throws IOException, ClassNotFoundException {
        ois.defaultReadObject();
        
        // 메타데이터 검증
        String version = ois.readUTF();
        if (!version.startsWith("ILIC-")) {
            throw new InvalidObjectException("Invalid version: " + version);
        }
        
        long savedAt = ois.readLong();
        // 너무 오래된 데이터?
        if (savedAt < System.currentTimeMillis() - Duration.ofDays(365).toMillis()) {
            log.warn("Shipment data is over 1 year old");
        }
        
        // transient 재초기화
        this.cachedDocs = new ConcurrentHashMap<>();
        
        // 검증
        if (id == null || blNo == null) {
            throw new InvalidObjectException("Required fields missing");
        }
    }
}

6.9 자기 점검 답변

writeObject / readObject 커스텀의 활용은?

:
1. 시그니처:

  • private 필수
  • JVM 의 reflection 호출
  1. 자주 활용:

    • defaultWriteObject / defaultReadObject
    • 추가 데이터
    • 검증
    • transient 재초기화
  2. 활용 5가지:

    • 보안 (검증)
    • 호환성 (V1 → V2)
    • 컬렉션 커스텀
    • 추가 메타데이터
    • 검증
  3. 다른 커스텀:

    • writeReplace (교체)
    • readResolve (Singleton)
    • readObjectNoData

7️⃣ 직렬화의 6가지 문제

7.1 문제 1 — 보안 취약점

보안 취약점:

  악의적 데이터로 역직렬화 → 임의 코드 실행
  CVE 사례 무수히 많음

예: Apache Commons Collections (2015)
  - InvokerTransformer 의 코드 실행
  - Ysoserial 같은 공격 도구
  - 자바 생태계 큰 충격

원인:
  - readObject 가 모든 클래스 로드
  - 검증 없이 객체 생성
  - 임의 객체 그래프 가능

권장:
  - 신뢰할 수 없는 데이터 역직렬화 X
  - JEP 290 (filterInputObjectStreams)
  - 또는 다른 형식 (JSON)

7.2 보안 검증

// Java 9+ 의 ObjectInputFilter
ObjectInputStream ois = new ObjectInputStream(in);
ois.setObjectInputFilter(filter -> {
    Class<?> clazz = filter.serialClass();
    if (clazz == null) return ObjectInputFilter.Status.ALLOWED;
    
    // 허용 클래스만
    if (clazz.getPackageName().startsWith("com.ilic.")) {
        return ObjectInputFilter.Status.ALLOWED;
    }
    return ObjectInputFilter.Status.REJECTED;
});

Object obj = ois.readObject();
// 허용된 클래스만 로드

7.3 문제 2 — 성능

직렬화의 성능 문제:

1. 느림
   - JSON 보다 약 2-3배 느림
   - Protobuf 보다 10배+ 느림

2. 크기 큼
   - 클래스 메타데이터 포함
   - 필드 이름 등

3. GC 압박
   - 임시 객체 많이 생성

벤치마크 (예시):
  자바 직렬화: 100ms, 5KB
  JSON (Jackson): 30ms, 3KB
  Protobuf: 5ms, 1KB

7.4 문제 3 — 호환성

호환성 문제:

1. 같은 클래스 필수
   - 직렬화 시 / 역직렬화 시
   - 다른 JVM 에 같은 클래스 있어야

2. serialVersionUID 변경
   - 필드 추가/변경 시
   - 옛 데이터 못 읽음

3. 패키지 이동
   - 클래스 이동하면 호환 X

4. 다른 언어
   - 자바 외 X
   - 다른 시스템과 호환 X

7.5 문제 4 — 유연성 X

유연성 부족:

1. 형식 자바 고정
   - 사용자 정의 형식 X
   - 자바 표준 형식만

2. 스키마 X
   - JSON Schema, Protobuf .proto 같은
   - 형식 정의 어려움

3. 도구 부족
   - JSON 의 풍부한 도구 vs
   - 자바 직렬화 도구 미흡

7.6 문제 5 — 캡슐화 위반

캡슐화 위반:

  private 필드도 직렬화
  → 형식이 코드 구현에 강결합

영향:
  1. 리팩토링 어려움
     - 필드 이름 변경 = 호환성 깨짐
  
  2. 보안
     - private 정보 노출
  
  3. 유지보수
     - 형식 = 구현 = 변경 어려움

7.7 문제 6 — 디버깅 어려움

디버깅 어려움:

1. 바이너리 형식
   - 사람이 읽기 어려움
   - 전용 도구 필요

2. 에러 메시지 부족
   - StreamCorruptedException
   - 어느 위치인지 모름

3. 도구 부족
   - hex dump 정도

vs JSON:
  - 텍스트, 가독성
  - 풍부한 도구

7.8 Joshua Bloch 의 권고

Effective Java (3rd Edition):

  "자바 직렬화는 위험하며 피해야 한다."

  Item 85: Prefer alternatives to Java serialization
  
  Bloch 의 권고:
  - 새 시스템에 자바 직렬화 사용 X
  - JSON, Protobuf, Avro 등 권장
  - 레거시만 어쩔 수 없이 유지

7.9 Oracle 의 권고

JEP 290 (Java 9):
  - Object Filter 도입
  - 보안 강화

JEP 415 (Java 17):
  - Context-Specific Deserialization Filters

장기 계획:
  - 자바 직렬화 deprecation 검토 중
  - Project Amber 의 새 메커니즘

7.10 자기 점검 답변

직렬화의 6가지 문제는?

:
1. 보안 취약점: 임의 코드 실행 (CVE 다수)
2. 성능: JSON 의 2-3배 느림, Protobuf 의 10배+
3. 호환성: 같은 클래스 필수, 다른 언어 X
4. 유연성 X: 자바 표준 형식만
5. 캡슐화 위반: private 필드 노출
6. 디버깅 어려움: 바이너리, 도구 부족

Effective Java 권고: "Prefer alternatives to Java serialization"


8️⃣ 현대의 대안 (JSON, Protobuf)

8.1 대안 비교

형식가독성크기속도스키마언어
Java 직렬화XJava
JSON↑↑옵션모두
XML↑↑큼↑XSD모두
Protobuf작음↑↑↑필수 (.proto)모두
Avro작음↑↑필수 (JSON)모두
MessagePack작음↑↑X모두
Kryo (Java)작음↑↑XJava

8.2 JSON (Jackson)

// Jackson 의 활용
ObjectMapper mapper = new ObjectMapper();

// 직렬화
Shipment s = new Shipment(...);
String json = mapper.writeValueAsString(s);
// {"id":1,"blNo":"BL-001",...}

// 파일 저장
mapper.writeValue(Path.of("shipment.json").toFile(), s);

// 역직렬화
Shipment loaded = mapper.readValue(json, Shipment.class);
Shipment fromFile = mapper.readValue(
    Path.of("shipment.json").toFile(), Shipment.class);

// 컬렉션
List<Shipment> list = mapper.readValue(
    json, new TypeReference<List<Shipment>>(){});

// 장점:
// - 가독성
// - 모든 언어 호환
// - 도구 풍부
// - 스키마 옵션

8.3 Protobuf

// shipment.proto
syntax = "proto3";

package com.ilic;

message Shipment {
  int64 id = 1;
  string bl_no = 2;
  string consignee = 3;
  double weight = 4;
  int64 created_at = 5;
  ShipmentStatus status = 6;
}

enum ShipmentStatus {
  PENDING = 0;
  SHIPPED = 1;
  DELIVERED = 2;
}
// 자동 생성된 Java 코드
Shipment s = Shipment.newBuilder()
    .setId(1)
    .setBlNo("BL-001")
    .setWeight(100.5)
    .build();

// 직렬화 — 바이너리, 매우 작음
byte[] bytes = s.toByteArray();

// 역직렬화
Shipment loaded = Shipment.parseFrom(bytes);

// 장점:
// - 매우 작음 (vs JSON 10배 작음)
// - 매우 빠름
// - 스키마 강제 (.proto)
// - 모든 언어
// - 버전 관리 (필드 번호)

8.4 비교 시나리오

// Shipment 객체 (10개 필드)

// Java 직렬화
// - ~500 바이트
// - ~10ms

// JSON (Jackson)
// - ~200 바이트
// - ~3ms

// Protobuf
// - ~50 바이트
// - ~0.3ms

// 차이:
// - 크기: Protobuf < JSON < Java
// - 속도: Protobuf > JSON > Java
// - 가독성: JSON > Java > Protobuf

8.5 어느 것을 언제?

JSON 권장:
  ✓ REST API
  ✓ 설정 파일
  ✓ 사람이 봄
  ✓ 다양한 언어
  ✓ 가독성

Protobuf 권장:
  ✓ 마이크로서비스 (gRPC)
  ✓ 대량 데이터
  ✓ 성능 우선
  ✓ 스키마 강제
  ✓ 작은 메시지

Java 직렬화:
  ✗ 일반적으로 권장 X
  ✓ RMI (제한적)
  ✓ JCache, JMS (불가피)
  ✓ 레거시

8.6 마이그레이션 전략

// 자바 직렬화 → JSON
public class MigrationTool {
    
    private final ObjectMapper mapper = new ObjectMapper();
    
    // 옛 자바 직렬화 데이터를 JSON 으로
    public void migrate(Path source, Path dest) throws Exception {
        Object obj;
        try (ObjectInputStream ois = new ObjectInputStream(
                Files.newInputStream(source))) {
            obj = ois.readObject();
        }
        
        // JSON 으로
        mapper.writeValue(dest.toFile(), obj);
    }
}

// 단계적 전환:
// 1. JSON 직렬화 추가
// 2. 새 데이터는 JSON
// 3. 옛 데이터 마이그레이션
// 4. 자바 직렬화 코드 제거

8.7 ILIC 의 권장 아키텍처

// REST API → JSON (Jackson)
@RestController
public class ShipmentController {
    
    @GetMapping("/api/shipments/{id}")
    public Shipment get(@PathVariable Long id) {
        return service.findById(id);
        // Spring 이 자동으로 JSON 직렬화
    }
}

// 내부 마이크로서비스 → gRPC (Protobuf)
@GrpcService
public class ShipmentGrpcService extends ShipmentServiceGrpc.ShipmentServiceImplBase {
    
    @Override
    public void getShipment(GetShipmentRequest request, 
                          StreamObserver<ShipmentResponse> responseObserver) {
        // ...
    }
}

// 캐시 → JSON 또는 Protobuf
@Cacheable("shipments")
public Shipment findById(Long id) {
    // Redis 에 JSON 또는 byte[] 저장
}

// 메시지 큐 → Avro 또는 Protobuf
@KafkaListener(topics = "shipments")
public void handle(ShipmentEvent event) {
    // 스키마 보장
}

// 자바 직렬화 사용 안 함

8.8 자기 점검 답변

현대의 직렬화 대안은?

:
1. JSON (Jackson):

  • 가독성 ↑
  • 모든 언어
  • REST API 표준
  1. Protobuf:

    • 매우 작고 빠름
    • 스키마 강제 (.proto)
    • gRPC 와 함께
  2. Avro:

    • 스키마 (JSON)
    • Hadoop 생태계
  3. MessagePack:

    • JSON 의 바이너리
    • 빠름
  4. Kryo (Java):

    • 자바 전용
    • 빠른 직렬화
    • Spark 등

자바 직렬화는 일반적으로 권장 X


9️⃣ 면접 + 자기 점검

9.1 면접 단골 질문 매핑

Q핵심 답변
직렬화 정의?객체 ↔ 바이트
Serializable 의 역할?마커 인터페이스
마커 인터페이스?메서드 없이 능력 표시
ObjectOutputStream?writeObject 로 직렬화
객체 그래프?모든 참조 자동 추적
transient?직렬화 제외
transient 활용?비밀번호, 캐시, 자원
writeObject 커스텀?private, defaultWriteObject
순환 참조?자동 처리
같은 객체 참조?한 번만 저장
Java 직렬화의 문제?보안/성능/호환/유연/캡슐화/디버깅
대안?JSON, Protobuf
언제 사용?레거시, RMI, JCache
Externalizable?완전 수동 제어

9.2 자기 점검 체크리스트

정의

  • 직렬화의 정의와 목적
  • 5가지 활용 시나리오

Serializable

  • 마커 인터페이스
  • 구현 방법
  • 전체 그래프 요구

메커니즘

  • ObjectInputStream/OutputStream
  • writeObject / readObject
  • Object 캐스팅
  • ClassNotFoundException

객체 그래프

  • 자동 추적
  • 순환 참조
  • 같은 객체 한 번만
  • 깊은 복사 활용

transient

  • 의미와 활용
  • static 과 차이
  • 재초기화

커스텀

  • private writeObject/readObject
  • defaultWriteObject/defaultReadObject
  • writeReplace/readResolve

문제와 대안

  • 6가지 문제
  • JSON, Protobuf
  • 마이그레이션

9.3 추가 심화 질문

Q1: Serializable vs Externalizable?

답:

// Serializable — 자동
class A implements Serializable {
    private String data;
    // 자동 직렬화
}

// Externalizable — 완전 수동
class B implements Externalizable {
    private String data;
    
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeUTF(data);
    }
    
    public void readExternal(ObjectInput in) throws IOException {
        data = in.readUTF();
    }
    
    public B() { }   // public 기본 생성자 필수
}

// 차이:
// - Serializable: 자동 (편함)
// - Externalizable: 수동 (제어 강함, 빠름)
// - Externalizable 은 인기 X (사용 어려움)

Q2: 생성자가 호출되나?

답:

  • 일반 직렬화: 생성자 호출 X
  • JVM 의 내부 메커니즘 (Unsafe.allocateInstance)
  • 부모의 가장 가까운 비 Serializable 의 기본 생성자만 호출
  • Externalizable: public 기본 생성자 호출

Q3: serialVersionUID 자동 생성?

답:

  • 명시 안 하면 컴파일러가 자동 계산
  • 클래스의 모든 필드/메서드 해시
  • 작은 변경에도 다른 값
  • → 명시 권장 (다음 Unit 9.5)

Q4: enum 의 직렬화?

답:

  • enum 은 자동 Serializable
  • 단, name 만 직렬화 (필드 X)
  • 역직렬화 시 같은 enum 상수 반환
  • Singleton 처럼 동작

Q5: 직렬화의 final 필드?

답:

class A implements Serializable {
    private final String name;   // ✓ 직렬화 OK
    
    public A(String name) {
        this.name = name;
    }
}

// 역직렬화:
// - 생성자 호출 X
// - JVM 이 reflection 으로 final 필드 설정
// - final 의 약속 깨짐 (특수 케이스)

🎯 핵심 요약 — 3줄 정리

1. 자바 직렬화

  • Serializable 마커 인터페이스
  • ObjectInputStream/OutputStream
  • 객체 그래프 자동 추적

2. 핵심 키워드

  • transient: 직렬화 제외
  • writeObject/readObject: 커스텀
  • defaultWriteObject/defaultReadObject

3. 현대 권장

  • 6가지 문제 (보안/성능/호환/유연/캡슐화/디버깅)
  • JSON (Jackson) 또는 Protobuf 권장
  • Java 직렬화는 레거시만

📚 다음으로...

Unit 9.5 — serialVersionUID

이번 Unit에서 직렬화의 기초를 봤다면, 다음은 버전 관리의 핵심.

  • serialVersionUID 의 역할
  • 자동 vs 명시
  • 호환성 관리
  • 보안 고려

Phase 9 진행 상황

🚀 Phase 9 — I/O 강화
  ✅ Unit 9.1 try-with-resources
  ✅ Unit 9.2 BufferedInputStream / BufferedOutputStream
  ✅ Unit 9.3 DataInputStream / DataOutputStream
  ✅ Unit 9.4 Serialization (직렬화) ← 여기
  ⏭ Unit 9.5 serialVersionUID

3주차 누적 진행

✅ Phase 1 ~ 8 완주 (37 Unit)
🚀 Phase 9 — I/O 강화 (4/5 진행)

총: 41/43 Unit (약 95%)

profile
Software Developer

0개의 댓글