kimyenac
techblog
diary
OSSCA 2026 Challenges 회고
2026-08-14

오픈소스 컨트리뷰션은 늘 "언젠가 해봐야지" 목록에 있었지만, 실제로 기여를 해 본 적은 한 번도 없었습니다. 그렇게 우선순위가 밀린 채로 지내다가 OSSCA 에서 참여형 멘티를 모집한다는 메시지를 받게 되었고, 비교적 즉흥적으로 지원한 뒤 Apache Zeppelin 프로젝트 팀에서 활동하게 되었습니다.


Challenges 기간 동안 Apache Zeppelin 에 PR 22건을 올려 모두 머지했습니다. 이번 글에서는 그 한 달 동안 기술적으로 재미있었던 작업들과, 오픈소스 기여가 회사 업무와 어떻게 달랐는지를 정리해보고자 합니다.




Apache Zeppelin

Apache Zeppelin 은 웹 기반 데이터 분석 노트북입니다. Jupyter 와 비슷한 도구인데, Spark 나 JDBC 같은 여러 인터프리터를 한 노트 안에서 함께 쓸 수 있고 여러 사람이 같은 노트를 동시에 볼 수 있다는 점이 다릅니다.


프론트엔드 입장에서 흥미로운 점은 UI 구현이 여러 벌 존재한다는 것입니다.

zeppelin-web          AngularJS 로 만든 Classic UI (여전히 기본값)
zeppelin-web-angular  Angular 로 다시 쓴 New UI
zeppelin-react        일부 화면의 React 구현

같은 기능이 두세 군데에 존재하기 때문에, 한쪽만 고쳐진 버그가 꽤 있습니다. 뒤에서 다시 이야기하겠지만 이 점이 신규 컨트리뷰터에게는 오히려 기회가 되었습니다.





첫 PR

발대식이 7월 11일이었고, 첫 PR 이 머지된 것은 7월 12일입니다.


큰 작업은 아니었습니다. Zeppelin 공식 홈페이지에 Twitter 임베드 섹션이 깨진 채로 노출되고 있었는데, 트위터가 API 정책을 바꾸면서 jekyll-twitter-plugin 이 더 이상 동작하지 않았기 때문입니다. 해당 섹션과 동작하지 않는 의존성을 제거하고, 섹션이 사라지면서 어긋나는 레이아웃을 함께 정리했습니다.


코드로는 몇십 줄짜리 변경입니다. 그런데 이 작은 성공이 남은 한 달의 페이스를 만들어줬습니다. "이 프로젝트에서 내가 할 수 있는 일이 있다" 는 감각이 생기면서 이슈 탭을 여는 부담이 줄었습니다.


진입점을 찾는 방법

며칠 헤매다가 알게 된 점이 하나 있습니다. 코드베이스에는 이미 "여기 문제가 있다" 는 표시가 잔뜩 남아 있다는 것입니다.


제가 실제로 검색한 키워드는 아래와 같습니다.

rg "TODO\(" --type ts
rg "@ts-ignore" --type ts
rg "deprecated" --type ts

이렇게 찾은 것들이 그대로 PR 이 되었습니다. TODO(hsuanxyz) dateStarted undefined after start 주석은 실행 시간이 잘못 표시되던 버그였고, "TypeScript 4.9 이전이라 satisfies 를 쓸 수 없다" 는 TODO 3곳은 이미 5.9 를 쓰고 있어서 필요가 없어진 주석이었습니다. @ts-ignore 3개는 타입 가드로 대체할 수 있었습니다.


이런 주석은 기존 개발자들이 "지금은 고치지 못하지만 누군가 정리해주면 좋겠다" 는 의미로 남겨둔 표시입니다. 맥락이 주석 안에 이미 요약되어 있기 때문에, 신규 컨트리뷰터에게는 좋은 진입점이 됩니다.





PR 본문이 코드만큼 중요한 이유

한 달 동안 가장 크게 바뀐 습관입니다.


회사에서 코드 리뷰를 받을 때는 "이거 왜 이렇게 했어요?" 라는 질문이 오면 슬랙으로 답하면 끝입니다. 맥락을 공유하는 사람들끼리이기 때문에 두세 마디면 충분합니다.


오픈소스는 다릅니다. 리뷰어는 저를 모르고, 하루에 여러 PR 을 봐야 하기 때문에 제 PR 에 쓸 수 있는 시간은 몇 분 정도입니다. 그 시간 안에 "이 변경은 안전하다" 는 점을 설명하지 못하면 PR 은 뒤로 밀리게 됩니다.


