두 번째 계정을 추가하고 나면 화면에 로그인 필요라는 문구가 뜬다. 로그아웃한 기억도 없고 토큰 만료 시점도 아직 멀었는데 이 계정만 그렇다. 저장된 토큰을 꺼내 API를 직접 호출해 보면 응답은 200이다. 요금제와 남은 사용량까지 정상으로 돌아온다. 앱만 이 계정을 로그인이 끊긴 상태로 표시한다.

다계정을 지원하는 데스크톱 앱에서 실제로 겪은 두 가지 결함이 있다. 하나는 계정을 식별하는 값 자체가 어긋난 경우고 다른 하나는 값은 맞는데 그 값을 누구 것으로 볼지 검증 없이 넘겨짚은 경우다. 여러 계정을 동시에 다루는 코드는 "이 자격증명이 누구 것인가"라는 질문에 매번 명확한 답을 갖고 있어야 한다.

두 개의 열쇠고리 아이콘 중 하나가 다른 계정 위로 겹쳐지며 자격증명이 잘못 배정되는 상황을 표현한 일러스트
직접 생성한 오리지널 일러스트.

두 번째 계정만 로그인이 풀리는 이유

계정 목록 API는 정상이었고 토큰 갱신 로그에도 에러가 없었다. 그런데도 특정 계정 하나만 계속 로그인 필요로 떴다. 결국 계정을 추가하는 코드부터 다시 읽었더니 원인이 금방 드러났다. 계정을 새로 등록할 때 두 개의 고유식별자(UUID, Universally Unique Identifier — 겹칠 확률이 사실상 없는 임의 문자열)가 각각 독립적으로 발급된다. 하나는 계정 자체를 가리키는 값이고 다른 하나는 그 계정의 자격증명 파일을 가리키는 값이다. 자격증명은 이 두 번째 값을 파일명 삼아 저장된다.

문제는 조회하는 쪽이다. 토큰이 필요할 때마다 도는 폴러(poller, 주기적으로 상태를 확인하는 백그라운드 작업)는 하나같이 계정 값으로만 자격증명을 찾는다. 자격증명 파일명으로 쓰인 두 번째 값은 조회 코드 어디에도 등장하지 않는다. 먼저 만든 계정은 우연히 두 값이 같아서 문제가 드러나지 않았을 뿐이다. 새로 추가한 계정은 두 값이 다르니 조회가 매번 빗나간다. 빗나간 조회는 401 오류로 이어지고 앱은 이를 로그인 필요로 해석한다.

이 앱은 원래 계정 하나만 다루던 구조에서 출발했다. 계정이 하나면 계정 값과 자격증명 값을 굳이 구분할 이유가 없다. 어차피 조회할 대상이 하나니 어느 값을 기준으로 삼든 결과가 같다. 이후 다계정 지원을 얹으면서 발급하는 쪽 코드는 두 값을 분리했지만 조회하는 쪽 코드는 예전 그대로 남았다. 한쪽만 다계정에 맞춰 바뀌고 다른 쪽은 단일 계정 시절의 가정을 그대로 물려받은 셈이다.

실측이 이 진단을 뒷받침했다. 문제의 계정에서 계정 값과 자격증명 값을 나란히 찍어 보니 앞자리부터 서로 달랐다. 그런데 저장된 토큰으로 API를 직접 호출하면 정상 응답이 돌아왔다. 토큰은 멀쩡했다. 앱의 조회 경로가 애초에 잘못된 위치를 보고 있었을 뿐이다.

계정 추가 시 계정 값과 자격증명 값이 각각 발급되지만 조회는 계정 값 하나로만 이뤄져 두 번째 계정에서 어긋나는 구조를 보여주는 다이어그램
직접 생성한 오리지널 다이어그램.

더 눈에 띄는 건 이 결함이 컴파일 단계에서 전혀 걸리지 않았다는 점이다. 계정 값과 자격증명 값 모두 문자열(String) 타입으로 선언돼 있었다. 타입 시스템 입장에서는 둘 다 그냥 문자열이니 서로 바꿔 써도 오류가 아니다. 의미가 다른 두 값을 같은 타입에 담아 두면 실수를 막아 줄 장치가 하나 사라진다.

식별자를 두 개 냈으면 조회도 두 갈래로 가야 한다

이 결함에서 건질 교훈은 특정 언어나 프레임워크에 묶여 있지 않다. 계정을 다루는 코드가 식별자를 하나가 아니라 두 개 이상 발급한다면 조회 경로도 그 개수만큼 갈래를 갖춰야 한다. 발급하는 쪽과 조회하는 쪽이 서로 다른 값을 기준으로 삼으면 값이 우연히 겹치는 동안만 동작하는 코드가 나온다.

