모든 개발자를 위한 HTTP 웹 기본 지식

|

김영한님의 강의를 듣고 정리한 글이다.

인터넷 네트워크

인터넷 통신

HTTP 통신을 이해하기 위해선 인터넷 통신이 어떻게 돌아가는지 알아야 하고 그 통신을 위한 기반 요소들이 있다.

  • IP
  • TCP, UDP
  • PORT
  • DNS

이 요소들을 이해하면서 인터넷 통신을 알아보자.

IP(인터넷 프로토콜)

IP(Internet Protocol)이란

한국에 있는 내가 미국에 있는 친구에게 편지를 보내려면 어떻게 해야할까? 먼저 주소를 알아야 보낼 수 있지 않는가. 인터넷 세계도 마찬가지로 주소가 있어야 메시지를 보낼 수 있는데 이때 사용되는게 IP 주소이다. IP는 패킷이라는 단위로 포장되어 보내지는데 그 안에는 출발지, 목적지 주소와 이후의 내용들로 이루어져있다. 메세지를 보내면 인터넷 망에서 장비들이 패킷의 주소들을 보아 목적지까지 이동하게 된다.

프로토콜이란 규약이란 뜻인데 통신하기 위해 정해둔 약속이라 생각하면 되겠다. 마치 한국인과 일본인이 서로 언어가 다루니 국제 언어인 영어를 사용하는것 정도로 이해하면 될듯하다.

IP 프로토콜의 한계

IP는 비연결성이라 패킷을 받을 대상이 없거나 서비스 불능 상태여도 패킷을 전송하게 된다. 또환 비신뢰성적이라 중간에 패킷이 사라지거나 순서를 보장하지 않는다. 그리고 같은 IP를 사용하는 서버에 통신하는 애플리케이션이 있으면 구분할 방법이 없다. 이를 해결하기 위해서는 새로운 개념을 도입하게 되는데 이것이 TCP, UDP가 되겠다.

TCP, UDP

먼저 우리가 표준으로 쓰고 있는 TCP/IP 프로토콜의 계층 구조는 다음과 같이 이루어 져있다.

애플리케이션 계층 - HTTP, FTP  
전송 계층 - TCP, UDP  
인터넷 계층 - IP  
네트워크 인터페이스 계층  

그리고 애플리케이션에서 상대방에게 통신을 보내기 위해서 위에서부터 캡슐화가 이루어 지게 된다.

  1. 프로그램이 Hello, World! 메시지 작성 (애플리케이션)
  2. SOCKET 라이브러리를 통해 전달 (애플리케이션)
  3. TCP 정보 생성, 메시지 데이터 포함 (OS 영역)
  4. IP 패킷 생성, TCP 데이터 포함 (OS 영역)
  5. 랜카드 타고 슈웅~ (네트워크 인터페이스)

IP 패킷 전에 TCP로 감싸게 되는데, 이 TCP에 IP의 문제점을 해결할 내용들이 담겨있다. 바로 전송 제어, 순서, 검증 정보 등의 내용이다.

TCP (Transmission Control Protocol) 특징

  • 연결지향 - TCP 3 way handshake (가상 연결)
  • 데이터 전달 보증
  • 순서 보장
  • 신뢰할 수 있는 프로토콜
  • 현재는 대부분 TCP를 사용

3 way handshake

TCP는 서로 먼저 살아있음을 확인하고 데이터 전송을 시작하는데 이런 확인을 위해 3 way handshake라는 과정을 거친다.

  1. 클라이언트에서 서버에 SYN(연결) 요청을 보냄
  2. 서버에서는 ACK(응답)과 함께 SYN(클라이언트도 살아있는지 확인을 위해)을 보냄
  3. 클라이언트에서 ACK를 보내 서로 살아있음을 확인 (동시에 데이터 전송도 가능하다)

이 과정은 물리적으로 연결되는게 아니라 개념적으로 연결되는 것이다. 컴퓨터들끼리 논리적으로 인지하게 되는 것이다.

데이터 전달 보증

클라이언트에서 데이터를 전송하면 서버에서는 데이터를 잘 받았다고 응답을 준다. 만약 잘못 받았으면 서버에서 에러 코드를 보내던가 하여 못 받은 데이터를 요구하게 된다.

순서 보장

또한 순서를 보장해준다. 만약 순서대로 오지 않는다면 맞지 않는 순서부터 다시 보내라고 응답을 한다.

UDP (User Datagram Protocol)

UDP는 TCP와 달리 기능이 거의 없다시피 하다.

  • 연결 지향 X
  • 데이터 전달 보증 X
  • 순서 보장 X
  • 대신 단순하고 빠르다. (그래서 과거 스타크래프트 같은 게임에서 UDP로…)
  • IP랑 비슷한데 PORT와 체크섬 정도가 추가 되어있다.
  • 과거에는 UDP는 안전하지 않다고 안쓰였지만 최근에는 애플리케이션들의 최적화들로 인해 다시 각광받고 있는 추세라고 한다.

PORT

우리는 지금 컴퓨터를 쓸 때 동시에 웹 브라우저도 쓰고 있고, 게임도 하고 있고 화상통화로 수업도 받고 있다. 즉, 여러 애플리케이션 데이터들이 동시다발적으로 들어 오고 있다는 것이다. 클라이언트는 각 애플리케이션들의 데이터를 어떻게 구별할까? 바로 포트(PORT)를 활용해서 구분한다. TCP와 UDP 정보에는 출발지 포트와 목적지 포트가 있어 이것을 통해 구분한다. SSH - 22, HTTP - 80번 이런 숫자가 다 포트이다. 포트는 0부터 65535까지 할당 가능한데, 0 ~ 1023까지는 Well Known 포트라해서 이미 쓰이고 있는 포트라 건들지 않는게 좋고, 그 위로해서 사용하면 된다. 일단 안 겹치는 것이 중요하다.

쉽게, 아이피는 아파트, 포트는 아파트 호수로 이해하면 좋다.

DNS (Domain Name System)

IP를 통해 통신한다곤 했는데, 사실 IP는 기억하기 어렵다. IPv4만 해도 32억개 정돈데 내가 원하는 IP를 찾기란 불가능에 가깝다. 또한 IP 주소는 바뀔 수 있기에 더더욱 어렵다. 이런 문제점을 해결하기 위해 도입된게 DNS이다. 이 DNS를 활용해 우리가 쓰는 www.naver.com 주소와 IP 주소를 매핑시켜주어 쉽게 찾을 수 있게 해주는 것이다.

URI와 웹 브라우저 요청 흐름

URI (Uniform Resource Identifier)

  • Uniform: 리소스 식별하는 통일된 방식
  • Resource: 자원, URI로 식별할 수 있는 모든 것(제한 없음)
  • Identifier: 다른 항목과 구분하는데 필요한 정보

  • URI: 로케이터, 이름 또는 둘다 추가로 분류될 수 있다.
  • URL (Resource Locator): 리소스가 있는 위치를 지정
  • URN (Resource Name): 리소스에 이름을 부여
    • ex) urn:isbn:8960777331 (김영한님의 책이 나온다 ㅎㅎ)
    • URN 이름만으로는 실제 방법 찾기가 쉽지가 않아 보편화 되지 않았다.
    • 우리는 URL로만 다루는 걸로 이해하면 될거 같다.

URL 예

URL은 다음과 같이 구성되어있다.

대괄호 부분은 생략 가능하다.

  • scheme://[userinfo@]host[:port][/path][?query][#fragment]
  • https://www.google.com:443/search?q=hello&hl=ko

  • scheme는 어떤 프로토콜을 사용할건지 정하는 것이다. ex) http, https, ftp
  • userinfo는 사용자 정보를 포함해서 인증할 때 쓰는데, 거의 사용되지 않는다.
  • host는 호스트명으로 도메인 명이나 ip 주소를 입력한다.
  • port는 위에서 배운 포트 주소를 입력한다. 일반적으로 80, 443이 쓰인다.
  • path는 리소스가 있는 경로이다. 우리 폴더같이 계층적으로 사용되어 /home/file.jpg 같은 경로를 쓰거나 /members, /members/100 같이 쓴다.
  • query는 key=value 형태로 웹서버에 제공하는 파라미터이다. 문자 형태로 이루어 져있다.
    • ex) ?keyA=value&keyB=valueB (?로 시작하고, 여러개 쓸때는 &로 이어준다.)
    • query parmeter나 query string으로 부름
  • fragment: html 내부 북마크 등에 사용되며, 서버에 전송되는 내용은 아니다.

웹 브라우저 요청 흐름

클라이언트가 https://www.google.com:443/search?q=hello&hl=ko로 메시지를 보낸다고 가정해보자. 클라이언트는 먼저 도메인명을 IP로 바꾸기 위해 DNS 서버에 IP를 얻고, 아래와 같은 요청 메시지를 만든다.

GET /search?q=hello&hl=ko HTTP/1.1
HOST: www.google.com

그리고 TCP계층과 IP계층 캡슐화를 하여 서버에 전송하고 서버는 자신이 줄 수 있는 내용이면 200 코드와 함께 내용을 보내준다. 클라이언트는 받은 내용을 적절하게 렌더링해서 보여준다.

HTTP 기본

모든 것이 HTTP (HyperText Transfer Protocol)

이전에는 텍스트형태로만 내용이 전달되었었는데, 지금에 와서는 모든 내용을 HTTP 프로토콜에 담아 보낼 수 있게 되었다. 예로

  • HTTP, TEXT
  • IMAGE, 음성, 영상, 파일
  • JSON, XML (API)
  • 거의 모든 형태의 데이터 전송이 가능하다.
  • 서버간에 데이터를 주고 받을 때도 대부분 HTTP를 사용한다.

위처럼 모든 것을 HTTP로 보내는 지금, HTTP 시대라 해도 과언이 아닐 정도이다.

HTTP 역사

  • HTTP/0.9 1991년: GET 메서드만 지원, HTTP 헤더 X
  • HTTP/1.0 1996년: 메서드, 헤더 추가
  • HTTP/1.1: 1997년: 가장 많이 사용, 우리에게 가장 중요한 버전 (2014년도 개정 완료, RFC7230~7235)
  • HTTP/2 2015년: 성능 개선
  • HTTP/3 진행중: TCP 대신에 UDP 사용, 성능 개선

기반 프로토콜

  • TCP: HTTP1/1, HTTP/2
  • UDP: HTTP/3
  • 현재는 HTTP/1.1를 주로 사용하고 2/3도 점점 증가하고 있다.

HTTP 특징

  • 클라이언트 서버 구조이다.
  • 무상태 프로토콜(stateless), 비연결성 지향
  • HTTP 메시지로 통신
  • 단순하고 확장 가능하다.

클라이언트 서버 구조

HTTP는 Request Response 구조로 클라이언트가 서버에 요청을 보내고, 응답을 대기한다. 서버는 요청에 대한 결과를 만들어서 응답을 보낸다. 클라이언트는 UI에 집중하고 서버는 비즈니스 로직에 집중할 수 있도록 한다.

Stateful, Stateless

HTTP는 무상태 프로토콜(Stateless)이다. 때문에 서버가 클라이언트의 상태를 보존하지 않아 서버 확장성(스케일 아웃)이 높다는 장점이 있지만, 단점으론 클라이언트가 추가 데이터를 전송해도 누가 보냈던건지 모르게 된다. 반면 stateful은 연결을 유지해서 문맥 보존이 된다. 점원과 손님과의 관계로 나타내면 stateless는 점원이 계속 바뀔 수 있기에 손님이 요청사항을 갖고있는 형태로 하고, stateful은 점원이 요청사항을 유지하는 형태로 띈다. 그렇기에 stateful은 중간에 다른 점원으로 바뀌면 안되는데, (아니면 그 상태를 전달해 준다거나) stateless는 중간에 다른 점원으로 바뀌어도 어차피 손님이 요청사항을 갖고 있기에 문제 없이 처리할 수 있다. 즉,

  • 갑자기 고객이 증가해도 점원을 대거 투입할 수 있고
  • 갑자기 클라이언트 요청이 증가해도 서버를 대거 투입할 수 있다.

결론적으로 무상태(stateless)는 응답 서버를 쉽게 바꿀 수 있어, 무한한 서버 증설이 가능하다. (Scale Out) 하지만 무상태도 실무적으로 한계가 있다. 일단 데이터를 많이 보내게 된다.

무상태의 좋은 적용은 로그인이 필요없는 단순한 서비스 소개 화면일 때 좋고, 상태 유지는 로그인 상황에 좋다. 그래야 장바구니든 사용자가 처리한 정보를 계속 갖고 있으니까. 일반적으로 상태 유지는 브라우저 쿠키와 서버 세션등을 사용해서 유지하며, 최소한 적으로 사용하는게 좋다.

비 연결성(connectionless)

TCP로 서버와 클라이언트의 연결을 계속 유지하게 되면, 서버는 필연적으로 자원을 계속 소모하게 된다. 한 두대면 몰라도 수만 대가 물리게 되면 결국엔 서버가 다운이 될 것이다. 그렇기에 서버와 클라이언트가 필요한 대화만 하고 연결을 끊어버려 서버 자원을 아낄 수 있다. HTTP는 기본적으로 연결을 유지하지 않는 모델이며, 일반적으로 초 단위의 이하의 빠른 속도로 응답을 해준다. 1시간 동안 수천명이 서비스를 사용해도 실제 서버에서 동시에 처리하는 요청은 수십개 이하로 작다.(계속 검색 버튼을 누르진 않으니까) 이렇게 비 연결로 지향을 하면 서버 자원을 매우 효율적으로 사용할 수 있다. 하지만 언제나 그렇듯 단점도 있다.

비 연결성 한계와 극복

  • 일단 필요한 대화만 나누니, 대화가 필요할 때 TCP/IP 연결을 새로 맺어줘야 한다. (3 way handshake 시간 추가)
  • 웹 브라우저로 사이트를 요청하면 HTML 뿐만 아니라 자바스크립트, css, 추가 이미지 등 수 많은 자원이 함께 다운로드 된다.
  • 지금은 HTTP 지속 연결(Persistent Connections)로 문제 해결. (몇십초 동안 연결 유지)
  • HTTP/2, HTTP/3에서 더 많은 최적화가 이루어 지고 있다.

스테이스리스를 기억하자

서버 개발자들이 어려워하는 업무가 있다. 바로 정말 같은 시간에 딱 맞추어 발생하는 대용량 트랙픽들이다. 예시로는

  • 선착순 이벤트, 명절 KTX 예약, 학과 수업 등록
  • 저녁 6:00 선착순 1000명 치킨 할인 이벤트 이런게 열리면 수만명의 동시 요청이 들어온다.

이런 문제점들 때문에 꼭 스테이스리스로 구현할 수 있도록 해보자.

HTTP 메시지

HTTP 메시지는 구조는 대략 이러하다.

statrt-line 시작 라인
header 헤더
empty line 공백 라인 (CRLF)
message body

공백라인은 필수로 있어야 한다. 이것을 통해 message body와 구분하고 그럴 수 있는 것이다.

요청 메시지

요청 메시지의 형태는 이러하다.

  • start-line = request-line/status-line
  • request-line = method SP(공백) request-target SP HTTP-version CRLF(엔터)
    • ex) GET /search?q=hello&hl=ko HTTP/1.1
    • Method
      • GET: 리소스 조회
      • POST: 요청 내역 처리
    • 요청 대상(request-target)
      • absolute-path[?query] (절대경로[?쿼리] 형태이다.)
      • 절대경로는 “/”로 시작하는 경로이다.
    • HTTP 버전

응답 메시지

응답 메시지는 다음과 같다.

  • start-line = request-line / status-line
  • status-line = HTTP-version SP status-code SP reason-phrase CRLF
    • ex) HTTP/1.1 200 OK
    • HTTP 버전
    • HTTP 상태 코드: 요청 성공, 실패를 나타냄
      • 200: 성공
      • 400: 클라이언트 요청 오류
      • 500: 서버 내부 오류
    • 이유 문구: 사람이 이해할 수 있는 짧은 상태 코드 설명 글

HTTP 헤더

헤더는 HTTP 전송에 필요한 모든 부가정보가 들어가있다. 예로들면, 메시지 바디의 내용, 메시지 바디의 크기, 압축, 인증, 요청 클라이언트(브라우저) 정보, 서버 애플리케이션 정보, 캐시 관리 정보 등 엄청 많다. 또 필요시 임의의 헤더를 추가할 수 있다.

  • header-field = field-name: OWS field-value OWS (OWS: 띄어쓰기 허용)
  • field-name은 대소문자 구문 없다.
  • 요청 메시지 예
    • Host: www.google.com
  • 응답 메시지 예
    • Content-Type: text/html:charset=UTF-8
    • Content-Length: 3423

메시지 바디

  • 실제 전송할 데이터
  • HTML 문서, 이미지, 영상, JSON 등등 byte로 표현할 수 있는 모든 데이터는 전송 가능하다.

HTTP 메서드

HTTP API를 만들어보자

회원 정보 관리 API를 만들어 보자. 먼저 필요한 목록들이 뭔지 고민해보는 것이다.

  • 회원 목록 조회
  • 회원 조회
  • 회원 등록
  • 회원 수정
  • 회원 삭제

처음에는 이렇게 설계해볼 수도 있겠다. /read-member-list /read-member-by-id /create-member /update-member /delete-member

하지만 URI를 고민해볼 때는 리소스를 중점으로 두어야 한다. 회원이라는 개념이 리소스이다. 조회니 등록이니는 행위 같은게 되겠다. 즉 리소스를 식별하는게 우선이다. 그렇기에 회원 리소스를 URI에 매핑해야 한다. 그러면 아래와 같이 설계해볼 수 있다.