사례 1. substr 을 slice 로 바꾸는 데 검증이 필요했던 이유

가장 사소해 보였던 PR 이 이 부분을 가장 잘 보여줍니다.


노트 생성 다이얼로그가 String.prototype.substr() 을 사용하고 있었습니다. MDN 에서 레거시(Annex B)로 분류된, 웹 호환성 유지를 위해서만 남아 있는 API 입니다. slice 로 바꾸면 되는 간단한 작업처럼 보이지만, 두 함수는 두 번째 인자의 의미가 다릅니다.

'hello world'.substr(6, 3)   // 'wor'   6번 인덱스부터 '3글자'
'hello world'.slice(6, 3)    // ''      6번 인덱스부터 '3번 인덱스까지'

그래서 일괄로 치환하면 동작이 조용히 달라질 수 있습니다. 호출부 3곳을 각각 확인했습니다.

newNoteName()     substr(15)              인자 1개, 끝까지 → 동일
cloneNoteName()   substr(0, lastIndex)    시작이 0이라 우연히 일치 → 동일
cloneNoteName()   substr(lastIndex)       인자 1개, 끝까지 → 동일

두 번째 케이스가 중요합니다. substr(0, n)slice(0, n) 은 시작점이 0일 때만 같은 결과를 냅니다. 만약 substr(3, 5) 같은 코드가 있었다면 그대로 바꿀 수 없었습니다.


이 확인 내용을 PR 본문에 그대로 적었더니 리뷰가 빠르게 통과했습니다. 리뷰어가 세 군데를 직접 열어보고 확인할 필요가 없어졌기 때문입니다.



사례 2. 툴팁 대신 aria-label 을 선택한 이유

인터프리터 설정 폼에 아이콘만 있는 버튼이 6개 있었습니다. 자식 요소가 아이콘 글리프뿐이라 텍스트도, aria-label 도, title 도 없었습니다. 스크린 리더로 들으면 "button" 이라고만 읽히기 때문에 무슨 버튼인지 알 수 없습니다. 여기서 "툴팁을 달면 되지 않나" 라는 의견이 나올 수 있습니다. Zeppelin 은 ng-zorro 를 쓰고 있어서 nz-tooltip 한 줄이면 되기 때문입니다.


그래서 툴팁으로는 해결되지 않는 이유를 먼저 적었습니다. 툴팁은 hover 또는 focus 시점에 DOM 에 삽입되는 별도 요소입니다. 그래서 접근성 트리에서 버튼의 이름(accessible name)을 안정적으로 제공하지 못하고, WCAG 2.1 SC 4.1.2 를 툴팁만으로는 충족할 수 없습니다. 이렇게 적어두면 리뷰어가 같은 논의를 다시 시작할 필요가 없습니다. 예상되는 질문에 미리 답해두는 것이 오픈소스 PR 본문에서 가장 중요한 부분이라고 생각하게 되었습니다.


부수적인 효과도 있었습니다. "이 변경이 안전한 이유" 를 글로 정리하다 보면 스스로 검증하는 과정이 됩니다. 쓰다가 "이 케이스는 확인하지 않았다" 는 것을 깨닫고 되돌아간 적이 여러 번 있었습니다.





프론트엔드 코드만 봐서는 원인이 보이지 않는 버그

가장 재미있었던 두 건입니다. 둘 다 프론트엔드 파일만 들여다봐서는 원인이 나오지 않는 버그였습니다.


isTrash 가 항상 false 를 반환하던 버그

증상은 아래와 같았습니다. 휴지통에 있는 노트를 열면 메뉴에 "Remove permanently" 가 아니라 "Move to trash" 가 표시됩니다. 그리고 그것을 누르면 노트 경로가 이렇게 바뀝니다.

/~Trash/my-note
  → /~Trash/~Trash/my-note
    → /~Trash/~Trash/~Trash/my-note

휴지통 안의 노트를 다시 휴지통으로 보내는 것입니다. 영구 삭제는 아예 할 수 없었습니다.


원인 코드는 대략 아래와 같은 모양이었습니다.

// NoteStatusService - 실제 코드를 단순화했습니다
get isTrash(): boolean {
  const path = this.note.name.split('/');
  return path[1] === TRASH_FOLDER_ID;
}

note.name 이 /~Trash/my-note 라면 split('/')[1]~Trash 이므로 맞는 것처럼 보입니다. 실제로 이 코드가 작성된 시점에는 맞는 코드였습니다.


