# 문자 인코딩

  • 프로그래밍 언어에서는 문자를 표현하기 위한 문자 집합이 필요하다.
  • 자바에서는 Charset.availableCharSets() 함수를 호출하여 이용 가능한 모든 문자 집합을 조회할 수 있다.
  • String.getByte(CharSet) 메서드를 사용하면 문자를 바이트 배열로 변경할 수 있다.
  • 현재 프로그래밍 체계에서는 UTF-8을 일반적으로 사용한다.
  • 문자집합에 없는 문자를 디코딩하려는 경우 깨진 결과를 볼 수 있다.

# 스트림

  • 자바 프로세스 데이터를 밖으로 내보내려면 출력 스트림을, 외부 데이터를 자바 프로세스로 가져오려면 입력 스트림을 사용한다.
public static void main(String[] args) throws IOException {
    FileOutputStream fos = new FileOutputStream("temp/hello.dat");
    fos.write(65);
    fos.write(66);
    fos.write(67);
    fos.close();

    FileInputStream fis = new FileInputStream("temp/hello.dat");
    System.out.println(fis.read());
    System.out.println(fis.read());
    System.out.println(fis.read());
    System.out.println(fis.read());
    fis.close();
}
  • new FileOutputStream("file.data"): 파일에 데이터를 출력하는 스트림이다.
    • 파일이 없으면 파일을 자동으로 만들고, 데이터를 해당 파일에 저장한다.
    • 폴더를 만들지는 않는다.
    • FileOutputStream("file.data", true)와 같이 append 옵션 파라미터를 추가할 수 있다.
      • true로 지정 시 기존 파일 끝에 이어서 쓴다.
  • write(): 바이트 단위로 값을 출력한다.
  • new FileInputStream("file.dat"): 파일에서 데이터를 읽어오는 스트림이다.
  • read(): 파일에서 바이트 단위로 값을 읽어온다.
    • EOF 도달시 -1을 반환한다.
  • close(): 파일에 접근하는 자바 외부 자원을 닫아준다. (반드시 호출 필요하다.)

byte[]을 사용하여 원하는 크기만큼 데이터 다루기

public static void main(String[] args) throws IOException {
    // 1. output
    FileOutputStream fos = new FileOutputStream(("temp/hello.dat"), true);
    byte[] input = {65,66,67};
    fos.write(input);
    fos.close();

    // 2. input
    FileInputStream fis = new FileInputStream("temp/hello.dat");
    byte[] buffer = new byte[10];
    int readCount = fis.read(buffer, 0, 10);

    System.out.println("readCount: " + readCount);
    System.out.println(Arrays.toString(buffer));

    fis.close();
}
  • 출력 스트림
    • write(byte[]): byte[]에 데이터를 담고 write()에 전달하여 데이터를 한번에 출력할 수 있다.
  • 입력 스트림
    • read(byte[], offset, length): byte[]를 미리 변수로 만들어두고 입력 스트림을 통해 데이터를 한번에 읽어올 수 있다.
      • offset: 데이터 입력을 시작할 byte[]의 인덱스
      • length: 읽어올 byte의 최대 길이
    • offset과 length를 생략할 수 있다. 생략 시 아래와 같이 디폴트 값이 사용된다.
      • offset: 0
      • length: byte[].length
    • readAllBytes를 호출하면 한번의 호출로 모든 데이터를 한 번에 읽어들일 수 있다.
  • 자바 데이터 입출력은 모두 바이트 단위로 이루어진다.
  • 데이터 I/O는 파일 저장소, 네트워크, 콘솔 등 다양한 경로에서 이루어질 수 있다.
    • 각 경로마다 모두 구현체가 달라지게 되면 코드의 변경이 커지게 된다.
  • 이 문제를 해결하기 위해 자바 InputStream, OutputStream이라는 기본 추상 클래스를 제공한다.
    • InputStream: read(), read(byte[]), readAllBytes()
      • FileInputStream: 파일 스트림
      • ByteArrayInputStream: 메모리 스트림
      • SocketInputStream
    • OutputStream: write(int), write(byte[])
      • FileOutputStream: 파일 스트림
      • ByteArrayOutputStream: 메모리 스트림
      • SocketOutputStream
  • ByteArray..Stream을 사용하면 메모리에 스트림을 읽고 쓸수 있다.
    • 메모리에 데이터를 읽고 쓸때는 배열 및 컬렉션을 사용하면 되기 때문에 거의 사용하지 않는다.
  • PrintStream은 콘솔 출력 스트림이다.
    • System.out이 PrintStream이다.
    • 위 스트림은 자바가 시작될때 자동으로 만들어진다.

# 파일 입출력 최적화

public static void main(String[] args) throws IOException {
    FileOutputStream fos = new FileOutputStream(FILE_NAME);
    long startTime = System.currentTimeMillis();

    for (int i = 0; i < FILE_SIZE; i++) {
        fos.write(1);
    }
    fos.close();

    long endTime = System.currentTimeMillis();
    System.out.println(endTime - startTime);
}
  • fos.write(1) 코드를 FILE_SIZE만큼 반복하며 호출하는데, 1바이트 데이터를 계속해서 스트림을 통해 출력하는 것을 의미한다. 이는 부하가 큰 방식이다.
    • write, read는 호출할때마다 OS 시스템 콜을 통해 파일을 읽거나 쓰는 명령어를 전달한다.
    • 하드디스크 쓰기 읽기 작업도 부하가 존재한다.
    • 물론 내부적으로 시스템 레벨의 최적화가 있긴 하지만, 방식 자체가 부하가 큰 것은 사실이다.
  • 최적화를 위해서는 더 많은 양의 데이터를 한번에 입출력해야한다.
