Develop/DevOps

알아두면 언젠간 깨달을 도커지식 2 - 도커 네트워크 | Docker Knowledge You'll Eventually Appreciate 2 - Docker Network

이글은 야매로 작성된 글이며 필자가 깨달아갈 수록 추가되는 글입니다필자가 도커를 공부하며 깨달은 지극히 주관적인 관점일 수 있으니 이상한 점은 친절한 댓글 부탁드립니다!그리고 항상 도커의 길로 인도해주는 영우찡 감사여! 가상 네트워크아이피의 갯수는 제한적이다 우리가 사용하는 공유기는 주로 192.168.x.x 아이피 대역으로 내 컴퓨터에 아이피를 할당해준다. 그치만 이 아이피는 내 공유기 밖에서는 접근 할 수 없다.그럼 이 때 외부에서 공유기 안의 특정 서버에 접근하기 위해서는 포트 포워딩을 사용한다. 포트포워딩이 필요한 이유는 공유기는 하나인데 비해(ip는 하나인데 비해) 내부 서버는 여러대일 수 있으니 어느 서버의 포트로 연결을 해주어야 하는지 몰라서 포트 포워딩을 사용하는 것이다. 외부에서 8080..

알아두면 언젠간 깨달을 도커지식 2 - 도커 네트워크 | Docker Knowledge You'll Eventually Appreciate 2 - Docker Network

728x90

이글은 야매로 작성된 글이며 필자가 깨달아갈 수록 추가되는 글입니다
필자가 도커를 공부하며 깨달은 지극히 주관적인 관점일 수 있으니 이상한 점은 친절한 댓글 부탁드립니다!
그리고 항상 도커의 길로 인도해주는 영우찡 감사여!

 

가상 네트워크

아이피의 갯수는 제한적이다 우리가 사용하는 공유기는 주로 192.168.x.x 아이피 대역으로 내 컴퓨터에 아이피를 할당해준다. 그치만 이 아이피는 내 공유기 밖에서는 접근 할 수 없다.

그럼 이 때 외부에서 공유기 안의 특정 서버에 접근하기 위해서는 포트 포워딩을 사용한다. 포트포워딩이 필요한 이유는 공유기는 하나인데 비해(ip는 하나인데 비해) 내부 서버는 여러대일 수 있으니 어느 서버의 포트로 연결을 해주어야 하는지 몰라서 포트 포워딩을 사용하는 것이다. 
외부에서 8080포트를 요청할 때 공유기 하위에 있는 서버들이 모두 8080을 쓰고 있을 때 어느 서버의 8080인지를 몰라서 서버를 지정하는게 목적이다. 추가로 서버를 지정하면서 포트도 바꿀수 있게 되었고!

주로 사용하는 http와 tcp 요청은 ip와 포트로 타겟을 지정하는데, 외부에서 요청할 때

ip는 우리집 공유기가 가진 공인 아이피를 지정할 테고,
port는 공유기 아래 있는 여러대의 서버중에 어떤 곳에 이 요청을 전달해야 할 지 모르기 때문에 포트포워딩으로 미리 정해두고 전달하는 것이다.

 

도커의 포트포워딩

도커도 마찬가지로 -p 플래그를 사용해서 포트포워딩을 지정할 수 있다. 도커도 하나의 서버에 여러개의 컨테이너가 있어서, 서버의 포트에 컨테이너를 지정할지가 애매해서, 위에서 공유기에서 서버를 지정할 때 애매했던 것과 동일한 이유로 포트포워딩이 필요하다.

도커환경에서는 OS가 설치된 host machine이 공유기의 역할을 하고, 도커가 가상 망을 이루고 있는 것이다.

ip는 도커가 설치된 머신을 의미하고
port는 도커 망안에서 어떤 컨테이너로 연결을 할지에 대한 의미로 생각하자 (-p)

 

도커 안에서는 모든 컨테이너가 전부 다른 서브넷을 가진다.

도커 컨테이너 끼리는 통신을 하지 못한다
-> 컨테이너끼리 연결해주는 작업이 도커 안에 네트워크를 다는 것

사실 컨테이너 가상화가 각자 분리된 영역을 만들어 주기 위함이었으니 서로 통신도 분리를 해둔 것이라고 생각하면 편하다.(필요할 경우에 통신을 연결하면 되도록 만든 것)

 

도커 네트워크

하지만 컨테이너가 분리가 되어있다 하더라도 통신은 필요하다. 예를 들자면 내 springboot 서비스에 대한 컨테이너 A와 DB에 대한 컨테이너 B 둘사이에서 통신이 일어나는 경우를 생각할 수 있을 것이다.

따라서 컨테이너끼리 통신을 위해 도커 네트워크를 만들어서 여러개의 컨테이너를 하나의 가상의 망 아래에 묶을 수 있다.

이런식으로 네트워크를 만들고

docker network create test_network

만들어진 네트워크에 컨테이너를 등록하는 형태로 지정하면 컨테이너간 통신이 가능해진다.

docker network connect web_server_container

 

이때 문제는 컨테이너의 아이피가 랜덤으로 만들어진다는 점이다. (아이피가 컨테이너마다 부여된다) 컨테이너가 뜰 때마다 아이피가 랜덤이다.

아이피가 랜덤으로 부여되면, 아이피를 이용해서 통신을 할 수 없다. 따라서 통신을 하기위해서는 컨테이너 네임으로 지정하는 방식을 이용한다.

컨테이너 네임이 Container network interface(CNI)이라는 도커 내부의 가상망을 만들어주는 곳에 등록되어 있어 ip 대신 컨테이너 네임을 DNS 서버에 등록한다.

도커 네트워크에 컨테이너가 등록될 때 컨테이너의 ip는 랜덤하게 부여되지만, CNI 덕분에 컨테이너 네임을 이용할 수 있다.

결국 도커에서 네트워크 통신을 할 때 DNS 검색 우선 순위에서 CNI가 1순위이기 때문에 컨테이너 이름으로 호출이 가능한 것이다.

컨테이너 이름을 아이피 대신 사용한다.

 

그래서 웹앱 컨테이너 하나랑 db 컨테이너하나 띄우고 두 컨테이너를 하나의 네트워크로 묶어서 앱에서 DB를 호출할 때.

  네임스페이스 포트
웹앱 컨테이너 web_app_container 9090
DB 컨테이너 DB_container 3306

다음과 같은 정보를 갖고있다면, 그저 springboot 에서 DB 컨테이너에 연결을 하고자 한다면 DB_container:3306 이런식으로 적어도 작동이 된다는 것이다.

 

이해를 위한 예제

Q. 집에 공유기가 있고, 그 안에 서버가 있고, 그안에 컨테이너 한경에서 9090 포트로 서비스하는앱이 있다. 이때 포트 포워딩은 몇 번 일어날까?

A. 2번일어난다 : 공유기에서 한번, 도커에서 한번

Q. 클라우드 환경에서 nginx를 사용한 포트포워딩을 할 때, nginx는 springboot 로 연결 연결, springboot에서는 db로 연결할 때 도커 네트워크 통신은 몇번 일어날까?

A. 2번 일어난다 : nginx의 proxy_pass 1번, spring에서 mysql로의 1번

container_name:portNumber 의 규칙을 사용하여 도커 네트워크 통신을 한다

[연결 글]

2020/07/12 - [Develop/DevOps] - 알아두면 언젠간 깨달을 도커지식 1 - 가상화

 

알아두면 언젠간 깨달을 도커지식 1 - 가상화

이글은 야매로 작성된 글이며 필자가 깨달아갈 수록 추가되는 글입니다 필자가 도커를 공부하며 깨달은 지극히 주관적인 관점일 수 있으니 이상한 점은 친절한 댓글 부탁드립니다! 그리고 항상

jyami.tistory.com

 

This post is written in a rough-and-ready style and gets updated as I learn more
This may reflect a highly subjective perspective gained while studying Docker, so please leave a kind comment if anything seems off!
And thanks as always to Youngwoo for guiding me down the path of Docker!

 

Virtual Network

The number of IP addresses is limited. The router we use typically assigns an IP to our computer in the 192.168.x.x range. However, this IP is not accessible from outside our router.

So, to access a specific server behind the router from the outside, we use port forwarding. The reason port forwarding is needed is that while there's only one router (only one IP), there can be multiple internal servers, and there's no way to know which server's port to route the connection to — that's why we use port forwarding. 
When an external request comes in on port 8080, if all the servers behind the router are using 8080, there's no way to tell which server's 8080 it should go to — so the purpose is to specify the server. As a bonus, you can also change the port while specifying the server!

Commonly used HTTP and TCP requests specify targets using IP and port. When making a request from the outside:

The IP will point to the public IP of our home router,
and since there's no way to know which of the multiple servers behind the router should receive the request, we use port forwarding to predetermine and route it.

 

Port Forwarding in Docker

Docker works the same way — you can specify port forwarding using the -p flag. Since Docker also has multiple containers on a single server, it's ambiguous which container should be assigned to the server's port. For the same reason it was ambiguous when specifying servers on a router, port forwarding is needed.

In a Docker environment, the host machine with the OS installed acts as the router, and Docker forms its own virtual network.

Think of the IP as referring to the machine where Docker is installed,
and the port as indicating which container to connect to within the Docker network (-p).

 

Inside Docker, every container has a completely different subnet.

Docker containers cannot communicate with each other
-> The process of connecting containers is adding a network within Docker