리소스는 명사, 행위는 동사로 보면 좋다.

  • 목록 조회 /members
  • 회원 조회/members/{id}
  • 등록 /members/{id}
  • 수정 /members/{id}
  • 삭제 /members/{id}

계층 구조상 상위를 컬렉션으로 보고 복수단어 사용을 권장한다. (member대신 members)

근데 위처럼 하면 조회부터 삭제까지 구분이 안된다. 어떻게 구분해야할까 해서 나온것이 HTTP의 메서드로 구분하는 것이다.

HTTP 메서드 - GET, POST

메서드는 클라이언트가 서버에 요청할 때 기대하는 행동이다.

  • GET: 리소스 조회
  • POST: 요청 데이터 처리, 주로 등록에 사용
  • PUT: 리소스를 대체, 해당 리소스가 없으면 생성
  • PATCH: 리소스 부분 변경
  • DELETE: 리소스 삭제

기타 메서드

  • HEAD: GET과 동일하지만 메시지 부분을 제외하고, 상태 줄과 헤더만 반환
  • OPTIONS: 대상 리소스에 대한 통신 가능 옵션(메서드)을 설명(주로 CORS에서 사용)
  • CONNECT: 대상 자원으로 식별되는 서버에 대한 터널을 설정
  • TRACE: 대상 리소스에 대한 경로를 따라 메시지 루프백 테스트를 수행

GET

GET /search?q=hello&hl=ko HTTP/1.1
Host: wwww.google.com
  • 리소스 조회에 사용
  • 서버에 전달하고 싶은 데이터는 query(쿼리 파라미터, 쿼리 스트링)를 통해 전달
  • 메시지 바디를 사용해서 데이터를 전달할 수 있지만, 지원하지 않는 곳이 많아 권장하지 않음

POST

POST /members HTTP/1.1
Content-Type: application/json

{
  "username": "hello",
  "age": 20
}
  • 요청 데이터 처리
  • 메시지 바디를 통해 서버로 요청 데이터를 전달한다.
  • 서버는 요청 데이터를 처리한다.
    • 메시지 바디를 통해 들어온 데이터를 처리하는 모든 기능을 수행한다.
    • ex) 주문에서 결제완료 -> 배달시작 -> 배달완료 처럼 단순히 값 변경을 넘어 프로세스의 상태가 변경되는 경우
    • POST의 결과로 새로운 리소스가 생성되지 않을 수 있다.
    • ex) POST /orders/{orderId}/start-delivery(컨트롤 URI)
  • 주로 전달된 데이터로 신규 리소스 등록 또는 프로세스 처리에 사용한다.
  • 예시
    • HTML 양식에 입력 된 필드와 같은 데이터 블록을 데이터 처리 프로세스에 제공: HTML FORM에 입력한 정보로 회원 가입, 주문 등에서 사용
    • 게시판, 뉴스 그룹, 메일링 리스트, 블로그 또는 유사한 기사 그룹에 메시지 게시: 게시판 글쓰기, 댓글 달기
    • 서버가 아직 식별하지 않은 새 리소스 생성: 신규 주문 생성
    • 기존 자원에 데이터 추가: 한 문서 끝에 내용 추가하기

위 같은 리소스 URI에 POST 요청이 오면 요청 데이터를 어떻게 처리할지 리소스마다 따로 정해줘야 한다.

POST로 약속된 내용을 받고 잘 처리가 되면 이런식으로 응답을 해준다.

HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 34
Location: /members/100

{
  "username": "young",
  "age": 20
}

Location을 통해 자원의 경로를 보내준다.

HTTP 메서드 - PUT, PATCH, DELETE

PUT

PUT /members/100 HTTP/1.1
Content-Type: application/json

{
  "username": "hello";
  "age": 20
}
  • 리소스를 대체
    • 리소스가 있으면 대체
    • 리소스가 없으면 생성
    • 덮어버린다 생각하면 됨
  • 클라이언트가 리소스를 식별
    • 클라이언트가 리소스 위치를 알고 URI 지정한다.

그런데 put은 완전 덮어버리는 것이기에 자칫하면 내용이 완전 없어질 수도 있다.

기존에 /members/100이 username과 age 필드를 가지고 있는데 put으로는 age만 보내게 되면 username이 사라지게 된다. 이런 부분을 해결하기 위해 나타난 메서드가 PATCH이다.

PATCH

PATCH /members/100 HTTP/1.1
Content-Type: application/json

{
  "age": 50
}

PATCH로 보내면 부분적으로만 변경이 된다. 지원 안해주는 서버가 있으면 POST를 쓰자

DELETE

DELETE는 리소스를 제거해준다.

DELETE /members/100 HTTP/1.1
Host: localhost:8080

HTTP 메서드의 속성

HTTP 메서드의 속성으론 다음과 같다.

  • 안전(Safe Methods)
  • 멱등(Idempotent Methods)
  • 캐시가능(Cacheable Methods)

안전 (Safe)

호출해도 리소스를 변경하지 않는다는 의미이다. 근데, 계속 호출해서 로그 같은게 쌓여 장애가 발생한다면 어떨까. 안전은 해당 리소스만 고려하기에 그런 부분은 고려하지 않는다.

멱등 (Idempotent)

  • f(f(x)) = f(x)
  • 한 번 호출하든 두 번 호출하든 100번 호출하든 결과가 똑같다.
  • 멱등 메서드
    • GET: 한 번 조회하든, 두 번 조회하든 같은 결과가 조회된다.
    • PUT: 결과를 대체한다. 따라서 같은 요청을 여러번 해도 최종 결과는 같다.
    • DELETE: 결과를 삭제한다. 같은 요청을 여러번 해도 삭제된 결과는 똑같다.
    • POST: 멱등이 아니다. 두 번 호출하면 같은 결제가 중복해서 발생할 수 있다.
  • 활용
    • 자동 복구 메커니즘
    • 서버가 TIMEOUT 등으로 정상 응답을 못주었을 때, 클라이언트가 같은 요청을 다시해도 되는가의 판단 근거가 된다.

근데 만약 재요청(GET) 중간에 리소스를 변경해버리면 어떻게 될까? 멱등은 외부 요인으로 중간에 리소스가 변경되는 것 까지는 고려하지 않는다.

캐시 가능 (Cacheable)

  • 응답 결과 리소스를 캐시해서 사용
  • GET, HEAD, POST, PATCH 캐시 가능
  • 실제로는 GET, HEAD 정도만 캐시로 사용한다.
    • POST, PATCH는 본문 내용까지 캐시 키로 고려해야 하는데, 구현이 쉽지 않다.

HTTP 메서드 활용

클라이언트에서 서버로 데이터 전송

데이터 전달 방식은 크게 2가지가 있다.

  1. 쿼리 파라미터를 통한 데이터 전송
    • GET (이미지, 정적 텍스트 문서)
    • 주로 정렬 필터(검색어, 동적 데이터 조회)
  2. 메시지 바디를 통한 데이터 전송
    • POST, PUT, PATCH (Form 전송)
    • 회원가입, 상품 주문, 리소스 등록, 리소스 변경 등

Form POST 전송 시 메시지

메시지 바디를 통해 key=value 형태로 전송한다.

POST /save HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded(한글 인코딩돼서 넘어감)

username=kim&age=20

form에서 enctype=”multipart/form-data”으로 전송 시

바이너리 데이터(사진, 영상 등)을 넣을 때 사용한다. 다른 종류의 여러 파일과 폼의 내용 함께 전송 가능하기에 이름이 multipart로 쓰이는 것이다.

POST /save HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=----XXX(랜덤으로 만듬)
Content-Length: 10457

----XXX
Content-Disposition: form-data; name="username"

kim

----XXX
Content-Disposition: form-data; name="file1"; filename="intro.png"
Content-Type: image/pgn

asdfsdfl;kasjf;lkasjdf;ljadsfk;ldasjf...

HTTP API 데이터 전송

  • 서버 to 서버
    • 백엔드 시스템 통신
  • 앱 클라이언트
    • 아이폰, 안드로이드
  • 웹 클라이언트
    • HTML에서 Form 전송 대신 자바 스크립트를 통한 통신에 사용(AJAX)
    • ex) React, VueJs 같은 웹 클라이언트와 API 통신
  • POST, PUT, PATCH: 메시지 바디를 통해 데이터 전송
  • GET: 조회, 쿼리 파라미터로 데이터 전달
  • Content-Type: application/json을 주로 사용 (사실상 표준)
    • TEXT, XML, JSoN 등등
POST /members HTTP/1.1
Content-Type: application/json

{
  "username": "young",
  "age": 20
}

HTTP API 설계 예시

  • HTTP API - 컬렉션
    • POST 기반 등록
    • ex) 회원 관리 API 제공
  • HTTP API - 스토어
    • PUT 기반 등록
    • ex) 정적 컨텐츠 관리, 원격 파일 관리
  • HTML FORM 사용
    • 웹 페이지 회원 관리
    • GET, POST만 지원

회원 관리 시스템

리소스를 이용하고 POST 기반으로 등록한다. 클라이언트는 등록될 리소스의 URI를 모르기에 POST로 보내고, 서버는 새로 등록된 리소스 URI를 Location: /members/100 이런 식으로 보내준다.

컬렉션(Collection)은 서버가 관리하는 리소스 디렉토리로 서버가 리소스의 URI를 생성하고 관리한다. 여기서는 /members가 컬렉션이다.

  • 회원 목록 /members -> GET
  • 회원 등록 /members -> POST
  • 회원 조회 /members/{id} -> GET
  • 회원 수정 /members/{id} -> PATCH, PUT, POST
  • 회원 삭제 /members/{id} -> DELETE

파일 관리 시스템

PUT 기반으로 설계해본다. 파일을 등록할 때 클라이언트가 리소스 URI를 알고 있어야 한다. 그렇기에 PUT /files/star.jpg 이런식으로 해서 서버에 보내게 된다.

스토어(store)는 클라이언트가 관리하는 리소스 저장소이다. 클라이언트가 리소스의 URI를 알고 관리하며 여기서 스토어는 /files가 된다.

  • 파일 목록 /files -> GET
  • 파일 조회 /files/{filename} -> GET
  • 파일 등록 /files/{filename} -> PUT
  • 파일 삭제 /files/{filename} -> DELETE
  • 파일 대량 등록 /files -> POST

HTML FORM 사용

HTML FORM은 GET과 POST만 지원한다. 물론 AJAX를 사용하면 해결할 수 있지만 여기서는 순수 HTML 기준으로 한다.

  • 회원 목록 /members -> GET
  • 회원 등록 폼 /members/new -> GET
  • 회원 등록 /members/new, /members -> POST
  • 회원 조회 /members/{id} -> GET
  • 회원 수정 폼 /members/{id}/edit -> GET
  • 회원 수정 /members/{id}/edit, /members/{id} -> POST
  • 회원 삭제 /members/{id}/delete -> POST

순수 HTML은 GET과 POST만 지원하기에 제약이 있다. 그래서 동사처럼 사용하는 컨트롤 URI를 포함시키는 것이다. POST의 /new, /edit, /delete가 컨트롤 URI 들이다. 그렇다고 남발하면 안된다. 최대한 리소스를 중점으로 URI를 만들되 그것만으로 부족할 때 도입하도록 하자.

참고하면 좋은 URI 설계 개념

  • 문서(document)
    • 단일 개념(파일 하나, 객체 인스턴스, 데이터베이스 row)
    • 예) /memberes/100, /files/star.jpg
  • 컬렉션(collection)
    • 서버가 관리하는 리소스 디렉터리
    • 서버가 리소스의 URI를 생성하고 관리
    • 예) /membeers
  • 스토어(store)
    • 클라이언트가 관리하는 자원 저장소
    • 클라이언트가 리소스의 URI를 알고 관리
    • 예) /files
  • 컨트롤러(controller), 컨트롤 URI
    • 문서, 컬렉션, 스토어로 해결하기 어려운 추가 프로세스 실행
    • 동사를 직접 사용
    • 예) /members/{id}/delete

https://restfulapi.net/resource-naming 을 참고해보자

HTTP 상태코드

HTTP 상태코드 소개

상태 코드는 클라이언트가 보낸 요청의 처리 상태를 응답에서 알려주는 기능이다.

  • 1XX (Informational): 요청이 수신되어 처리중, 거의 사용하지 않는다.
  • 2XX (Successful): 요청 정상 처리
  • 3XX (Redirection): 요청을 완료하려면 추가 행동이 필요
  • 4XX (Client Error): 클라이언트 오류, 잘못된 문법등으로 서버가 요청을 수행할 수 없음
  • 5XX (Server Error): 서버 오류, 서버가 정상 요청을 처리하지 못함

만약 모르는 상태 코드일 시

클라이언트가 인식할 수 없는 상태코드를 서버가 반환하면 클라이언트는 상위 상태코드로 해석해서 처리한다. 그렇기에 미래에 새로운 상태 코드가 추가되어도 클라이언트를 변경하지 않아도 된다. 만약 299인데 이건 지금 없는 코드다. 이것을 받으면 2XX이런식으로 받아들여 처리한다.

2XX - 성공

클라이언트의 요청을 성공적으로 처리하면 보내는 코드이다. 대표적으로

  • 200 OK
  • 201 Created
  • 202 Accepted
    • 요청이 접수되었으나 처리가 완료되지 않았음.
    • 배치 처리 같은 곳에서 사용 예) 요청 접수 후 1시간 뒤에 배치 프로세스가 요청을 처리함
  • 204 No Content
    • 서버가 요청을 성공적으로 수행했지만, 응답 페이로드 본문에 보낼 데이터가 없음
    • 웹 문서 편집기에서 save 버튼 눌렀을 때
    • save 버튼의 결과로 아무 내용이 없어도 되고, 같은 화면을 유지해야 한다.
    • 결과 내용이 없어도 204 메시지 만으로 성공을 인식할 수 있다.

3XX - 리다이렉션1

요청을 완료하기 위해 유저 에이전트(웹 브라우저)의 추가 조치 필요

리다이렉션 이해

웹 브라우저는 3XX 응답의 결과에 Location 헤더가 있으면, Location 위치로 자동 이동한다. 이것을 리다이렉트라고 한다.

만약 기존의 /event라는 url에서 이벤트를 진행했는데 관리자가 이제 /new-event로 옮겼다고 하자. 근데 기존 고객들은 계속 /event로 들어오려 할테다. 이럴때 300코드와 함께 Location을 주면 거기로 이동하게 되는 것이다.

리다이렉션 종류

  • 영구 리다이렉션(301, 308): 특정 리소스의 URI가 영구적으로 이동, 원래의 URL를 사용하지 않고, 검색 엔진 등에서도 변경을 인지한다.
    • 301 Moved Permanently: 리다이렉트시 요청 메서드가 GET으로 변하고, 본문이 제거될 수 있음
    • Permaent Redirect: 301과 기능은 같다. 리다이렉트 요청 메서드와 본문을 유지한다. (처음 POST를 보내면 리다이렉트도 POST로)
    • /members -> /users
    • /event -> /new-event
  • 일시 리다이렉션: 일시적인 변경
    • 주문 완료 후 주문 내역 화면으로 이동
    • PRG 패턴: Post/Redirect/Get
  • 특수 리다이렉션: 결과 대신 캐시를 사용
HTTP/1.1 301 Moved Permanently
Location: /new-event
  • 300 Multiple Choices
  • 301 Moved Permanently
  • 302 Found
  • 303 See Other
  • 304 Not Modified
  • 307 Temporary Redirect
  • 308 Permanent Redirect

3XX - 리다이렉션2

일시적인 리다이렉션으로는 302, 307, 303이 있다. 이 일시적인 리다이렉션은 리소스의 URI가 일시적으로는 변경되지만 완전 바뀌는건 아니기에 검색 엔진 등에서 URL을 변경하면 안 된다.

  • 302 Found: 리다이렉트시 요청 메서드가 GET으로 변하고, 본문이 제거될 수 있음
  • 307 Temporary Redirect: 302와 기능 같음. 리다이렉트시 요청 메서드와 본문 유지(요청 메서드를 변경하면 안된다.)
  • 303 See Other: 302와 같음. 리다이렉트시 요청 메서드가 GET으로 변경

PRG: Post/Redirect/Get

POST를 보내서 DB에 반영됐는데, 만약 새로고침(마지막꺼 적용)을 하게되면 중복으로 POST가 보내져 데이터가 저장될 수도 있다. 물론 서버쪽에서 막으면 되지만, 클라이언트쪽에서 막는것이 좋기 때문에 PRG패턴을 사용하여 방지를 한다.

  • POST로 주문후에 주문 결과 화면을 GET 메서드로 리다이렉트
  • 새로고침해도 결과 화면을 GET으로 조회
  • 중복 주문 대신에 결과 화면만 GET으로 다시 요청

307과 303을 권장하지만 현실적으로 이미 많은 애플리케이션 라이브러리들이 302를 기본값으로 사용한다. 자동 리다이렉션시에 GET으로 변해도 되면 그냥 302를 사용해도 큰 문제가 없다.

기타 리다이렉션

  • 300 Multiple Choices: 안씀
  • 304 Not Modified
    • 캐시를 목적으로 사용
    • 클라이언트에게 리소스가 수정되지 않았음을 알려준다. 따라서 클라이언트는 로PC에 저장된 캐시를 재사용한다. (캐시로 리다이렉트)
    • 304 응답은 응답에 메시지 바디를 포함하면 안된다. (로컬 캐시 사용을 위해)
    • 조건부 GET, HEAD 요청시 사용

4XX - 클라이언트 오류, 5XX - 서버 오류