public static void main(String[] args) throws IOException {
    FileOutputStream fos = new FileOutputStream(FILE_NAME);
    long startTime = System.currentTimeMillis();

    byte[] buffer = new byte[BUFFER_SIZE];
    int bufferIndex = 0;

    for (int i = 0; i < FILE_SIZE; i++) {
        buffer[bufferIndex++] = 1;

        if (bufferIndex == BUFFER_SIZE) {
            fos.write(buffer);
            bufferIndex = 0;
        }
    }

    if (bufferIndex > 0) {
        fos.write(buffer, 0, bufferIndex);
    }

    fos.close();

    long endTime = System.currentTimeMillis();
    System.out.println(endTime - startTime);
}
  • 위 코드에서 BUFFER_SIZE만큼 데이터를 쌓은 뒤 버퍼가 꽉찰때마다 출력하는 구조로 코드를 변경했다.
  • 성능이 매우 크게 개선되는 것을 볼 수 있다.
    • 버퍼 크기가 무한정 크다고 해서 성능이 좋아지는 것은 아니다.
    • 디스크 및 파일 시스템에서 데이터를 읽고 쓰는 기본 단위가 보통 4KB / 8KB이기 때문이다.

BufferedOutputStream

  • BufferedOutputStream은 버퍼 기능을 내부에서 대신 처리해준다.
public static void main(String[] args) throws IOException {
    FileOutputStream fos = new FileOutputStream(FILE_NAME);
    BufferedOutputStream bos = new BufferedOutputStream(fos, BUFFER_SIZE);
    long startTime = System.currentTimeMillis();

    for (int i = 0; i < FILE_SIZE; i++) {
        bos.write(1);
    }
    bos.close();

    long endTime = System.currentTimeMillis();
    System.out.println(endTime - startTime);
}
  • 아웃풋 스트림 객체를 생성자에 전달한다.
  • 버퍼 크기도 지정할 수 있다.
  • byte[]직접 다룰 필요가 없다.
  • 버퍼가 가득차면 내부적으로 버퍼 내용을 비우고, 다시 채우기 시작한다.
  • flush 메서드를 사용하면 버퍼가 다 차지 않아도 출력이 가능하다.
  • 내부에 데이터가 남아있는데 close호출하는 경우 내부적으로 flush를 호출하여 출력을 진행한 뒤 자원을 반납한다.
  • 읽기 연산의 경우 다음과 같이 동작한다.
    • FileInputStream에서 데이터를 BUFFER SIZE만큼 읽어들인 뒤 버퍼를 최대한 꽉 채워 저장해둔다.
    • 1바이트 단위로 데이터를 읽어 자바 프로세스로 전달한다.
    • 버퍼가 비게 되면 다시 FileInputStream에서 새러 받아온다.
  • Buffered 스트림은 데이터 동기화가 내부적으로 처리되어 있어 직접 다루는 것보다 성능이 떨어진다.

# 문자 다루기

  • 스트림 시 모든 데이터는 byte 단위를 사용한다. 따라서 바이트가 아닌 문자를 스트림에 직접 전달할 수 없다.
  • String문자를 스트림을 통해 파일에 저장하려면 byte로 변환한 뒤 저장해야 한다.
    • 문자열.getBytes(UTF_8) 메서드를 호출하여 바이트로 변경한다.
  • 입출력간 데이터들을 바이트로 변환해줘야 하는 과정을 StreamWriter가 해준다.
    • OutputStreamWriter: 스트림에 byte대신 문자를 저장할 수 있게 지원한다.
      • 문자를 입력받고, 받은 문자를 인코딩하여 byte[]로 변환한다.
      • 변환한 바이트 데이터를 전달하기 위해 인코딩 문자 집합도 필요하다.
    • InputStreamWriter: 스트림에 byte 대신 문자를 읽을 수 있게 지원한다.
      • 인풋 스트림과 문자 집합이 필요하다.
      • 스트림에서 byte[]로 데이터를 먼저 읽어들이고, InputStreamReader에서 바이트 데이터를 char로 변환한다.
      • 반환값 자체는 EOF 표현을 위해 int이며, 문자로 사용하려면 타입 변환을 하면 된다.
  • 스트림은 기본적으로 바이트 단위로 데이터를 읽고 쓴다. write 함수의 파라미터 역시 byte 데이터들을 전달했다.
  • 반면 StreamWriter의 write 함수는 String이나 char를 사용한다.
  • 위의 이유는 byte를 다루는 클래스와 문자를 다루는 클래스가 구분되어 있기 때문이다.
    • 문자를 직접 다루는 부모 클래스는 Writer, Reader이다.
    • 문자를 직접 다룬다 하여 내부 데이터도 문자 자체가 저장되는 것은 아니다.
  • FileWriter, FileReader를 사용하면 기존에 파일 읽고 쓰기 시 바이트 단위로 처리했던 코드가 간결해진다.
    • Filewriter, FileReader는 OuputStreamWriterInputStreamWriter의 자식 클래스이다.
    • 내부적으로 FileOutputStream, FileInputStream을 생성해주어 조금이나마 편리하게 사용하게 해주는 역할을 한다.
  • BufferedReader, BufferedReader는 한줄단위로 데이터를 읽어들인다.
    • 내부에서 버퍼 처리를 자동으로 해준다.

try-with-resource

  • Writer, Reader 사용시 try-with-resource 구문을 사용하면 명시적인 close 함수 호출 없이도 try 블럭이 끝나면 자원을 정리해준다.
try (BufferWriter bw = new BufferedWriter(new FileWriter(FILE_PATH, UTF_8, true))) {
    // ...
}
  • 인라인 형태로 작성하면 된다.

# DataInputStream

  • DataStream은 구분자 및 라인없이 데이터를 저장하고 조회할 수 있다.
