반응형

개요

 Git에는 local, remote 리포지토리와 같은 저장소도 있지만, commit 하기 전 상태를 저장해놓는 저장소도 있다. 이 저장소를 Staging Area라고 한다.

 

Commit 하기 전이요??

Commit 하기 전은 특정 파일을 add 명령어를 사용해 staged 상태로 변경시켰을때를 말한다. 즉, 어떤 파일을 새로 생성하거나, 수정한 후 add 명령어를 사용하면 이를 "staged 상태로 변경했다" 라고 말한다.

 

staged 상태가 있다면 다른 상태도 있나요?

그렇다 Staged 말고도 Untracked, Unmodified, Modified 상태도 존재한다. 이 상태들은 크게 두 가지로 구분할 수 있다. 깃이 관리하는 Tracked 상태(관리대상), 깃이 관리하지 않는 Untracked 상태(비관리 상태)이다. Tracked 상태에 Unmodified, Modified, Staged 상태가 포함된다. 아래 이미지를 보며 이해해보자.

 

출처 : https://velog.io/@soyi47/GitGithub-staging-commit

 

 

1. Untracked 상태

 깃이 관리하지 않는, 즉, 파일의 변경에 대해 추적하지 않는 상태이다. 이러한 상태를 갖는 파일들은 어떤 파일일까? 새로 생성한 파일의 경우 COMMIT 하기 전 특정 부분을 수정한다 한들 수정된 라인을 알 수 없다. 아직 깃에 의해 관리되지 않기 때문이다. 깃에 의해 관리되다가 삭제된 파일도 마찬가지이다. 이러한 상태를 Untracked 상태라고 한다.

 새로 생성한 파일을 add 하여 Staged 상태를 만들고, 이를 Commit 한다면, 이는 깃에 의해 관리(Tracked)되는 Unmodified 상태가 된다.

 

2. Unmodified 상태

 수정되지 않은 상태이다. 파일을 수정하지 않거나, 수정, 생성했던 파일을 commit 한 직후에 해당하는 상태이다.

 

3. Modified 상태

 수정된 상태이다. 단, 깃에 의해 관리되는 Staged 상태의 파일에 대해 수정을 했을 때이며, Untracked 상태의 파일을 수정했을 때는 Modified 상태라고 하지 않는다.

 

4. Staged 상태

  Commit 시 저장소에 기록될 준비를 마친 상태이다. 이를 "stage에 올라갔다" 라고도 표현한다.

 

Stage 에 올라간 파일들은 어디에 저장되나요?

.git/index 경로에 Stage에 올라간 파일들이 저장된다.

 

Stage 상태가 필요한가요? Commit 까지의 과정을 번거롭게 하는 것 같아요

이를 설명해주는 좋은 글이 있어 공유한다.

https://blog.npcode.com/2012/10/23/git%EC%9D%98-staging-area%EB%8A%94-%EC%96%B4%EB%96%A4-%EC%A0%90%EC%9D%B4-%EC%9C%A0%EC%9A%A9%ED%95%9C%EA%B0%80/

 

Git의 Staging Area는 어떤 점이 유용한가

Git에는 Staging Area라는 공간이 있다. 어떤 변경사항이 저장소에 커밋되기 전에, 반드시 거쳐야만 하는 중간단계이다. 다른 버전관리도구에는 이에 정확히 대응하는 것은 없다. 저장소가 추적하는

blog.npcode.com

 

이해한 내용을 정리하면 아래와 같다.

 

첫째, Commit 예정인 파일들을 관리할 수 있다.

사실 어떤 파일을 생성하거나 수정한 후 바로 Commit을 할 수도 있지만, 개발을 하면서 Commit 하고자 하는 파일들을 넣고 빼나가면서 '이번 Commit에는 최종적으로 어떤 파일들이 추가되어야 하는지'를 고민하면서 하나의 Commit 에도 공을 들이시는 경우도 있다. (참고로 필자는 아니다) 이 때 특정 파일들을 Stage 에 저장하며 관리한다면, Commit 예정인 파일들을 관리할 수 있다.

 