4XX(Client Error) 클라이언트 오류

클라이언트의 요청에 잘못된 문법등으로 서버가 요청을 수행할 수 없다. 즉 오류의 원인이 클라이언트에 있는 것이다. 클라이언트가 이미 잘못된 요청, 데이터를 보내고 있는 것이기에 똑같은 재시도가 실패하게 된다.

400 Bad Request

클라이언트가 잘못된 요청을 해서 서버가 요청을 처리할 수 없다.

  • 요청 구문, 메시지 등등 오류
  • 클라이언트는 요청 내용을 다시 검토하고, 보내야한다.
  • 예) 요청 파라미터가 잘못되거나, API 스펙이 맞지 않을 때 발생 (백엔드 개발자가 철저하게 막아야한다.)

401 Unauthorized

클라이언트가 해당 리소스에 대한 인증이 필요함

  • 인증(Authentication)이 되지 않음
  • 401 오류 발생시 응답에 WWW-Authenticate 헤더와 함께 인증 방법을 설명
  • 인증(Authentication): 본인이 누구인지 확인, (로그인)
  • 인가(Authorization): 권한부여 (ADMIN 권한처럼 특정 리소스에 접근할 수 있는 권한, 인증이 있어야 인가가 있음)
  • 오류 메시지가 Unauthorized 이지만 인증 되지 않음 (이름이 아쉬움)

403 Forbidden

서버가 요청을 이해했지만 승인을 거부.

  • 주로 인증 자격 증명은 있지만, 접근 권한이 불충분한 경우
  • 예) 어드민 등급이 아닌 사용자가 로그인은 했지만, 어드민 등급의 리소스에 접근하는 경우

404 Not Found

요청 리소스를 찾을 수 없음

  • 요청 리소스가 서버에 없음
  • 또는 클라이언트가 권한이 부족한 리소스에 접근할 때 해당 리소스를 숨기고 싶을 때

5XX (Server Error)

  • 서버 문제로 오류 발생
  • 서버에 문제가 있기 때문에 재시도 하면 성공할 수도 있음(복구가 되거나 등등)

500 Internal Server Error

서버에 문제로 오류 발생, 애매하면 500 오류

503 Service Unavailable

서비스 이용 불가

  • 서버가 일시적인 과부하 또는 예정된 작업으로 잠시 요청을 처리할 수 없음
  • Retry-After 헤더 필드로 얼마뒤에 복구되는지 보낼 수도 있음

HTTP 헤더1 - 일반 헤더

HTTP 헤더 개요

HTTP 헤더는 HTTP 전송에 필요한 모든 부가정보를 넣는 곳이다. 예를들어, 메시지 바디의 내용, 메시지 바디의 크기, 압축, 인증, 요청 클라이언트, 서버 정보, 캐시 관리 정보 등등 무수히 많은 표준 정보를 넣을 수 있고, 또는 임의의 헤더를 넣어서 사용할 수 있다.

  • header-field = field-name “:” OWS field-value OWS (OWS: 띄어쓰기 허용)
  • field-name은 대소문자 구문 없음
GET /search?q=hello&hl=ko HTTP/1.1
Host: www.google.com
HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8
Content-Length: 3423

HTTP BODY - RFC7230

  • 메시지 본문(message body)을 통해 표현 데이터 전달
  • 메시지 본문 = 페이로드(payload)
  • 표현은 요청이나 응답에서 전달할 실제 데이터
  • 표현 헤더는 표현 데이터를 해석할 수 있는 정보 제공
    • 데이터 유형(html, json), 데이터 길이, 압축 정보 등등

표현

예전에는 헤더들을 엔티티라 불렀었는데 최근에는 표현(representation)으로 바꿨다. 왜냐하면 예전같은 경우에는 HTML로만 데이터를 응답했던 반면에 지금은 리소스를 HTML뿐만 아니라 XML, JSON 등으로 어떤 자료형태로 표현하여 응답이 가기 때문에 그에 맞춰 단어를 바꾸게 되었다. 아래의 헤더들을 활용해 표현 데이터의 정보를 기입해준다.

  • Content-Type: 표현 데이터의 형식
    • 미디어 타입, 문자 인코딩 예) text/html; charset=utf-8, application/json, image/png 등
  • Content-Encoding: 표현 데이터의 압축 방식
    • 표현 데이터를 압축하기 위해 사용, 데이터를 전달하는 곳에서 압축 후 인코딩 헤더를 추가하면 읽는 쪽에서 인코딩 헤더의 정보로 압축을 해제한다.
    • 예) gzip, deflate, identity
  • Content-Language: 표현 데이터의 자연 언어
    • 예) ko, en, en-US
  • Content-Length: 표현 데이터의 길이
    • 바이트 단위, Transfer-Encoding(전송 코딩)을 사용하면 Content-Length를 사용하면 안됨
  • 표현 헤더는 전송, 응답 둘다 사용
HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8
Content-Length: 3423

<html>
  <body>...</body>
</html>

협상(콘텐츠 네고시에이션)

클라이언트가 선호하는 표현 요청, 요청 시에만 사용한다.

  • Accept: 클라이언트가 선호하는 미디어 타입 전달
  • Accept-Charset: 클라이언트가 선호하는 문자 인코딩
  • Accept-Encoding: 클라이언트가 선호하는 압축 인코딩
  • Accept-Language: 클라이언트가 선호하는 자연 언어

만약 내가 한국어 브라우저를 사용하는데, 접근하려는 서버가 기본적으로 영어를 사용하고 부가적으로 한국어를 지원하면, 먼저 영어로 제공하게 된다. 이럴 때 클라이언트가 Accept-Language: ko 협상 헤더를 보내 한국어로 달라할 수 있다. 근데 서버에서 1순위 독일어, 2순위 영어를 지원하면? 한국어가 없기에 독일어를 받게 될 것이다. 이런 문제를 조금이나마 줄이기 위해 우선순위를 도입하게 된다.

우선순위 1

GET /event
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7

위의 구문을 보면 q가 있는데 이것은 Quality Values로 우선순위 값이다. 0~1을 할당하고 클수록 높은 우선순위를 갖게 된다. 생략하면 1이 된다. 만약 Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7

  1. ko-KR;q=1 (q생략)
  2. ko;q=0.9
  3. en-US;q=0.8
  4. en:q=0.7

이런 식으로 순위가 메겨진다.

우선순위 2