문제는 그 사이에 백엔드가 바뀌었다는 점입니다. git log 를 거슬러 올라가 보니 ZEPPELIN-4041 에서 Note.name 의 의미가 바뀌어 있었습니다.

변경 전   note.name = /~Trash/my-note   (전체 경로)

변경 후   note.name = my-note           (마지막 세그먼트)
         note.path = /~Trash/my-note   (전체 경로)

'my-note'.split('/')[1] 은 항상 undefined 이고, isTrash 는 몇 년째 무조건 false 를 반환하고 있었습니다.


같은 코드베이스의 NoteListService 는 이미 note.path 를 올바르게 사용하고 있었습니다. 백엔드가 변경될 때 한쪽만 따라간 상태로 남아 있었던 것입니다.

// 수정 후
get isTrash(): boolean {
  return this.note.path?.split('/')[1] === TRASH_FOLDER_ID;
}

이 버그를 추적하면서 생각이 하나 바뀌었습니다. 코드는 현재 상태의 스냅샷이 아니라 히스토리에 가깝다는 것입니다.


"왜 이렇게 작성했을까" 라는 질문의 답이 현재 코드 안에 없는 경우가 많습니다. 그 코드가 작성된 시점의 환경이 지금과 달랐기 때문입니다. 그 뒤로 회사 코드를 볼 때도 이해되지 않는 로직을 만나면 git log -S 부터 확인하게 되었습니다.



다른 사용자의 동작 때문에 내 화면이 깨지는 버그

두 번째는 Job Manager 크래시입니다. 재현 조건이 특이했습니다. A 사용자가 노트를 영구 삭제하면 B 사용자의 Job Manager 화면이 깨집니다.


B 사용자는 jobmanager 페이지를 보고 있었을 뿐인데 아래 에러가 발생합니다.

TypeError: Cannot read properties of undefined (reading 'match')

그리고 한 번 발생하면 끝이 아니라, 필터 입력창에 한 글자씩 입력할 때마다 계속 에러가 납니다.


원인은 서버 쪽 동작이었습니다. Zeppelin 서버는 노트가 삭제되면 WebSocket 으로 모든 구독자에게 아래와 같은 stub 을 브로드캐스트합니다.

{ "noteId": "2A94M5J1Z", "noteName": null, "isRemoved": true }

이름도 없고 다른 필드도 없는 최소한의 객체입니다. "이 노트가 삭제되었으니 목록에서 제거하라" 는 신호이기 때문에 이 정보만으로 충분합니다.


문제는 B 사용자의 초기 job 목록이 소유자 기준으로 필터링되어 있다는 점입니다. A 사용자의 노트는 애초에 B 사용자의 목록에 없습니다.

const currentJobIndex = jobs.findIndex(j => j.noteId === newJob.noteId);

if (currentJobIndex === -1) {
  jobs.push(newJob);        // 목록에 없으니 '새 job' 으로 판단해 추가
} else if (newJob.isRemoved) {
  jobs.splice(currentJobIndex, 1);
}

noteName 이 null 인 객체가 job 목록에 추가되고, 그 뒤로 filterJobs 가 job.noteName.match(...) 를 호출할 때마다 에러가 발생합니다.


수정 자체는 가드 한 줄이지만, 가드를 어디에 넣는지가 중요했습니다.

if (currentJobIndex === -1) {
  if (!newJob.isRemoved) {   // 여기에 중첩
    jobs.push(newJob);
  }
} else if (newJob.isRemoved) {
  jobs.splice(currentJobIndex, 1);
}

currentJobIndex === -1 && !newJob.isRemoved 처럼 조건을 합칠 수도 있는데, 그렇게 하면 삭제 stub 이 else 분기로 흘러가 splice 를 만나게 됩니다. 인덱스가 -1 이므로 배열의 마지막 요소를 삭제해버립니다. 조건을 합치는 것이 항상 리팩토링은 아니라는 점을 배웠습니다.


이 버그를 풀 수 있었던 것은 며칠 전 멘토님의 서버 오버뷰 세션 덕분이었습니다. "서버가 모든 구독자에게 브로드캐스트한다" 는 구조를 알고 있었기 때문에, "내 화면의 상태" 가 아니라 "서버가 모두에게 보내는 메시지" 로 시야를 옮길 수 있었습니다.



성공도 실패도 아닌 상태를 처리하지 않은 코드

활동 막바지에 Module Federation 으로 얹은 React 리모트 쪽 코드를 보게 되었습니다. 셸이 remoteEntry.js 를 동적으로 불러오는 구조인데, 로더가 아래와 같이 작성되어 있었습니다.