dataOutputStream.writeUTF("UTF데이터_쓰기");
dataInputStream.readUTF(); // 문자열 읽기
  • writeUTF 메서드는 UTF-8로 문자를 저장하는데, 저장 시 2byte를 추가로 사용하여 글자 길이를 저장한다.
  • 기타 타입들은 각 타입들의 사용 바이트 규격에 맞춰 데이터를 읽어들인다.
  • write과 read의 읽어들이는 데이터 순서 쌍이 동일해야 한다.
    • writeUTF & writeInt를 했는데 readInt부터 호출하게 되면 데이터 파싱 및 읽기가 실패한다.

# ObjectStream

  • ObjectStream을 사용하면 메모리에 보관되어 있는 인스턴스를 파일에 편리하게 저장할 수 있다.
  • 자바 객체 직렬화를 사용하면, 객체 인스턴스를 바이트 스트림으로 변환하여 파일에 저장하거나 네트워크를 통해 전송할 수 있다.
  • 객체 직렬화를 하려면 클래스는 반드시 Serializable 인터페이스를 구현해야 한다.
    • 구현해야할 특정 기능은 없다.
    • 직렬화 가능한 클래스라는 것을 표시하기 위한 목적이다.
    • 표시가 목적인 인터페이스를 마커 인터페이스라 한다.
public class ObjectRepositoryImpl implements MemberRepository {
    @Override
    public void add(Member member) {
        List<Member> members = findAll();
        members.add(member);

        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("members-object.dat"))) {
            oos.writeObject(members);
        } catch (IOException e) {
        }

    }

    @Override
    public List<Member> findAll() {
        try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("members-object.dat"))) {
            Object objects = ois.readObject();
            return (List<Member>) objects;
        } catch (IOException | ClassNotFoundException e) {
            return new ArrayList<>();
        }
    }
}
  • 비어있을때 List.of를 리턴하면 불변 리스트를 반환하기 때문에 new ArrayList를 반환해야 한다.
  • writeObject로 객체 쓰기를 하고, readObject로 객체 읽기가 가능하다.
  • ClassNotFoundException도 잡아야 하는 예외이다.
    • 현대에는 객체 직렬화를 거의 사용하지 않는다.
      • 버전 관리가 어렵다.
      • 자바 플랫폼에 종속적이다.
      • 직렬화 및 역직렬화가 성능을 저하시킨다.
      • 유연하지 않다.
      • 크기가 상대적으로 크다.
  • 이러한 한계점으로 현대에는 JSON을 주로 사용한다.

# File / Files

  • 자바에서 파일 또는 디렉토리를 다룰 때는 File / Files / Path 클래스를 사용하면 된다.
  • 해당 클래스들로 파일이나 폴더 삭제 및 생성, 조회가 가능하다
  • 다양한 메서드들이 존재한다.
    • new File("directory/file.dat") 생성자에 경로를 지정하면 파일을 생성할 수 있다.
    • 확장자를 지정하지 않고 mkdir() 메서드를 호출하면 디렉토리로 생성된다.
    • file.createNewFile(): 파일 생성, 생성 성공 시 true반환
    • file.isDirectory(), file.isFile(): 디렉토리 여부, 파일 여부 반환
    • file.getName(): 파일명 반환
    • originalFile.renameTo(File newFile): 기존 파일명을 동일 디렉토리에서 변경하거나, 다른 디렉토리로 이동
    • file.lastModified(): 마지막 수정시각 반환
  • Files, Path는 자바 1.7 이후 File을 대체하기 위해 생겨났다.
    • FileInputStream과 같이 파일과 관련된 스트림 사용 전에 Files에 있는 기능을 찾아보는게 더 권장된다.
    • 많은 유틸 함수들을 제공한다.
    • Files.writeString(path, writeString, UTF_8), Files.readString(path, UTF_8)등으로 쉽게 읽고 쓸수있다.
    • List<String> lines = Files.readAllLines(path, UTF_8) 함수 호출로 라인별로 파일을 읽을 수 있다.
  • 파일 복사시 InputStream에는 transferTo라는 메서드가 있다.
    • 해당 메서드는 읽은 데이터를 바로 OutputStream으로 출력한다.
    • 성능 최적화가 되어있어 자바 프로세스 메모리에 바이트단위로 직접 복사하는 것보다 조금 더 빠를 수 있다.
    • 코드도 inputStream.transferTo(outputStream)이면 끝이기 때문에 속도가 빠르다.
    • Files에서 제공하는 Files.copy(Path source, Path target, StandardCopyOption option)메서드를 사용하면 자바 프로세스 메모리에 바이트를 적재하지 않고, 즉시 파일끼리 복사 작업을 처리하게 된다.
      • 운영체제 자체의 파일 복사 기능을 사용하기 때문에 속도가 가장 빠르다.

# 네트워크

  • 클라이언트가 요청을 보내고 서버가 그 요청을 처리하여 응답을 돌려주는 모델을 클라이언트-서버 모델이라고 한다.
  • 자바 Main 객체가 서비스 클래스에 메서드 호출로 요청을 하고, 그에 대한 작업을 수행하는 것도 마찬가지로 클라이언트-서버 모델이다. (타입이 void여도 상관없다.)
  • 클라이언트와 동시에 서버가 될 수 있다.

