main 이 끝나면 프로그램은 죽는다 — 서버는 왜 안 죽는가
Article
자바 편에서 우리가 만든 프로그램은 전부 이렇게 생겼었다.
public class Hello {
public static void main(String[] args) {
System.out.println("안녕");
}
}
javac -encoding UTF-8 Hello.java
java Hello
- 안녕
- $
"안녕"을 찍고, 프롬프트가 돌아왔다. 프로그램이 죽었다는 뜻이다.
이건 실패가 아니다. 정상이다. main 의 마지막 줄이 끝나면 프로그램은 할 일을 다 한 것이고, JVM 은 짐을 싸서 나간다. 자바 편 2강에서 JVM 이 main 을 찾아 들어간다고 했다. 들어갔으면 나오기도 한다.
그런데 웹사이트는 그러지 않는다. 새벽 세 시에 주소창에 주소를 쳐도 답이 온다. 어제도 답했고 오늘도 답한다. 누군가 죽지 않고 계속 기다리고 있다는 뜻이다.
이번 강의는 Spring 을 쓰지 않는다. 설치도 하지 않는다. 대신 지금까지 배운 자바만으로 서버 비슷한 것을 하나 만들어보고, 그 다음부터 이걸 다시는 안 만들게 되는 이유를 본다.
우리 프로그램에는 없던 두 가지
서버가 우리가 만들던 프로그램과 다른 점은 둘이다.
java Hello
자바 편에서 만든 것
- 할 말을 하고 죽는다.
main이 끝나면 끝 - 밖에서 아무것도 받지 않는다. 값은 전부 코드 안에 적혀 있었다
- 결과는
System.out.println으로 나에게만 보인다
서버
지금부터 만들 것
- 안 죽는다. 끝내지 않고 기다린다
- 밖에서 들어오는 것을 받는다. 누가, 무엇을 달라고 하는지
- 결과를 다른 컴퓨터에게 돌려준다
두 번째 항목을 한 번 더 짚고 싶다. 자바 편 스물한 강을 통틀어, 우리 프로그램은 바깥에서 무언가를 받아본 적이 단 한 번도 없다. 더할 숫자도, 담을 책 제목도 전부 코드에 직접 적었다. 프로그램은 자기가 아는 것만 말하고 죽었다.
지금부터는 반대다. 무엇을 달라고 할지 모르는 채로 켜두고, 물어보면 그때 답한다. 이 시리즈는 사실상 그 한 문장을 스물네 강에 걸쳐 푸는 것이다.
안 죽는 프로그램
죽지 않으려면 main 이 끝나지 않으면 된다. 방법은 간단하다.
while (true) {
// 여기서 뭔가 기다린다
}
문제는 "뭔가"다. 그냥 while (true) { } 라고 쓰면 프로그램은 안 죽지만, 대신 CPU 를 100% 태우면서 아무 일도 안 한다. 우리에게 필요한 건 아무 일도 안 하면서 조용히 멈춰 서서 기다리다가, 누가 오면 깨어나는 것이다.
자바는 그 도구를 표준으로 갖고 있다. ServerSocket 이다.
- 포트를 연다8080 번 문에 자리를 잡는다
- 기다린다accept() 에서 멈춰 선다
- 받는다누가 무엇을 달라는지 읽는다
- 답한다글자를 돌려주고 연결을 끊는다
포트가 뭔가
한 컴퓨터에는 프로그램이 여러 개 떠 있다. 밖에서 편지가 왔을 때, 그게 누구에게 온 편지인지 구분해야 한다.
포트는 그 구분에 쓰는 번호다. 컴퓨터가 건물이라면 포트는 호실 번호다. 주소(IP)로 건물을 찾고, 포트로 호실을 찾는다.
8080 은 아무 뜻 없는 숫자다. 개발할 때 관습적으로 쓰는 번호일 뿐이고, 9999 로 해도 된다. 다만 이미 다른 프로그램이 쓰고 있는 번호를 고르면 자리를 못 잡는다. 그 얘기는 잠시 뒤에 한다.
직접 만들어본다
아래가 전부다. 자바 편에서 배운 것만 썼다 — 클래스, main, while, try 없는 throws, 그리고 String.
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws Exception {
ServerSocket server = new ServerSocket(8080);
System.out.println("8080 포트에서 기다린다");
while (true) {
Socket client = server.accept();
System.out.println("누가 왔다");
BufferedReader in = new BufferedReader(
new InputStreamReader(client.getInputStream(), "UTF-8"));
String first = in.readLine();
System.out.println("첫 줄: " + first);
String body = "안녕";
byte[] bodyBytes = body.getBytes("UTF-8");
String head = "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/plain; charset=utf-8\r\n"
+ "Content-Length: " + bodyBytes.length + "\r\n"
+ "\r\n";
OutputStream out = client.getOutputStream();
out.write(head.getBytes("US-ASCII"));
out.write(bodyBytes);
out.flush();
client.close();
}
}
}
컴파일하고 실행한다.
javac -encoding UTF-8 Server.java
java Server
- 8080 포트에서 기다린다
- 커서가 깜빡인다. 프롬프트가 돌아오지 않는다.
프롬프트가 안 돌아온다. Hello 를 실행했을 때와 결정적으로 다른 점이다. 프로그램이 안 죽었다.
죽지 않은 이유는 while (true) 때문이 아니다. server.accept() 때문이다. 이 메서드는 누가 접속할 때까지 그 자리에서 멈춰 선다. CPU 를 태우지 않고, 그냥 잠들어서 기다린다. 이걸 블로킹이라고 부른다.
자바 편에서 배운 메서드는 전부 불렀다가 곧바로 돌아왔다. accept() 는 안 돌아온다. 누가 오기 전까지는.
말을 걸어본다
터미널을 하나 더 연다. 서버는 켜둔 채로 둔다.
curl 은 주소로 요청을 보내고 답을 받아 찍어주는 도구다. -i 를 붙이면 답의 머리 부분까지 보여준다.
curl -i http://localhost:8080/
- HTTP/1.1 200 OK
- Content-Type: text/plain; charset=utf-8
- Content-Length: 6
- 안녕
그리고 서버를 켜둔 첫 번째 터미널을 보면, 없던 줄이 늘어 있다.
- 8080 포트에서 기다린다
- 누가 왔다
- 첫 줄: GET / HTTP/1.1
여기서 두 가지를 확인했다.
하나. accept() 가 깨어났다. 잠들어 있다가 curl 이 접속하자 일어나서 Socket 하나를 돌려줬다. 그리고 답을 보낸 뒤 while 을 한 바퀴 돌아 다시 accept() 에서 잠들었다. 서버는 여전히 살아 있다. 한 번 더 curl 을 치면 또 답한다.
둘. 우리 프로그램이 생애 처음으로 바깥에서 글자를 받았다. 그 글자가 이것이다.
GET / HTTP/1.1
우리는 이 문장을 해석하지 않았다. readLine() 으로 한 줄 읽어서 그대로 화면에 찍었을 뿐이다. 무슨 뜻인지도 모른 채, 누가 오기만 하면 무조건 "안녕"을 돌려줬다.
우리가 만든 것은 서버가 아니다
방금 만든 것은 서버 흉내다. 진짜 서버라면 해야 하는데 안 하는 일이 널려 있다.
| 해야 하는 일 | 우리 코드는 | 어디서 배우나 |
|---|---|---|
GET / HTTP/1.1 이 무슨 뜻인지 안다 | 읽고 버린다 | 2강 |
/books 와 /users 를 다르게 대접한다 | 전부 "안녕" | 3강 · 9강 |
| 가져가기(GET)와 새로 만들기(POST)를 구분한다 | 구분 안 한다 | 3강 |
| 없는 것을 달라고 하면 없다고 답한다 | 무조건 200 OK | 3강 · 21강 |
| 글자가 아니라 객체를 주고받는다 | 손으로 조립한 글자 | 10강 · 11강 |
Content-Length: 6 을 손으로 계산해서 넣은 것을 보라. "안녕"이 6바이트라서 6이다. 한 글자만 늘려도 이 숫자를 고쳐야 하고, 안 고치면 응답이 잘린다. 답 하나 돌려주는 데 이런 걸 신경 써야 한다면, 정작 하고 싶었던 일에 쓸 힘이 남지 않는다.
우리가 하고 싶은 일은 책 목록을 돌려주는 것이지, 바이트 수를 세는 게 아니다.
- Exception in thread "main" java.net.BindException: Address already in use: bind
- at java.base/sun.nio.ch.Net.bind0(Native Method)
- at java.base/java.net.ServerSocket.<init>(ServerSocket.java:167)
- at Server.main(Server.java:9)
스택 트레이스는 위가 터진 곳, 아래가 시작한 곳이라고 자바 편 18강에서 배웠다. 그러니 맨 아래를 보면 된다. Server.java 의 9번째 줄, new ServerSocket(8080) 에서 터졌다.
그래서 Spring 을 쓴다
Spring Boot 가 하는 일을 한 줄로 줄이면 이렇다.
저 while (true) 와 accept() 를 대신 돌려주고, 우리에게는 "무엇을 답할지"만 적게 한다.
직접 만든 서버
방금 짠 것
ServerSocket을new한다while을 돌린다accept()로 기다린다- 요청을 손으로 읽는다
- 응답 헤더를 손으로 조립한다
- "안녕"이라는 글자는 딱 한 줄이다
Spring Boot
9강에서 실제로 쓴다
@RestController를 붙인다- 메서드 하나를 적는다
return "안녕";- —
- —
- 나머지는 전부 프레임워크가 한다
여기서 이 시리즈 전체를 관통하는 첫 번째 질문이 나온다.
우리는 방금 new ServerSocket(8080) 이라고 썼다. new 를 우리 손으로 쳤다. 자바 편에서 객체는 언제나 우리가 만들었다. 그런데 Spring 을 쓰기 시작하면 이상한 일이 벌어진다. 어디에도 new 가 없는데 객체들이 만들어져서 서로 연결되어 있다.
누가 만들었나. 그 질문에 5강부터 답한다.
정리하면
서버는 죽지 않고 기다리는 프로그램이고, 우리는 그 기다리는 코드를 직접 쓰지 않게 된다.
- 01
main이 끝나면 프로그램은 죽는다자바 편에서 만든 프로그램은 전부 할 말을 하고 죽었다. 그게 정상이다.
- 02
서버는
accept()에서 잠들어 기다린다CPU 를 태우지 않고 멈춰 서 있다가, 누가 접속하면 깨어난다. 이걸 블로킹이라고 한다.
Socket client = server.accept(); // 여기서 멈춘다 - 03
포트는 컴퓨터 안의 호실 번호다
이미 누가 살고 있으면 BindException 이 난다. 앞으로 가장 자주 볼 에러다.
- 04
우리 프로그램이 처음으로 바깥의 글자를 받았다
GET / HTTP/1.1을 받아서 그대로 찍었다. 무슨 뜻인지는 아직 모른다. 2강에서 본다. - 05
new를 우리가 쳤다는 것을 기억해둘 것new ServerSocket(8080). 앞으로 이 new 가 사라진다. 누가 대신 하는지가 5강의 주제다.
ServerSocket server = new ServerSocket(8080);
다음 강의에서는 방금 읽고 버린 그 한 줄을 제대로 본다.
GET / HTTP/1.1
세 덩어리로 되어 있고, 각각 이름이 있다. 그리고 우리가 손으로 조립해서 돌려준 HTTP/1.1 200 OK 도 마찬가지다. HTTP 는 어렵지 않다. 사람이 읽을 수 있는 글자 몇 줄이 전부다. 2강에서 확인한다.