1. 웹소켓(Websocket)


[웹소켓이란?]

HTTP의 경우 클라이언트의 요청이 있을때만 서버가 응답하고, 매번 연결을 맺고 끊는 비용이 발생하기 때문에 실시간성을 보장해야하는 서비스에서는 적합하지 않습니다. 이경우 양방향 통신 방식을 사용하는것이 적합한데 이때 사용할 수 있는 프로토콜이 웹소켓입니다.

 

웹소켓은 2011년 IETF에 의해 표준화(RFC 6455)된 양방향 통신 프로토콜입니다. 웹소켓 연결 수립을 위해 HTTP프로토콜을 사용하며, 기존 HTTP프로토콜의 포트(80, 443)과 호환되어 추가적인 방화벽 설정이 필요하지 않다는 장점이 있습니다. HTML5를 지원하는 대부분의 브라우저가 웹소켓을 지원하고 있습니다.

 

 

[HTTP 과 Websocket의 비교]

HTTP Websocket
단방향(클라이언트 → 서버) 양방향(클라이언트 ↔ 서버)
비연결성 연결지향
필요한 경우에만 서버에 접근하는 서비스에 적합 서버와 실시간으로 데이터를 주고받아야 하는 서비스에 적합

 

 

[웹소켓과 소켓 연결]

웹소켓과 소켓은 IP와 PORT를 사용한다는 점에서 유사하지만, 소켓 연결은 TCP/IP 프로토콜을 기반으로 연결을 맺는 네트워크 연결방식으로 전송계층에서 동작합니다. 반면 웹소켓의 경우 HTTP를 통해 연결을 수립하며 애플리케이션 계층에서 동작한다는 차이가 있습니다.

 

 

2. 웹소켓의 동작 방식


 

[Connection State]

클라이언트의 웹소켓 연결 요청

GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

클라이언트는 웹소켓 연결을 수립하기 위해서 HTTP 요청보내야 합니다. 이때 반드시 HTTP 1.1 버전 이상이어야 하며, GET 메서드를 사용하여 연결을 요청해야 합니다. Upgrade, Connection 헤더를 통해 웹소켓 프로토콜로의 업그레이드 요청을 하게됩니다.

 

 

서버측 웹소켓 연결 요청 응답

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

서버가 클라이언트로부터 웹소켓 연결 요청을 받게되면, 서버는 101 상태코드로 프로토콜을 변경하겠다는 응답을 하게 됩니다. 이때 Sec-WebSocket-Accept 의 값은 클라이언트가 보낸 Sec-Websocket-Key 로부터 계산된 값으로 클라이언트가 계산한 값과 일치하지 않으면 연결이 수립되지 않습니다.

 

 

[Open State]

데이터 프레임

연결이 수립되고 나면 다음과 같은 데이터 프레임을 통해 데이터를 주고 받게됩니다.

Frame format:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+

 

 

[Closing State]

웹소켓 종료 요청, 응답

웹소켓 종료 요청은 클라이언트, 서버 상관없이 보낼수 있습니다. 요청을 받은 상대방은 웹소켓 종료 요청에 대한 응답을 보내고 웹소켓 연결을 종료합니다.

 

 

3. Websocket Emulation : SockJS


[Websocket Emulation]

만약 브라우저에서 websocket을 지원하지 않는다면, Websocket Emulation기술을 사용해 웹소켓을 사용하는것처럼 사용자 경험을 제공할 수 있습니다. 대표적으로 SockJS와 Socket.io가 있는데 SockJS는 스프링에서, Socket.io는 node.js에서 주로 사용됩니다.

 

 

[SockJS의 동작 방식]

SockJS클라이언트는 GET /info 요청을 통해 브라우저가 어떤 전송방식을 지원하는지 확인합니다. 만약 브라우저가 Websocket을 지원하지 않는다면 WebSocket → HTTP Streaming → HTTP Long Polling 순으로 연결 방식을 바꿉니다. SockJS를 사용하기 위해서는 SockJS 클라이언트를 통해 연결을 요청해야 하며, 서버측은 SockJS를 지원해야 합니다.

 

4. Polling, Long Polling, Streaming


[Polling]

 

브라우저가 새로운 정보를 확인하기 위해 요청을 계속 전송하고 서버는 이에 대해 매번 즉시 응답하는 방식입니다. 클라이언트가 정보를 확인하는 빈도가 빈번하지 않은경우에 적합합니다. 클라이언트가 매번 요청을 보내기 때문에 클라이언트의 수가 많아지면 서버의 부담이 커진다는 단점이 있습니다.

 

 

 

[Long Polling]

 

Polling 방식과 동일하게 브라우저가 새로운 정보를 확인하기 위해 요청을 계속 전송하지만, 서버는 새로운 정보가 업데이트 될때까지 요청에 대해 응답하지 않는 방식입니다. 데이터 업데이트가 빈번한경우 Polling 방식에 비해 성능 이점이 크지 않습니다. 다수의 클라이언트에 대해 동시에 이벤트가 발생할 경우 서버의 부담이 커진다는 단점이 있습니다.

 

 

 

[Streaming]

 

서버가 클라이언트에 응답을 보낼때 연결을 닫지않고 계속해서 응답을 보내는 방식입니다. 응답시 연결을 유지하기 때문에 Polling 방식에 비해 요청이 줄어든다는 장점이 있지만, 일반적인 HTTP통신 방식이 아니기 때문에 범용성이 떨어집니다.

 

 

Reference

'CS' 카테고리의 다른 글

[DesignPattern] 프록시(Proxy)  (0) 2022.11.11
[OS] 데드락의 탐지와 해결방법  (1) 2022.10.03
[Design Pattern] Facade 패턴을 통한 SRP원칙 준수  (0) 2022.08.22
[Design Pattern] Singleton Pattern  (0) 2022.08.22
[OS] Scheduler  (0) 2022.08.22

