백엔드 개발자로서 새로운 프로젝트를 시작할 떄, 다른 분들이 어떻게 하시는지는 모르겠습니다만 저는 가장 먼저 DB 테이블 설계를 진행합니다. 서비스의 요구사항을 파악하고, 테이블 스키마를 먼저 작성해야 앞으로 어떤 API가 필요하고 코드를 어떻게 분리해야 할지 큰 그림을 보기가 편하더라구요.
예를 들어서 회원가입과 로그인이 필요한 서비스를 개발해야 한다고 가정해봅시다. 유저의 아이디와 비밀번호를 저장해야 할거고, 사이트의 요구사항에 따라 이메일, 닉네임 또는 마케팅 수신 동의 등의 정보를 저장해야겠죠. 만약 유저가 탈퇴 후 1주일이 지나야 DB에서 삭제되도록 하고 싶다면 soft delete를 위한 필드로 하나 추가해야 할거구요. 이걸 감안해서 먼저 테이블을 설계하면 아래와 같이 엔티티 객체를 만들 수 있습니다. 이와 같이 테이블 설계를 하면 API 설계를 하기도 편해집니다. 어떤 API에서 어떤 테이블의 필드를 채워야 하는지 생각하기가 쉬워지는 것 같습니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class MemberEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String nickname;
@Column(nullable = false, unique = true)
private String username;
private String tel;
@Column(unique = true)
private String email;
@Column(name = "is_deleted")
private boolean isDeleted;
@Column(nullable = false)
private String password;
@Column(name = "member_role")
@Enumerated(EnumType.STRING)
private MemberRole memberRole;
private Boolean isMarketingAgreed;
}
백엔드 개발이 이렇게 필요한 API를 개발하는 것으로만 이루어진다면 정말 쉽고 간단하겠지만, 항상 그렇듯 실제 운영 관점으로 가면 추가적인 문제가 발생합니다. 특히 예시로 들고온 유저 정보를 저장하는 테이블에서 신경써야할 부분이 많아지죠. 실제 유저들의 데이터를 직접적으로 다루는 것이 백엔드 개발자의 역할이기에, 정보 보안에 대한 총 책임을 가지는 것도 백엔드 개발자의 역할인 경우가 많습니다. 그냥 기능 작동만 시키면 되는 일반적인 프로젝트와 다르게 서비스를 운영할 때는 유저들의 개인정보도 함께 관리해야 한다는 말이죠.
그래서 실제 서비스에서는 대부분, 유저가 로그인에 사용하는 비밀번호를 암호화해서 저장합니다. 해커에게 DB의 내용이 그대로 빼앗기더라도, 유저의 비밀번호는 단방향 암호화가 되어 있기 때문에 이를 활용하여 유저의 로그인 정보를 알 수 없게 하는거죠. 하지만 상용 서비스에서 이정도 보안 수준으로는 부족합니다. 데이터 보안 책임자가 개인정보보호법(PIPA) 등 컴플라이언스 준수하기 위해서는 해당 User 테이블 정보를 통한 식별이 불가능하도록 해야 합니다.
다시말해, 전화번호나 이름, 이메일 같은 정보도 암호화를 해야 한다는 거죠. 다만 사이트 이용중 유저 정보 수정등의 상황에 확인 가능해야 하므로 비밀번호와는 다르게 해독이 가능한 양방향 암호화를 적용해야 합니다. 커뮤니티 사이트를 예로 들면 DB가 탈취 되더라도 해당 유저가 누구인지 알 수 없도록, 즉 userId = 123을 갖는 유저가 어떤 글을 작성했는지 조회 가능한것은 괜찮지만 해당 유저가 실제로 어떤 이메일을 사용하는지, 어떤 전화번호를 사용하는지가 드러나면 안된다는 뜻입니다. 사이트 내에서 공개적으로 사용하는 아이디는 상관 없습니다. 이는 어차피 사이트 내에서도 충분히 검색 가능한 정보니까요.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class MemberEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String nickname;
@Column(nullable = false, unique = true)
private String username;
@Convert(converter = CryptoConverter.class)
@Column(length = 500)
private String tel;
@Convert(converter = CryptoConverter.class)
@Column(length = 500)
private String email;
@Column(name = "is_deleted")
private boolean isDeleted;
@Column(nullable = false)
private String password;
@Column(name = "member_role")
@Enumerated(EnumType.STRING)
private MemberRole memberRole;
private Boolean isMarketingAgreed;
}
@Component
@Converter
public class CryptoConverter implements AttributeConverter<String, String> {
// 암호화 버전 prefix (v1: AES-256-GCM + PBKDF2)
private static final String ENCRYPTION_PREFIX = "v1:";
// 암호화 관련 설정 받아옴
private static CryptoUtil cryptoUtil;
@Autowired
public void setCryptoUtil(CryptoUtil cryptoUtil) {
CryptoConverter.cryptoUtil = cryptoUtil;
}
}
다행히 스프링부트 프레임워크에서는 이런 암호화를 통한 작업을 상당히 쉽고 직관적으로 구현할 수 있도록 도와줍니다. 바로 @Convert 라는 JPA 어노테이션을 사용하는건데요. 이를 사용하면 제가 원하는 암호화 클래스를 컨버터로 받아서, DB 저장시 자동으로 암호화를 하고, data-jpa 메서드를 활용해 DB에서 불러올 때는 자동으로 복호화가 된 상태로 사용할 수 있습니다. 물론 queryDsl에서 엔티티가 아닌 dto로 조회하거나, 네이티브 쿼리로 원문 데이터를 직접 조회하는 경우에는 자동 암호화/복호화를 지원하지 않습니다만, 그걸 감안해도 아주 강력한 정보보안 기능이라고 볼 수 있죠. 책임 분리 관점에서도 훌륭합니다. 서비스에서 암호화/복호화 여부까지 신경쓴다면 매우 번거롭고, 진행 여부에 따라 까먹기 아주 좋은데 이렇게 어노테이션을 활용하면 관리도 간단하니까요.
하지만 이렇게 개발을 진행하면 2가지 문제가 발생합니다. 첫번째는 이런식의 암호화는 중복값을 검증하지 못한다는 것이고, 두번째는 암호화된 데이터를 활용한 검색 성능이 심각해진다는 것이죠. 둘 다 서비스 운영에 심각한 지장을 줄 수 있는 문제이지만, 해결하기 전에 우선 왜 이런 문제가 발생했는지부터 알아봅시다.
우선 서비스에서 양방향 암호화를 위해 사용한 AES-GCM 알고리즘과, 왜 이 알고리즘을 사용했는지에 대한 맥락을 파악할 필요가 있습니다. 먼저 가장 기본적인 형태의 양방향 암호화를 생각해 보겠습니다. 전통적인 방식의 결정적 암호화(Deterministic Encryption)에서는 원문 데이터와 비밀키를 어떠한 알고리즘을 사용해 섞어서 암호화 된 데이터를 만들어 저장합니다. 복호화 할때는 비밀키를 사용하여 알고리즘을 반대로 적용해 원문 데이터를 복원할 수 있죠. 하지만 이런 방식의 암호화는 원문 데이터가 같으면 암호화된 값도 똑같다는 문제가 있죠.
[ 유저 A ] [ 비밀키 🔑 ] [ DB 저장값 ]
"010-1234" -----> [ AES ] -----> "XYZ_999"
^
|
[ 유저 B ] (같은 키)
"010-1234" -----> [ AES ] -----> "XYZ_999"
이게 어떻게 문제가 되냐면, 만약 해커가 DB 접근 권한을 얻은 후 서비스에 직접 회원가입을 한다고 가정을 해보죠. 회원가입 후 추가된 데이터를 확인해 보면 010-1111-2222가 어떻게 암호화가 되는지 알게 될거고, 이와 같은 문자열을 찾아 자신과 같은 전화번호를 갖는 데이터가 어떻게 저장되는지 확인할 수 있겠죠. 이런식으로 대조해보면 금방 DB의 모든 전화번호의 원문 데이터를 알 수 있게 될겁니다. 전화번호의 경우의 수는 그렇게 많지 않으니까요.
그렇기 때문에 AES-GCM와 같은 확률적 암호화(Probabilistic Encryption) 에서는 모든 데이터를 암호화 할 때 매번 다른 랜덤 문자열 IV(Initialization Vector)를 추가합니다. 이 랜덤 문자열을 일반적으로 Salt 값이라고 합니다. 원문 데이터에 IV와 비밀키를 붙이고 알고리즘을 적용해서 암호화 된 데이터를 만듭니다. 그리고 맨 앞에 IV 문자열을 붙임으로서 이 데이터가 어떤 문자열이 랜덤으로 더해진 암호화 데이터인지를 알려주죠. 복호화 할때도 반대로, IV를 때고 알고리즘을 반대로 적용하여 복호하 한 뒤, 원문 데이터에 붙어있던 IV와 비밀키를 떼어내어 반환합니다. 이런 방식으로 데이터가 저장될때마다 매번 다른 문자열을 집어넣어 DB를 탈취한 해커가 원문 데이터를 유추하거나 패턴을 파악하지 못하게 만들죠. 해커는 IV값을 알 수는 있지만 결국 비밀키를 모르기 때문에 원문 데이터를 파악할 수도 없을거구요.
[ 유저 A ] [ 랜덤 IV 🎲 ] [ 비밀키 🔑 ] [ DB 저장값 ]
"010-1234" + "a1b2..." -----> [ AES-GCM ] -----> "Klo_721"
^
|
[ 유저 B ] [ 랜덤 IV 🎲 ] (같은 키)
"010-1234" + "f9z8..." -----> [ AES-GCM ] -----> "Bwq_392"
바로 이 점 떄문에 위에서 보았던 문제가 발생하는겁니다. 같은 데이터를 넣더라도 랜덤 문자열 때문에 DB에 이미 들어가 있는 데이터인지 알 수 없습니다. 그렇기 떄문에 데이터 사이의 대소 비교가 불가능하고 이는 인덱싱을 불가능하게 만들죠. 그러다 보니 검색 성능도 극악일 수밖에 없습니다. "abc1235@example.com" 이라는 이메일을 찾으려고 하자니, 기존에 똑같은 이메일이 저장되어 있더라도 랜덤 문자열 때문에 어떻게 저장이 되어 있을지 예상할 수 없으니 모든 데이터를 복호화해서 일일히 대조해보는 수밖에 없거든요.
현대의 네이버나 배달의 민족, 특히 토스 같은 대규모 서비스는 보안과 검색 성능을 트레이드 오프로 둘 수 없습니다. 둘다 포기할 수 없는 너무나 중요한 가치이니까요. 그래서 이를 해결하기 위해 기존의 설계와는 다른 접근이 필요합니다. 대부분의 대규모 서비스에서 이를 해결하기 위해 추가로 해시 컬럼을 둡니다. 암호화된 컬럼과 1대1로 매칭되는 해시 데이터를 둠으로서 중복 데이터 저장 문제와 검색 성능 문제를 동시에 해결할 수 있죠.
해시란 하나의 단방향 암호화라고 할 수 있습니다. 그렇기 때문에 특정값에 대한 해시값은 하나밖에 없다고 할 수 있죠. (물론 비밀키가 동일하다는 가정 하에요.) 물론 원본 데이터를 그대로 SHA-256과 같은 해싱 알고리즘을 적용하면 위의 해커에 의한 문제가 그대로 발생하겠죠. 그래서 서버에서만 저장하는 고정 비밀키를 함께 사용하여 해시값을 만듭니다. 이 고정값을 일반적으로 pepper 값이라고 부릅니다. 예를 들어서 전화번호를 저장할 때 tel이라는 컬럼에는 원본 전화번호를 암호화하여 저장하고, hash_tel 이라는 별도의 컬럼을 두어, 이를 통해 중복 검사와 검색을 진행합니다.
+--------------------------------------------------+
| HMAC (검색용 해시 생성) |
+--------------------------------------------------+
[ 원본 데이터 ] [ 서버 비밀키 🗝️ ]
"010-1234-5678" "MySecretKey_v1"
| |
+-------------+--------------+
|
v
+-----------------------+
| HMAC 알고리즘 (믹서기) |
| (SHA-256 + Key Mixing)|
+-----------------------+
|
v
[ 최종 해시값 (Digest) ]
"a1b2c3d4..." (64자 문자열)
이 pepper값과 해싱 알고리즘 덕분에 해시 값을 안다고 해서 원본 데이터를 알 수 없습니다. 하지만 원본 데이터를 통해서는 해시값을 찾을 수 있기 때문에, 해시 값을 만든 이후, 테이블 내에 겹치는 해시값이 있는지 중복 검사를 할 수 있고 또한 여기에 인덱싱을 적용하여 검색 성능도 정상화 할 수 있죠. 물론 DB가 통째로 털린다면 해커가 원본 값의 패턴은 알 수 있습니다만, 이정도 트레이드 오프는 감안할 수 있죠.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class MemberEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String nickname;
@Column(nullable = false, unique = true)
private String username;
@Convert(converter = CryptoConverter.class)
@Column(length = 500)
private String tel;
@Column(nullable = false, unique = true)
private String hash_tel;
@Convert(converter = CryptoConverter.class)
@Column(length = 500)
private String email;
@Column(nullable = false, unique = true)
private String hash_email;
@Column(name = "is_deleted")
private boolean isDeleted;
@Column(nullable = false)
private String password;
@Column(name = "member_role")
@Enumerated(EnumType.STRING)
private MemberRole memberRole;
private Boolean isMarketingAgreed;
}
그렇게 하면 위와 같은 테이블 구조로 설계할 수 있을거 같습니다. 정말 약간의 보안을 포기하는 대신 검색 성능을 유지하는 설계입니다. 특히 전화번호나 이메일같은 경우, 동일 유저의 중복 가입을 막기 위해 테이블에서 같은 데이터가 있는지 빠르게 검증해야 하죠. 하지만 반드시 보호해야만 하는 민감한 데이터이기 때문에 대부분의 큰 서비스는 이런 테이블 설계를 지향합니다.
여기서 조금만 더 보안쪽으로 나아가보죠. 보안 시스템을 구축할 때 가장 중요한 대원칙 중 하나는 "모든 키는 언젠가 유출될 수 있다" 고 가정하는 것입니다. 또한 유출되지 않았더라도, ISMS 같은 보안 인증 심사를 통과하려면 주기적(통상 1년)으로 암호화 키를 교체해야 합니다. 하지만 수백만명의 이미 기존 키로 암호화되어 저장된 데이터들이 있죠. 서비스 도중 이걸 복호화/재암호화 할 수는 없죠. 그래서 보통 버전 Prefix를 활용한 전략을 활용합니다.
암호화가 된 데이터 맨 앞에 v1, v2 등을 붙여 버전을 명시하는겁니다. 그리고 각 버전에 해당하는 암호키를 서버에 모두 저장하는거죠. 그럼 서버는 각 버전에 따라 어떤 암호키를 활용하여 복호화를 해야 하는지 판단할 수 있죠. 또한 유저 정보를 수정하거나 새로 가입할 때는 무조건 최신 버전의 암호키를 활용하도록 하는겁니다. 이렇게 설계한 후 개발자가 키를 추가하여 배포하기만 하면 사용자들이 서비스를 이용하는 과정에서 자연스럽게 구버전 데이터가 신버전 데이터로 덮어씌워집니다. (추가로 오랫동안 로그인하지 않는 유저 데이터는 별도의 배치 프로그램을 사용할 수 있을겁니다.) 물론 검색용 해시 키는 로테이션이 매우 까다롭기 때문에 이때 키를 교체하기 위해서는 대규모의 배치 작업이 필요하겠죠.

