반응형

출처 : 위키피디아

 

1. 개요 

 

 앞선 포스팅에서는 Git Merge 에 대해 알아봤다. 코드 충돌이 나는 원인과 과정을 이해하고, Fast Forward Merge , 3-Way-Merge 가 어떤 상황에 발생하는지도 알았다. 다음은 병합과 연관되는 또 다른 명령어인 Rebase에 대해 알아보자. Rebase and Merge 전략이 있을 만큼 연관성있는 명령어이다.

 


2. Rebase 가 뭔가요? 🤔

Re-Base

 

말 그대로 브랜치의 Base Commit(= Base) 를 재설정 (= Re) 하는 명령어이다.

A 브랜치에서 B 브랜치에 대한 Rebase를 할 경우 A 브랜치의 Base Commit이 B 브랜치의 Head Commit으로 변경된다.

 


3. 재설정하면 뭐가 좋은데요? 🤔

 Fast Forward Merge를 통해 깔끔한 커밋 히스토리를 유지할 수 있다. 예를들어 Main 브랜치에서 A 라는 브랜치를 병합하기 전에, A 브랜치가 Main 브랜치에 Rebase 작업을 한다면 어떨까?

A 브랜치의 Base Commit이 Main 브랜치의 Head Commit으로 Re-base 될것이다.

 이후 Main 브랜치에서 A 브랜치를 병합하면 어떻게 될까?

Fast Forward Merge가 되어 깃 히스토리가 직렬로 되어 깔끔하게 관리할 수 있다.

 


4. 자세한 예를들어 설명해주세요. 😖

 

아래의 깃 히스토리를 보자.

개발자는 D 기능 개발을 위해 Main 브랜치로부터 D 브랜치를 생성하고, 작업을 하며 D-C1, D-C2 Commit을 한 상황이다. 이때 D 브랜치의 Base Commit은 M-C2이다.

 

Main 브랜치로부터 D 브랜치를 생성한 상황

 

 

그리고 다른 개발자는 E 기능을 개발하기 위해 마찬가지로 Main 브랜치로부터 E 브랜치를 생성하고, 작업을 하며 E-C1 Commit을 한 상황이다. E 브랜치의 Base Commit은 M-C2이다.

Main 브랜치로부터 E 브랜치를 생성한 상황

 

 

개발이 끝나고 테스트까지 마친 D 기능은 운영 반영을 위해 Main 브랜치에서 Merge 되게 된다. Main 브랜치의 Header Commit과 병합하려는 D 브랜치의 Base Commit이 일치하므로 Fast Forward 방식으로 병합된다.

 

FF 방식으로 병합된 Main, D 브랜치

 

 

곧이어 E 기능도 개발과 테스트가 끝났고, 운영 반영을 위해 Main 브랜치에서 Merge 하게 된다. 이때는 FF 병합이 불가능하므로 3-Way-Merge 방식의 병합이 진행된다.

3-Way-Merge를 통한 Main, E 브랜치 병합

 

 


5. Rebase 설명하랬더니 왜 Merge를...? 🙄

Rebase의 사용 목적을 이해하기 위함이다. 상황을 조금만 복잡하게 만들어보도록 하겠다.

연초에 들어서니 개발 및 수정 요청건이 많아져 F, G, H, I, J, K 기능을 개발해야 하는 상황이 발생했다. 마찬가지로 Main 브랜치로부터 각각의 브랜치를 생성한 후 개발을 진행했으며, 결과적으로 아래와 같은 깃 히스토리가 만들어지게됐다.

6개의 브랜치를 생성했다.

 

 

이후 작업이 완료된 브랜치에 대해 하나씩 운영반영을 할 경우 Main 브랜치에서 브랜치별로 Merge 하게 된다. 몇일에 걸친 운영 반영을 모두 마치고 보니 아래와 같이 병렬 형태의 깃 히스토리가 생성되어 있었다. 예로 든건 6개의 브랜치이나, 작업이 더 추가되어 브랜치가 늘어날 경우 깃 히스토리는 두꺼운 병렬 형태를 띄게 되고, Main 브랜치에 대한 작업 히스토리를 파악하기 어려워진다.

복잡해질 준비를 하는 깃 히스토리

 


6. Rebase를 하면 뭐가 달라지나요? 🤔

 

Rebase를 하면 어떻게 될까? 위 브랜치 중 F, G, H에 대해서만 시뮬레이션을 돌려보겠다.

본격적으로 들어가기 전 Rebase 의 정의를 리마인드 하도록 하겠다.

 

 

말 그대로 브랜치의 Base Commit(= Base) 를 재설정 (= Re) 하는 명령어이다.

A 브랜치에서 B 브랜치에 대한 Rebase를 할 경우 A 브랜치의 Base Commit이 B 브랜치의 Head Commit으로 변경된다.


 

먼저 F 브랜치 운영 반영을 위해 F 브랜치에서 Main 브랜치에 대해 Rebase를 한다.

Main 브랜치의 Head Commit은 Merge Commit이고, F 브랜치의 Base Commit 도 Main 브랜치의 Merge Commit 이므로 아무일도 일어나지 않는다.

F 브랜치 > Main 브랜치 Rebase

 

이제 Main 브랜치에서 F 브랜치를 Merge 하면 Fast Forward Merge 방식으로 병합된다.

 

Main 브랜치 > F 브랜치 Merge

 

 

다음, H 브랜치 작업 운영 반영을 위해 H 브랜치에서 Main 브랜치에 대해 Rebase 한다. 

Main 브랜치의 Head Commit은 F-C3 이고, H 브랜치의 Base Commit 은 Merge Commit 이므로

H 브랜치의 Base Commit은 F-C3로 변경된다.

 

H 브랜치 > Main 브랜치 Rebase

 

 

이제 Main 브랜치에서 H 브랜치를 Merge 하면 Fast Forward Merge 방식으로 병합된다.

Main 브랜치 > H 브랜치 Merge

 

 

 

 

다음, G 브랜치 작업 운영 반영을 위해 G 브랜치에서 Main 브랜치에 대해 Rebase 한다. 

Main 브랜치의 Head Commit은 H-C2 이고, G 브랜치의 Base Commit 은 Merge Commit 이므로

G 브랜치의 Base Commit은 H-C2로 변경된다.

 

G 브랜치 > Main 브랜치 Rebase

 

이제 Main 브랜치에서 G 브랜치를 Merge 하면 Fast Forward Merge 방식으로 병합된다.

Main 브랜치 > G 브랜치 Merge

 

 

직접 테스트를 해보면 아래와 같이 직렬 형태의 깃 히스토리가 남는 것을 알 수 있다. 알아보기도 쉽다.

즉, Rebase를 통한 Merge를 하면 보기 쉬운 형태로 깃 히스토리를 관리하게 된다.

Rebase - Merge 전략 활용

 

 

 

 


7. 위험이 도사리고 있는 Rebase 👺

Rebase를 사용하면 간단하게 Base Commit이 변경된다고 설명했지만, 사실 위험한 작업이다. 왜 위험할까?

Origin 입장에서 생각해보자.

G 브랜치에서 작업 후 PUSH를 했을테니 로컬, 원격 브랜치 모두 아래와 같이 G-C1, G-C2, G-C3 커밋이 있었을 것이다.

G 브랜치

 

그런데 G 브랜치에서 Main 브랜치에 대해 Rebase, 정확히 말하면 로컬 G 브랜치에서 Main 브랜치에 대해 Rebase를 한다. 그럼 로컬 G 브랜치의 깃 히스토리는 아래와 같이 변경된다. 이때 push를 하면 어떻게 될까?

G 브랜치 > Main 브랜치 Rebase

 

 

 

그렇다. 충돌이 발생한다. Rebase 에 의해 로컬 브랜치의 깃 히스토리가 변경되었고 원격 브랜치로 Push 하려 하니 원격 브랜치 입장에서 깃 히스토리가 일치하지 않아 충돌이 발생하는 것이다. 원격 브랜치 입장에서는 로컬 브랜치가 자신과 이름만 같은 브랜치인 것이다.

로컬, 원격 브랜치의 조상 Commit 이 다르니 3-Way-Merge 방식을 통해 아래와 같이 병합된다.

로컬 G 브랜치와 원격 G 브랜치간 충돌 / 3-way-merge

 

결과적으로 Rebase를 했더니 동일 브랜치간 Merge 작업이 추가되고, Main 에서 이를 Merge하면 이러한 히스토리도 남게된다. 이렇게 보니 그냥 Merge를 하는것 보다 못하는 상황이다.

 

앞서 예시에서 보여준 Rebase는 이러한 케이스가 없었다. 즉, 로컬, 원격 브랜치가 충돌이 일어나지 않았다는 건데, 이건 무엇을 의미할까?

 

바로 강제 Push 를 통해 로컬과 원격 브랜치 간 히스토리를 맞춰준 것이다. 즉, Rebase를 하면 강제 Push 작업이 동반된다.

 

이러한 강제 Push는 해당 브랜치에 협업자가 있을 경우 큰 문제를 야기할 수 있다. 특히 이러한 사실을 몰랐을 때 예기치 못한 상황에 당황할 수 있고, 자신이 Push 했던 작업 내역을 잃어버릴 수도 있다.

 

1번 개발자가 G 브랜치를 pull 받은 후 Rebase 한 이후 시점에 2번 개발자가 G 브랜치에 작업 내역을 push 한다면, 1번 개발자의 로컬 G 브랜치에는 2번 개발자가 작업한 내용이 반영되지 않은 상태이다. 이때 1번 개발자가 Rebase 한 내역을 원격 브랜치에 덮어쓰기 위해 강제 push를 하게 된다면 2번 개발자가 작업한 내용과 히스토리 모두 날아가게 된다.

 

다행히도 이 문제는 강제 push 시 force-with-lease 옵션을 사용하면 해결된다. 로컬 저장소와 원격 저장소를 비교했을 때, 원격 저장소에 새로운 커밋이 추가되어 있는 경우 강제 Push 작업을 취소시키는 옵션이다.

 


 

8. 양날의 검 Rebase

Rebase는 깔끔한 깃 히스토리를 관리하고, 매우 단순하게 동작하는 착한(?) 명령인줄 알았으나, 깃 히스토리를 조작하고, 강제 Push 를 동반해야하는 위험한 녀석이었다. 이러한 위험성을 인지하고, 협업자와 충분한 소통과 함께 사용한다면 큰 문제가 없을테지만, Git을 통한 협업이 어색하고, Rebase의 동작과 위험성을 충분히 인지하지 못한 상태에서 사용한다면 코드를 날려먹거나 협업 간 사소하지 않은 문제를 야기할 수 있을 테니 조심히 사용하자.

반응형

'Git' 카테고리의 다른 글

[Git] Git Merge 란? / 쉽게 이해하기 / Fast Forward / 3-way-merge  (0) 2024.04.22
반응형