수정은 새 메서드 하나로 끝났다. 자격증명을 조회할 때 계정에 연결된 자격증명 값이 있으면 그 값을 쓰고 없으면 계정 값으로 되돌아가는 폴백(fallback, 원래 값이 비어 있을 때 대신 쓰는 값)을 넣었다. 기존 계정은 두 값이 같으니 어느 쪽을 써도 결과가 같다. 새 계정은 자격증명 값이 우선 적용되면서 조회가 제자리를 찾는다. 인덱스 갱신처럼 계정 값 자체가 필요한 자리는 그대로 뒀다. 바뀐 건 자격증명을 읽고 쓰는 경로뿐이다.

근본적으로는 두 값을 같은 문자열 타입에 담아 둔 설계가 이런 실수를 허용했다. 계정 값과 자격증명 값을 서로 다른 타입으로 감싸 두면 컴파일러가 둘을 바꿔 쓰는 실수를 그 자리에서 잡아 준다. 이번엔 조회 경로에 폴백을 넣는 선에서 막았다.

우편함 두 개를 나란히 세워 두고 집배원에게는 주소를 하나만 알려준 상황과 비슷하다. 첫 번째 우편함은 그 주소로 지어졌으니 집배원이 매번 제대로 찾아온다. 두 번째 우편함은 다른 주소로 지어졌는데 집배원 손에 쥔 종이엔 여전히 첫 번째 주소만 적혀 있다. 편지가 안 오는 게 아니라 엉뚱한 곳에서 기다리는 셈이다. 조회 경로에 폴백을 넣은 건 두 번째 주소도 종이에 함께 적어 준 것과 같다.

확인 없이 넘겨받은 자격증명이 다른 계정을 덮어쓴다

같은 앱에서 다른 연동을 점검하다가 두 번째 결함을 찾았다. 이번엔 값이 어긋난 게 아니라 값을 누구 것으로 볼지 확인하지 않은 경우였다. 한 CLI(명령줄 도구) 연동은 로그인 정보를 디스크에 남기지 않는다. 앱이 실행되는 그 순간 운영체제 키체인(keychain, 로그인 정보를 안전하게 보관하는 시스템 저장소)에 떠 있는 로그인 세션을 그대로 읽어와 쓰는 구조다.

재현은 오히려 첫 번째 결함보다 쉬웠다. 계정을 새로 하나 추가한 직후 목록을 새로고침하면 방금 추가한 계정의 표시 이름이 어느새 첫 번째 계정 것과 똑같아져 있었다. 두 계정의 자격증명 파일 자체는 서로 안 섞였다. 화면에 보여주는 라벨과 상태 값만 첫 번째 계정 것으로 조용히 바뀐 상태였다. 자격증명 파일이 아니라 표시 정보 쪽이 오염된다는 점에서 첫 번째 결함과는 증상부터 달랐다.

계정이 하나뿐이면 이 방식에 문제가 없다. 키체인에 떠 있는 세션은 그 계정 것일 수밖에 없다. 계정이 둘 이상이면 얘기가 달라진다. 어느 계정이 지금 그 세션을 열었는지 확인하는 절차 없이 코드는 "지금 떠 있는 세션은 이 계정 것"이라고 그냥 가정해 버렸다. 두 번째 계정을 조회할 때도 첫 번째 계정이 열어 둔 세션을 그대로 가져다 썼다. 조용히 두 번째 계정의 표시 정보가 첫 번째 계정 것으로 바뀌었다. 반면 다른 CLI 연동은 이미 계정 일치 여부를 확인하는 코드를 갖추고 있었다. 같은 앱 안에서도 연동마다 검증 여부가 갈렸다는 뜻이다.

비슷한 결함이 또 다른 연동에서도 나왔다. 로컬 앱에 로그인된 계정의 남은 사용량을 읽어오는 함수가 어느 계정을 조회하는지 인자로 받지 않았다. 지금 로그인된 계정 하나의 값만 돌려주는 구조인데 이 값을 등록된 모든 계정에 그대로 적용하고 있었다. 실제로는 한 계정의 정보인데 나머지 계정들의 라벨까지 그 값으로 덮어썼다.

두 번째 결함은 값을 적용하기 전에 대조 절차 하나를 끼워 넣어 고쳤다. 새로 받아온 값에 딸려 오는 식별 정보를, 라벨을 갱신하려는 그 계정의 등록 정보와 먼저 맞춰 본다. 둘이 맞아떨어지는 계정만 라벨을 갱신하고 나머지는 손대지 않는다. 대조에 실패한 계정은 다른 확인 경로로 값을 다시 받아오거나, 그마저 안 되면 상태를 확인 불가로 남겨 둔다.

확인 없이 값을 모든 계정에 적용하던 기존 로직과, 계정이 둘 이상이면 신원을 확인하고 확인에 실패하면 귀속을 거부하는 수정 후 로직을 비교한 다이어그램
직접 생성한 오리지널 다이어그램.