Since container virtualization was designed to create isolated areas in the first place, it makes sense that communication is also separated. (It's built so that you connect communication only when needed.)

 

Docker Network

However, even though containers are isolated, communication is still necessary. For example, you can think of a case where container A running your Spring Boot service needs to communicate with container B running the DB.

So, for inter-container communication, you can create a Docker network to group multiple containers under a single virtual network.

You create a network like this:

docker network create test_network

Then you register containers to the created network, and communication between containers becomes possible.

docker network connect web_server_container

 

The problem here is that container IPs are assigned randomly. (Each container gets its own IP.) Every time a container starts up, the IP is random.

If IPs are assigned randomly, you can't rely on IPs for communication. Therefore, to communicate, we use the container name instead.

The container name is registered in Docker's internal virtual network manager called the Container Network Interface (CNI), and the container name is registered in the DNS server instead of the IP.

When a container is registered to a Docker network, its IP is assigned randomly, but thanks to CNI, you can use the container name instead.

Ultimately, when Docker handles network communication, the CNI has the highest priority in DNS lookup, which is why you can call containers by their name.

Container names are used in place of IPs.

 

So when you spin up one web app container and one DB container, group them under a single network, and the app calls the DB:

  Namespace Port
Web App Container web_app_container 9090
DB Container DB_container 3306

If you have the information above, then to connect from Spring Boot to the DB container, you can simply write DB_container:3306 and it will work.

 

Examples for Understanding

Q. You have a router at home, a server behind it, and inside that server, an app serving on port 9090 in a container environment. How many times does port forwarding occur?

A. It happens 2 times: once at the router, once at Docker

Q. In a cloud environment using nginx for port forwarding, where nginx connects to Spring Boot, and Spring Boot connects to the DB — how many times does Docker network communication occur?

A. It happens 2 times: once for nginx's proxy_pass, once for Spring to MySQL

Docker network communication follows the rule of container_name:portNumber

[Related Posts]

2020/07/12 - [Develop/DevOps] - Docker Knowledge You'll Eventually Appreciate 1 - Virtualization

 

Docker Knowledge You'll Eventually Appreciate 1 - Virtualization

This post is written in a rough-and-ready style and gets updated as I learn more. This may reflect a highly subjective perspective gained while studying Docker, so please leave a kind comment if anything seems off! And thanks as always

jyami.tistory.com

 

댓글

Comments

Develop/DevOps

알아두면 언젠간 깨달을 도커지식 1 - 가상화 | Docker Knowledge You'll Eventually Appreciate 1 - Virtualization

이글은 야매로 작성된 글이며 필자가 깨달아갈 수록 추가되는 글입니다필자가 도커를 공부하며 깨달은 지극히 주관적인 관점일 수 있으니 이상한 점은 친절한 댓글 부탁드립니다!그리고 항상 도커의 길로 인도해주는 영우찡 감사여! 서버 가상화가상화에는 두가지 종류가 있다.하이퍼 바이저 가상화컨테이너 가상화 하이퍼바이저 가상화하이퍼바이저 기반의 가상화를 사용할 경우에는 내 서버 위에 host OS를 설치하고, 사용할 만큼만 자원을 분할하여 가상머신을 생성한 후에 그 위에 Guest OS를 설치하여 그 위에서 어플리케이션을 사용한다.하지만 하이퍼바이저 기반 가상화의 경우엔 vm마다 자원을 따로따로 분리해서 사용을하는데, 중복된 자원이 있을 경우엔 사실 불필요한 용량(자원)을 소모한다고 볼 수 있다. 컨테이너 가상화따라..

알아두면 언젠간 깨달을 도커지식 1 - 가상화 | Docker Knowledge You'll Eventually Appreciate 1 - Virtualization

728x90

이글은 야매로 작성된 글이며 필자가 깨달아갈 수록 추가되는 글입니다
필자가 도커를 공부하며 깨달은 지극히 주관적인 관점일 수 있으니 이상한 점은 친절한 댓글 부탁드립니다!
그리고 항상 도커의 길로 인도해주는 영우찡 감사여!

 

서버 가상화

가상화에는 두가지 종류가 있다.

  1. 하이퍼 바이저 가상화
  2. 컨테이너 가상화

 

하이퍼바이저 가상화

하이퍼바이저 기반의 가상화를 사용할 경우에는 내 서버 위에 host OS를 설치하고, 사용할 만큼만 자원을 분할하여 가상머신을 생성한 후에 그 위에 Guest OS를 설치하여 그 위에서 어플리케이션을 사용한다.

하지만 하이퍼바이저 기반 가상화의 경우엔 vm마다 자원을 따로따로 분리해서 사용을하는데, 중복된 자원이 있을 경우엔 사실 불필요한 용량(자원)을 소모한다고 볼 수 있다.

 

컨테이너 가상화

따라서 이 OS 기반 가상화 기술을 대체할 수 있게 나온것이 Container 기술이다 이 컨테이너 기술은 LXC(linux Containers)라고 부르며 이 LXC 기술을 잘 활용해서 만든 것이 Docker이다.

 

도커환경에서는 위 그림처럼 Guest OS가 필요하지 않다. 쉬운 이해를 위해 예를 들어보자

 

Host OS가 Cent OS이고, Ubuntu에서 application을 실행하고 싶다.

1. 하이퍼 바이저에서는, GuestOS인 Ubuntu를 설치하고 그 위에서 app을 실행한다. 그러면 Guest OS에 있는 Kernel이 하이퍼 바이저에 명령을 전달하고 하이퍼 바이저가 다시 Cent OS의 Kernel로 명령어를 전달한다.

사실 이때만 봐도 커널과, OS가 각각 2개이다 (많은 리소스를 소모한다)

이때 하이퍼 바이저는 논리적으로 CPU와 Memory 자원을 분리하는 역할을 한다.

 

2. 반면 도커엔진은 Guest OS인 Ubuntu와 Ubuntu의 Kernel 없이 앱의 명령을 내 host OS인 Cent OS의 kernel에 전달 할 수 있다.

이는 도커엔진이 Ubuntu(guest OS) 위에서 실행되는 app의 명령어를 Cent OS(host OS) kernel이 이해할 수 있게 바꿔주는 역할을 하기 때문이다.

하이퍼 바이저와 비교해서 리소스 소모가 훨씬 적다.

 

하이퍼 바이저와는 다르게 도커는 커널을 설치하지 않아, 베이스 이미지에도 커널이 없다. 따라서 하이퍼바이저에서 서버를 설치할 때 쓰는 윈도우나 리눅스 이미지 파일(iso)에 비해서 도커 이미지는 (베이스 이미지 조차도) 훨씬 가볍다

가벼운만큼 설치가 엄청 빠르다!!! (컨테이너 환경은 가볍다)

 

LXC(Linux Containers)

컨테이너 환경이 실제 메모리 상에 어떤식으로 저장될지를 생각해보면,

리눅스에 프로세스의 네임스페이스를 다르게 해서 분리해둔 것이라고 생각하면된다. 리눅스에 격리 구역을 만들어서 서로 엉키지 않게 분리해놓고 그냥 프로세스를 실행하는 것이다

이 작업들을 LXC가 해준다

 

LCX는 리눅스 커널 컨테이너 기능을 위한 사용자 영역 인터페이스다. 리눅스 사용자가 쉽게 시스템과 어플리케이션의 컨테이너들을 생성/관리 할 수 있게 해준다.

공식문서에 따르면 아래와 같은 기능이 있다 한다. (근데 읽어도 솔직히 잘 모르겠다)

  • 커널 네임스페이스 (ipc, uts, 마운트, pid, 네트워크, 사용자)
  • Seccomp 정책
  • Chroots (pivot_root 사용)
  • 커널 캐퍼빌리티
  • CGroups (컨트롤 그룹)
  • Apparmor와 SELinux 프로파일

그래서 그냥 컨테이너 환경에서는 도커 위에 올라간 코드를 돌린다는 것은

  1. LCX를 통해 리눅스 커널안에 Cgroup이라는 구분자와 NameSpace라는 구분자를 결합하여 격리된 구역을 만든다.
  2. 해당 격리 구역에 있는 코드를 도커엔진은 LXC를 사용하여 격리된 구역 안에서 프로그램을 실행한다
  3. 즉, 도커엔진이 리눅스에서 돌 때 LXC를 사용하여 위에 말한 host OS kernel 명령어로의 치환이 이루어 지고 구역을 격리시킨다는 것이다

첨언하자면 옛날의 윈도우의 도커는

하이퍼바이저위에 geust OS로 리눅스를 설치하고 그위에서 도커엔진을 돌렸다고한다 (리소스 절약이 없었을 것)

요즘엔 그냥 된다고 한다.(최근 윈도우에 리눅스 커널[WSL]이 들어왔다고..mac유저라 관심이 없었음)

 

[연결 글]

2020/07/12 - [Develop/DevOps] - 알아두면 언젠간 깨달을 도커지식 2 - 도커 네트워크

 

알아두면 언젠간 깨달을 도커지식 2 - 도커 네트워크

이글은 야매로 작성된 글이며 필자가 깨달아갈 수록 추가되는 글입니다 필자가 도커를 공부하며 깨달은 지극히 주관적인 관점일 수 있으니 이상한 점은 친절한 댓글 부탁드립니다! 그리고 항상

jyami.tistory.com

 

This post is written in a rough-and-ready style and will be updated as I learn more
This may reflect a highly subjective perspective from my experience studying Docker, so please leave a kind comment if anything seems off!
And shoutout to Youngwoo for always guiding me down the path of Docker!

 

Server Virtualization

There are two types of virtualization.

  1. Hypervisor Virtualization
  2. Container Virtualization

 

Hypervisor Virtualization

Hypervisor-based virtualization works by installing a host OS on your server, partitioning resources as needed to create virtual machines, then installing a Guest OS on each VM to run applications on top of it.

However, with hypervisor-based virtualization, each VM uses its own separately allocated resources — and when there are duplicate resources across VMs, it essentially wastes unnecessary capacity (resources).

 

Container Virtualization

So what came along to replace this OS-based virtualization technology is Container technology. This container technology is called LXC (Linux Containers), and Docker is something built by leveraging this LXC technology really well.

 

In a Docker environment, as shown in the diagram above, a Guest OS is not needed. Let's walk through an example for easier understanding.

 

The Host OS is CentOS, and you want to run an application on Ubuntu.

1. With a hypervisor, you install Ubuntu as the Guest OS and run the app on top of it. Then the kernel in the Guest OS passes commands to the hypervisor, and the hypervisor relays them to the CentOS kernel.

Just looking at this, there are already two kernels and two OSes (consuming a lot of resources).

Here, the hypervisor's role is to logically separate CPU and memory resources.

 

2. On the other hand, the Docker engine can deliver app commands directly to the host OS (CentOS) kernel without needing the Guest OS (Ubuntu) or Ubuntu's kernel.

This is because the Docker engine translates the commands from apps running on Ubuntu (Guest OS) into something the CentOS (host OS) kernel can understand.

Compared to a hypervisor, resource consumption is significantly lower.

 

Unlike a hypervisor, Docker doesn't install a kernel, so even the base image has no kernel. Therefore, Docker images (even base images) are much lighter compared to the Windows or Linux image files (ISO) used when setting up servers in a hypervisor.

Being lightweight means installation is incredibly fast!!! (Container environments are lightweight)

 

LXC (Linux Containers)

If you think about how a container environment is actually stored in memory,

you can think of it as processes separated by different namespaces in Linux. It creates isolated zones in Linux so things don't get tangled up, and simply runs processes within those zones.

This is what LXC does for us.

 

LXC is a userspace interface for the Linux kernel containment features. It lets Linux users easily create and manage system and application containers.

According to the official documentation, it has the following features. (Though honestly, I still don't fully get it even after reading it)

  • Kernel namespaces (ipc, uts, mount, pid, network, user)
  • Seccomp policies
  • Chroots (using pivot_root)
  • Kernel capabilities
  • CGroups (control groups)
  • Apparmor and SELinux profiles

So, running code on top of Docker in a container environment basically means:

  1. Through LXC, isolated zones are created inside the Linux kernel by combining identifiers called Cgroups and Namespaces.
  2. The Docker engine uses LXC to run programs inside those isolated zones.
  3. In other words, when the Docker engine runs on Linux, it uses LXC to translate commands into host OS kernel commands (as mentioned above) and isolate the zones.

As a side note, Docker on Windows in the old days

used to install Linux as a Guest OS on top of a hypervisor and run the Docker engine on that (so there were no resource savings).

Nowadays it just works natively, they say. (Apparently a Linux kernel [WSL] was recently added to Windows..I'm a Mac user so I didn't really care)

 

[Related Post]

2020/07/12 - [Develop/DevOps] - Docker Knowledge You'll Eventually Appreciate 2 - Docker Networking

 

Docker Knowledge You'll Eventually Appreciate 2 - Docker Networking

이글은 야매로 작성된 글이며 필자가 깨달아갈 수록 추가되는 글입니다 필자가 도커를 공부하며 깨달은 지극히 주관적인 관점일 수 있으니 이상한 점은 친절한 댓글 부탁드립니다! 그리고 항상

jyami.tistory.com

 

댓글

Comments

Develop/JAVA

volatile을 사용한 쓰레드간 통신 동기화 | Thread Synchronization for Inter-Thread Communication Using volatile

이펙티브 자바를 읽으면서 짜릿한 단일검사(racy single-check)에 대해 찾아보던 중, 알게된 내용이다. 동기화의 기능자바의 쓰레드 프로그래밍을 해보았다면 synchronized 키워드를 몇번 접해볼 수 있을 것이다. 동기화에서 synchronized 를 이용해 한가지 자원을 동시에 접근할 때 thread safe하게 자원의 내용을 변경할 수 있어, 이 기능만 동기화의 기능이라고 보기 쉽다. 즉 synchronized 가 걸려있는 블록 혹은 메서드에서 한번에 한 쓰레드씩 수행하도록 한다.그러나 사실 동기화의 기능은 총 2가지이다. a. 배타적 실행위에 말한 대로 한 쓰레드가 변경하는 중이라서, 상태가 일관되지 않는 (공유하는 자원의) 객체를 현재 사용중인 쓰레드만 접근이 가능하고, 다른 쓰레드가..

volatile을 사용한 쓰레드간 통신 동기화 | Thread Synchronization for Inter-Thread Communication Using volatile

728x90

이펙티브 자바를 읽으면서 짜릿한 단일검사(racy single-check)에 대해 찾아보던 중, 알게된 내용이다.

 

동기화의 기능

자바의 쓰레드 프로그래밍을 해보았다면 synchronized 키워드를 몇번 접해볼 수 있을 것이다. 동기화에서 synchronized 를 이용해 한가지 자원을 동시에 접근할 때 thread safe하게 자원의 내용을 변경할 수 있어, 이 기능만 동기화의 기능이라고 보기 쉽다. 즉 synchronized 가 걸려있는 블록 혹은 메서드에서 한번에 한 쓰레드씩 수행하도록 한다.

그러나 사실 동기화의 기능은 총 2가지이다.

 

a. 배타적 실행

위에 말한 대로 한 쓰레드가 변경하는 중이라서, 상태가 일관되지 않는 (공유하는 자원의) 객체를 현재 사용중인 쓰레드만 접근이 가능하고, 다른 쓰레드가 보지 못하게 막는 용도를 말한다.

이때 락에 대한 개념이 나온다. 락을 건 메서드에서 객체의 상태를 확인하고 필요하면 수정하도록 작성했을 때, 한 쓰레드에서 해당 메서드를 사용하게 되면 객체에 락이 걸리게 되고, 해당 객체는 다른 쓰레드에서 동시에 접근이 불가능하다.

즉 배타적 실행은 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시키는 것이다.

 

b. 쓰레드 사이의 안정적 통신

나는 이 a번만 이전에 알고있었는데, 동기화의 중요한 기능이 하나 더 있다.

동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.
동기화덕분에 한 스레드에서 락의 보호하에 수행된 수정사항을 다른 쓰레드에서 최종 결과를 볼 수 있다.

자바 언어에서 long과 double을 제외한 변수를 읽고 쓰는 동작은 원자적이다. 여러 쓰레드가 primitive 타입의 변수를 동기화 없이 수정하더라도, 각 스레드에서는 정상적으로 그 값을 온전하게 (연산중간에 끼어들지 않고 온전히) 읽어온다

 

원자적 연산

위에서 읽고 쓰는 동작이 원자적이라 했는데, 원자적 연산은 중단이 불가능한 연산을 이야기한다
여러 자바의 연산은 바이트코드로 이루어져 있는데, 하나의 연산을 수행하기 위해  바이트코드가 수행될 때 중간에 다른 쓰레드가 끼어들어서 연산의 결과가 올바르지 않게 변한다면 해당 연산은 원자적 연산이 아니다.

원자적이지 않은 동작의 예시로는 a++(증가 연산자)이 있다. cleancode책의 동시성 부록에서는 아래와 같은 설명이 나온다

lastId값이 42였다고 가정하자. 다음은 getNextId 메서드의 바이트 코드다.
예를 들어 첫째 스레드가 ALOAD 0, DUP, GETFIELD lastId까지 실행한 후 중단 되었다고 가정하자.
둘째 스레드가 끼어들어 모든 명령을 실행했다. 즉, lastId를 하나 증가해 43을 얻어갔다.
이제 첫째 스레드가 중단했던 실행을 재개한다.
첫째 스레드가 GETFIELD lastId를 실행한 당시 lastId 값은 42였다. 그래서 피연산자 스택에도 42가 들어있었다. 여기에 1을 더해 43을 얻은 후 결과를 저장한다.
첫째 스레드가 반환하는 값 역시 43이다. 둘째 스레드가 증가한 값은 잃어 버린다.
둘째 쓰레드가 첫째 스레드를 중단한 후 다시 실행된 첫째 스레드가 둘째스레드의 작업을 덮어썼기 때문이다.

즉, 여기서의 문제는 연산을 수행할 때 JVM에서 사용하는 프레임, 지연변수, 피연산자 스택에 저장하는 과정에서 원자적 연산이 아닌경우, 연산 중간 과정이 덮어씌워져 올바르지 않은 값을 갖는다는 것이다.

좀더 쉽게는 두개의 쓰레드에서 ++ 연산을 했으니 +2가 되어야하는데, ++ 연산이 원자적이지 않아 +1만 되었다는 것이다.

 

원자적 데이터에서의 동기화

위의 원자성에 대한 이야기를 들으면 원자적 데이터를 읽고 쓸 때는 (할당 연산은 원자적이다) 동기화를 하지 않아도 괜찮다고 생각 할 수 있다. (중단이 불가능하기 때문에!)

하지만 원자적 데이터라도 동기화가 필요하다

Java언어에서 스레드가 (원자적 데이터 값을 가지더라도) 필드를 읽을 때 '수정이 완전히 반영된' 값을 얻는다고 보장하지 않는다.
즉 A 쓰레드에서 필드를 수정했더라도, B 쓰레드에서 수정된 필드를 반드시 볼 수 있는 것은 아니라는 것이다.

따라서 한 쓰레드에서 수정이된 필드값을 다른 쓰레드에서 '잘 읽기' 위해서라도 동기화의 안정적인 통신이 필요하다
이는 자바 메모리 모델 때문이다.

 

동기화의 관점에서의 자바의 메모리 모델

동기화를 하지 않으면 스레드가 변수를 읽어올 때 각 쓰레드가 변수를 cached한 영역에서 읽어오게 된다. 그래서 한 쓰레드로 인해 해당 변수가 값이 변화해도, 다른 쓰레드에서는 이전에 읽었던 cached된 변수의 값을 읽기 때문에 변경된 사항을 볼 수 없다.

따라서 각 쓰레드에서 변경한 값을 값을 통신하기 위해 동기화가 필요하며. 이때 통신을 위한 동기화를 사용하기 위해서는 volatile 한정자를 사용하는 방법이 있다. (Synchronized는 배타적수행, 안정적 통신을 모두수행하는 것이고, volatile은 안정적 통신만을 수행한다고 생각하면 편하다)

즉, 여러 스레드가 공유하는 변수값을 읽어오기 위해서 volatile 키워드를 붙이면 그 변수를 읽어올때 각 쓰레드의 cached한 영역이 아닌 메인 메모리에서 직접 읽어오기 때문에 안정적인 통신을 보장할 수 있다.

 

공식문서에 있는 자바 메모리 모델에 대한 설명

volatile 변수의 경우에는 inter-thread action에 해당하여, synchronized된 경우의 자바 메모리 모델 reordering 규칙이 적용된다. (Reordering은 다른 쓰레드의 변수값을 읽어오기 위한 작업으로, 한 쓰레드의 변경사항이 다른 쓰레드에 표시될 수 있게 하기 위한 작업이라 생각하자.)
이 규칙은 volatile 변수가 쓰기가 일어날 경우에는, 항상 임의의 읽기 쓰레드에 의해서 동기화가 되도록 reordering되는 것을 의미하며, reordering이 된다는 것은 다른 쓰레드에서 변수를 읽을 때 최신 변경사항을 읽을 수 있다는 것이다.

좀더 자세한 내용을 보고싶다면 더보기 클릭

더보기

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

위 글에서는 자바의 메모리 모델에 대해 설명하며, 동기화 되지 않은 프로그램이 어떻게 놀라운 결과를 나타내는지를 보여주고 있다.

자바의 메모리 모델은 한 실행에 대해서 각각의 읽기를 검사하고, 특정 규칙에 따라 읽기가 쓰기를 옵저빙하면서 쓰기가 유효한지를 확인하여 작동한다.

격리된 각 쓰레드의 동작은 표시되는 값이 메모리 모델에 의해 결정되는 경우를 제외하고, 각 쓰레드의 의미에 따라 제어되는 방식으로 작동한다. (intra-thread semantics). 

즉, 격리된 각 쓰레드의 동작이 메모리 모델에 의해 결정되는 경우는 멀티쓰레드에서 표시되는 값을 기반으로 이해해야하는 경우를 이야기 한다. 

intra-thread semantics : 싱글 쓰레드에서 스레드 안에서만 표시되는 값을 기반으로 스레드 동작을 예측 가능하다
inter-thread action : 한 스레드에서 수행되어 다른 스레드에의해 직접 감지되거나 영향을 받는 작업

 

자바의 메모리 모델의 reordering

왼쪽 : reordering되기 전 / 오른쪽 : reordering된 후

왼쪽의 경우에 r2 == 2, r1 == 1이 되는것이 불가능해 보인다. 하지만, 컴파일러는 스레드의 실행에 영향을 미치지 않는 경우 두 스레드에서 명령어를 다시 정렬할 수 있다 (오른쪽 사진처럼 정렬). d

불가능한 이유 : 1번이 먼저오면 4번에 의한 쓰기 결과를 볼 수없고, 3번이 먼저오면 2번에 의한 쓰기 결과를 볼 수 없기 때문이다.

하지만 오른쪽의 경우에는 동기화가 되지 않았다.

  • 한개의 thread에서는 쓰기를 하고있고
  • 같은 변수값을 다른 쓰레드에서 읽고있고
  • 쓰기와 읽기는 동기화에 의해 정렬되지 않았다 : 동기화에 의한 정렬에 대한 설명은 17.4.4에 있다.

 

동기화의 경우에 자바 메모모리 모델의 reordering

volatile 변수에 대한 쓰기는 임의의 쓰레드에 의해 해당 변수를 subsequent read(동기화 순서에 의해 정의된 read)를 하여 동기화된다. -> volatile 변수의 변경 사항은 항상 다른 쓰레드에 표시된다.

 

long과 double

위에서 "자바 언어에서 long과 double을 제외한 변수를 읽고 쓰는 동작은 원자적이다." 라고 언급했다. 그렇다면 long과 double은 왜 변수를 읽고 쓰는 동작이 원자적이지 않을까?

JVM 비트수와 관련이 있다. 자바 메모리 모델에 의하면 32비트 메모리에 값을 할당하는 연산은 중단이 불가능하다. (원자적이다)
그렇지만 long과 double의 경우에는 64비트의 메모리 공간을 갖고있기 때문이다.

https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

해당 글을 읽어보면, 3가지로 요약이 된다.

  • volatile이 설정되지 않은 long, doulbe에 대한 쓰기는 두번에 이루어진다
    => 먼저 첫번째 32비트를 쓰고 다음 쓰기에서 두번째 32비트를 쓴다.
  • volatile이 설정된 long, double이라면 항상 원자적이다
  • 프로그래머는 shared 되는 62bit 값은 volatile이나 synchronize 로 선언하는게 좋다. complication을 피하기 위해!

즉 첫 비트 32비트 값을 할당한 직후에, 즉 둘째 32비트를 할당하기 직전에 다른 쓰레드가 끼어들어 두 32비트 값중 하나를 변경할 수 있기 때문에 long, double은 원자적 연산이 될 수 없다.

하지만 volatile을 사용을 한다는 것은 여러 쓰레드에서 하나의 변수가 같은 값을 읽도록 보장하는 것이기 때문에, 메모리를 2번 접근을 하더라도 같은 값을 읽도록하는. 변수에 접근하는 연산을 원자적으로 수행하게 보장한다는 것이다.

in which case the Java memory model ensures that all threads see a consistent value for the variable

따라서 long, double 변수를 원자적으로 사용하고 싶다면 volatile로 선언하는게 좋다.

I came across this while reading Effective Java and looking into the racy single-check idiom.

 

Functions of Synchronization

If you've done any thread programming in Java, you've probably encountered the synchronized keyword a few times. In synchronization, synchronized lets you safely modify a shared resource when multiple threads access it simultaneously, so it's easy to think that's all synchronization does. In other words, it ensures that only one thread at a time can execute a synchronized block or method.

But in fact, synchronization has two functions in total.

 

a. Mutual Exclusion

As mentioned above, this refers to preventing other threads from seeing a shared object while one thread is modifying it and its state is inconsistent — only the thread currently using the object can access it.

This is where the concept of a lock comes in. When you write a method that acquires a lock to check and potentially modify an object's state, once a thread enters that method, the object becomes locked, and other threads cannot access it simultaneously.

In short, mutual exclusion is about transitioning an object from one consistent state to another consistent state.

 

b. Reliable Communication Between Threads

I previously only knew about point (a), but there's another important function of synchronization.

Without synchronization, changes made by one thread may not be visible to other threads.
Thanks to synchronization, modifications performed under the protection of a lock in one thread can be seen as the final result by other threads.

In the Java language, reading and writing variables is atomic for all types except long and double. Even if multiple threads modify a primitive variable without synchronization, each thread will read the value correctly and completely (without being interrupted mid-operation).

 

Atomic Operations

I mentioned above that read and write operations are atomic. An atomic operation is one that cannot be interrupted.
Many Java operations are composed of bytecode instructions, and if another thread can intervene during the bytecode execution of an operation and cause incorrect results, then that operation is not atomic.

A classic example of a non-atomic operation is a++ (the increment operator). The concurrency appendix of the Clean Code book explains it like this:

Assume lastId had a value of 42. Here is the bytecode for the getNextId method.
For example, suppose the first thread executes up to ALOAD 0, DUP, GETFIELD lastId and then gets interrupted.
The second thread cuts in and executes all the instructions — it increments lastId and gets 43.
Now the first thread resumes execution from where it was interrupted.
When the first thread executed GETFIELD lastId, the value of lastId was 42. So 42 was on the operand stack. It adds 1 to get 43 and stores the result.
The first thread also returns 43. The value incremented by the second thread is lost.
This happened because the second thread interrupted the first thread, and when the first thread resumed, it overwrote the second thread's work.

The problem here is that when performing operations, if the operation is not atomic during the process of storing values in the JVM's frame, local variables, and operand stack, intermediate results can get overwritten, leading to incorrect values.

To put it more simply: two threads each performed a ++ operation, so the result should have been +2, but since the ++ operation is not atomic, only +1 was applied.

 

Synchronization with Atomic Data

After hearing about atomicity above, you might think that you don't need synchronization when reading and writing atomic data (since assignment operations are atomic). (Because they can't be interrupted!)

However, even with atomic data, synchronization is necessary.

The Java language does not guarantee that when a thread reads a field (even if the data is atomic), it will get a 'fully updated' value.
In other words, even if thread A modifies a field, thread B is not guaranteed to see the modified value.

Therefore, reliable communication through synchronization is needed even just to ensure that a field value modified by one thread is 'properly read' by another thread.
This is due to the Java Memory Model.

 

Java Memory Model from a Synchronization Perspective

Without synchronization, when a thread reads a variable, it reads from its own cached area. So even if one thread changes the variable's value, other threads still read the previously cached value and cannot see the change.

Therefore, synchronization is needed for threads to communicate changed values. One way to achieve communication-only synchronization is by using the volatile modifier. (Think of it this way: synchronized provides both mutual exclusion and reliable communication, while volatile only provides reliable communication.)

In other words, when you add the volatile keyword to a shared variable, reading that variable goes directly to main memory instead of each thread's cached area, which guarantees reliable communication.

 

Java Memory Model Explained in the Official Documentation

Volatile variables are considered inter-thread actions, so the Java Memory Model's reordering rules for synchronized cases apply. (Think of reordering as the mechanism for reading variable values from other threads — it's what makes changes in one thread visible to other threads.)
This rule means that writes to volatile variables are always reordered so that they are synchronized by any reading thread through subsequent reads (as defined by synchronization order). Being reordered means that other threads can read the latest changes when they access the variable.

