문제로 풀어보는 알고리즘 0.3 생각해보기 풀이

출판사 인사이트 블로그에 아래와 같은 퀴즈가 올라왔네요 ( 원문: http://www.insightbook.co.kr/post/3814 )

문제

배열 arr[]과 위치 s, t가 있을 때,arr[s], arr[s+1], … , arr[t-1]을 오른쪽으로 한 칸씩 이동하고,arr[t]는 arr[s]로 복사하는 것을 ’1만큼 오른쪽으로 회전시켰다’고 한다.

예를 들어 길이가 8인 배열에서 s=2, t=6이면 다음 그림처럼 바뀐다.

image

길이가 n인 배열의 위치는 0, 1, 2, … , n-1이다.

문제 :k를 인자로 받아서 k만큼 오른쪽으로 회전시키는 함수를 작성하라.단, 1만큼 오른쪽으로 이동시키는 과정을 k번 반복해서는 안 된다.

조건 1 : 작성하는 언어에는 제한이 없습니다. 조건 2 : 답안으로 작성하신 글 제목에는 ‘문제로 풀어보는 알고리즘 0.3 생각해보기 풀이’라는 문장이 들어가야 합니다. (저희 블로그에도 트랙백을 걸어주세요.)

(주의: 이 코딩 인터뷰는 인사이트 입사와는 무관합니다. ㅡㅁㅡ /)

풀이

밀린 일이 많아서 정신적인 여유가 없지만, 경품에 눈이 어두워서 급하게 풀어봤습니다

우선 테스트부터 먼저 작성하고

import static org.hamcrest.CoreMatchers.*;
import static org.junit.Assert.*;
import org.junit.Test;

public class ArrayHandlerTest {
    ArrayHandler handler = new ArrayHandler();

    @Test
    public void testShift1() {
        int[] array = {1,2,3,4,5};
        handler.shift(array,0,3,1);
        assertThat(array, is(new int[]{4,1,2,3,5}));
    }

    @Test
    public void testShift2() {
        int[] array = {1,2,3,4,5};
        handler.shift(array,1,3,1);
        assertThat(array, is(new int[]{1,4,2,3,5}));
    }

    @Test
    public void testShift3() {
        int[] array = {1,2,3,4,5};
        handler.shift(array,1,2,1);
        assertThat(array, is(new int[]{1,3,2,4,5}));
    }
....

그리고 이를 통과시키는 실행코드입니다.

import java.util.Arrays;public class ArrayHandler {
    public void shift(int[] array, int s, int t, int k) {
        if (s > t) return;
        if (t >= array.length) return;

        int[] rangeToShift = Arrays.copyOfRange(array, s, t + 1);
        int rangeLength = t-s + 1; for(int i=0; i< rangeLength; i++) {
            int offset = (i+k) % rangeLength;
            array[s + offset] = rangeToShift[i];
        }
    }
}

대용량 배열일 때 메모리를 더 적게 쓰는 방법들도 고민해볼만도 하지만, 우선은 쉽게 문제를 푸는데 집중했습니다. t,s, k 같은 한글자 변수명은 별로 좋아하지 않지만, 문제에 있는 변수명이라서 그대로 살렸습니다.

전체 소스는 Github에 올렸습니다.

참고로 java.util.Collections.roate()메서드를 쓰면 위 문제는 아래 두 줄로 풀 수 있기도 합니다.

[source,java

List<Integer> range = list.subList(s, t+1);
Collections.rotate(range, k);

그런데 이렇게 하는게 이 문제의 의도는 아닐듯해서 참고구현 정도로 남겨 두었습니다. 처음 풀이에서도 Arrays.copyOfRange 메서드를 쓰기는 했지만 이 부분은 특별한 알고리즘이 없는 단순한 배열복사라서 활용했습니다.

버전 관리 시스템 유랑기, 그리고 Git 적응기

https://gist.github.com/benelog/2922437#%EB%B2%84%EC%A0%84-%EA%B4%80%EB%A6%AC-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EC%9C%A0%EB%9E%91%EA%B8%B0-%EA%B7%B8%EB%A6%AC%EA%B3%A0-git-%EC%A0%81%EC%9D%91%EA%B8%B0버전 관리 시스템 유랑기, 그리고 Git 적응기

2011년 6월 9일11월 10일에 열린 세미나에서 두 번 발표했던 내용을 글로 정리했습니다.

지금까지 다양한 버전관리 시스템을 쓴 경험과 Git의 장점이라고 느낀 점에 대해서 발표했었습니다.

https://gist.github.com/benelog/2922437#%EB%B2%84%EC%A0%84%EA%B4%80%EB%A6%AC%EB%A5%BC-%EA%B1%B0%EC%9D%98-%EC%95%88-%ED%95%98%EB%8D%98-%EC%8B%9C%EC%A0%88버전관리를 거의 안 하던 시절

https://gist.github.com/benelog/2922437#%EB%B2%84%EC%A0%84%EA%B4%80%EB%A6%AC%EB%8A%94-%EB%A8%BC-%EB%82%98%EB%9D%BC%EC%9D%98-%EC%9D%B4%EC%95%BC%EA%B8%B0버전관리는 먼 나라의 이야기?

Git에 대해 이야기를 하기 전에 지금까지 어떻게 버전관리를 해왔는지 되돌아보겠습니다. 아마 비슷한 경험을 하시고 공감하실 분들이 많으실 듯합니다.

버전관리 시스템을 실무에서 쓰기 전에는 책에서 간단한 소개를 접한 정도였습니다. 김익환님이 지은 '대한민국에는 소프트웨어가 없다' 라는 책에는 아래와 같은 내용이 있습니다.

내가 아무리 소스코드관리 프로그램과 버그 관리 프로그램의 찬양자가 되어 조언을 해도 평생 사용하지 않을 회사들이 대부분일 것이다. 이건 진짜 좋은 건데. 안타깝다(153쪽)

이 책이 나온 때가 2003년 말이였는데, 그 당시에 국내에서 버전관리를 제대로 사용하는 개발 조직은 많지 않았나봅니다. 제 주변에서도 그랬습니다.

그때는 버전관리가 실무에서 필요하다기 보다 'CMMI' 같은 프로세스처럼 이론으로만 강조되는 분야라고 느꼈습니다. 아니면 엄격한 프로세스를 준수하고 그만큼 개발자에게 시간을 충분히 주는 소프트웨어 선진국에서나 가능한 기법이라고 생각했습니다.

김익환님이 미국에서 근무했던 'GTE’라는 조직에서는 개발자들마다 코딩하는 줄 수를 측정했더니 하루 평균 8줄이 나왔다는 내용이 있습니다.(171쪽). 그정도로 엄격한 리뷰와 관리를 거친다는데, 하루에 화면 4~5개씩 찍어내야하는 제 주변의 현실과는 동떨어진 이야기였습니다. 그래서 버전관리도 우리와는 다른 방식으로 개발을 하는 먼 나라의 일로, 우리와는 관계가 없는 절차로만 여겼습니다.

https://gist.github.com/benelog/2922437#%EA%B0%9C%EB%B0%9C%EC%84%9C%EB%B2%84%EC%97%90-%ED%8C%8C%EC%9D%BC%EC%9D%B4-%EC%9E%88%EB%8A%94%EB%8D%B0-%EC%99%9C-%EB%98%90-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EB%94%B0%EB%A1%9C-%EA%B4%80%EB%A6%AC%ED%95%A0%EA%B9%8C개발서버에 파일이 있는데 왜 또 파일을 따로 관리할까?

신입 때 처음 투입되었던 프로젝트에서는 버전관리를 하기는 했지만, 공유폴더보다 더 불편하다는 느낌 뿐이였습니다.

그 프로젝트에서는 Local PC에 개발환경을 설치하지 않고, 개발서버에 FTP로 jsp 파일를 올려서 개발을 진행했었습니다. JSP로만 주로 개발을 하던 초기에는 흔한 방식이였습니다. 그래서 개발서버에 이미 최신 파일이 올라가 있는데, 따로 왜 버전관리 시스템이라는 것을 써야하는지 이해가 되지 않았습니다. 가끔 실수로 파일을 덮어쓸 때에는 백업 폴더에 가서 복사해서 복구했습니다.

그 조직에도 Merant Version Manager라는 버전관리 시스템이 설치되어 있기는 했습니다.그 도구를 개발자들이 필요해서 쓴다기보다는 본사에서 시키니까 쓰기는하는데, 뭔가 부가적인 작업을 한다고 느꼈습니다. 개발서버에 파일 올려서 테스트를 다 해본다음에, 버전관리 시스템에 로그인하고, PC에 있는 파일 찾아서 올리고, Local에서도 여러 군대의 파일을 같이 신경써줘야 했습니다. 귀찮고 그냥 빼먹고 싶은 작업이였습니다. 그때는 지금의 SVN처럼 Eclipse plugin이 있는 것도 아니어서 따로 클라이언트 프로그램을 띄워야했기에 더욱 불편했었습니다. 그래서인지 두번 째 투입되었던 조직에서는 개발서버에만 파일을 올려놓고 아예 버전관리를 안 했었습니다.

https://gist.github.com/benelog/2922437#%EA%B3%B5%EC%9C%A0%ED%8F%B4%EB%8D%94%EC%97%90-%EC%98%AC%EB%A0%A4%EC%A3%BC%EC%8B%9C%EB%A9%B4-%EC%A0%80%EB%85%81-6%EC%8B%9C%EC%97%90-%EB%B0%B0%ED%8F%AC%ED%95%B4-%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4공유폴더에 올려주시면 저녁 6시에 배포해 드립니다~

세번째 프로젝트에서는 요즘처럼 Local PC에 WAS를 띄우고 Eclipse를 써서 개발을 하기는 했지만, 윈도우즈의 공유 폴더로 소스 관리를 했었습니다. 그때 완성된 프로그램을 서버에 배포하는 절차는 다음과 같았습니다.

  • 개발이 끝난 파일은 공유폴더의 약속한 위치에 올린다.

  • 담당자 한명이 매일 그 폴더의 파일을 자기 PC로 복사하고, Eclipse에서 컴파일한다.

  • 컴파일 에러가 나는 것이 있으면 그 파일을 올렸을 것으로 추측되는 사람에게 이야기해서 고쳐달라고 한다.

  • 컴파일 에러 안 나면 Eclipse에서 컴파일된 class파일과 JSP등을 FTP로 개발서버에 올리고 서버를 재시작한다.

  • 서버 재시작이 끝나면 개발자들이 각자 자기가 만든 모듈을 확인해본다.

  • 개발서버에서 잘 안 되는 기능 중에 급한 것은 다시 고쳐서 공유폴더에 넣고, 미안하지만 한번더 올려달라고 담당자에게 부탁한다.

  • 안 급한 것은 그냥 내일 반영하기로 하고, 일단 Local PC에서만 고쳐둔다.

  • 파일서버의 backup 폴더 아래에 날짜별로 오늘 배포한 것은 복사해 둔다.

매일매일 위의 절차를 반복하는데 30분을 넘게 썼었습니다. 프로젝트 구성원들 모두 개선을 해보려는 생각은 있었지만 당장의 업무가 급하다보니 투자할 시간이 없었습니다. 돌이켜 보면 프로젝트 개발기간이 6개월이 넘었는데, 6개월동안 매일 30분이면 엄청난 시간이 들어가는 작업이였습니다.

이 프로젝트에서도 버전관리시스템인 Merant Version Manager를 본사의 담당자가 와서 설치해주고,고객와 개발자들을 다 모아서 따로 교육까지 해주었습니다.그러나 역시나 버전관리 시스템에 파일을 올리는 건 파일서버에 복사를 하는 것보다 번거로운 일이라고 생각했었습니다. 그래서 버전관리 서버에는 1차 프로젝트 종료 후에 몰아서 한번 파일을 올리기로 합의했고, 그 방식이 효율적이라고 모든 프로젝트 구성원들이 공감을 했습니다.

그나마 개발 영역별로 업무분담이 명확해서 파일 충돌은 잘 일어나지 않았습니다. 충돌보다는 작업한 내용을 실수로 날릴까봐 걱정하는 사람은 많았는데, 어떤 개발자는 매일 퇴근 전에 개인 PC에 날짜별로 복사해 놓기도 했었습니다. 다들 그 개발자를 보고 부지런하고 좋은 습관을 가지고 있다고 칭찬을 했었죠. 이 기간 동안에도 공유폴더에 만족을 하고 버전관리는 업무를 더 느리게 하는 작업이라고 인식했습니다.

https://gist.github.com/benelog/2922437#merant-version-manager%EC%9D%84-%EC%93%B0%EB%8D%98-%EC%8B%9C%EC%A0%88Merant Version Manager을 쓰던 시절

https://gist.github.com/benelog/2922437#%EB%B9%8C%EB%93%9C-%EC%9E%90%EB%8F%99%ED%99%94-%EA%B7%B8%EB%A6%AC%EA%B3%A0-merant-version-manager%EB%A5%BC-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8B%A4빌드 자동화, 그리고 Merant Version Manager를 제대로 사용하기 시작하다.

그렇게 공유폴더를 쓰던 개발팀에서 고도화 성격의 2차프로젝트가 시작되었습니다.운영 중인 시스템에서 몇가지 업무가 추가되는 사업이였는데, 운영 중인 시스템에 당장 배포할 소스와 2차 프로젝트 오픈 때 배포할 소스를 따로 관리해야 했습니다. 유지보수성 요청 때문에 수정된 소스는 바로 운영 시스템에 배포를 하지만, 2차 사업에 추가요구사항으로 개발된 소스는 오픈 시점에 맞춰서 운영서버에 배포해야 했습니다. 그래서 유지보수와 신규개발 버전을 따로 브랜치를 분리할 필요성이 생겼습니다. 그리고 전에는 담당자별로 업무영역이 명확하게 나뉘어져 있었는데, 새로운 프로젝트에서는 그런 경계가 명확하지 않은 부분이 많이 생겼습니다.그런 필요성 때문에 버전관리를 본격적으로 시작하게 되었습니다.

그 시점에서 빌드자동화 스크립트도 구성했습니다. 수동으로 해서 매일 30분이 걸리던 빌드절차를 개선하는 시도였었습니다. 파일서버에 따로 복사한 파일이 아닌, 버전관리에 올라온 최신 소스 파일을 읽어서 컴파일하고 서버에 FTP로 올리고, WAS를 재시작하는 스크립트를 ANT로 구성했습니다. 제가 한 3일정도 걸려서 그 작업을 했었는데, 처음 맨바닥에서 하는 일이라서 지금의 기준으로 보면 정말 많은 시간이 걸렸습니다. 지금은 Maven + Hudson으로 하면 몇십분 안에 가능한 작업이지만, 그래도 3일 투자해서 매일매일 30분의 작업을 없애서 정말 만족스러웠습니다.그때부터 다른 개발자들도 이제 공유폴더에 파일을 복사해 넣는 대신 매일매일 버전관리시스템인 Merant Version Manager에 파일을 올리기 시작했습니다.

https://gist.github.com/benelog/2922437#-%EC%9D%B4-%EB%8C%80%EB%B6%80%EB%B6%84%EC%9D%B8-commit-%EB%A1%9C%EA%B7%B8'.' 이 대부분인 commit 로그

이 단계부터는 단순하게 파일시스템으로 백업을 하는 시대는 지났지만, 버전관리를 깊이 있게 사용하지는 못했습니다.그때 대부분의 커밋로그를 보면 점하나 (.)만 찍은 것이 많았습니다. 아무런 내용을 안 쓰면 입력하라고 메시지가 나오니까 그 걸 피하려고 넣은 문자가 '.'점이였습니다. 시스템을 운영하고, 담당자가 바뀌고, 업무 규칙이 바뀌어갈수록 버전관리 시스템에 있는 커밋로그가 정말 소중한데, 그때는 그런 걸 알지 못했습니다. SI프로젝트이다 보니 빨리 개발 다 끝내고 도망갈 생각이 먼저이지, 운영할 사람에게 도움이 되는 정보를 남겨야한다는 생각은 잘 나지 않았습니다.한참 지난 다음의 이야기지만, 불행스럽게도 제가 그 프로젝트에서 마지막까지 남아서 유지보수를 했습니다. 물론 그 덕분에 중간에 나갔으면 몰랐을 것들을 많이 느끼긴했습니다.

https://gist.github.com/benelog/2922437#lock-%EA%B1%B8%EA%B3%A0-%ED%87%B4%EA%B7%BCLock 걸고 퇴근

제가 처음 버전관리 시스템으로 썼던 Merant Version Manager는 CVS처럼 배타적인 방식으로 파일의 소유권을 관리했었습니다. 즉, 고칠파일이 있으면 먼저 그 파일들을 'Checkout’해야 하는데, 그 상태에서는 다른 사람은 그 파일을 건드리지 못하게 됩니다. 수정이 끝난 후에 'Check in’을 하면 고친 파일이 올라가고, 다른 사람이 'Check out’을 받을 수 있는 상태로 변합니다. 그 제품의 최신 버전은 어떤지는 몰라도 당시에는 그렇게 썼었습니다.

몇번은 다른 개발자가 파일을 Checkout 해놓은 상태로 퇴근을 했는데, 급하게 파일을 고쳐야 할 때가 있었습니다. 전화해서 비밀번호를 물어보고 해결을 했는데, 나중에는 업무가 밀접하게 관련있는 사람들끼리는 비밀번호를 거의 공유하다시피 했었습니다. 그리고 Lock 걸린 파일을 수정해야 할 때도 급한 일이 아니라면 먼저 'Check out’한 사람이 다 고칠 때까지 기다렸었습니다.

그때는 그게 당연한 방식이였고, 그렇게 해야 질서가 있고 안전하다고 생각했습니다. 얼핏 SVN이 lock없이 파일을 고쳐도 된다고 듣기는 했지만, 그렇게 하면 소스가 엉켰을 때 해결이 되지 않을 위험한 방식이라고 추측했습니다. 당시에는 그 버전관리 방식에 역시나 만족을 했었습니다.

https://gist.github.com/benelog/2922437#svn%EC%9D%84-%EC%93%B0%EA%B8%B0-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8B%A4SVN을 쓰기 시작하다

https://gist.github.com/benelog/2922437#svn-%EC%9E%85%EB%AC%B8SVN 입문

그러다가 직장이 바뀌어서 SVN을 쓰는 개발조직에서 일하게 되었습니다.

SVN을 특별히 공부한 적이 없었는데도 SubclipseSubversive같은 Eclipse plugin 덕분에 금방 적응을 했습니다. 그런 Plugin 덕분에 버전관리가 부가적인 작업이 아니고, 개발과정에 자연스럽게 녹아든다는 느낌이 들었습니다.배타적이지 않은 lock관리 방식과 'transactional’한 특성을 가진 commit 방식도 직접 써보니 더 편리한 개념이라는 것을 깨달았습니다. 가끔 Eclipse plugin의 버그나 뭔가 상태가 꼬였을 때 헤매기도 했지만, 얼마 지나지 않아 비슷한 상황에서의 문제해결에 익숙해지고, Plugin도 점점 안정화되어서 큰 불편없이 쓰게 되었습니다.

SVN을 쓰기 전까지는 이전의 버전관리 방식에 만족을 하고 있었는데, 막상 써보니 이전에는 그 불편함을 어떻게 견디었나하는 생각이 들었습니다.

https://gist.github.com/benelog/2922437#%EB%A8%B8%EC%A7%80%EB%8A%94-%EC%96%B8%EC%A0%9C%EB%82%98-%EB%91%90%EB%A0%A4%EC%9A%B4-%EC%9D%BC머지는 언제나 두려운 일

그러나 SVN에서는 브랜치 관리나 머지가 여전히 번거롭거나 두려운 일이였습니다.특히나 브랜치를 생성하고 오랫동안 유지하는 일은 정말 손이 많이 가는 일이였습니다.장기 프로젝트를 했을 때는 브랜치가 분기된 이후로 트렁크(trunk)에 반영된 사안들을 다시 브랜치로 옮기는 작업을 주기적으로 했는데, 자주 안하면 나중에 최종 머지가 두려워지고, 그렇다고 자주하기에는 시간이 많이 걸리는 작업이였습니다.

브랜치 관리와 머지를 잘하기 위해서 여러 개발팀에서 다양한 방법을 쓰고 있는 것을 보았습니다. 어떤 프로젝트에서는 일주일에 한번씩 트렁크 쪽의 이력을 가장 많이 알고 있는 사람과 브랜치를 가장 많이 수정한 사람이 같이 앉아서 작업을 했는데, 오래 걸리면 한번에 30~40분이 넘게 걸리기도 했습니다.팀에서 그런 머지작업을 많이 한 사람이 '머지 전문가’로 불리기도 했습니다. 당시 썼던 SVN버전에서는 머지한 후에 그동안의 이력이 남지 않기 때문에, 머지를 수정한 커밋 로그에는 "from revision 10244 to 10532" 와 같은 형식으로, 어디서부터 어디까지를 합쳤는지 로그를 적었습니다. 머지에 쓰는 도구도 사람마다 팀마다 다양했습니다. SubclipseSubversive의 메뉴를 활용하는 팀이 있었는데, 각자 개인마다 나름대로의 노하우가 있는 듯했습니다.어떤 팀에서는 그런 도구를 이용하면 오히려 실수가 많다면서 2개의 브랜치를 모두 Eclipse에서 띄운 후에 Diff로 하나하나 비교해가면서 파일도 하나하나 복사해서 가는 방식을 썼었습니다.시간이 상당히 많이걸리는 방식이였지만, 그 팀에서는 머지는 그렇게 하는게 최고라면서 상당히 만족하고 있었습니다.

오랜 노하우가 쌓인다고 해도 어떤 방식이든 여전히 실수할 여지는 많았습니다. 머지되어야할 이력이 빠져서 오류가 발생했던 경험도 있습니다.

그렇게 브랜치를 관리하는 부담이 크다보니, 규모가 큰 변경이 아니라면 왠만해서는 브랜치를 안 따고 Trunk만 사용하고 싶었습니다.그런데 개발서버에라도 언제 배포될지도 모르는 Trunk에 마음대로 커밋을 하는 것은 부담이 되는 일이였습니다. 부분적으로 잘 안 돌아가는 소스를 커밋할 수는 없으니,어느 정도 기능별로 완결된 것만 커밋을 하게 되었고, 그러다보면 커밋을 자주하지 않게 되기도 했습니다.

박재성님이 쓰신 Java 프로젝트 필수유틸리티라는 책에서도 아래와 같이 SVN merge의 어려움에 대해서 나와있습니다.

SVN에서 소스 코드 머지가 어려워 브랜치를 사용하지 않고 신규 메소드를 추가하는 방식으로 변경할 수 밖에 없는 상황도발생한다. 그렇지 않아도 VCS의 사용에 반대하는 개발자들이 있는 상황에서 머지의 어려움은 개발자들을 설득하기 어렵게 한다.(307쪽)

위의 문단을 포함해서 이 책의 306~307쪽에는 머지를 한후 커밋 로그에 리비전 번호를 기록하는 팁이라던지, SVN에서 merge를 하다가 문제를 겪으신 분의 이야기가 나와 있습니다.( 안영회님의 블로그 글에 달린 강수형님의 댓글 )

SVN의 최신버전에서는 머지를 하면 SVN에서 알아서 따로 추가적인 정보를 남기도록 개선이 되었다고는 합니다.

https://gist.github.com/benelog/2922437#git%EB%A5%BC-%EB%A7%9B%EB%B3%B4%EB%8B%A4Git를 맛보다

https://gist.github.com/benelog/2922437#dvcs%EC%97%90-%EB%8C%80%ED%95%9C-%EC%86%8C%EB%AC%B8%EB%A7%8C-%EB%93%A3%EB%8B%A4%EA%B0%80DVCS에 대한 소문만 듣다가..

그럭저럭 SVN에 만족했지만 브랜치 관리에는 다소 아쉬움이 있었던 차에, 여러 오픈소스 프로젝트들이 Git이나 Mercurial같은 분산버전관리시스템(DVCS)로 옮기어 간다는 이야기를 들었습니다.인터넷에도 분산버전관리 시스템에 대한 글들이 많이 올라왔는데, 그 중에서 조엘 스폴스키가 쓴 Mercurial 튜터리얼이 인상 깊었습니다.

그래도 직접 써본적이 없으니, 어떤 점이 좋을지 막연하게 상상한 하는 정도였습니다.그러던 중 팀내부에서 진행하는 프로젝트에서 Git으로 버전관리를 하게 되면서, Git의 좋은 점들을 직접 체험하게 되었습니다.

https://gist.github.com/benelog/2922437#%EC%B2%B4%EA%B0%90%ED%95%9C-%EC%9E%A5%EC%A0%90체감한 장점

아래에 정리한 Git의 장점들은 다른 누군가가 그렇다더라.. 해서 적은 것이 아니고, 실제로 프로젝트를 하면서 솔직하게 느껴졌던 것들입니다.오랫동안 쓰다보면 이것보다 더 많은 것을 느끼겠지만, 처음 써보는 프로젝트에서는 이정도가 개발하는데 도움이 된다고 생각되었습니다.

https://gist.github.com/benelog/2922437#ctrlz-%ED%8C%8C%EC%9D%BC-%EB%B3%B5%EC%82%AC%EB%B3%B4%EB%8B%A4-%EC%95%88%EC%8B%AC%EC%9D%B4-%EB%90%98%EB%8A%94-local-%EB%B2%84%EC%A0%84-%EA%B4%80%EB%A6%ACCtrl+Z, 파일 복사보다 안심이 되는 Local 버전 관리

개발자의 Local PC에서도 버전관리를 할 수 있다는 점은 DVCS의 대표적인 장점으로 꼽히고 있습니다.그런데 과연 그것이 꼭 필요한지, 오히려 버전관리 과정을 더 복잡하게 하지 않는지 우려하시는 분이 많고, 저도 그랬었습니다.

실제로 프로젝트를 해보니 지금까지 'Ctrl + Z’나 주석문으로 가리기, 파일복사로 해왔던 작업 중에 어떤 부분은 Local에서의 버전관리로 해결할 부분이 아니었나하는 생각이 듭니다.코드를 작성하다보면 성공의 확신이 없는 시도를 해볼 때도 있고, 시도한 것이 여의치 않았을 때 다시 돌아올 수 있는 지점을 기록하고 싶어지기도 합니다.물론 간단한 1~2줄의 수정 같은 정도는 Ctrl+Z, 주석문 등을 이용하는 것이 더 편리할 수도 있습니다. 그러나 파일의 여러 부분이 동시에 바뀌어야 하는데 나중에 되돌릴 가능성이 있다던지, 두가지 방식의 구현을 시험삼아서 같이 진행할 때는 Local에서 중간중간 commit을 하거나 브랜치를 생성하는 편이 훨씬 편리했습니다.

SVN에서는 따로 브랜치를 따고, 나중에 머지하는 일이 번거롭고 두렵기 때문에 통합할 가능성이 확실하지 않은 수정은 Local PC에서 원래의 파일을 복사본을 만들어서 수정하기도 했었습니다. 그러나 Git에서는 Local branch 생성과 이동이 빠르고 결과를 머지하는 것도 더 안정감이 있다는 느낌이 들기 때문에, Ctrl + Z,주석문 처리(//), 파일 복사로 때우던 소규모의 변경 지점 관리가 더 편해집니다.

Git을 썼던 프로젝트에서 XML파싱을 하는 모듈을 개발했었는데, JDOM과 DOM4J중에서 어떤 것이 해당 용도에 적합할지 확신이 없는 상태에서 2개의 Local branch를 만들어서 작업을 했었습니다.처음에 JDOM으로 갔다가, 마음에 안 드는 부분이 있어서 DOM4J로 구현했다가 다시 JDOM으로 돌아왔는데, 이렇게 결정을 번복하는 과정에서 Local의 버전관리가 없었다면 훨씬 번거롭고 다른 작업자들에게도 방해가 되었을 듯합니다.

https://gist.github.com/benelog/2922437#%EB%B2%84%EC%A0%84%EA%B8%B0%EB%A1%9D%EA%B3%BC-%ED%86%B5%ED%95%A9%EC%9D%98-%EA%B0%9C%EB%85%90-%EB%B6%84%EB%A6%AC'버전기록’과 '통합’의 개념 분리

앞에서 말했듯이, Local에서 버전관리가 가능하기 때문에 '버전기록’과 '통합’이 다른 개념이 됩니다.SVN에서 '커밋’은 '버전기록’이자 '소스 통합’을 의미합니다.. 즉, 작업한 내용을 기록하는 일과 다른 사람이 일한 소스와 합치는 일이 같은 일입니다.저도 지금까지 그것이 당연하다고 생각해왔기 때문에 두가지를 같이 취급하는 것이 전혀 어색하지 않았습니다.

그런데 엄밀히 따지면 2가지는 구분이 됩니다. 버전기록은 중간중간 의미 있는 단위의 작업이 끝날 때마다 할만한 일입니다. 그에 반해서 통합은 내가 작업한 소스를 다른 작업자들이 받아가도 괜찮은 시점에 하는 것이 바람직합니다. 예를 들어 컴파일도 되지 않는 소스를 커밋을 하고 그 에러를 고치는 커밋을 하기 전에 다른 동료가 소스를 받아간다면, 동료는 원래 하는 일에 방해를 받게 됩니다.SVN에서는 '커밋=통합’이라서 개인이 작업한 것을 전체 소스에 통합을 해도 괜찮은 시점에만 커밋을 해야 그런 부작용이 없습니다.만약 작업하는 브랜치가 언제라도 배포될 수 있는 브랜치라면 극단적으로는 개발작업에 완료된 시점에만 커밋을 해야 안전합니다. 이렇게 '커밋=통합’일 때는 버전관리의 모든 욕구를 다 충족시키지 못할 수도 있습니다. 오류가 있거나, 아직 확정되지 않은 인터페이스를 포함하고 있어서 다른 사람에게 전파되지 않았으면 하는 프로그램의 상태라도 버전기록은 하고 싶은 상황도 생길 수 있습니다.많은 경우, 통합보다 버전기록이 더 자주 필요한 일입니다.그리고 통합은 버전기록보다 더 신중하고 노력이 많이 들어가는 일입니다.예를 들면 안전한 통합을 위해서는 svn update → mvn test → commit의 절차를 거쳐야하는데, 버전기록이 필요할 때마다 위와 같이 안전한 통합을 위한 절차를 할지말지 고민해야 한다면 버전기록은 훨씬 무거운 일이됩니다.

물론 SVN으로도 별도의 브랜치를 딴다면 다른 작업자에게 영향없이 독립적으로 버전기록을 할 수 있지만, 굉장히 번거롭습니다.Git에서는 버전기록이 필요한 시점에서는 언제든지 commit을 하고, 통합할만한 상태가 되었을 때만 push를 하면 됩니다.

어떤 분은 통합 없는 버전기록을 할 수 있다면 통합을 자주 하지 않게 된다는 우려를 하시기도 합니다.그런데 SVN으로 작업할 때도 통합 가능한 상태를 염두에 두지 않고 버전기록이 필요할 때마다 자주 commit을 하다보면 CI서버의 빌드가 자주 깨지게 됩니다.빌드 실패는 빠른 피드백으로서 의미가 있지만, 너무 자주 실패하면 잡음이 되어서 다른 사람에게 방해가 되거나, 그 신호를 무시하게 됩니다.그런 부작용을 우려해서 오히려 commit을 자주 하지 않게 되는 경우를 보기도 했었습니다. 그래서 어떤 프로젝트에서는 '적어도 하루에 한번씩은 commit을 하자’라고 규칙을 정한 곳도 있었습니다.

버전기록과 통합을 강제로 묶어서 통합을 자주 하기를 바라기보다는 두 가지 개념을 구분하고 어떻게 활용할지는 상황에 따라서 선택하는 편이 좋다고 생각합니다.버전기록과 통합이 강제로 묶여있다면 개발자들이 오히려 둘 다 자주 하지 않게 될 수도 있습니다.통합을 자주하고 싶다면 필수적인 통합주기를 개발팀에서 약속을 하는 편이 더 실용적입니다. 기술 도메인의 특성이나 작업을 어떻게 분담하느냐에 따라서 통합과 버전관리의 주기는 다양할 것입니다.보편적인 버전관리 도구라면 상황에 따라서 다양한 정책을 적용할 수 있도록 개념이 섬세하고 유연한 편이 더 좋다고 생각합니다.

https://gist.github.com/benelog/2922437#%EB%B9%A0%EB%A5%B4%EA%B3%A0-%ED%8E%B8%ED%95%9C-%EB%B8%8C%EB%9E%9C%EC%B9%98-%EC%9D%B4%EB%8F%99빠르고 편한 브랜치 이동

어느 정도 규모가 있는 프로젝트에 참여하다보면 여러 브랜치를 왔다갔다 하면서 작업할 일이 많습니다. SVN에서도 Local에 받은 소스 폴더를 다른 브랜치로 이동을 할 수 있고, Eclipse plugin으로 편하게 지원됩니다.

Switch to branchhttps://camo.githubusercontent.com/14111eced471ead7d87404b86e3d337f404dce42/687474703a2f2f646c2e64726f70626f782e636f6d2f752f31333936303330302f6769742f73766e5f7377746963685f6272616e63682e706e67[SVN switch branch]

그런데, 저는 프로젝트를 하면서 저 기능을 거의 쓴 적이 없습니다. 네트워크로 다른 브랜치의 파일을 받아오기 때문에 이동하는 속도가 굉장히 느리기 때문입니다.그리고 원래 있던 브랜치에서 커밋을 아직 안 한 파일이 남아 있어도 이동이 안 됩니다.

그런 불편함 때문에 보통 여러 branch를 Eclipse에서 별도의 프로젝트로 받아서 한꺼번에 열어두는 일이 많습니다.

SVN branches

그러다보면 파일을 수정할때 어느 브랜치의 파일을 수정하는 건지 헷갈릴 때가 많습니다. 보통 저는 Ctrl + Shift + R로 파일을 찾는데, 여러 브랜치를 동시에 열고 있을 때는 같은 파일이름이 동시에 떠서 불편합니다.

Open Resources

이렇게 여러 브랜치가 열려있는 상황에서 의도하지 않은 브랜치의 파일을 고치는 실수는 주변에서 굉장히 흔하게 보였습니다.

Git에서는 이런 브랜치 전환이 굉장히 빠릅니다. 네트워크를 타지 않고 Local에서 branch를 전환을 하니 당연한 일입니다.Git을 처음 써본 프로젝트에서도 몇번 브랜치를 전환하면서 작업을 했었는데, 하나의 프로젝트만 열고만 있으면 되니 훨씬 편리하고 빨랐습니다.

https://gist.github.com/benelog/2922437#%EB%8D%94-%EC%A0%95%EA%B5%90%ED%95%9C-%EC%BB%A4%EB%B0%8B-%EB%A1%9C%EA%B7%B8더 정교한 커밋 로그

SVN에서는 브랜치에 있던 파일을 트렁크에 머지하면 그동안 브랜치에 커밋한 이력은 옮겨지지 않았습니다. 트렁크와 브랜치 사용 정책을 정하느냐에 따라서 다르지만, 어떤 프로젝트에서는 트렁크에는 아래와 같이 머지를 했다는 기록 밖에 없고, 머지를 담당한 사람의 이름밖에는 남아 있지 않았었습니다.

SVN history

Git에서는 원한다면 모든 이력을 남길 수 있습니다. Eclipse plugin으로 보니 머지한 이력을 그래프로 이쁘게 보여줬습니다.

Git history

Git에서는 커밋 로그를 정교하게 수정할 수도 있습니다. 몇개의 커밋을 하나로 합칠 수도 있고, 앞에 커밋한 이력을 수정할 수도 있습니다.파일을 하나 빠뜨리고 커밋을 해서 불필요하게 커밋이 나누어지거나, 설명을 대충 적은 것 같아서 아쉬울 때가 많았는데 Git에서는 commit을 한 이후에도 그걸 보완하는 작업이 가능합니다. 어쩌면 한 번에 완벽한 코드를 짜는 것이 불가능한 것처럼, 한번에 깔끔한 커밋 이력을 남기고 논리적으로 균일한 커밋 단위를 관리하기는 어렵습니다.오래 가는 코드를 위해서는 커밋 로그는 깔끔하고 친절하게 정리되어야 합니다. Git을 쓰면 commit 로그도 리팩토링의 대상이 되어서 더 정교한 commit 관리를 원하는 사람에게 도움이 됩니다.

https://gist.github.com/benelog/2922437#%EA%B7%B8%EB%A6%AC%EA%B3%A0-gitflow-gerrit그리고 Gitflow, Gerrit

지금까지는 직접 경험한 장점을 이야기했는데, GitflowGerrit은 직접 써본 도구는 아닙니다. 그래도 Git의 매력중의 중요한 부분이라고 생각되어서 간단하게 언급하고 넘어갑니다.

Gitflow는 Best Practice라고 할만한 버전관리 절차를 Git으로 편하게 쓸 수 있게 도와주는 도구입니다.

Gitflow model

Git이 워낙 기능이 많기 때문에 어떤 버전관리 정책을 써야할지 처음에는 막막하게 느껴질 수도 있을 것 같습니다. GitFlow는 'Feature' ,'Hotfix' ,'Release' 등과 같이 전형적인 역할의 브랜치들을 기본적으로 녹여내고 있습니다. 즉 좀 더 정형화되고 추상화된 개념들을 더 짧은 명령어로 쓸 수 있는 것입니다. 'git flow hotfix start’와 같이 명령어를 치면, hotfix를 위한 branch 생성을 해주는 식입니다.

Gerrit은 Git바탕의 코드 리뷰도구입니다.

어떤 블로거는 Getrrit을 소개하면서 'Someday, all software will be built this way.'라고 주장하기도 했습니다.

아래에 Gerrit과 Jenkins를 연동한 인상적인 Demo도 있습니다.

9분 40초부터 1분동안의 데모가 핵심적인 장면입니다. 코드를 수정하고 commit, push를 하고 Gerrit에 들어가보면 CI서버지인 Jenkins에서는 'verified’되었다는 표시가 나옵니다.그리고 그 코드를 리뷰해서 'Great’라는 메시지를 달아주고 +2점으로 점수를 부여해주는 장면이 나옵니다.

https://gist.github.com/benelog/2922437#%EB%85%BC%EC%9F%81%EA%B1%B0%EB%A6%AC%EB%A5%BC논쟁거리를

앞서서 Git을 쓰면 통합을 자주 안 하게 된다는 주장에 대해서 말씀드렸지만, 그외에도 Git에 대한 이런저런 불평들은 많이 있습니다.

https://gist.github.com/benelog/2922437#%EC%96%B4%EB%A0%A4%EC%9B%8C%EC%9A%94-%EB%B3%B5%EC%9E%A1%ED%95%B4%EC%9A%94어려워요~ 복잡해요~

우선 기능이 많고 어렵다는 것입니다. 이 부분은 저도 어느 정도 동감을 합니다.저도 프로젝트를 끝낸 후에 오랜 만에 다시 Git를 쓰려니 명령어가 잘 기억이 나지 않아서 헤메었던 기억이 있습니다.그리고 분명히 SVN보다 더 많은 개념과 기능을 제공하니 제대로 쓰려면 배워야할 것이 많기도 합니다. 중간에 많은 시행착오를 거치거나 실수로 소스를 날려먹은 사람도 몇번 봤습니다.

그렇지만 처음부터 그 많은 기능을 모두 알 필요는 없다고 생각합니다.간단히 SVN으로 하던 주요 사용법만 Git으로 배우는 것은 얼마 걸리지 않습니다.그리고 차근차근 기능을 익혀나가면 Git으로 더 편리하게 할 수 있는 일들이 늘어가고 앞에서 언급한 Git의 장점로 개발이 더 편해질 것이라 생각합니다.

Git의 많은 기능들은 Git 자체의 오버엔지니어링이라기보다는, 버전관리 업무 자체의 복잡함을 보여준다고 생각합니다.수많은 작업자가 같이 작업하고, 여러 배포 버전을 동시에 살려나가야 하는 대규모 소프트웨어는 버전관리 자체가 워낙 어려운 과제입니다.그런 문제 상황을 정교하게 분류하다보니, 점점 새로운 개념들이 생겨나서 처음 접하는 사람에게는 논리적으로 어렵다고 느껴질 듯합니다.그런데 그런 개념을 어떻게 응용할지 고민하면서 쓰다보면, 지금까지의 버전관리가 충분하지 않았음을 느끼게 됩니다.저도 파일 공유 서버로 버전관리가 충분했다고 생각하는 시절에는 SVN에서 제공하는 transactional한 commit 같은 기능이 필요하다고 생각하지 못했었습니다.마찬가지로 DVCS가 없이도 큰 아쉬움은 없었는데, Git등을 써보고 나니 이전에는 SVN에서 그냥 넘어갔던 부분이 더 불편하게 느껴졌습니다.

https://gist.github.com/benelog/2922437#eclipse-plugin%EC%9D%B4-%EB%B6%88%ED%8E%B8%ED%95%B4%EC%9A%94Eclipse Plugin이 불편해요

또 하나는 Eclipse plugin이 아직도 불안정하고 불편하다는 점입니다. 저는 Eclipse plugin은 history를 보는 용도로만 쓰고 모든 작업은 명령행으로 해서 특별히 불편을 겪은 적은 없습니다.다른 써보신 분들에 의견에 따르면 Eclipse plugin만으로는 Git의 모든 기능을 원활히 쓸 수 없다고 합니다.그래서 Eclipse plugin은 덤정도로 여기면서 큰 기대를 안 하고, 명령행 방식을 주로 쓴다고 생각하면 오히려 시행착오가 적을 것 같기도 합니다.명령어를 외우는 것이 처음에는 부담될지 몰라도, 나중에는 메뉴를 찾아해메는 것보다 작업속도가 더 빨리질수도 있습니다. 그리고 정보공유나 인수인계에서는 GUI보다 명령행이 더 유리하기도 하고, 더 재미있어할 개발자들도 많을 것입니다.

https://gist.github.com/benelog/2922437#%EB%A7%88%EC%B9%98%EB%A9%B0마치며

버전관리는 새로운 시도를 하기에는 워낙 두려운 분야이기는 합니다. 현재 방식의 버전관리로 충분히 만족하는 조직이 많고, 조직의 관습이나 변화에 필요한 비용등을 생각하면 그것이 어떤 조직에서는 정답일수도 있습니다.저도 돌이켜보면 버전관리의 필요성을 못 느끼던 시절부터 시작해서, Merant Version manager, SVN, Git을 거쳤고, 그 사이에 생각도 많이 바뀌었습니다.원래 하던 방식이 마음이 편했기에 제가 처한 환경의 특수성을 과대평가해서 새로운 방식이 필요하지 않다고 생각했던 시절이 많았었습니다.

그래도 발전한 도구들 덕분에 개발은 훨씬 편해진 것 같기도 하지만, 더 어려워지기도 했습니다. 단순히 소프트웨어 개발만 할 줄 알던 시절에서 빌드도구, 버전관리 도구에 대해서도 전에보다 더 많은 지식을 알아야 코드 한 줄을 쓸 수 있게 되었습니다.어떤 분들은 이런 흐름이 필요이상의 복잡함을 더했다고 느끼겠고, 새로 업계에 들어오는 개발자들에게는 점점 숙제거리가 늘어만 가는듯합니다.

저는 이런 변화는 발전이고, 여러 조직에서 비슷한 고민들이 있어서 공유하다가 약간씩 더 나은 해결방식이 나오고 있다고 봅니다. 저는 Git을 쓰는 프로젝트를 처음 해 보면서 다른 개발자들이 어떤 고민을 했을지 좀 더 많이 이해했고, 그런 고민들을 저는 지나쳤음을 깨달았습니다.그 점이 Git으로 얻은 가장 큰 선물이였습니다.

스레드 안전성의 문서화와 검증: Javadoc 표기, 애너테이션, 정적 분석, ArchUnit

스레드 안전성을 Javadoc에서 어떻게 표시하는 것이 좋을지와 멀티스레드에서 위험한 코드가 배포되지 않도록 애너테이션과 정적 분석 도구, 테스트로 방어하는 방법을 정리합니다.

어떤 클래스의 인스턴스를 여러 스레드가 공유해도 되는지는 개발할 때 주의 깊게 살펴야 할 정보입니다. 그런데 Javadoc에는 스레드 안전성을 명확히 표현하는 규약이 없습니다. 그래서 클래스 사용자가 제공자의 의도를 잘 인지하지 못할 위험성이 큽니다. 이 글은 스레드 안전성을 Javadoc에 남길 때 바람직한 분류 방식과 애너테이션과 정적 분석 도구, ArchUnit 테스트로 멀티스레드에서 위험한 코드가 배포되지 않도록 방어하는 방법을 정리합니다.

Javadoc으로 표시하는 스레드 안전성

Javadoc이 메서드의 synchronized를 표시하지 않는 이유와 그럼에도 클래스 수준의 규약이 왜 필요한지, 문서화를 한다면 어떤 분류가 적절할지, 그리고 실제 라이브러리 문서의 사례를 차례로 살펴봅니다.

Javadoc이 synchronized를 감추는 이유

클래스의 인스턴스 메서드에 synchronized 키워드가 붙으면 그 인스턴스 자체가 lock이 됩니다. 즉 여러 스레드가 한 인스턴스의 synchronized 메서드를 동시에 호출해도 한 스레드만 진입할 수 있고, 다른 스레드는 먼저 들어간 스레드가 메서드를 마칠 때까지 기다립니다. static synchronized 메서드는 해당 클래스의 Class 객체를 lock으로 사용합니다. 따라서 synchronized는 스레드 안전성을 판단할 단서가 됩니다. 그래서 Javadoc에 공개되면 도움이 될 만한 정보이지만 Javadoc은 메서드 선언의 synchronized 키워드를 출력하지 않습니다. 동기화 여부는 구현 세부 사항이라서 API 문서에 노출할 계약이 아니라고 보기 때문입니다. Effective Java 3판의 아이템 82도 같은 근거를 들어 이 정책을 옹호합니다.

메서드 선언부의 synchronized는 메서드 본문 전체를 synchronized(this) 블록으로 감싼 것과 같습니다. 즉 아래 코드는

메서드 선언부의 synchronized
synchronized void run() {
    // do something
}

다음 코드와 같은 일을 합니다.

메서드 내부 전체를 synchronized 블록으로 선언
void run() {
    synchronized (this) {
        // do something
    }
}

구현을 개선하면서 동기화 구간을 메서드의 일부로 좁힐 수도 있고, this 대신 별도의 lock 객체를 쓸 수도 있습니다. 이런 세부 구현은 외부 인터페이스보다 자주 바뀝니다. lock으로 보호되는 모든 구간을 메서드 선언부에 표시하기도 어렵습니다. 내부적으로 java.util.concurrent.locks.Lock이나 CAS(Compare And Swap) 연산으로 동기화한 클래스도 있으니, synchronized 키워드의 유무는 스레드 안전성을 판단하는 유일한 기준이 되지 못합니다.

클래스 수준 스레드 안전성 표기의 필요성

멀티 스레드에서 특정 메서드 하나의 호출이 부작용이 없더라도 여러 메서드 호출을 조합하면 안전하지 않을 수 있습니다. 예를 들어 HashMap은 생성이 끝난 뒤 다른 스레드에 공개되고 더 이상 변경되지 않는다면 여러 스레드가 get()을 호출해도 문제가 없습니다. 그러나 한 스레드가 put()으로 구조를 바꾸는 동안 다른 스레드가 get()을 호출하면 결과를 보장할 수 없습니다. 모든 메서드가 synchronizedHashtable이나 Collections.synchronizedMap()으로 감싼 Map도 '키가 없으면 넣는다' 같은 복합 동작은 외부에서 lock을 잡지 않으면 경쟁 조건이 생깁니다.

그러므로 문서화할 대상은 메서드의 동기화 여부가 아니라 클래스가 어떤 조건에서 안전한지입니다. Javadoc에 이를 표시할 규약이 없으니 개발자가 클래스 설명에 직접 적어야 합니다. 다음 절에서 볼 Effective Java의 분류가 그 기준이 됩니다.

Effective Java의 스레드 안전성 다섯 단계

Effective Java 3판의 아이템 82 '스레드 안전성 수준을 문서화하라'(2판에서는 아이템 70)는 스레드 안전성을 다섯 단계로 나눠 문서화하라고 권합니다.

  • 불변(immutable)

    • 상태가 바뀌지 않으므로 외부 동기화가 필요 없습니다.

    • 예: String, Long, BigDecimal, java.time.LocalDate

  • 무조건적 스레드 안전(unconditionally thread-safe)

    • 상태가 있으나 내부에서 충분히 동기화합니다.

    • 예: AtomicLong, ConcurrentHashMap

  • 조건부 스레드 안전(conditionally thread-safe)

    • 일부 메서드는 외부 동기화가 있어야 안전합니다.

    • 예: Collections.synchronizedList()로 감싼 List. iterator로 순회하는 동안에는 List 객체를 lock으로 잡아야 합니다. 그렇지 않으면 순회 결과가 정의되지 않으며, fail-fast iterator가 ConcurrentModificationException을 던질 수도 있지만 이는 보장되지 않습니다.

  • 스레드 안전하지 않음(not thread-safe)

    • 외부에서 동기화해야 합니다.

    • 예: HashMap, ArrayList

  • 스레드 적대적(thread-hostile)

    • 외부에서 동기화해도 멀티스레드에서 쓸 수 없습니다. 주로 동기화 없이 static 데이터를 수정하는 클래스가 여기에 해당합니다. 다행히 Java 라이브러리에는 거의 없습니다.

    • 예: System.runFinalizersOnExit(). 책이 든 이 예는 JDK 11에서 제거되었습니다.

클래스 설명문의 표기 사례

Java 표준 라이브러리와 Spring Batch가 스레드 안전성을 어디에 어떻게 기술하는지 세 가지 예를 보겠습니다.

java.util.LinkedList

LinkedList의 JDK 25 Javadoc은 클래스 설명의 세 번째 문단에서 굵은 글씨로 'not synchronized’라고 밝힙니다.

LinkedList의 JDK 25 Javadoc

java.text.SimpleDateFormat

SimpleDateFormat의 JDK 25 Javadoc은 클래스 설명의 마지막 즈음에 'Synchronization’이라는 제목의 절을 두고 'Date formats are not synchronized’라고 설명합니다. JDK 25 문서는 'API Note’로 DateTimeFormatter를 'immutable and thread-safe alternative’로 권합니다.

SimpleDateFormat의 JDK 25 Javadoc

권장 대상인 DateTimeFormatterLocalDate의 문서에는 'Implementation Requirements' 항목에 'This class is immutable and thread-safe.'라고 적혀 있습니다. JDK 8에서 추가된 java.time 패키지의 클래스들은 Javadoc의 @implSpec 태그로 같은 문장을 같은 자리에 적습니다. 표준 라이브러리 안에서도 새로 만든 API일수록 스레드 안전성을 일관된 형식으로 표시하는 추세입니다.

org.springframework.batch.item.file.FlatFileItemWriter

Spring Batch 5.2.6의 FlatFileItemWriter Javadoc은 클래스 설명의 마지막 줄에 'not’만 굵게 표시해서 'The implementation is not thread-safe.'라고 적어 두었습니다.

FlatFileItemWriter의 Spring Batch 5.2.6 Javadoc

이렇게 클래스마다 스레드 안전성을 표시하는 위치와 방법이 제각각입니다. 나름대로 강조하지만 API 문서를 주의 깊게 읽는 사람이 아니라면 지나치기 쉽습니다. '스레드 안전성은 클래스 설명의 제일 윗줄에 넣고, 반드시 눈에 띄게 표시한다' 같은 규칙이 있었으면 얼마나 좋을까 하는 생각까지 듭니다.

애너테이션으로 표시하는 스레드 안전성

설명문 대신 애너테이션으로 스레드 안전성을 표시하면 Javadoc에서의 위치와 형식이 일정해집니다. 개발 프로젝트에서 그런 용도의 애너테이션을 직접 정의한 사례를 먼저 보고, 여러 프로젝트가 같이 쓸 수 있는 애너테이션을 이어서 봅니다.

Apache HttpClient의 @Contract

Apache HttpClient 5는 스레드 안전성을 표시하는 애너테이션을 org.apache.hc.core5.annotation.Contract 하나로 정의했습니다. threading 속성에 ThreadingBehavior enum 값을 지정합니다.

ThreadingBehavior 의미

IMMUTABLE

완전한 불변 객체이며 스레드 안전합니다.

IMMUTABLE_CONDITIONAL

생성자로 주입받은 의존 객체가 불변일 때 불변이고, 의존 객체가 스레드 안전할 때 스레드 안전합니다.

STATELESS

상태가 없으며 스레드 안전합니다.

SAFE

스레드 안전합니다.

SAFE_CONDITIONAL

생성자로 주입받은 의존 객체가 스레드 안전할 때만 스레드 안전합니다.

UNSAFE

스레드 안전하지 않습니다. threading 속성을 생략했을 때의 기본값입니다.

HttpClient 5.5의 소스에서 주요 클래스들은 아래처럼 선언되어 있습니다.

HttpClient 5.5
@Contract(threading = ThreadingBehavior.SAFE)
public abstract class CloseableHttpClient implements HttpClient, ModalCloseable {

@Contract(threading = ThreadingBehavior.SAFE_CONDITIONAL)
public class PoolingHttpClientConnectionManager
        implements HttpClientConnectionManager, ConnPoolControl<HttpRoute> {

@Contract(threading = ThreadingBehavior.SAFE)
public class BasicHttpClientConnectionManager implements HttpClientConnectionManager {

SAFE_CONDITIONAL은 Effective Java의 '조건부 스레드 안전’과 이름은 비슷하지만 뜻이 다릅니다. Effective Java의 분류는 '어떤 메서드 호출 순서에 외부 동기화가 필요한가’를 말하고, HttpClient의 값은 '생성자로 받은 의존 객체가 스레드 안전한가’를 말합니다.

@Contract@Documented 메타 애너테이션이 붙어 있어서 Javadoc의 클래스 선언부에 함께 나타납니다. 아래는 BasicHttpClientConnectionManager의 Javadoc입니다.

BasicHttpClientConnectionManager의 HttpClient 5 Javadoc

일관된 위치에 표시되기 때문에 한눈에 스레드 안전성 여부를 인식할 수 있습니다. 클래스 설명 문단에도 'this class is fully thread-safe’라고 적혀 있지만, 선언부의 애너테이션이 먼저 눈에 들어옵니다.

HttpClient가 처음부터 독자적인 애너테이션을 만든 것은 아닙니다. HttpClient 4.5.2까지는 HttpGet과 위의 5.5 예제와 같은 클래스들의 선언에 아래처럼 @NotThreadSafe, @ThreadSafe가 붙어 있었습니다.

HttpClient 4.5.2
@NotThreadSafe
public class HttpGet extends HttpRequestBase {

@ThreadSafe
public abstract class CloseableHttpClient implements HttpClient, Closeable {

@ThreadSafe
public class PoolingHttpClientConnectionManager
    implements HttpClientConnectionManager, ConnPoolControl<HttpRoute>, Closeable {

@ThreadSafe
public class BasicHttpClientConnectionManager implements HttpClientConnectionManager, Closeable {

이 애너테이션들은 org.apache.http.annotation 패키지에 들어 있었지만, Javadoc은 'Java Concurrency in Practice' 책에서 유래했다고 설명했습니다. 그런데 뒤에서 볼 JCIP 원본 라이브러리의 라이선스 문제 때문에 HttpCore 4.4.5는 이 4가지 애너테이션을 제거했고, HttpClient는 4.5.3부터 @Contract로 바뀌었습니다.

JCIP 애너테이션

HttpClient처럼 프로젝트마다 애너테이션을 정의해도 되지만, 널리 쓰이는 오픈소스의 애너테이션을 쓰면 사용자가 새로 익힐 필요가 없고 SpotBugs 같은 정적 분석 도구나 IDE의 지원을 받기도 쉽습니다. HttpClient 4.x가 가져다 쓴 JCIP 애너테이션이 그런 공통 애너테이션의 출발점입니다.

JCIP 애너테이션은 'Java Concurrency in Practice' 책의 부록 A에서 제안한, 스레드 안전성을 표시하는 애너테이션입니다. 아래 4가지 애너테이션을 제공합니다.

  • @ThreadSafe : 스레드 안전한 클래스

  • @NotThreadSafe : 스레드 안전하지 않은 클래스

  • @Immutable : 불변 클래스. 불변이면 스레드 안전합니다.

  • @GuardedBy("lock") : 필드나 메서드에 붙여서, 어떤 lock을 잡은 상태에서만 접근해야 하는지 표시합니다. synchronized에 쓰는 내장 lock과 java.util.concurrent.locks.Lock을 모두 지정할 수 있습니다.

앞의 세 애너테이션은 클래스의 계약을 사용자에게 알리고, @GuardedBy는 해당 클래스를 유지보수하는 사람에게 어떤 lock을 지켜야 하는지 보여 줍니다.

Effective Java의 다섯 단계를 기준으로 JCIP 애너테이션을 맞춰 보면 다음과 같습니다. (JCIP가 2006년, Effective Java 2판이 2008년에 나왔으므로 대응 관계를 설명한 쪽은 Effective Java입니다.)

Effective Java의 다섯 단계 JCIP 애너테이션 차이

불변

@Immutable

JCIP는 불변이면 스레드 안전하다고 보므로 @ThreadSafe를 겹쳐 붙이지 않습니다.

무조건적 스레드 안전

@ThreadSafe

같은 뜻입니다.

조건부 스레드 안전

@ThreadSafe

JCIP에는 별도의 이름이 없습니다. 어떤 lock을 잡아야 하는지는 설명문에 적습니다. 두 분류가 실제로 다른 항목입니다.

스레드 안전하지 않음

@NotThreadSafe

JCIP는 이 애너테이션을 선택 사항으로 둡니다.

스레드 적대적

없음

JCIP에는 대응하는 개념이 없습니다.

해당 없음

@GuardedBy("lock")

필드와 메서드에 붙이며 유지보수하는 사람을 위한 정보입니다. Effective Java의 다섯 단계는 클래스 단위로 사용자에게 하는 약속이므로 분류의 기준 자체가 다릅니다.

정리하면 Effective Java는 사용자 관점에서 외부 동기화가 얼마나 필요한지를 다섯 단계로 나눈 분류입니다. JCIP는 안전한지 아닌지의 이분법에 불변을 특수한 경우로 추가하고, 유지보수하는 사람을 위한 정보는 @GuardedBy로 따로 분리했습니다. JCIP 4.5절 'Documenting synchronization policies’는 스레드 안전성 보장은 사용자를 위해, 동기화 정책은 유지보수하는 사람을 위해 문서화하라고 권합니다.

JCIP와 같은 이름으로 퍼진 애너테이션들

JCIP 책과 함께 배포된 원본 라이브러리는 Maven Central의 net.jcip:jcip-annotations:1.0입니다. 그런데 이 라이브러리의 라이선스는 Creative Commons Attribution입니다. Creative Commons 재단 스스로 소프트웨어에는 권하지 않는 라이선스입니다. 그래서 같은 API를 Apache License 2.0으로 다시 구현한 com.github.stephenc.jcip:jcip-annotations:1.0-1이 나왔습니다. 두 라이브러리의 패키지 이름과 애너테이션 이름은 net.jcip.annotations로 같습니다.

그 외에도 JCIP 애너테이션은 여러 프로젝트로 복제되었습니다. 다음 라이브러리들은 애너테이션 이름은 같지만 패키지가 다릅니다.

  • javax.annotation.concurrent : FindBugs가 배포한 JSR-305 애너테이션 구현인 com.google.code.findbugs:jsr305에 같은 4가지 애너테이션이 들어 있습니다. JSR-305 자체는 정식 릴리스 없이 중단되어 dormant 상태입니다. Java 9와 10에서는 module path에 올린 이 jar의 javax.annotation 패키지가 JDK의 java.xml.ws.annotation 모듈과 겹칠 수 있었습니다. 다만 이 JDK 모듈은 JDK 11에서 제거되었으므로, Java 9 이후 모든 버전에 해당하는 문제는 아닙니다.

  • com.google.errorprone.annotations : Google의 Error Prone이 쓰는 @Immutable, @ThreadSafeconcurrent 하위 패키지의 @GuardedBy가 있습니다.

  • androidx.annotation.GuardedBy : Android용입니다.

  • org.apache.http.annotation : 앞에서 본 대로 Apache HttpComponents가 4.5.2까지 복제해 쓰던 4가지입니다. HttpCore 4.4.5와 HttpClient 4.5.3부터 @Contract로 바뀌었습니다.

목적에 맞는 애너테이션 고르기

원본 JCIP 라이브러리는 라이선스 때문에, JSR-305는 표준화가 중단되어서 새 프로젝트에 권하기 어렵습니다. 다음 절에서 볼 정적 분석 도구의 검사 범위를 고려하여 둘 중 하나를 권장합니다.

  • 컴파일 시점 검증이 우선이라면 error_prone_annotations가 적합합니다. Error Prone과 함께 쓰면 @Immutable@GuardedBy를 컴파일 시점에 검사할 수 있습니다. Guava를 사용하는 모듈의 컴파일 클래스패스에도 들어오지만, 코드에서 직접 사용한다면 Guava의 전이 의존성에 기대지 말고 명시적으로 의존성을 선언하는 편이 안전합니다. @ThreadSafe는 검사하지 않고 @NotThreadSafe는 없으므로, 표시가 없는 클래스를 스레드 안전하지 않다고 간주하는 관례도 필요합니다.

  • 공개 API에 @NotThreadSafe를 포함한 JCIP 애너테이션 네 가지를 모두 표현하는 것이 우선이라면 com.github.stephenc.jcip:jcip-annotations가 적합합니다. IntelliJ와 SpotBugs를 사용하는 프로젝트라면 기존 도구와 빌드 설정을 크게 바꾸지 않고 적용할 수 있습니다.

정적 분석 도구의 검사 범위

애너테이션은 문서화만으로도 가치가 있지만, 도구가 위반을 잡아 주면 더 유용합니다. SpotBugs, Error Prone, IntelliJ IDEA가 각각 어디까지 검사하는지 정리합니다. 도구의 지원 범위는 버전에 따라 바뀔 수 있으므로 이 글에서 실행하거나 문서로 확인한 기준을 먼저 밝힙니다.

Table 1. 검증 기준
대상 버전과 확인 방법

Java

JDK 25

SpotBugs

SpotBugs 4.10.4, Gradle 플러그인 6.5.11을 예제에서 실행

Error Prone

Error Prone 2.50.0, Gradle 플러그인 5.1.1을 예제에서 실행

IntelliJ IDEA

Inspectopedia 2026.2 문서와 IntelliJ Community 소스 826413b22cfe의 인스펙션 등록 정보 기준

SonarQube

Java 분석기 SonarJava 소스 9bf31b6e0037의 내장 규칙과 SpotBugs 외부 규칙 목록 기준

Eclipse JDT

JDT Core OptionsEclipse JDT 소스 8c40c7d2ae12 기준

2026년 9월 시점의 JDT Core Options 전체 목록과 Eclipse JDT 소스 8c40c7d2ae12에서는 스레드 안전성 애너테이션을 다루는 컴파일러 검사를 찾지 못했습니다. Eclipse 사용자는 SpotBugs Eclipse 플러그인으로 보완할 수 있습니다.

같은 시점에 SonarQube의 Java 분석기인 SonarJava 소스 9bf31b6e0037에서 내장 규칙 구현을 ThreadSafe, GuardedBy, javax.annotation.concurrent로 검색했을 때 스레드 안전성 애너테이션을 참조하는 구현은 S3077뿐이었고, 애너테이션의 약속을 직접 검증하는 규칙은 찾지 못했습니다. 참조 타입 필드의 volatile을 막는 S3077 구현은 필드 타입에 JSR-305 패키지의 @Immutable이나 @ThreadSafe가 붙어 있으면 예외로 넘기지만 @GuardedBy는 다루지 않습니다. SonarQube는 SpotBugs 보고서를 외부 이슈로 가져올 수 있고, 외부 규칙 목록에는 뒤에서 볼 세 버그 패턴이 모두 들어 있습니다. 따라서 SonarQube에서 이 결과를 보려면 SpotBugs를 빌드에서 돌려 보고서를 넘겨야 합니다. SpotBugs와 Error Prone 예제는 examples/thread-safety-static-analysis에 있습니다.

SpotBugs

FindBugs의 후속 프로젝트인 SpotBugs가 JCIP 애너테이션을 위해 둔 버그 패턴은 @Immutable 용 하나입니다. @GuardedBy@ThreadSafe는 전용 검사가 없고, 동기화가 일관되지 않은 필드를 찾는 탐지기가 판단의 근거로 읽습니다. 인식하는 패키지는 아래 3가지입니다.

  • net.jcip.annotations (원본 JCIP)

  • javax.annotation.concurrent (JSR-305)

  • jakarta.annotation.concurrent (SpotBugs가 jakarta 이름공간 지원에 맞춰 미리 인식하는 패키지. Jakarta Annotations 3.0.0에는 이 패키지가 없음)

FindBugs 2.0 때부터 있던 JCIP_FIELD_ISNT_FINAL_IN_IMMUTABLE_CLASS 버그 패턴은 @Immutable이 붙은 클래스에 final이 아닌 필드가 있으면 경고합니다. 다만 SpotBugs 4.10.4의 구현은 transientvolatile 필드는 이 검사에서 제외합니다.

아래처럼 @Immutable로 선언했지만 setter가 있는 클래스를 만들고

package net.benelog;

import net.jcip.annotations.Immutable;

/**
 * {@code @Immutable}로 선언했지만 final이 아닌 필드가 있어서
 * SpotBugs가 JCIP_FIELD_ISNT_FINAL_IN_IMMUTABLE_CLASS로 보고한다.
 */
@Immutable
public class Memo {
	private String content;

	public void setContent(String content) {
		this.content = content;
	}

	public String getContent() {
		return content;
	}
}

Gradle에 SpotBugs 플러그인을 설정한 뒤

plugins {
	java
	id("com.github.spotbugs") version "6.5.11"
}

repositories {
	mavenCentral()
}

dependencies {
	implementation("com.github.stephenc.jcip:jcip-annotations:1.0-1")
}

java {
	toolchain {
		languageVersion.set(JavaLanguageVersion.of(25))
	}
}

spotbugs {
	toolVersion.set("4.10.4")
	ignoreFailures.set(true)
}

tasks.spotbugsMain {
	val report = layout.buildDirectory.file("reports/spotbugs/main.txt")
	reports.create("text") {
		required.set(true)
		outputLocation.set(report)
	}
	doLast {
		println(report.get().asFile.readText())
	}
}

./gradlew :spotbugs:spotbugsMain을 실행하면 보고서에 다음 줄이 나옵니다.

M B JCIP: Memo.content should be final since net.benelog.Memo is marked as Immutable.  In Memo.java

예제는 경고가 있어도 보고서를 끝까지 출력하려고 ignoreFailurestrue로 두었습니다. 실제 CI에서 위반 때문에 빌드를 실패시키려면 이 설정을 false로 바꾸거나 생략해야 합니다.

@GuardedByIS_FIELD_NOT_GUARDED 버그 패턴이 다룹니다. 그런데 이 패턴은 애너테이션을 보고 위반을 찾는 것이 아닙니다. 필드 접근 중 lock을 잡은 비율로 동기화 누락을 추정하는 IS2_INCONSISTENT_SYNC 탐지기가 경고를 내기로 한 필드에 @GuardedBy("this")가 붙어 있으면 이름만 바꿔서 보고합니다. 인식하는 값도 "this" 뿐입니다. 이 탐지기는 lock을 잡은 접근이 절반에 못 미치는 필드는 오탐으로 간주해 우선순위를 낮춥니다.

그래서 같은 프로젝트의 Counter@GuardedBy("this") 필드를 lock 없이 수정하는데도 기본 설정에서는 보고되지 않습니다. increment()의 읽기와 쓰기는 lock 없이, synchronized 메서드 get()의 읽기만 lock을 잡고 이루어져서 lock을 잡은 접근이 33%입니다. 예제 저장소에서 ./gradlew :spotbugs:spotbugsMain -PreportLow로 낮은 우선순위까지 보고하게 하면 그제야 나타납니다.

L M IS: Counter.count not guarded against concurrent access; locked 33% of time  Unsynchronized access at Counter.java:[line 16]

같은 위반에 synchronized 메서드 두 개를 더한 아래 클래스는 lock을 잡은 접근이 60%가 됩니다.

package net.benelog;

import net.jcip.annotations.GuardedBy;
import net.jcip.annotations.ThreadSafe;

/**
 * Counter와 같은 위반이지만 synchronized 메서드가 더 많아서
 * lock을 잡은 접근 비율이 높다. SpotBugs가 IS_FIELD_NOT_GUARDED로 보고한다.
 */
@ThreadSafe
public class LockedCounter {
	@GuardedBy("this")
	private int count;

	public void increment() {
		count++;
	}

	public synchronized int get() {
		return count;
	}

	public synchronized void reset() {
		count = 0;
	}

	public synchronized boolean isZero() {
		return count == 0;
	}
}

이 클래스는 기본 설정에서도 높은 우선순위로 보고됩니다. 출력의 첫 글자는 우선순위, 둘째 글자는 분류입니다.

H M IS: LockedCounter.count not guarded against concurrent access; locked 60% of time  Unsynchronized access at LockedCounter.java:[line 16]

@ThreadSafe@NotThreadSafe도 이 탐지기가 읽습니다. @NotThreadSafe 클래스의 필드는 검사에서 제외하고, @ThreadSafe 클래스의 필드는 경고 우선순위를 올리는 근거로 씁니다. 애너테이션의 약속이 지켜졌는지 검증하지는 않습니다. @ThreadSafe로 선언한 클래스가 @NotThreadSafe인 필드를 가져도 경고하지 않습니다.

정리하면 SpotBugs가 애너테이션만 보고 결정적으로 검사하는 것은 @Immutable 클래스의 final 규칙뿐입니다. @GuardedBy 위반은 lock을 잡은 접근과 잡지 않은 접근의 비율로 짐작한 결과가 같은 결론에 이를 때만 보고됩니다.

Error Prone

Google의 Error Pronejavac에 플러그인으로 붙어서 컴파일 시점에 에러를 예방하는 규칙을 검사합니다.

@GuardedBy 검사

Error Prone의 GuardedBy 검사@GuardedBy(lock)이 붙은 필드나 메서드를 지정한 lock을 잡지 않고 접근하면 컴파일 오류를 냅니다. 접근 비율에 따라 보고 여부가 달라지는 SpotBugs와 달리, 위반하는 접근 하나하나를 그대로 잡습니다.

다만 이 검사가 인식하는 애너테이션은 com.google.errorprone.annotations.concurrent.GuardedBy, javax.annotation.concurrent.GuardedBy, Android용 androidx.annotation.GuardedBy 등이고, 원본 JCIP의 net.jcip.annotations.GuardedBy는 목록에 없습니다. @Immutable 검사도 com.google.errorprone.annotations.Immutable만 검사하고 javax.annotation.concurrent.Immutable은 검사하지 않는다고 문서에 명시되어 있습니다. 다음 절에서 확인합니다.

Gradle에서는 net.ltgt.errorprone 플러그인으로 Error Prone을 javac에 붙입니다. 세 가지 패키지의 애너테이션을 비교하기 위해 라이브러리도 셋 다 넣었습니다.

plugins {
	java
	id("net.ltgt.errorprone") version "5.1.1"
}

repositories {
	mavenCentral()
}

dependencies {
	implementation("com.github.stephenc.jcip:jcip-annotations:1.0-1")
	implementation("com.google.code.findbugs:jsr305:3.0.2")
	implementation("com.google.errorprone:error_prone_annotations:2.50.0")
	errorprone("com.google.errorprone:error_prone_core:2.50.0")
}

java {
	toolchain {
		languageVersion.set(JavaLanguageVersion.of(25))
	}
}

실제로 확인해 보면 JSR-305의 @GuardedBy를 쓴 아래 클래스는

package net.benelog;

import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;

/**
 * JSR-305 패키지. Error Prone이 @GuardedBy 위반을 컴파일 오류로 보고한다.
 */
@ThreadSafe
public class JsrCounter {
	@GuardedBy("this")
	private int count;

	public void increment() {
		count++;
	}

	public synchronized int get() {
		return count;
	}
}

Error Prone 2.50.0에서 다음과 같은 컴파일 오류가 납니다.

JsrCounter.java:15: error: [GuardedBy] This access should be guarded by 'this', which is not currently held
		count++;
		^
    (see https://errorprone.info/bugpattern/GuardedBy)

같은 코드에서 import만 net.jcip.annotations.GuardedBy로 바꾼 JcipCounter는 아무 오류 없이 컴파일됩니다. 반대로 Error Prone 자체 패키지의 @GuardedBy를 쓴 ErrorProneCounter는 JsrCounter와 같은 오류로 잡힙니다. 세 클래스는 예제 저장소의 errorprone 서브프로젝트에 있고 ./gradlew :errorprone:compileJava로 확인할 수 있습니다.

@Immutable 검사

Immutable 검사com.google.errorprone.annotations.Immutable이 붙은 클래스가 깊은 불변인지 검사합니다. SpotBugs의 JCIP 검사는 필드가 final 인지만 보지만, Error Prone은 참조 필드의 타입이 불변인지도 봅니다. Error Prone 문서가 설명하는 보수적인 불변성의 정의에는 생성자에서 this가 밖으로 새지 않아야 한다는 조건도 있지만, 2.50.0의 ImmutableChecker는 일반적인 생성자 본문의 this 탈출까지 분석하지는 않습니다. 아래 클래스는 final이 아닌 필드와 final 이지만 타입이 가변인 필드를 하나씩 가지고 있습니다.

package net.benelog;

import java.util.List;

import com.google.errorprone.annotations.Immutable;

/**
 * Error Prone 자체 패키지의 @Immutable. final이 아닌 필드와
 * final이지만 가변 타입인 필드를 모두 컴파일 오류로 보고한다.
 */
@Immutable
public class ErrorProneMemo {
	private String content;
	private final List<String> tags;

	public ErrorProneMemo(String content, List<String> tags) {
		this.content = content;
		this.tags = tags;
	}

	public String getContent() {
		return content;
	}

	public List<String> getTags() {
		return tags;
	}
}

두 필드가 모두 컴파일 오류로 보고됩니다.

ErrorProneMemo.java:13: error: [Immutable] type annotated with @Immutable could not be proven immutable: 'ErrorProneMemo' has non-final field 'content'
	private String content;
	               ^
    (see https://errorprone.info/bugpattern/Immutable)
  Did you mean 'private final String content;'?
ErrorProneMemo.java:14: error: [Immutable] type annotated with @Immutable could not be proven immutable: 'ErrorProneMemo' has field 'tags' of type 'java.util.List<java.lang.String>', 'List' is mutable
	private final List<String> tags;
	                           ^
    (see https://errorprone.info/bugpattern/Immutable)

tags가 통과하려면 Error Prone이 불변으로 아는 타입이거나 @Immutable이 붙은 타입이어야 합니다. @Immutable이 붙은 인터페이스를 구현한 클래스도 같은 검사를 받고, 제네릭 클래스는 containerOf 속성으로 어떤 타입 파라미터가 불변이어야 하는지 지정합니다.

같은 코드에서 import만 javax.annotation.concurrent.Immutable로 바꾼 JsrMemo는 오류 없이 컴파일됩니다. @GuardedBy는 JSR-305 패키지도 검사하지만 @Immutable은 Error Prone 자체 패키지만 검사합니다.

@ThreadSafe 검사

com.google.errorprone.annotations.ThreadSafe에도 ThreadSafe 검사 문서가 있습니다. 버그 패턴 목록에서 'Experimental' 그룹에 있어서 켜기만 하면 될 것처럼 보이지만, 실제로는 켤 수 없습니다. Gradle 플러그인에서 options.errorprone.error("ThreadSafe")로 켜면 'ThreadSafe is not a valid checker name' 오류로 빌드가 실패합니다. Error Prone 2.50.0 소스의 내장 검사 목록인 BuiltInCheckerSuppliersGuardedByCheckerImmutableChecker는 있지만 ThreadSafeChecker는 없습니다. 확인해 본 2.3.0부터 2.45.0까지의 이전 버전들에도 없었습니다. 검사기 클래스 자체는 error_prone_core jar에 들어 있습니다.

그래서 아래 클래스는 기본 설정에서 아무 오류 없이 컴파일됩니다.

package net.benelog;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import com.google.errorprone.annotations.ThreadSafe;

/**
 * Error Prone 자체 패키지의 @ThreadSafe. 기본 설정의 Error Prone 2.50.0은 검사하지 않는다.
 * -PthreadSafeCheck로 ThreadSafeChecker를 등록하면 lock 없이 바뀌는 필드와
 * 스레드 안전하지 않은 타입의 final 필드를 컴파일 오류로 보고한다.
 */
@ThreadSafe
public class ErrorProneRegistry {
	private int count;
	private final Map<String, String> entries = new HashMap<>();
	private final ConcurrentHashMap<String, String> safeEntries = new ConcurrentHashMap<>();

	public void register(String key, String value) {
		count++;
		entries.put(key, value);
		safeEntries.put(key, value);
	}

	public int getCount() {
		return count;
	}
}

이 검사기가 무엇을 보는지 확인하려고 예제 저장소에 threadsafe-check 서브프로젝트를 만들었습니다. ThreadSafeChecker를 상속한 클래스를 META-INF/services에 등록해서 Error Prone의 플러그인 검사로 불러오는 실험용 코드입니다. Error Prone은 컴파일러의 처리기 경로에서 com.google.errorprone.bugpatterns.BugChecker를 서비스 인터페이스로 삼아 ServiceLoader로 검사기를 찾기 때문에, 그 이름의 파일에 검사기 클래스 이름을 적어 두면 됩니다.

com.google.errorprone.bugpatterns.threadsafety.ThreadSafeCheck

등록한 클래스는 ThreadSafeChecker를 상속하고 @BugPattern으로 검사 이름을 붙인 래퍼입니다. 생성자가 패키지 전용이라 같은 패키지 이름을 쓰고, ServiceLoader가 요구하는 기본 생성자를 형식적으로 두는 등 2.50.0의 내부 구조에 기댄 코드라서 실제 프로젝트에 권하지는 않습니다.

package com.google.errorprone.bugpatterns.threadsafety;

@BugPattern(
		name = "ThreadSafe",
		summary = "Type declaration annotated with @ThreadSafe is not thread safe",
		severity = ERROR)
public class ThreadSafeCheck extends ThreadSafeChecker {

	/** ServiceLoader가 요구하는 public 기본 생성자. Error Prone은 @Inject 생성자를 쓴다. */
	public ThreadSafeCheck() {
		super(null, null);
	}

	@Inject
	ThreadSafeCheck(WellKnownThreadSafety wellKnownThreadSafety,
			ThreadSafeAnalysis.Factory threadSafeAnalysisFactory) {
		super(wellKnownThreadSafety, threadSafeAnalysisFactory);
	}
}

이 서브프로젝트를 검사 대상 프로젝트의 errorprone 구성에 의존성으로 넣으면 플러그인 검사로 실립니다. 예제에서는 Gradle 프로퍼티가 있을 때만 넣도록 했고, 앞에서 인용한 build.gradle.kts에서는 이 부분을 생략했습니다.

dependencies {
	errorprone("com.google.errorprone:error_prone_core:2.50.0")
	if (project.hasProperty("threadSafeCheck")) {
		// 기본 검사 목록에 없는 ThreadSafeChecker를 플러그인으로 등록한다.
		errorprone(project(":threadsafe-check"))
	}
}

./gradlew :errorprone:compileJava -PthreadSafeCheck로 실행하면 다음과 같이 보고합니다.

ErrorProneRegistry.java:16: error: [ThreadSafe] @ThreadSafe class fields should be final or annotated with @GuardedBy. See https://errorprone.info/bugpattern/ThreadSafe for details.
	private int count;
	            ^
    (see https://errorprone.info/bugpattern/ThreadSafe)
  Did you mean 'private final int count;'?
ErrorProneRegistry.java:17: error: [ThreadSafe] @ThreadSafe class has non-thread-safe field, 'Map' is not thread-safe
	private final Map<String, String> entries = new HashMap<>();
	                                  ^
    (see https://errorprone.info/bugpattern/ThreadSafe)

검사 규칙은 필드가 final이면서 선언 타입이 Error Prone이 스레드 안전하다고 아는 타입이거나, @GuardedBy가 붙어 있어야 한다는 것입니다. countfinal@GuardedBy도 없어서, entries는 선언 타입 Map이 스레드 안전 타입이 아니라서 걸립니다. 실제 객체가 ConcurrentHashMap이어도 선언 타입이 Map이면 걸립니다. 선언 타입까지 ConcurrentHashMapsafeEntries는 통과합니다. 앞에서 본 ErrorProneCounter는 count@GuardedBy("this")가 있어서 이 검사는 통과하고 @GuardedBy 검사에만 걸립니다.

정리하면 2026년 9월 시점의 Error Prone은 @Immutable@GuardedBy는 검사하지만, @ThreadSafe는 위처럼 검사기를 직접 플러그인으로 등록하지 않는 한 문서 역할만 합니다.

IntelliJ IDEA

IntelliJ IDEA 2026.2는 Concurrency annotation issues 그룹에 인스펙션 여섯 개를 두고 있습니다. 다섯 개는 @GuardedBy 용이고 하나는 @Immutable 용입니다. @ThreadSafe의 약속을 검증하는 인스펙션은 없습니다.

IntelliJ IDEA 2026.2의 Concurrency annotation issues 인스펙션 목록

Unguarded field access or method call 인스펙션은 net.jcip.annotations, javax.annotation.concurrent, org.apache.http.annotation, com.android.annotations.concurrency, androidx.annotation, com.google.errorprone.annotations.concurrent 패키지의 @GuardedBy를 모두 인식합니다. SpotBugs도 원본 JCIP 패키지를 읽지만 @GuardedBy("this")만 다루고 접근 비율로 짐작해 경고하는 데 그칩니다. IntelliJ는 이에 비해 원본 JCIP의 guard 표현을 직접 검사합니다.

Non-final field in @Immutable class 인스펙션은 @Immutable 클래스에 final이 아닌 필드가 있으면 경고합니다. 필드 타입이 가변인지는 보지 않으므로 검사 범위는 Error Prone이 아니라 SpotBugs와 같습니다. 원본 JCIP와 JSR-305 패키지에 더해 Error Prone의 com.google.errorprone.annotations.Immutable도 인식합니다.

@ThreadSafe는 'Threading issues' 그룹의 Access to static field locked on instance 인스펙션이 힌트로만 읽습니다. 이 인스펙션은 인스턴스 lock을 잡고 상수가 아닌 static 필드에 접근하면 경고합니다. 접근한 필드가 final이면 그 선언 타입의 애너테이션을 확인하고, 등록된 스레드 안전 애너테이션이 있으면 경고를 생략합니다. 따라서 애너테이션이 붙은 클래스 자체의 규약을 검증하지는 않습니다. 기본 목록에는 원본 JCIP, JSR-305, org.apache.http.annotation, com.android.annotations.concurrency 패키지의 @ThreadSafeandroidx.annotation, android.support.annotation 패키지의 @AnyThread가 들어 있습니다. Error Prone의 @ThreadSafe는 기본 목록에 없습니다.

주의할 점은 IntelliJ IDEA 2026.2의 인스펙션 등록 정보에서 'Concurrency annotation issues' 그룹이 모두 enabledByDefault="false", level="WARNING"으로 선언되어 있다는 것입니다. Settings의 Editor | Inspections | Java | Concurrency annotation issues에서 직접 켜야 합니다. 켜도 편집기 안의 경고라서 Error Prone처럼 javac 컴파일을 막지는 못합니다. CI에서 강제하려면 같은 인스펙션을 제공하는 Qodana 프로필에서 검사를 켜고 실패 조건을 설정하는 것처럼 별도의 실행 환경이 필요합니다.

세 도구를 정리하면 애너테이션만 보고 결정적으로 검사하는 범위는 좁습니다. SpotBugs는 @Immutable 클래스의 final 규칙만 직접 검사하고, @GuardedBy는 lock을 잡은 접근 비율로 짐작하며, @ThreadSafe@NotThreadSafe는 그 짐작의 근거로만 읽습니다. Error Prone은 @GuardedBy의 lock 누락과 @Immutable의 깊은 불변성을 컴파일 오류로 잡지만, 원본 JCIP 패키지는 인식하지 않고 @ThreadSafe 검사는 따로 등록해야 켜집니다. IntelliJ IDEA는 원본 JCIP를 포함한 여러 패키지의 @GuardedBy를 직접 검사하고 @Immutable은 SpotBugs와 같은 범위로 보지만, 인스펙션이 기본으로 꺼져 있고 편집기 경고에 그칩니다. 앞에서 error_prone_annotationsjcip-annotations 중 하나를 권한 이유가 여기에 있습니다. 컴파일을 막는 검사가 필요하면 Error Prone이 인식하는 애너테이션을, 네 가지 애너테이션을 모두 표기하면서 IDE와 SpotBugs의 검사를 받으려면 원본 JCIP 패키지를 써야 합니다.

ArchUnit으로 검증하는 프로젝트별 규칙

앞의 정적 분석 도구들은 애너테이션이 붙은 클래스 자체가 선언대로 구현되었는지를 일부 검사합니다. 그런데 비즈니스 애플리케이션을 개발하는 프로젝트에서 더 자주 생기는 사고는 다른 형태입니다. 스레드 안전하지 않다고 표시된 클래스를, 여러 스레드가 공유하는 객체가 아무런 동기화 없이 필드로 들고 있는 경우입니다.

예를 들어 Spring의 @RestController@Service 빈은 별도의 scope를 지정하지 않으면 기본이 singleton이라서 여러 요청 스레드가 필드를 공유합니다. request scope 같은 다른 scope를 쓸 수도 있지만 프로젝트의 정책으로 controller가 이런 타입을 필드로 보유하는 것 자체를 금지해서 이 문제를 예방하기도 합니다. 이런 규칙은 프로젝트에서 추구하는 구조에 따라 달라지므로 범용 정적 분석 도구에는 없습니다. ArchUnit을 쓰면 이 규칙을 JUnit 테스트로 작성해서 빌드마다 검사할 수 있습니다.

예제 코드

예제로 @NotThreadSafe가 붙은 클래스와, 그 클래스를 필드로 가진 컨트롤러를 만들었습니다. 이 컨트롤러에는 SimpleDateFormat 필드도 있습니다. 전체 프로젝트는 examples/thread-safety-archunit에 있습니다.

package net.benelog.report;

import net.jcip.annotations.NotThreadSafe;

@NotThreadSafe
public class ReportFormatter {
	private final StringBuilder buffer = new StringBuilder();

	public String format(String title, String body) {
		buffer.setLength(0);
		return buffer.append(title).append('\n').append(body).toString();
	}
}
package net.benelog.web;

import java.text.SimpleDateFormat;
import java.util.Date;

import net.benelog.report.ReportFormatter;
import net.benelog.report.ReportService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ReportController {
	private final ReportService reportService;
	private final ReportFormatter formatter = new ReportFormatter();
	private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");

	public ReportController(ReportService reportService) {
		this.reportService = reportService;
	}

	@GetMapping("/reports/{id}")
	public String report(@PathVariable long id) {
		return formatter.format(dateFormat.format(new Date()), reportService.find(id));
	}
}

규칙과 실행 결과

ArchUnit 테스트는 규칙 두 개로 구성했습니다.

첫 번째 규칙은 @RestController가 붙은 클래스의 필드 타입에 @NotThreadSafe가 붙어 있으면 외부 동기화 여부와 bean scope를 따로 분석하지 않고 실패합니다. ArchUnit은 바이트코드에서 애너테이션을 읽으므로 JCIP 계열의 RUNTIME retention이든 HttpClient @Contract의 CLASS retention이든 모두 검사할 수 있습니다. 분석 대상 패키지 밖에 있는 라이브러리 클래스도 기본 설정에서는 클래스패스에서 읽어 오므로, 라이브러리가 붙인 @NotThreadSafe도 같은 규칙으로 검사할 수 있습니다.

두 번째 규칙은 SimpleDateFormat처럼 애너테이션이 없는 JDK 클래스를 위한 것입니다. JDK 클래스에는 스레드 안전성 애너테이션이 없으므로 Format, Calendar, StringBuilder를 목록으로 지정했습니다.

package net.benelog;

import static com.tngtech.archunit.core.domain.JavaClass.Predicates.assignableTo;
import static com.tngtech.archunit.core.domain.properties.CanBeAnnotated.Predicates.annotatedWith;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields;

import java.text.Format;
import java.util.Calendar;

import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import net.jcip.annotations.NotThreadSafe;
import org.springframework.web.bind.annotation.RestController;

@AnalyzeClasses(packages = "net.benelog")
class ThreadSafetyArchTest {

	@ArchTest
	static final ArchRule controllers_should_not_hold_not_thread_safe_types =
			fields().that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
					.should().notHaveRawType(annotatedWith(NotThreadSafe.class))
					.because("controller는 기본 scope가 singleton이라 모든 요청 스레드가 필드를 공유한다");

	private static final DescribedPredicate<JavaClass> KNOWN_NOT_THREAD_SAFE_JDK_TYPES =
			assignableTo(Format.class)
					.or(assignableTo(Calendar.class))
					.or(assignableTo(StringBuilder.class))
					.as("JDK의 스레드 안전하지 않은 타입(Format, Calendar, StringBuilder)");

	@ArchTest
	static final ArchRule controllers_should_not_hold_known_not_thread_safe_jdk_types =
			fields().that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
					.should().notHaveRawType(KNOWN_NOT_THREAD_SAFE_JDK_TYPES)
					.because("JDK 클래스에는 스레드 안전성 애너테이션이 없으므로 목록으로 막는다");
}

ArchUnit 1.5.0, Spring Web 7.0.9, JUnit 6.1.3, JDK 25에서 ./gradlew test를 실행하면 두 규칙 모두 실패하고, 어떤 필드가 규칙을 어겼는지 알려 줍니다.

Architecture Violation [Priority: MEDIUM] - Rule 'fields that are declared in classes that are annotated with @RestController should not have raw type annotated with @NotThreadSafe, because controller는 기본 scope가 singleton이라 모든 요청 스레드가 필드를 공유한다' was violated (1 times):
Field <net.benelog.web.ReportController.formatter> has raw type annotated with @NotThreadSafe in (ReportController.java:0)

Architecture Violation [Priority: MEDIUM] - Rule 'fields that are declared in classes that are annotated with @RestController should not have raw type JDK의 스레드 안전하지 않은 타입(Format, Calendar, StringBuilder), because JDK 클래스에는 스레드 안전성 애너테이션이 없으므로 목록으로 막는다' was violated (1 times):
Field <net.benelog.web.ReportController.dateFormat> has raw type JDK의 스레드 안전하지 않은 타입(Format, Calendar, StringBuilder) in (ReportController.java:0)

같은 프로젝트에서 ClockDateTimeFormatter만 필드로 가진 HealthController는 두 규칙을 통과했습니다.

규칙에 걸린 ReportControllerSimpleDateFormat 대신 불변인 DateTimeFormatter를 필드로 두고, ReportFormatter를 요청을 처리하는 메서드의 지역 변수로 가두는 식으로 고칠 수 있습니다.

private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

@GetMapping("/reports/{id}")
public String report(@PathVariable long id) {
	ReportFormatter formatter = new ReportFormatter();
	return formatter.format(DATE_FORMAT.format(LocalDate.now()), reportService.find(id));
}

ReportController를 이렇게 바꾸고 테스트를 다시 실행하면 두 규칙을 모두 통과합니다. ReportFormatter는 호출마다 새로 만들어져 다른 요청 스레드와 공유되지 않습니다. 그러나 이 규칙을 통과했다는 사실은 컨트롤러 전체의 스레드 안전성을 증명하지 않습니다. 다른 공유 상태나 메서드 호출 순서의 경쟁 조건은 별도로 검토해야 합니다.

이 방식에는 한계도 있습니다. ArchUnit은 이 규칙에서 필드의 raw 선언 타입만 보므로, List로 선언한 필드에 ArrayList를 넣는 경우나 제네릭 타입 인자로 들어간 스레드 안전하지 않은 타입은 잡지 못합니다. 상위 타입에만 애너테이션이 있고 실제 선언 타입에는 없는 경우도 별도로 계층을 탐색하지 않으면 놓칩니다. 필드 접근을 외부 lock으로 보호하는지나 controller에 별도 scope가 붙었는지도 이 규칙은 판단하지 않습니다. 애너테이션이 없는 JDK 타입은 두 번째 규칙처럼 목록을 직접 관리해야 합니다.

정리

Java에서 어떤 클래스가 멀티스레드에서 의도하지 않게 쓰일 때 그 부작용은 심각하지만, 문제가 생긴 곳을 추적하기는 어렵습니다. 그렇기 때문에 스레드 안전성은 문서에 분명하게 적어야 합니다. 그러나 Javadoc 설명문에 적는 방식은 클래스마다 위치와 표현이 제각각이고, 사람이 주의 깊게 읽어야만 효과가 있습니다.

스레드 안전성을 애너테이션으로 표시하면 위치와 형식이 일정해지고 도구가 읽을 수 있습니다. Error Prone의 애너테이션이나 Apache 라이선스로 재구현된 JCIP 애너테이션을 권합니다. IDE와 빌드 도구로 이들 애너테이션이 의도하는 규칙을 지켰는지도 검사할 수 있습니다.

스레드 안전성을 위해 프로젝트별로 정한 규칙은 ArchUnit으로 검사하기를 권합니다.

참고 자료

주요 변경이력
  • 2026.09.05

    • 정적 검사 도구와 IDE 지원 범위 조사 보강

  • 2026.09.03

    • 제목과 절 구조를 개편

    • 대상 라이브러리 최신화

    • ArchUnit 검사 추가

  • 2012.05.30

    • 최초 작성