데드락의 정의

  • 두 개 이상의 프로세스나 스레드가 서로 자원을 얻지 못해 다음 처리를 하지 못하는 상태
  • 무한히 다음 자원을 기다리게 되는 상태를 말한다

식사하는 철학자

  • 다익스트라가 운영체제의 교착상태(Deadlock)을 설명하기 위해 낸 문제
  • 5명의 철학자가 원탁에 앉아서 식사를 한다
    • 왼쪽 포크가 사용가능할때 까지 대기한다. 사용가능하다면 집어든다.
    • 오른쪽 포크가 사용가능할때 까지 대기한다. 사용가능하다면 집어든다
    • 양쪽의 포크를 잡으면 일정 시간만큼 식사를 한다.
    • 오른쪽 포크를 내려놓는다.
    • 왼쪽 포크를 내려놓는다.
    • 다시 1번으로 돌아간다
  • 모든 철학자가 왼쪽의 포크를 집어든 상태에서 오른쪽 포크가 사용가능할때 까지 무한히 대기하는 상황이 연출 된다.

교착상태의 발생 조건

  • 상호배제
    • 한 프로세스가 자원을 점유하고 있을때 다른 프로세스가 해당 자원을 점유하지 못하는 것.
  • 점유대기
    • 프로세스가 자원을 점유하고 있는 상태에서 다른 자원을 기다린다.
  • 비선점
    • 프로세스는 해당 자원을 사용하는 프로세스가 자원을 자발적으로 반환할 때까지 기다린다.
  • 순환대기
    • A프로세스가 필요한 자원을 B프로세스가 점유하고있고, B프로세스가 필요한 자원을 A프로세스가 점유하고 잇는 상황

교착 상태의 해결 방법

예방

  • 교착 상태 발생조건중 하나를 제거하여 교착상태를 예방할 수 있다.
    • 상호 배제 방지
      • 여러 프로세스가 동시에 자원을 점유하게 되면, 동시성 문제가 발생할 수 있다.
    • 점유 대기 방지
      • 자원을 요구할때 자원을 반납하고 요구한 자원을 사용하기 위해 기다리게 하면 자원에 대한 내용을 저장하고 복원하기 위한 비용이 발생하기 때문에 비효율적이다. 또한 여러자원에 일관성을 보장해야 하는 작업의 경우 여러 자원에 동시에 접근하여 업데이트 해야 하므로, 이방법을 적용할 수 없다.
    • 비선점 방지
      • 만약 선점을 가능하게 할 경우 기아상태가 발생할 수 있으며, 작업의 진행상태를 저장하지 않는 자원의 경우 적용이 어렵다.
    • 순환 대기 방지
      • 자원에 우선순위를 매겨 순차적으로 획득하도록 한다면, 추가로 필요한 자원들이 어떠한 프로세스에게도 점유 되어 있지 않음을 보장할 수 있지만, 우선순위를 매기는데 비용이 크다.
  • 시스템 처리량이나 자원 사용의 효율성을 떨어트릴 수 있다.

회피

  • 데드락 발생 가능성을 지속적으로 검사해서 데드락을 회피하는 방식.
  • 은행원 알고리즘
    • Safe State(안전 상태) : 시스템이 교착상태를 일으키지 않으며, 각 프로세스가 요구한 최대 요구량 만큼 자원을 할당할 수 있는 상태. 안전순서열 존재
    • Unsafe State(불안전 상태) : 안전순서열이 존재하지 않는 상태, 교착상태의 필요조건으로 무조건 교착상태가 발생하는 것은 아니다.
    • 요구사항
      • Max : 각 고객들이 최대로 요구할 돈
      • Allocated : 각 고객들이 빌리고 있는 돈
      • Available : 빌려 줄 수 있는 돈
  • 은행원 알고리즘은 해당 프로세스가 시작할때 프로세스가 가져야할 자원의 최대 개수를 미리 알아야 하기 때문에 실제 돌아가는 프로그램에 적용하기 어렵다.

은행이 가지고있는 자원 : 12

최대 요구량 현재 할당량

A 10 5
B 5 2
C 9 2

안전 순서열 : B → A → C

은행은 현재 9달러를 할당하고 있으므로 가용 달러는 3달러이다.

B : 3달러를 B에게 빌려주면, B는 채무를 해결하고 은행은 5달러를 돌려받아 가용 달러는 5달러가 된다.

A : 5달러를 A에게 빌려주면, A는 채무를 해결하고 은행은 10달러를 돌려받아 가용달러는 10달러가 된다.

C : 7달러를 C에게 빌려주면, C는 채무를 해결하고 모든 대출자가 채무를 해결하게 된다.

탐지

  • 각 유형의 자원이 한개씩 있는 경우
    • 대기 그래프
      • 자원 할당 그래프에서 자원을 제거한 후 간선들을 결합하여 사이클이 발생하면 교착상태로 판단한다
  • 각 유형의 자원이 여러개 있는 경우
    • 대기 그래프 는 사용할 수 없으며, 은행원 알고리즘과 같이 상태정보를 가지는 자료구조를 사용해야 한다.
  • 탐지 알고리즘의 실행 시점
    • 교착 상태가 자주 발생할수록 자주 실행시켜야 한다.
    • 자원요청할 때마다 탐지 알고리즘을 호출하면 성능저하가 발생한다.
    • 지정된 시간간격으로 돌리거나 CPU사용률을 기준으로 돌린다.