그리고 다음으로 구체적인 것을 우선으로 한다. Accept: text/*, text/plain, text/plain;format=flowed, */* 이런 헤더가 있으면, 아래와 같이 우선순위가 적용된다.

  1. text/palin;format=flowed
  2. text/plain
  3. text/*
  4. /

이런식으로 우선순위를 제공하고, 클라이언트는 자신의 환경에 맞게 적절한 헤더와 우선순위를 배치해서 서버에 보내면 된다.

전송 방식

서버에서 전송을 어떻게 할건지에 대해 여러 방식이 있다.

  • 단순 전송
    • 컨텐츠의 길이값을 알면 Content-Length: 3423 이런식으로 전송한다.
  • 압축 전송
    • 서버에서 gzip같은 형태로 줄인다면 Content-Encoding: gzip을 추가로 해서 무엇으로 압축했는지 알려준다.
  • 분할 전송
    • Transfer-Encoding: chunked(덩어리로 쪼개서)로 설정하여 데이터를 쪼개서 보낼 수 있다. 마지막에 \r\n으로 종료된다. 이때는 Content-Length를 넣으면 안된다.
  • 범위 전송
    • 클라이언트가 부분적으로 다시 보내달라 할때 쓴다. Range: bytes=1001-2000 이런식으로 요청하면, Content-Range: bytes 1001-2000 / 2000 이런식으로 전송해준다.

일반 정보

  • From: 유저 에이전트의 이메일 정보
    • 잘 사용되지 않는데, 검색 엔진 같은 곳에서 주로 사용한다. 요청에서 사용된다.
  • Referer: 이전 웹 페이지 주소
    • 크롤링할 때 정말 필요한 정보이다. A -> B로 이동하는 경우 B를 요청할 때 Referer: A를 포함해서 요청한다.
    • Referer를 사용해서 유입 경로를 분석할 수 있다.
    • 요청에서 사용되며, referrer의 오타인데 그냥 사용한다고 한다.
  • User-Agent: 유저 에이전트 애플리케이션 정보
    • 클라이언트 브라우저의 정보
    • 통계 정보에 사용
    • 어떤 종류의 브라우저에서 장애가 발생하는지 파악 가능하다.
    • 요청에서 사용
    • 이것 또한 크롤링할 때 사용되는 정보이다.
  • Server: 요청을 처리하는 오리진(실제 데이터를 주는) 서버의 소프트웨어 정보
    • Server: Apache/2.2.22 (Debian)
    • server: nginx
    • 응답에서 사용
  • Date: 메시지가 생성된 날짜
    • Date: Tue, 15 Nov 1994 08:12:31 GMT
    • 응답에서 사용

특별한 정보

  • Host: 요청한 호스트 정보(도메인)
    • 요청에서 사용하며 필수
    • 하나의 서버가 여러 도메인을 처리해야 할 때
    • 하나의 IP 주소에 여러 도메인이 적용되어 있을 때, 구분 지어서 관리해줄 수 있다.
  • Location: 페이지 리다이렉션
    • 웹 브라우저는 3XX 응답의 결과에 Location 헤더가 있으면, Location 위치로 자동 이동한다.(리다이렉트)
    • 201 (Created): Location 값은 요청에 의해 생성된 리소스 URI
    • 3XX (Redirection): Location 값은 요청을 자동으로 리디렉션하기 위한 대상 리소스를 가리킴
  • Allow: 허용 가능한 HTTP 메서드
    • 405 (Method Not aAllowed)에서 응답에 포함되어야함
    • Allow: GET, HEAD, PUT
  • Retry-After: 유저 에이전트가 다음 요청을 하기까지 기다려야 하는 시간
    • 503 (Service Unavailabe): 서비스가 언제까지 불능인지 알려줄 수 있음
    • Retry-After: Fri, 31 Dec 1999 23:59:59 GMT (날짜 표기)
    • Retry-After: 120 (초단위 표기)

인증

  • Authorization: 클라이언트 인증 정보를 서버에 전달
    • Authorization: Basic xxxxxxxxxxx
    • OAuth 인증 같은거를 공부해보자.
  • WWW-Authenticate: 리소스 접근시 필요한 인증 방법 정의
    • 401 Unauthorized 응답과 함께 사용
    • WWW-Authenticate: Newauth realm=”apps”, type=1, title”Login to " apps"”, Basic realm=”simple”

쿠키

Stateless

HTTP는 무상태 프로토콜이기 때문에 클라이언트와 서버가 요청과 응답을 주고 받으면 연결이 끊어진다. 그렇기에 클라이언트가 다시 요청하면 서버는 이전 요청을 기억하지 못하기에 로그인을 했다해도 다른 페이지에서는 이것을 알 도리가 없어진다. 대안으로는 모든 요청과 링크에 정보를 넣으면 되겠지만 사실 엄청난 노가다기에 말이 안된다. 그렇기에 쿠키를 사용한다.

  • Set-Cookie: 서버에서 클라이언트로 쿠키 전달(응답)
    • Set-Cookie: user=홍길동
    • 클라이언트는 이를 쿠키 저장소에 저장한다.
  • Cookie: 클라이언트가 서버에서 받은 쿠키를 저장하고, HTTP 요청시 서버로 전달
    • Cookie: user=홍길동
    • 해당 서버에 요청할때마다 쿠키 저장소를 찾아 위의 헤더를 보낸다.

그런데 위처럼 서버에다 무조건적으로 쿠키를 찾아 보내는것도 좋지는 않기에 관리가 필요하다. 서버에서 다음과 같이 쿠키를 설정해서 보내준다.

  • 예) set-cookie: sessionId=abcde1234; expires=Sat, 26-Dec-2020 00:00:00 GMT; path=/; domain=.google.com; Secure
  • 사용처
    • 사용자 로그인 세션 관리
    • 광고 정보 트래킹
  • 쿠키 정보는 항상 서버에 전송됨
    • 네트워크 트래픽 추가 유발
    • 최소한의 정보만 사용(세션 id, 인증 토큰)
    • 서버에 전송하지 않고, 웹 브라우저 내부에 데이터를 저장하고 싶으면 웹 스토리지를 (localStorage, sessionStorage) 참고해보자.
  • 주의할점
    • 보안에 민감한 데이터는 저장하면 안됨(주민번호, 신용카드 번호 등등)

쿠키 - 생명주기

쿠키를 무한정 저장하면 안되기에 생명주기를 설정한다.

  • Set-Cookie: expires=Sat, 26-Dec-2020 04:39:21 GMT
    • 만료일이 되면 쿠키 삭제
  • Set-Cookie: max-age=3600 (3600초)
    • 0이나 음수를 지정하면 쿠키 삭제
  • 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시까지만 유지
  • 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지

쿠키 - 도메인

  • 예) doamin=example.org
  • 명시: 명시한 문서 기준 도메인 + 서브 도메인 포함
    • domain=example.org를 지정해서 쿠키 생성
      • example.org는 물론이고
      • dev.example.org도 쿠키 접근
  • 생략: 현재 문서 기준 도메인만 적용
    • example.org에서 쿠키를 생성하고 domain 지정을 생략
      • example.org 에서만 쿠키 접근
      • dev.example.org는 쿠키 미접근

쿠키 - 경로

  • 예) path=/home
  • 이 경로를 포함한 하위 경로 페이지만 쿠키 접근
  • 일반적으로 path=/(루트)로 지정
    • path=/home로 지정했다면,
    • /home -> 가능
    • /home/level1 -> 가능
    • /home/level1/level2 -> 가능
    • /hello -> 불가능

쿠키 - 보안 (Secure, HttpOnly, SameSite)

  • Secure
    • 쿠키는 http, https를 구분하지 않고 전송
    • Secure를 적용하면 https인 경우에만 전송
  • HttpOnly
    • XSS 공격 방지
    • 자바스크립트에서 접근 불가(document.cookie)
    • HTTP 전송에만 사용
  • SameSite
    • XSRF 공격 방지
    • 요정 도메인과 쿠키에 설정된 도메인이 같은 경우만 쿠키 전송

HTTP 헤더2 - 캐시와 조건부 요청

캐시 기본 동작

캐시 기능이 없을 때는 클라이언트가 서버에 요청을 해서 그에 맞게 이미지를 전송해준다. 그리고 또 사진을 요청하면 서버에 또 요청하고 받게된다. 이렇게 되면 문제점이

  • 데이터가 변경되지 않아도 네트워크를 통해 데이터를 다운로드 받아야 한다.
  • 인터넷 네트워크는 매우 느리고 비싿.
  • 브라우저 로딩 속도도 느려져, 사용자도 불편해진다.

그렇기에 캐시 기능을 적용을 하게 된다. 아래와 같이 헤더에 cache-control: max-age=60를 추가하여 보내준다. 이를 받은 브라우저는 브라우저 캐시 저장소에 캐시를 저장한다. 그렇게 되면 유효기간동안은 같은 사진은 캐시에서 조회하게 되는 것이다. 이렇게 되면 위의 문제점들이 해결된다.

HTTP/1.1 200 OK
Content-Type: image/jpeg
cache-control: max-age=60 (초 단위)
Content-Length: 34012

adfasdfasdf....

근데 만약 세 번째 요청 때 캐시 시간이 초과되면 어떻게 될까? 그러면 다시 서버에다가 요청을 하게 되는 것이다. 근데 내용이 안바뀌었는데 또 요청을 하게 되면 불필요한 다운로드가 일어나게 되는 것이다. 이를 또 해결하는 방법이 있다.

검증 헤더와 조건부 요청1

캐시 유효 시간이 초과해서 서버에 다시 요청하면 다음 두 가지 상황이 나타난다.

  1. 서버에서 기존 데이터를 변경함
  2. 서버에서 기존 데이터를 변경하지 않음 -> 로컬 데이터를 쓰게 해줘야 한다. 검증 헤더 추가

아래처럼 데이터가 마지막에 수정된 시간을 추가해준다.

cache-control: max-age=60
Last-Modified: 2020년 11월 10일 10:00:00

이렇게 받은 내용을 캐시에 저장한다. 그러고 유효시간이 만료되면 클라이언트는 요청을 보낸다. if-modified-since를 보내 자신이 가진 데이터의 최종 수정일을 보낸다.

GET /star.jpg
if-modified-since: 2020년 11월 10일 10:00:00

서버는 해당 헤더의 날짜를 보고 수정되지 않았음이 판단되면, 다음과 같이 보낸다.

HTTP/1.1 304 Not Modified
Content-Type: image/jpeg
cache-control: max-age=60
Last-Modified: 2020년 11월 10일 10:00:00
Content-Length: 34012

HTTP Body 없이 304 코드를 보내 수정되지 않음을 알려준다. 이러면 브라우저는 유효시간을 다시 세팅하고, 캐시 저장소에 있는 데이터를 사용한다. Body만 없어도 네트워크 부하가 확 줄어든다.

크롬 개발도구에서 status 항목의 색상이 회색인것은 캐시에서 불러온 것이다.

검증 헤더와 조건부 요청2

  • 검증 헤더
    • 캐시 데이터와 서버 데이터가 같은지 검증하는 데이터
    • Last-Modified, ETag
  • 조건부 요청 헤더
    • 검증 헤더로 조건에 따른 분기
    • If-Modified-Since: Last-Modified 사용
    • If-None-Match: ETag 사용
    • 조건이 만족하면 200 OK
    • 조건이 만족하지 않으면 304 Not Modified

위에서 다룬 Last-Modified, If-Modified-Since의 단점은 다음과 같다.

  • 1초 미만(0.X초) 단위로 캐시 조정이 불가능
  • 날짜 기반의 로직 사용
  • 데이터를 수정해서 날짜가 다르지만, 같은 데이터를 수정해서 데이터 결과가 똑같은 경우
  • 서버에서 별도의 캐시 로직을 관리하고 싶은 경우
    • 예) 스페이스나 주석처럼 크게 영향이 없는 변경에서 캐시를 유지하고 싶은 경우

ETag, If-None-Match

  • ETag(Entity Tag)
  • 캐시용 데이터에 임의의 고유한 버전 이름을 달아줌
    • 예) ETag: “v1.0”, ETag: “a131231321”
  • 데이터가 변경되면 이 이름을 바꾸어서 변경함(Hash를 다시 생성)
    • 예) ETag: “aaaaa” -> ETag: “bbbbb”
  • 단순하게 ETag만 보내서 같으면 유지하고, 다르면 다시 받는다.
  • 캐시 제어 로직을 서버에서 완전히 관리하여 클라이언트는 단순히 이 값을 서버에 제공한다. 즉, 클라이언트는 캐시 메커니즘을 몰라도 된다.
    • 예) 서버는 베타 오픈 기간인 3일 동안 파일이 변경되어도 ETag를 동일하게 유지
    • 애플리케이션 배포 주기에 맞추어 ETag 모두 갱신

아래처럼 헤더에 ETag를 추가해서 클라이언트에 보내준다.

ETag: "aaaaaaaa"

그러면 클라이언트는 캐시 저장소에 ETag를 저장한다. 그리고 만료시간이 보내면 클라이언트는 요청을 보낸다.

GET /star.jpg
If-None-Match: "aaaaaaa"

서버는 변함이 없으면 304 Not Modified 응답코드를 보낸다. 그럼 클라이언트는 다시 유효시간을 세팅한다.

캐시와 조건부 요청 헤더

  • Cache-Control: 캐시 제어
    • Cache-Control: max-age: 캐시 유효 시간 설정, 초 단위
    • Cache-Control: no-cache: 데이터는 캐시해도 되지만, 항상 원(Origin) 서버에 검증하고 사용
    • Cache-Control: no-store: 데이터에 민감한 정보가 있으므로 저장하면 안됨(메모리에서 사용하고 최대한 빨리 삭제)
  • Pragma: 캐시 제어(하위 호환)
    • Pragma: no-cache
    • HTTP/1.0 이전 버전에 사용
  • Expires: 캐시 유효 기간(하위 호환)
    • expires: Mon, 01 Jan 1990 00:00:00 GMT
    • 캐시 만료일을 정확한 날짜로 지정
    • HTTP 1.0 부터 사용
    • 지금은 더 유연한 Cache-Control: max-age 권장
    • Cache-Control: max-age와 함께 사용하면 Expires는 무시된다.
  • 검증 헤더 (Validators)
    • ETag: “v1.0”, ETag: “asdfas2”
    • Last-Modified: Thu, 04 Jun 2020 07:19:24 GMT
  • 조건부 요청 헤더
    • If-Match, If-None-Match: ETag 값 사용
    • If-Modified-Since, If-Unmodified-Since: Last-Modified 값 사용

프록시 캐시

원(Origin)서버가 만약에 미국에 있다면? 인터넷 속도가 엄청 빠르긴 하다만 한국에 있는 것보다는 오래걸리게 될 것이다. 그렇게 해서 도입된게 프록시 캐시 서버이다. CDN서비스라 해서 자국 내에 원 서버와 같은 캐시 서버를 놓아 한국 서버를 이용하게 되게끔 한다. 프록시 캐시 서버에 있는 캐시를 public 캐시라 하고 내 브라우저에 저장되는 캐시는 private 캐시라 지칭한다.

캐시 지시어(directives)

  • Cache-Control: public - 응답이 public 캐시에 저장되어도 됨
  • Cache-Control: private - 응답이 해당 사용자만을 위한 것임, private 캐시에 저장해야 함(기본값)
  • Cache-Control: s-maxage - 프록시 캐시에만 적용되는 max-age
  • Age: 60 (HTTP 헤더) - 오리진 서버에서 응답 후 프록시 캐시 내에 머문 시간(초)

캐시 무효화

Cache-Control에 확실한 캐시 무효화를 하는 응답이 있다. 간혹 들어 브라우저가 임의로 캐싱을 하는 경우가 있는데 이를 막을 필요가 있다.

  • Cache-Control: no-cache, no-store, must-revalidate
  • Pragma: no-cache (HTTP 1.0 하위 호환)

캐시 지시어(directives) - 확실한 캐시 무효화

  • Cache-Control: no-cache
    • 데이터는 캐시해도 되지만, 항상 원 서버에 검증하고 사용(이름에 주의)
  • Cache-Control: no-store
    • 데이터에 민감한 정보가 있으므로 저장하면 안됨(메모리에서 사용하고 최대한 빨리 삭제)
  • Cache-Control: must-revalidate
    • 캐시 만료후 최초 조회시 원 서버에 검증해야함
    • 원 서버 접근 실패시 반드시 오류가 발생해야함 - 504(Gateway Timeout)
    • must-revalidate는 캐시 유효 시간이라면 캐시를 사용함
  • Pragma: no-cache
    • HTTP 1.0 하위 호환

2021-09-02-스프링핵심원리-by-김영한님

|

김영한님의 강의를 듣고 정리하는 글이다.

객체 지향 설계와 스프링

자바 진영의 추운 겨울과 스프링의 탄생

2000년대 초반에는 EJB(Enterprise Java Beans)라는 기술을 사용했었다. 근데 너무 복잡하고, 비쌌다.(한 대당 수천만원 가량..!) 그리고 EJB에 너무 의존적이게 되어 개발하기가 어려웠다. 그렇게 고통을 받고 있다가 로드 존슨이라는 사람이 책을 하나 냈는데, 이것이 지금의 스프링의 기초 토대가 되었다. 또한 EJB에서는 엔티티빈이라 해서 ORM을 제공했는데 이 또한 사용하긴 별로여서 개빈 킹이라는 인물이 Hibernate를 만들었고 현재는 거의 표준처럼 사용하게 되었다.

JPA는 인터페이스가 Hibernate는 구현체가 된다.

스프링 역사

  • 2002년 로드 존슨 책 출간
  • EJB의 문제점 지적
  • EJB가 없어도 충분히 고품질의 확장 가능한 애플리케이션을 개발할 수 있음을 보여주고, 30,000라인 이상의 기반 기술을 예제 코드로 선보였다.
  • 지금의 핵심 개념과 기반 코드가 들어있어서 개발자들이 이 책을 참고해서 개발했다.
  • BeanFactory, ApplicationContext, POJO, 제어의 역전, 의존 관계등이 기술되어었다.
  • 이후 유겐 휠러와 얀 카로프가 오픈소스로 만들자고 하여 되었다.
  • EJB라는 겨울을 넘어 새로운 시작이란 뜻으로 Spring이라고 지어졌다.

스프링이란

스프링은 하나만 있는게 아니라 여러가지의 기술의 모음이라고 봐야 한다.

  • 필수: 스프링 프레임워크, 스프링 부트
  • 선택:
    • 스프링 데이터: CRUD를 쉽게 해줄 수 있게 도와줌. (스프링 데이터 JPA)
    • 스프링 세션: 세션 기능을 관리
    • 스프링 시큐리티: 보안 관리
    • 스프링 Rest Docs: API 문서화 편히 해줌
    • 스프링 배치: 배치 처리를 관리
    • 스프링 클라우드: 클라우드 기술 관리

스프링 프레임워크

  • 핵심 기술: 스프링 DI 컨테이너, AOP, 이벤트, 기타
  • 웹 기술: 스프링 MVC, 스프링 WebFlux
  • 데이터 접근 기술: 트랜잭션, JDBC, ORM 지원, XML 지원
  • 기술 통합: 캐시, 이메일, 원격접근, 스케줄링
  • 테스트: 스프링 기반 테스트 지원
  • 언어: 코틀린, 그루비

스프링 부트를 활용하면 스프링 프레임워크의 기술들을 편리하게 사용할 수 있다.

스프링 부트

  • 스프링을 편리하게 사용할 수 있도록 지원, 최근에는 기본으로 사용한다.
  • 단독으로 실행할 수 있는 스프링 애플리케이션을 쉽게 생성
  • Tomcat 같은 웹 서버를 내장해서 별도의 웹 서버를 설치하지 않아도 됨
  • 손쉬운 빌드 구성을 위한 starter 종속성 제공
  • 스프링과 3rd party(외부) 라이브러리 자동 구성
  • 메트릭, 상태 확인, 외부 구성 같은 프로덕션 준비 기능 제공
  • 관례에 의한 간결한 설정

스프링 부트는 프레임워크를 보조해주는거지 따로 쓸 수 있는 것은 아니다.

스프링 단어

스프링이라는 단어는 문맥에 따라 다르게 사용된다.

  • 스프링 DI 컨테이너 기술
  • 스프링 프레임워크
  • 스프링 부트, 스프링 프레임워크 등을 모두 포함한 스프링 생태계

스프링은 왜 만들어 졌을까

모든 기술에는 핵심 개념이 있다. 스프링도 지금 엄청 거대한데 처음에는 3만줄에서 시작되었다. 도대체 스프링에는 무슨 핵심 개념이 있었기에 이렇게 거대해질 수 있었을까? 전자 정부 프레임워크라서? DB 관리 등을 편히 해주어서? 그것들은 결과물들이고 핵심은 아래와 같다.

  • 스프링은 자바 언어 기반의 프레임워크이다.
  • 자바 언어의 가장 큰 특징은 객체 지향 언어이다.
  • 스프링은 객체 지향 언어가 가진 강력한 특징을 살려내는 프레임워크이다.
  • 스프링은 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크이다.

EJB 시절에는 EJB에 종속되어가지고 자바의 객체 지향을 잃게 되었었는데, 스프링은 DI를 활용하여 이를 해결해주었다.

좋은 객체 지향 프로그래밍이란

객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 “객체”들의 모임으로 파악하고자 하는 것이다. 각각의 객체는 메시지를 주고받고, 데이터를 처리할 수 있다. (협력)

객체 지향 특징

  • 추상화
  • 캡슐화
  • 상속
  • 다형성

객체 지향 프로그래밍은 프로그램을 유연하고 변경이 용이하게 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다. 이런 유연하고 변경이 용이하게 해주는것이 다형성 덕분이다. 다형성을 위해서는 역할과 구현을 분리해서 생각해야 한다. 가령, 운전자가 있고 자동차가 있다. 운전자는 자동차가 K3든 테슬라든 아무리 차가 바뀌어도 자동차의 기능만 알면 사용할 수 있다. 이처럼 역할과 구현으로 구분하면 세상이 단순해지고, 유연해지면 변경도 편해진다. 이의 장점으론 다음과 같다.

  • 클라이언트는 대상의 역할(인터페이스)만 알면 된다.
  • 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
  • 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
  • 클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않는다.

자바에서는 역할은 인터페이스가 되고, 구현은 인터페이스를 구현한 클래스, 구현 객체가 된다. 객체를 설계할 때 역할과 구현을 명확히 분리해준다. 객체를 설계할 때 역할(인터페이스)를 먼저 부여하고, 그 역할을 수행하는 구현 객체를 만들어 준다. 혼자 있는 객체는 없기에 클라이언트(요청)와 서버(응답) 관계로 서로 협력을 한다.

다형성의 본질

  • 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다.
  • 다형성의 본질을 이해하려면 협력이라는 객체사이의 관계에서 시작해야 한다.
  • 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.

스프링과 객체 지향

  • 다형성이 가장 중요하다.
  • 스프링은 다형성을 극대화해서 이용할 수 있게 도와준다.
  • 스프링에서는 제어의 역전, 의존관계 주입은 다형성을 활용해서 역할과 구현을 편리하게 다룰 있도록 지원한다.

좋은 객체 지향 설계의 5가지 원칙 (SOLID)

SOLID는 클린코드로 유명한 로버트 마틴이 좋은 객체 지향 설계의 5가지 원칙을 정리한 것이다.

  • SRP: 단일 책임 원칙 (Single Responsibility Principle)
  • OCP: 개방-폐쇄 원칙 (Open/Closed Principle)
  • LSP: 리스코프 치환 원칙 (Liskov Substitution Principle)
  • ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)
  • DIP: 의존관계 역전 원칙 (Dependency Inversion Principle)

SRP 단일 책임 원칙

  • 한 클래스는 하나의 책임만 가져야 한다.
  • 하나의 책임이라는 것은 모호하다.
    • 클 수 있고, 작을 수 있다.
    • 문맥과 상황에 따라 다르다.
  • 중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다.

OCP 개방-폐쇄 원칙

  • 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
  • 다형성을 활용한다.
  • 인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현

아래의 코드를 봐보자.

// MemberRepository m = new MemoryMemberRepository();
MemberRepository m = new JdbcMemberRepository();

구현체를 바꾸기 위해서 코드를 수정하였다. 이렇게 되면 다형성을 이용한거지만 OCP원칙이 지켜지지 않은 것이다. 왜냐, 수정을 한 것이기 때문이다. 이런 문제를 해결하려면 객체를 생성하고, 연관관계를 맺어주는 별도의 조립, 설정자가 필요하다.

LSP 리스코프 치환 원칙

  • 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
  • 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것, 다형성을 지원하기 위한 원칙, 인터페이스를 구현한 구현체는 믿고 사용하려면 이 원칙이 필요하다.

만약에 자동차라는 인터페이스에서 엑셀은 앞으로 가게끔 규정해야한다. 그런데 뒤로가게 하는 구현체를 만들면 LSP를 위반하게 되는 것이다. 느리든 빠르든 앞으로 가게끔 구현해야 한다.

ISP 인터페이스 분리 원칙

  • 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
  • 자동차 인터페이스 -> 운전 인터페이스, 정비 인터페이스로 분리
  • 사용자 클라이언트 -> 운전자 클라이언트, 정비사 클라이언트로 분리
  • 분리하면 정비 인터페이스 자체가 변해도 운전자 클라이언트에 영향을 주지 않는다.
  • 인터페이스가 명확해지고, 대체 가능성이 높아진다.

DIP 의존관계 역전 원칙

  • 프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다.” 의존성 주입은 이 원칙을 따르는 방법 중 하나다.
  • 구현 클래스에 의존하지 말고, 인터페이스에 의존하라이다.
  • 즉, 역할에 의존하게 해야 한다는 것이다. 객체 세상에서도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있다. 구현체에 의존하게 되면 변경이 아주 어려워진다.
MemberRepository m = new MemoryMemberRepository();

위 코드는 인터페이스에 의존하는 동시에 구현 클래스도 동시에 의존한다. 왜냐하면 new를 통해 구현 클래스를 직접 선택했기 때문이다. 이렇게 되면 DIP를 위반하게 되는 것이다.

객체 지향 설계와 스프링

스프링은 DI(Dependency Injection)와 DI 컨테이너를 제공함으로써 OCP와 DIP를 해결해준다. 이를 통해 클라이언트 코드의 변경 없이 기능 확장을 할 수 있고, 쉽게 부품을 교체하듯이 개발할 수 있다.

스프링 핵심 원리 이해1 - 예제 만들기

비즈니스 요구사항과 설계

  • 회원
    • 회원을 가입하고 조회
    • 일반 등급과 VIP 등급
    • 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다.
  • 주문
    • 회원은 상품을 주문할 수 있다.
    • 회원 등급에 따라 할인 정책을 적용할 수 있다.
    • 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용 (나중에 변경될 수 있음)
    • 할인 정책은 변경 가능성이 높다. 회사는 아직 정책을 정하지 못했고, 오픈 직전까지 고민을 미루는 중이다. 최악의 경우 할인 적용을 하지 않을 수 있다.

미확정된 부분으로 개발을 무기한 연장할 순 없다. 이때 객체 지향 설계 방법을 활용하여 해결해본다. 바로 인터페이스를 만들고 구현체를 언제든지 갈아끼울 수 있도록 설계한다.

회원 도메인 설계

영한님의 자료에 관계도와 다이어그램이 있다.

회원 도메인 개발

github 참조

인터페이스의 구현체가 하나일 땐 관례적으로 클래스명 끝에 Impl을 붙혀준다고 한다. ex) MemberServiceImpl

주문과 할인 도메인 설계

  1. 주문 생성: 클라이언트는 주문 서비스에 주문 생성을 요청
  2. 회원 조회: 할인을 위해서 등급 필요. 주문 서비스는 회원 저장소에서 회원 조회
  3. 할인 적용: 주문 서비스는 회원 등급에 따른 할인 여부를 할인 정책에 위임한다.
  4. 주문 결과 반환: 주문 서비스는 할인 결과를 포함한 주문 결과를 반환한다.

스프링 핵심 원리 이해2 - 객체 지향 원리 적용

새로운 할인 정책 적용과 문제점

order 구현체에서 할인 정책을 바꿔보자.

public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();
//    private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}
  • 역할과 구현을 충실하게 분리는 했다.
  • 다형성도 활용하고, 인터페이스와 구현 객체를 분리했다.
  • OCP, DIP 같은 객체지향 설계 원칙을 충실히 준수한걸로 보이지만…
    • 추상(인터페이스)뿐만 아니라 구체(구현) 클래스에도 의존하고 있다.
      • 추상(인터페이스) 의존: DiscountPolicy
      • 구체(구현) 클래스: FixDiscountPolicy, RateDiscountPolicy
    • OCP 또한 못 지켰다. 코드를 변경했기 때문이다. 이러면 클라이언트에 영향을 주게 된다.

관심사의 분리

위의 코드의 상황을 하나의 공연으로 치자면 배역(인터페이스)와 남자 배우(구현체)가 있다고 친다. 지금 상황은 남자 배우가 여자 배우를 직접 부르는 것과 같다. 본인 일에만 집중해야 하는데, 여러 가지 일을 맡게 된 것이다. 이를 방지하기 위해선 관심사를 분리해야 한다. 즉, 공연 기획자를 데려와 공연을 구성하고, 배우 섭외와 배우를 지정해주는 역할을 해주어야 한다.

AppConfig

애플리케이션의 전체 동작 방식을 구성(config)하기 위해, 구현 객체를 생성하고 연결하는 책임을 가지는 별도의 설정 클래스이다.

package hello.core;

import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository());
    }

    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
    }
}

package hello.core;

import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;

public class MemberApp {

    public static void main(String[] args) {
        AppConfig appConfig = new AppConfig();
        MemberService memberService = appConfig.memberService();
        Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);

        Member findMember = memberService.findMember(1L);
        System.out.println("new member = " + member.getName());
        System.out.println("find member = " + findMember.getName());
    }
}

이런식으로 해두면 Service에서는 인터페이스만 의존하고, 실행만하면 된다.

IOC, DI, 컨테이너

제어의 역전 IoC(Inversion of Control)

기존 프로그램은 클라이언트 구현 객체가 스스로 필요한 서버 구현 객체를 생성하고, 연결하고, 실행했다. 즉 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다. 개발자의 입장에서는 자연스러운 흐름이다. 반면 AppConfig가 등장하여 구현 객체는 자신의 로직을 실행하는 역할만 담당한다. 프로그램의 제어 흐름은 이제 AppConfig가 가져가게 되어 OrderServiceImpl은 필요한 인터페이스들을 호출하지만 어떤 구현 객체들이 실행될지 모른다. 다시말해 제어 흐름에 대한 것들은 AppConfig가 가지고 있고 위의 OrderServiceImpl도 AppConfig가 생성한다. 뿐만 아니라 다른 인터페이스의 다른 구현 객체를 생성하고 실행한다. 이렇게 되면 OrderServiceImpl은 자신의 로직만 실행하면 된다. 이렇듯 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전이라고 한다.

프레임워크 vs 라이브러리**

  • 프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크이다. (JUit)
  • 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 프레임워크가 아니라 라이브러리다.

의존관계 주입 DI(Dependency Injection)

OrderServiceImpl은 DiscountPolicy라는 인터페이스에 의존한다. 실제 어떤 구현 객체가 사용될지는 모른다. 의존관계는 정적인 클래스 의존 관계와, 실행 시점에 결정되는 동적인 객체(인스턴스) 의존 관계 둘을 분리해서 생각해야 한다.

정적인 클래스 의존관계

클래스가 사용하는 import 코드만 보고 의존관계를 쉽게 판단할 수 있다. 정적인 의존관계는 애플리케이션을 실행하지 않아도 분석할 수 있다. 예로 OrderServiceImpl은 MeberRepository, DiscountPolicy에 의존한다는 것을 알 수는 있다. 하지만 어떤 구현 객체가 주입될지는 알 수 없다.

동적인 객체 인스턴스 의존관계

애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결 되는 것을 의존관계 주입이라고 한다. 객체 인스턴스를 생성하고, 그 참조값을 전달해서 연결되는 것이다. 이 의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다. 또한 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.

IoC 컨테너 or DI 컨테이너

  • AppConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 IoC 또는 DI 컨테이너라고 한다.
  • 의존관계 주입에 초점을 맞추어 최근에는 DI 컨테이너라 한다.
  • 또는 어샘블러, 오브젝트 팩토리 등으로 불리기도 한다.

스프링으로 전환하기

이제 슬슬 스프링으로 넘어가는 설정을 하겠다. AppConfig에 다음과 같이 어노테이션들을 달아주자.

어노테이션은 발표해본게 있으니 정리하겠다.

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public DiscountPolicy discountPolicy() {
//        return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

public class MemberApp {

    public static void main(String[] args) {
//        AppConfig appConfig = new AppConfig();
//        MemberService memberService = appConfig.memberService();

        ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
        MemberService memberService = applicationContext.getBean("memberService", MemberService.class);

        Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);

        Member findMember = memberService.findMember(1L);
        System.out.println("new member = " + member.getName());
        System.out.println("find member = " + findMember.getName());
    }
}

MemberApp에 AnnotationConfigApplicationContext를 생성했는데 이것은 어노테이션 기반으로 빈 객체를 만들어주는 클래스이다. 이를 토대로 우리가 @Bean으로 등록한 객체들을 싱글톤 객체로 관리해준다. 실행하면 아래와 같이 출력된다.

19:22:30.034 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@17d677df
19:22:30.085 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor'
19:22:30.702 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerProcessor'
19:22:30.705 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerFactory'
19:22:30.708 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor'
19:22:30.711 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor'
19:22:30.735 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'appConfig'
19:22:30.749 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'memberService'
19:22:30.823 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'memberRepository'
19:22:30.836 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'orderService'
19:22:30.839 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'discountPolicy'

스프링 컨테이너와 스프링 빈

스프링 컨테이너 생성

  • ApplicationContext를 스프링 컨테이너라 한다.
  • ApplicationContext는 인터페이스이다. 즉 다형성이 적용된다.
  • XML로도 할 수 있지만 자바 설정을 많이 쓴다.

컨테이너에 등록된 모든 빈 조회

@Test
@DisplayName("애플리케이션 빈 출력하기")
void findApplicationBean() {
    String[] beanDefinitionNames = ac.getBeanDefinitionNames();
    for (String beanDefinitionName : beanDefinitionNames) {
        BeanDefinition beanDefinition = ac.getBeanDefinition(beanDefinitionName);

        // Role ROLE_APPLICATION: 직접 등록한 애플리케이션 빈
        // Role ROLE_INFRASTRUCTURE: 스프링이 내부에서 사용하는 빈
        if (beanDefinition.getRole() == BeanDefinition.ROLE_APPLICATION) {
            Object bean = ac.getBean(beanDefinitionName);
            System.out.println("name = " + beanDefinitionName + " object name = " + bean);
        }
    }
}

스프링 빈 조회

스프링 컨테이너에서 스프링 빈을 찾는 가장 기본적인 조회 방법은 다음과 같다.

  • ac.getBean(빈이름, 타입)
  • ac.getBean(타입)
  • 조회 대상 스프링 빈이 없으면 예외 발생한다.
    • NoSuchBeanDefinitionException: No bean named ‘xxxx’ available
package hello.core.beanfind;

import hello.core.AppConfig;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class ApplicationContextBasicFindTest {

    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    @Test
    @DisplayName("빈 이름으로 조회")
    void findBeanByName() {
        MemberService memberService = ac.getBean("memberService", MemberService.class);
        assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
    }

    @Test
    @DisplayName("이름 없이 타입으로만 조회")
    void findBeanByType() {
        MemberService memberService = ac.getBean(MemberService.class);
        assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
    }

    @Test
    @DisplayName("구체 타입으로 조회")
    void findBeanByName2() {
        MemberService memberService = ac.getBean("memberService", MemberServiceImpl.class);
        assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
    }

    @Test
    @DisplayName("빈 이름으로 조회X")
    void findBeanByNameX() {
//        ac.getBean("xxxxx", MemberService.class);
        assertThrows(NoSuchBeanDefinitionException.class, () -> ac.getBean("xxxxx", MemberService.class));
    }
}

스프링 빈 조회 - 동일한 타입이 둘 이상

public class ApplicationContextSameBeanFindTest {

    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SameBeanConfig.class);

    @Test
    @DisplayName("타입으로 조회시 같은 타입이 둘 이상 있으면, 중복 오류가 발생한다")
    void findBeanByTypeDuplicate() {
        MemberRepository bean = ac.getBean(MemberRepository.class);
    }

    @Configuration
    static class SameBeanConfig {

        @Bean
        public MemberRepository memberRepository1() {
            return new MemoryMemberRepository();
        }

        @Bean
        public MemberRepository memberRepository2() {
            return new MemoryMemberRepository();
        }
    }
}

위와 같이 타입만 지정해두면 동일한 타입을 가진 빈이 2개가 있기에 예외를 발생시킨다.

NoUniqueBeanDefinitionException

BeanFactory와 ApplicationContext

다양한 설정 형식 지원 - 자바 코드, XML

스프링 컨테이너는 다양한 형식의 설정 정보를 받아드릴 수 있게 유연하게 설계되어있다.

ex) 자바 코드, XML, Groovy 등

  • XML 설정 사용

최근에는 스프링 부트를 많이 사용하면서 잘 사용하지 않는다. 아직 레거시 프로젝트들이 XML로 이루어져있고, XML을 사용하면 컴파일 없이 빈 설정 정보를 변경할 수 있는 장점도 있다. GenericXmlApplicationContext를 사용한다.

package hello.core.xml;

import hello.core.member.MemberService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericXmlApplicationContext;
import org.springframework.test.context.support.GenericXmlContextLoader;

public class XmlAppContext {

    @Test
    void xmlAppContext() {
        ApplicationContext ac = new GenericXmlApplicationContext("appConfig.xml");
        MemberService memberService = ac.getBean("memberService", MemberService.class);
        Assertions.assertThat(memberService).isInstanceOf(MemberService.class);
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id="memberService" class="hello.core.member.MemberServiceImpl">
        <constructor-arg name="memberRepository" ref="memberRepository" />
    </bean>

    <bean id="memberRepository" class="hello.core.member.MemoryMemberRepository"/>

    <bean id="orderService" class="hello.core.order.OrderServiceImpl">
        <constructor-arg name="memberRepository" ref="memberRepository"/>
        <constructor-arg name="discountPolicy" ref="discountPolicy"/>
    </bean>

    <bean id="discountPolicy" class="hello.core.discount.RateDiscountPolicy"/>
</beans>

이렇게 스프링은 정말 유연하게 여러 설정을 제공해준다.

스프링 빈 설정 메타 정보 - BeanDefinition

스프링이 이렇게 다양한 설정 형식을 지원할 수 있는 이유는 BeanDefiniton이라는 추상화가 있어서다. 쉽게 말해 역할과 구현을 개념적으로 나눈 것이다.

예로 XML을 읽어서 BeanDefinition을 만들거나, 자바 코드를 읽어서 BeanDefinition을 만든다. 스프링 컨테이너는 자바 코드인지, XML인지 몰라도 된다. 오직 BeanDefinition만 알면 된다. 이 BeanDefinition을 빈 설정 메타정보라고 한다. @Bean이나 <bean> 당 각각 하나씩 메타 정보가 생성된다.

싱글톤 컨테이너

웹 애플리케이션과 싱글톤

스프링은 태싱이 기업용 온라인 서비스 기술을 지원하기 위해 탄생했다. 대부분의 스프링 애플리케이션은 웹 애플리케이션이다. 웹 뿐만 아니라 애플리케이션 개발도 얼마든지 할 수 있다. 웹 애플리케이션은 보통 여러 고객이 동시에 요청을 한다. 요청을 할 때마다 객체를 생성하고 소비하는 것은 낭비가 될 수도 있다. 만약 고객 트래픽이 초당 100이 나오면 초당 100개 객체가 생성되고 소멸되는 것이다. 이를 방지하기 위해 하나의 객체만 생성해서 그것을 공유하도록 설계하면 되는 것이다. 이런 디자인 패턴을 싱글톤 패턴이라고 한다.

싱글톤 패턴

싱글톤 패턴은 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다. 그렇기에 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다. 즉, private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용하지 못하도록 막아야 한다.

public class SingletonService {

    private static final SingletonService instance = new SingletonService();

    public static SingletonService getInstance() {
        return instance;
    }

    private SingletonService() {
    }

    public void login() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}

이렇게 해두면 jvm이 올라오면서 static final SingletonService 부분을 초기화해두어서 접근할 수 있게 해준다. 그리고 생성자를 private으로 했기에 new로 접근할 수 없고 오직 getInstance() 스태틱 메소드로 접근할 수 있도록 해준다. 이런 싱글톤 패턴을 적용하면 고객의 요청이 올 때 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 는 있지만 다음과 같은 문제점을 가지고 있다.

  • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어감.
  • 의존관계상 클라이언트가 구체 클래스에 의존 -> DIP 위반
  • 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
  • 테스트하기 어렵다.
  • 내부 속성을 변경하거나 초기화 하기 어렵다.
  • private 생성자로 자식 클래스를 만들기 어렵다.
  • 위와 같은 문제로 유연성이 떨어지고 안티패턴으로 될 수도 있다.

싱글톤 컨테이너

스프링 컨테이너는 위의 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤(1개만 생성)으로 관리해준다. 바로 빈 객체를 만들어서 해주는 것이다. 스프링 컨테이너는 싱글턴 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다. 이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다. 스프링의 컨테이너의 이런 기능 덕분에 우리는 싱글턴 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있는 것이다. 참고로 스프링 컨테이너는 싱글톤 방식뿐만 아니라 다른 방식도 설정할 수 있다. 근데 거의 싱글톤으로 많이 쓴다고 한다.

싱글톤 방식의 주의점

싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다. 즉, 무상태(stateless)로 설계해야 한다. 다시 정리하면

  • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
  • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
  • 가급적 읽기만 가능해야 한다.
  • 필드 대신에 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.
  • 스프링 빈이 필드에 공유 값을 설정하면 정말 큰 장애가 발생할 수 있다.
package hello.core.singleton;

public class StatefulService {

    private int price; // 상태를 유지하는 필드

    public void order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
        this.price = price; // 여기서 문제 발생
    }

    public int getPrice() {
        return price;
    }
}

class StatefulServiceTest {

    @Test
    void statefulServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean(StatefulService.class);
        StatefulService statefulService2 = ac.getBean(StatefulService.class);

        // ThreadA: A 사용자가 10000원 주문
        statefulService1.order("userA", 10000);

        // ThreadB: B 사용자가 20000원 주문
        statefulService2.order("userB", 20000);

        // ThreadA: 사용자A 주문 금액 조회
        int price = statefulService1.getPrice();
        System.out.println("price = " + price);

        Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }

    static class TestConfig {

        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}

위의 코드대로 하면은 우리는 분명 A 사용자가 만원을 했는데 중간의 B 사용자로 인해 A 사용자의 돈도 2만원으로 찍히게 되는 것이다. 이러면 현장에서 정말 큰 장애가 발생하는 것이다. 그렇기에 스프링 빈은 반드시 무상태(stateless)로 설계해야 한다.

@Configuration 싱글톤

@Configuration 바이트 조작의 마법

컴포넌트 스캔

컴포넌트 스캔과 의존관계 자동 주입 시작하기

지금까지는 스프링 빈을 등록할 때 자바 코드에 @Bean이나 XML의 bean 등을 통해 설정 정보에 직접 등록할 스프링 빈을 나열했다. 그런데 현업에서는 이렇게 등록해야할 빈이 수십, 수백개가 되면 관리하기가 많이 힘들어 진다.

일단 개발자들은 반복을 싫어한다.

그래서 스프링에서는 설정 정보가 없어도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔이라는 기능을 제공한다. 또한 의존관계도 자동으로 주입하는 @Autowired라는 기능도 제공한다.

package hello.core;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;

@Configuration
@ComponentScan(
        excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
)
public class AutoAppConfig {

}
  • @ComponentScan을 통해 @Component들을 찾아 빈 객체로 등록을 해준다.
  • excludeFilters에서 @Configuration 어노테이션은 빈으로 등록 안하도록 설정해주었다. 기존의 AppConfig 클래스를 등록하면 중복이 발생하니까.
  • @Component를 붙혔으면 @ComponentScan가 찾아서 빈으로 등록하는데 클래스명을 사용하면서 맨 앞글자를 소문자로 바꾼다. 만약 MemberServiceImp이면 memberServiceImpl로 등록한다.
  • 만약 빈 이름을 지정하고 싶으면 @Component(“이름”) 써주면 된다.

그리고 의존성을 주입하기 위해서는 클래스의 생성자에다가 @Autowired를 달아준다. 이렇게 하면 스프링 컨테이너가 타입이 맞는 빈 객체를 찾아서 넣어준다.

탐색 위치와 기본 스캔 대상

탐색할 패키지의 시작 위치 지정

모든 자바 클래스를 다 컴포넌트 스캔하면 시간이 오래 걸린다. 그래서 꼭 필요한 위치부터 탐색하도록 시작 위치를 지정할 수 있다.

@ComponentScan(
    basePackges = "hello.core",
)

디폴트로는 @ComponentScan이 붙은 설정 정보 클래스의 패키지가 시작 위치가 된다.

권장방법으로 패키지 위치를 지정하지 않고, 설정 정보 클래스의 위치를 프로젝트 최상단에 두는 것이다. 최근 스프링 부트도 이 방법을 기본으로 제공한다. 가령 다음과 같은 구조가 있다 하자.

  • com.hello
  • com.hello.service
  • com.hello.repository

이렇게 있으면 com.hello가 시작 루트이고, 여기에 AppConfig 같은 메인 설정 정보를 두고 @ComponentScan 어노테이션을 붙이고, basePackages 지정은 생략한다. 요새는 관례를 따르는것이 추세이기에 이렇게 프로젝트를 대표하는 정보같은 설정 정보는 시작 루트 위치에 두는 것이 좋다. 추가적으로 스프링 부트를 사용하면 스프링 부트의 대표 시작 정보인 @SpringBootApplication를 이 프로젝트 시작 루트 위치에 두는 것이 관례이고, 이 안에도 @ComponentScan이 달려있다.

컴포넌트 스캔 기본 대상

컴포넌트 스캔은 @Component 뿐만 아니라 다음 내용도 추가로 대상에 포함된다.

  • @Component: 컴포넌트 스캔에서 사용
  • @Controller: 스프링 MVC 컨트롤러에서 사용
  • @Service: 스프링 비즈니스 로직에서 사용
  • @Repository: 스프링 데이터 접근 계층에서 사용
  • @Configuration: 스프링 설정 정보에서 사용

위 항목들은 기본으로 @Component를 달고 있다. 또한 부가 기능을 수행해준다.

  • @Controller: 스프링 MVC 컨트롤러로 인식
  • @Repository: 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환해준다.
  • @Configuration: 스프링 설정 정보로 인식하고, 스프링 빈이 싱글톤을 유지하도록 추가 저리
  • @Service: 특별한 처리는 하지 않지만, 개발자들이 핵심 비즈니스 로직이 여기에 있겠구나 라고 비즈니스 계층을 인식하는데 도움이 된다.

어노테이션에는 상속관계라는 것이 없다. 그래서 이렇게 어노테이션이 특정 어노테이션을 들고 있는 것은 인식할 수 있는 것은 자바 언어가 지원하는 기능은 아니고, 스프링이 지원하는 기능이다.

필터

  • includeFilters: 컴포넌트 스캔 대상을 추가로 지정한다.
  • excludeFilters: 컴포넌트 스캔에서 제외할 대상을 지정한다.
@Configuration
@ComponentScan(
        includeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
        excludeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class)
)

FilterType 옵션으로는 여러가지가 있다.

  • ANNOTATION: 기본값, 어노테이션을 인식해서 동작한다.
  • ASSIGNABLE_TYPE: 지정한 타입과 자식 타입을 인식해서 동작한다.
  • ASPECTJ: AspectJ 패턴 사용
  • REGEX: 정규표현식
  • CUSTOM: TypeFilter 이라는 인터페이스를 구현해서 처리

includeFilters를 사용할 일이 거의 없다. excludeFilters는 여러가지 이유로 간혹 사용할 때가 있지만 많지는 않다. 옵션을 변경하기보다는 스프링의 기본 설정에 최대한 맞추어 사용하는 것을 권장한다.

중복 등록과 충돌

컴포넌트 스캔에서 같은 빈 이름을 등록하면 충돌이 일어난다. 보통 이런 경우가 있다.

  1. 자동 빈 등록 vs 자동 빈 등록
  2. 수동 빈 등록 vs 자동 빈 등록

자동 빈 등록 vs 자동 빈 등록

컴포넌트 스캔에 의해 자동으로 스프링 빈이 등록되는데, 그 이름이 같은 경우 오류를 발생시킨다.

ConflictingBeanDefinitionException

수동 빈 등록 vs 자동 빈 등록

이런 경우에는 수동 빈 등록이 우선권을 가지게 되어 자동 빈을 오버라이딩 해버린다.

Overriding bean definition for bean ‘memoryMemberRepository’ with a different definition replacing

개발자가 의도한 상황이면 이래도 되겠지만 보통 이런 식의 일은 결과를 꼬이게 만들게 된다. 즉, 버그가 발생하게 되는 것이다..! 그래서 최근 스프링 부트에서는 수동 빈과 자동 빈 등록이 충돌나면 오류가 발생하도록 기본 값을 바꾸었다.

Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

의존관계 자동 주입

다양한 의존관계 주입 방법

의존관계 주입은 크게 4가지 방법이 있다.

  • 생성자 주입
  • 수정자 주입(setter 주입)
  • 필드 주입
  • 일반 메서드 주입

생성자 주입

생성자를 통해서 의존 관계를 주입 받는 방법이다. 생성자 호출시점에 딱 1번만 호출되는 것이 보장된다. 불변, 필수 의존관계에 사용되는 것이 특징이다.

생성자가 하나인 경우에는 @Autowired 생략해도 된다. (단, 스프링 빈일 때)

수정자 주입(setter 주입)

setter라 불리는 필드의 값을 변경하는 수정자 메서드를 통해서 의존관계를 주입하는 바업이다. 선택, 변경 가능성이 있는 의존관계에서 사용하고 자바빈 프로퍼티 규약의 수정자 메서드 방식을 사용하는 방법이다.

자바빈 프로퍼티, 자바에서는 과거부터 필드의 값을 직접 변경하지 않고, setXXX, getXXX 라는 메서드를 통해서 값을 읽거나 수정하는 규칙을 만들었는데 이것을 자바빈 프로퍼티라고 한다.

class Data {
    private int age;
    public void setAge(int age) {
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}

필드 주입

필드에 바로 주입하는 방법이다. 코드가 간결해서 많은 개발자들을 유혹하지만 외부에서 변경이 불가능해서 테스트가히 어려운 치명적인 단점이 있다. DI 프레임워크가 없으면 아무것도 할 수 없다. 되도록이면 사용하지 말자.

일반 메서드 주입

일반 메서드를 통해서 주입 받을 수 있다. 한번에 여러 필드를 주입 받을 수 있는데, 일반적으로 잘 사용하지 않는다.

옵션 처리

주입할 스프링 빈이 없어도 동작해야 할 때가 있다. 그런데 @Autowired만 사용하면 required 옵션의 기본값이 true로 되어 있어서 자동 주입 대상이 없으면 오류가 발생한다. 자동 주입 대상을 옵션으로 처리하는 방법은 다음과 같다.

  • @Autowired(required=false): 자동 주입할 대상이 없으면 수정자 메서드 자체가 호출 안됨
  • org.springframework.lang@Nullable: 자동 주입할 대상이 없으면 null이 입력된다.
  • Optional<>: 자동 주입할 대상이 없으면 Optional.empty가 입력된다.
public class AutowiredTest {

    @Test
    void AutowiredOption() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestBean.class);
    }

    static class TestBean {

        @Autowired(required = false)
        public void setNoBean1(Member noBean1) {
            System.out.println("noBean1 = " + noBean1);
        }

        @Autowired
        public void setNoBean2(@Nullable Member noBean2) {
            System.out.println("noBean2 = " + noBean2);
        }

        @Autowired
        public void setNoBean3(Optional<Member> noBean3) {
            System.out.println("noBean3 = " + noBean3);
        }
    }
}

@Autowired(false)이면 빈 객체가 없으면 메서드 자체가 실행되지 않아 내용이 출력되지 않는다.

@Nullable, Optional은 스프링 전반에 걸쳐서 지원된다. 예를 들어 생성자 자동 주입에서 특정 필드에만 사용해도 된다.

생성자 주입을 선택해라!

과거에는 수정자 주입과 필드 주입을 많이 사용했지만, 최근에는 스프링을 포함한 DI 프레임워크 대부분이 생성자 주입을 권장한다. 이유는 다음과 같다.

불변

  • 대부분의 의존관계 주입은 한 번 일어나면 애플리케이션 종료시점까지 의존관계를 변경할 일이 없다. 오히려 대부분의 의존관계는 애플리케이션 종료 전까지 변하면 안된다.(즉, 불변해야 한다.)
  • 수정자 주입을 사용하면, setXXX 메서드를 public으로 열어두어야 한다.
  • 누군가 실수로 변경할 수도 있고, 변경하면 안되는 메서드를 열어두는 것은 좋은 설계 방법이 아니다.
  • 생성자 주입은 객체를 생성할 때 딱 한 번만 호출되므로 이후에 호출되는 일이 없다. 따라서 불변하게 설계 가능하다.

final 키워드

생성자 주입을 사용하면 필드에 final 키워드를 사용할 수 있다. 이렇게 하여 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아준다.

컴파일 오류는 세상에서 가장 빠르고 좋은 오류다

수정자 주입을 포함한 나머지 주입 방식은 모두 생성자 이후에 호출되므로, 필드에 final 키워드를 사용할 수 없다. 오직 생성자 주입 방식만 final 키워드를 사용할 수 있다.

롬복과 최신 트렌드

막상 개발을 해보면, 대부분이 다 불변이기에 생성자에 final 키워드를 사용하게 된다. 그런데 생성자도 만들어야 하고, 주입 받은 값을 대입하는 코드도 만들어야 하는 귀찮음이 있다. 역시 개발자들은 편리함을 좋아하기에 무엇인가 만들어 놓았다. 바로 lombok이다.


@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Override
    public Order createOrder(Long memberId, String itemName, int itemPrice) {
        Member member = memberRepository.findById(memberId);
        int discountPrice = discountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }
}

@RequiredArgsConstructor를 사용하면 final로 지정한 필드에 대한 생성자를 만들어 준다. 롬복은 자바의 어노테이션 프로세서라는 기능을 이용해서 컴파일 시점에 생성자 코드를 자동으로 생성해준다. 실제로 .class 파일을 열어보면 내용이 추가된 것을 볼 수 있다.

조회 빈이 2개 이상 - 문제

@Autowired는 기본적으로 타입을 탐색한다. 그러다 보니 타입이 두 개 이상일 때 문제가 발생한다.

NoUniqueBeanDefinitionException 예외 발생

이때 하위 타입으로 지정하여 해결할 수도 있겠지만, DIP를 위반하고 유연성이 떨어지게 된다. 그렇기에 다른 방법으로 해결해야 한다.

@Autowired 필드 명, @Qualifier, @Primary

조회 빈이 2개 이상일 때 해결 방법은 다음과 같다.

  • @Autowired 필드 명 매칭
  • @Qualifier -> @Qualifier끼리 매칭 -> 빈 이름 매칭
  • @Primary 사용

@Autowired 필드 명 매칭

@Autowired는 처음에는 타입 매칭을 시도하고, 여러 빈이 있으면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭한다.

  1. 타입 매칭
  2. 타입 매칭 결과가 2개 이상일 때 필드명, 파라미터 명으로 빈 이름 매칭

@Qualifier 사용

추가 구분자를 붙여주는 방법이다. 주입시 추가적인 방법을 제공하는 것이지 빈 이름을 변경하는 것은 아니다. @Qualifier로 주입할 때 @Qualifier(“mainDiscountPolicy”)를 못 찾으면, mainDiscountPolicy라는 이름의 스프링 빈을 추가로 찾는다. 번거로운 점으론 모든 코드에 @Qualifier를 붙여주어야 하는 것이다.

@Primary 사용

우선순위를 정하는 방법이다. @Autowired 시에 여러 번 매칭되면 @Primary가 우선권을 가진다. 먼저 적용해줄 컴포넌트 클래스에 적어주면 된다.

@Primary, @Qualifier 활용

코드에서 자주 사용하는 메인 데이터베이스의 커넥션을 획득하는 스프링 빈이 있고, 코드에서 특별한 기능으로 가끔 사용하는 서브 데이터베이스의 커넥션을 획득하는 스프링 빈이 있다고 가정해보자. 메인 데이터베이스의 커넥션을 획득하는 스프링 빈은 @Primary를 적용해서 조회하는 곳에서 @Qualifier 지정 없이 편리하게 조회하고, 서브 데이터베이스 커넥션 빈을 획득할 때는 @Qualifier를 지정해서 명시적으로 획득하는 방식으로 사용하면 코드를 깔끔하게 유지할 수 있다. 물론 이때 메인 데이터베이스의 스프링 빈을 등록할 때 @Qualifier를 지정해주는 것은 상관없다.

@Primary는 기본값처럼 동작하는 것이고, @Qualifier는 매우 상세하게 동작한다. 스프링은 자동보다는 수동이, 넓은 범위의 선택권보다는 좁은 범위의 선택권이 우선 순위가 높다. 따라서 @Qualifier가 우선권이 높다.

어노테이션 직접 만들기

위에서 배운 @Qualifier(“mainDiscountPolicy”)는 문자열이기에 컴파일 시 타입 체크가 안된다. 다음처럼 만들면 문제를 해결할 수 있다.

import org.springframework.beans.factory.annotation.Qualifier;

import java.lang.annotation.*;

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Qualifier("mainDiscountPolicy")
public @interface MainDiscountPolicy {
}

어노테이션은 상속이라는 개념이 없다. 이렇게 여러 어노테이션을 모아서 사용하는 기능은 스프링이 지원해주는 기능이다. @Qualifier 뿐만 아니라 다른 어노테이션들도 함께 조합해서 사용할 수 있다. 단적으로 @Autowired도 재정의 할 수 있다. 물론 스프링이 제공하는 기능을 뚜렷한 목적 없이 무분별하게 재정의하는 것은 유지보수에 더 혼란만 가중할 수 있다.

조회한 빈이 모두 필요할 때, List, Map

의도적으로 해당 타입의 스프링 빈이 다 필요한 경우가 있다. 예를 들어, 할인 서비스를 제공하는데 클라이언트가 할인의 종류(rate, fix)를 선택할 수 있다 가정해보자. 스프링을 사용하면 전략 패턴을 매우 간단히 구현할 수 있다.

public class AllBeanTest {

    @Test
    void findAllBean() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);

        DiscountService discountService = ac.getBean(DiscountService.class);
        Member member = new Member(1L, "userA", Grade.VIP);
        int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");

        assertThat(discountService).isInstanceOf(DiscountService.class);
        assertThat(discountPrice).isEqualTo(1000);

        int rateDiscountPrice = discountService.discount(member, 20000, "rateDiscountPolicy");
        assertThat(rateDiscountPrice).isEqualTo(2000);
    }

    static class DiscountService {
        private final Map<String, DiscountPolicy> policyMap;
        private final List<DiscountPolicy> policies;

        @Autowired
        public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
            this.policyMap = policyMap;
            this.policies = policies;
            System.out.println("policyMap = " + policyMap);
            System.out.println("policies = " + policies);
        }

        public int discount(Member member, int price, String discountCode) {
            DiscountPolicy discountPolicy = policyMap.get(discountCode);
            return discountPolicy.discount(member, price);
        }
    }
}

자동, 수동의 올바른 실무 운영 기준

스프링이 나오고 시간이 갈수록 자동을 선호하는 추세이다. @Component뿐만 아니라 @Controller, @Service, @Repository처럼 계층에 맞추어 일반적인 애플리케이션 로직을 자동으로 스캔할 수 있도록 지원한다.

빈 생명주기 콜백

빈 생명주기 콜백 시작

데이터베이스 커넥션 풀이나, 네트워크 소켓처럼 애플리케이션 시작 시점에 필요한 연결을 미리 해두고, 애플리케이션 종료 시점에 연결을 모두 종료하는 작업을 진행하려면, 객체의 초기화와 종료 작업이 필요하다. 스프링에서는 어떻게 진행하는지 예제로 알아보자.

스프링 빈은 다음과 같은 라이프사이클을 가진다. 객체 생성 -> 의존관계 주입

스프링 빈은 객체를 생성하고, 의존관걔 주입이 다 끝난 다음에야 필요한 데이터를 사용할 수 있는 준비가 완료된다. 따라서 초기화 작업은 의존관계 주입이 모두 완료되고 난 다음에 호출해야 한다. 그러면 개발자는 어떻게 의존관계 주입이 모두 완료되었는지 알 수 있을까? 이는 스프링에서 의존관계 주입이 완료되면 스프링 빈에게 콜백 메서드를 통해서 초기화 시점을 알려주는 다양한 기능을 제공한다. 또한 종료되기 직전에 소멸 콜백을 준다.

스프링 빈의 이벤트 라이플 사이클

스프링 컨테이너 생성 -> 스프링 빈 생성 -> 의존 관계 주입 -> 초기화 콜백 -> 사용 -> 소멸전 콜백 -> 스프링 종료

스프링은 크게 3가지 방법으로 빈 생명주기 콜백을 지원한다.

  • 인터페이스(InitializingBean, DisposableBean)
  • 설정 정보에 초기화 메서드, 종료 메서드 지정
  • @PostConstruct, @PreDestroy 어노테이션 지원

인터페이스 InitializingBean, DisposableBean

package hello.core.lifecycle;

import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;

public class NetworkClient implements InitializingBean, DisposableBean {

    // 의존관계 주입이 끝나면
    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("NetworkClient.afterPropertiesSet");
        connect();
        call("초기화 연결 메시지");
    }

    @Override
    public void destroy() throws Exception {
        System.out.println("NetworkClient.destroy");
        disconnect();
    }
}

인터페이스를 상속받아 구현하면 된다. 이때의 문제점은 스프링 전용 인터페이스이기에 해당 코드가 스프링 전용 인터페이스에 의존되고, 초기화 소멸 메서드의 이름을 변경할 수 없고, 내가 코드를 고칠 수 없는 외부 라이브러리(클래스 파일로 받아진것들)에는 적용할 수 없다. 이 방법은 초창기 방법이고 지금은 더 나은 방법들이 있어 거의 사용되지 않는다.

빈 등록 초기화, 소멸 메서드

설정 정보에 @Bean(initMethod ="init", destroyMethod = "close")처럼 초기화, 소멸 메서드를 지정할 수 있다.

@Configuration
static class LifeCycleConfig {

    @Bean(initMethod = "init", destroyMethod = "close")
    public NetworkClient networkClient() {
        NetworkClient networkClient = new NetworkClient();
        networkClient.setUrl("http://hello-spring.dev");
        return networkClient;
    }
}

// Network Class
public void init() {
        System.out.println("NetworkClient.init");
        connect();
        call("초기화 연결 메시지");
}

public void close() {
    System.out.println("NetworkClient.close");
    disconnect();
}

@Bean에서 initMethod와 destoryMethod를 통해 이름을 지정했다. 이렇게 함으로써 메서드 이름을 자유롭게 줄 수 있고, 스프링 빈이 스프링 코드에 의존하지 않고, 코드가 아니라 설정 정보를 사용하기 때문에 코드를 고칠 수 없는 외부 라이브러리에도 초기호, 종료 메서드를 적용할 수 있다.

종료 메서드 추론

@Bean의 destroyMethod 속성에는 아주 특별한 기능이 있는데, 라이브러리는 대부분 close, shutdown이라는 이름으로 종료 메서드를 가지고 있다. @Bean의 destroyMethod는 기본값이 inferred (추론)으로 등록되어있는데, 이 추론 기능을 통해 close나 shutdown라는 메서드를 자동으로 호출해준다. 그렇기에 종료 메서드는 따로 적어주지 않아도 잘 동작하고, 추론 기능을 사용하기 싫으면 destoryMethod=”“처럼 빈 공백을 지정하면 된다.

어노테이션 @PostConstruct, @PreDestory

이제는 이 어노테이션을 통해 많이 쓴다고 한다.

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

@PostConstruct
public void init() {
    System.out.println("NetworkClient.init");
    connect();
    call("초기화 연결 메시지");
}

@PreDestroy
public void close() {
    System.out.println("NetworkClient.close");
    disconnect();
}
  • 최신 스프링에서 가장 권장하는 방법이다.
  • 어노테이션 하나만 붙이면 되므로 매우 편리하다.
  • javax.annotation.PostConstruct. 스프링에 종속적인 기술이 아니라 JSR-250라는 자바 표준이기에 스프링이 아닌 다른 컨테이너에서도 동작한다.
  • 컴포넌트 스캔과 잘 어울린다.
  • 유일한 단점으론 외부 라이브러리에는 적용하지 못하는 것이다. 외부 라이브러리를 초기호, 종료해야 하면 @Bean의 기능을 사용하자.

javax 패키지는 자바 진영에서 공식적으로 지원하는 패키지이다.

빈 스코프

빈 스코프란?

지금까지 스프링 빈이 스프링 컨테이너의 시작과 함께 생성되어서 스프링 컨테이너가 종료될 때 까지 유지된다고 학습했다. 이것은 스프링 빈이 싱글톤 스코프로 생성되기 때문이다. 스코프는 번역 그대로 빈이 존재할 수 있는 범위를 뜻한다.

스프링은 다양한 스코프를 지원한다.

  • 싱글톤: 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
  • 프로토타입: 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프이다.
  • 웹 관련 스코프
    • request: 웹 요청이 들어오고 나갈때 까지 유지되는 스코프
    • session: 웹 세션이 생성되고 종료될 때 까지 유지되는 스코프
    • application: 웹의 서블릿 컨텍스와 같은 범위로 유지되는 스코프

등록은 다음과 같이 지정할 수 있다.

컴포넌트 스캔 자동 등록

@Scope("prototype")
@Component
public class HelloBean {}

수동 등록

@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
    return new HelloBean();
}

프로토타입 스코프

싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다. 반면, 프로토타입 스코프로 설정하면 항상 새로운 인스턴스를 생성해서 반환한다. 반환한 이후에는 스프링 컨테이너에선 관리하지 않는다. 그렇기에 프로토타입 빈을 관리할 책임은 프로토타입 빈을 받은 클라이언트에 있다. 그래서 @PreDestroy 같은 종료 메서드가 호출되지 않는다. 클라이언트가 직접 종료하던가 해야한다.

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제점

프로토타입과 싱글톤을 같이 사용할 때는 의도한 대로 잘 동작하지 않으므로 주의해야 한다.

package hello.core.scope;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

import static org.assertj.core.api.Assertions.assertThat;

public class SingletonWithPrototypeTest1 {

    @Test
    void prototypeFind() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        prototypeBean1.addCount();
        assertThat(prototypeBean1.getCount()).isEqualTo(1);

        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        prototypeBean2.addCount();
        assertThat(prototypeBean2.getCount()).isEqualTo(1);
    }

    @Test
    void singletonClientUsePrototype() {
        AnnotationConfigApplicationContext ac =
                new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);

        ClientBean clientBean1 = ac.getBean(ClientBean.class);
        int count1 = clientBean1.logic();
        assertThat(count1).isEqualTo(1);

        ClientBean clientBean2 = ac.getBean(ClientBean.class);
        int count2 = clientBean2.logic();
        assertThat(count2).isEqualTo(2);
    }

    @Scope("singleton")
    static class ClientBean {
        private final PrototypeBean prototypeBean; // 생성시점에 주입

        @Autowired
        public ClientBean(PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }

        public int logic() {
            prototypeBean.addCount();
            int count = prototypeBean.getCount();
            return count;
        }
    }

    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;

        public void addCount() {
            count++;
        }

        public int getCount() {
            return count;
        }

        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }

        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}

위대로 테스트하면 두번째 테스트에서 의문점이 생길 것이다. 분명 프로토타입은 계속 생겨야하는 것인 아닌가. 하지만 싱글톤 빈은 생성 시점에만 의존관계를 주입을 받기 때문에, 프로토타입 빈이 새로 생성되기는 하지만, 싱글톤 빈과 함께 계속 유지가 되는 것이다. 사용자는 계속 새로운 프로토타입을 원할 것이다. 이것은 다음 장에서 설명된다.

프로토타입 스코프 - 싱글톤 빈과 함께 사용시 Provideer로 문제 해결

의존관계를 외부에서 주입(DI) 받는게 아니라 직접 필요한 의존관계를 찾는 것을 Dependency Lookup (DL), 의존관계 조회(탐색)이라고 한다. 이런 DL 기능을 활용해서 프로토타입 빈을 찾으면 된다.

ObjectFactory, ObjectProvider

과거에는 ObjectFactory를 사용했는데 편의 기능(옵션, 스트림 처리 등)을 추가해서 ObjectProvider를 사용한다. 둘다 스프링에 의존한다.

@Scope("singleton")
static class ClientBean {

    @Autowired
    private ObjectProvider<PrototypeBean> prototypeBeanProvider;

    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        prototypeBean.addCount();
        int count = prototypeBean.getCount();
        return count;
    }
}

JSR-330 Provider

javax.inject.Provider라는 JSR-330 자바 표준을 사용하는 방법이 있다. 단 라이브러리를 추가해주어야 한다.

@Scope("singleton")
static class ClientBean {

    private Provider<PrototypeBean> prototypeBeanProvider;

    public int logic() {
        PrototypeBean prototypeBean = prototypeBeanProvider.get();
        prototypeBean.addCount();
        int count = prototypeBean.getCount();
        return count;
    }
}

provider.get()을 통해 항상 새로운 프로토타입 빈이 생성된다. get()을 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환해준다.(DL) 자바 표준이고, 기능이 단순하여 단위 테스트를 만들거나 mock 코드를 만들기 훨씬 쉽다.

웹 스코프

웹 스코프는 웹 환경에서만 동작한다. 웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리한다. 따라서 종료 메서드가 호출된다. 종류는 다음과 같다.

  • request: HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프, 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고, 관리된다.
  • session: HTTP Session과 동일한 생명주기를 가지는 스코프
  • application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
  • websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프

request 스코프 예제 만들기

웹 스코프는 웹 환경에서만 동작하므로 web 환경이 동작하도록 라이브러리를 추가해야한다.

implementation 'org.springframework.boot:spring-boot-starter-web'

이 라이브러리를 추가하면 스프링 부트는 내장 톰켓 서버를 활용해서 웹 서버와 스프링을 함께 실행시킨다. 참고로 스프링 부트는 웹 라이브러리가 없으면 우리가 지금까지 학습한 AnnotationConfigApplicationContext을 기반으로 애플리케이션을 구동한다. 웹 라이브러리가 추가되면 웹과 관련된 추가 설정과 환경들이 필요하므로 AnnotationConfigServletWebServerApplicationContext를 기반으로 애플리케이션을 구동한다.

다른 포트로 사용하고 싶으면 application.properties에 server.port=9090으로 설정해주자.

스코프와 Provider

ObjectProvider를 사용하면 호출하는 시점까지 request scope 빈의 생성을 지연할 수 있다. ObjectProvider.getObject()를 호출하는 시점에서 HTTP 요청이 진행중이므로 request scope 빈의 생성이 정상 처리된다. 컨트롤러나 서비스에서 따로 호출해도 같은 HTTP 요청이면 같은 스프링 빈이 반환된다.

스코프와 프록시

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger

프록시로 지정을 하면 GCLIB 라이브러리를 통해 위 클래스를 상속 받은 가짜 프록시 객체를 만들어서 주입해준다. 그리고 실제 요청이 오면 그때 내부에서 실제 빈을 요청하는 위임 로직을 동작시킨다. 이 프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 사용하여 request scopef를 편하게 이용할 수 있다. Provider나 프록시의 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리 한다는 점이다.

스프링입문-by-김영한님

|

김영한님의 강의를 듣고 정리하는 글이다.

프로젝트 환경 설정

요새는 스프링부트로 많이 시작하는데 start.spring.io 에서 적절한 dependecies와 자바 버전, 빌드 도구 등을 선택해서 만들어 주면 된다.

스프링 웹 개발 기초

정적 컨텐츠

  • 정적 컨텐츠: 정적인 html파일을 던져주어 변화가 없다.
  • MVC와 템플릿 엔진: 서버 내에서 템플릿 엔진을 통해 동적으로 변환한 값을 준다.
  • API: JSON형태로 데이터를 클라이언트에 보내준다. 그러면 클라이언트 측에서 알아서 렌더링하게 처리해준다.

정적 파일은 resources의 static 디렉터리에서 찾아서 출력해준다. 그래서 url/파일명.html을 하면 해당 정적 파일이 출력이 가능하다.

MVC와 템플릿 엔진

MVC: Model, View, Controller 예전에는 JSP에다가 통으로 넣어서 관리했었는데, 이러면 관리 측면에서 많이 어려워지기에 위의 3가지 형식으로 분리해서 관리하게 되었다.

API

이전에 했던 방식은 데이터를 html파일에서 렌더링해서 보여주는 방식이고 이번에는 API 방식으로 데이터를 보내는 방식이다. 이 방식을 쓰면 JSON 으로 반환해준다. 이때는 @ResponseBody 어노테이션을 쓰면 된다.

@GetMapping("hello-api")
@ResponseBody
public Hello helloApi(@RequestParam("name") String name) {
    Hello hello = new Hello();
    hello.setName(name);
    return hello;
}

static class Hello {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

회원 관리 예제 - 백엔드 개발

비즈니스 요구사항 정리

  • 데이터: 회원 아이디, 이름
  • 기능: 회원 등록, 조회

일반적인 웹 어플리케이션 계층 구조

  • 컨트롤러: 웹 MVC의 컨트롤러 역할
  • 서비스: 핵심 비즈니스 로직 구현
  • 리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인: 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨.
// 저장소 인터페이스
package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.List;
import java.util.Optional;

public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

// 구현체
package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements MemberRepository {

    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    @Override
    public Member save(Member member) {
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny();
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }
}

회원 리포지토리 테스트 케이스 작성

개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해 실행하거나, 웹 애플리케이션의 컨트롤러를 통해 해당 기능을 실행한다. 근데 이런 방법들은 준비라던가 실행시간이 오래 걸리고 반복하거나 한 번에 테스트하기 어렵다. 이런 문제점을 jUnit이라는 프레임워크로 해결한다.

// test/java/hello/hellospring/repository 관례적으로 main 디렉터리랑 같은 구조로 만들어 준다.
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.*;

class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    public void afterEach() {
        repository.clearStore();
    }

    @Test
    public void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        assertThat(member).isEqualTo(result);
    }

    @Test
    public void findByName() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        Member result = repository.findByName("spring1").get();

        assertThat(result).isEqualTo(member1);
    }

    @Test
    public void findAll() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        List<Member> result = repository.findAll();

        assertThat(result.size()).isEqualTo(2);
    }
}

@AfterEach 어노테이션은 각 테스트가 끝나면 해당 메서드가 실행되게끔 한다. 테스트를 전체적으로 실행하면 우리가 위 처럼 짠 코드 순서대로 실행되는게 아니라 jUnit이 실행하기에 이전에 했던 내용으로 충돌될 수도 있다. 그렇기에 위에서는 사용했던 내용을 초기화 해주도록 해준 것이다.

회원 서비스 개발

service 디렉터리를 만들어서 파일을 관리해주자. 서비스는 실제 비즈니스에 사용될 로직이기에 메서드 명들을 비즈니스적으로 작명해주는 것이 좋다. 회원가입 같은 경우에는 join 이런식으로.

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;

import java.util.List;
import java.util.Optional;

public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    /*
    회원 가입
    * */
    public Long join(Member member) {
        validateDuplicateMember(member); // 중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

    /*
    전체 회원 조회
    * */
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

회원 서비스 테스트

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class MemberServiceTest {

    MemberService memberService = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();

    @AfterEach
    public void afterEach() {
        memberRepository.clearStore();
    }

    @Test
    void 회원가입() {
        // given
        Member member = new Member();
        member.setName("spring");

        // when
        Long saveId = memberService.join(member);

        // then
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    @Test
    public void 중복_회원_예외() {
        // given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring");

        // when
        memberService.join(member1);
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));

        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

//        try {
//            memberService.join(member2);
//            fail();
//        } catch (IllegalStateException e) {
//            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        }

        // then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

테스트의 구조는 given, when, then 이렇게 세 가지로 나누어서 연습해보는 것이 좋다.

  • given: 데이터 등을 주어줌.
  • when: 메서드 등을 동작시킴.
  • then: 어떤 테스트를 할 것인지 정함.

그리고 MemberService memberService = new MemberService(); MemoryMemberRepository memberRepository = new MemoryMemberRepository();

이 부분에서 Repository는 MemberSerivce에서도 생성되고 지금의 memberRepository에서도 있는데 이것들은 서로 다른 객체이다. 이렇게 되면 문제점이 발생할 수 있기에 아래처럼 바꾸어 보자.

> 레포지토리의 private static Map<Long, Member> store = new HashMap<>(); 부분이 static이라서 위 코드는 문제없이는 돌아갈 것이다.
// MemberService
public MemberService(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
}

// MemberServiceTest
MemberService memberService;
MemoryMemberRepository memberRepository;


@BeforeEach
public void beforeEach() {
    memberRepository = new MemoryMemberRepository();
    memberService = new MemberService(memberRepository);
}

MemberService에서 생성자를 만들어 인자로 레포지토리를 넣어줬다. 이런식으로 하는것이 DI, 의존성 주입이라고 한다.

스프링 빈과 의존 관계

컴포넌트 스캔과 자동 읜존관계 설정

스프링은 컨테이너를 만들고 빈 객체를 만드는데 이를 위해 클래스에 특별한 어노테이션들을 달아준다.

  • @Component 어노테이션이 있고 이름 포함한 어노테이션들이 있다.
    • @Controller, @Service, @Repository

클래스 위에 위와 같은 어노테이션을 달아주면 스프링 시작 시 빈 객체를 만들어 준 후 이를 사용하려면 @Autowired라는 어노테이션을 통해 연결해주면 된다.

package hello.hellospring.controller;

import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;

@Controller
public class MemberController {

    private MemberService memberService;

    @Autowired
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
}

생성자에 걸어도 되고 필드에다가도 걸어주면 된다. 참고로 스프링 어플리케이션이 위치한 패키지 내에서만 위 어노테이션들을 스캔해주기에 다른 패키지에 달아두면 안 된다.

자바 코드로 직접 스프링 빈 등록하기

위의 컴포넌트 어노테이션을 달지 않고 자바 코드로 빈 객체로 만드는 방법이 있다.

//SpringConf.java
package hello.hellospring;

import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringConfig {

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
}

예전에는 XML로 설정을 했었다는데 요새는 쓰이지 않는다고 한다. 의존성 주입(DI)은 필드 주입, setter 주입, 생성자 주입 이렇게 3가지가 있는데, 의존관계가 실행중에 동적으로 변하는 경우는 거의 없기에 생성자 주입을 권장한다. 실무에서는 주로 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드는 컴포넌트 스캔을 사용한다. 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면 설정을 통해 스프링 빈으로 등록하는 것이 좋다. 주로 DB 설정 관련해서 변경할 때 사용된다고 한다.

회원 관리 예제 - 웹 MVC 개발

회원 웹 기능 - 홈 화면 추가

package hello.hellospring.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

    @GetMapping("/")
    public String home() {
        return "home";
    }
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
    <div>
        <h1>Hello Spring</h1>
        <p>회원 기능</p>
        <p>
            <a href="/members/new">회원 가입</a>
            <a href="/members">회원 목록</a>
        </p>
    </div>
</div> <!-- /container -->
</body>
</html>

아무 경로 없이 주소만 치면 index.html이 나와야할텐데 왜 위의 화면이 나오는 이뉴는 컨트롤러가 정적 파일보다 우선순위가 높기 때문이다. 만약 컨틀롤러가 없으면 정적파일이 나올 것이다.

회원 웹 기능 - 등록

// memberForm 생성
package hello.hellospring.controller;

public class MemberForm {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}


@GetMapping("/members/new")
public String createForm() {
    return "members/createMemberForm";
}

@PostMapping("/members/new")
public String create(MemberForm form) {
    Member member = new Member();
    member.setName(form.getName());

    memberService.join(member);

    return "redirect:/";
}

html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
    <form action="/members/new" method="post">
        <div class="form-group">
            <label for="name">이름</label>
            <input type="text" id="name" name="name" placeholder="이름을입력하세요">
        </div>
        <button type="submit">등록</button>
    </form>
</div> <!-- /container -->
</body>
</html>

PostMapping 메서드 쪽을 보면 매개변수로 MemberForm을 받는거를 볼 수 있는데 위에 있는 html 파일을 보면 post 요청으로 key값이 name인 값을 스프링으로 보낸다. 스프링은 key 값을 통해 setter 메서드를 불러 넣어주게 되고 이를 활용할 수 있게 된다.

회원 웹 기능 - 조회

@GetMapping("/members")
public String list(Model model) {
    List<Member> members = memberService.findMembers();
    model.addAttribute("members", members);
    return "members/memberList";
}

html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
    <div>
        <table>
            <thead>
            <tr>
                <th>#</th>
                <th>이름</th>
            </tr>
            </thead>
            <tbody>
            <tr th:each="member : ${members}">
                <td th:text="${member.id}"></td>
                <td th:text="${member.name}"></td>
            </tr>
            </tbody>
        </table>
    </div>
</div> <!-- /container -->
</body>
</html>

모델 인스턴스에 members 인스턴스를 추가해주었다. 이를 thymeleaf 엔진과 함께 활용하여 동적으로 데이터가 추가되게끔 했다. 지금까지는 메모리 상에서 값이 추가되기에 서버를 재시작하면 다 날라간다. 이를 해결하기 위해 DB를 설정해보자!

스프링 DB 접근 기술

H2 데이터베이스 설치

H2 데이터베이스는 개발이나 테스트 용도로 가볍고 편리한 DB이다. 웹 관리자 화면도 제공해준다.

  • https://www.h2database.com 본인의 맞는 OS로 다운로드 및 설치
  • h2 데이터베이스 버전은 스프링 부트 버전에 맞춘다.
  • 권한 주기: chmod 755 h2.sh (윈도우 사용자는 x)
  • 실행: ./h2.sh (윈도우 사용자는 h2.bat)
  • 앞의 주소를 localhost로 바꿔서 접속하면 된다.

데이터베이스 파일 생성 방법은 아래와 같다.

  • jdbc:h2:~/test (최초 한번)
  • ~/test.mv.db 파일 생성 확인
  • 이후부터는 jdbc:h2:tcp://localhost/~/test 이렇게 접속 (파일로 접근하면 어플리케이션하고 웹 콘솔 충돌날 수도 있음)

이제 테이블을 생성해보자

drop table if exists member CASCADE;
create table member
(
 id bigint generated by default as identity,
 name varchar(255),
 primary key (id)
);

순수 JDBC

애플리케이션을 통해 DB를 연결하여 데이터들을 저장하고 관리해보자. 먼저 20년된 방식인 순수 JDBC로 해보자…! 먼저 build.gradle에 라이브러리들을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'

그러고 디비에 접근하기 위한 접속 정보 (ID라던가 url)를 입력해야되는데, 예전에는 엄청 노가다였다한다. 지금은 resources/application.properties에다가 입력해주면 된다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa

영한님이 제공하는 코드를 적용 후 설정파일에서 레포지토리를 갈아끼워보자!

private DataSource dataSource;

@Autowired
public SpringConfig(DataSource dataSource) {
    this.dataSource = dataSource;
}

@Bean
public MemberRepository memberRepository() {
//        return new MemoryMemberRepository();
    return new JdbcMemberRepository(dataSource);
}

우리는 이렇게만 바꿔줘도 다른 내용은 고칠 필요없이 그대로 사용할 수 있다. 이게 바로 자바의 다형성과 스프링의 힘인거 같다. 위와 같이 수정없이 사용하는 원칙이 SOLID 중에 O, OCP(Open-Closed Principle)이다. 이 원칙은 확장에는 열려있고, 수정, 변경에는 닫혀있다는 의미이다.

스프링 통합 테스트

기존 테스트에서는 순수 자바 코드로만 이루어졌기에 스프링 내용들 없이도 할 수 있었다. 하지만 이제 DB도 연결했고 이것을 사용하는 빈 객체들을 사용하기 위해서는 스프링 컨테이너를 만들어서 테스트 해야한다. 이를 위한 어노테이션이 @SpringBootTest이다. 이와 함께 @Transactional이 사용되는데, 테스트는 한 번만 실행하는게 아니라 계속 사용할 수 있어야하기에 디비 내용을 롤백 시켜줘야 한다. 이 어노테이션을 활용하면 테스트 시작 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백해준다. 클래스에 적용하면 모든 테스트마다 적용된다.

스프링 JdbcTemplate

  • 순수 Jdbc와 동일한 환경설정을 해주면 된다.
  • 스프링 JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복 코드를 대부분 제거해준다. 하지만 SQL은 직접 작성해주어야 한다.
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;

import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class JdbcTemplateMemberRepository implements MemberRepository {

    private final JdbcTemplate jdbcTemplate;

    public JdbcTemplateMemberRepository(DataSource dataSource) {
        jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("member").usingGeneratedKeyColumns("id");

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("name", member.getName());

        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
        member.setId(key.longValue());
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
        return result.stream().findAny();
    }

    @Override
    public Optional<Member> findByName(String name) {
        List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
        return result.stream().findAny();
    }

    @Override
    public List<Member> findAll() {
        return jdbcTemplate.query("select * from member", memberRowMapper());
    }

    private RowMapper<Member> memberRowMapper() {
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return member;
        };
    }
}

생성자가 아나일 때는 Autowired 생략가능하다.

JPA

JPA는 기존의 반복 코드는 물론이고, 기본적인 SQL도 JPA가 직접 만들어서 실행해준다. JPA를 사용하면 SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환할 수 있다. JPA는 인터페이스이고 Hibernate라는 구현체를 거의 표준으로 사용한다고 한다.

gradle에 라이브러리를 추가해주자

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

application.properties에도 다음과 같이 추가하자

spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none

첫 번째는 쿼리를 보여주는 설정이고, 두 번째는 jpa는 Entity를 설정하는데 이 엔티티를 통해 자동으로 테이블을 만들어 주려하기 때문에 꺼두는 설정이다.

Member 클래스를 수정해보자

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
}