Click "Show More" for more details

더보기

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

The article above explains Java's memory model and demonstrates how unsynchronized programs can produce surprising results.

Java's memory model works by examining each read in an execution and checking whether the write being observed by the read is valid according to specific rules.

The behavior of each isolated thread operates in a manner controlled by that thread's semantics, except when the values it sees are determined by the memory model (intra-thread semantics). 

In other words, when the behavior of an isolated thread is determined by the memory model, it needs to be understood based on values visible in a multithreaded context. 

intra-thread semantics: In a single thread, the thread's behavior is predictable based on values visible only within that thread.
inter-thread action: An action performed by one thread that can be directly detected or affected by another thread.

 

Reordering in the Java Memory Model with Synchronization

Left: Before reordering / Right: After reordering

In the left case, it seems impossible for r2 == 2 and r1 == 1. However, the compiler can reorder instructions in both threads if it doesn't affect each thread's execution (as shown in the right image).

Why it seems impossible: If instruction 1 comes first, it can't see the write result from instruction 4. If instruction 3 comes first, it can't see the write result from instruction 2.

But in the right case, there is no synchronization.

  • One thread is writing
  • Another thread is reading the same variable
  • The writes and reads are not ordered by synchronization: the explanation of synchronization ordering is in section 17.4.4.

 

Reordering in the Java Memory Model with Synchronization

A write to a volatile variable is synchronized with any subsequent read (a read defined by synchronization order) of that variable by any thread. → Changes to a volatile variable are always visible to other threads.

 

long and double

I mentioned above that "in the Java language, reading and writing variables is atomic for all types except long and double." So why aren't read and write operations atomic for long and double?

It's related to the JVM's bit width. According to the Java memory model, assigning a value to 32-bit memory is an uninterruptible operation (i.e., atomic).
However, long and double occupy 64-bit memory space.

https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7

 

Chapter 17. Threads and Locks

class A { final int x; A() { x = 1; } int f() { return d(this,this); } int d(A a1, A a2) { int i = a1.x; g(a1); int j = a2.x; return j - i; } static void g(A a) { // uses reflection to change a.x to 2 } } In the d method, the compiler is allowed to reorder

docs.oracle.com

Reading through that article, it can be summarized in three points:

  • Writes to non-volatile long and double are done in two steps.
    => The first 32 bits are written first, then the second 32 bits are written next.
  • If long or double is declared volatile, the operation is always atomic.
  • Programmers should declare shared 64-bit values as volatile or synchronized to avoid complications.

In other words, right after assigning the first 32 bits — just before assigning the second 32 bits — another thread can cut in and modify one of the two 32-bit values. That's why long and double cannot be atomic operations.

However, using volatile guarantees that multiple threads read the same value from a single variable. So even though memory is accessed twice, it ensures the same value is read — meaning the variable access operation is guaranteed to be performed atomically.

in which case the Java memory model ensures that all threads see a consistent value for the variable

Therefore, if you want to use long or double variables atomically, it's best to declare them as volatile.

댓글

Comments

Develop/JAVA

자바의 제네릭 타입 소거, 리스트에 관하여 (Java Generics Type Erasure, List) | Java's Generic Type Erasure, Regarding Lists (Java Generics Type Erasure, List)

1. 자바의 제네릭과 로타입 (Java Generics and Raw Type)public class Example{ private T member;}위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example 를 제네릭타입이라고 이야기한다.제네릭을 사용하면 로타입이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다 즉, 위 제네릭 타입Example를 Example 로만 선언하여 사용했을 경우를 말한다.public class Example { private T member; public Example(T member) { this.membe..

자바의 제네릭 타입 소거, 리스트에 관하여 (Java Generics Type Erasure, List) | Java's Generic Type Erasure, Regarding Lists (Java Generics Type Erasure, List)

728x90

1. 자바의 제네릭과 로타입 (Java Generics and Raw Type)

public class<T> Example{
	private T member;
}

위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example<T> 를 제네릭타입이라고 이야기한다.

제네릭을 사용하면 로타입이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다 즉, 위 제네릭 타입Example<T>Example 로만 선언하여 사용했을 경우를 말한다.

public class Example<T> {
    private T member;

    public Example(T member) {
        this.member = member;
    }

    public static void main(String[] args) {
        Example<Integer> parameterType = new Example<>(1);
        Integer parameterTypeMember = parameterType.member;
        System.out.println(parameterTypeMember);

        Example rawType = new Example(1);
        Object rawTypeMember = rawType.member;
        System.out.println(rawTypeMember);
    }
}

위 코드는 제네릭 파라미터 타입과 로타입을 사용한 경우이다. 하지만 로타입은 사용하지말자.

제네릭의 장점은 컴파일 타임에 타입에 대한 안정성을 보장받을 수 있다는 점이다. 제네릭 타입으로 선언한 변수는 컴파일 타임에 타입 체크를 하기 때문에 런타임에서 ClassCastException과 같은 UncheckedException을 보장 받을 수 있다.

반면 아래와 같이 로타입으로 사용될 경우에는 제네릭을 사용했을 때의 안정성과 표현력이라는 장점을 발휘할 수 없기 때문에, IDE 에서도 "Raw use of parameterized class 'Example' " 라는 경고를 주는 것을 볼 수 있다.

그럼 로타입이 나오게 된 이유는 무엇일까? 로타입이 나오게 된 이유는 제네릭의 특징인 소거와 관련이 있다.

제네릭은 JDK5 에서 도입이되었다. 버그를 줄이기 위한 목적과, 다른 추상화된 타입에 대한 레이어를 추가하기 위해서이다.
이에 따라 제네릭을 도입한 JDK5는 기존의 코드를 모두 수용하면서 제네릭을 사용하는 새로운 코드와의 호환성을 유지 했어야 했다. 따라서 코드의 호환성 때문에 : 로타입의 지원 + 제네릭을 구현할 때 소거(erasure)하는 방식을 이용하였다.

 

2. 제네릭의 타입소거 (Generics Type Erasure)

소거란 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것이다. 다른 말로는 컴파일 타임에만 타입에 대한 제약 조건을 적용하고, 런타임에는 타입에 대한 정보를 소거하는 프로세스이다.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다");

다음과 같은 상황에서 컴파일시에 타입 오류를 바로 알 수 있다 (리스트도 제네릭 타입으로 구현되어있기 때문에)

Java 컴파일러는 타입소거를 아래와 같이 적용한다.

  • 제네릭 타입( Example<T>) 에서는 해당하는 타입 파라미터 (T) 나 Object로 변경해준다. 
    Object로 변경하는 경우는 unbounded 된 경우를 뜻하며, 이는 <E extends Comparable<E>>와 같이 bound를 해주지 않은 경우를 의미한다.
    따라서 이 소거 규칙에 대한 바이트코드는 제네릭을 적용할 수 있는 일반 클래스, 인터페이스, 메서드에만 해당된다.
  • 타입 안정성 보존을 위해 필요하다면 type casting을 넣어준다.
  • 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridege method를 생성한다.
public static <E> boolean containsElement(E[] elements, E element) {
	for (E e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

실제로 이렇게 선언되어있는 제네릭 메서드의 경우 선언 방식에 따라 컴파일러가 타입파라미터 E를 변경한다.

public static boolean containsElement(Object[] elements, Object element) {
	for (Object e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

컴파일러는 첫번째 규칙에 따라 타입 파라미터 E가 bound하게 선언되어있지 않기 때문에 타입 파라미터 EInteger로 우선적으로 바꾼다.

이때 만약 프로그래머가 continasElement(Integer[], Integer) 형식으로 해당 메서드를  사용했다면, 컴파일러 내부에서 두번재 규칙에 따라 타입 안정성 보존을 위해 Object -> Integer로의 타입 캐스트 코드를 넣어주어 제네릭의 타입 안정성을 보장해주는 것이다. 

반면 로타입일 경우에는, 타입 파라미터가 정해져있지 않아. Object로 변환한 것에서 끝난다.

더보기

반면 타입 파라미터 E를 bound하게 설정한다면 

public static <E extends Comparable<E>> void containsElement(E[] elements) {
	for (E e : elements) {
		System.out.println("%s", e);
	}
}

타입이 소거될때 Object로 바뀌는 것이 아닌 한정시킨 타입인 Comparable로 변환된다.

public static void containsElement(Comparable[] elements) {
	for (Comparable e : elements) {
		System.out.println("%s", e);
	}
}

추가로 세번째 규칙에 대해서 언급하자면 java compiler는 제네릭의 타입안정성을 위해 Bridge Method도 만들어낼 수있다. Bridge Method는 java 컴파일러가 컴파일 할 때 메서드 시그니처가 조금 다르거나 애매할 경우에대비하여 작성된 메서드이다. 이 경우는 파리미터화된 클래스나 인터페이스를 확장한 클래스를 컴파일 할 때 생길 수 있다.

public class IntegerStack extends Stack<Integer> {
    public Integer push(Integer value) {
        super.push(value);
        return value;
    }
}

 

Java 컴파일러는 다형성을 제네릭 타입 소거에서도 지키기 위해, IntegerStackpush(Integer) 메서드와 Stack의 push(Object) 메서드 시그니처 사이에 불일치가 없어야 했다. 따라서 컴파일러는 런타임에 해당 제네릭 타입의 타입소거를 위한 Bridge 메서드를 만드는데 아래와같은 방식으로 만든다.

public class IntegerStack extends Stack {
    // Bridge method generated by the compiler
     
    public Integer push(Object value) {
        return push((Integer)value);
    }
 
    public Integer push(Integer value) {
        return super.push(value);
    }
}

extends Stack<Integer> -> Stack 으로  변경한 것을 볼 수 있으며, push에 parameter를 Object가 아닌 Integer로 맞추기 위한 도우미 메서드가 늘어났다는 것을 알 수 있다. 결과적으로 Stack 클래스의 push method는 타입소거를 진행한 후에, IntegerStack 클래스의 원본 push 방법을 사용하게 한다.

 

3. 제네릭에서는 리스트를 사용하자

실체화 불가 타입(Non-Reifiable Type)에 대한 설명이 있다 runtime에 타입 정보를 갖고있지 않고, compile-time에 타입 소거가 되는 타입을 의미한다고 한다. 이에 반대하는 개념으로는 실체화(reifiable)가 있다. 이는 타입 정보를 런타임에 완벽하게 사용할 수 있는 유형으로, 소거와는 반대 개념이다. 

실체화 불가 타입의 대표적인 예시로는 List<String> List<Number> 와 같은 리스트가 있고, 실체화 타입의 대표적인 예시로는 String[], Number[] 와 같은 배열이 있다.

이펙티브 자바에서는 타입소거라는 특성이 있는 제네릭은 실체화 불가 타입인 List와 함께 사용하기를 권장한다. Array 에서는 런타임에 타입정보를 갖고있는데, 제네릭을 사용하면 타입이 소거되기 때문에 해당 제네릭 변수에 대한 정보를 런타임에 갖고있지 않기 때문이다.

사실 이 부분은 이펙티브 자바를 읽으면서 스터디에서 했던 제네릭과 관련한 이야기를 예시로 이야기 하려한다.

관련 이슈 : https://github.com/Java-Bom/ReadingRecord/issues/88

 

[아이템 32] toArray · Issue #88 · Java-Bom/ReadingRecord

193p 마지막 코드에서 toArray를 바로 String[]배열로 받는건 되는데 왜 pickTwo를 거쳐서 String[]로 받는건 안될까?

github.com

static <T> T[] pickTwo(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return toArray(a, b);
    case 1:
      return toArray(b, c);
    case 2:
      return toArray(a, c);
  }
  throw new AssertionError();
}

static <T> T[] toArray(T... args) {
  return args;
}

public static void main(String[] args) {
  String[] strings = pickTwo("좋은", "빠른", "저렴한");  
}

위 코드에서 ClassCastException이 터진다. 반면 아래와 같이 pickTwo 메서드를 사용했을 때는 ClassCastException이 터지지 않는다. 왜그럴까? 

static <T> List<T> pickTwoList(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return Arrays.asList(a, b);
    case 1:
      return Arrays.asList(b, c);
    case 2:
      return Arrays.asList(a, c);
  }
  throw new AssertionError();
}

List<String> strings = pickTwoList("좋은", "빠른", "저렴한");

문제를 좀 더 단순히 해보자.

static <T> List<T> pickList(T a) {
        return Arrays.asList(a);
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    static <T> T[] pickArray(T a) {
        return toArray(a);
    }

    public static void main(String[] args) {
        List<String> stringList = pickList("자바봄");
        String[] stringArray = pickArray("자바봄");
   }
}

위 코드에서 pickList를 지나 pickArray를 실행하면 runtimeException이 터지는 것을 볼 수 있다. 내용은 [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; 이다 무엇이 문제일까?

정답은 Array와 List의 실체화에 있었다. 

코드를 이해해보자. 위에서는 제네릭 타입추론이 2 depth가 들어간다. pickArray에서 타입 매개변수 TString과 대응하여 들어가기 때문에 pickArray(String) toArray(String...) 이 들어갈 것으로 예상한다. 하지만 실제 런타임에 확인해보면 pickArray(String),  toArray(Object...)가 들어간다. 그 이유는 위에서 제네릭 타입추론을 이야기할 때 우선적으로 bounded가 아닌 매개변수일경우 컴파일러가 Object로 대체한다는 이야기와 대응된다. pickArray에서는 main에 있는 String 타입으로 타입추론이 가능했으나 toArray는 제네릭 타입을 바라보고 있으므로 타입추론을 Object로 하여 런타임에 타입정보를 갖고있게 된다.

따라서 pickArrayString[] 으로 타입캐스팅을 준비하였으나, 위에서 말한 것처럼 런타임에 toArray가 갖고있는 타입은 Object[] 이므로 런타임에 (String[]) Object[] 와 같은 형식으로 강제적으로 타입캐스팅을 하다가 ClassCastException이 발생한다. (String 배열은, Object 배열의 하위 타입이 아니기 때문에 Casting이 되지 않는다.)

반면에 pickList를 호출할때는 컴파일이 성공한다. 이 이유는 List가 실체화 불가타입이었기 때문이다. 컴파일 타임에 캐스팅할 정보가 이미 결정이되고, 런타임때에는 제네릭의 소거라는 특성 때문에 Java 컴파일러가 타입에 맞는 캐스팅 방식을 올바르게 추가해줘서 캐스팅 에러가 나지 않는다.

정리하자면, 리스트 + 제네릭은 컴파일 타임에 결정된 캐스팅 정보가 올바르기 때문에 통과가 된다. 제네릭의 장점인 컴파일 타임에 타입이 안맞는 것을 체크해주는 걸 List에서도 수행하기 때문이다. 반면 배열 같은 경우엔 타입 캐스팅을 런타임에 결정하기 때문에 문제가 생긴 것이다. 

 

조금 길었지만 사실 결론은 간단하다. 제네릭은 타입소거라는 특징으로 컴파일러가 컴파일 타임에 타입을 추론할 수 있으며, 이런 타입 추론 기능을 강력하게 사용하기 위해서는 런타임에 타입을 추론하는 Array 대신에 컴파일타임에 타임을 추론하는 List를 함께 사용해야 안정성을 보장 할 수 있다는 것이다.

 

참고 글

https://www.baeldung.com/java-generics

Effecitve Java 3/E - Chapter 5: Generics 

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

https://www.baeldung.com/java-type-erasure

 

1. Java Generics and Raw Type

public class<T> Example{
	private T member;
}

When a type parameter T is used in a class or interface declaration like above, we call it a generic class or generic interface. The class Example<T> used here is referred to as a generic type.

When using generics, the concept of raw types comes up. A raw type is when you don't use a type parameter at all with a generic type — in other words, when you declare and use the generic type Example<T> as just Example.

public class Example<T> {
    private T member;

    public Example(T member) {
        this.member = member;
    }

    public static void main(String[] args) {
        Example<Integer> parameterType = new Example<>(1);
        Integer parameterTypeMember = parameterType.member;
        System.out.println(parameterTypeMember);

        Example rawType = new Example(1);
        Object rawTypeMember = rawType.member;
        System.out.println(rawTypeMember);
    }
}

The code above shows both a generic parameterized type and a raw type in use. But don't use raw types.

The advantage of generics is that they guarantee type safety at compile time. Since variables declared with a generic type are type-checked at compile time, you're protected from UncheckedException errors like ClassCastException at runtime.

On the other hand, when used as a raw type like below, you lose the benefits of safety and expressiveness that generics provide. That's why you can see the IDE giving you a warning like "Raw use of parameterized class 'Example' ".

So why do raw types exist in the first place? The reason raw types came about is related to erasure, a key characteristic of generics.

Generics were introduced in JDK5. The goals were to reduce bugs and to add a layer of abstraction over types.
Because of this, JDK5 — which introduced generics — had to accommodate all existing code while maintaining compatibility with new code that uses generics. So for the sake of code compatibility, they supported raw types and implemented generics using erasure.

 

2. Generics Type Erasure

Erasure means that element types are only checked at compile time and the type information is not available at runtime. In other words, it's a process where type constraints are enforced only at compile time, and type information is erased at runtime.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다");

In a situation like this, you can immediately catch the type error at compile time (because List is also implemented as a generic type).

The Java compiler applies type erasure as follows:

  • In a generic type (Example<T>), it replaces the type parameter (T) with the corresponding type or Object
    Replacing with Object happens when the type is unbounded — meaning it hasn't been bounded like <E extends Comparable<E>>.
    Therefore, the bytecode for this erasure rule only applies to regular classes, interfaces, and methods that can use generics.
  • It inserts type casting where necessary to preserve type safety.
  • It generates bridge methods to preserve polymorphism in extended generic types.
public static <E> boolean containsElement(E[] elements, E element) {
	for (E e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

For a generic method declared like this, the compiler replaces the type parameter E depending on how it's declared.

public static boolean containsElement(Object[] elements, Object element) {
	for (Object e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

Following the first rule, since the type parameter E is not declared as bounded, the compiler first replaces the type parameter E with Integer.

At this point, if the programmer used the method in the form of containsElement(Integer[], Integer), the compiler internally inserts type casting code from Object to Integer according to the second rule to preserve type safety, thus guaranteeing the type safety of generics.

In the case of raw types, however, since no type parameter is specified, it simply ends with the conversion to Object.

더보기

On the other hand, if you set the type parameter E as bounded:

public static <E extends Comparable<E>> void containsElement(E[] elements) {
	for (E e : elements) {
		System.out.println("%s", e);
	}
}

When the type is erased, instead of being replaced with Object, it gets converted to the bounded type Comparable.

public static void containsElement(Comparable[] elements) {
	for (Comparable e : elements) {
		System.out.println("%s", e);
	}
}

Additionally, regarding the third rule, the Java compiler can also generate Bridge Methods for generic type safety. A Bridge Method is a method created by the Java compiler during compilation to handle cases where method signatures are slightly different or ambiguous. This can happen when compiling a class that extends a parameterized class or interface.

public class IntegerStack extends Stack<Integer> {
    public Integer push(Integer value) {
        super.push(value);
        return value;
    }
}

 

The Java compiler needed to ensure there was no mismatch between the push(Integer) method signature of IntegerStack and the push(Object) method signature of Stack, in order to preserve polymorphism even during generic type erasure. So the compiler creates a Bridge method for the type erasure of that generic type at runtime, and it does it like this:

public class IntegerStack extends Stack {
    // Bridge method generated by the compiler
     
    public Integer push(Object value) {
        return push((Integer)value);
    }
 
    public Integer push(Integer value) {
        return super.push(value);
    }
}

You can see that extends Stack<Integer> has been changed to just Stack, and a helper method has been added to match the push parameter to Integer instead of Object. As a result, the Stack class's push method, after type erasure, delegates to the original push method of the IntegerStack class.

 

3. Use Lists with Generics

Non-Reifiable Type refers to a type that doesn't hold type information at runtime and undergoes type erasure at compile time. The opposite concept is reifiable, which refers to types whose type information is fully available at runtime — the opposite of erasure. 

Typical examples of non-reifiable types include lists like List<String> and List<Number>, while typical examples of reifiable types include arrays like String[] and Number[].

Effective Java recommends using generics — which have the characteristic of type erasure — with List, a non-reifiable type. This is because arrays hold type information at runtime, but when you use generics, the type gets erased, so the generic variable's information isn't available at runtime.

This part is actually something I want to illustrate with an example from a study group discussion about generics that we had while reading Effective Java.

Related issue : https://github.com/Java-Bom/ReadingRecord/issues/88

 

[Item 32] toArray · Issue #88 · Java-Bom/ReadingRecord

On p.193, the last code example — receiving toArray directly as a String[] array works, but why doesn't it work when receiving as String[] through pickTwo?

github.com

static <T> T[] pickTwo(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return toArray(a, b);
    case 1:
      return toArray(b, c);
    case 2:
      return toArray(a, c);
  }
  throw new AssertionError();
}

static <T> T[] toArray(T... args) {
  return args;
}

public static void main(String[] args) {
  String[] strings = pickTwo("좋은", "빠른", "저렴한");  
}

In the code above, a ClassCastException is thrown. However, when using the pickTwo method like below, no ClassCastException occurs. Why is that?

static <T> List<T> pickTwoList(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return Arrays.asList(a, b);
    case 1:
      return Arrays.asList(b, c);
    case 2:
      return Arrays.asList(a, c);
  }
  throw new AssertionError();
}

List<String> strings = pickTwoList("좋은", "빠른", "저렴한");

Let's simplify the problem a bit.

static <T> List<T> pickList(T a) {
        return Arrays.asList(a);
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    static <T> T[] pickArray(T a) {
        return toArray(a);
    }

    public static void main(String[] args) {
        List<String> stringList = pickList("자바봄");
        String[] stringArray = pickArray("자바봄");
   }
}

In the code above, after pickList passes, executing pickArray throws a runtimeException. The message is [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; — what's the problem?

The answer lies in the reifiability of Array vs. List.

Let's understand the code. Here, generic type inference goes 2 levels deep. Since the type parameter T in pickArray corresponds to String, you'd expect pickArray(String) toArray(String...) to be called. But when you actually check at runtime, it's pickArray(String), toArray(Object...) that gets called. The reason is exactly what we discussed above about generic type inference — when the parameter is unbounded, the compiler replaces it with Object first. While pickArray could infer the String type from main, toArray looks at the generic type, so it infers Object and holds that type information at runtime.

Therefore, pickArray prepares to cast to String[], but as mentioned above, the type that toArray holds at runtime is Object[], so at runtime it tries to forcefully cast like (String[]) Object[], which causes a ClassCastException. (A String array is not a subtype of an Object array, so the cast fails.)

On the other hand, calling pickList compiles successfully. The reason is that List is a non-reifiable type. The casting information is already determined at compile time, and at runtime, thanks to the erasure characteristic of generics, the Java compiler correctly adds the appropriate casting, so no casting error occurs.

To summarize, List + generics works because the casting information determined at compile time is correct. List performs the same compile-time type mismatch checking that is the advantage of generics. Arrays, on the other hand, determine type casting at runtime, which is where the problem arises.

 

That was a bit long, but the conclusion is actually simple. Generics use type erasure, which allows the compiler to infer types at compile time. To fully leverage this type inference capability, you should use List — which infers types at compile time — instead of Array — which infers types at runtime — to guarantee type safety.

 

References

https://www.baeldung.com/java-generics

Effecitve Java 3/E - Chapter 5: Generics 

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

https://www.baeldung.com/java-type-erasure

 

댓글

Comments

Develop/git-github

git status 한글 깨짐 | git status Korean character broken

git status를 할 때, 한글이름을 가지는 파일일 경우에 /200/300/385 이런식으로 파일명이 깨지는 경우가 있다. (mac 터미널)git config --global core.quotepath false위 설정으로 바꾸면 올바르게 한글이름 파일을 git status로 상태확인이 가능해진다.core.quotePathCommands that output paths (e.g. ls-files, diff), will quote "unusual" characters in the pathname by enclosing the pathname in double-quotes and escaping those characters with backslashes in the same way C escapes c..

git status 한글 깨짐 | git status Korean character broken

728x90

git status를 할 때, 한글이름을 가지는 파일일 경우에 /200/300/385 이런식으로 파일명이 깨지는 경우가 있다. (mac 터미널)

git config --global core.quotepath false

위 설정으로 바꾸면 올바르게 한글이름 파일을 git status로 상태확인이 가능해진다.

core.quotePath

Commands that output paths (e.g. ls-files, diff), will quote "unusual" characters in the pathname by enclosing the pathname in double-quotes and escaping those characters with backslashes in the same way C escapes control characters (e.g. \t for TAB, \n for LF, \\ for backslash) or bytes with values larger than 0x80 (e.g. octal \302\265 for "micro" in UTF-8). If this variable is set to false, bytes higher than 0x80 are not considered "unusual" any more. Double-quotes, backslash and control characters are always escaped regardless of the setting of this variable. A simple space character is not considered "unusual". Many commands can output pathnames completely verbatim using the -z option. The default value is true.

output path에 대한 커맨드는 unusual인 패스 이름을 조정한다. ( " 가 들어가 있거나, escaping 이 들어가 있는 경우 )
이때 한글 인코딩이 UTF-8에 들어가 0x80 보다 큰 바이트를 가진 escape 문자 처리가 되어 "unusual"인 케이스로 포함이 된다.

그래서 이 변수를 false로 설정하면

0x80보다 높은 바이트는 더 이상 "unusual" 인 것으로 간주되지 않는다.
"unusual"로 간주되는 큰 따옴표, 백 슬래시 및 제어 문자는 이 변수의 설정에 관계없이 항상 이스케이프 되며,
단순한 공백 문자는 "unusual"로 간주되지 않는다.

참고 : https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath

 

Git - git-config Documentation

When using the deprecated [section.subsection] syntax, changing a value will result in adding a multi-line key instead of a change, if the subsection is given with at least one uppercase character. For example when the config looks like [section.subsection

git-scm.com

 

When running git status, if a file has a Korean name, the filename may appear garbled like /200/300/385. (Mac terminal)

git config --global core.quotepath false

If you change to the above setting, you'll be able to correctly check the status of files with Korean names using git status.

core.quotePath

Commands that output paths (e.g. ls-files, diff), will quote "unusual" characters in the pathname by enclosing the pathname in double-quotes and escaping those characters with backslashes in the same way C escapes control characters (e.g. \t for TAB, \n for LF, \\ for backslash) or bytes with values larger than 0x80 (e.g. octal \302\265 for "micro" in UTF-8). If this variable is set to false, bytes higher than 0x80 are not considered "unusual" any more. Double-quotes, backslash and control characters are always escaped regardless of the setting of this variable. A simple space character is not considered "unusual". Many commands can output pathnames completely verbatim using the -z option. The default value is true.

Commands that deal with output paths adjust pathnames that are considered unusual. (e.g., when they contain double quotes or escape characters)
In this case, Korean characters encoded in UTF-8 have bytes larger than 0x80, so they get treated as escape characters and fall under the "unusual" case.

So if you set this variable to false:

Bytes higher than 0x80 are no longer considered "unusual".
Double-quotes, backslashes, and control characters that are considered "unusual" are always escaped regardless of this variable's setting,
and simple space characters are not considered "unusual".

Reference: https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath

 

Git - git-config Documentation

When using the deprecated [section.subsection] syntax, changing a value will result in adding a multi-line key instead of a change, if the subsection is given with at least one uppercase character. For example when the config looks like [section.subsection

git-scm.com

 

댓글

Comments

Develop/Springboot

인터페이스 빈주입을 사용하는 이유 | Why We Use Interface-Based Dependency Injection

Springboot를 사용할 때 대부분, 인터페이스를 이용한 @Service 빈주입을 한다. 하지만 왜 하는지는 모르는채 사용만 했었는데, 백기선님의 유튜브 영상을 보고 깨닫게 되었다.영상 : https://www.youtube.com/watch?v=C6nsjqrCJq4&feature=youtu.be서비스 코드public interface MyService { void doSomething();}@Servicepublic class MyServiceImpl implements MyService { @Override public void doSomething() { System.out.println("hello Im Impl Service"); }}이렇게 서비스가 있는경우..

인터페이스 빈주입을 사용하는 이유 | Why We Use Interface-Based Dependency Injection

728x90

Springboot를 사용할 때 대부분, 인터페이스를 이용한 @Service 빈주입을 한다. 하지만 왜 하는지는 모르는채 사용만 했었는데, 백기선님의 유튜브 영상을 보고 깨닫게 되었다.

영상 : https://www.youtube.com/watch?v=C6nsjqrCJq4&feature=youtu.be

서비스 코드

public interface MyService {
    void doSomething();
}
@Service
public class MyServiceImpl implements MyService {
    @Override
    public void doSomething() {
        System.out.println("hello Im Impl Service");
    }
}

이렇게 서비스가 있는경우를 생각해보자. 
Controller 에서는 Service를 두가지 방법으로 주입 받을 수 있다.

1. MyService 를 타입으로 하는 (인터페이스 타입) 빈주입
2. MyServiceImpl 을 타입으로 하는 (클래스 타입) 빈주입

application properties를 이용하여 spring.aop의 proxy-target-class를 false로 설정할 경우엔 클래스를 이용한 @Service 빈 주입을 할 수 없음을 상기하자

spring.aop.proxy-target-class=false
# spring의 기본 설정은 true이다.

 

결론부터 말하면 인터페이스 서비스로 빈 주입을 해야하는 이유는 spring proxy라고 할 수 있다.

spring proxy는 상속을 이용하여 프록시를 생성하는데, class service는 상속을 받아서 프록시를 만드는 과정에서 빈을 만들다가 에러가 난다.

  • 사용자가 service class를 final로 설정해버리거나,
  • 생성자를 private으로 생성하여 자식인 프록시가 부모 생성자를 찾지 못하는 경우

 

1. MyService 를 타입으로 하는 (인터페이스 타입) 빈주입

@Autowired
private MyService myService;

구현 상속 관계 : MyService ---> MyserviceImpl
프록시 상속 관계 : Myservice ---> ProxyMyService 

따라서 ProxyMyService 가 인터페이스인 MyService를 상속받는다.

 

2. MyServiceImpl 을 타입으로 하는 (클래스 타입) 빈주입

@Autowired
private MyServiceImpl myService;

구현 상속 관계 : MyService ---> MyserviceImpl
프록시 상속 관계 : MyServiceImpl ---> ProxyMyService 

이경우에 스프링이 생성하는 프록시가 MyServiceImpl 을 부모로하여 상속을 받는 구조인 것이다.

이런 구조를 가진 상황에서 final 혹은 private 생성자를 이용해서 프록시를 만들지 못하게 되고,
빈 생성시 에러가 난다 (sub classing 에러)

 

When using Spring Boot, most people inject @Service beans through interfaces. But I had been using this pattern without really understanding why — until I watched a YouTube video by Baek Ki-sun and it finally clicked.

Video: https://www.youtube.com/watch?v=C6nsjqrCJq4&feature=youtu.be

Service Code

public interface MyService {
    void doSomething();
}
@Service
public class MyServiceImpl implements MyService {
    @Override
    public void doSomething() {
        System.out.println("hello Im Impl Service");
    }
}

Let's consider a case where we have a service like this. 
In the Controller, you can inject the Service in two ways.

1. Bean injection using MyService as the type (interface type)
2. Bean injection using MyServiceImpl as the type (class type)

Keep in mind that if you set spring.aop's proxy-target-class to false in application properties, you won't be able to do @Service bean injection using the class type.

spring.aop.proxy-target-class=false
# spring의 기본 설정은 true이다.

 

To cut to the chase, the reason you should inject beans using an interface service is because of spring proxy.

Spring proxy creates proxies using inheritance. With a class service, errors can occur during bean creation while trying to create a proxy by inheriting from the class.

  • If the user marks the service class as final, or
  • If the constructor is made private, so the child proxy can't find the parent constructor

 

1. Bean injection using MyService as the type (interface type)

@Autowired
private MyService myService;

Implementation inheritance: MyService ---> MyServiceImpl
Proxy inheritance: MyService ---> ProxyMyService 

So ProxyMyService inherits from MyService, the interface.

 

2. Bean injection using MyServiceImpl as the type (class type)

@Autowired
private MyServiceImpl myService;

Implementation inheritance: MyService ---> MyServiceImpl
Proxy inheritance: MyServiceImpl ---> ProxyMyService 

In this case, the proxy that Spring creates has a structure where it inherits from MyServiceImpl as its parent.

In this structure, if you use final or a private constructor, the proxy can't be created,
and you'll get an error during bean creation (a sub-classing error).

 

댓글

Comments

Develop/Springboot

@Valid 를 이용해 @RequestBody 객체 검증하기 | Validating @RequestBody Objects Using @Valid

Springboot를 이용해서 어노테이션을 이용한 validation을 하는 방법을 적으려 한다. RestController를 이용하여 @RequestBody 객체를 사용자로부터 가져올 때, 들어오는 값들을 검증할 수 있는 방법을 소개한다.Jakarata Bean Validation API Packages에 있는 javax.validation.constraints package에 있는 기본적인 검증 어노테이션을 이용한다.@Valid를 이용하면, service 단이 아닌 객체 안에서, 들어오는 값에 대해 검증을 할 수 있다.javax.validation.constraints 패키지를 보면 많은 어노테이션들이 존재한다. @Valid를 이용한 객체 검증 시 기본적으로 이 어노테이션을 이용한다. 사실 이름만 봐..

@Valid 를 이용해 @RequestBody 객체 검증하기 | Validating @RequestBody Objects Using @Valid

728x90

Springboot를 이용해서 어노테이션을 이용한 validation을 하는 방법을 적으려 한다. 
RestController를 이용하여 @RequestBody 객체를 사용자로부터 가져올 때, 들어오는 값들을 검증할 수 있는 방법을 소개한다.

Jakarata Bean Validation API Packages에 있는 javax.validation.constraints package에 있는 기본적인 검증 어노테이션을 이용한다.
@Valid를 이용하면, service 단이 아닌 객체 안에서, 들어오는 값에 대해 검증을 할 수 있다.

javax.validation.constraints 패키지를 보면 많은 어노테이션들이 존재한다. @Valid를 이용한 객체 검증 시 기본적으로 이 어노테이션을 이용한다. 사실 이름만 봐도 각각의 용도를 이해할 수 있다. 

추가 : springboot가 버전업을 하면서 web 의존성안에 있던 constraints packeage가 아예 모듈로 빠졌다.

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

포스팅 관련 소스코드 : https://github.com/mjung1798/Jyami-Java-Lab/tree/master/annotation-validation

 

mjung1798/Jyami-Java-Lab

Jyami의 Spring boot 및 Java 실험소. Contribute to mjung1798/Jyami-Java-Lab development by creating an account on GitHub.

github.com

 

1. @Valid를 사용하는 방법

간단한 API를 작성해보자.

@RestController
@Slf4j
public class TestController {

    @PostMapping("/user")
    public ResponseEntity<String> savePost(final @Valid @RequestBody UserDto userDto) {
        log.info(userDto.toString());
        return ResponseEntity.ok().body("postDto 객체 검증 성공");
    }
}

파라미터로 @RequestBody 어노테이션 옆에 @Valid를 작성하면, RequestBody로 들어오는 객체에 대한 검증을 수행한다. 이 검증의 세부적인 사항은 객체 안에 정의를 해두어야 한다.

@ToString
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {

    @NotNull
    private String name;
    
    @Email
    private String email;
}

위와 같이 UserDto 객체를 정의한 후 각 필드에 맞는 어노테이션을 사용하면 된다.

@NotNull : 인자로 들어온 필드 값에 null 값을 허용하지 않는다
@Email : 인자로 들어온 값을 Email 형식을 갖추어야 한다.
필드에 맞는 어노테이션에 대한 설명은 아래로 스크롤을 내려보자 : 3. javax.constraint 어노테이션 이해하기

실제로 PostMan을 이용해서 valid가 적용되는지 확인하기 위해, email 값을 이상하게 줘보면,

자동으로 일정 규격에 맞춰서 springboot가 생성한 error 틀에 따라 response가 나간다.
즉, @Valid와 검증 어노테이션만 같이 잘 써줘도, 객체 단에서 에러를 잡을 수 있다는 뜻이다.

@Valid 어노테이션의 동작 여부를 확인하기 위한 테스트 코드는 간단한 Controller 테스트를 이용하여 확인할 수 있다.
아래 테스트 코드는 @NotNull 어노테이션에 따라, name을 null로 넣었을 경우를 가정한 테스트이다.

@SpringBootTest
@AutoConfigureMockMvc
class TestControllerTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;

    @Test
    @DisplayName("@NotNull 에러 테스트")
    void isValidObject() throws Exception {

        UserDto userDto = UserDto.builder()
                .email("mjung1798@gmail.com")
                .build();

        String userDtoJsonString = objectMapper.writeValueAsString(userDto);

        mockMvc.perform(post("/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(userDtoJsonString))
                .andExpect(status().isBadRequest());
    }
}

 

2. @Valid시 발생하는 Exception Handling 

@Valid로 requestBody로 들어온 객체의 검증이 이루어지면서 위와 같이 BadRequest가 나가는 경우에 custom 한 errorhandling도 할 수 있다. 

위에서 잘못된 객체 값이 나갔을 때 Springboot에 올라온 Log를 살펴보면 MethodArgumentNotValidException이 발생했음을 알 수 있어, 이 Exception을 사용하여 custom한 ErrorMessage를 response로 내보낼 수도 있다.

@ControllerAdvice를 이용한 전역 에러 핸들링, 혹은 @Controller단에서의 지역 에러 핸들링을 사용하면 된다.
MethodArgumentNotValidException에 대한 @ExceptionHandler 어노테이션을 지정하여 커스텀 에러 핸들링을 해보자

@RestControllerAdvice
public class ApiControllerAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex){
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors()
                .forEach(c -> errors.put(((FieldError) c).getField(), c.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }

}

ResponseEntity 값으로, error가 난 field 값과, 에러 메시지를 Map 형태로 만들어서, Response로 넣어주었다. 이때 Map으로 선언하여 forEach를 한 이유는 @Valid를 사용할 때, 해당 객체에서 valid에 실패한 내용을 모두 리턴해주기 때문에, 모든 error 값을 수용하기 위해서이다.

이 상태로 다시 서버를 Run 시켜서 Postman으로 확인을 해본다.
이때는 @NotNull, @Email 모두 validation이 안되도록 requestBody를 작성하였다.

Response값을 살펴보면, BadRequest인 status값, @Valid를 통과하지 못한 모든 필드 값에 대한 어려와, 에러 내용을 커스텀하게 내려준 것이 잘 반영되었음을 알 수 있다.

 

3. javax.constraint 어노테이션 이해하기

가장 아래에 관련 표를 넣어두었다 필요할 때 참고하자.

공식 문서 : https://docs.jboss.org/hibernate/beanvalidation/spec/2.0/api/

 

Jakarta Bean Validation API 2.0.2

 

docs.jboss.org

 

1. 문자열 유무 검증 (@NotBlank, @NotEmpty, @NotNull의 차이점)

@NotBlank 
- null 이 아닌 값이다.
- 공백이 아닌 문자를 하나 이상 포함한다

@NotEmpty
- Type :
 CharSequence (length of character) Collection (collection size) Map (map size Array (array length)
- null 이거나 empty(빈 문자열)가 아니어야 한다.

@NotNull
- Type :
 어떤 타입이든 수용한다.
- null 이 아닌 값이다.

@Null
- Type :
어떤 타입이든 수용한다.
- null 값이다.

이 부분은 헷갈리는 부분이라 DTO와 Contoller를 만들어서 확인해보자.

@NoArgsConstructor
@Getter
@ToString
public class NotDto {
    @NotNull
    private String notNull;
    @NotEmpty
    private String notEmpty;
    @NotBlank
    private String notBlank;
}

결과는 아래와 같다. 각각의 error message를 통해 각 validation 방법을 확인 할 수 있다.

  • @NotNull : 반드시 값이 있어야 한다.
  • @NotEmpty : 반드시 값이 존재하고 길이 혹은 크기가 0보다 커야한다.
  • @NotBrank : 반드시 값이 존재하고 공백 문자를 제외한 길이가 0보다 커야 한다.

 

  null "" " "
@NotNull Invalid Valid Valid
@NotEmpty Invalid Invalid Valid
@NotBlank Invalid Invalid Invalid

용도에 맞게 validation을 할 수 있도록 확인하자.

 

2. 최대 최소에 대한 검증

suppportType 
- BigDecimal BigInteger CharSequence byte, short, int, long, 이에 대응하는 Wrapper 클래스
- double, float는 rounding error 때문에 지원하지 않는다.
- null도 valid로 간주된다.

Validation
- @DecimalMax : 지정된 최대 값보다 작거나 같아야 한다.
      Require : String value  => max 값을 지정한다.
- @DecimalMin : 지정된 최소 값보다 크거나 같아야 한다.
      Require : String value  => min 값을 지정한다.
- @Max : 지정된 최대 값보다 작거나 같아야 한다.
      Require : int value  => max 값을 지정한다.
- @Min : 지정된 최소 값보다 크거나 같아야 한다.
      Require : int value  => min 값을 지정한다.

Usage

@NoArgsConstructor
@Getter
@ToString
public class MinMaxDto {
    @DecimalMax(value = "1000000000")
    private BigInteger decimalMax;
    @DecimalMax(value = "1")
    private BigInteger decimalMin;
    @Max(value = 1000)
    private Integer max;
    @Max(value = 1)
    private Integer min;
}

DecimalMax/Min과, Max/Min의 차이는 범위 값의 차이이다. String을 사용하느냐 Integer를 사용하느냐에 따라 범위 값이 현저히 달라지기 때문이다

 

3. 범위 값에 대한 검증

suppportType 
- BigDecimal BigInteger CharSequence byte, short, int, long, double, float 이에 대응하는 Wrapper 클래스
- null도 valid로 간주된다.

Validation
- @Positive : 양수인 값이다.
- @PositiveOrZero : 0이거나 양수인 값이다.
- @Negative : 음수인 값이다.
- @NegativeOrZero : 0이거나 음수인 값이다.

Usage

@NoArgsConstructor
@Getter
@ToString
public class RangeDto {
    @Positive
    private Integer positive;
    @PositiveOrZero
    private Integer positiveOrZero;
    @Negative
    private Integer negative;
    @NegativeOrZero
    private Integer negativeOrZero;
}

 

4. 시간 값에 대한 검증

suppportType 
- java.util.Date java.util.Calendar java.time.Instant java.time.LocalDate java.time.LocalDateTime java.time.LocalTime java.time.MonthDay java.time.OffsetDateTime java.time.OffsetTime java.time.Year java.time.YearMonth java.time.ZonedDateTime java.time.chrono.HijrahDate java.time.chrono.JapaneseDate java.time.chrono.MinguoDate java.time.chrono.ThaiBuddhistDate
- null도 valid로 간주된다.

Validation
- @Future : Now 보다 미래의 날짜, 시간이어야 한다.
- @FutureOrPresent : Now 거나 미래의 날짜, 시간이어야 한다.
- @Past : Now 보다 과거 의의 날짜, 시간이어야 한다.
- @PastOrPresent : Now 거나 과거의 날짜, 시간이어야 한다.

Now의 기준 : ClockProvider의 가상 머신에 따라 현재 시간을 정의하며 필요한 경우 default time zone을 적용한다.

Usage

@NoArgsConstructor
@Getter
@ToString
public class TimeDto {
    @Future
    private Date future;
    @FutureOrPresent
    private Date futureOrPresent;
    @Past
    private Date past;
    @PastOrPresent
    private Date pastOrPresent;
}

 

4. 이메일 검증

suppportType 
- null도 valid로 간주된다.

Validation
- @Email : 올바른 형식의 이메일 주소여야 한다. (@가 들어가야한다.)

Usage

@NoArgsConstructor
@Getter
@ToString
public class EmailDto {
    @Email
    private String email;
}

 

5. 자릿수 범위 검증

suppportType 
- BigDecimal BigInteger CharSequence byte, short, int, long, 이에 대응하는 Wrapper 클래스
- null도 valid로 간주된다.

Validation
- @Digits : 허용된 범위 내의 숫자이다.
      Require : int integer  => 이 숫자에 허용되는 최대 정수 자릿수
      Require : int fraction  => 이 숫자에 허용되는 최대 소수 자릿수

Usage

@NoArgsConstructor
@Getter
@ToString
@Builder
@AllArgsConstructor
public class DigitsDto {
    @Digits(integer = 5, fraction = 5)
    private Integer digits;
}

 

6. Boolean 값에 대한 검증

suppportType 
- Boolean, boolean

Validation
- @AssertTrue : 값이 항상 True 여야 한다.
- @AssertFalse : 값이 항상 False 여야 한다.

Usage

@NoArgsConstructor
@Getter
@ToString
public class BooleanDto {
    @AssertTrue
    private boolean assertTrue;
    @AssertFalse
    private boolean assertFalse;
}

 

7. 크기 검증

suppportType 
- CharSequence (length of character sequence) Collection (collection size) Map (map size) Array (array length)
- null도 valid로 간주된다.

Validation
- @Size : 이 크기가 지정된 경계(포함) 사이에 있어야 한다.
      Require : int max  => element의 크기가 작거나 같다.
      Require : int min  => element의 크기가 크거나 같다.

Usage

@NoArgsConstructor
@Getter
@ToString
public class SizeDto {
    @Size(max = 5, min = 3)
    private String size;
}

 

8. 정규식 검증

suppportType 
- CharSequence
- null도 valid로 간주된다.

Validation
- @Pattern : 지정한 정규식과 대응되는 문자열 이어야 한다. Java의 Pattern 패키지의 컨벤션을 따른다
      Require : String regexp  => 정규식 문자열을 지정한다

Usage

@NoArgsConstructor
@Getter
@ToString
public class PatternDto {
    //yyyy-mm-dd 형태를 가지는 패턴 조사
    @Pattern(regexp = "^(19|20)\\d{2}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[0-1])$")
    private String pattern;
}

 

4. @Valid 정리 표

@AssertTrue Boolean, boolean 값이 항상 True 여야 한다.  
@DecimalMax 실수 제외 숫자 클래스. 지정된 최대 값보다 작거나 같아야 하는 숫자이다. String : value (max 값을 지정한다.)
@DecimalMin 실수 제외 숫자 클래스. 지정된 최소 값보다 크거나 같아야하는 숫자이다. String : value (min 값을 지정한다.)
@Digits BigDecimalBigIntegerCharSequencebyte, short, int, long, 이에 대응하는 Wrapper 클래스 허용된 범위 내의 숫자이다. int : integer (이 숫자에 허용되는 최대 정수 자릿수)
int : fraction (이 숫자에 허용되는 최대 소수 자릿수)
@Email null도 valid로 간주된다. 올바른 형식의 이메일 주소여야한다.  
@Future 시간 클래스 Now 보다 미래의 날짜, 시간  
@FutureOrPresent 시간 클래스 Now의 시간이거나 미래의 날짜, 시간  
@Max 실수 제외 숫자 클래스. 지정된 최대 값보다 작거나 같은 숫자이다. long : value (max 값을 지정한다)
@Min 실수 제외 숫자 클래스. 지정된 최소 값보다 크거나 같은 숫자이다. long : value (min 값을 지정한다)
@Negative 숫자 클래스 음수인 값이다.  
@NegativeOrZero 숫자 클래스 0이거나 음수인 값이다  
@NotBlank   null 이 아닌 값이다.공백이 아닌 문자를 하나 이상 포함한다  
@NotEmpty CharSequence,Collection, Map, Array null이거나 empty(빈 문자열)가 아니어야 한다.  
@NotNull 어떤 타입이든 수용한다. null 이 아닌 값이다.  
@Null 어떤 타입이든 수용한다. null 값이다.  
@Past 시간 클래스 Now보다 과거의 날짜, 시간  
@PastOrPresent 시간클래스 Now의 시간이거나 과거의 날짜, 시간  
@Pattern 문자열 지정한 정규식과 대응되는 문자열이어야한다. Java의 Pattern 패키지의 컨벤션을 따른다 String : regexp (정규식 문자열을 지정한다)
@Positive 숫자 클래스 양수인 값이다  
@PositiveOrZero 숫자 클래스 0이거나 양수인 값이다.  
@Size CharSequence,Collection, Map, Array 이 크기가 지정된 경계(포함) 사이에 있어야한다. int : max (element의 크기가 작거나 같다)
int : min (element의 크기가 크거나 같다)

[@ValidAnnotation].List : 동일한 요소에 여러개의 @ValidAnnotation[] 제약조건을 정의한다.

실제로 잘 사용하지는 않는 것으로 보인다.

 

[관련 블로그]

같은 내용의 글을 팀 블로그에도 업로드 한 상태이다.

https://javabom.tistory.com/

 

자바봄

자바 스프링 블로그 입니다.

javabom.tistory.com

 

참고 자료

https://www.baeldung.com/spring-boot-bean-validation

https://www.baeldung.com/java-bean-validation-not-null-empty-blank

 

I'm going to write about how to perform annotation-based validation using Spring Boot. 
This post introduces how to validate incoming values when receiving a @RequestBody object from the user via a RestController.

We'll use the basic validation annotations from the javax.validation.constraints package in the Jakarta Bean Validation API Packages.
By using @Valid, you can validate incoming values within the object itself, rather than at the service layer.

If you look at the javax.validation.constraints package, there are many annotations available. These annotations are used by default when performing object validation with @Valid. Honestly, you can understand what each one does just by looking at the name. 

Update: As Spring Boot has been upgraded, the constraints package that used to be inside the web dependency has been separated into its own module.

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

Source code for this post: https://github.com/mjung1798/Jyami-Java-Lab/tree/master/annotation-validation

 

mjung1798/Jyami-Java-Lab

Jyami's Spring Boot and Java experiment lab. Contribute to mjung1798/Jyami-Java-Lab development by creating an account on GitHub.

github.com

 

1. How to Use @Valid

Let's write a simple API.

@RestController
@Slf4j
public class TestController {

    @PostMapping("/user")
    public ResponseEntity<String> savePost(final @Valid @RequestBody UserDto userDto) {
        log.info(userDto.toString());
        return ResponseEntity.ok().body("postDto 객체 검증 성공");
    }
}

By writing @Valid next to the @RequestBody annotation in the parameter, validation is performed on the incoming object from the RequestBody. The specific details of this validation must be defined inside the object.

@ToString
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserDto {

    @NotNull
    private String name;
    
    @Email
    private String email;
}

After defining the UserDto object as above, you simply use the appropriate annotation for each field.

@NotNull: Does not allow null values for the incoming field.
@Email: The incoming value must be in a valid email format.
For a detailed explanation of field annotations, scroll down to: 3. Understanding javax.constraint Annotations

To actually verify that validation is working using PostMan, let's try sending an invalid email value:

The response automatically goes out following the error template generated by Spring Boot in a certain format.
In other words, just by using @Valid along with the validation annotations properly, you can catch errors at the object level.

You can verify whether the @Valid annotation works using a simple Controller test.
The test code below assumes the case where name is set to null, according to the @NotNull annotation.

@SpringBootTest
@AutoConfigureMockMvc
class TestControllerTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;

    @Test
    @DisplayName("@NotNull 에러 테스트")
    void isValidObject() throws Exception {

        UserDto userDto = UserDto.builder()
                .email("mjung1798@gmail.com")
                .build();

        String userDtoJsonString = objectMapper.writeValueAsString(userDto);

        mockMvc.perform(post("/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content(userDtoJsonString))
                .andExpect(status().isBadRequest());
    }
}

 

2. Exception Handling for @Valid 

When validation is performed on the incoming requestBody object with @Valid and a BadRequest is returned as shown above, you can also do custom error handling. 

If you look at the log in Spring Boot when an invalid object value was sent, you can see that a MethodArgumentNotValidException was thrown. You can use this Exception to send a custom ErrorMessage as the response.

You can use global error handling with @ControllerAdvice, or local error handling at the @Controller level.
Let's try custom error handling by specifying the @ExceptionHandler annotation for MethodArgumentNotValidException.

@RestControllerAdvice
public class ApiControllerAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex){
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors()
                .forEach(c -> errors.put(((FieldError) c).getField(), c.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }

}

As the ResponseEntity value, we created a Map containing the field that caused the error and the error message, and put it into the Response. The reason we declared a Map and used forEach is that when using @Valid, it returns all the contents that failed validation in the object, so we need to accommodate all error values.

Let's run the server again and check with Postman.
This time, I wrote the requestBody so that both @NotNull and @Email validation would fail.

Looking at the Response, you can see that the BadRequest status value, the errors for all field values that failed @Valid, and the custom error content are all properly reflected.

 

3. Understanding javax.constraint Annotations

I've included a reference table at the very bottom. Refer to it when needed.

Official documentation: https://docs.jboss.org/hibernate/beanvalidation/spec/2.0/api/

 

Jakarta Bean Validation API 2.0.2

 

docs.jboss.org

 

1. String Presence Validation (Differences between @NotBlank, @NotEmpty, and @NotNull)

@NotBlank 
- The value must not be null.
- Must contain at least one non-whitespace character.

@NotEmpty
- Type:
 CharSequence (length of character) Collection (collection size) Map (map size Array (array length)
- Must not be null or empty (empty string).

@NotNull
- Type:
 Accepts any type.
- The value must not be null.

@Null
- Type:
Accepts any type.
- The value must be null.

This part can be confusing, so let's create a DTO and Controller to verify.

@NoArgsConstructor
@Getter
@ToString
public class NotDto {
    @NotNull
    private String notNull;
    @NotEmpty
    private String notEmpty;
    @NotBlank
    private String notBlank;
}

The results are as follows. You can understand each validation method through their respective error messages.

  • @NotNull: A value must be present.
  • @NotEmpty: A value must exist and its length or size must be greater than 0.
  • @NotBlank: A value must exist and its length, excluding whitespace characters, must be greater than 0.

 

  null "" " "
@NotNull Invalid Valid Valid
@NotEmpty Invalid Invalid Valid
@NotBlank Invalid Invalid Invalid

Make sure to use the appropriate validation for your use case.

 

2. Min/Max Value Validation

supportType 
- BigDecimal BigInteger CharSequence byte, short, int, long, and their corresponding Wrapper classes
- double, float are not supported due to rounding errors.
- null is also considered valid.

Validation
- @DecimalMax: Must be less than or equal to the specified maximum value.
      Require: String value  => Specifies the max value.
- @DecimalMin: Must be greater than or equal to the specified minimum value.
      Require: String value  => Specifies the min value.
- @Max: Must be less than or equal to the specified maximum value.
      Require: int value  => Specifies the max value.
- @Min: Must be greater than or equal to the specified minimum value.
      Require: int value  => Specifies the min value.

Usage

@NoArgsConstructor
@Getter
@ToString
public class MinMaxDto {
    @DecimalMax(value = "1000000000")
    private BigInteger decimalMax;
    @DecimalMax(value = "1")
    private BigInteger decimalMin;
    @Max(value = 1000)
    private Integer max;
    @Max(value = 1)
    private Integer min;
}

The difference between DecimalMax/Min and Max/Min is in the range of values. The range differs significantly depending on whether you use String or Integer.

 

3. Range Value Validation

supportType 
- BigDecimal BigInteger CharSequence byte, short, int, long, double, float and their corresponding Wrapper classes
- null is also considered valid.

Validation
- @Positive: The value must be positive.
- @PositiveOrZero: The value must be zero or positive.
- @Negative: The value must be negative.
- @NegativeOrZero: The value must be zero or negative.

Usage

@NoArgsConstructor
@Getter
@ToString
public class RangeDto {
    @Positive
    private Integer positive;
    @PositiveOrZero
    private Integer positiveOrZero;
    @Negative
    private Integer negative;
    @NegativeOrZero
    private Integer negativeOrZero;
}

 

4. Time Value Validation

supportType 
- java.util.Date java.util.Calendar java.time.Instant java.time.LocalDate java.time.LocalDateTime java.time.LocalTime java.time.MonthDay java.time.OffsetDateTime java.time.OffsetTime java.time.Year java.time.YearMonth java.time.ZonedDateTime java.time.chrono.HijrahDate java.time.chrono.JapaneseDate java.time.chrono.MinguoDate java.time.chrono.ThaiBuddhistDate
- null is also considered valid.

Validation
- @Future: Must be a date/time in the future compared to Now.
- @FutureOrPresent: Must be Now or a date/time in the future.
- @Past: Must be a date/time in the past compared to Now.
- @PastOrPresent : Must be Now or a date/time in the past.

Definition of Now: The current time is defined according to the virtual machine of the ClockProvider, and the default time zone is applied if necessary.

Usage

@NoArgsConstructor
@Getter
@ToString
public class TimeDto {
    @Future
    private Date future;
    @FutureOrPresent
    private Date futureOrPresent;
    @Past
    private Date past;
    @PastOrPresent
    private Date pastOrPresent;
}

 

4. Email Validation

supportType 
- null is also considered valid.

Validation
- @Email: Must be a properly formatted email address (must contain @).

Usage

@NoArgsConstructor
@Getter
@ToString
public class EmailDto {
    @Email
    private String email;
}

 

5. Digit Range Validation

supportType 
- BigDecimal BigInteger CharSequence byte, short, int, long, and their corresponding Wrapper classes
- null is also considered valid.

Validation
- @Digits: Must be a number within the allowed range.
      Require: int integer  => Maximum number of integer digits allowed for this number.
      Require: int fraction  => Maximum number of fractional digits allowed for this number.

Usage

@NoArgsConstructor
@Getter
@ToString
@Builder
@AllArgsConstructor
public class DigitsDto {
    @Digits(integer = 5, fraction = 5)
    private Integer digits;
}

 

6. Boolean Value Validation

supportType 
- Boolean, boolean

Validation
- @AssertTrue: The value must always be True.
- @AssertFalse: The value must always be False.

Usage

@NoArgsConstructor
@Getter
@ToString
public class BooleanDto {
    @AssertTrue
    private boolean assertTrue;
    @AssertFalse
    private boolean assertFalse;
}

 

7. Size Validation

supportType 
- CharSequence (length of character sequence) Collection (collection size) Map (map size) Array (array length)
- null is also considered valid.

Validation
- @Size: The size must be between the specified boundaries (inclusive).
      Require: int max  => The element size must be less than or equal to this value.
      Require: int min  => The element size must be greater than or equal to this value.

Usage

@NoArgsConstructor
@Getter
@ToString
public class SizeDto {
    @Size(max = 5, min = 3)
    private String size;
}

 

8. Regex Validation

supportType 
- CharSequence
- null is also considered valid.

Validation
- @Pattern: The string must match the specified regular expression. It follows the conventions of Java's Pattern package.
      Require: String regexp  => Specifies the regex string.

Usage

@NoArgsConstructor
@Getter
@ToString
public class PatternDto {
    //yyyy-mm-dd 형태를 가지는 패턴 조사
    @Pattern(regexp = "^(19|20)\\d{2}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[0-1])$")
    private String pattern;
}

 

4. @Valid Summary Table

@AssertTrue Boolean, boolean The value must always be True.  
@DecimalMax Numeric classes excluding floating-point types. The number must be less than or equal to the specified maximum value. String : value (specifies the max value.)
@DecimalMin Numeric classes excluding floating-point types. The number must be greater than or equal to the specified minimum value. String : value (specifies the min value.)
@Digits BigDecimal, BigInteger, CharSequence, byte, short, int, long, and their corresponding Wrapper classes The number must be within the allowed range. int : integer (maximum number of integer digits allowed for this number)
int : fraction (maximum number of fractional digits allowed for this number)
@Email null is also considered valid. Must be a properly formatted email address.  
@Future Date/time classes A date or time in the future relative to Now  
@FutureOrPresent Date/time classes A date or time that is Now or in the future  
@Max Numeric classes excluding floating-point types. The number must be less than or equal to the specified maximum value. long : value (specifies the max value)
@Min Numeric classes excluding floating-point types. The number must be greater than or equal to the specified minimum value. long : value (specifies the min value)
@Negative Numeric classes The value must be negative.  
@NegativeOrZero Numeric classes The value must be 0 or negative.  
@NotBlank   The value must not be null. Must contain at least one non-whitespace character.  
@NotEmpty CharSequence, Collection, Map, Array Must not be null or empty (empty string).  
@NotNull Accepts any type. The value must not be null.  
@Null Accepts any type. The value must be null.  
@Past Date/time classes A date or time in the past relative to Now  
@PastOrPresent Date/time classes A date or time that is Now or in the past  
@Pattern String The string must match the specified regular expression. Follows the conventions of Java's Pattern package. String : regexp (specifies the regex string)
@Positive Numeric classes The value must be positive.  
@PositiveOrZero Numeric classes The value must be 0 or positive.  
@Size CharSequence, Collection, Map, Array The size must be between the specified boundaries (inclusive). int : max (the element size must be less than or equal to this)
int : min (the element size must be greater than or equal to this)

[@ValidAnnotation].List : Defines multiple @ValidAnnotation[] constraints on the same element.

In practice, this doesn't seem to be used very often.

 

[Related Blog]

I've also uploaded the same content to our team blog.

https://javabom.tistory.com/

 

Javabom

A Java Spring blog.

javabom.tistory.com

 

References

https://www.baeldung.com/spring-boot-bean-validation

https://www.baeldung.com/java-bean-validation-not-null-empty-blank

 

댓글

Comments

Develop/Springboot

GCP Cloud Storage + Springboot 연동하기 | GCP Cloud Storage + Spring Boot Integration

이번 외주를 맡은 내용이 Google Cloud Storage를 이용해서 file을 업로드, 다운로드하는 API 기능을 구현해서 이 내용을 정리하고자 한다. Cloud Storage를 다루는 방법으로 Google Cloud Console, Cloud SDK를 이용한 command인 gsutil 등이 있지만, Springboot를 이용하여, Cloud Storage의 버킷 및 객체를 Client library 레벨에서 다루는 방법을 살펴보자. 1. Cloud Storage란?Google Cloud에 객체를 저장하는 서비스이다. 이때 객체는 모든 형식의 파일을 의미하며, 버킷이라는 컨테이너에 객체를 저장한다.모든 버킷은 프로젝트와 연결되어있으며, 프로젝트의 권한 지정을 통해 원하는 사용자가 storage안 ..

GCP Cloud Storage + Springboot 연동하기 | GCP Cloud Storage + Spring Boot Integration

728x90

이번 외주를 맡은 내용이 Google Cloud Storage를 이용해서 file을 업로드, 다운로드하는 API 기능을 구현해서 이 내용을 정리하고자 한다. Cloud Storage를 다루는 방법으로 Google Cloud Console, Cloud SDK를 이용한 command인 gsutil 등이 있지만, Springboot를 이용하여, Cloud Storage의 버킷 및 객체를 Client library 레벨에서 다루는 방법을 살펴보자.

 

1. Cloud Storage란?

Google Cloud에 객체를 저장하는 서비스이다. 이때 객체는 모든 형식의 파일을 의미하며, 버킷이라는 컨테이너에 객체를 저장한다.
모든 버킷은 프로젝트와 연결되어있으며, 프로젝트의 권한 지정을 통해 원하는 사용자가 storage안 데이터에 액세스 하도록 설정하는 것도 가능하다.

cloud storage의 구조
  • Organization : 쉽게 유저 계정이라 생각하자. (유저는 N개의 프로젝트를 만들 수 있다.)
  • Project : 각각의 프로젝트는 하나의 어플리케이션과 연관되어있으며, 각각의 프로젝트는 고유한 cloud storage api와 resource를 가진다.
  • Bucket : 각 프로젝트는 여러개의 bucket을 가질 수 있다. bucket은 object를 저장하는 컨테이너이다.
  • Object : 각각의 파일을 의미한다.

Cloud Storage와 관련한 자세한 개념은 아래 링크에서 참고할 수 있다.
https://cloud.google.com/storage/docs/concepts?hl=ko

 

개념  |  Cloud Storage  |  Google Cloud

Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies. Java is a registered trade

cloud.google.com

 

1. Google Cloud Storage Bucket 생성 및 Object 추가

Google Cloud Storage Bucket 생성 과정은 Web Console을 이용했다. https://cloud.google.com/

google cloud platform console에 접속 후 원하는 project를 생성한다. 프로젝트를 생성하지 않았다면, 프로젝트를 생성한다. 나는 daily-commit이라는 이름의 프로젝트를 사용했다. 왼쪽 상단의 탐색 바를 누른 후 Storage > browser 탭으로 들어간다.

스토리지 브라우저에 들어가서 버킷생성을 누르면 다음과 같이 버킷을 생성할 수 있다. 저장 위치, 데이터 클래스부터 시작해서 storage 라벨까지 고급설정을 세팅할 수 있다. 나는 javabom-storage라는 버킷을 생성했다.

버킷 생성을 위한 gsutil 명령어이다. -p, -c, -l, -b 옵션을 사용하여 버킷에 대한 상세 설정을 커맨드 라인에서 지정할 수 있다.

gsutil mb gs://[BUCKET_NAME]/

생성된 버킷에 들어가면, 현재 내 PC에 있는 파일, 폴더 업로드가 가능함을 알 수 있다. 또한 버킷잠금 탭을 가면 bucket의 생명주기 또한 세팅할 수 있는 기능이 있다. 이 곳에 나는 javaBomLogo.png 파일을 업로드한 상태이다. (드래그 드롭을 이용한 업로드도 가능하다.)

현재 객체의 공개 액세스 상태를 보면 공개아님으로 되어있어 승인된 사용자만 객체에 접근할 수 있음을 알수있다. 공개 액세스 상태를 공개로 바꾼다면 모든 사용자가 URL을 이용해서 이 객체에 접근할 수 있겠지만, 현재는 그렇지 않다. 

따라서 Springboot에서 내 프로젝트의 Cloud storage에 접근권한을 가질 수 있도록, Access Key를 받아 등록해야한다. 탐색 창을 켜서 IAM 및 관리자 > 서비스 계정 탭으로 들어가 서비스 계정 키를 생성하자.

서비스 계정에서 "+ 서비스 계정 만들기"  버튼을 선택하고, 아래와 같이 서비스 계정에 대한 설정을 완료해준다.

1. 서비스 계정에 대한 이름과 간단한 설명을 기술한다.
2. 내가 생성할 서비스 계정의 권한을 설정해준다. (이 서비스 계정으로 storage object와 관련한 권한을 추가했다. )
3. key만들기를 선택하여 json 키를 생성하여, 이 json 키를 로컬에 저장한다.

서비스 계정 키를 로컬에 GOOGLE_APPLICATION_CREDENTIALS 환경변수로 설정하면 로컬 환경에서도 설정한 서비스 계정이 권한을 가진 GCP 서비스에 접근할 수 있다.

이제는 생성된 서비스 계정의 정보를 가진 json 키를 springboot에 넣어, springboot 내에서 GCS의 객체에 접근할 수 있게 만들 것이다.

 

2. Springboot GCS Setup 

build.gradle에 gcp storage 의존성을 추가한다.

# build.gradle

dependencies {
	implementation 'org.springframework.cloud:spring-cloud-gcp-starter-storage'
}

참고로 Google Cloud Platform에서는 다양한 springboot 의존성을 제공하지만, springboot initializer에서 제공하는 서비스는 GCP storage, GCP Messaging, GCP Support 세 가지이다. 

구현을 위한 간단한 프로젝트 구조

그리고 스프링 부트 프로젝트에서 storage에 접근 할 수 있도록 application.properties에서 keyfile을 등록해주어야 한다. 위에서 다운로드한 json key file을 resources 폴더에 넣고,  classpath를 아래와 같이 등록해준다.

내가 다운로드 받은 json 키파일의 이름은 daily-commit-265411-498 dc92a620d.json이다.

# application.properties

spring.cloud.gcp.storage.credentials.location=classpath:daily-commit-265411-498dc92a620d.json

key.json 파일 내용을 살펴보면 type, project_id, private_key_id, private_key 등 storage를 사용하는데 필요한 내용이 저장되어있다. 따라서 application.properties에 키 파일의 경로를 적어주면 스프링 부트는 키 파일의 내용을 바탕으로 stroage 변수에 자동으로 의존성을 부여한다.

 

 

3. File Download From Cloud Storage

key.json 파일의 내용을 담은 storage 객체 정보가 bean으로 등록되었으니, 변수 storage를 생성자 주입 혹은 @Autowired를 사용해서 의존성을 주입한다. 

이후 아래와 같이 storage.get("버켓 이름", "버켓에서 다운로드할 파일 이름")으로 내 gcs에 있는 객체의 정보를 받아올 수 있다. 다운로드한 파일의 타입은 Blob인데, 이 Blob 타입은 Cloud Storage의 불변 객체이다. 문서를 찾아보니 바이트 배열로 이루어진 데이터이다. 이후 blob.downloadTo("로컬에 저장할 파일 이름"); 를 지정하여 다운로드를 시행한다.

public Blob downloadFileFromGCS() {
	Blob blob = storage.get("버켓이름", "버킷에서 다운로드할 파일 이름");
	blob.downloadTo(Paths.get("로컬에 저장할 파일 이름"));
	return blob;
}

따라서 아래와 같이 간단한 Http API를 만들어 실행해보자.

// GCSController.java

@RestController
@RequiredArgsConstructor
public class GCSController {

    private final GCSService gcsService;

    @PostMapping("gcs/download")
    public ResponseEntity localDownloadFromStorage(@RequestBody DownloadReqDto downloadReqDto){
        Blob fileFromGCS = gcsService.downloadFileFromGCS(downloadReqDto);
        return ResponseEntity.ok(fileFromGCS.toString());
    }
}

// GCSService.java

@Service
@RequiredArgsConstructor
public class GCSService {

    private final Storage storage;

    public Blob downloadFileFromGCS(String bucketName, String downloadFileName, String localFileLocation) {
        Blob blob = storage.get(bucketName, downloadFileName);
        blob.downloadTo(Paths.get(localFileLocation));
        return blob;
    }
}

// DownloadReqDto.java

@AllArgsConstructor
@Getter
public class DownloadReqDto {
    private String bucketName;
    private String downloadFileName;
    private String localFileLocation;
}

이 API를 이용하여 javabom-storage에 있는 JavaBomLogo.png 파일을 내 local에 저장해보도록 하겠다.

intellij 플러그인으로 확인한 내 cloud storage 객체 리스트 (좌), 다운로드 받을 파일인 JavaBomLogo.png 파일 (우)

아래와 같이 API를 호출하면, 현재 내 프로젝트에 download/java-bom.png 파일이 다운로드되어야 한다.

다만 가끔 에러가 터지는 경우가 있는데, 그 이유는 내 프로젝트 내에 download 폴더가 없기 때문이다. download 폴더를 추가하고 다운로드하면 잘 되므로 코드단에서는 Files 모듈을 사용해서 mkdir() 등을 해주는 로직으로 방어해주어야 한다.

download 폴더를 추가해준 후,  API를 호출하니 내 로컬 프로젝트 안에 GCS에 있던 파일은 java-bom.png파일이 잘 다운로드됨을 확인할 수 있다.

 

4. File Upload To Cloud Storage

download 로직과 마찬가지로 storage를 생성자 주입 혹은 @Autowired를 사용해서 의존성을 주입한다. 

upload는 GCS에 Blob파일을 만드는 것이기 때문에, BlobInfo의 Builder를 이용하여 cloud storage에 객체를 생성해준다. download와 마찬가지로 객체를 생성할 버켓 이름, 버켓에 저장할 파일 이름을 적어주며, 로컬에서 업로드할 파일 이름도 적어주어야 한다.

이때 BlobInfo의 Builder를 이용해 Acl 설정을 통해 업로드할 객체의 권한을 코드로 설정할 수 있을 뿐 아니라  객체의 ContentsType 설정도 가능하다.

public BlobInfo uploadFileToGCS() throws IOException {

        BlobInfo blobInfo =storage.create(
                BlobInfo.newBuilder("버켓 이름", "버켓에 업로드할 파일 이름") 
                        .setAcl(new ArrayList<>(Arrays.asList(Acl.of(Acl.User.ofAllAuthenticatedUsers(), Acl.Role.READER))))
                        .build(),
                new FileInputStream("로컬에서 업로드 할 파일이름"));

        return blobInfo;
}

마찬가지로 Http API를 작성해보자

// GCSController.java

@RestController
@RequiredArgsConstructor
public class GCSController {

    private final GCSService gcsService;

    @PostMapping("gcs/upload")
    public ResponseEntity localUploadToStorage(@RequestBody UploadReqDto uploadReqDto) throws IOException {
        BlobInfo fileFromGCS = gcsService.uploadFileToGCS(uploadReqDto);
        return ResponseEntity.ok(fileFromGCS.toString());
    }
}

// GCSService.java

@Service
@RequiredArgsConstructor
public class GCSService {

    private final Storage storage;

    @SuppressWarnings("deprecation")
    public BlobInfo uploadFileToGCS(UploadReqDto uploadReqDto) throws IOException {

        BlobInfo blobInfo =storage.create(
                BlobInfo.newBuilder(uploadReqDto.getBucketName(), uploadReqDto.getUploadFileName())
                        .setAcl(new ArrayList<>(Arrays.asList(Acl.of(Acl.User.ofAllAuthenticatedUsers(), Acl.Role.READER))))
                        .build(),
                new FileInputStream(uploadReqDto.getLocalFileLocation()));

        return blobInfo;
    }
    
}

// UploadReqDto.java

@AllArgsConstructor
@Getter
public class UploadReqDto {
    private String bucketName;
    private String uploadFileName;
    private String localFileLocation;
}

이 API를 이용하여 내 local 프로젝태 내에 있는 upload/jyamiLogo.png 파일을 GCS에 업로드하겠다. 

내 프로젝트내 업로드할 파일 디렉터리 구조 (좌), 업로드할 파일인 jyamiLogo.png 파일 (우)

아래와 같이 API를 호출하면, 내 GCS 내에 있는 javabom-storage 버켓에 uploadGCS/jyamiLogo.png라는 폴더구조를 가진 객체가 생성되어야 한다.

구글 콘솔이나 인텔리제이 플러그인으로 storage를 확인하면 아래와 같이 파일이 잘 업로드됨을 확인할 수 있다.

콘솔 웹 (좌) / 인텔리제이 플러그인 (우)

구글에서 객체를 업로드할 때 파일명 자체를, [폴더 경로]/[파일명] 구조로 저장하기 때문에, 따로 GCS내에 폴더를 추가하는 로직 없이 업로드가 가능하다.

 

추가. intellij cloud code plugin

플러그인을 사용하면 웹 콘솔을 사용하지 않아도 편리하게 cloud storage의 내용을 확인할 수 있다.

https://jyami.tistory.com/51

 

[Intellij] GCP Plugin - Cloud Code 사용하기 in GCS

GCP Storage를 연동하여 사용하는 글을 블로그에 포스팅 하려고 Spring initializer을 이용해서 프로젝트를 생성하면서 좋은 플러그인을 발견하여 기록하려 한다. 위와 같은 dependency로 새로운 프로젝트를 설정..

jyami.tistory.com

 

[참고 문서]

For a recent freelance project, I implemented file upload and download API features using Google Cloud Storage, so I'd like to summarize what I learned. There are various ways to work with Cloud Storage, such as using the Google Cloud Console or the gsutil command via Cloud SDK, but let's take a look at how to manage Cloud Storage buckets and objects at the client library level using Spring Boot.

 

1. What is Cloud Storage?

It's a service for storing objects in Google Cloud. Here, objects refer to files of any format, and they are stored in containers called buckets.
Every bucket is associated with a project, and by configuring project permissions, you can control which users have access to the data in storage.

Structure of Cloud Storage
  • Organization : Think of it simply as a user account. (A user can create N projects.)
  • Project : Each project is associated with a single application, and each project has its own unique Cloud Storage API and resources.
  • Bucket : Each project can have multiple buckets. A bucket is a container that stores objects.
  • Object : Refers to individual files.

You can find more detailed concepts about Cloud Storage at the link below.
https://cloud.google.com/storage/docs/concepts?hl=ko

 

Concepts  |  Cloud Storage  |  Google Cloud

Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies. Java is a registered trade

cloud.google.com

 

1. Creating a Google Cloud Storage Bucket and Adding Objects

I used the Web Console for the Google Cloud Storage Bucket creation process. https://cloud.google.com/

After accessing the Google Cloud Platform console, create the project you want. If you haven't created a project yet, go ahead and create one. I used a project named daily-commit. Click the navigation bar in the upper left corner and go to Storage > browser tab.

Once you enter the storage browser and click "Create Bucket," you can create a bucket as shown below. You can configure advanced settings ranging from storage location and data class to storage labels. I created a bucket named javabom-storage.

Here's the gsutil command for creating a bucket. You can specify detailed bucket settings from the command line using the -p, -c, -l, and -b options.

gsutil mb gs://[BUCKET_NAME]/

When you enter the created bucket, you can see that it's possible to upload files and folders from your local PC. Also, if you go to the bucket lock tab, there's a feature to set the bucket's lifecycle as well. I've already uploaded a javaBomLogo.png file here. (Upload via drag and drop is also supported.)

If you look at the current public access status of the object, it's set to "Not public," which means only authorized users can access the object. If you change the public access status to public, all users could access this object via URL, but that's not the case right now. 

Therefore, in order for Spring Boot to have access permissions to the Cloud Storage of my project, we need to obtain and register an Access Key. Open the navigation menu and go to IAM & Admin > Service Accounts tab to create a service account key.

In Service Accounts, click the "+ Create Service Account" button and complete the service account settings as shown below.

1. Enter a name and brief description for the service account.
2. Set the permissions for the service account you're creating. (I added permissions related to storage objects for this service account.)
3. Select "Create Key" to generate a JSON key, and save this JSON key locally.

If you set the service account key as the GOOGLE_APPLICATION_CREDENTIALS environment variable locally, you can access GCP services that the configured service account has permissions for, even in your local environment.

Now we're going to add the JSON key containing the created service account information into Spring Boot, so that we can access GCS objects from within Spring Boot.

 

2. Springboot GCS Setup 

Add the GCP storage dependency to build.gradle.

# build.gradle

dependencies {
	implementation 'org.springframework.cloud:spring-cloud-gcp-starter-storage'
}

For reference, Google Cloud Platform provides various Spring Boot dependencies, but the services available through Spring Boot Initializer are limited to GCP Storage, GCP Messaging, and GCP Support. 

Simple project structure for implementation

Then, you need to register the key file in application.properties so that the Spring Boot project can access the storage. Place the JSON key file you downloaded earlier into the resources folder and register the classpath as shown below.

The name of the JSON key file I downloaded is daily-commit-265411-498dc92a620d.json.

# application.properties

spring.cloud.gcp.storage.credentials.location=classpath:daily-commit-265411-498dc92a620d.json

If you look at the contents of the key.json file, it contains information needed to use storage such as type, project_id, private_key_id, private_key, etc. So when you specify the key file path in application.properties, Spring Boot will automatically inject the dependency into the storage variable based on the key file's contents.

 

 

3. File Download From Cloud Storage

Since the storage object information containing the key.json file contents has been registered as a bean, inject the dependency for the storage variable using constructor injection or @Autowired. 

Then, as shown below, you can retrieve information about objects in your GCS using storage.get("bucket name", "file name to download from bucket"). The type of the downloaded file is Blob, which is an immutable object in Cloud Storage. Looking at the documentation, it's data consisting of a byte array. After that, specify blob.downloadTo("file name to save locally"); to perform the download.

public Blob downloadFileFromGCS() {
	Blob blob = storage.get("버켓이름", "버킷에서 다운로드할 파일 이름");
	blob.downloadTo(Paths.get("로컬에 저장할 파일 이름"));
	return blob;
}

So let's create a simple HTTP API as shown below and try it out.

// GCSController.java

@RestController
@RequiredArgsConstructor
public class GCSController {

    private final GCSService gcsService;

    @PostMapping("gcs/download")
    public ResponseEntity localDownloadFromStorage(@RequestBody DownloadReqDto downloadReqDto){
        Blob fileFromGCS = gcsService.downloadFileFromGCS(downloadReqDto);
        return ResponseEntity.ok(fileFromGCS.toString());
    }
}

// GCSService.java

@Service
@RequiredArgsConstructor
public class GCSService {

    private final Storage storage;

    public Blob downloadFileFromGCS(String bucketName, String downloadFileName, String localFileLocation) {
        Blob blob = storage.get(bucketName, downloadFileName);
        blob.downloadTo(Paths.get(localFileLocation));
        return blob;
    }
}

// DownloadReqDto.java

@AllArgsConstructor
@Getter
public class DownloadReqDto {
    private String bucketName;
    private String downloadFileName;
    private String localFileLocation;
}

I'll use this API to save the JavaBomLogo.png file from javabom-storage to my local machine.

Cloud Storage object list verified via IntelliJ plugin (left), JavaBomLogo.png file to download (right)

When you call the API as shown below, the file download/java-bom.png should be downloaded to my current project.

However, you might occasionally run into errors, and the reason is that the download folder doesn't exist in my project. It works fine once you add the download folder, so in your code you should add defensive logic using the Files module with something like mkdir().

After adding the download folder and calling the API, I can confirm that the file from GCS, java-bom.png, was successfully downloaded into my local project.

 

4. File Upload To Cloud Storage

Just like the download logic, inject the storage dependency using constructor injection or @Autowired. 

Since upload is about creating a Blob file in GCS, we use BlobInfo's Builder to create an object in Cloud Storage. Similar to download, you need to specify the bucket name where the object will be created, the file name to save in the bucket, and the local file name to upload.

At this point, using BlobInfo's Builder, you can not only set the uploaded object's permissions through Acl configuration in code, but also configure the object's ContentType.

public BlobInfo uploadFileToGCS() throws IOException {

        BlobInfo blobInfo =storage.create(
                BlobInfo.newBuilder("버켓 이름", "버켓에 업로드할 파일 이름") 
                        .setAcl(new ArrayList<>(Arrays.asList(Acl.of(Acl.User.ofAllAuthenticatedUsers(), Acl.Role.READER))))
                        .build(),
                new FileInputStream("로컬에서 업로드 할 파일이름"));

        return blobInfo;
}

Let's write the HTTP API as well.

// GCSController.java

@RestController
@RequiredArgsConstructor
public class GCSController {

    private final GCSService gcsService;

    @PostMapping("gcs/upload")
    public ResponseEntity localUploadToStorage(@RequestBody UploadReqDto uploadReqDto) throws IOException {
        BlobInfo fileFromGCS = gcsService.uploadFileToGCS(uploadReqDto);
        return ResponseEntity.ok(fileFromGCS.toString());
    }
}

// GCSService.java

@Service
@RequiredArgsConstructor
public class GCSService {

    private final Storage storage;

    @SuppressWarnings("deprecation")
    public BlobInfo uploadFileToGCS(UploadReqDto uploadReqDto) throws IOException {

        BlobInfo blobInfo =storage.create(
                BlobInfo.newBuilder(uploadReqDto.getBucketName(), uploadReqDto.getUploadFileName())
                        .setAcl(new ArrayList<>(Arrays.asList(Acl.of(Acl.User.ofAllAuthenticatedUsers(), Acl.Role.READER))))
                        .build(),
                new FileInputStream(uploadReqDto.getLocalFileLocation()));

        return blobInfo;
    }
    
}

// UploadReqDto.java

@AllArgsConstructor
@Getter
public class UploadReqDto {
    private String bucketName;
    private String uploadFileName;
    private String localFileLocation;
}

I'll use this API to upload the upload/jyamiLogo.png file from my local project to GCS. 

Directory structure of the file to upload in my project (left), jyamiLogo.png file to upload (right)

When you call the API as shown below, an object with the folder structure uploadGCS/jyamiLogo.png should be created in the javabom-storage bucket in my GCS.

If you check the storage through the Google Console or IntelliJ plugin, you can confirm that the file was uploaded successfully as shown below.

Console web (left) / IntelliJ plugin (right)

Since Google stores the file name itself in a [folder path]/[file name] structure when uploading objects, you can upload without any additional logic to create folders within GCS.

 

Bonus: IntelliJ Cloud Code Plugin

Using the plugin, you can conveniently check Cloud Storage contents without using the web console.

https://jyami.tistory.com/51

 

[IntelliJ] Using GCP Plugin - Cloud Code in GCS

GCP Storage를 연동하여 사용하는 글을 블로그에 포스팅 하려고 Spring initializer을 이용해서 프로젝트를 생성하면서 좋은 플러그인을 발견하여 기록하려 한다. 위와 같은 dependency로 새로운 프로젝트를 설정..

jyami.tistory.com

 

[References]

댓글

Comments