회복

  • 교착상태가 발생한 이후 문제를 해결하는 방법
    • 교착상태의 프로세스를 종료한다. (모두 종료하거나, 하나씩 종료해가며 해결)
    • 교착상태의 프로세스를 선점하여 다른 프로세스에 할당한다.
      • 희생자 선택 : 최소의 피해를 줄 수 있는 프로세스를 선택해야 한다.
      • 롤백 : 선점당한 프로세스를 문제없는 이전 상태로 롤백해야 한다.
      • 기아상태 : 한 프로세스가 계속 자원을 선점하지 못하도록 해야한다.

무시

  • 교착상태해결을 위해서도 문맥교환으로 인한 오버헤드가 발생한다. 교착 상태로 인한 오버헤드보다 교착상태 해결을 위한 오버헤드가 큰 경우 무시한다.

'CS' 카테고리의 다른 글

[DesignPattern] 프록시(Proxy)  (0) 2022.11.11
[Network] 웹소켓(Websocket)  (0) 2022.10.09
[Design Pattern] Facade 패턴을 통한 SRP원칙 준수  (0) 2022.08.22
[Design Pattern] Singleton Pattern  (0) 2022.08.22
[OS] Scheduler  (0) 2022.08.22

1. 도입 계기


로그인 이후 발급받은 토큰을 Session Storage에 저장한다면 XSS와 같은 스크립트 기반 공격이 가능하기때문에 탈취될 가능성이 있습니다. 이를 대비하기 위해 RefreshToken을 httpOnly,Secure 쿠키에 저장하는 방법을 사용하고자 SSL을 적용했습니다.

 

SSL을 적용하는 방법에는 스프링 부트의 내장 톰캣에 적용하는 방법과 Nginx프록시 서버를 앞단에 두고 SSL 인증을 적용하는 방법 두 가지가 있었습니다.

 

후자의 경우 Let’s Encrypt를 통해 간편하게 SSL인증서를 발급받고 갱신하는 과정을 자동화할 수 있고, SSL Termination을 통해 Nginx서버가 암호해독을 하도록 만듦으로써 스프링 애플리케이션 서버의 부담을 줄일 수 있다는 장점이 있었습니다. 이에 따라 Nginx를 도입하여 SSL 인증을 적용하였습니다.

 

2. Nginx


 

Nginx이전에 자주 사용되던 Apach서버의 경우 요청마다 프로세스를 생성하는 방식으로 메모리와 CPU 문맥교환으로 인한 오버헤드로 인해 구조적 한계가 있었습니다(C10K 문제). Nginx는 이러한 문제를 해결하기 위해 비동기 이벤트 기반의 구조로 설계되어, 다수의 연결을 효과적으로 처리할 수 있습니다. 주로 웹 애플리케이션 서버 앞단에 두어 프록시 서버로 활용합니다.

 

[Nginx를 프록시 서버로 두었을 때의 장점]

 

  1. WAS를 내부망에 둠으로써 직접적인 접근을 차단하여 보안성을 강화할 수 있습니다.
  2. 웹서버가 클라이언트 요청을 캐시하여 동일한 요청에 대해 성능상의 이점을 얻을 수 있습니다.
  3. SSL Termination을 활용하여 백엔드 서버의 부담을 줄일 수 있습니다.
  4. 비동기 이벤트 기반의 구조로 Apach서버에 비해 처리량이 높습니다.

 

[SSL Termination]

Untitled

그림과 같이 로드 밸런서가 SSL을 처리하고 로드밸런서와 웹서버는 HTTP통신을 하도록 만들어 웹서버의 SSL 처리에 대한 부담을 줄일 수 있습니다.

 

 

3. Nginx에 SSL 적용하기


#nginx 설치
sudo apt-get update
sudo apt install nginx -y 

#nginx 설치 확인
nginx -v

#nginx 동작 확인
sudo service nginx status

 

[Let’s Encrypt(Certbot)를 통한 SSL 인증서 발급]

# 설치
sudo add-apt-repository ppa:certbot/certbot$ sudo add-apt-repository ppa:certbot/certbot
sudo apt-get update
sudo apt-get install python-certbot-nginx -y
sudo certbot certonly --nginx -d {도메인 명}

# SSL 생성 확인
ls -al /etc/letsencrypt/live/{도메인 명}

# 인증서 갱신 가능 확인
sudo certbot renew --dry-run

# 인증서 갱신
sudo certbot renew

certbot을 설치한 후 위의 과정을 통해 도메인에 대한 SSL인증서를 발급하면, 다음과 같이 4개의 .pem 파일과 1개의 readme 파일이 생성됩니다.

README    cert.pem  chain.pem  fullchain.pem  privkey.pem

README 파일을 열어보면 다음과 같이 각 파일들에 대한 설명을 확인할 수 있습니다.

`privkey.pem`  : the private key for your certificate.
`fullchain.pem`: the certificate file used in most server software.
`chain.pem`    : used for OCSP stapling in Nginx >=1.3.7.
`cert.pem`     : will break many server configurations, and should not be used
                 without reading further documentation (see link below).

 

 

[Nginx 설정]

 

nginx의 공식문서에 따르면 HTTP서버를 구성하기 위해서는 다음과 같이 listening socket에 ssl 파라미터를 추가하고, server certificateprivate key 파일의 위치를 지정하여야 합니다.

server {
    listen              443 ssl;
    server_name         www.example.com;
    ssl_certificate     www.example.com.crt;
    ssl_certificate_key www.example.com.key;
    ssl_protocols       TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ...
}

nginx 설정을 위해 /etc/nginx/ 폴더의 nginx.conf 파일을 수정해도 되지만, nginx.conf파일을 살펴보면 include를 통해 외부의 설정들을 가져오는 부분이 있습니다. 이를 활용하여 설정들을 잘 모듈화 하여 관리할 수 있습니다.

    ##
    # Virtual Host Configs
    ##

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;