둘째, 충돌을 해결할 때 사용된다.

merge 시 충돌이 발생할 경우 이를 해결해야 한다. 만약 충돌의 범위가 거대하여 많은 시간이 소요된다면 중간 세이브 파일처럼 충돌을 해소한 파일을 디스크 어딘가에 저장해놓는 것이 안전하다. 이처럼 깃에서는 충돌을 해결한 부분에 대해 add 할 경우 해당 파일이 Stage 에 올라가 디스크에 저장된다.

 

참고

https://blog.npcode.com/2012/10/23/git%EC%9D%98-staging-area%EB%8A%94-%EC%96%B4%EB%96%A4-%EC%A0%90%EC%9D%B4-%EC%9C%A0%EC%9A%A9%ED%95%9C%EA%B0%80/

https://velog.io/@soyi47/GitGithub-staging-commit

반응형
반응형

개요

코드 분석중...

 

 누군가 작성한 코드를 분석하기 위해 100줄 남짓한 메서드를 보고있다. 최상위에 지역변수들이 초기화되어 있고, 아래로 비지니스 로직이 주욱 구현되어 있다. 코드를 분석해나가는 도중 어떤 지역변수가 사용되었지만, 이 값이 어떤 값을 갖고있는지를 잊어버려 최상위에 적힌 지역변수를 다시 확인했다. 어떤 변수는 반복문에서도, try 문 안에서도, 다른 변수에 값을 할당하는 부분에도 사용됐다. 변수의 유효 범위가 너무 넓은 것이다. 변수를 체크하다보니 로직의 흐름을 까먹어 다시 분석하기도 한다. 이런 과정을 반복하면서 코드 분석을 마무리했다.

 


지역변수가 최상위에 선언되어 있지 않았다면?

 지역변수들이 최상위에 선언되어 있지 않고, 쓰일 때 초기화되어 있거나, 현재 보이는 코드라인에 초기화 된 값이 보인다면 어땠을까? 다시 확인할 필요도 없고, 코드 분석에 대한 집중력도 유지할 수 있다. 즉, 지역변수의 범위를 최소화한다면 유지보수성과 가독성을 향상시킬 수 있다.

 


지역변수의 범위를 최소화하는 방법

 가장 강력한 방법은 '가장 처음 쓰일 때 선언하는 방법'이다. 앞서 개요에서 말했던 방법이다. 사용하려면 멀었는데 미리 선언부터 해두면 이를 다시 확인하거나 잘못 사용하게 되는 상황이 발생할 수 있다. 사실 거의 모든 지역변수는 선언과 동시에 초기화해야한다. 여기서 거의라고 말한 이유는 그렇지 않은 예외 케이스가 존재하기 때문인데 바로 try-catch 문이다. 예외 처리를 해야하는 경우 선언은 try 문 밖에서, 초기화는 try 문 안에서 해야 catch나 finally 에서 이에 맞게 핸들링을 하거나 리소스를 제거하는 행위를 할 수 있다.

 

아래와 같이 FileInputStream 과 같은 타입의 값을 초기화할 때 예외처리를 하지 않을 경우 아래와 같이 컴파일 타임에 에러가 발생한다. 이를 처리하기 위해 예외를 외부로 던질수도 있지만, 이는 예외에 대한 책임을 전가하기에 메서드 내에서 처리를 하려는 사람도 있을 것이다.

예외 처리를 하지 않아 컴파일 에러 발생

 

 

그 경우 inputStream을 try 외부에서 먼저 선언하고 내부에서 초기화하게 된다.

