Showing Posts From

버전

버전 관리 충돌: Git merge의 악몽

버전 관리 충돌: Git merge의 악몽

버전 관리 충돌: Git merge의 악몽 월요일 오전 10시 출근했다. 슬랙을 켰다. 빨간 알림 37개. "김개발님, UserService.java 머지 부탁드립니다 ㅎㅎ" 후배 박주니어가 올린 PR이다. 코드 확인했다. 내가 금요일에 수정한 파일이랑 겹친다. 아뿔싸. 금요일 저녁 7시. 급하게 hotfix 커밋하고 튀었다. push는 했는데 PR은 안 올렸다. 주말 내내 까먹었다. 박주니어는 금요일 오후에 브랜치 땄다. 내 hotfix 이전 버전에서. "충돌 날 거 같은데요." 답장을 보냈다. 3분 후. "어? 진짜네요. 어떻게 하죠?"충돌 파일 열어보기 <<<<<<< HEAD public void updateUser(Long userId, UserDto dto) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException()); user.update(dto); cacheManager.evict("users", userId); // 내가 추가한 캐시 처리 ======= public void updateUser(Long userId, UserDto dto) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException()); validateUserStatus(user); // 박주니어가 추가한 검증 로직 user.update(dto); >>>>>>> feature/user-validation둘 다 필요하다. 내 코드는 캐시 버그 수정이다. 프로덕션에서 유저 정보가 안 갱신되던 문제. 급해서 금요일에 핫픽스했다. 박주니어 코드는 기획에서 요청한 기능이다. 탈퇴 유저 업데이트 방지. 월요일 배포 예정이었다. 둘 다 놓치면 안 된다. 전화했다. "주니어님, 잠깐 통화 가능하세요?" "네 형님!" 형님 소리는 별로다. 나이 차이 4살인데. 협상 시작 "제 코드랑 주니어님 코드 둘 다 살려야 할 것 같은데요." "아 네! 그럼 제가 다시 짤까요?" 착한 후배다. 근데 시간이 아깝다. "아니요. 제가 해볼게요. 근데 validateUserStatus 메서드 어디 있어요?" "아 그거요. UserValidator 클래스 새로 만들었어요. src/main/java/validators 폴더에요." validators 폴더? 처음 듣는다. "그 폴더 언제 만드셨어요?" "이번 주에요! 검증 로직 분리하려고요. 클린 코드 책에서 봤는데..." 좋은 의도다. 근데 팀원들한테 얘기했어야지. 폴더 구조 바꾸는 건 함께 논의한다. 우리 팀 룰이다. 주니어는 아직 모른다. "아 그거 좋은데요. 근데 다음엔 먼저 얘기해주세요. 폴더 구조 바뀌면 다른 분들도 헷갈려하거든요." "아... 죄송해요." 목소리가 풀 죽었다. 기분 상한 것 같다. "아니 괜찮아요. 좋은 시도예요. 나중에 팀 회의 때 같이 정리해봅시다." "네!" 목소리가 다시 밝아졌다. 다행이다.코드 합치기 시작 일단 pull 받는다. git pull origin develop예상대로 conflict 발생. CONFLICT (content): Merge conflict in src/main/java/service/UserService.java CONFLICT (content): Merge conflict in src/test/java/service/UserServiceTest.java테스트 코드도 충돌이다. 예상 못 했다. 테스트 파일 열었다. <<<<<<< HEAD @Test void updateUser_캐시_삭제_확인() { // given Long userId = 1L; UserDto dto = createUserDto(); // when userService.updateUser(userId, dto); // then verify(cacheManager).evict("users", userId); ======= @Test void updateUser_탈퇴유저_검증() { // given Long userId = 1L; User withdrawnUser = createWithdrawnUser(); when(userRepository.findById(userId)).thenReturn(Optional.of(withdrawnUser)); // then assertThrows(UserStatusException.class, () -> userService.updateUser(userId, createUserDto())); >>>>>>> feature/user-validation둘 다 다른 테스트다. 근데 같은 위치에 넣었다. 이건 둘 다 남겨야 한다. 순서만 정리하면 된다. 합치는 중 UserService.java부터 정리했다. public void updateUser(Long userId, UserDto dto) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException()); // 박주니어 코드 validateUserStatus(user); // 내 코드 user.update(dto); cacheManager.evict("users", userId); }깔끔하다. 검증 먼저, 업데이트 다음, 캐시 삭제 마지막. 근데 생각해보니 validateUserStatus는 private 메서드로 만들어야 한다. 외부에서 부를 필요 없다. 박주니어 코드 확인했다. public으로 되어 있다. 고쳤다. private void validateUserStatus(User user) { if (user.isWithdrawn()) { throw new UserStatusException("탈퇴한 회원입니다."); } }테스트는 더 간단했다. 두 테스트 메서드 순서만 정리. @Test void updateUser_정상동작() { // 기존 테스트 }@Test void updateUser_탈퇴유저_검증() { // 박주니어 테스트 }@Test void updateUser_캐시_삭제_확인() { // 내 테스트 }저장했다. 빌드 돌려보기 ./gradlew clean build기도했다. 제발 한 번에 성공해라. 30초 후. BUILD SUCCESSFUL in 28s됐다! 근데 테스트 결과 확인해야 한다. ./gradlew test --tests UserServiceTest전부 통과했다. 124개 테스트 모두 초록불. 안심이다.푸시하기 전 확인 박주니어한테 메시지 보냈다. "주니어님, 코드 합쳤어요. 제가 좀 수정한 게 있는데 괜찮을까요?" "뭐 수정하셨어요?" "validateUserStatus를 private으로 바꿨어요. 이 메서드는 UserService 내부에서만 쓰니까요." "아 맞네요! 그게 맞는 것 같아요." "그리고 validators 패키지는 일단 안 만들었어요. 나중에 검증 로직이 더 많아지면 그때 분리하는 게 좋을 것 같아서요." 잠시 답이 없었다. 3분쯤 지났다. "네 알겠습니다. 그게 나을 것 같네요." 목소리가 조금 아쉬운 것 같다. 그래도 이해는 하는 것 같다. "주니어님 코드 아이디어는 좋았어요. 검증 로직 분리하는 거. 나중에 꼭 해봅시다. 지금은 검증이 하나뿐이라 오버 엔지니어링일 수 있어서요." "아 네! 감사합니다 형님!" 다시 밝아졌다. 푸시와 PR git add . git commit -m "Merge: user validation + cache eviction" git push origin develop푸시했다. 슬랙에 알림이 떴다. GitHub Actions 돌아간다. CI 파이프라인이 돌아간다. 빌드, 테스트, 린트 체크. 2분 후. 초록불. 박주니어 PR에 코멘트 남겼다. "LGTM. 제 hotfix랑 합쳤습니다. 확인 부탁드려요." 1분 후. 박주니어가 approve 했다. "확인했습니다! 감사합니다 형님!" 머지 버튼 눌렀다. develop 브랜치에 합쳐졌다. 끝났다. 오후 3시, 후속 작업 근데 문제가 또 있다. 이찬민 팀장이 슬랙 메시지 보냈다. "김개발님, user 관련 코드 왜 두 번 커밋됐어요? 금요일이랑 오늘이랑." 아차. PR 안 올리고 바로 푸시한 거 들켰다. "금요일에 긴급 버그 수정이었습니다. PR 올릴 시간이 없어서..." "그래도 PR은 올려야죠. 팀 룰이잖아요." 맞는 말이다. "죄송합니다. 다음부터 조심하겠습니다." "알겠습니다. 근데 주니어님이랑 충돌 안 났어요?" "났습니다. 제가 합쳤어요." "수고하셨어요." 휴. 팀장은 착하다. 다른 회사 동기들 얘기 들으면 우리 팀장은 천사다. 회고 merge conflict는 피할 수 없다. 팀 프로젝트니까. 중요한 건 충돌을 어떻게 해결하느냐다. 오늘 배운 것들:hotfix도 PR 올리기: 급해도 기록은 남겨야 한다. 나중에 누가 왜 고쳤는지 알아야 한다.폴더 구조 변경은 함께: 좋은 의도라도 혼자 결정하면 안 된다. 팀원들이 헷갈린다.conflict 해결은 소통으로: 내 코드가 맞다고 우기지 말기. 상대방 코드 의도 먼저 이해하기.테스트는 꼭 돌리기: 합쳤다고 끝이 아니다. 빌드 성공해도 테스트 실패할 수 있다.후배 기분 챙기기: 코드 리뷰는 코드만 보는 게 아니다. 사람도 봐야 한다.박주니어는 좋은 개발자가 될 것 같다. 의욕도 있고 배우려고 한다. 나도 저랬다. 7년 전에. 그때 선배들이 잘 가르쳐줬다. 지금 내가 그 역할이다. 책임감이 든다. 무겁다. 퇴근길 6시 15분에 나왔다. 평소보다 늦었다. conflict 해결하느라. 지하철에서 폰 봤다. 슬랙 알림 3개. "김개발님 내일 회의 때 validators 패키지 건 같이 얘기해봐요!" 박주니어다. "네 좋습니다." 답장 보냈다. 집에 도착했다. 7시 10분. 아내가 저녁 만들고 있다. 김치찌개 냄새가 난다. "오빠 늦었네?" "응. merge conflict." "뭐 그게 뭐야?" 설명 안 했다. 길다. "그냥 코드 충돌." "힘들었겠다." "응." 밥 먹었다. 맛있다. 넷플릭스 틀었다. 아무 생각 없이 봤다. 10시에 잤다. 내일 또 회의다.충돌은 일상이다. 해결하면 된다.