저는 sites-enabled 폴더에 nginx 설정파일을 다음과 같이 구성하였습니다.

#1
server {
            listen 80;
            server_name bidmarket-api.shop www.bidmarket-api.shop;
            return 301 https://bidmarket-api.shop$request_uri;
}

#2
server {
            listen 443 ssl http2;
            server_name bidmarket-api.shop www.bidmarket-api.shop;

            ssl_certificate /etc/letsencrypt/live/{도메인 주소}/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/{도메인 주소}/privkey.pem;

            #3
            location / {
                  proxy_pass http://localhost:8080;
                  proxy_set_header Host $http_host;
                  proxy_set_header X-Real-IP $remote_addr;
                  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                  proxy_set_header X-Forwarded-Proto $scheme;

            }

            #4
            location /ws-stomp {
                  proxy_pass http://localhost:8080;
                  proxy_http_version 1.1;
                  proxy_set_header Upgrade $http_upgrade;
                  proxy_set_header Connection "Upgrade";
                  proxy_set_header Host $host;
            }
}
  1. http로 요청이 들어오더라도 https로 리다이렉트 되도록 설정하였습니다.
  2. listening 소켓에 ssl 키워드를 추가하고, ssl 인증을 위한 .pem 파일의 위치를 지정하였습니다.
  3. proxy_pass를 통해 / 로 시작하는 path로 들어오는경우 [http://localhost:8080](http://localhost:8080) 로 요청을 돌리게 설정하였습니다.
  4. 웹소켓을 사용하기 때문에 nginx 공식문서를 참고하여 stomp 엔드포인트에 대한 설정을 하였습니다.
    1. https://www.nginx.com/blog/websocket-nginx/

1. Spring Triangle : IOC, AOP, PSA


 

스프링은 엔터프라이즈 애플리케이션 개발을 편리하게 하기 위해 등장하였습니다. 비즈니스와 애플리케이션 로직의 복잡함을 상대하기 위해 스프링은 객체지향과 DI라는 핵심 도구를 가지고 유연하고 확장성 있는 설계를 가능하게 만들었습니다.

 

스프링은 순수 자바 오브젝트(POJO)를 이용해 특정 환경과 기술에 종속되지 않고 비즈니스 로직 구현이 가능한데, 이러한 배경에는 스프링을 지탱하고 있는 3대 가능 기술 IoC, AOP, PSA가 있습니다.

 

 

 

2. IoC/DI : Inversion of Control, Dependency Injection


[의존성 주입]

A → B(인터페이스)라는 의존관계를 갖는 구조일 때, A가 B의 구현 클래스 인스턴스를 외부에서 주입받는 방식을 의존성 주입이라고 합니다. B의 변경이 A에 영향을 주지 않기 때문에 B의 입장에서는 유연한 확장이 가능하며, A의 입장에서는 변경 없이 재사용이 가능합니다(OCP). 이때 B의 구현 클래스 인스턴스를 생성하고 주입하는 역할을 스프링의 IoC 컨테이너가 하게 됩니다.

 

 

[IoC 컨테이너]

라이브러리와 프레임워크를 나누는 기준은 개발자가 제어권을 갖느냐 아니냐로 볼 수 있습니다. 스프링은 IoC 컨테이너를 통해 제어의 역전을 제공하기 때문에 프레임워크라고 부릅니다. 스프링은 관계설정의 책임을 BeanFactory(ApplicationContext)에 위임함으로써 유연한 확장을 가능하게 합니다.

 

ApplicationContext vs Bean Factory

ApplicationContext는 빈 팩토리를 조금 더 확장한 개념입니다. 스프링의 AOP기능을 쉽게 이용할 수 있도록 해 주고, MessageResource관리, 이벤트 발행 및 응용 계층을 위한 컨텍스트(ex WebApplicationContext)를 제공합니다. 따라서 Bean Factory라고 말할 때는 IoC의 기본 기능에 초점을 맞추게 되고, ApplicationContext라고 말하면 애플리케이션 전반에 걸쳐 모든 구성요소의 제어 작업을 담당하는 IoC엔진이라는 의미가 강조됩니다.

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans)

 

IoC컨테이너는 어떤 객체를 어떻게 관리해야할지에 대한 정보를 Configuration Metadata로부터 얻습니다. Configuration MetaData는 XML, 어노테이션, 또는 자바 코드로 표현할 수 있습니다.

 

- XML 기반의 구성 정보

<?xml version="1.0" encoding="UTF-8"?>
<beans>
    <bean id="myService" class="com.acme.services.MyServiceImpl"/>
</beans>

 

- Annotation 기반의 구성정보

@Configuration
public class AppConfig {

    @Bean
    public MyService myService() {
        return new MyServiceImpl();
    }
}

 

 

3. AOP : Aspect Oriented Programming


 