# 인터넷 프로토콜

  • IP 프로토콜 기반으로 클라이언트와 서버가 통신하는 과정을 정리한다.
    1. 클라이언트와 서버 사이에는 인터넷이 존재한다.
    2. 매우 복잡한 노드들로 구성되어 있다.
    3. 서로를 찾아가기 위한 식별자로 IP주소가 각각 부여된다.
    4. IP 인터넷 프로토콜은 지정한 IP주소로, 패킷이라는 통신 단위로 데이터를 전달하게 해준다.
  • IP 프로토콜은 한계가 존재한다.
    1. 비연결성: 패킷을 받을 대상이 없거나 서비스 불능이어도 패킷을 전송한다.
    2. 비신뢰성: 중간에 패킷이 소실되거나 패킷 송수신 순서를 보장하지 못한다.
    3. 프로그램 구분: 동일 IP를 사용하는 서버에서 통신하는 애플리케이션이 둘 이상일 수 있다.
  • 인터넷 프로토콜 스택 4계층
    1. 애플리케이션 계층: HTTP, FTP, DNS, TLS
    2. 전송 계층: TCP, UDP
    3. 인터넷 계층: IP
    4. 네트워크 인터페이스 계층: LAN 장비, Wi-Fi, Ethernet
  • 메시지를 특정 목적지로 전달하는 것을 구현 측면에서 살펴보면 아래와 같다.
    1. App → HTTP 메시지 생성
    2. Socket API → 커널(OS)로 전달
    3. TCP 헤더 추가 → 세그먼트(Segment)
      • 출발지 PORT, 목적지 PORT
      • 전송 제어, 순서, 검증 정보 등 포함
    4. IP 헤더 추가 → 패킷(Packet)
      • 출발지 IP, 목적지 IP
    5. Ethernet 헤더 추가 → 프레임(Frame)
      • 기기 자체 맥주소 등 포함
    6. Network Interface Card → 물리 신호로 송출 (0과 1의 물리신호)
    7. 인터넷 망(라우터 경유) → 목적지 도달

# TCP(Transmission Control Protocol) 특징

  • TCP 3 way handshake 기반으로 연결 지향으로 처리
    1. SYN(synchronize): 클라이언트에서 서버로 연결요청
    2. SYN+ACK(acknowledgement): 서버에서 클라이언트로 연결 가능 응답
    3. ACK: 클라이언트에서 서버로 연결 처리
    4. 데이터 전송
  • 데이터 전달 보증
    • 데이터 전송 시 서버에서 데이터 수신에 대한 성공 여부를 응답으로 보낸다.
  • 순서 보장
    • 순서가 꼬인 패킷부터 다시 보내라고 클라이언트에 응답한다.
  • 신뢰 가능한 프로토콜
  • 클라이언트 서버 사이의 연결은 물리적 실제 연결이 아닌 개념적 연결이다.

# UDP(User Datagram Protocol) 특징

  • 기능이 거의 없음
  • 연결지향 아님
  • 데이터 전달 보증 안함.
  • 순서 보장 안함
  • 속도가 빠름
  • IP와 거의 유사하고, PORT 및 checksum 정도만 추가된다.
    • 애플리케이션 레벨에서 추가 작업이 필요하다.
    • 체크섬: 데이터가 제대로 왔는지 검증하는 데이터

포트

  • 포트는 동일 IP에서 실행중인 서로 다른 애플리케이션에 데이터를 전달할때 사용한다.
  • 같은 IP 내에서 프로세스를 구분할때 사용한다.

# PORT

  • 0~65535 범위에서 할당 가능
  • 0~1023은 잘 알려진 포트로 사용하지 않는게 좋다.
    • FTP: 20, 21
    • TELNET: 23
    • HTTP: 80
    • HTTPS: 443

# DNS (Domain Name System)

  • IP는 기억하기 어렵고 변경될 수 있다.
  • 도메인 명을 IP 주소로 변환하여 사용하기 위한 시스템이 DNS이다.
    1. 도메인 명으로 DNS 서버에 요청한다.
    2. DNS 서버가 매핑된 IP 주소를 응답해준다.
    3. 클라이언트가 해당 IP로 접속한다.

# 네트워크 프로그램 작성

  • 아래는 클라이언트 코드다.
    1. localhost는 IP가 아니므로 내부에서 InetAddress를 통해 IP를 조회한다.
    2. 호스트 파일에 저장되어 있으므로 127.0.0.1 주소를 반환받고 PORT변수값의 포트 번호를 덧붙여 접속을 시도한다.
    3. 연결이 완료되면 Socket객체를 반환한다.
    4. 해당 객체는 서버와 연결된 연결점이라고 보면 된다.
    5. 소켓 객체를 통해 서버와 통신하게 된다.
public static void main(String[] args) throws IOException {
    log("클라이언트 시작");
    Socket socket = new Socket("localhost", PORT);
    DataInputStream input = new DataInputStream(socket.getInputStream());
    DataOutputStream output = new DataOutputStream(socket.getOutputStream());

    String toSend = "Hello";
    output.writeUTF(toSend);

    String received = input.readUTF();
    System.out.println(received);

    input.close();
    output.close();
    socket.close();
}
  • 아래는 서버 코드다.
    • 클라이언트 서버 통신은 소켓 객체가 제공하는 스트림을 사용하면 된다.
    • 클라이언트 OutputStream -> 서버 InputStream
    • 서버 OutputStream -> 클라이언트 InputStream
  • 서버는 서버 소켓(ServerSocket)이라는 특별한 소켓을 사용해야 한다.
    1. 지정한 포트로 서버 소켓을 열어둔다.
    2. 클라이언트에서 해당 포트에 연결을 시도한다.
    3. OS 계층에서 TCP 3way hanshake가 이루어진다.
    4. TCP 연결이 완료되면 OS backlog queue라는 곳에 클라이언트와 서버 사이의 TCP 연결 정보를 보관한다.
      • 해당 연결정보에 IP, PORT 정보들이 들어있다.
  • 클라이언트는 요청시 포트를 명시적으로 지정할 필요가진 없다.
    • 지정하지 않는 경우 랜덤 포트가 할당된다.
  • serverSocket.accept(): 서버 소켓은 단순히 클라이언트와 서버의 TCP 연결만 지원하는 특별한 소켓이다.
    • 실제 클라이언트 서버 사이의 데이터 통신을 위해서는 Socket객체가 필요하다.
    • accept()메서드를 호출하면 TCP 연결정보를 기반으로 Socket객체를 생성한다.
  • accept호출시 백로그 큐에서 TCP 연결 정보를 조회한다.
    • 연결 정보가 없는 경우 해당 정보가 생성될때까지 블로킹하며 대기한다.
    • 해당 정보를 기반으로 Socket 객체를 생성한다.
    • 사용한 TCP 정보는 큐에서 제거된다.