git

반응형

개요

 평소 Git을 사용했고, 이슈별로 브런치를 생성하며 개발했지만 혼자이다보니 Main 브랜치로 Merge 해도 코드 충돌이 발생할 확률은 극히 적었다. 협업을 시작하니 특정 브랜치로 Merge 해야하는 상황이 많이 발생했고, 코드 충돌이 잦아졌다. 발생할 이유가 없다고 생각했던 브랜치들이 충돌하는 이유, Git 트리가 상황에 따라 다르게 구성되는 이유, 아무 내용도 없는 Merge Commit 등... 여러 궁금증들이 생겼다. 이를 해소하려면 이 상황의 근간이 되는 Merge를 이해해야 했다.

 Branch를 왜 만들고, Merge를 왜 하고, 코드 충돌은 왜 발생하고, Git 트리는 왜 그렇게 구성되는지를  이해해보도록 하겠다.

 

더 이상 code Conflict 경고에 죽지 않으리라.

 


Merge란?

 

Merge란 브랜치와 브랜치를 합치는 과정을 말한다.

 

깃을 처음 사용하면 Pull과 헷갈릴 수 있는데, Pull은 원격 리포지토리 있는 코드를 로컬 리포지토리로 받아오는, 즉 업데이트를 하는 개념이고 Merge는 합치는 개념이다.

 


Merge 과정에서 코드 충돌이 발생하는 이유

합치려는 브랜치가 동일한 코드를 수정할 경우 발생한다.

 

 

말은 쉬운데 생각해보면 뭔말인가 싶다. 위 내용을 보다 쉽게 이해하기 위해 깃을 사용하지 않았다면 어땠을까를 생각해봤다.

 


깃을 사용하지 않고 협업하기

첫째, 팀장님이 개발자 두 명에게 각각 A와 B 기능을 구현하라고 지시했다. 협업이 시작된것이다. 이 회사는 Git이나 SVN과 같은 SCM을 도입하지 않아 팀장님이 갖고 있는 Base Code를 복사해서 자신의 로컬 환경으로 옮겼다. (참고로 Base Code는 현재 운영중인 코드이다.) 이로써 개발자들은 독립적인 개발 환경을 갖추게 되었다. 

개발을 위해 Base Code를 복사한다.

 

 

 

둘째, 두 개발자 모두 개발, 코드리뷰, 리팩토링 과정을 거치며 성공적으로 기능 구현을 완료했다.

리뷰 / 수정을 반복해가며 코드를 완성한다.

 

 

셋째, A 개발자가 작업한 A 기능을 선 반영하고, 하루 뒤에 B 개발자가 개발한 B 기능을 반영하기로 했다. 팀장님은 A 기능을 반영하기 위해 자신의 Base Code와 A 개발자가 작업한 코드를 병합한 후 코드를 운영서버에 반영했다. 병합은 어렵지 않았다. 팀장님이 갖고 있던 Base Code는 A 개발자에게 Base Code를 전달한 이후로 수정한 이력이 없기 때문에 A 개발자의 코드와 그대로 가져와도 됐기 때문이다.

굳이 비교할 필요가 없는 병합

 

 

넷째, 다음 날 팀장님은 B 기능을 반영하기 위해 Base Code(지금은 A와 병합된 코드가 Base Code이다) 와 B 개발자가 작업한 코드를 병합하려했다. 앞서 A 개발자의 코드를 그대로 가져올 순 없었다. B 개발자가 개발한 코드에는 A에 대한 기능이 없기 때문이다. 때문에 업데이트된 Base Code와 Base Code + B 코드를 비교하며 수정된 부분을 추출하여 병합하기로 했다. 그런데 이게 웬걸, A 개발자가 기능 구현을 위해 수정했던 UserService의 코드를 B 개발자도 수정한 것이다. 앞서 말한 동일한 코드를 수정한 상황이다. 이때 코드 충돌이 발생한다.

비교가 필요한 병합

 

다섯째, 팀장님은 두 개발자를 호출하여 충돌이 발생한 코드 중 어떤 코드를 사용할지를 토론했다. B 개발자가 수정한 코드가 더 깔끔하고, A 개발자가 구현한 A 기능에도 문제가 되지 않아 B 개발자의 코드로 수정했다. 곧 이어 운영반영도 완료했다.

 


깃을 사용했다면 어땠을까?

 

첫째, A, B 두 개발자는 독립적인 개발환경을 마련하기 위해 Main 브랜치를 기준으로 각각 A 브랜치, B 브랜치를 생성했을 것이다.

Main 브랜치를 Base로 하여 A, B 브랜치 생성

 

 

둘째, Commit - Push - PR 의 반복을 통해 기능 구현을 마쳤을 것이다.

A, B 기능구현 끝

 

 

셋째, Main 브랜치에서 A 브랜치를 Merge 했을 것이다. 'Main 브랜치의 Head Commit (M3) 기준으로 생성된 A 브랜치'가 생성되고 난 후 Main 브랜치의 커밋 내역이 없으므로 Fast-Forward 방식으로 병합될 것이다. Fast-Forward 방식은 바로 아래 설명하도록 하겠다.

 

Main 브랜치와 A 브랜치를 FF 병합

Head Commit
Branch의 마지막 커밋

 

 

 

넷째, 앞서 A 브랜치와의 Merge로 인해 Main 브랜치의 Head Commit이 변경됐기에 Main 브랜치와 B 브랜치를 Merge 할 경우 Fast-Forward 병합이 불가능하다. 추가로 코드 충돌이 발생했기 때문에 3-way-merge 를 통해 Merge 하고 이에 대한 Merge Commit 이 생성될 것이다.

 

Merge Commit
Merge 시 발생한 모든 변경사항들과 충돌 코드에 대한 대한 처리 내역을 뭉쳐놓은 커밋

 


Fast Forward 병합이 뭔가요?

Fast Forward Merge
브랜치 간 병합 시 현재 브랜치의 HeadCommit 과 병합하려는 브랜치의 Base Commit이 일치할 경우, 현재 브랜치의 Head Commit이 병합하려는 브랜치의 Head Commit으로 이동되는 병합 방식이다.

 


 

현재 브랜치의 Head Commit과 병합하려는 브랜치의 Base Commit이 일치...요? 🤔

Head Commit은 마지막 커밋, Base Commit은 브랜치를 생성할 때 Base가 됐던 브랜치의 마지막 커밋이다. 즉, 현재 브랜치의 HeadCommit 과 병합하려는 브랜치의 Base Commit이 일치한다는 것은, 현재 브랜치의 마지막 지점과 병합하려는 브랜치의 생성 지점이 동일하다는 것이다.

 (이해가 잘 되지 않는다면 어떤 물체의 끝 지점이 다른 물체의 시작지점과 일치한다면 이 지점을 연결해주는것만으로 병합이 되는 원리로 이해해보자.)

 

예를들어 아래와 같이 Main 브랜치로부터 생성된 A, B 브랜치가 있다. Main 브랜치의 Head Commit은 M-C2, A 브랜치의 Head Commit은 A-C2, B 브랜치의 Head Commit은 B-C2 이고, A 브랜치와 B 브랜치 모두 Base Commit이 M-C2 이다.

Main 브랜치로부터 생성된 A, B 브랜치

 

 

 

Main 브랜치에서 A 브랜치를 병합할 경우 Main 브랜치의 Head Commit인 M-C2와 A 브랜치의 Base Commit인 M-C2가 같으므로 이 지점을 연결한 후 Main 브랜치의 Head Commit A-C2 브랜치로 이동시키는 Fast Forward 방식으로 병합된다.

 

Fast-Forward 방식으로 병합

 

 

 

실 테스트를 위해 main 브랜치 생성 후 main 브랜치로부터 a-branch를 생성했다. 그 후 a-branch 에서 Create-AClass, Create-BClass 라는 두 개의 Commit 을 생성하고, main 브랜치에서 a-branch 를 Merge 했다. 그 결과, Fast Forward 방식으로 병합되어 main 브랜치의 Head commit이 a-branch 브랜치의 Head commit으로 이동된 것을 확인할 수 있다.

Main 브랜치 생성 및 첫 Commit
main 브랜치에서 a-branch 브랜치 병합

 

 


그럼 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않으면 어떤일이 발생하나요? 🤔

 

예를 들어 설명해보겠다. 앞서 Main 브랜치가 A 브랜치와 Fast Forward 병합한 후 Main 브랜치와 B 브랜치를 병합하려는 상황이다. 현재 Main 브랜치의 Head Commit은 A-C2, B 브랜치의 Base Commit은 B-C2로 현재 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않는 상황이다. 이때에는 3-way-merge 방식으로 병합하게 된다. 병합한 내용은 Merge Commit 에 생성되고 아래와 같은 형태의 커밋 히스토리가 생성된다. 

Main 브랜치와 B 브랜치 Merge

 

 

실 테스트를 통해 main 브랜치에서 b-branch를 병합할 경우 아래와 같이 초록색 선의 b-branch가 main 브랜치와 병합되었다는 것을 알리는 형태로 깃 트리가 생성되며, Merge Commit도 생성된 것을 확인할 수 있다.

Main 브랜치와 b-branch Merge

 

 

 

 

이 외에도 현재 브랜치의 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않는 케이스가 있다. 바로 Base Commit을 갖는 브랜치에서 신규 Commit이 생긴 경우이다.

 

Main 브랜치에서 생성된 새로운 Commit

 

 

 Main 브랜치를 기준으로 C 브랜치를 생성하고 C 브랜치의 작업자가 두 개의 Commit 을 생성하는 사이, Main 브랜치에서 M-C3 커밋이 발생했다. 이때 Main 브랜치의 Head Commit은 M-C3, C 브랜치의 Base Commit은 Merge Commit로 현재 브랜치의 Head Commit과 병합하려는 브랜치의 Base Commit이 일치하지 않아 3-way-merge 방식으로 병합되게 된다.

 

Main 브랜치와 C 브랜치 Merge

 

 

 

실 테스트를 위해 c-branch를 만든 후 Create-CClass, Create-CInterface commit 후, Main 브랜치에서 Create-MainClass라는 커밋을 생성하였다. Main 브랜치에서 c-branch 를 Merge 하니 아래와 같은 형태의 깃 트리와 Merge Commit 이 생성됨을 확인할 수 있다.

Main 브랜치와 c-branch Merge

 

 


3-Way-Merge 병합은 뭐에요?

앞서 Fast-Forward 병합이 가능하지 않는 상황에 발생하는 병합으로 3개의 Commit을 통해 병합하는 방법이다. 두 브런치를 병합한다면 각 브런치의 Head-Commit 기준으로 비교하면 되지 않을까라고 생각할 수도 있지만 병합하기 힘든 케이스가 많다. 2개의 Commit 보다 3개의 Commit으로 비교하는 것이 훨씬 효율적이다. 효율적인 이유를 한번 알아보자. 

 