공통 관심사(https://www.javaguides.net/2019/05/understanding-spring-aop-concepts-and-terminology-with-example.html)

 

AOP는 프로그래밍 패러다임으로 공통관심사를 분리하여 모듈화를 이끌어 내는 것입니다. 객체지향에서 모듈화의 단위를 클래스(Class)라고 본다면, AOP에서는 모듈화의 단위를 애스팩트(Aspect)로 봅니다. 애스팩트는 여러 객체와 타입에 걸친 공통 관심사(ex 트랜잭션 관리)의 모듈화를 가능하게 합니다.

 

 

 

트랜잭션 프록시를 통한 메서드 호출 과정(https://docs.spring.io/spring-framework/docs/3.0.0.M4/reference/html/ch10s05.html)

 

스프링에 적용된 가장 인기 있는 AOP의 적용대상은 선언적 트랜잭션기능입니다. 선언적 트랜잭션은 소스코드에 직접 트랜잭션 관련 로직을 넣어두지 않고 비즈니스 로직에서 분리해 냅니다. 비즈니스 로직을 담당하고 있는 객체는 트랜잭션을 어떻게 해야 할지에 대한 구체적인 방법과 환경에 종속되지 않으며, 비즈니스 로직에 대한 테스트를 손쉽게 만들어낼 수 있게 됩니다.

 

[용어 정리]

  • Aspect : AOP의 기본 모듈로 한개 이상의 포인트 컷과 어드바이스의 조합으로 만들어집니다.
  • Join point : 프로그램을 실행할때의 시점으로 스프링 AOP에서는 메서드가 실행될 때를 말합니다.
  • Advice : aspect를 통해 특정 join point에서 가져온 부가기능을 의미합니다. 부가기능을 언제 실행할지에 따라 around, before, after 등의 타입으로 나뉩니다.
  • Pointcut : Advice를 적용할 Join Point를 선별하는 기능을 정의한 모듈을 말합니다.
  • Weaving : PointCut에 의해 결정된 타겟에 Advice를 삽입하는 과정을 말합니다.
  • Advisor : Pointcut과 Advice를 하나씩 갖고 있는 오브젝트로 부가기능을 어디에 전달할 것인가를 알고 있는 AOP의 기본 모듈입니다. 스프링 AOP에서만 사용되는 특별한 용어입니다.

 

[AOP 적용기법]

  • 다이나믹 프록시 사용
    • 스프링의 기본적인 AOP구현 방법
    • 기존 코드에 영향을 주지 않고 부가기능을 적용하게 해주는 데코레이터 패턴을 응용한 것으로 만들기 쉽고 적용하기 간편합니다.
    • 부가기능을 부여할 수 있는 곳이 메서드의 호출이 일어나는 지점뿐이라는 제약이 있습니다.
  • AspectJ
    • 오픈소스 AOP툴로 프록시 방식 AOP에서는 불가능한 다양한 조인 포인트를 제공합니다
    • 메소드 호출뿐만 아니라 인스턴스 생성, 필드 액세스, 특정 호출 경로를 가진 메서드 호출 등에도 부가기능을 제공할 수 있습니다.

 

 

4. PSA : Portable Service Abstraction


POJO로 개발된 코드는 특정 환경이나 구현방식에 종속적이지 않아야 합니다. 이를 위해 스프링이 제공하는 대표적인 기술이 바로 서비스 추상화 기술입니다. 스프링은 다양한 기술에 서비스 추상화를 제공하는데 그중 스프링 MVC와 트랜잭션에서의 서비스 추상화를 살펴보도록 하겠습니다.

 

[MVC에서의 서비스 추상화]

스프링 컨트롤러에서 메서드를 작성할때 특정 URL의 HTTP 메서드 요청을 처리하기 위해 @GetMapping, @PostMapping과 같은 어노테이션을 사용하곤 합니다. 이때 서블릿 애플리케이션을 만들고 있음에도 불구하고 다음과 같은 서블릿 코드를 작성하지 않습니다.

public class UserSignupServlet extends HttpServlet {

  @Override
  protected void doPost(
      HttpServletRequest req,
      HttpServletResponse resp
  ) throws ServletException, IOException {
    super.doPost(req, resp);
    // ...
  }

}

대신 단순히 어노테이션 사용만으로 위의 서블릿 기반의 코드를 동작하게 할 수 있습니다.

@PostMapping("signup")
@ResponseStatus(HttpStatus.CREATED)
public void signup(
    @Valid @RequestBody
    UserSignupRequest request
) {
  userService.signup(request.username(), request.profileImgUrl());
}

이렇게 스프링이 서비스 추상화를 제공하는 이유는 위와 같은 편의성을 제공하기 위함과 기술에 종속적이지 않은 코드를 작성할 수 있도록 도와주기 위함도 있습니다. 위의 코드를 변경하지 않고 spring-boot-starter-web의존성을 spring-boot-starter-webflux 의존성으로 변경하더라도 코드의 변경 없이 정상적으로 실행됩니다.

 

 

[트랜잭션에서의 서비스 추상화]

먼저 JDBC로 트랜잭션을 구현한 코드를 살펴보도록 하겠습니다.

package com.mkyong.jdbc;

import java.math.BigDecimal;
import java.sql.*;
import java.time.LocalDateTime;

public class TransactionExample {

    public static void main(String[] args) {

        try (Connection conn = DriverManager.getConnection(
                "jdbc:postgresql://127.0.0.1:5432/test", "postgres", "password");
             Statement statement = conn.createStatement();
             PreparedStatement psInsert = conn.prepareStatement(SQL_INSERT);
             PreparedStatement psUpdate = conn.prepareStatement(SQL_UPDATE)) {

            statement.execute(SQL_TABLE_DROP);
            statement.execute(SQL_TABLE_CREATE);

            // start transaction block
            conn.setAutoCommit(false); // default true

            // Run list of insert commands
            psInsert.setString(1, "mkyong");
            psInsert.setBigDecimal(2, new BigDecimal(10));
            psInsert.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now()));
            psInsert.execute();

            psInsert.setString(1, "kungfu");
            psInsert.setBigDecimal(2, new BigDecimal(20));
            psInsert.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now()));
            psInsert.execute();

            // Run list of update commands

            // error, test roolback
            // org.postgresql.util.PSQLException: No value specified for parameter 1.
            psUpdate.setBigDecimal(2, new BigDecimal(999.99));
            //psUpdate.setBigDecimal(1, new BigDecimal(999.99));
            psUpdate.setString(2, "mkyong");
            psUpdate.execute();

            // end transaction block, commit changes
            conn.commit();

            // good practice to set it back to default true
            conn.setAutoCommit(true);

        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    //...

}