script.onload  = () => resolve(container)
script.onerror = () => fail()

성공하면 resolve, 실패하면 fail 이므로 언뜻 문제가 없어 보입니다.


그런데 서버가 요청을 받은 뒤 응답하지 않는 경우에는 onload 도 onerror 도 호출되지 않습니다. 브라우저가 연결을 유지하는 동안, 즉 몇 분 동안 Promise 가 pending 상태로 남습니다.


onError 가 호출되지 않으니 이 Promise 를 기다리는 호스트들도 폴백하지 않습니다. 문단 푸터는 빈 mount div 를 그대로 들고 있고, 발행된 문단은 아무것도 렌더링하지 않습니다. 사용자에게는 고장이 아니라 그냥 느린 페이지로 보이는데, 이것이 가장 나쁜 실패 방식입니다.


수정은 타임아웃을 추가하는 것이었습니다. 기본 10초로 두고, 만료되면 기존 실패 처리 흐름을 그대로 사용해서 스크립트 태그를 제거하고 캐시를 비워 다음 마운트에서 재시도할 수 있게 했습니다.


한 가지 의도적으로 수정하지 않은 부분이 있습니다. 컨테이너가 로드된 뒤 실제 청크를 받아오는 경로에는 타이머를 걸지 않았습니다. 그쪽은 리모트 자신의 webpack 런타임이 output.chunkLoadTimeout 으로 이미 제한하고 있는데, 여기에 더 짧은 타이머를 추가하면 느린 네트워크에서 수 MB 짜리 청크 다운로드를 중간에 끊게 됩니다. 원래 고치려던 문제보다 더 나쁜 결과를 만드는 셈입니다.


이 작업에서 배운 점은 비동기 경계에는 성공과 실패 외에 "아무 일도 일어나지 않는" 상태가 있다는 것입니다. 그리고 이 상태는 에러가 발생하지 않기 때문에 대체로 처리되지 않은 채로 남아 있습니다.





프론트엔드 밖의 기여 : zeppelin-server

활동 후반에는 처음으로 zeppelin-server, 즉 Java 코드를 수정했습니다.


시작은 가벼운 작업이었습니다. @Override 애너테이션이 빠진 곳을 채우는 이슈였는데, 원 리포트가 지목한 클래스는 이미 다른 이슈에서 정리된 상태였습니다. 그래서 이슈를 그냥 닫는 대신 모듈 전체를 확인해서 애너테이션이 빠진 7곳을 찾아 채웠습니다.


그다음 작업이 조금 더 흥미로웠습니다. 사용자 목록 검색 API 에서 아래 코드를 발견했습니다.

// ShiroAuthenticationService - 단순화
String filter = "(&(objectClass=" + objectClass + ")("
              + attribute + "=*" + searchText + "*))";

searchText 는 클라이언트가 보낸 값이고, 이 문자열은 LDAP 검색 필터로 사용됩니다.


LDAP 필터에는 특수한 의미를 갖는 문자들이 있습니다. 여는 괄호, 닫는 괄호, 별표, 백슬래시, 그리고 NUL 입니다. 사용자가 별표를 입력하면 문자 그대로가 아니라 와일드카드로 해석되고, 닫는 괄호를 입력하면 필터 구조 자체를 바꿀 수 있습니다.


SQL 인젝션과 같은 원리입니다. 신뢰할 수 없는 입력을 구문으로 해석되는 위치에 문자열 결합으로 넣었기 때문입니다.


해결 방법도 이미 프로젝트 안에 있었습니다. 같은 코드베이스의 ActiveDirectoryGroupRealm 이 RFC 4515 이스케이프 유틸리티를 이미 사용하고 있었습니다.


한 가지 신경 쓴 점은 기존 기능을 깨지 않는 것이었습니다. Zeppelin 은 부분 문자열 매칭을 위해 검색어 양쪽에 별표를 붙입니다. 이 와일드카드는 Zeppelin 이 붙인 것이므로 이스케이프하면 안 되고, 사용자가 입력한 별표만 일반 문자로 처리되어야 합니다.

// Zeppelin 이 붙인 와일드카드는 escape 바깥에
String filter = "(&(objectClass=" + esc(objectClass) + ")("
              + esc(attribute) + "=*" + esc(searchText) + "*))";

그리고 필터를 조립하는 부분을 별도 헬퍼로 분리해서, 최종적으로 만들어진 필터 문자열을 단위 테스트에서 직접 검증할 수 있게 했습니다.