아래 글에서 매우 잘 설명해주신 것 같아 해당 글을 참고했다.

https://wonyong-jang.github.io/git/2021/02/05/Github-Merge.html

 

[Git] Merge(3-way merge) 이해하기 - SW Developer

다른 형상 관리툴들과는 달리 git은 branch를 생성할 때 파일을 복사하는 것이 아니라 파일의 스냅샷만 가지고 생성하기 때문에 자원의 부담없이 branch를 만들어 사용할 수 있다. 이러한 장점 때문

wonyong-jang.github.io

 

효율적인 이유를 알아보기 위해 필요한 커밋은 3개로 현재 브랜치의 커밋, 병합할 브랜치의 커밋, 두 브랜치의 공통 조상이 되는 커밋이다.

 

Main 브랜치에 A 브랜치를 병합하는 상황을 가정하고, 메인 브랜치의 커밋을 M-C, 공통 조상이 되는 커밋을 B-C, 병합할 브랜치의 커밋을 A-C라고 가정하겠다. 생성된 커밋 히스토리는 아래와 같다.

 

현재 Commit History

 

 

여기서 변경된 부분이 a, b, c, d 라고 가정한다면 아래와 같은 표로 표현할 수 있다.

Main, A 브랜치의 수정 부분 및 Base Commit의 코드

 

 

그럼 Base Commit 기준으로 Main 브랜치는 c, d 부분이 수정되었고, A 브랜치는 a, c 가 수정됐음을 인지할 수 있다. 그리고 이를 병합한다면 충돌이 일어난 c 부분만 작업자가 직접 병합하고 나머지는 자동으로 병합되는 상황이다.

Base Commit 기준으로 Main, A 브랜치의 수정 부분을 Merge 한 결과

 

 

만약 Base Commit 없이 Main 브랜치와 A 브랜치로만 병합해야한다면 어떨까? b는 둘다 똑같으니 문제되지 않지만, a와 a'는 둘 중 어떤 코드가 베이스 코드인지, 수정된 코드인지 알 수 없고, d'와 d도 마찬가지 상황에 처한다. 병합 시 a와 a', d'와 d 중 어느 코드를 적용해야할 지 모르는 상황이다. 이를 알기위해선 개발자가 직접 두 브랜치의 공통되는 커밋, 즉 조상 커밋을 찾고 어떤 부분이 수정됐는지를 체크해야한다.

결국 개발자의 편의를 위해, 그리고 효율성을 위해 3-way-merge로 병합하는 것이다.

Merge 하려니 앞이 깜깜..

 

반응형

'Git' 카테고리의 다른 글

[Git] Git Rebase 란? / 쉽게 이해하기 / 예시  (0) 2024.04.22
반응형

1. 개요

 자바 8 이전에는 빈 값에 대한 처리 선택지가 '예외' 혹은 'null 반환'이었다. 이 둘 모두 허점이 있다.

예외는 정말 예외적인 상황에서만 사용해야한다는 것과(빈 값이라고 예외를 날려서는 안될 케이스도 있다. ex. 검색결과)

예외를 생성할 때 스택 추적을 하므로 이에 대한 비용 문제가 있다. null을 반환하면 위 문제가 생기지 않지만 언젠가, 그리고 어디선가 NullPointException 이 발생하여 시스템 버그를 초래할 수 있다. 그런데 자바 8 이후 또 하나의 선택지가 생겼다. 그게 바로 Optional<T> 이다. 

 


2. Optional<T> 이란?

 옵셔널은 1개의 null이 아닌 T 타입 객체를 담거나, 아무것도 담지 않을 수 있는 불변 컬렉션이다. 옵셔널을 사용하지 않는다면 보통 T를 반환할테지만 상황에 따라 아무것도 반환하지 않아야 할때가 있다면 T 대신 Optional<T> 를 반환하도록 선언하면 된다. 옵셔널을 반환하는 메서드는 예외를 던지는 메서드보다 유연하고 사용하기 쉬우며, null을 반환하는 메서드보다 오류 가능성이 낮다.

 


3. max 값을 구하는 예제

 

최댓값을 구하는 메서드이다. 파라미터로 들어온 Collection의 요소가 없을 경우 예외를 발생시키고 있다.

public static <E extends Comparable<E>> E max(Collection<E> c){
    if(c.isEmpty())
        throw new IllegalArgumentException("빈 컬렉션"); // 클래스 내부에서 예외를 던지고 있다.

    E result = null;
    for(E e : c)
        if (result == null || e.compareTo(result) > 0)
            result = Objects.requireNonNull(e);
    
    return result;
}

 

 

이처럼 예외 처리가 메서드 내에 강하게 결합되어 있기 때문에 여러가지 한계를 맞이하게 된다. 이를 호출하는 클래스의 상황에 따라 예외 메시지를 다르게 하고싶을 수도, 예외를 발생시키지 않을 수도 있지 않은가? 하지만 이 방식은 그게 쉽지않다.

예외 메시지를 다르게 하고싶다면 클라이언트는 이 런타임 예외가 발생한다는 것을 알고 있어야하고, 이 예외에 대해 외부에서 catch 문에서 예외 전환 로직을 작성해야한다. 원치않는 결합도가 생겨버렸다.

예외를 발생시키고 싶지 않다면 마찬가지로 catch 문에서 예외를 잡고 아무것도 수행하지 않도록 설정해야한다. 이상하다.

 

이를 Optional 로 구현해보면 어떨까?

public static <E extends Comparable<E>> Optional<E> max2(Collection<E> c){
    if(c.isEmpty())
        return Optional.empty(); // 빈 옵셔널을 반환함으로써 클래스 외부에서 예외를 발생시킬 수도, 시키지 않을 수도 있다. Null도 아니다!

    E result = null;
    for(E e: c)
        if(result == null || e.compareTo(result) > 0)
            result = Objects.requireNonNull(e);

    return Optional.of(result); // result 를 참조하는 Optional 타입을 반환한다.
}

 

 

빈 옵셔널은 Optional.empty(), 값이 든 옵셔널은 Optional.of(value)로 처리한게 끝이다. 이 방식은 위 방식과는 다르게 호출한 클라이언트에서 Optional 값에 대한 처리가 가능하다. 원하는 예외메시지를 발생시킬수도, 발생시키지 않을수도 있고, 더 나아가 기본 값을 넣을 수도 있다. 앞선 코드보다 훨씬 유연하고 깔끔한 것을 볼 수 있다.

Integer maxValue = max2(list).orElse(0); // 빈 옵셔널일 경우 0으로 설정

//클라이언트에 따른 예외 메시지 변경
Integer maxValue2 = max2(list).orElseThrow(() -> new IllegalArgumentException("요청 리스트가 비어있습니다."));

 

 

참고로 Optional.of(value)에 null을 넣으면 NPE가 발생하므로 주의해야한다. 또한 옵셔널을 반환하는 메서드는 절대 null을 반환하면 안된다. 옵셔널을 도입한 취지를 저버리는 행위이기 때문이다.

 


4. Optional 을 사용해야하는 기준

그렇다면 null, 예외를 던지는 대신 옵셔널을 선택하는 기준은 뭘까? 옵셔널은 검사 예외와 취지가 비슷하다. 즉, 반환 값이 없을 수도 있음을 API 사용자에게 명확히 알려준다. 반환 값이 없을 경우에 대한 처리를 사용자가 작성해야하므로 null 보다 안전하고 깔끔하게 처리할 수 있다.

 

클라이언트 입장에서 이를 사용한다면 클라이언트는 옵셔널에 대한 처리를 클라이언트 코드에서 선택하면 된다.

1) 기본 값 설정

int maxValue = max2(list).orElse(0);  // 기본 값 설정

 

2) 원하는 예외 설정

// 원하는 예외 설정
String maxString = max2(wordList).orElseThrow(() -> new RuntimeException("단어 리스트가 비어있습니다. 리스트를 확인해주세요"));

 

3) 항상 값이 유효하다고 가정할때 설정

int value = max2(list).get(); // 항상 값이 있다고 가정하고 get!

 

 

이 외에도 filter, map, ifPresent 등 다양한 메서드들을 지원하고 있다. 앞선 기본 메서드로 처리가 힘들다면 API 문서를 참조해 문제를 해결해줄 수 있는 메서드가 있는지 찾아보자. 만약 적합한 메서드를 찾지 못했다면 isPresent 메서드를 활용할 수 있다. isPresent 메서드는 가스레인지의 안전벨브 역할로, 옵셔널 값이 채워져있다면 true, 비어있다면 false를 리턴한다. 

 

자바 9 버전부터 지원하는 Optional.map 메서드를 사용해 원하는 타입을 갖는 Optional 객체로 변환할 수도 있다. 아래는 Integer 타입이 들어있는 최대값을 받아 String 타입으로 변환한다. 값이 없을 경우에 대해 "N/A" 값이 반환되도록 orElse 체인 메서드로 처리했다.

String res = max2(list).map(val -> Integer.toString(val)).orElse("N/A");

 

 

참고로 이를 지원하지 않는 자바 8 버전의 경우 아래와 같이 작성할 수 있다. isPresent로 필터링 후 get으로 빼온다.

streamOfOptionals
    .filter(Optional::isPresent)
    .map(Optional::get)

 

 

자바 9에서는 Optional 에 stream() 메서드가 추가되었다. Optional을 stream으로 변환해주는 어댑터다. 옵셔널에 값이 있으면 그 값을 원소로 담은 스트림으로 한단계 벗겨주는 것이다. 어찌됐든 이런 저런 메서드들을 많이 지원하니 찾아보는 것을 권장한다.

 


5. 반환값으로 옵셔널을 사용했을때의 단점

결합도를 낮추고 유연성과 코드의 깔끔함을 더해주지만 구조상 단점이 있다. 객체를 박싱하는 옵셔널 특성 상 박싱하고자 하는 객체가 많을수록 Optional 객체를 생성하고 박싱하는 비용이 발생한다. 언박싱할때도 비용이 발생하는 건 마찬가지이다. 때문에 리스트, 스트림, 배열 등에 사용할 요소들에 대해 별 생각없이 Optional 을 사용한다면 성능 측면에서 문제가 발생할 수 있다. 성능이 중요한 상황에서는 옵셔널을 사용하지 않는 것이 좋다.

 


6. 기본 타입을 담는 옵셔널도 있다.

int, long, double 과 같은 기본 타입에 대해 Optional 을 사용할 땐 Integer, Long, Double 과 같은 참조 타입으로 박싱하여 사용하지 말고 OptionalInt, OptionalLong, OptionalDouble 과 같은 옵셔널 타입을 사용하자. 박싱된 기본 타입을 담은 옵셔널을 반환하는 일은 없도록 해야한다.

 


7. 정리