이렇게 JDBC에서 트랜잭션을 적용하기위해서는 commit과 관련된 코드들을 작성해야 하지만, 스프링에서는 단순히 @Transactional 어노테이션을 사용하기만 하면 트랜잭션을 적용할 수 있습니다.

 

이러한 트랜잭션은 사용하는 기술에따라 트랜잭션을 관리하는 구현체를 JDBC를 사용하면 DatasourceTransactionManager를, JPA를 사용하면 JpaTranscationManager로 바꾸어 사용할 수 있습니다.

 

 

 

참고

Junit은 자바의 단위테스트를 위한 프레임 워크 입니다. 거의 매일 사용하는데 정작 내부 동작이 어떻게 이루어 지는지 공부해본적 없는것 같아 향로님의 블로그 글을 참고하여 추석 연휴동안 만들어 보았습니다.

구현할 주요 기능들

  • Assert
  • 단위 테스트
  • 어노테이션

구현

Assert

Junit에는 단위테스트를 편리하게 하기위해 Assert 라는 유틸리티 클래스가 존재합니다. 인스턴스화를 방지하기 위해 생성자는 private으로 두고 구현을 진행합니다.

package myjunit;

public class Assert {

  private Assert() {}

  public static void isTrue(boolean expression, String message) {
    if (!expression) {
      throw new AssertionFailedException(message);
    }
  }

  public static void isTrue(boolean expression) {
    isTrue(expression, "[Assertion failed] - this expression must be true");
  }

}

isTrue 메서드에 true인 값이 전달되면 테스트가 통과하고, false값이 전달되면 AssertionFailedException을 발생시켜 테스트가 실패하도록 구현하였습니다.

public class AssertionFailedException extends RuntimeException {

  public AssertionFailedException(String message) {
    super(message);
  }

}

단위 테스트

단위테스트는 테스트가 서로 독립적이어야 하며 테스트를 어떤 순서로든 실행할 수 있어야 합니다. 때문에 하나의 인스턴스를 기반으로 구현하기 보다 각 테스트마다 독립적인 인스턴스를 갖도록 설계해야 합니다.

package myjunit;