public void myMethod(){
    File file = new File("");

    FileInputStream inputStream = null;
            
    try{
        inputStream = new FileInputStream(file);
    }catch (FileNotFoundException e){
        e.printStackTrace();
    }finally {
        try{
            inputStream.close();
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}

 

물론 InputStream은 autoClose 가 구현되어 있으니 try-resource를 사용한다면 try 괄호 안에서 초기화가 되겠지만, 그게 아닐 경우 위처럼 선언과 동시에 초기화되지 않을 수도 있다. 어쨌든 중요한건 대부분의 지역변수는 사용할때, 선언과 동시에 초기화해야 한다는 것이다.

 


반복문의 반복변수

 지역변수는 반복문에서도 사용된다. 바로 '반복변수'이다. 일반적으로 for나 while 과 같은 반복문에서 사용하는데 만약 반복 변수의 값을 반복문이 종료된 뒤에도 써야 하는 상황이 아니라면 while 보다 for 문을 쓰는 게 낫다.

 

예를들어 컬렉션을 순회하는 코드를 작성할 경우 for문은 아래와 같이 for-each나 전통적인 for문을 사용해서 구현할 수 있다.

List<String> list = new ArrayList<>();
//...


// for-each
for(String e : list){
    //...
}

// 전통 for
for(Iterator<String> i = list.iterator(); i.hasNext();){
	//...
}

 

 

while의 경우는 아래와 같이 구현되는데, while 구문 안에 조건을 넣어야하므로 while 구문 전에 반복 변수를 초기화를 해야한다. 그런데 아래와 같이 while 반복문이 두번 사용될 경우 복붙 과정에서 실수로 조건부의 i를 i2로 수정하지 않아 버그가 발생할 수 있다. 이 경우 두번째 반복문이 실행되지 않을 것이다.

Iterator<String> i = list.iterator();
while(i.hasNext()){
    //...
}

Iterator<String> i2 = list.iterator();
while(i.hasNext()){ // 버그유발
    //...
}

 

 

for문의 경우는 어떨까? 반복 변수의 유효범위가 for문 안으로 한정되어 있기 때문에 위와 같은 버그가 발생하지 않는다. 심지어 변수명을 다르게 설정할 필요도 없다.

for(Iterator<String> i = list.iterator(); i.hasNext();){
    //...
}
for(Iterator<String> i = list.iterator(); i.hasNext();){
    //...
}

 

 

여기서 키포인트는 초기화를 내부에서 했냐, 외부에서 했냐가 아니다. while에 사용되는 반복변수는 for문에 사용되는 반복변수보다 유효 범위가 훨씬 넓다는 것이다. while의 반복변수는 메서드 전체인데 반해, for문의 반복변수는 for문 안으로 한정되어 있다. 즉, 지역변수의 유효범위가 넓을수록 버그를 발생시킬 확률이 높고, 가독성과 유지보수성을 헤친다는 것을 말하고 있다.

 


정리

 지역변수의 범위를 최소화하는 목적은 가독성과 유지보수성을 높이는 것이다. 이를 위해 지역변수를 선언과 동시에 초기화하거나, 실제로 쓰일 때 초기화 하거나, while 보다는 for문을 쓴것인데, 만약 이를 다 지킨다고 하더라도, 코드가 길면 어떨까? 긴 코드 안에는 여러 책임들이 얽혀있고, 여러 기능들을 하고, 여러 예외들을 처리한다면 지역변수의 범위를 최소화한다 한들 가독성과 유지보수성을 높인다는 목적을 이루지 못한다.

 

때문에 지역변수의 범위를 최소화하기에 앞서 선행되어야 할 가장 중요한 것은 단일 책임원칙에 맞게 코드를 구현하여 메서드를 작게 만드는 것이다. 이게 선행됐을 때 비로소 지역변수의 범위를 최소화하는 것이 의미있는 행위가 될것이다.

반응형
반응형

출처 : 위키피디아

 

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을 사용했고, 이슈별로 브런치를 생성하며 개발했지만 혼자이다보니 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 하려니 앞이 깜깜..

 

반응형
반응형

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

반응형

+ Recent posts