public static void main(String[] args) throws IOException {
    ServerSocket serverSocket = new ServerSocket(PORT);

    Socket socket = serverSocket.accept();
    DataInputStream input = new DataInputStream(socket.getInputStream());
    DataOutputStream output = new DataOutputStream(socket.getOutputStream());

    String received = input.readUTF();
    String toSend = received + " World";
    output.writeUTF(toSend);

    input.close();
    output.close();
    socket.close();
}
  • 서버 프로그램을 먼저 실행해야 한다.

localhost, 127.0.0.1

  • 현재 사용중인 컴퓨터 자체를 가리키는 호스트 이름이다.
    • 127.0.0.1이라는 IP로 매핑된다.
    • 위 주소는 IP 주소 체계에서 루프백 주소로 지정된 IP 주소이다.
    • 컴퓨터가 스스로를 가리킬때 사용된다.
  • 자바에서는 InetAddress 클래스를 사용하면 호스트명으로 IP 주소를 찾을 수 있다.
    • InetAddress.getByName("호스트명")
    • DNS 조회 전, 시스템의 로컬 호스트 파일을 먼저 조회한다.
    • 호스트파일에 해당 호스트가 없으면 DNS에 요청하여 IP주소를 얻는다.
    • https://parkjju.github.io/vue-TIL/라는 URL이 있으면, parkjju.github.io가 호스트 명에 해당된다.
# hosts 파일 예시
127.0.0.1       localhost
192.168.1.10    my-server.local
93.184.216.34   example.com

# 여러 클라이언트 연결 처리하기

  • 서버는 하나의 클라이언트가 아닌 여러 클라이언트 연결을 처리해야 한다.
  • 서로 다른 포트의 클라이언트로부터 서버로 요청이 이루어진다.
  • 두 클라이언트에 대해 모두 TCP 3way handshake가 이루어지고 OS backlog queue에 각 클라이언트에 대해 연결정보가 보관된다.
  • Socket.accept()함수를 호출하여 큐 정보를 기반으로 소켓 객체를 생성한다.
    • 큐 정보에서 추출된 연결 정보를 기반으로 해당 클라이언트와 스트림을 통해 데이터를 송수신한다.
    • 이때 나머지 하나 클라이언트에서는 메세지 송수신이 안되는 것으로 보인다.
  • 그 이유는 다음과 같다.
    1. 클라이언트가 메시지를 서버에 전송하는 경우 다음과 같은 흐름이 존재한다.
      • 애플리케이션 -> OS TCP 송신 버퍼 -> 네트워크 인터페이스
      • 서버 네트워크 인터페이스 -> OC TCP 수신 버퍼 -> 애플리케이션
    2. 이때 나머지 클라이언트에서 송신한 메시지는 서버 애플리케이션에서 아직 읽지 않아 서버 TCP 수신 버퍼에서 대기한다.
    3. 소켓 객체 없이 서버 소켓만으로도 TCP 연결은 완료된다는 점을 기억하자.
    4. 연결 이후 서로 메시지를 주고 받기 위해서는 소켓 객체가 필요하다.
  • aceept 메서드는 새로운 연결 정보가 도착할때까지 블로킹 상태로 대기한다.
  • 여러 클라이언트 연결을 처리하기 위해서는 서버쪽에서 새로운 연결이 있을때마다 Session객체와 별도 스레드를 생성하고, 새로운 스레드에서 해당 객체를 실행하면 된다.
public static void main(String[] args) throws IOException {
    log("서버 시작");
    ServerSocket serverSocket = new ServerSocket(PORT);
    log("서버 소켓 시작 - 리스닝 포트: " + PORT);

    while (true) {
        Socket socket = serverSocket.accept(); // 블로킹
        log("소켓 연결: " + socket);

        SessionV3 session = new SessionV3(socket);
        Thread thread = new Thread(session);
        thread.start();
    }
}
  • 메인스레드에서 serverSocket.accept를 무한루프로 호출하더라도 해당 함수를 호출하는 곳에서 블로킹이 이루어진다.
  • 새 연결이 감지되면 블로킹이 해제되고 새 세션을 생성하여 새로운 스레드에서 데이터 송수신을 처리하게 된다.

# 자원 정리

  • 클라이언트 연결을 직접 종료하면 클라이언트 프로세스가 종료되면서 클라이언트 <-> 서버 TCP 연결도 종료된다.
  • 이때 readUTF로 서버에서 클라이언트 메시지를 읽으려 하면 EOFException이 발생한다.
    • 위와 같은 예외가 발생하게 되면 자원 정리 코드를 호출하지 못한다.
  • 서버는 프로세스가 계속 살아 실행되어야 하기 때문에 자원 사용 후에는 반드시 정리해야 한다.
  • try-catch-finally 구문으로 자원 정리가 반드시 이루어지도록 코드를 작성해야 한다.
    • finally에서 자원 정리를 할때 만약 예외가 발생한다면 finally 내에서 해당 예외를 잡아 처리하도록 해야한다.
    • 위 구문 역시 close코드를 명시적으로 호출하지 않을 여지가 존재한다.
  • try-with-resource 구문을 활용하여 자원을 정리하는 것이 가장 좋다.
  • 자원 클래스에서 AutoCloseable 인터페이스를 구현해야 한다.
    • close 메서드를 오버라이딩 해야한다.
    • 자원 정리 과정에서 해당 메서드가 자동으로 호출될것인데, 이때 예외를 던졌다고 가정해보자.
    • try-catch-resource구문에서 해당 예외를 잡아 처리를 하게 된다.