반환 값이 없을 수도 있는 메서드라면 옵셔널 사용을 고려해야한다. 하지만 성능 저하가 뒤따르니, 성능에 민감한 메서드라면 null을 반환하거나 예외를 던지는 편이 나을 수 있다.

반응형
반응형

개요

메서드 시그니처를 신중히 설계하라. 개별 아이템으로 두기 애매한 API 설계 요령들을 정리한 아이템이다.


1. 메서드 이름을 신중히 짓자

메서드 명은 표준 명명 규칙을 따라야 한다. 아래는 점프 투 자바에서 제공한 명명 규칙이다.

https://wikidocs.net/1936

 

02-03 이름 짓는 규칙

자바 코드를 작성하면서 클래스, 메서드, 변수 등의 이름을 지을 때 개발자들이 가장 고민한다. 하지만 규칙을 알아 두면 부담을 크게 줄일 수 있다. 여러 사람이 프로그래밍할 때 …

wikidocs.net

 

첫째, 메서드 명은 동사로 한다.

둘째, 메서드 명은 소문자로 시작한다.

셋째, 카멜케이스 방식을 사용한다.

 

이 사항을 기본적으로 지키면서 이해하기 쉽고 일관되게 네이밍을 지어야 한다.

실제로 협업 간에는 변수, 클래스, 메서드 등에 대한 네이밍 컨벤션을 미리 정리해놓고 사용하기도 한다. 


2. 편의 메서드를 너무 많이 만들지 말자

편의메서드
편의를 위한 메서드로 클래스의 책임에 충실한 클래스와는 거리가 멀다는 특징이 있다.
예를들어 게시글을 CRUD 하는 BoardService 클래스에 max, min, extractString 과 같이 편의를 위한 메서드가 있다면 편의 메서드로 볼 수 있다.

 

메서드가 너무 많은 클래스는 유지보수가 어렵다. 인터페이스도 마찬가지이다. 아주 자주 쓰일 경우에만 별도의 약칭 메서드를 만들고, 그 외에는 편의 메서드를 만들지 않는게 좋다.

 


3. 매개변수 목록은 짧게 유지하자

매개변수는 4개 이하가 적당하다. 만약 긴 매개변수 목록을 유지해야한다면 아래 내용 적용을 고려해야한다.

 

첫째, 여러 메서드로 쪼갠다.

둘째, 매개변수 여러 개를 묶어주는 정적 멤버 클래스를 만들어 활용한다.

셋째, 빌더 패턴과 정적 멤버클래스를 함께 사용한다. 매개변수가 많거나 일부를 생략해도 될때 좋다.

 


4. 매개변수의 타입으로는 클래스보다 인터페이스를 사용하자

예를들어 HashMap을 매개변수로 사용하는 대신 Map을 사용할 경우 HashMap 뿐만 아니라 TreeMap, ConcurrentHashMap 등 다양한 타입의 구현체 클래스를 전달할 수 있다.

인터페이스 대신 클래스를 사용하면 클라이언트에게 특정 구현체만 사용하도록 제한하는 꼴이다.

 


5. boolean 보다는 원소 2개짜리 열거 타입이 낫다.

* 단, 메서드 이름상 boolean을 받아야 의미가 더 명확할 때는 예외다.

 

열거 타입을 사용하면 코드를 읽고 쓰기가 쉽고, 확장하기도 용이하다.

다음은 화씨온도와 섭씨온도를 원소로 정의한 열거타입이다.

public enum TemperatureScale { FAHRENHEIT, CELSIUS }

 

온도계 클래스의 정적 팩터리 메서드가 이 열거 타입을 입력받아 적합한 온도 인스턴스를 생성해준다고 생각해보자. Thermometer.newInstance(true) 보다는 Thermometer.newInstance(TemperatureScale.CELSIUS) 가 명확하다. 만약, 나중에 캘빈 온도도 지원해야 한다면 열거타입에 추가만 하면 된다.

반응형
반응형

 

자바 방패를 들고 있는 아기 바다표범

 

개요

자바는 C, C++ 에 비해 메모리 관리 측면에서 안전하다. 또 자바로 작성한 클래스는 불변식이 지켜진다. 개발자가 원한다면 어떤 객체 안의 멤버필드를 특정 조건을 만족하도록 클래스를 설계하고, 불변성을 갖도록 할 수 있다.

하지만 아무런 노력 없이 이러한 클래스를 만들 수 있는건 아니다. 프로그래머의 실수로 인해 의도하지 않는 변경이 일어날 수 있다. 외부에 의해 불변식이 깨질 수도 있다는 뜻이다.

 

일반적으로 객체를 생성할 때 수정자와 같은 장치가 없다면 외부에서 내부를 수정하는 것을 막아놨음을 의미한다. 하지만 자기도 모르게 내부를 수정하도록 허락하는 경우가 생긴다.


불변식을 지키고 싶었던 클래스

반응형

Period 클래스를 통해 객체를 생성할 경우 end 값이 start 보다 느리도록 설정된다. 유효성 검사도 하기때문에 문제가 없어보이지만 start나 end 값을 변경하여 불변식을 깨트릴 수 있다.

public final class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end){
        if(start.compareTo(end) > 0){
            throw new IllegalArgumentException(start +" 가 "+ end +" 보다 늦다.");
        }

        this.start = start;
        this.end = end;
    }

    public Date start(){
        return start;
    }

    public Date end(){
        return end;
    }
}

 

 

불변식을 헤치고 싶었던 외부 클래스

Date start = new Date();
Date end = new Date();

Period p = new Period(start, end);

// start 와 end 는 변경되....엔다
start.setTime(10);

 

외부 클래스에 의해 불변식이 깨져버렸다.

위와 같이 변경이 가능한 이유는 Date 클래스가 가변 객체이기 때문이다.

 

가변객체
클래스의 인스턴스가 생성된 이후 내부 상태 변경이 가능한 객체

 


Date 는 Instant or LocalDateTime 을 사용하자

이와 같은 문제는 자바 8에서 해결됐다. Date 대신 사용 가능한 불변 객체인 Instant나 LocalDateTime이 등장했기 때문이다. 가변적 성질을 가진 Date 사용은 지양하자.

그럼 모든 멤버필드를 불변객체로 사용하는건 어떨까??

 


모든 멤버필드를 불변객체로 사용하는 건 좀...

 위와 같이 불변식을 지키려고 모든 멤버필드를 불변객체로 사용하는 건 힘.들.다. 멤버필드는 충분히 가변 객체일 수도, 커스텀 클래스 타입일 수도 있는데, 이러한 객체를 모두 불변객체로 대체하거나 만드려면 너모 힘들기 때문이다.

그럼 Date 와 같은 가변객체를 사용하는 상황에서 Period 인스턴스의 내부를 보호하려면 어떻게 해야할까?

바로 방어적 복사, defensive copy 를 사용해야 한다.

 

다시금 자바 방패를 들고 있는 아기 바다표범

 

 


Defensive Copy 를 적용하여 인스턴스 내부 보호하기

 

Defensive Copy는 인스턴스 내부 필드를 설정할 때, 파라미터로 온 값을 그대로 할당하는 게 아닌 복사하는 방법이다.

아래와 같이 매개변수로 받은 가변객체에 대해 똑같은 타입의 새로운 객체 생성(복사)한 후 멤버필드에 할당하고 있다.

public final class Period {
    private final Date start;
    private final Date end;

    public Period(Date start, Date end){

        this.start = new Date(start.getTime()); // defensive copy
        this.end = new Date(end.getTime()); // defensive copy

        if(start.compareTo(end) > 0){
            throw new IllegalArgumentException(start +" 가 "+ end +" 보다 늦다.");
        }
    }

    public Date start(){
        return start;
    }

    public Date end(){
        return end;
    }
}

 

 

불변식을 헤쳤던 코드를 적용해보니 아래의 start와 Period 내부에 생성된 start는 엄연히 다른 인스턴스이므로 불변식 지킬 수 있게 되었다.

public static void main(String[] args) {

    Date start = new Date();
    Date end = new Date();

    Period p = new Period(start, end);

    // start는 변경되지 않는다!!
    start.setTime(10);
}

 

 

 

라고 생각했지만 가변 객체는 가변 객체. 어떻게든 멤버필드를 외부에서 가져간다면, 불변식은 깨지게 된다. 아래처럼 말이다.

// start 는 변경된다.
p.start().setTime(10);

 

 

이런 상황에서 불변성을 지키려면 어떻게 해야할까? 멤버 필드에 할당할 때와 마찬가지로, 멤버 필드를 리턴할 때도  defensive copy 를 적용하면 된다. 이로써 Period 인스턴스는 불변식을 지키게 되었다.

public Date start(){
        return new Date(start.getTime());
    }

    public Date end(){
        return new Date(end.getTime());
    }
}

 


방어적 복사본을 유효성 검사 이전에 하자!

 

눈치챘을 지 모르지만 위 코드에서 특이한 부분이 하나 있다. 유효성 검사보다 방어적 복사본을 만드는 코드가 먼저 위치한다. 유효성 검사를 먼저하는게 당연하다 생각할 수 있지만 이유가 있다.

public Period(Date start, Date end){

    this.start = new Date(start.getTime()); // 방어적 복사 먼저
    this.end = new Date(end.getTime()); // 방어적 복사 먼저

    if(start.compareTo(end) > 0){ // 그 다음 유효성 검사
        throw new IllegalArgumentException(start +" 가 "+ end +" 보다 늦다.");
    }
}

 

 

 

이유?!

유효성 검사를 먼저 할 경우 멀티스레드 환경에서 불변식을 헤치는 상황이 발생할 수 있기 때문이다.

유효성 검사를 끝낸 직후 방어적 복사를 하기 전에 다른 스레드에서 파라미터로 들어온 가변객체의 값을 변경이라도 한다면 불변식이 깨져버린다. 

유효성 체크를 먼저하다가 불변식이 깨져버린 상황

 

 

만약 방어적 복사를 유효성 검사보다 선행한다면 어떨까? start.setTime(23) 이 호출되어도 인스턴스 내부에 영향을 미치지 않게 되는 것이다.


방어적 복사의 사용 시기

 

매개변수를 방어적으로 복사하는 목적은 외부로부터 들어오는 매개변수가 가변객체이고, 이를 인스턴스 내부에서 갖게 될때  인스턴스의 불변식을 지키기 위함이다. 인스턴스 내에 멤버필드를 추가할 때에는 '변경될 수 있는 멤버필드인가'를 항상 고려해야한다. 변경되도 상관 없다면 방어적 복사를 할 필요가 없지만, 불변식을 가져야 한다면 방어적 복사를 사용해야 한다. 멤버필드의 불변성을 확신할 수 없을 때도 방어적 복사를 사용하는 것이 좋다.

 


되도록 불변 객체를 조합해 객체를 구성하자

 