@Entity 어노테이션을 달아주어 Entity임을 명시해주고, @Id는 해당 필드가 PK임을 알려주는 어노테이션이다. @GeneratedValue는 PK 값을 어떻게 설정해주느냐인데, IDENTITY 전략으로 사용한다고 설정해준다. 이를 쓰면 PK값을 잘 올려준다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {

    private final EntityManager em;

    public JpaMemberRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public Member save(Member member) {
        em.persist(member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);
    }

    @Override
    public Optional<Member> findByName(String name) {
        List<Member> result = em.createQuery("select m from Member as m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();

        return result.stream().findAny();
    }

    @Override
    public List<Member> findAll() {
        return em.createQuery("select m from Member  m", Member.class)
                .getResultList();
    }
}

스프링 설정을 바꿔주자

@Configuration
public class SpringConfig {

    private DataSource dataSource;

    private EntityManager em;

    @Autowired
    public SpringConfig(DataSource dataSource, EntityManager em) {
        this.dataSource = dataSource;
        this.em = em;
    }

    @Bean
    public MemberRepository memberRepository() {
//        return new MemoryMemberRepository();
//        return new JdbcMemberRepository(dataSource);
//        return new JdbcTemplateMemberRepository(dataSource);
        return new JpaMemberRepository(em);
    }
}

스프링 데이터 JPA

스프링 부트와 JPA만 사용해도 개발 생산성이 정말 많이 증가하고, 개발해야할 코드가 확연히 줄어든다. 여기서 스프링 데이터 JPA를 사용하면 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있다. 그리고 반복 개발해온 기본 CRUD 기능도 스프링 데이터 JPA가 모두 제공한다. 이를 활용하여 개발자는 핵심 비즈니스 로직을 개발하는데 집중할 수 있다. 실무에서 관계형 데이터베이스를 사용한다면 스프링 데이터 JPA는 필수이다.

스프링 데이터 JPA는 JPA를 편리하게 사용하도록 도와주는 기술이기에 JPA를 잘 이해해서 사용하자.

스프링 데이터 JPA는 인터페이스만 만들어 주면 될 정도로 놀라운 기술이다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {

    // JPQL select m from Member m where m.name = ?
    @Override
    Optional<Member> findByName(String name);
}

이런식으로 인터페이스만 만들어주면은 스프링 데이터 JPA가 SpringDataJpaMemberRepository를 스프링 빈으로 자동 등록해준다. 이 스프링 데이터 JPA는 기본적인 CRUD를 제공해주고, findByName()이나 findByEmail 같은 것들을 메서드로만 추가해줘도 잘 동작해준다. 실무에서는 JPA와 스프링 데이터 JPA를 기본으로 사용하고, 복잡한 동적 쿼리는 Querydsl이라는 라이브러리를 사용하면 된다. Querydsl을 사용하면 쿼리도 자바 코드로 안전하게 작성할 수 있고, 동적 쿼리도 편리하게 작성할 수 있다. 이 조합으로 해결하기 어려운 쿼리는 JPA가 제공하는 네이티브 쿼리를 사용하거나, JdbcTemplate를 사용하면 된다.

AOP

Aspect Oriented Programming의 약자로 공통 관심사항과 핵심 관심 사항을 분리하는 것이다. 가령 우리는 매 메서드마다 시간 측정하고 싶은 기능을 넣으려고 한다. 근데 이것은 사실 핵심 기능은 아니다. 이런 기능을 공통 관심 사항으로 두고 따로 빼서 적용을 하면 되는 것이다.

package hello.hellospring.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class TimeTraceAop {

    @Around("execution(* hello.hellospring..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        System.out.println("START: " + joinPoint.toString());
        try {
            return joinPoint.proceed();
        } finally {
            long finish = System.currentTimeMillis();
            long timeMs = finish - start;
            System.out.println("END: " + joinPoint.toString() + " " + timeMs + "ms");
        }
    }
}

@Aspect 어노테이션은 AOP를 위해 사용하는 어노테이션이다. @Around 어노테이션은 어느 클래스에 적용할 것인지 설정하는 것이다. ProceedingJoinPoint는 실제 메서드(?)를 실행할 때 사용되는 것이라 보면 된다. 이렇게 만들어 두면 스프링은 프록시 기능을 활용하여 작동시키게 된다.

내가 쓰는 단축키 정리

|

intelliJ

  • option + command + v를 입력하면 변수명을 추천해준다.
  • command + p: 메서드 매개변수 확인 가능
  • command + shift + enter: 현재 줄 ;으로 완료지어주고 다음줄로 이동
  • shift + fn + f6: 한 번에 이름 바꾸기 용이
  • control + t: 리팩토링 메뉴 보여줌
    • extract method: 메소드 따로 추출해줌
  • command + option + m: 메서드로 리팩토링
  • command + shift + t: 테스트 생성 (디렉터리하고 메서드 다 가능하다.)
  • command + option + n: inline 변수로 만들어줌
  • f2: 오류가 있는데로 이동해준다.
  • iter + tab: 리스트나 배열있으면 반복문 자동 완성
  • command + shift + t: 테스트 생성
  • command + fn + f12: 해당 클래스의 메소드, 필드 보여줌
  • command + option + b: 구현체 리스트들을 보여줌
  • control + o: Override/implement 리스트 출력

스프링입문정리-by-백기선님

|

인프런의 백기선님의 강의를 보고 정리하는 글입니다.

intelliJ에서 command + fn + f9를 하면 변경사항 적용

스프링 IoC

IoC는 Inversion of Control로 제어의 역전을 의미한다. 본래라면 개발자가 직접 인스턴스를 만들어주거나 의존을 주입을 해주어야 하는데, 이런 점을 스프링에서 대신 만들어 주기 때문에 개발자는 비즈니스 로직에만 집중할 수 있게 된다. 본래라면 아래와 같이 내가 의존을 넣어줘야 한다. 의존이란 한 클래스에서 다른 클래스의 인스턴스를 생성해 메서드 등을 사용함을 의미한다. 어쨌든 스프링에서는 IoC 컨테이너라가 의존성을 자동으로 해주는데, 이를 DI(Dependecy Injection), 의존성 주입이라고 한다.

class OwnerController {
    private OwnerRepository repo;

    public OwnerController(OwnerRepository repo) {
        this.repop = repo;
    }
}

class OwnerControllerTest {
    @Test
    public void create() {
        OwnerRepository repo = new OwnerRepository();
        OwnerController controller = new OwnerController(repo);
    }
}

그런데 스프링에서는 특정 어노테이션을 달아줌으로써 스프링에서 관리해주는 Bean 객체가 되고 스프링이 알아서 자료형을 보고 의존을 주입해준다.

스프링 IoC 컨테이너

IoC 컨테이너는 ApplicationContext(BeanFactory)를 사용해서 만들어진다. 이 컨테이너의 역할은 빈을 만들어주고 빈들 사이의 의존성을 엮어주고 제공해준다. 모든 클래스가 빈으로 등록되는 것은 아니고 특정 어노테이션등을 달아준 클래스들이 빈 객체로 관리된다.

intelliJ에서 클래스 옆에 녹색 콩(?)모양의 표시가 있으면 빈 객체이다. (마우수를 올려보면 빈이라 적혀있다 ㅎㅎ)

스프링에서 의존성 주입은 빈 끼리만 가능하다. 즉, 스프링 IoC 컨테이너에 등록된 빈 끼리만 의존성 주입이 가능하다.

public OwnerController(OwnerRepository clinicService, VisitRepository visits) {
    this.owners = clinicService;
    this.visits = visits;
}

이런 코드가 있는데, 만약 이 메서드의 클래스가 빈 객체로 등록되어있다면 컨테이너는 매개 변수의 자료형을 탐색하여 알맞는 빈 객체를 주입해준다. 테스트를 통해서 ApplicationContext에 등록된 빈을 한 번 조회해볼 수 있다.

@Autowired
private ApplicationContext applicationContext;

@Test
public void getBean() {
    OwnerController bean = applicationContext.getBean(OwnerController.class);
    assertThat(bean).isNotNull();
}

빈 객체는 싱글톤으로 제공되는데, 하나의 인스턴스로만 사용하는 것이다. 이렇게 하면 관리 용이성에 있어서 좋다.

option + command + v를 입력하면 변수명을 추천해준다.

스프링 빈(Bean)

빈 객체는 ApplicationContext가 만든 객체를 의미한다. 다른 경우 (우리가 new 해서 만든 객체)는 빈 객체가 아니다. 그렇다면 빈은 어떻게 등록을 해야할까

  1. Component 어노테이션으로 등록하기
    • @Component로 등록을 하거나 그 외
      • @Repository
      • @Service
      • @Controller
  2. 직접 일일히 XML이나 자바 설정 파일에 등록을 한다.

첫 번째 방법은 Spring IoC 컨테이너를 만들고 빈을 등록할 때 사용하는 인터페이스인데 이를 라이플 사이클 콜백이라고 한다. @CompnenetScan을 통해서 어디서부터 어디까지 스캔을 해서 등록할지를 정하여 하위 패키지들을 스캔을 하게 된다. 그리고 위에 적은 어노테이션들을 찾아 생성하여 빈으로 등록하는 것이다.

두 번째 방법에서는 요새는 xml파일보다는 자바 설정 파일을 등록을 하는 추세라 한다.

// SampleConfig.java

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SampleConfig {
    @Bean
    public SampleController sampleController() {
        return new SampleController();
    }
}

위처럼 등록하면 @Controller 어노테이션을 안 달아도 된다.

의존성 주입 (Dependency Injection)

@Autowired 어노테이션을 통해 의존성을 주입할 수 있다. 이 어노테이션은 필드나 세터, 생성자에 달 수 있다. 생성자의 경우에는 클래스에 생성자가 하나있고, 매개 변수의 타입이 빈 객체이면 어노테이션을 안 달수도 있다. 스프링 공식 문서에서 추천하는 방법은 생성자에다가 어노테이션을 다는 것이라고 한다. 생성자를 통해 필요한 의존을 강제할 수 있기 때문이다.

Spring AOP (Aspect Oriented Programming)

스프링은 크게 IoC, AoP, PSA를 제공해준다. AOP는 관점 지향적인 프로그래밍이라고 해석된다. 예를 들어 보면

class A {
    method a() {
        AAAA
        어쩌구 저쩌구
        BBBB
    }
}

class B {
    method b() {
        AAAA
        이것저것
        BBBB
    }
}

class C {
    method c() {
        AAAA
        c method 입니다.
        BBBB
    }
}

위와 같은 코드처럼 AAA, BBB가 클래스의 메서드마다 내용이 반복되는 것을 볼 수 있다. 이런 식이면 AAAA의 내용을 변경해주어야 하면 다른 메서드에서도 다 바꿔주어야 하는 번거로움이 발생할 수 있다. 그렇기에 공통된 내용은 따로 모아두어 관리하자는게 AOP이다.

AOP의 구현 방법은 여러가지가 있다.

  1. 컴파일: A.java —–(AOP)—–> A.class (AspectJ가 처리해줌)
  2. 바이트코드 조작: A.java -> A.class –> 메모리에 올릴 때 조작 (AspectJ)
  3. 프록시 패턴 (스프링 AOP)

스프링 @APO 실습

package org.springframework.samples.petclinic.owner;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {
}

package org.springframework.samples.petclinic.owner;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;

@Component
@Aspect
public class LogAspect {

     logger = LoggerFactory.getLogger(LogAspect.class);

    @Around("@annotation(LogExecutionTime)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        Object proceed = joinPoint.proceed();

        stopWatch.stop();
        logger.info(stopWatch.prettyPrint());

        return proceed;
    }
}

// 컨트롤러에 @LogExecution 어노테이션을 달아주면 된다.

스프링 PSA(Portable Service Abstraction)