private static void logic() throws CallException, CloseException {
    try (ResourceV2 resource1 = new ResourceV2("resource1");
         ResourceV2 resource2 = new ResourceV2("resource2")) {

        resource1.call();
        resource2.callEx(); // CallException
    } catch (CallException e) {
        System.out.println("ex: " + e);
        throw e;
    }
}
  • 위 구문은 아래 문제들을 해결한다.
    1. AutoCloseable 기반으로 close를 자동으로 호출해주어 자원 정리 코드를 호출하지 않을 휴먼에러를 제거한다.
    2. close 과정에서 발생하는 예외를 잡아 처리하여, 다른 자원들을 무사히 닫을 수 있게 한다.
    3. close 호출 순서를 잘못 작성할 여지를 방지한다.
    4. try-catch-finally를 거쳐 catch이후 자원 반납을 하는 것이 아닌 try 구문 내에서 자원 반납을 마친다.
  • try 로직 예외와 자원정리 예외가 동시에 발생하는 경우
    • Suppressed라는 이름으로 부가 예외들을 담아준다.
private static void logic() throws CallException, CloseException {
    try (ResourceV2 resource1 = new ResourceV2("resource1");
         ResourceV2 resource2 = new ResourceV2("resource2")) {

        resource1.call();
        resource2.callEx(); // CallException
    } catch (CallException e) {
        System.out.println("ex: " + e);
        throw e;
    }
}
  • 위 코드에서 resource2를 생성하는 과정에서 예외가 발생하는 경우 resource1의 자원은 닫고 catch로 넘어간다.
  • 만약 resource2에서 예외가 발생하여 resource1을 close하는데, 이때 예외가 발생하는 경우
    • e.suppressed에 close 과정의 예외가 담긴다. (부가 예외)
    • resource2 생성 예외가 주 예외가 된다.
System.out.println("주 예외: " + e.getMessage());

for (Throwable t : e.getSuppressed()) {
    System.out.println("Suppressed: " + t.getMessage());
}
  • 주 예외, 부가 예외는 위와 같이 출력한다.

# 서버 종료

  • 자바 서버를 종료할 때 서버 소켓과 연결된 모든 소켓 자원을 다 반납한 뒤, 안정적으로 종료하는 방법이 필요하다.
  • 셧다운 훅(Shutdown Hook)
    • 자바에서는 프로세스 종료시 자원 정리나 로그 기록과 같이 종료 작업을 마무리 할 수 있는 셧다운 훅 기능을 지원한다.
    • 프로세스 종료는 크게 두가지로 분류 가능하다.
      • 정상 종료
        • 모든 non 데몬 스레드 실행 완료로 프로세스 종료
        • Ctrl+C를 눌러 프로그램 중단
        • kill 명령어 전달 (kill -9는 제외)
          • kill -9는 OS 커널이 직접 프로세스를 종료하는 옵션임.
          • kill 명령어만 전달하는 경우 프로세스가 직접 graceful shutdown을 수행함
        • 인텔리제이 stop버튼
      • 강제 종료
        • kill -9
        • 운영체제에서 프로세스를 더 이상 유지할 수 없다고 판단할때 사용
    • 정상 종료는 셧다운 훅이 작동하여 프로세스 종료 전 필요한 후처리가 가능하다.
    • 셧다운 훅 구현시 모든 세션들을 찾아서 종료해야 한다.
    • 따라서 세션들을 보관하고 관리할 매니저 객체가 필요하다.
public class SessionManagerV6 {

    private List<SessionV6> sessions = new ArrayList<>();

    public synchronized void add(SessionV6 session) {
        sessions.add(session);
    }

    public synchronized void remove(SessionV6 session) {
        sessions.remove(session);
    }

    public synchronized void closeAll() {
        for (SessionV6 session : sessions) {
            session.close();
        }
        sessions.clear();
    }
}
  • try-with-resources 구문은 try 가 끝나는 시점에 즉시 자원을 정리한다.
  • try에서 자원 선언과 정리를 묶어 처리할때 사용 가능하다.
  • 만약 try 구문 뿐만 아니라, 프로세스를 종료하는 시점에도 동일하게 자원을 정리하고 싶은 경우에는 try-with-resources를 통해 자원을 정리할 수 없게 된다.
    • try 구문은 지역변수 기반으로 수행되기 때문에 세션 매니저 멤버를 활용해야 한다.
  • try 구문 내에서도, 셧다운 훅 내에서도 자원 정리 코드가 중복으로 호출될 수 있기 때문에 synchronized 키워드를 사용해야 한다.
public static void main(String[] args) throws IOException {
    log("서버 시작");
    SessionManagerV6 sessionManager = new SessionManagerV6();
    ServerSocket serverSocket = new ServerSocket(PORT);
    log("서버 소켓 시작 - 리스닝 포트: " + PORT);

    // ShutdownHook 등록
    ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
    Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));
    //...
}
  • 셧다운 훅 등록은 위와 같이 서버 소켓과 세션 매니저를 생성자에 전달해야 한다.
  • 이후 런타임에 해당 셧다운 훅 객체를 등록해야 한다.
static class ShutdownHook implements Runnable {
    private final ServerSocket serverSocket;
    private final SessionManagerV6 sessionManager;

    public ShutdownHook(ServerSocket serverSocket, SessionManagerV6 sessionManager) {
        this.serverSocket = serverSocket;
        this.sessionManager = sessionManager;
    }