불변식을 지키기 위한 코드를 작성하려면 생각보다 많은 고민과 시간을 쏟아야 할것 같지 않은가? 또한 방어적 복사는 멤버필드를 새로 생성하기 때문에 성능 저하가 수반된다. 즉, 불변식을 지키는 클래스를 설계할 때에는 가변 객체보다는 불변 객체를 조합해 구성하는 것이 좋다.

 


정리

불변식을 지키고자 하는 클래스가 매개변수로 받거나 반환하는 멤버필드가 가변객체라면 반드시 방어적 복사(defensive copy)를 해야한다. 클래스 설계시 의도적으로 수정자 메서드를 생성하지 않았다면 '방어적 복사'를 떠올려야 할 때이다.

 

 

 

반응형
반응형

개요

 어떤 메서드를 구현할 때 매개변수가 유효하다는 것을 당연하게 여기곤 한다. 이렇게 가정한 상태에서 비지니스 로직을 구현하곤 한다. 어떤 이들은 이러한 사태를 방지하기 위해 생성자나 메서드 호출 부 앞단에 유효성 검사하는 메서드를 추가하기도 한다.

 어찌됐든, 유효한 매개변수를 당연시하게 될 경우 여러 문제가 발생할 수 있다. 메서드 수행 중간에 개발자가 생각지 못했던 예외가 발생한다거나, 메서드가 잘 수행됐지만 잘못된 값을 반환하거나 하는 등의 문제이다.

 

매개변수로 인한 예외는 문서화하라

public 과 protected 메서드는 매개변수 값이 잘못됐을 때 던지는 예외를 문서화 하길 권장하고 있다. @throws 자바독 태그를 사용하면 된다. 이렇게 문서화를 하는 이유는 유효하지 않은 매개변수에 대한 것을 개발자도 인지하고 있고, 다른 개발자에게도 알려주기 위함이라고 생각한다. 왜? 접근제어자에 따라 어디서든 호출될 수 있기 때문이다.

 

    /**
     * 현재 값 mod m 값을 반환한다.
     * 항상 음이 아닌 BigInteger를 반환한다.
     * 
     * @param m 계수(양수여야 한다.)
     * @return 현재 값 mod m
     * @throws ArithmeticException m이 0보다 작거나 같으면 발생한다.
     */
    public BigInteger mod(BigInteger m){
        if(m.signum() <= 0)
            throw new ArithmeticException("계수(m)은 양수여야 합니다. "+ m);

        return m.mod(m);
    }

 

 

m 이 null인 경우도 있잖아요?? 그럼 NullPointException 도 추가해야하는거 아닌가요?

추가하지 않는다. 그 이유는 이 설명을 mod 와 같은 개별 메서드가 아닌 매개변수 자체, 즉, BigInteger 클래스 수준에서 기술했기 때문이다. 클래스 수준 주석은 그 클래스의 모든 public 메서드에 적용되므로 각 메서드에 일일이 기술하는 것보다 깔끔하다.

 

아래는 BigInteger 클래스 내에 주석을 보면 [이 클래스의 모든 메서드와 생성자는 입력 매개변수에 대해 null 개체 참조가 전달되면 NullPointerException 이 발생한다]고 기재되어 있다.

* <p>All methods and constructors in this class throw
* {@code NullPointerException} when passed
* a null object reference for any input parameter.

 

어찌됐던 Null 검사는 해야하지 않나요?

 

순수하게 null을 체크하고자 한다면 자바 7에 추가된 java.util.Objects.requireNonNull 메서드를 사용하면 된다. a != null 과 같은 방법보다 훨씬 유연한 방법이다. 원하는 예외 메시지를 지정할 수도 있고, 입력을 그대로 반환하니 이를 활용할 수도 있다. 반환 값을 무시하고 순수한 null 검사 목적으로 사용해도 된다.

 

Null Check + 예외 메시징 처리

Integer value = null;
Objects.requireNonNull(value, "value 값이 null입니다.");

 

Null Check 와 예외 메시징 처리를 동시에 수행한다.

 

 

체크 입력 값 그대로 반환

Integer value2 = 3;
Integer value3 = Objects.requireNonNull(value2, "value 값이 null입니다.");
System.out.println(value3); // 3

 

private 는 매개변수로 인한 예외를 문서화하지 않나요?

굳이 문서화할 필요가 없다. 왜냐하면 public 이나 protected 는 외부에서 호출이 가능하다. 특히 public 은 어디서든 호출이 가능하기 때문에 매개변수로 인한 예외 가능성이 다분하다. 이에 반해 private 는 클래스 내에서만 호출 가능하다. 즉, 유효한 매개변수가 들어온다는 것을 충분히 보증할 수 있고, 또 그렇게 해야 한다. 이런 상황에서는 예외가 아닌 단언문(assert)를 사용해 매개변수 유효성을 검증할 수 있다.

 

private static void sort(long a[], int offset, int length){
    assert a != null;
    assert offset >= 0;
    assert length >= 0;
    ...
}

 

여기서 핵심은 이 단언문들은 자신이 단언한 조건이 무조건 참이라고 선언하는 것이다. 단언문은 몇가지 면에서 유효성 검사와 다르다. 첫째, 실패하면 AssertionError를 던진다. 둘째, 런타임에 아무런 효과도, 성능 저하도 없다.

 

그럼 무조건 매개변수 유효성 검사를 해야하나요?

예외는 있다. 유효성 검사 비용이 지나치게 높거계산 과정에서 암묵적으로 검사가 수행될 때다. 예를들어 Collections.sort(List) 처럼 객체 리스트를 정렬하는 메서드의 경우 리스트 안의 객체들은 모두 상호 비교될 수 있어야 하며, 이 과정에서 사실상 유효성 검사가 이루어진다. 그 객체와 비교할 때 비교될 수 없는 타입의 데이터가 있을 경우 ClassCastException 이 발생하기 때문이다.

 sort() 메서드 실행 초반부에 파라미터로 들어온 List 에 대한 유효성 검사를 한다면, 사실상 두 번의 유효성 검사를 한 격이다. 단, 이런 암묵적 유효성 검사의 너무 의존하는 것은 좋지 않다.

 

정리

 메서드나 생성자를 작성할 때 매개변수들에 어떤 제약이 있을지 생각해야 한다. 그 제약들을 문서화하고 메서드 코드 시작 부분에서 명시적으로 검사해야 한다. 이 노력은 유효성 검사가 실제 오류를 처음 걸러낼 때 보상받을 수 있다. 하지만 이번 아이템을 "매개변수에 제약을 두는게 좋다"로 해석하면 안된다. 메서드는 최대한 범용적으로 설계하는게 좋기 때문이다. 

반응형
반응형

개요

 스트림을 처음 접하면 이해하기 어렵고, 스트림으로 뭔가를 구현하더라도 이로인한 이점을 파악하기 어렵다. 스트림은 함수형 프로그래밍에 기초한 패러다임이기 때문이다. 스트림의 이점을 이해하고 사용하려면 이 패러다임까지 함께 이해해야한다.

 


함수형 프로그래밍이란?

함수형 프로그래밍(functional programming)은 자료 처리를 함수의 계산으로 취급하고
상태와 가변 데이터를 멀리하는 프로그래밍 패러다임

 

데이터 처리를 원시적인 계산 코드가 아닌 정형화된 함수로 처리하는 것이다. 사용하는 함수는 상태 값이나 가변 데이터를 멀리하도록 구현해야하는데 이러한 함수를 '순수 함수'라고 한다.

 

입력만이 결과에 영향을 주는 함수. 즉, 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는 함수를 순수 함수라 한다. - 이펙티브 자바 中

 

 

순수 함수는 가변 및 상태 값을 참조하지 않기에 입력 값에 의해 결과 값이 정해지는 특성을 갖는다. 만약 상태 값을 참조하게 된다면 입력 값에 의해 결과 값이 달라지는 '부작용'이 발생할 수 있다. 결국 이번 주제인 '부작용 없는 함수''순수 함수'를 말한다.

 

순수 함수는 개발자가 커스텀하여 만들 수도 있지만, 스트림 API에서 제공하는 함수를 사용하는 것도 좋은 방법이다. 스트림에서 제공하는 공식적인 함수이므로 부작용이 없고, 성능 최적화가 되어있으며, 40가지 이상의 다양한 함수를 제공하기 때문이다.

 

먼저 스트림 API를 사용했지만, 저자는 스트림 코드라고 인정하지 않는 코드를 살펴보자.


단어 빈도표 예제

 

다음은 텍스트 파일에서 단어별 수를 세어 빈도표로 만드는 코드이다. 스트림, 람다, 메서드 참조를 사용했고, 결과도 올바르지만 이를 스트림 코드라 하지 않는다. 스트림을 잘못 사용했고, 사용하지 않았을 때보다 가독성이 떨어지기 때문이다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word ->
            freq.merge(word.toLowerCase(), 1L, Long::sum));
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

 

문제는 외부 상태인 빈도 수(freq)를 수정하는 람다 부분이다. 이 코드의 모든 데이터 처리 작업이 최종 연산(종단 연산)인 forEach 구문에서 일어나고 있는데, forEach는 스트림 계산 결과를 보고할 때만 사용하는 것이 권장된다. 연산 결과를 보여주는 일 이상을 하니 좋은 코드라 할 수 없다.

 


스트림의 최종 연산이 뭔가요?

 

최종 연산 (종단 연산)
스트림의 요소를 '소비' 해가며 결과를 만들어내는 연산이다. 소비를 하기 때문에 최종 연산 후 스트림은 재사용할 수 없는 빈 상태가 된다.

 

더 이상 추가적인 연산을 할 수 없는 상태이기에 최종 연산이라고 한다. 최종 연산에 사용되는 대표 함수를 몇개 기재한다.

함수 내용
void forEach() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 X)
void forEachOrdered() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 O)
long count() 요소 개수 반환
Optional max() 요소 중 최대 값을 참조하는 Optional 반환
Optional min() 요소 중 최소 값을 참조하는 Optional 반환
Optional findFirst() 첫번째 요소를 참조하는 Optional 반환
Optional findAny() 첫번째 요소를 참조하는 Optional 반환 (병렬 스트림에서는 첫번째 요소 보장 X)
boolean allMatch() 모든 요소가 특정 조건을 만족하는지 여부를 반환
boolean anyMatch() 하나의 요소라도 특정 조건을 만족하는지 여부를 반환
boolean noneMatch() 모든 요소가 특정 조건을 불만족 하는지에 대한 여부를 반환
reduce() 요소를 하나씩 빼며 지정된 연산 처리 후 결과를 반환
collect() 요소들을 컬렉션으로 반환

 

 


 

최종 연산이 나와서 말인데... 중간 연산은 뭔가요?

 

중간 연산
스트림 생성부터 시작해서 최종 연산 직전까지의 연산이다. 최종 연산과 달리 체이닝 메서드 방식으로 여러 개의 중간 연산이 수행될 수 있다.

 

