알아두면 언젠간 깨달을 도커지식 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 의 규칙을 사용하여 도커 네트워크 통신을 한다
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
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.
Hypervisor Virtualization
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)
So, running code on top of Docker in a container environment basically means:
Through LXC, isolated zones are created inside the Linux kernel by combining identifiers called Cgroups and Namespaces.
The Docker engine uses LXC to run programs inside those isolated zones.
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)
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이 된다는 것은 다른 쓰레드에서 변수를 읽을 때 최신 변경사항을 읽을 수 있다는 것이다.
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.
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.
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.
자바의 제네릭 타입 소거, 리스트에 관하여 (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하게 선언되어있지 않기 때문에 타입 파라미터 E를 Integer로 우선적으로 바꾼다.
이때 만약 프로그래머가 continasElement(Integer[], Integer) 형식으로 해당 메서드를 사용했다면, 컴파일러 내부에서 두번재 규칙에 따라 타입 안정성 보존을 위해 Object -> Integer로의 타입 캐스트 코드를 넣어주어 제네릭의 타입 안정성을 보장해주는 것이다.
반면 로타입일 경우에는, 타입 파라미터가 정해져있지 않아. Object로 변환한 것에서 끝난다.
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 컴파일러는 다형성을 제네릭 타입 소거에서도 지키기 위해, IntegerStack의 push(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 에서는 런타임에 타입정보를 갖고있는데, 제네릭을 사용하면 타입이 소거되기 때문에 해당 제네릭 변수에 대한 정보를 런타임에 갖고있지 않기 때문이다.
사실 이 부분은 이펙티브 자바를 읽으면서 스터디에서 했던 제네릭과 관련한 이야기를 예시로 이야기 하려한다.
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("좋은", "빠른", "저렴한");
위 코드에서 pickList를 지나 pickArray를 실행하면 runtimeException이 터지는 것을 볼 수 있다. 내용은 [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; 이다 무엇이 문제일까?
정답은 Array와 List의 실체화에 있었다.
코드를 이해해보자. 위에서는 제네릭 타입추론이 2 depth가 들어간다. pickArray에서 타입 매개변수 T가 String과 대응하여 들어가기 때문에 pickArray(String) toArray(String...) 이 들어갈 것으로 예상한다. 하지만 실제 런타임에 확인해보면 pickArray(String), toArray(Object...)가 들어간다. 그 이유는 위에서 제네릭 타입추론을 이야기할 때 우선적으로 bounded가 아닌 매개변수일경우 컴파일러가 Object로 대체한다는 이야기와 대응된다. pickArray에서는 main에 있는 String 타입으로 타입추론이 가능했으나 toArray는 제네릭 타입을 바라보고 있으므로 타입추론을 Object로 하여 런타임에 타입정보를 갖고있게 된다.
따라서 pickArray는 String[] 으로 타입캐스팅을 준비하였으나, 위에서 말한 것처럼 런타임에 toArray가 갖고있는 타입은 Object[] 이므로 런타임에 (String[]) Object[] 와 같은 형식으로 강제적으로 타입캐스팅을 하다가 ClassCastException이 발생한다. (String 배열은, Object 배열의 하위 타입이 아니기 때문에 Casting이 되지 않는다.)
반면에 pickList를 호출할때는 컴파일이 성공한다. 이 이유는 List가 실체화 불가타입이었기 때문이다. 컴파일 타임에 캐스팅할 정보가 이미 결정이되고, 런타임때에는 제네릭의 소거라는 특성 때문에 Java 컴파일러가 타입에 맞는 캐스팅 방식을 올바르게 추가해줘서 캐스팅 에러가 나지 않는다.
정리하자면, 리스트 + 제네릭은 컴파일 타임에 결정된 캐스팅 정보가 올바르기 때문에 통과가 된다. 제네릭의 장점인 컴파일 타임에 타입이 안맞는 것을 체크해주는 걸 List에서도 수행하기 때문이다. 반면 배열 같은 경우엔 타입 캐스팅을 런타임에 결정하기 때문에 문제가 생긴 것이다.
조금 길었지만 사실 결론은 간단하다. 제네릭은 타입소거라는 특징으로 컴파일러가 컴파일 타임에 타입을 추론할 수 있으며, 이런 타입 추론 기능을 강력하게 사용하기 위해서는 런타임에 타입을 추론하는 Array 대신에 컴파일타임에 타임을 추론하는 List를 함께 사용해야 안정성을 보장 할 수 있다는 것이다.
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.
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("좋은", "빠른", "저렴한");
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.
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"로 간주되지 않는다.
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".
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.
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)
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).
@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가 아예 모듈로 빠졌다.
@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를 통과하지 못한 모든 필드 값에 대한 어려와, 에러 내용을 커스텀하게 내려준 것이 잘 반영되었음을 알 수 있다.
@NotEmpty - Type :CharSequence(length of character)Collection(collection size)Map(map sizeArray(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 - BigDecimalBigIntegerCharSequencebyte, 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 값을 지정한다.
suppportType - java.util.Datejava.util.Calendarjava.time.Instantjava.time.LocalDatejava.time.LocalDateTimejava.time.LocalTimejava.time.MonthDayjava.time.OffsetDateTimejava.time.OffsetTimejava.time.Yearjava.time.YearMonthjava.time.ZonedDateTimejava.time.chrono.HijrahDatejava.time.chrono.JapaneseDatejava.time.chrono.MinguoDatejava.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 - BigDecimalBigIntegerCharSequencebyte, 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;
}
@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[] 제약조건을 정의한다.
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.
@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.
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.
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 sizeArray(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 - BigDecimalBigIntegerCharSequencebyte, 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.
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 -BigDecimalBigIntegerCharSequencebyte,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.
supportType - java.util.Datejava.util.Calendarjava.time.Instantjava.time.LocalDatejava.time.LocalDateTimejava.time.LocalTimejava.time.MonthDayjava.time.OffsetDateTimejava.time.OffsetTimejava.time.Yearjava.time.YearMonthjava.time.ZonedDateTimejava.time.chrono.HijrahDatejava.time.chrono.JapaneseDatejava.time.chrono.MinguoDatejava.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 - BigDecimalBigIntegerCharSequencebyte, 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.
이번 외주를 맡은 내용이 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를 저장하는 컨테이너이다.
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의 객체에 접근할 수 있게 만들 것이다.
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의 내용을 확인할 수 있다.
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.
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.
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.
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.
댓글
Comments