암호화 관련된 부분을 공부할 때만 되면 제가 처음으로 진행했던 프로젝트가 생각납니다. ec2 인스턴스 대여해서 프로젝트 빌드해서 nohup으로 실행시키고 ssr, csr 이런 개념도 없어가지고 남들이 작성한 react 프로젝트를 빌드한다음 그대로 스프링부트 정적파일에 넣어 배포했었죠. aws rds에 대한 개념도 없어가지고, DB는 ec2 내에 mysql 서버를 직접 설치해서 연결했습니다.
이런 초보 개발자가 보안이나 백업 같은걸 어떻게 알겠습니까. DB 접근하기 어려워서 외부 접근 모두 허용하고, 비밀번호도 정말 간단한 문자열로 설정해놨습니다. 지금 생각해보면 제발 DB에 있는 데이터를 가져가달라고 애원하는 정도였는데, 그때는 그걸 모르고 오오 접속된다 하고 신기해했죠.
아니나 다를까, 한달정도 지나니 사이트 로그인이 안되고, DB 확인해보니 누군가 접근하여 전체 데이터 drop을 하셨더라구요. ec2로그도 확인해 보니까 누군가 다른나라 IP로 인스턴스에 이런저런 비밀번호로 접근하려는 시도가 있었더군요. 처음으로 제 힘으로 진행하고 배포까지 직접 한 프로젝트가 그렇게 털리고 나니까 정말 많은 생각이 들더라구요. 특히 개발을 처음 시작할때잖아요. 누군지 모르겠지만 제 DB에 침입한 저 사람이 잘못한거지만, 이런 기본적인 보안도 하지 못한 내 잘못이 아닌가. 내가 개발에 재능이 없는건가. 그런 생각이 많이 들었습니다. 지금 다시 생각해보면 그때 경험으로 더 크게 성장할 수 있었다 그런 생각이 아직도 안들어요. 아직도 그때 생각하면 무섭습니다. 돈이 안나가는 피해였어서 다행이지, 보안은 정말 신경을 많이 써야해요.
오늘도 읽어주셔서 감사합니다 d^^b
'[프로젝트 일지]' 카테고리의 다른 글
| 트랜잭션과 병렬처리에 대한 고찰 (3) | 2025.10.23 |
|---|---|
| pdf 관련 기능 도입기 (2) | 2025.10.13 |
| 결제가 복사가 된다고?? (2) | 2025.10.03 |
| 대용량 이미지 데이터의 (base64) 요청과 응답 처리 (7) | 2025.09.27 |