함수 내용
Stream<T> distinct() 요소의 중복 제거
Stream<T> filter() 요소에 대한 필터링 조건 추가
Stream<T> limit() 요소 개수 제한
Stream<T> skip() 처음 n개의 요소 건너뛰기 
Stream<T> sorted() 요소 정렬
Stream<T> peek() 요소에 대한 작업 수행, forEach와는 다르게 요소를 소비하지 않음

 


다시 단어 빈도표 예제로

다시 돌아가서 위 코드를 올바른 스트림 코드로 작성한다면 아래와 같다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

 이 코드는 collector를 사용하는데 collector는 스트림을 사용하려면 꼭 배워야하는 개념이다. 참고로 collector는 java.util.stream.Collectors 클래스를 말하며 groupingBy는 Collectors 클래스가 지원하는 static 메서드이다. 앞서 언급했던 '부작용' 없는 함수 중 하나인 것이다. groupingBy 메서드는 요소들을 그룹핑하여 Map 타입으로 반환한다. counting 메서드는 동일한 단어의 수를 반환하는데, 최종적으로 key는 소문자 단어, value는 단어의 수를 가진 Map이 생성된다.

 

collector는 이러한 메서드를 40개 이상 지원한다. 복잡한 세부 사항을 잘 몰라도 사용 가능하다.

이러한 메서드를 활용해 빈도표에서 가장 흔한 단어 10개를 뽑아내는 스트림 파이프라인을 작성해보자.

 

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toString, counting()));

    List<String> list = freq.keySet().stream()
            .sorted(comparing(freq::get).reversed())
            .limit(10)
            .collect(Collectors.toList());

    list.forEach(System.out::println);
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

comparing 메서드는 비교할 키를 받아 비교자를 생성하는 메서드이며, freq의 value 값을 비교 키로 하기 위해 freq::get 메서드를 파라미터로 전달하고 있다. 그 후 내림차순 정렬을 위해 reversed() 메서드를 호출한다.

 


toMap 사용해보기

collector에서 제공하는 메서드 중 toMap(keyMapper, valueMapper)은 스트림으로 가장 간단하게 맵을 만들 수 있는 메서드이다. 사용법도 쉬운게 키에 매핑하는 함수, 값에 매핑하는 함수를 인수로 넘기면 된다.

List<String> values = List.of("one","two","three","four");
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

키 함수는 s -> s 로 List의 원소 값이 그대로 들어가고, 벨류 함수는 String::length 로 원소의 길이가 들어가도록 하였다. 하지만 만약 키가 중복될 경우 파이프라인에서 예외가 발생한다.