import java.lang.reflect.Method;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TestUnit {

  private final Logger logger;
  private final String name;

  public TestUnit(String name) {
    this.name = name;
    this.logger = LoggerFactory.getLogger(name);
  }

  public void execute() {
    test();
  }

  public void test() {
    try {
      Method method = this
          .getClass()
          .getMethod(name);
      method.invoke(this);
      logger.info("Test passed");
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

}
  • Logger의 경우 습관적으로 private static final 을 붙여 작성하곤 하지만, 각각의 단위 테스트의 통과여부를 이름으로 구분할 수 있도록 정적으로 선언하지 않았습니다. (클래스당 하나의 로거가 필요한 경우라면 static으로 선언하는것이 맞습니다)
  • test() 메서드의 경우 리플렉션을 활용하여 메서드의 이름을 통해 테스트 메서드를 호출하도록 구현하였습니다. 또한 리플렉션을 사용하였을때 발생하는 CheckedException은 RuntimeException으로 전환하도록 구현하였습니다.

만든 단위 테스트를 수행해보겠습니다.

public class DefaultTestUnit extends TestUnit {

  public DefaultTestUnit(String name) {
    super(name);
  }

  public void passTest() {
    Assert.isTrue(true);
  }

  public void failTest() {
    Assert.isTrue(false);
  }

  public static void main(String[] args) {
    new DefaultTestUnit("passTest").execute();
    new DefaultTestUnit("failTest").execute();
  }

}
Caused by: java.lang.reflect.InvocationTargetException

테스트 결과 InvocationTargetException 예외가 발생하여 다른 테스트 결과를 확인할 수 없었습니다. invoke()로 호출한 메서드 내에서 예외가 발생하면 해당 예외를 InvocationTargetException으로 wrapping하게 됩니다. 때문에 어떤 이유로 테스트가 실패하였는지 구분하기 위해 예외를 try-catch문으로 처리 해주어야 합니다.

테스트시 발생하는 에러와 실패를 구분하고 테스트 결과를 확인하기 위해 TestFailure, TestError, TestResult 클래스를 구현하였습니다.

package myjunit;

public class TestFailure {

  private final TestUnit testUnit;

  public TestFailure(TestUnit testUnit) {
    this.testUnit = testUnit;
  }

  public String getTestName() {
    return testUnit.getName();
  }

}
package myjunit;

public class TestError extends TestFailure {

  private final Exception exception;

  public TestError(
      TestUnit testUnit,
      Exception exception
  ) {
    super(testUnit);
    this.exception = exception;
  }

  public Exception getException() {
    return exception;
  }

}
package myjunit;

import java.util.ArrayList;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TestResult {

  private static final Logger logger = LoggerFactory.getLogger(TestResult.class);

  private int runTestCount;
  private final List<TestError> errors;
  private final List<TestFailure> failures;

  public TestResult() {
    this.runTestCount = 0;
    this.errors = new ArrayList<>();
    this.failures = new ArrayList<>();
  }

  public synchronized void startTest() {
    this.runTestCount++;
  }

  public void addError(
      TestUnit testUnit,
      Exception e
  ) {
    errors.add(new TestError(testUnit, e));
  }

  public void addFailure(TestUnit testUnit) {
    failures.add(new TestFailure(testUnit));
  }

  public void printResult() {
    logger.info("Total Test Count: {}", runTestCount);
    logger.info("Total Test Success Count: {}", runTestCount - failures.size() - errors.size());
    logger.info("Total Test Failure Count: {}", failures.size());
    logger.info("Total Test Error Count: {}", errors.size());
  }

}

그리고 TestResult 에 테스트 수행결과를 저장할 수 있도록 TestUnit 을 수정하였습니다.

package myjunit;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TestUnit {

  private final Logger logger;
  private final String name;

  public TestUnit(String name) {
    this.name = name;
    this.logger = LoggerFactory.getLogger(name);
  }

  public String getName() {
    return name;
  }

  public void execute(TestResult testResult) {
    testResult.startTest();
    try {
      test();
    } catch (InvocationTargetException e) {
      if (isAssertionFailed(e)) {
        logger.info("Test failed");
        testResult.addFailure(this);
      } else {
        logger.info("Test error occur");
        testResult.addError(this, e);
      }
    } catch (Exception e) {
      logger.info("Test failed");
      testResult.addError(this, e);
    }
  }

  public void test() throws Exception {
    Method method = this
        .getClass()
        .getMethod(name);
    method.invoke(this);
    logger.info("Test passed");
  }

  private boolean isAssertionFailed(InvocationTargetException ite) {
    return ite.getTargetException() instanceof AssertionFailedException;
  }

}

만든 UnitTest 를 통해 테스트를 진행하고 결과를 확인하였습니다

package myjunit;

public class DefaultTestUnit extends TestUnit {

  public DefaultTestUnit(String name) {
    super(name);
  }

  public void passTest() {
    Assert.isTrue(true);
  }

  public void failTest() {
    Assert.isTrue(false);
  }

  public static void main(String[] args) {
    TestResult testResult = new TestResult();
    new DefaultTestUnit("passTest").execute(testResult);
    new DefaultTestUnit("failTest").execute(testResult);

    testResult.printResult();
  }

}

결과

19:37:19.625 [main] INFO passTest - Test passed
19:37:19.626 [main] INFO failTest - Test failed
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Count: 2
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Success Count: 1
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Failure Count: 1
19:37:19.626 [main] INFO myjunit.TestResult - Total Test Error Count: 0

어노테이션

테스트를 수행하기 위해선 일일이 테스트 메서드의 이름을 통해 DefaultTestUnit 인스턴스를 생성해야합니다. 이러한 불편함을 줄이기 위해 단순히 어노테이션만을 붙이는것으로 변경해 보겠습니다.

Test.java

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

}

CaseOne.java

public class CaseOne {

  @Test
  void passTest() {
    Assertion.isTrue(true);
  }

  @Test
  void failTest() {
    Assertion.isTrue(false);
  }
}

다음으로 해당 어노테이션이 붙은 메서드만 모아서 실행시켜야 합니다.

Java의 런타임 메타데이터 분석 라이브러리 Reflections를 활용하여 테스트하고자 하는 패키지의 모든 @Test 어노테이션이 붙은 메서드를 스캔하는 스캐너를 만들었습니다.

public class TestScanner {

  public Set<Method> scanTestMethods(String packageName) {
    Reflections reflections = new Reflections(packageName, Scanners.MethodsAnnotated);
    return reflections.getMethodsAnnotatedWith(Test.class);
  }

}

그리고 Reflection을 통해 가져온 Method를 수행시키도록 TestUnit 클래스를 변경하였습니다.

public class TestUnit {

  private final Logger logger;
  private final Method method;

  public TestUnit(Method method) {
    this.method = method;
    this.logger = LoggerFactory.getLogger(method.getName());
  }

  public String getName() {
    return method.getName();
  }

  public void execute(TestResult testResult) {
    testResult.startTest();
    try {
      test();
    } catch (InvocationTargetException e) {
      if (isAssertionFailed(e)) {
        logger.info("Test failed");
        testResult.addFailure(this);
      } else {
        logger.info("Test error occur");
        testResult.addError(this, e);
      }
    } catch (Exception e) {
      logger.info("Test failed");
      testResult.addError(this, e);
    }
  }

  public void test() throws Exception {
    Object newInstance = getNewInstanceOfDeclaringClass(method);
    method.setAccessible(true);
    method.invoke(newInstance);
    logger.info("Test passed");
  }

  private boolean isAssertionFailed(InvocationTargetException ite) {
    return ite.getTargetException() instanceof AssertionFailedException;
  }

  private Object getNewInstanceOfDeclaringClass(Method method) throws Exception {
    return method
        .getDeclaringClass()
        .getDeclaredConstructor()
        .newInstance();
  }

}

이때 메서드를 선언한 클래스의 새로운 인스턴스를 생성하고, 이를 통해 Method의 invoke()함수를 호출하도록 구현하였습니다. 만약 메서드가 선언되지 않은 클래스를 통해 호출하게 되면 IllegalArgumentException이 발생하게 됩니다.

또한 setAccessible(true)를 통해 해당 메서드의 접근지정자를 무시하고 호출할 수 있도록 하였습니다. 기본 접근지정자가 private이기에 번거롭게 접근지정자를 public으로 두는 수고를 덜기 위해서입니다.

다음으로 스캐너를 통해 가져온 테스트대상 메서드를 실행시키는 테스트 러너를 구현하였습니다.

public class TestRunner {

  private static final String testPackage = "com.myjunit.testCase";

  public static void main(String[] args) {
    TestResult testResult = new TestResult();
    TestScanner testScanner = new TestScanner();
    Set<Method> methods = testScanner.scanTestMethods(testPackage);

    methods.forEach(method -> new TestUnit(method).execute(testResult));

    testResult.printResult();
  }

}

TestScanner를 통해 모든 @Test 어노테이션이 붙은메서드를 가져오고 TestUnit을 통해 해당 메서드들에 대한 단위테스트를 수행하도록 로직을 구현하였습니다.

결과

23:40:03.992 [main] INFO org.reflections.Reflections - Reflections took 33 ms to scan 1 urls, producing 1 keys and 2 values
23:40:03.998 [main] INFO failTest - Test failed
23:40:03.999 [main] INFO passTest - Test passed
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Count: 2
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Success Count: 1
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Failure Count: 1
23:40:03.999 [main] INFO com.myjunit.core.TestResult - Total Test Error Count: 0

소스코드

https://github.com/waterfogSW/make-junit

참고

https://jojoldu.tistory.com/231

https://github.com/ronmamo/reflections

DTO는 Data Transfer Object의 약자로 계층간 데이터를 전달하는 객체로 사용합니다.

 

스프링을 활용한 프로젝트에서는 주로 계층형 아키텍쳐를 설계하면서 도메인과 화면간의 의존성을 줄이기 위해 DTO를 사용하게 되는데, 이때 DTO와 Domain간의 변환을 어느 계층에서 수행할지 고민하게 됩니다.

DTO ↔ Domain 변환은 컨트롤러에서하자

DTO와 Domain의 변환을 컨트롤러에서 하게 되면 엔티티가 컨트롤러계층까지 올라온다는것을 의미합니다. 이때 DTO객체를 만들면서 의도치 않게 연관된 엔티티를 조회하면서 LazyInitializationException 혹은 N+1문제가 발생할 수 있습니다.

 

스프링 JPA의 open-in-view옵션을 false로 두고 JPA 지연 로딩을 사용하는 경우, controller에서 연관된 엔티티의 정보를 가져오기 위해 조회를 하게 되면 LazyInitializationException 이 발생합니다.

 

예를들어 상품을 조회하는 API를 설계하는 상황에서, 상품이 유저(판매자)와 연관관계를 맺고 있고, 상품의 판매자 정보를 포함한 응답을 주어야 하는 상황입니다.

 

상품

@Getter
@Entity
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {

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

  private String name;

  private Long price;

  private Long quantity;

  @OneToOne(fetch = FetchType.LAZY)
  private User seller;

	...
}

유저

@Getter
@Entity(name = "`user`")
public class User {

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

  @Column(unique = true)
  private String name;

}

상품 조회 응답

public record ProductSelectResponse(
    String productName,
    Long quantity,
    Long price,
    String sellerName
) {

  public static ProductSelectResponse from(Product product) {
    return new ProductSelectResponse(
        product.getName(),
        product.getQuantity(),
        product.getPrice(),
        product.getSeller().getName()
    );
  }

}

상품 API 컨트롤러

@RestController
@RequiredArgsConstructor
@RequestMapping("api/v1/products")
public class ProductRestController {

  private final ProductService productService;

  @GetMapping("{id}")
  public ProductSelectResponse selectOne(
      @PathVariable long id
  ) {
    Product product = productService.findOne(id);
    return ProductSelectResponse.from(product);
  }

}

에러 로그

org.hibernate.LazyInitializationException: could not initialize proxy [com.example.demo.user.entity.User#1]

FetchType을 Eager로 두면 되지 않을까?

단순히 Product엔티티를 상품을 조회하는데에만 사용하고 싶다면, Eager로 두어도 됩니다. 하지만, 연관된 엔티티의 정보(User)의 정보가 불필요한 경우에도 매번 유저정보까지 한번에 조회하는 쿼리를 발생시키게 되기 때문에 가급적이면 지연로딩을 사용하는것이 좋습니다.

OSIV를 true로 두면 되지 않을까?

LazyInitializationException을 해결하기 위해 OSIV설정을 true로 둘 수 있습니다(기본값 true). OSIV를 true로 두게 되면 스프링 애플리케이션을 처음 실행할 때 다음과 같은 warn로그가 뜨는것을 확인할 수 있습니다.

JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

OSIV옵션은 영속성 컨텍스트의 생존범위를 컨트롤러 레이어까지 허용하는 것인데 이는 다른말로 DB와의 Connection을 컨트롤러까지 유지하고 있다는 뜻이 됩니다. 이는 성능과 확장성 측면에서 좋지않은 영향을 줍니다.

open-in-view: true

open-in-view: false

What is this spring.jpa.open-in-view=true property in Spring Boot?

DTO ↔ Domain 변환은 서비스에서하자

그렇다면 DTO ↔ Domain 변환을 서비스에서 하면 되는것 아닌가하는 생각을 하게 됩니다.

 

DTO와 Domain의 변환을 서비스 레이어에서 하게되면 서비스레이어 메서드의 요청과 응답은 DTO가 됩니다. 이경우 만약 JPA를 사용하고 있다면 서비스 메서드의 응답이 영속화된 엔티티가 아니라, DTO가 되기 때문에 다른 서비스 메서드가 반환된 값을 활용하고자 할 경우 JPA의 더티 체킹을 활용할 수 없게됩니다.

 

또한 서비스가 엔티티와 DTO모두에 의존하게 되기 때문에 서비스 메서드의 재사용성을 떨어뜨리게 됩니다.

관점의 차이

사실 이 두문제는 정답이 없는 문제입니다. 서비스 메서드의 재사용성을 위해 컨트롤러에서 DTO와 엔티티를 변환할지, 응답까지도 비즈니스 로직이라 보고 서비스레이어에서 DTO와 엔티티를 변환할지 다양한 관점에 따라 의견이 갈리는 주제입니다.

 

선택에 대한 트레이드오프와 관점의 차이를 잘 이해하고 설계에 맞게 유연하게 사고하는것이 필요하겠다는 생각이 들었습니다. 무조건 이렇게 하는것이 좋다더라 이렇게 하자 보다는 많이 코드를 작성해보고 고민해 보면서 자신만의 기준을 세우는것이 좋을것 같습니다.

 

예제 코드

https://github.com/waterfogSW/blog-dto-domain

+ Recent posts