Spring Boot 버전 업그레이드: 일주일을 잃다

Spring Boot 버전 업그레이드: 일주일을 잃다

Spring Boot 버전 업그레이드: 일주일을 잃다 아, 그거요. 이 이야기를 하려면 먼저 지난 월요일 아침으로 돌아가야 한다. 커피를 마시면서 슬랙을 보다가 기술 뉴스 채널에서 "Spring Boot 3.2.0 릴리즈! 획기적인 성능 개선과 새로운 기능들"이라는 메시지를 봤다. 그 순간의 나는 정말 한심한 수준의 낙관주의자였다. 마치 신작 게임이 나왔다고 생각하고 선구매하는 게이머처럼, 나는 "오, 이 정도면 업그레이드 간단하겠는데?"라고 생각했다. 지금 생각해보니 그게 모든 고통의 시작이었다. 호기심 같은 업그레이드의 시작 현재 프로젝트는 Spring Boot 2.7.15 버전을 쓰고 있었다. 안정적이고, 버그도 별로 없고, 팀원들도 익숙한 버전. 근데 왜 하필 그날 업그레이드를 시작했을까? 이유는 정말 초라한 수준이었다. "요새 Spring Boot 3.2를 안 쓰면 구시대 개발자처럼 보일 거 같은데?" 이 정도의 이유였다.build.gradle 파일을 열고 springBootVersion을 3.2.0으로 바꿨다. 엔터를 쳤다. IDE는 즉시 빨간 줄을 그었다. 대량의 빨간 줄이었다. 처음엔 괜찮을 거라고 생각했다. 점진적으로 해결하면 되겠지, 하는 마음으로. 결국 모든 프로젝트의 죽음은 이런 순진함에서 시작된다. 의존성 지옥의 나락으로 첫 번째 문제는 Gradle 버전이었다. Spring Boot 3.2.0은 Gradle 7.6 이상을 요구했는데, 우리 프로젝트는 7.3을 쓰고 있었다. 간단하다고 생각했다. Gradle 버전을 올리면 되겠지. 그런데 Gradle 버전을 올리니까 이번엔 Java 버전 문제가 나왔다. 프로젝트에서 Java 11을 쓰고 있었는데, Spring Boot 3.x는 최소 Java 17을 요구한다. 알겠다, Java 버전도 올리자. Java 17로 올렸다. 이제 정말 되겠지? 아니었다. 이건 마치 도미노 같았다. 한 개를 넘치니까 다 넘어지는 그런 느낌이었다. 프로젝트에서 쓰던 Querydsl이 호환성 문제를 일으켰다. Maven 중앙 저장소를 뒤져보니, 우리가 쓰던 Querydsl 4.4.0은 Spring Boot 3.x와 호환성 문제가 있었다. 최신 버전인 5.0.0으로 올려야 했다. Querydsl 5.0.0으로 올렸다. 그럼 이제 되겠지? gradle build를 실행했다. 또 다른 에러가 나왔다. javax 패키지들이 사라졌다는 것이었다. Spring Boot 3.0부터 javax.*에서 jakarta.*로 완전히 전환되었기 때문이다. 이건 단순 버전 업 문제가 아니라 전체 구조 변경이었다. // 이게 모두 바뀌어야 함 import javax.persistence.Entity; import javax.persistence.GeneratedValue;// 이렇게 import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue;프로젝트 전체에서 javax를 jakarta로 바꾸는 작업이 시작되었다. IDE의 대량 검색-바꾸기 기능을 썼지만, 손으로 일일이 확인해야 하는 경우도 많았다. 왜냐하면 javax.servlet도 바꿔야 하고, javax.validation도 바꿔야 하고, javax.mail도... 이 작업만 3시간이 걸렸다. 화요일: 첫 번째 깨달음 화요일 오전. 슬랙에서 후배 개발자가 물어봤다. "선임님, 이 PR 리뷰 좀 해주실 수 있나요?" 나는 "오케이, 잠깐만" 이라고 했지만, 내 손은 여전히 EntityManagerFactory 설정 파일을 수정 중이었다. 일주일에 보기로 했던 신입 면접 준비도 날려먹었다. 점심 시간에 회사 근처 김치찌개집에서 사장님과 뵈면서도 "요새 너무 바빠서 ㅋㅋㅋ" 하고 웃었지만, 사실 웃음 뒤에는 절망이 묻어있었다."Spring Boot 3.2 하면서 뭐 이렇게 복잡해?" 라고 생각했지만, 알고 보니 그건 시작일 뿐이었다. 수요일: Jackson의 배신 수요일이 되자, 빌드 에러는 줄었지만 이번엔 런타임 에러가 터지기 시작했다. API 응답이 JSON으로 변환되지 않았다. 원인은 Jackson 라이브러리 업데이트였다. 예전 버전에서는 기본적으로 작동하던 기능들이 새 버전에서는 명시적으로 설정해야 했다. // 기존엔 이게 자동으로 됐는데... LocalDateTime.now()// 이제는 이렇게 설정해야 함 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createdAt;프로젝트 곳곳에 있는 API 응답 DTO 수십 개를 일일이 확인하고 수정했다. 각각의 LocalDateTime에 어노테이션을 달아야 했다. 이 작업을 하면서 나는 진짜로 "차라리 Spring Boot 2.7 유지하는 게 낫지 않을까?"라는 생각을 처음으로 진지하게 해봤다. 슬랙에 투덜거렸다. "이거 왜 이리 복잡하냐고... 대충 호환 모드 같은 게 없나?" 팀장이 답장했다. "그냥 고민 좀 해봐. 급할 건 없으니까." 하지만 그건 팀장 말이었고, 내 심리 상태는 이미 "이건 내가 해결해야 하는 문제"라는 강박에 빠져있었다. 목요일: 데이터베이스 커넥션의 악몽 목요일. 애플리케이션이 어느 정도 떠올랐다. 그런데 데이터베이스 커넥션에서 문제가 터졌다. Connection pool 에러였다. Spring Boot 3.x에서는 기본 데이터베이스 커넥션 풀이 HikariCP에서 다른 것으로 변경되었을 뿐 아니라, 설정 방식도 살짝 달라져있었다. # 예전엔 이게 먹혔는데 spring: datasource: hikari: maximum-pool-size: 20# 새 버전에선 이렇게 해야 함 (세부사항이 좀 다름) spring: datasource: hikari: max-lifetime: 1800000 idle-timeout: 900000설정 파일을 뒤지고, Spring Boot 공식 문서를 뒤지고, 스택오버플로우를 샅샅이 뒤졌다. "아 그거요" 하면서 몇 가지 설정을 건드렸더니, 로컬 환경에서는 돌아갔다. 근데 스테이징 환경에서는 안 돌아갔다. 왜냐하면 스테이징 환경은 우리 프로젝트가 쓰는 여러 마이크로서비스 중 하나일 뿐이었고, 다른 서비스들도 같은 데이터베이스를 쓰고 있었기 때문이다. 커넥션 풀 설정을 건드리니까 다른 서비스들의 연결이 끊어졌다. 데이터베이스 팀에 연락했다. 죄송합니다, 제가 실수했습니다. 다시 설정할 테니 잠깐만요. 금요일: 마이크로미터 메트릭 문제 금요일이 되자, 모니터링 시스템이 문제를 일으켰다. Prometheus 메트릭 수집이 안 되고 있었다. Spring Boot 3.x에서 메트릭 수집 방식이 바뀌면서 기존 설정이 더 이상 작동하지 않았다. Micrometer 라이브러리의 주요 메서드와 클래스 이름이 바뀌었다. // 예전 코드 MeterRegistry meterRegistry; meterRegistry.counter("custom.metric").increment();// 새 코드 (내부 동작 방식이 좀 다름) @Timed(value = "custom.metric") public void someMethod() { // ... }금요일 오후 3시. 나는 모니터링 알림이 작동하는 것을 보기 위해 필사적이었다. 왜냐하면 월요일에 본배포가 예정되어있었기 때문이다. 퇴근 시간을 넘겨서 계속 작업했다. 6시, 7시, 8시... 9시가 되어서야 메트릭 수집이 정상화되었다. 집에 도착한 건 10시가 넘었다. 아내는 이미 잠들어있었다. 나는 라면을 끓여먹고 침대에 쓰러졌다. 둘째 주 월요일: 통합 테스트의 지옥 둘째 주 월요일. 로컬 빌드는 성공했지만, CI/CD 파이프라인에서 테스트가 실패했다. "어? 로컬에선 되는데?" 이건 개발자가 가장 싫어하는 상황이다. 로컬 환경과 테스트 환경의 차이 때문이었다. 통합 테스트에서 쓰는 TestContainers의 버전과 Spring Boot 3.x의 호환성 문제였다. Docker를 통해 테스트 DB를 구성하는데, 이 과정에서 버전 미스매치로 인한 타이밍 이슈가 발생했다. 테스트를 하나씩 실행하면 성공하는데, 전체 테스트를 실행하면 일부가 실패했다. 이건 경합 조건(race condition) 같은 거였다. TestContainers 버전을 올렸다. 그럼 이제 되겠지? 아니었다. 이번엔 다른 테스트가 실패했다. 결국 나는 8시간을 테스트 디버깅에만 썼다. 마지막에는 테스트 타임아웃 설정을 조정하고, 데이터베이스 초기화 순서를 바꿔야 했다.셋째 주 초: 배포와 롤백의 악몽 업그레이드를 시작한 지 8일째. 드디어 본배포 준비가 됐다고 생각했다. 배포는 성공했다. 서버가 떠올랐다. 모든 것이 정상으로 보였다. 그런데 2시간 뒤, 모니터링 대시보드에서 에러율이 갑자기 올라갔다. 원인은 Spring Security 설정이었다. Spring Boot 3.x에서 보안 설정 방식이 바뀌면서, 기존 설정이 예상하지 못한 동작을 하고 있었다. 특정 엔드포인트에 대한 권한 체크가 너무 엄격해져있었다. 30분 뒤 롤백 결정. 내가 업그레이드한 Spring Boot 3.2 버전은 다시 2.7로 돌아갔다. 슬랙 타이머가 종료되었다. 돌아온 월요일: 후회와 깨달음 원점으로 돌아왔다. 일주일을 완전히 낭비했다. 이 시간에 내가 할 수 있었던 것들을 생각해봤다.신입사원 온보딩 프로세스 개선 레거시 코드 리팩토링 테크 로드맵 수립 아내와 시간 보내기 충분한 숙면대신 나는 뭘 했나? javax → jakarta 변환하고, 테스트 타임아웃 튜닝하고, Spring Security 설정을 삽질했다. 후배들이 물어봤다. "선임님, Spring Boot 3.2는 괜찮던데요?" "음, 아직 좀 이른 거 같아. 마이너 버전 유지하자." 팀장은 나에게 물었다. "이번에 뭘 배웠어?" 나는 웃었다. "아, 그거요. 버전 업그레이드는 정말 신중해야 한다는 거요." 얻은 교훈 (그리고 다음번엔 이렇게 할 거) 일주일을 낭비한 대가로, 나는 몇 가지 중요한 걸 배웠다. 먼저, 주요 메이저 버전 업그레이드는 절대 혼자 끙끙대며 하지 말 것. 팀과 함께 계획을 세우고, 명확한 일정과 롤백 계획을 세워야 한다. 둘째, 마이너 버전 유지의 가치를 인정할 것. Spring Boot 2.7이 LTS 버전이고 충분히 안정적이라면, 굳이 3.x로 업그레이드할 이유가 있을까? 비용과 이득을 따져봐야 한다. 셋째, 호환성 문서를 미리 읽을 것. Spring 공식 마이그레이션 가이드가 있다. 읽어봤으면 훨씬 빨랐을 거다. 넷째, 의존성 버전 고정의 중요성. Gradle의 dependency lock을 활용하면, 버전 충돌을 미리 감지할 수 있다. 마지막으로, 신중함의 가치. 호기심과 선도적 기술 도입은 좋지만, 모든 비용을 개인이 떠안는 건 아니다. 에필로그: 그래도 계속 개발하는 이유 이 이야기를 읽으면, "개발 일이 답답하네. 그냥 그만두지?"라고 생각할 수도 있다. 근데 솔직히, 이런 일들이 반복되어도 나는 개발을 계속한다. 왜냐하면 이 불편함 뒤에 무언가가 있기 때문이다. 새로운 기술을 배우는 과정은 고통스럽지만, 배운 후에는 문제 해결 능력이 한 단계 올라간다. Spring Boot 3.x로 업그레이드하면서 나는 Jakarta 네임스페이스, 마이크로미터 메트릭, Spring Security 설정 등에 대해 깊이 있게 배웠다. 다음에 비슷한 문제가 나타나면, 훨씬 빨리 해결할 수 있을 거다. 그리고 이 경험이, 다른 후배들에게 "신중함이 얼마나 중요한지"를 가르쳐주는 좋은 사례가 될 수도 있다. 결국 개발이라는 일은 이런 반복이다. 실패하고, 배우고, 다시 시도하고. 매번 조금씩 성장한다. 물론 일주일을 날린 건 별로 기분 좋지 않은 결과지만, 그래도 내일은 또 다른 문제를 해결하러 깃을 켤 것이다. 왜냐하면 개발자는 그렇게 되어있으니까.차라리 처음부터 "유지보수 vs 혁신"을 제대로 따져볼 걸.