List<String> values = List.of("one","two","three","four","one");// 키 중복
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){ // IllegalStateException 발생 !
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

이러한 충돌을 다루는 전략으로 머지 함수를 제공한다. 함수의 형태는 BinaryOperator<U> 이며, 여기서 U는 해당 맵의 값 타입이다. 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다. 예컨데 병합 함수가 새로운 값으로 대체하는 함수라면 기존 값 대신 새로운 값으로 대체되도록 하려면 아래와 같이 작성할 수 있다.

Map<String, Integer> map = values.stream()
	.collect(Collectors.toMap(s -> s, String::length, (oldVal, newVal) -> newVal));

 

groupingBy는 입력으로 분류 함수(classifier)를 받고 출력으로 원소들을 카테고리별로 모아 놓은 맵을 담은 collector를 반환한다. 분류 함수는 입력 받은 원소가 속하는 카테고리를 반환한다. 그리고 이 카테고리가 해당 원소의 맵 키로 쓰이며, 해당 카테고리가 속하는 원소들을 담은 리스트는 값으로 쓰이게 된다.

List<String> values = List.of("one","two","three","four","five","six","seven");

Map<Integer,List<String>> map = values.stream().collect(groupingBy(s -> s.length()));

for(Map.Entry<Integer, List<String>> entry : map.entrySet()){
    System.out.println(entry.getKey());

    for(String str : entry.getValue()){
        System.out.println(str);
    }
}

 


정리

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수에 있다. 이러한 함수는 직접 만들어도 되지만 스트림에서 제공하는 함수가 매우 다양하기 때문에 이를 사용하는 것도 좋은 방법이다.

 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용하고 계산 자체에는 이용하지 말자.

 마지막으로 스트림을 잘 사용하려면 collector를 잘 알아둬야한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

반응형
반응형

개요

이 게시글은 자바 IO, NIO에 대해 정리하려고 했으나, 공부를 하다보니 InputStream, OutputStream 및 Stream과 같은 용어가 많이 등장했다. 곧 이에 대한 개념이 잡히지 않았되지 않다는 것을 알았고, 필요성을 느껴 이에 대해 정리해보았다. InputStream과 OutputStream에 대해 이해해보기 전 Stream의 개념에 대해 알아보자.

 

Stream이 뭐에요?

스트림이란 데이터, 패킷, 비트 등의 일련의 연속성을 갖는 흐름을 의미한다.

 

스트림과 비슷한 용어인 '스트리밍'을 생각해보자. 유투브에서 제공하는 영상들은 '동영상 스트리밍' 형태로 보게 되는데, 요즘은 영상 화질도 좋아져서 동영상의 용량이 어마어마하다는 것은 누구나 알것이다.

1.2GB 크기의 유투브 영상 추상화

 

하지만 이렇게 큰 용량을 가진 영상을 우리는 오랜 기다림 없이 바로 볼 수 있다. 그 이유가 뭘까? 서버에서 영상파일을 작은 단위로 쪼개어 클라이언트에게 전달하기 때문이다. 이처럼 데이터를 잘라 연속적인 데이터의 흐름 형태로 전달하는 것을 스트림이라고 한다.

 

네트워크가 불안정할 경우 영상이 끊긴 경험이 있을 것이다. 이 모두 영상 데이터에 대해 스트림 형태로 전달받다가 중간에 네트워크 문제가 생겨 다음 데이터를 전달을 받지 못해 발생하는 현상인 것이다. 만약 스트림이 아닌 영상 데이터를 한번에 전달받는 방식이었다면 1.2GB 크기의 유투브 영상을 모두 전달받기 전까지 영상을 재생할 수 없을 것이다.

 

InputStream이 뭐에요?

java.io 패키지에서 제공하는 이 InputStream는 데이터를 입력받기위한 스트림을 제공하는 추상 클래스이다.  입력받는다는 것은 데이터를 읽어오는 것이다. 

서브 클래스로 ByteArrayInputStream, FileInputStream, AudioInputStream 클래스 등 다양한 InputStream 클래스가 있는 것으로 보아 바이트 배열, 파일, 오디오 파일 등 다양한 형태의 데이터에 대한 스트림을 제공한다는 것을 알 수 있다. 한번 사용해보자.

 

Input, Output이 헷갈려요... 😫

Java 입장에서 생각해보자. Java 입장에서 Input은 들어오는 것이니 데이터를 읽어오는 것, Output은 나가는 것이니 데이터를 쓰는 것으로 이해하면 쉽다.

 

 

InputStream을 통해 텍스트 파일 읽어보기

// C:/testFile/txtFile.txt
aaaaaaaa
bbbbbbb
cccccccc
ddddddddd
eeeeeeee
ffffffff

 

위 텍스트 파일을 만든 후 InputStream으로 읽어오는 것을 테스트해보았다. 참고로 try/resources 를 사용해 close 메서드를 자동호출하도록 하였다. println 메서드를 통해 결과를 출력하면 '97'이 출력된다.

@Test
void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        System.out.println(inputStream.read()); // 97 출력
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

 

 

아까 말했듯이 Stream은 잘게 잘린 데이터의 연속적인 흐름이다. FileInputStream을 사용하면 파일 데이터를 byte 타입의 쪼개진 데이터들로 받을 수 있고, read() 메서드는 스트림을 통해 1byte씩 읽는 메서드이기 때문에 97이라는 값이 출력된 것이다. 참고로 97은 'a' 문자의 아스키코드이다.

 

read()
Reads the next byte of data from the input stream.
= 입력 스트림에서 데이터의 다음 바이트를 읽습니다.

 

 

그럼 read() 메서드를 계속 호출하면 어떻게 될까? 🙄

 

아래와 같이 더 이상 읽을 데이터가 없는 시점부터 -1이 출력된다. 스트림이 비어있는 것이다.

@Test
void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        
        System.out.println(inputStream.read());
        System.out.println(inputStream.read());
        System.out.println(inputStream.read());
        ...
        ...
        System.out.println(inputStream.read()); //-1 출력
        System.out.println(inputStream.read()); //-1 출력
        System.out.println(inputStream.read()); //-1 출력
       
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

 

이러한 특성때문에 일반적으로 스트림을 통해 데이터를 read 할때에는 while과 같은 반복문을 사용해 -1을 체크하는 구문이 들어가게 된다.

void fileInputStreamTest(){
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){

        int i = 0;
        while((i = inputStream.read()) != -1){
            System.out.write(i);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
}

 

출력결과

 


잠깐, System.out.print와 System.out.write 의 차이

 

System.out.print는 다양한 타입의 데이터를 '텍스트 형식'으로 출력하도록 설계된 메서드이다. 예를들어 System.out.print(97)을 사용하면 숫자 97이 그대로 출력된다.

 

이에반해 System.out.write는 바이트 데이터를 출력하기 위해 사용하는 메서드이다. 메서드로 전달된 정수 값을 바이트로 변환하여 출력하기때문에 아스키 코드에 해당하는 문자를 출력한다. 예를들어 System.out.write(97)을 사용하면 97은 'a'로 변환되어 출력된다.

 

print는 사용자 친화적인 텍스트 출력에, write는 바이트 단위의 데이터 처리와 출력에 사용된다.

 


read() 메서드는 1byte씩 스트림으로 전달한다? 너무 느리지않나요? 🤔

1 바이트씩 처리하는 InputStream.read()

 

 

@Test
void fileInputStreamTest(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i = 0;
        while((i = inputStream.read()) != -1){
            //System.out.write(i);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms"); // 2589ms
}

 

실제로 10만줄의 텍스트파일을 만들고, 불필요한 로직(System.out.write)을 제거한 후 실행해보니 온전히 InputStream을 통해 데이터를 읽는데만 약 2.5초(2589ms)가 걸렸다. Stream에서 1byte 씩 빼다보니 속도가 느린 것이다. 만약 Stream을 통해 데이터를 읽어올 때 1byte보다 큰 크기의 byte 데이터들을 읽어들인다면 속도가 개선되지 않을까?

 

 

b 바이트 만큼의 버퍼 단위로 처리하는 InputStream.read(byte[] b)

 

 
read(byte[] b)
Reads some number of bytes from the input stream and stores them into the buffer array b.
= 입력 스트림에서 일부 바이트를 읽고 이를 버퍼 배열에 저장합니다.

 

 

찾아보니 버퍼를 활용하는 read(byte[] b) 메서드가 있었다. 256byte의 버퍼 사이즈만큼 스트림에서 데이터를 읽도록 하니 속도가 12ms로 개선되었다. 아무래도 1byte씩 읽는 방식보다는 버퍼를 활용하는 방식을 사용하는 것이 좋아보인다. 

@Test
void fileInputStreamTest2(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i = 0;
        byte[] buf = new byte[256];
        while(inputStream.read(buf) != -1){
            //System.out.write(buf);
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms"); // 12ms
}

 

 

출력했더니 데이터가 누락됐어요! 😨

이제 작성한 코드에 있던 주석을 해제하고 읽어온 데이터를 콘솔에 출력해보았다. 그런데 ff 문자 두개가 누락된것 같다. 예외도 발생하지 않았다. 어째서 이런일이 발생하는 것일까? 

ff 문자가 누락된 모습

 

 

알고보니 데이터가 누락된게 아니었다. 이는 버퍼를 사용하는 메커니즘을 이해하지 못한 필자의 착각이었다. read(byte[] b) 메서드를 호출하면 직전 buffer에 스트림에서 읽은 데이터가 덮어 씌워진다. 필자는 buffer가 자동으로 비워질줄 알았는데 덮어 씌워지는 것이었다. 누락보다는 추가됐다고 할 수 있겠다.

 

Buffer는 있는 그대로 덮어 씌워버려요~

 

 

이때문에 InputStream.read(byte[] b) 메서드를 사용하려면 앞선 예제처럼 사용하면 안된다. buffer에서 read한 사이즈만큼 처리하는 로직을 따로 구현해야 한다.

앞서 read() 메서드는 스트림을 통해 읽은 바이트를 그대로 리턴한다. 이에반해 read(byte[] b) 메서드는 읽은 바이트의 수를 리턴한다. 이를 활용하여 i와 같은 임시 변수를 만든 후 읽은 바이트 수를 저장하고, 이 길이만큼 버퍼에서 읽도록 구현하자.

@Test
void fileInputStreamTest2(){
    long start = System.currentTimeMillis();
    try(InputStream inputStream = new FileInputStream("C:/testFile/txtFile.txt")){
        int i;
        byte[] buf = new byte[256];
        while((i = inputStream.read(buf)) != -1){
            for(int len = 0; len<i; len++){
                System.out.write(buf[len]);
            }
        }
    }catch (IOException e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 


OutputStream은 뭐야?

OutputStream은 데이터를 출력하기 위한 스트림을 제공하는 추상 클래스이다. 자바 입장에서 데이터를 나가게 하므로 외부 파일에 데이터를 쓰는 것이라고 생각하면 쉽다.

 

이것도 마찬가지로 write(byte b) 메서드를 통해 1byte씩 출력 스트림을 통해 데이터를 전달할 수 있다.

@Test
void fileOutputStreamTest(){

    byte[] bytes = {97,97,97,97,97};

    try(OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt")){
        for(byte b : bytes){
        	outputStream.write(b);
        }
        outputStream.flush();
    }catch (Exception e){
        e.printStackTrace();
    }

}

 

 

txtFile.txt에 97을 가진 byte 배열을 쓰기(쓰기작업) 위해 write() 메서드를 호출한다. 그럼 txtFile.txt에는 97에 해당하는 아스키 코드 값인 a가 5개 작성된다.

OutputStream.write() 를 통해 aaaaa를 넣어보아요

 

 


1Byte씩? 이것도 느리지않나요? 🤔

속도 테스트를 위해 bigFile이라는 많은 데이터를 가진 텍스트파일을 만들고 이 byte 값을 OutputStream을 통해 전달해보았다. 텍스트 내 데이터를 복사하는 것이다. 첫번째는 InputStream을 통해 읽어들인 byte 데이터를 1byte씩 write하고, 두번째는 버퍼 사이즈만큼 write하도록 하였다. 결론부터 말하면 느렸고, 버퍼를 활용하는 것이 훨씬 빠르다.

 

1Byte씩 write

2654ms 초가 걸렸다. 1byte씩 Stream을 통해 데이터를 전달해서 그런지 속도가 느리다.

@Test
void fileOutputStreamTest2(){
    long start = System.currentTimeMillis();
    try(
            OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt");
            InputStream inputStream = new FileInputStream("C:/testFile/bigFile.txt"))
    {
        byte[] buf = new byte[256];
        int i;
        while((i = inputStream.read(buf)) != -1){
            for(int len = 0; len<i; len++){
                outputStream.write(buf[len]); // 1byte씩 write
            }
        }
    }catch (Exception e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 

 

Buffer write

25ms가 걸렸다. InputStream 때와 마찬가지로 Buffer를 사용하니 스트림으로의 쓰기 작업 횟수가 줄어들어 속도가 굉장히 빨라진 것을 확인할 수 있었다.

@Test
void fileOutputStreamTest3(){
    long start = System.currentTimeMillis();
    try(
            OutputStream outputStream = new FileOutputStream("C:/testFile/txtFile.txt");
            InputStream inputStream = new FileInputStream("C:/testFile/bigFile.txt"))
    {
        byte[] buf = new byte[256];
        int i;
        while((i = inputStream.read(buf)) != -1){
            outputStream.write(buf,0,i); // 버퍼의 0부터 읽은 사이즈까지 한번에 write
        }
        outputStream.flush();
    }catch (Exception e){
        e.printStackTrace();
    }
    long end = System.currentTimeMillis();
    System.out.println("걸린시간 :"+(end - start) +"ms");
}

 

 

 

InputStream, OutputStream을 사용할때 무조건적으로 예제에서 제공하는 코드를 사용하다가는 큰코다칠 수 있다. 버퍼를 적극 활용하고 입출력에 대한 실행 시간을 고려해보는 습관을 갖자.

반응형
반응형

Jenkins sshPublishers의 removePrefix가 뭔지 잘 이해되지 않아 정리합니다. 추가로 remoteDirectory, sourceFiles 설정도 함께 정리하였습니다.

 

빌드 후 조치의 SSH Publishers 설정 🤗

예제로 사용할 Send build artifacts over SSH의 설정입니다.

빌드 후 조치 - SSH Server 설정

 

 

Source files

Source files는 로컬 서버에서 원격지 서버로 전송할 파일을 의미합니다. 주의할 점은 위와 같이 /build/libs/*.jar를 입력할 경우 build/libs에 있는 jar 파일만 이동되는게 아닙니다. /build/libs 폴더도 이동됩니다. 😯

 

/build/libs/*.jar 가 통으로 이동됨

 

 

remoteDirectory

빌드 후 조치 - Remote directory

 

ssh로 연결한 원격 서버의 작업 디렉토리를 의미합니다. Source files에 입력한 파일들이 이 작업디렉토리로 이동됩니다. 앞서 /build/libs/*.jar 파일들은 Remote directory에 입력한 namevalue 디렉토리 내에 위치한 것을 확인할 수 있습니다. 참고로 작업 디렉토리의 기준이 되는 루트 디렉토리는 Jenkins의 시스템 설정에 추가한 SSH Server 의 Remote Directory 입니다.

SSH Servers 설정

 

 

만약 /home/sksim/application/namevalue 라는 폴더로 파일들을 복사하고 싶다면 Remote directory에 /application/namevalue를 입력하면 됩니다.

 

removePrefix

지금은 jar 파일과 build/libs 디렉토리를 함께 전송하고 있습니다. 사실 저는 jar 파일만 이동시키면 됩니다. build/libs라는 디렉토리까지 이동시킬 필요가 없는 것입니다. 즉, Source files 에 포함된 파일들 중 전송시키고 싶지 않는 Prefix 경로를 적어주면 removePrefix에 적어주면 Prefix에 해당하는 파일이나 디렉토리는 이동되지 않습니다. /build/libs를 입력하면 *.jar 에 해당하는 파일만 이동되는 것입니다.

 

Remove prefix에 /build/libs를 입력

 

 

/build/libs 경로를 제외한 Source Files 이동

 

반응형
반응형

프로세스란 뭔가요? 🧐

프로세스의 개념은 프로그램과 관련 있습니다. 프로그램은 하드웨어에 '정적 상태'로 저장되어 있습니다. 누군가 실행시키지 않는 한 그 상태를 유지합니다. 그럼 프로그램이 실행되어 '동적 상태'로 되는 것은 무엇일까요? 이게 바로 프로세스입니다. 프로그램이 실행되어 메모리에 올라온 상태를 프로세스라고 합니다.

 

 

프로세스는 메모리에 올라간다!

프로그램이 실행되면 운영체제는 프로세스를 메모리의 적당한 위치로 가져오고, 프로세스의 정보들을 저장한 PCB(Process Control Block)를 생성합니다. 더 자세히는 프로세스는 메모리의 사용자(유저) 영역에, PCB는 커널 영역에 올라가게 됩니다. 

 

메모리

 

PCB(Process Control Block)
CPU가 프로세스를 실행하기 위해 필요한 프로세스 구분자, 메모리 관련 정보, 프로그램 카운터, 각종 중간값들을 보관하는 데이터 구조입니다. 프로그램이 프로세스가 되려면 메모리에 올라오는 것과 동시에 PCB가 반드시 생성되어야 합니다. 프로세스가 종료되면 프로세스는 메모리에서 삭제되며, PCB도 폐기됩니다. 

 

 

프로세스의 연산을 처리하는 CPU

실행중인 프로그램의 상태를 프로세스라고 했습니다. 그리고 실행중이라는 뜻은 프로그램에 정의된 코드들의 연산이 처리되는 것을 말합니다. 이 연산을 처리하는 것이 바로 CPU 입니다. 그럼 연산할 코드들은 어디서 얻어오는 걸까요? 바로 스레드입니다. 하나의 프로세스는 무조건 하나 이상의 스레드를 갖습니다. 이 스레드들을 CPU가 처리하는 것입니다.

 

프로세스 구조

프로세스에 스레드가 하나밖에 없으면 싱글 스레드, 둘 이상이면 멀티 스레드라고 말합니다. 이 둘의 구조적인 차이가 뭘까요? 이를 이해하기 위해서는 먼저 프로세스의 구조를 이해해야 합니다.

 

프로세스 구조

 

코드 영역

프로그램의 코드가 기술된 곳입니다. 프로그래머가 작성한 프로그램은 코드 영역에 탑재되며 탑재된 코드는 읽기전용으로 처리됩니다.

 

데이터 영역

코드가 실행되면서 사용하는 변수나 파일 등의 각종 데이터를 모아놓은 곳입니다. 데이터는 변하는 값이기때문에 읽기와 쓰기가 가능합니다. 물론 상수는 읽기 전용입니다.

 

스택 영역

운영체제가 프로세스를 실행하기 위해 부수적으로 필요한 데이터를 모아놓은 곳입니다. 프로세스 내에서 함수를 호출하면 함수 실행 후 돌아올 위치를 이 영역에 저장합니다. 위 예에서는 exit() 함수를 호출했을 때 돌아올 위치가 180이라는 주소임을 말하고 있습니다. 프로그램을 실행하면 운영체제는 프로그램을 메모리의 코드 영역에 넣습니다. 그리고 데이터 영역과 스택 영역을 확보하고  프로세스를 실행합니다. 이와 동시에 PCB도 생성합니다.

 

 

스레드가 뭔가요? 🤔

CPU가 처리하는 실행 단위를 말합니다. 한 개 이상의 스레드가 모여 프로세스를 이루기 때문에, 스레드를 프로세스 실행 단위라고도 합니다.

 

싱글 스레드와 멀티 스레드의 차이

이제 싱글 스레드와 멀티 스레드의 차이를 알아보겠습니다. 싱글 스레드는 앞서 언급한대로 프로세스가 하나의 스레드만을 갖는 것을 말합니다. CPU는 한번에 하나의 스레드만을 처리할 수 있으므로 CPU가 1 개인 시스템에서 프로세스의 실행은 문제가 되지 않습니다. 하지만 현재 시스템은 대부분 여러개의 CPU로 구성되어 있습니다. 필자의 경우 12개의 CPU 코어가 있으니, 동시에 12개의 스레드를 처리할 수 있습니다. 이러한 환경에서 단일 스레드 프로세스를 실행하게 되면 11개의 CPU 코어를 활용하지 못해 시스템의 효율성이 내려가게 됩니다. 이왕이면 여러 개의 스레드가 처리되는게 더 좋겠죠?

 

단일 스레드와 멀티 스레드를 대하는 CPU의 자세

 

 

프로세스를 여러개 만들면 되는거 아냐? 🤔

그럼 단일 스레드를 갖는 프로세스를 여러개 실행하면 어떻게될까요? 프로세스와 스레드가 새로 생성될것이고 여러 개의 CPU가 이들을 처리하게 될것입니다. 그런데 이 방식은 문제아닌 문제가 있습니다. 바로 프로세스마다 메모리 할당과 PCB 생성을 해야한다는 것입니다.

 

위에서 프로세스의 구조를 설명했는데 사실 힙 영역이라는 영역이 더 존재합니다. 그리고 힙 영역과 스택 영역은 동적 영역에 해당하는데 동적으로 크기가 줄어들고 늘어나는 영역입니다. 스택 영역은 함수 호출 후 복귀 시 사용하고, 추가로 지역변수를 저장할때 사용됩니다. 참고로 전역변수는 데이터 영역에 저장됩니다. 힙 영역은 프로그램이 실행되는 동안 할당되는 영역으로 자바의 인스턴스나 c언어의 malloc() 함수입니다.

 

프로세스 구조

 

 

스레드는 프로세스 구조 중 동적영역에 생성됩니다. 아래와 같이 말이죠. 

멀티 스레드

 

만약 단일 스레드 프로세스를 여러개 실행하면 어떻게될까요? 프로세스 개수만큼의 정적영역이 메모리에 추가로 할당되어야 할것입니다. 

멀티 태스킹

 

 

또 하나의 문제가 있습니다. 바로 Context Switching 속도가 느리다는 것입니다. 각각의 프로세스를 Context Switching 하는것보다 같은 프로세스를 갖는 스레드에 대해 Context Swtiching하는 속도가 더 빠릅니다.

 

Context Switching (문맥교환)
CPU를 차지하던 프로세스가 나가고 새로운 프로세스를 받아들이는 작업을 말합니다. 실행 상태에 있던 PCB에는 지금까지의 작업을 저장하고, 실행 상태로 들어오는 PCB의 내용으로 CPU가 다시 셋팅되는 작업입니다. 이와 같이 두 프로세스의 PCB를 교환하는 작업이 문맥교환입니다.

 

 

멀티 스레드의 문맥교환이 단일 스레드보다 더 빠른 이유가 뭐야? 🤔

멀티 스레드는 같은 프로세스에 속해있기 때문에 정적인 데이터를 공유하게 됩니다. 데이터 영역과 코드 영역을 공유합니다. 캐시는 CPU에서 읽어들인 메모리의 데이터를 저장하고 있다가 CPU가 다시 데이터를 요구할 때 메모리에서 전달해줍니다. 즉, 문맥 교환이 발생하고 PCB 내용을 기반으로 CPU를 셋팅할때 데이터 영역과 코드영역을 메모리영역에서 빠르게 읽어오게 됩니다. 왜? 프로세스가 같으니까요!

이에 반해 단일 스레드의 경우 PCB가 다르므로 기존에 쌓았던 캐시 데이터는 무의미해지고 CPU가 데이터를 읽어들이면 이를 다시 저장해야합니다. 이런 이유로 단일 스레드보다 멀티 스레드의 문맥교환이 더 빠른것입니다. 

 

 

그럼 문맥 교환은 언제 일어나는거야? 😲 

문맥 교환이 일어나는 상황은 매우 다양하나 대표적으로 두가지가 있습니다. 하나는 CPU가 처리중인 프로세스가 자신에게 주어진 시간을 다 사용했을 때이며, 하나는 인터럽트가 발생했을 때입니다. 인터럽트가 발생하는 상황은 매우 다양합니다. 예를들어 프로세스가 자신에게 주어진 메모리 공간을 넘어가려 한다면 인터럽트 관리 프로세스를 실행시킵니다. 이때 문맥교환이 발생합니다. 그리고 인터럽트 관리 프로세스가 메모리 범위를 넘어서려는 프로세스를 강제 종료하게 됩니다. 

 

멀티 스레드의 장점 

 

첫째, 응답성이 향상됩니다. 한 스레드가 입출력으로 인해 작업이 진행되지 않아도 다른 스레드가 작업을 계속하여 사용자의 작업 요구에 빨리 응답할 수 있습니다.

둘째, 자원을 공유합니다. 프로세스가 가진 자원을 모든 스레드가 공유하게 되어 작업을 원활하게 진행할 수 있습니다.

셋째, 시스템 효율성이 향상됩니다. 여러 개의 프로세스를 생성할 필요가 없어 불필요한 자원의 중복과 메모리 중복을 막고 문맥교환이 빨라집니다. 전반적인 시스템 효율이 향상되는 것입니다.

 

멀티 스레드의 단점

하나의 스레드에 문제가 생겨 종료될 경우 해당 스레드만 종료되는 것이 아니라 프로세스 전체가 종료됩니다. 인터넷 익스플로러는 멀티 스레드라 탭을 하나 추가할 경우 스레드가 생성된다. 이때 하나의 탭에 문제가 생겨 종료된다면 프로세스 자체가 종료되어 인터넷 익스플로러가 종료되게 됩니다. 이에반에 크롬은 싱글 스레드로 각 탭마다 독립적인 프로세스로 동작합니다. 만약 한 프로세스의 스레드에 문제가 생겨 종료되도, 다른 탭에 미치는 영향이 적습니다. 크롬은 이처럼 다른 스레드가 영향받는 것을 최소화하기 위해 낭비 요소가 있더라도 멀티스레드 대신 멀티태스킹을 사용합니다.

 

 

프로세스 상태

프로세스는 CPU 스케줄러에 의해 선별되며 스케줄러가 프로세스의 스레드를 CPU에게 전달하게 됩니다. 이를 '실행 상태' 라고 하는데, 이 외에도 여러 상태들이 있습니다. 한번 알아봅시다.

프로세스의 상태는 시스템마다 다르게 구성됩니다. 일괄 작업 시스템의 경우 생성, 실행, 완료 상태를 갖지만, 우리가 현재 대부분 사용하는 시분할 시스템의 프로세스 상태는 생성, 준비, 실행, 대기, 완료 상태를 갖습니다.

 

프로세스 상태

생성 상태

프로그램이 메모리에 올라오고, 운영체제로부터 PCB를 할당받은 상태입니다. 생성된 프로세스는 바로 실행되는 것이 아니라 준비 상태(준비 큐)에서 기다리게 됩니다.

 

준비 상태

프로세스가 CPU를 얻을때까지 기다리는 상태입니다. 준비 큐라는 곳에서 기다리며 CPU 스케줄러에 의해 관리됩니다.

참고로 CPU가 하나인 컴퓨터에서는 한번에 하나의 프로세스(정확히는 프로세스 내 스레드)만을 실행할 수 있습니다. CPU가 많을수록 준비 상태에 있는 프로세스가 빨리 처리될 것입니다.

 

CPU 스케줄러
준비 상태에 있는 여러 프로세스 중 다음 실행할 프로세스를 선정하는 일을 담당합니다. 준비 상태의 맨 앞에서 기다리는 PCB와 스레드를 CPU에게 전달하여 작업이 이루어지도록 합니다.

 

디스패치 (Dispatch)
준비 상태의 프로세스 중 하나를 골라 실행 상태로 바꾸는 CPU 스케줄러의 작업을 말합니다.

 

 

실행 상태

준비 상태에 있는 프로세스 중 하나가 CPU를 얻어 실제 작업(스레드)을 수행하는 상태를 말합니다. 실행 상태에 들어가는 프로세스의 수는 CPU의 개수만큼입니다. 프로세스마다 할당된 시간(타임 슬라이스)을 다 사용하고도 작업이 끝나지 않는다면 해당 프로세스는 준비 상태로 돌아가 다음 차례를 기다리게 됩니다.

 

타임 슬라이스 (= 퀀텀)
프로세스에 할당된 작업 시간을 말합니다.

 

클록
타임 슬라이스가 지났는지를 CPU에게 알려주는 장치입니다. 시간이 끝나면 인터럽트를 발생시켜 CPU에게 알려줍니다.

 

 

대기 상태

프로세스가 실행 상태에서 입출력(I/O)을 요청할 경우 입출력이 완료될 때까지 기다리는 상태입니다. 이 상태의 프로세스는 입출력 장치별로 마련된 큐에서 기다립니다. 입출력이 완료되면 입출력 관리자로부터 인터럽트를 받고, 준비 상태로 이동하여 다음 작업 수행을 기다린다.

 

완료 상태

실행 상태의 프로세스가 주어진 시간 동안 작업을 마치거나 종료되는 상태입니다. 프로세스를 메모리에서 제거하고, PCB를 폐기합니다. 만약 비정상 종료될 경우 코어 덤프가 발생합니다.

 

코어 덤프
프로세스가 비정상 종료될 경우 강제 종료 직전 메모리 상태를 저장 장치로 옮기는 것

 

 

 

반응형

'CS' 카테고리의 다른 글

[CS] 웹 프락시 / Proxy  (0) 2023.09.20
[CS] HTTP 메시지  (0) 2023.08.30
[CS] URL이란?  (0) 2023.08.30
[CS] Web Cache / 웹 캐시란?  (0) 2023.08.23

+ Recent posts