    @Override
    public void run() {
        log("shutdownHook 실행");
        try {
            sessionManager.closeAll();
            serverSocket.close();

            Thread.sleep(1000); // 자원 정리 대기
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("e = " + e);
        }

    }
}
  • 서버 소켓을 닫고, 세션 매니저가 관리하는 모든 자원들을 정리해준다.
  • non 데몬 스레드 실행이 완료되면 자바 프로세스가 정상 종료되지만, Ctrl + C, kill, intelliJ STOP의 종료들은 non 데몬 스레드 종료 여부와 상관없이 프로세스를 종료한다.
    • 다만 셧다운 훅 실행 종료까지는 기다려준다.
    • 각 세션에서 read / write 작업간에 지연이 발생할 수 있다.
    • graceful shutdown을 위해 synchronized 함수 내에서 close 호출이 된다.
    • Thread.sleep보다는 join을 통해 모든 락 해제가 완료되었을때 shutdown 훅이 호출되도록 하는것이 더 안전하다.
public static void main(String[] args) throws IOException {
    long start = System.currentTimeMillis();

    try {
        Socket socket = new Socket();
        socket.connect(new InetSocketAddress("192.168.1.250", 45678), 1000);
    } catch (SocketTimeoutException e) {
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("end = " + (end - start));
}
  • 소켓 객체 생성시 객체만 생성한 뒤 IP, 포트 지정을 나중에 할 수 있다.
  • 소켓 객체 생성 파라미터에 포트와 IP 주소를 지정하면 즉시 연결을 시도하는데, 생성자에 빈 파라미터를 전달하고 connect 메서드를 활용하면 연결 시점을 조절할 수 있다.
    • connect 메서드에는 timeout 파라미터도 전달할 수 있어서, 연결 타임아웃도 지정 가능하다.
  • 클라이언트가 서버에 요청을 했는데도 서버가 응답을 계속 주지 않는 경우도 존재한다. (서버 폭주 등)
    • 이때 소켓 타임아웃을 사용하면 된다.
    • socket.setSoTimeout(timeout);
    • 해당 타임아웃 시간만큼 지나면 read time out 예외가 발생한다.
  • 외부 서버와의 소켓 통신 시 소켓 타임아웃을 반드시 설정해야 한다.

# TCP 연결 해제

  • TCP 기반으로 A,B가 서로 통신한다.
  • TCP 연결 종료를 위해서는 서로 FIN 메시지를 주고 받아야 한다.
    • A -> B: FIN 메시지를 보낸다.
    • B -> A: FIN을 받은 B도 A에게 FIN을 보낸다.
  • socket.close()를 호출하면 TCP에서 FIN 메시지를 상대에게 보낸다.
  • FIN을 받은 상대방도 socket.close()를 호출하여 FIN을 다시 보내야 한다.
  • 과정을 자세히 정리해보면 다음과 같다.
    1. 서버가 연결 종료를 위해 socket.close()를 호출한다.
      • 서버가 클라이언트에 FIN을 전송한다.
    2. 클라이언트는 FIN을 받고, OS에서 FIN + ACK를 서버에 보낸다.
      • 서버가 FIN을 보냈더라도, 클라이언트는 아직 전송할 데이터가 남아있을 수 있기 때문에 일단 ACK으로 수신만 확인하고, 데이터를 다 보낸 후에 FIN을 따로 보낸다.
    3. 클라이언트도 연결 종료를 위해 socket.close()를 호출한다.
      • 클라이언트가 서버에 FIN을 전송한다.
    4. 서버 OS는 ACK를 클라이언트에 보낸다.
  • 클라이언트에서 데이터를 읽어들이는 메서드에 따라 EOF 해석이 다르다.
    • InputStrea read(): 1byte 단위로 데이터를 읽음 (-1)
    • BufferedReader().readLine(): 라인 단위로 String을 읽음 (null)
    • DataInputStream.readUTF(): DataInpuStream을 통해 String 단위로 데이터를 읽음 (EOFException 발생)
    • 위와 같이 EOF발생 시 읽어들일 데이터가 더 없다는 의미이므로 소켓 close 메서드를 호출하여 서버에 FIN 메시지를 전달해야 한다.
  • 만약 서버와의 TCP 연결에 문제가 생긴 경우 서버로부터 RST(Reset) 패킷이 전달된다.
    • 이 경우 해당 연결을 사용해서는 안된다는 의미이다.
    • read로 읽는 경우 Connection Reset
    • write로 쓰는 경우 Broken pipe 예외를 던진다.

# HTTP 서버 생성

public void start() throws IOException {
    ServerSocket serverSocket = new ServerSocket(port);
    log("서버 시작 port: " + port);

    while (true) {
        Socket socket = serverSocket.accept();
        es.submit(new HttpRequestHandlerV2(socket));
    }
}
  • 서버소켓 accept를 통해 클라이언트 요청에 대한 연결을 맺고 응답을 전송할 수 있다.

퍼센트(%) 인코딩

  • 한글을 UTF-8 인코딩으로 표현하면 한 글자당 3바이트 데이터를 사용한다.
  • URL은 아스키 코드만 입력하도록 표준이 정해져있다.
  • 위와같은 이유로 한글 각 바이트를 16진수로 표현하고 앞에 %를 붙이는 것으로 방식이 정해졌다.
    • 퍼센티지 뒤의 두글자는 반드시 16진수라는 것이 약속이다.
    • 만약 뒤 두글자가 16진수 형식에 맞지 않으면 스펙 위반에 해당한다.

WAS

  • WAS는 Web Application Server의 준말로, 웹서버의 역할을 하면서 프로그램 코드도 수행할 수 있는 서버이다.
  • 웹(HTTP)을 기반으로 작동하는 서버인데 해당 서버를 통해 프로그램 코드도 실행할 수 있는 서버를 말한다.

서블릿(Servlet)

  • HTTP와 웹 등장 이후 초창기에는 각 회사마다 자체적인 HTTP 서버 구현체를 작성했다.
    • A사의 서버를 이용하다가 B사의 서버를 활용하게 되는 경우 인터페이스의 차이로 인해 사용 코드를 변경해야 하는 한계가 존재했다.
  • 이러한 문제 해결을 위해 자바 진영에서 서블릿이라는 표준을 만들게 된다.
  • 서블릿은 서버 사이드에서 돌아가는 작은 자바 프로그램을 의미한다.
  • 서블릿은 Servlet, HttpServlet, ServletRequest, ServletResponse를 포함하여 많은 표준을 제공하고 있다.
  • 서블릿을 제공하는 주요 자바 WAS는 다음과 같다.
    • 오픈소스
      • Apache Tomcat
      • Jetty
      • GlassFish
      • Undertow
    • 상용
      • IBM WebSphere
      • Oracle WebLogic
  • 이들은 jakarta.servlet의 구현체이며, 서블릿 표준은 자바 자체적으로 제공하고 있다.

# 리플렉션

  • 커맨드 패턴은 인터페이스 내에 기능이 하나 뿐이다.
    • 이를 구현한 구현체 내에서 동적으로 처리 방식이 분기된다.
    • 분기가 많아질수록 구현체가 복잡해진다는 한계가 존재한다.
  • 클래스가 제공하는 다양한 정보를 동적으로 분석하고 사용하는 기능을 리플렉션이라고 한다.
    • 리플렉션을 통해 프로그램 실행 중 클래스, 메서드, 필드 등에 대한 정보를 얻을 수 있다.
    • 새로운 객체를 생성하고 메서드를 호출하며, 필드 값을 읽고 쓸수있다.
  • 리플렉션을 통해 얻을 수 있는 정보는 다음과 같다.
    1. 클래스의 메타데이터: 클래스 이름, 접근 제어자 등
    2. 필드 정보: 필드 이름, 타입, 접근제어자, 해당 값 읽기 등
    3. 메서드 정보: 메서드 이름, 반환 타입, 매개변수 정보 확인 등
    4. 생성자 정보: 생성자의 매개변수 타입과 갯수 확인, 동적으로 객체 생성도 가능
  • 클래스 메타데이터는 Class라는 클래스로 표현된다.
  • 클래스 메타데이터는 아래 방법들로 얻을 수 있다.
    • ClassName.class 멤버
    • Instance.getClass() 메서드
    • String className = "directory.package.ClassName"
      • Class.forName(className); 메서드
  • getMethod혹은 getDelcaredMethod메서드를 통해 Method 객체를 얻을 수 있다.
    • 해당 객체의 invoke(instnace, 인자) 메서드를 호출하면 직접 호출도 가능하다.

# 애노테이션

  • 프로그램 실행 중에 읽어서 사용할 수 있는 주석을 애노테이션이라 한다.
  • 애노테이션은 @interface키워드를 사용하여 만든다.
// 애노테이션 정의 코드
@Retention(RetentionPolicy.RUNTIME)
public @interface SimpleMapping {
    String value();
}

// 애노테이션 사용 코드
public class TestController {

    @SimpleMapping(value = "/")
    public void home() {
        System.out.println("TestController.Home");
    }
}
  • 애노테이션은 프로그램에 아무런 영향을 주지 않는다.
  • 마치 주석과 같은 역할을 하지만 일반적인 주석 역할이 아닌, 리플렉션과 같은 기술로 실행 시점에 읽어 활용 가능한 주석이다.
  • Method 객체의 getAnnotation(AnnotationClassName.class) 메서드를 호출하여 읽어들일 수 있다.
    • 파라미터에 애노테이션 정의 클래스명을 전달하면 된다.
  • 데이터 타입은 클래스 외에도 다양한 타입 기반으로도 정의 가능하다.
    • int, float, boolean
    • String
    • Class
    • enum
    • 다른 애노테이션 타입
    • 위 타입들의 컬렉션
  • 커스텀 클래스로는 애노테이션 정의가 불가능하다.
  • 아래 코드와 같이 여러 요소들을 동시에 정의하는 것도 가능하다.
    • tags 파라미터에 작성된 {} 코드는 배열을 의미한다.
// 여러 파라미터 정의
@AnnoElement(value = "data", count = 10, tags = {"t1", "t2"})
public class ElementData1 { }

// 사용
Class<ElementData1> annoClass = ElementData1.class;
AnnoElement annotation = annoClass.getAnnotation(AnnoElement.class);

String value = annotation.value(); // "data"
  • 애노테이션을 정의하는 데에 사용하는 특별한 애노테이션을 메타 애노테이션이라 한다.
  • 아래와 같은 종류가 있다.
    • 애노테이션 생존기간 정의: @Retention
      • RetentionPolicy.SOURCE: 소스 코드에만 남고, 컴파일 시점에 제거
      • RetentionPolicy.CLASS: .class파일까지 남지만 런타임에 제거 (디폴트 값)
      • RetentionPolicy.RUNTIME: 자바 런타임에도 남아있음
    • 애노테이션 적용 위치 지정: @Target
      • TYPE, FIELD, METHOD 등 애노테이션이 적용되는 위치를 지정한다.
      • 주로 위 세개를 많이 사용한다.
    • @Documented
      • javadoc 기반 API 문서 출력 시 작성한 현재 애노테이션이 포함되어 출력될지를 지정
      • 보통 함께 사용함
    • @Inherited
      • 클래스 상속 시 자식도 애노테이션 적용
      • 인터페이스 구현에서의 상속 개념은 애노테이션에 적용 불가능하다.
  • 모든 애노테이션은 java.lang.annotation.Annotation인터페이스를 묵시적으로 상속받는다.
    • 개발자가 명시적으로 해당 인터페이스를 상속하거나 구현할 필요는 없다.
    • @interface키워드를 통해 정의하면 자바 컴파일러가 자동으로 해당 인터페이스를 확장하도록 처리한다.
  • @Override, @Deprecated와 같은 애노테이션들은 자바에서 기본으로 제공하며, 코드에 직접 유용하게 사용하고 있다.