두 사례 모두 원인의 갈래는 달랐지만 패턴은 하나로 겹쳤다. 계정 정보를 받아오는 값 자체가 지금 어느 계정 것인지 알려주는 표시를 갖고 있지 않았다. 표시가 없는 값을 여러 계정에 나눠 붙이려면 어딘가에서 확인이 필요한데 그 확인이 아예 빠져 있었다.

귀속의 기본값은 거부다

수정 방향은 같은 원칙 하나로 정리됐다. 신원을 확인할 수 없거나 후보가 둘 이상이면 귀속을 거부한다. 등록된 계정이 하나뿐일 때는 모호할 여지가 없으니 검증 없는 값도 그대로 쓴다. 계정이 둘 이상이면 값을 어느 계정 것으로 볼지 검증하기 전엔 아무 계정에도 붙이지 않는다.

거부만으로 끝내면 화면에는 오래된 값이 그대로 남는다. 계정을 검증하지 못했을 때는 직전에 성공했던 값도 함께 지웠다. 지우지 않으면 사용자 눈에는 여전히 정상으로 보이는데 실제로는 확인되지 않은 상태이니 더 위험하다. 거부와 함께 "이 값은 더 이상 못 믿는다"는 표시까지 같이 남겨야 한다. 같은 앱의 다른 연동을 다룬 글에서도 증거 없이는 배정하지 않는다는 원칙을 같은 방식으로 지켰다.

이 원칙을 코드에 넣고 나서 검증 단계에서 우회로가 몇 군데 더 나왔다. 새 검증 게이트를 세우기 전에 이미 만들어진 캐시나 스냅숏이 게이트를 모르고 지나가는 경로였다. 오래된 버전의 앱이 만들어 둔 캐시까지 신뢰하지 않기로 했고 검증 순서도 다시 봤다. 어떤 값이 믿을 수 있는 경로에서 왔는지 먼저 확인하고 그다음에 신원이 일치하는지 확인하는 순서로 바꿨다. 순서가 뒤바뀌어 있으면 앞서 만들어진 값이 뒤 검사를 그냥 지나칠 수 있었다. 실패를 기본값으로 두는 설계는 앞서 다른 종류의 파이프라인에서도 같은 결로 정리한 적이 있다. 확인되지 않은 상태를 성공으로 흘려보내지 않는다는 원칙은 자격증명 귀속에도 그대로 옮겨 붙는다.

상황수정 전수정 후
등록된 계정이 1개검증 없이 그대로 사용검증 없이 그대로 사용(모호함 없음)
등록된 계정이 2개 이상먼저 뜬 세션·쿼터 값을 모든 계정에 적용신원 일치 검증, 불일치·확인 불가면 귀속 거부 + 직전 값 만료 처리

거부를 기본값으로 두면 로그인 필요 문구가 오히려 더 자주 뜰 수 있다. 검증이 애매한 순간마다 마침 손에 쥔 값을 그냥 쓰는 대신 확인부터 요구하기 때문이다. 불편해 보이지만 다른 계정의 자격증명이 조용히 붙는 사고보다는 로그인을 한 번 더 시키는 쪽이 낫다. 오귀속은 겉으로 티가 안 난다. 사용자는 화면에 뜬 값을 자기 계정 것으로 믿고 쓰는데 실제로는 다른 계정 것이니 나중에 드러났을 때 되돌리는 비용이 더 크다.

고치고 나서는 기존 계정들의 로그인이 회귀 없이 그대로 동작하는지, 새로 추가한 계정도 곧바로 정상 작동하는지 실제 기기에서 하나씩 확인했다. 여러 계정을 다루는 코드에서 값 하나를 특정 계정 것으로 확정하기 전에 아래 네 가지는 미리 확인해 둘 만하다. 코드 리뷰 체크리스트에 그대로 넣어도 된다.

  • 이 값이 어느 계정 것인지 확인할 방법이 코드 안에 실제로 있는가
  • 등록된 계정이 정말 하나뿐인가, 아니면 둘 이상 될 수 있는가
  • 확인이 실패했을 때 화면의 이전 값을 그대로 남겨 두는가, 아니면 지우는가
  • 새로 세운 검증 게이트보다 먼저 만들어진 캐시나 스냅숏이 그 게이트를 우회하지 않는가

남은 숙제가 하나 있다. 계정 값과 자격증명 값을 서로 다른 타입으로 나누면 혼동은 컴파일 단계에서 막을 수 있지만, 이미 저장된 기존 계정 데이터를 그 새 타입으로 무손실 이관하는 절차는 아직 정리하지 못했다. 문자열 하나에 두 가지 의미를 얹어 두는 설계와 확인 없이 넘겨받는 값은 계정이 하나뿐일 때는 안 보이다가 두 번째 계정이 생기는 순간 겉으로 드러난다.