프론트엔드만 하던 입장에서 LDAP 은 낯선 영역이었지만, 익숙한 원리를 낯선 곳에서 다시 만나는 경험이라 오히려 재미있었습니다.





한 달의 기록

제출한 PR      22건
머지된 PR      22건

버그수정        8건
리팩토링        7건
기능개선        3건
문서개선        4건

작업한 모듈은 zeppelin-web-angular, zeppelin-web, zeppelin-react, zeppelin-server, shell 인터프리터, 그리고 공식 문서와 웹사이트입니다.


숫자만 보면 많아 보이지만 대부분 작은 단위의 작업이었고, 그 점이 이 페이스를 유지할 수 있었던 이유이기도 합니다.


의식적으로 PR 을 작게 유지했습니다. 접근성 PR 에서 이미 텍스트가 있는 버튼은 수정하지 않았고, HTTPS 링크 PR 에서 Zeppelin 외의 외부 링크는 건드리지 않았습니다. 범위를 넓히면 리뷰가 무거워지고, 무거워지면 머지가 늦어지고, 늦어지면 다음 작업을 시작할 수 없습니다.


대신 검증은 충분히 붙였습니다. Playwright E2E 회귀 테스트를 추가하고, 링크를 바꿀 때는 그 스킴을 고정하고 있던 E2E 기대값까지 함께 수정하고, 호출부를 하나씩 확인하는 방식으로 진행했습니다.





아쉬운 점

솔직하게 정리하면, 대부분의 작업이 각각 독립적인 개선이었다는 점이 가장 아쉽습니다.


버그 하나를 고치고, 리팩토링 하나를 하고, 문서 하나를 고치는 식이었습니다. 하나하나는 의미가 있지만 "프로젝트를 이해했다" 고 말하기에는 부족합니다. 하나의 흐름을 관통하는 작업은 하지 못했습니다.


돌아보면 휴지통 버그와 Job Manager 버그는 개별 증상이 아니라 하나의 패턴일 가능성이 있습니다. 둘 다 서버와 클라이언트의 경계에서 가정이 어긋나며 생긴 버그이기 때문입니다.





오픈소스를 시작하려는 분들께

한 달 동안 해보면서 실제로 도움이 되었던 것들입니다.


첫 번째는 첫 PR 을 작게 잡는 것입니다. 제 첫 PR 은 깨진 트위터 위젯을 삭제하는 작업이었습니다. 이 정도 작업으로 PR 을 올리고 리뷰를 받고 머지되는 전체 사이클을 한 번 경험해보면 그다음부터는 심리적인 부담이 크게 줄어듭니다.


두 번째는 TODO, @ts-ignore, deprecated 를 검색해보는 것입니다. 이슈 목록보다 훨씬 빠른 진입점이 됩니다.


세 번째는 PR 본문에 예상 질문의 답을 미리 적어두는 것입니다. "왜 A 대신 B 인가", "왜 이 범위인가", "왜 안전한가" 이 세 가지만 적어도 리뷰 속도가 달라집니다.


네 번째는 티켓 범위 밖의 문제를 발견하면 커밋을 나누는 것입니다. 저는 노트 복제 버그를 고치다가 무관한 버그를 발견한 적이 있습니다. 무시하기도, 함께 고치기도 애매했는데 별도 커밋으로 나누고 본문에 "원하시면 떼어낼 수 있게 커밋을 분리해두었습니다" 라고 적었더니 깔끔하게 정리되었습니다.


다섯 번째는 프로젝트 안에 이미 답이 있는지 먼저 확인하는 것입니다. LDAP 이스케이프도, 휴지통 판별도 정답에 해당하는 코드가 같은 저장소의 다른 파일에 이미 있었습니다. 새로 구현하기 전에 검색부터 해보는 것이 빠릅니다.





마무리

시작할 때 목표는 "PR 하나만 머지시켜보자" 였습니다. 한 달 뒤에 22개가 되어 있는 것을 보니 스스로도 조금 놀랐습니다.


가장 좋았던 점은 오픈소스가 특별한 사람들만의 영역이 아니라는 것을 알게 된 것입니다. 제가 한 일은 대부분 회사에서 하던 일과 같습니다. 버그 원인을 추적하고, deprecated API 를 정리하고, 타입을 좁히고, 접근성을 챙기는 일입니다. 다른 점은 나를 모르는 사람에게 설명해야 한다는 것 하나뿐이었습니다.


Masters 기간에는 조금 더 욕심을 내어 큰 기능을 작업해보고, 다른 컨트리뷰터가 올린 PR 을 리뷰하는 것까지 진행해보려고 합니다.





Reference