비밀번호를 코드에서 분리해야 하는 이유
Spring Boot 프로젝트를 처음 만들다 보면 application.yml에 데이터베이스 접속 정보를 작성하게 된다.
1
2
3
4
5
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
username: app_user
password: app_password
로컬에서 혼자 개발할 때는 별문제가 없어 보인다. 설정 파일 하나만 있으면 애플리케이션이 바로 실행되고, 비밀번호를 따로 입력할 필요도 없다.
문제는 이 방식이 익숙해진 뒤에도 실제 운영 서버의 비밀번호를 같은 방법으로 관리하는 것이다.
1
2
3
4
5
spring:
datasource:
url: jdbc:postgresql://prod-db:5432/myapp
username: app_user
password: real-prod-password
이 파일을 Git에 올리면 소스 코드뿐 아니라 실제 비밀번호도 함께 저장된다.
비밀번호가 설정 파일에 적혀 있다는 사실 자체보다, 그 파일이 여러 곳으로 복사될 수 있다는 점이 더 큰 문제다.
개발자가 저장소를 내려받을 수 있고, CI/CD 서버도 배포를 위해 코드를 가져간다. 저장소는 백업되거나 다른 시스템으로 옮겨질 수 있으며, 필요에 따라 압축 파일이나 프로젝트 사본으로 공유되기도 한다.
처음에는 한 파일에 적혀 있던 비밀번호가 어느 순간 여러 컴퓨터와 서버에 남게 되는 것이다.
이 상태가 되면 다음과 같은 질문에 답하기 어려워진다.
- 누가 비밀번호를 봤는가?
- 비밀번호가 어디까지 복사됐는가?
- 아직 남아 있는 복사본은 없는가?
- 비밀번호가 유출되었을 때 모두 회수할 수 있는가?
그래서 비밀번호를 설정 파일에 넣지 말라는 것은 단순한 코딩 규칙이 아니다. 비밀번호가 퍼지는 범위를 통제하기 위한 보안 원칙에 가깝다.
private 저장소라면 괜찮을까?
저장소가 private이면 public 저장소보다는 안전하다. 하지만 private 저장소도 비밀번호를 보관하기에 적절한 장소는 아니다.
private은 아무도 볼 수 없다는 뜻이 아니라, 권한이 있는 사람과 시스템만 볼 수 있다는 뜻이다.
저장소에는 개발자뿐 아니라 여러 대상이 접근할 수 있다.
- CI/CD 서버
- 코드 분석 도구
- 백업 시스템
- 외부 협력 개발자
- 앞으로 프로젝트에 참여할 사람
- 저장소 관리 권한을 가진 운영자
프로젝트가 커질수록 저장소를 볼 수 있는 대상도 늘어난다.
애플리케이션 실행에는 비밀번호가 필요하지만, 소스 코드를 보는 모든 사람에게 비밀번호가 필요한 것은 아니다. 따라서 코드 접근 권한과 비밀번호 접근 권한을 같은 범위로 묶는 것은 좋은 구조가 아니다.
파일에서 지우면 해결되지 않을까?
Git에 비밀번호를 커밋한 뒤 다음과 같이 수정했다고 가정해보자.
처음 커밋에는 실제 비밀번호가 있었다.
1
password: real-prod-password
다음 커밋에서는 환경변수를 사용하도록 변경했다.
1
password: ${DB_PASSWORD}
현재 파일만 보면 비밀번호가 사라진 것처럼 보인다.
하지만 Git은 파일의 현재 상태만 저장하지 않는다. 과거 변경 내용도 함께 기록한다. 이전 커밋을 확인하면 실제 비밀번호를 다시 찾을 수 있다.
.gitignore에 파일을 추가하는 것도 이미 커밋된 내용을 없애주지는 않는다.
.gitignore는 Git이 아직 추적하지 않는 파일이 새로 추가되는 것을 막아준다. 이미 Git이 관리하고 있는 파일이나 과거 커밋에 저장된 내용까지 삭제해주는 기능은 아니다.
따라서 비밀번호가 한 번이라도 저장소에 올라갔다면 단순히 현재 파일에서 지우는 것으로 끝내면 안 된다.
코드는 비밀번호를 몰라도 된다
애플리케이션은 데이터베이스에 연결하기 위해 비밀번호가 필요하다.
하지만 비밀번호가 반드시 소스 코드 안에 있어야 하는 것은 아니다.
설정 파일에는 실제 값 대신 어떤 값이 필요한지만 작성할 수 있다.
1
2
3
4
5
6
7
8
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jwt:
secret: ${JWT_SECRET}
애플리케이션을 실행하는 환경에서는 각 이름에 해당하는 실제 값을 제공한다.
1
2
3
4
DB_URL=jdbc:postgresql://prod-db:5432/myapp
DB_USERNAME=app_user
DB_PASSWORD=real-prod-password
JWT_SECRET=real-jwt-secret
Spring Boot는 실행될 때 ${DB_PASSWORD}와 같은 표현을 발견하면 환경에서 같은 이름의 값을 찾아 사용한다.
이렇게 하면 Git에는 다음 내용만 올라간다.
1
이 애플리케이션을 실행하려면 DB_PASSWORD가 필요하다.
실제 비밀번호는 저장소에 올라가지 않는다.
코드는 어떤 설정이 필요한지만 알고, 실제 값은 애플리케이션이 실행되는 환경에서 제공하는 구조다.
환경변수로 옮기기만 하면 안전할까?
환경변수를 사용하면 비밀번호가 코드에 직접 들어가는 문제는 줄일 수 있다. 하지만 환경변수라는 이름만 붙였다고 자동으로 안전해지는 것은 아니다.
예를 들어 배포 스크립트에 다음과 같이 작성한 뒤 Git에 올린다면 이전과 크게 다르지 않다.
1
export DB_PASSWORD=real-prod-password
.env 파일에 실제 값을 적고 그 파일을 저장소에 커밋하는 것도 마찬가지다.
1
2
DB_PASSWORD=real-prod-password
JWT_SECRET=real-jwt-secret
비밀번호가 YAML에 들어 있느냐 .env에 들어 있느냐가 핵심은 아니다.
중요한 것은 실제 값이 저장소 밖에서 관리되는가이다.
환경변수는 비밀번호를 전달하는 방법이고, 실제 값을 어디에 보관할지는 별도로 결정해야 한다.
개발 환경과 운영 환경을 구분해야 한다
로컬에서 개발하기 위해 모든 개발자가 운영 데이터베이스 비밀번호를 공유할 필요는 없다.
로컬에서는 개발용 데이터베이스와 개발용 계정을 사용하고, 운영 서버에서는 운영 전용 계정을 사용하는 것이 좋다.
1
2
3
4
5
6
7
8
9
10
11
로컬 개발 환경
→ 로컬 데이터베이스
→ 개발용 계정
테스트 환경
→ 테스트 데이터베이스
→ 테스트용 계정
운영 환경
→ 운영 데이터베이스
→ 운영 전용 계정
환경별로 계정을 나누면 개발자의 로컬 설정이 노출되더라도 운영 데이터베이스까지 바로 영향을 받는 상황을 줄일 수 있다.
로컬 설정은 Git에 올리지 않는 별도 파일로 관리할 수도 있다.
1
application-local.yml
실제 파일은 .gitignore에 추가하고, 저장소에는 필요한 설정 형식만 보여주는 예시 파일을 올린다.
1
application-local.example.yml
예시 파일에는 실제 운영 값이 아니라 로컬에서 사용할 수 있는 샘플 값을 작성한다.
1
2
3
4
5
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
username: local_user
password: local_password
README에도 운영 비밀번호를 적으면 안 된다. README 역시 Git에 올라가는 파일이기 때문이다.
README에는 값 자체가 아니라 필요한 환경변수의 이름과 설정 방법을 안내하면 된다.
1
2
3
4
5
6
로컬 실행에 필요한 환경변수
DB_URL
DB_USERNAME
DB_PASSWORD
JWT_SECRET
운영에서는 어디에 보관할까?
운영 환경에서는 사용하는 인프라에 따라 비밀번호를 보관하는 방법이 달라진다.
GitHub Actions를 사용한다면 GitHub Actions Secrets에 값을 저장할 수 있고, Jenkins에서는 Credentials 기능을 사용할 수 있다.
Kubernetes 환경에서는 Kubernetes Secret을 사용할 수 있고, 클라우드 환경에서는 AWS Secrets Manager, Google Cloud Secret Manager, Azure Key Vault 같은 서비스를 사용할 수 있다.
규모가 크거나 여러 시스템의 비밀 값을 함께 관리해야 한다면 HashiCorp Vault 같은 별도의 secret 관리 도구를 사용할 수도 있다.
중요한 것은 어떤 제품이 가장 좋은가가 아니다.
적어도 다음 조건을 만족할 수 있어야 한다.
- 실제 비밀번호가 코드 저장소에 들어가지 않는다.
- 필요한 사람과 시스템만 값을 볼 수 있다.
- 누가 값에 접근했는지 확인할 수 있다.
- 값이 노출되었을 때 교체할 수 있다.
- 개발·테스트·운영 값을 구분할 수 있다.
작은 프로젝트라면 환경변수나 CI/CD의 secret 기능만으로도 시작할 수 있다. 서비스와 인프라가 커지면 중앙화된 secret 관리 시스템을 검토하면 된다.
비밀번호에도 권한 범위가 있다
비밀번호를 코드와 분리했더라도 연결된 계정이 너무 큰 권한을 가지고 있다면 위험하다.
애플리케이션이 특정 데이터베이스에서 조회와 수정만 하면 되는데, 데이터베이스 전체를 관리할 수 있는 관리자 계정을 사용할 필요는 없다.
계정이 노출되었을 때 발생하는 피해는 그 계정이 가진 권한에 따라 달라진다.
조회 권한만 있는 계정과 데이터베이스 전체를 삭제할 수 있는 계정은 피해 범위가 전혀 다르다.
따라서 애플리케이션 계정에는 실제 기능 수행에 필요한 권한만 부여해야 한다.
예를 들어 다음과 같이 구분할 수 있다.
1
2
3
4
5
6
7
8
애플리케이션 계정
→ 필요한 테이블 조회 및 수정
배치 작업 계정
→ 배치 수행에 필요한 범위
관리자 계정
→ 운영 관리자가 필요한 경우에만 사용
하나의 관리자 계정을 모든 애플리케이션이 공유하는 방식은 피하는 것이 좋다.
서비스와 환경별로 계정을 나누면 문제가 발생했을 때 영향을 받는 범위를 줄이고, 어떤 계정에서 문제가 발생했는지도 확인하기 쉬워진다.
비밀번호가 로그에 남을 수도 있다
비밀번호를 환경변수로 분리했더라도 로그에 실제 값이 출력되면 다시 노출된다.
애플리케이션을 시작할 때 전체 환경변수나 설정 값을 출력하는 코드는 특히 조심해야 한다.
다음과 같은 로그는 남기지 않는 것이 좋다.
1
2
3
DB_PASSWORD=real-prod-password
JWT_SECRET=real-jwt-secret
API_KEY=real-api-key
민감한 값은 출력하지 않거나 마스킹해야 한다.
1
2
3
DB_PASSWORD=****
JWT_SECRET=****
API_KEY=****
요청이나 응답 전체를 로그로 남기는 경우에도 Authorization 헤더, 쿠키, JWT, API Key가 포함되지 않는지 확인해야 한다.
로그는 개발자만 보는 파일이 아니다. 로그 수집 서버, 모니터링 도구, 운영 담당자 등 저장소와는 다른 대상에게도 전달될 수 있다.
결국 비밀번호를 코드에서 분리하는 것뿐 아니라, 실행 중에도 불필요한 위치에 남지 않도록 해야 한다.
이미 커밋했다면 가장 먼저 할 일
실제 비밀번호나 API Key를 Git에 올렸다면 먼저 해당 값이 노출되었다고 가정하는 편이 안전하다.
누가 봤다는 증거가 없더라도 저장소를 복제한 사람이나 외부 시스템의 캐시에 값이 남아 있을 수 있다.
이때 가장 먼저 해야 할 일은 기존 값을 교체하는 것이다.
일반적으로 다음 순서로 대응한다.
- 기존 비밀번호나 API Key를 폐기한다.
- 새로운 값을 발급한다.
- 애플리케이션이 새 값을 사용하도록 변경한다.
- 접근 기록이나 사용 내역을 확인한다.
- 필요하면 Git 이력에서 기존 값을 제거한다.
- 같은 문제가 반복되지 않도록 secret scanning을 적용한다.
Git 이력을 정리하는 것도 필요할 수 있지만, 그것만으로 비밀번호가 다시 안전해지지는 않는다.
누군가 이미 과거 저장소를 내려받았다면 이력을 수정한 뒤에도 기존 값을 알고 있을 수 있기 때문이다.
그래서 이미 노출된 비밀번호는 계속 사용하는 것이 아니라 새 값으로 바꾸는 것이 가장 중요하다.
비밀번호나 API Key를 새로운 값으로 교체하는 작업을 secret rotation이라고 한다.
모든 설정 값을 숨겨야 하는 것은 아니다
설정 파일에 들어가는 모든 값이 비밀 값은 아니다.
다음과 같은 값은 저장소에 들어가도 문제가 없는 경우가 많다.
1
2
3
4
5
6
server:
port: 8080
spring:
application:
name: my-service
반면 다음과 같은 값은 공개되었을 때 다른 사람이 시스템에 접근하거나 인증 정보를 위조할 수 있으므로 비밀 값으로 다뤄야 한다.
- 데이터베이스 비밀번호
- 외부 서비스 API Key
- JWT 서명 키
- OAuth Client Secret
- 클라우드 접근 키
- 암호화 키
- SSH Private Key
- 인증서의 Private Key
새로운 설정을 추가할 때는 다음과 같이 판단해볼 수 있다.
이 값이 공개되었을 때 다른 사람이 시스템을 사용할 수 있는가?
이 값으로 데이터에 접근하거나 데이터를 변경할 수 있는가?
이 값을 저장소 접근자 모두가 알아야 하는가?
노출되었을 때 쉽게 교체할 수 있는가?
하나라도 문제가 될 수 있다면 코드와 분리해서 관리하는 것이 좋다.
마무리
설정 파일에 비밀번호를 적는 방식은 처음에는 편하다.
파일 하나만 있으면 애플리케이션이 바로 실행되고, 개발자끼리 별도로 값을 전달할 필요도 없다.
하지만 그 파일이 Git에 올라가는 순간 비밀번호는 한곳에만 존재하지 않는다.
개발자의 컴퓨터, 원격 저장소, CI/CD 서버, 백업 시스템, 프로젝트 사본 등 여러 위치에 복사될 수 있다. 어디까지 퍼졌는지 알기 어려워지고, 문제가 생겨도 모든 복사본을 회수할 수 없다.
그래서 코드와 비밀 값의 역할을 나눠야 한다.
1
2
3
4
5
6
7
8
코드
→ 어떤 설정이 필요한지 정의한다.
실행 환경
→ 실제 설정 값을 제공한다.
Secret 관리 도구
→ 값을 저장하고 접근 권한과 변경 과정을 관리한다.
핵심은 비밀번호를 단순히 다른 파일로 옮기는 것이 아니다.
실제 값을 코드 저장소 밖에 두고, 필요한 대상만 접근하게 하며, 문제가 생겼을 때 새로운 값으로 교체할 수 있는 구조를 만드는 것이다.