내가 진행 중인 웹 프로젝트에서 암호화와 관련된 요구사항이 있었고 이를 구현하는 과정에서 느낀 점들을 정리하고자 한다.
일단 내가 진행했던 프로젝트에서 사용했던 기술들을 나열하고자 한다.
처음 암호화에 대한 생각은 너무 단순하게 DB에 저장할 때 '데이터를 바꿔서 넣으면 된다'의 단순한 생각이었다. 그래서 깊은 생각없이 구현 부분만 집중적으로 생각하여서 코딩을 진행했다.
스프링 시큐리티에서도 무조건 암호화 해야만 하는 부분이 존재하는데 바로 Password 부분이다. 실제로 PasswordEncoder를 사용하지 않으면 제대로 스프링 부트 프로젝트가 실행되지 않는다. 이런 PasswordEncoder 중에서 나는 가장 많이 사용하는 BCryptPasswordEncoder를 사용하였다.
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
프로젝트에서 요구하는 유저의 개인 정보들을 저장하기 위해서 사용한 암호화 방식으로 AES256Encoder를 요구 받았고 이를 구현했다.
package com.miniproject.miniprojectgroupthree.util;
import com.miniproject.miniprojectgroupthree.domain.Member;
import com.miniproject.miniprojectgroupthree.error.AES256EncodingException;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import static java.nio.charset.StandardCharsets.UTF_8;
public class AES256Encoder {
public static String alg = "AES/CBC/PKCS5Padding";
private final String key = "12345678910111213";
private final String iv = key.substring(0, 16); // 16byte
/**
* Encode 문자열.
*
* @param text the text
* @return the string
*/
public String encodeString(String text) {
try {
Cipher cipher = getCipher(Cipher.ENCRYPT_MODE);
byte[] encrypted = cipher.doFinal(text.getBytes(UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AES256EncodingException(e);
}
}
/**
* Decode 문자열.
*
* @param cipherText the cipher text
* @return the string
*/
public String decodeString(String cipherText) {
try {
Cipher cipher = getCipher(Cipher.DECRYPT_MODE);
byte[] decodedBytes = Base64.getDecoder().decode(cipherText);
byte[] decrypted = cipher.doFinal(decodedBytes);
return new String(decrypted, UTF_8);
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AES256EncodingException(e);
}
}
}
AES256Encoder를 만든 후 JPA의 repository를 통해서 save하려고 했다. 그런데 조금 문제가 생겼는데 너무 많은 하드 코딩?이 MemberService에 표시해야만 했다.
Member member = Member.builder()
.account(aes256Encoder.encodeString(form.getAccount()))
.password(passwordEncoder.encode(form.getPassword()))
.name(aes256Encoder.encodeString(form.getName()))
.phoneNumber(aes256Encoder.encodeString(form.getPhoneNumber()))
.role(Role.ROLE_USER)
.build()
);
처음 했던 아주 심각한 하드 코딩을 어떻게 하면 더 좋은 코드로 바꿀 수 있을까? 라는 고민을 하며 이런 저런 방법을 생각했다. 그러다 보니 자연스럽게 객체지향적인 코드로 바꿀 수 있다면 좋겠다는 생각에 닿았고 내가 읽었던 객체지향의 사실과 오해의 객체의 상호작용 과정에 대해서 생각하게 되었다. 서비스 객체와 AES256Encoder 객체 사이에서 Member 객체를 주고 받는 과정에서 객체 사이의 협력 과정에서 객체가 알아야 할 정보는 정말 필요한 정보 뿐이라는 것이다. 여기서 필요한 정보는 그저 Member 객체를 주고 받는 것이다.
AES256Encoder
/**
* Encode Member객체.
* {account, name, phoneNumber, birth} 암호화
*
* @param member the member
* @return the member
*/
public Member encodeMember(Member member) {
try {
Cipher cipher = getCipher(Cipher.ENCRYPT_MODE);
member.setAccount(Base64.getEncoder().encodeToString(cipher.doFinal(member.getAccount().getBytes(UTF_8))));
member.setName(Base64.getEncoder().encodeToString(cipher.doFinal(member.getName().getBytes(UTF_8))));
member.setPhoneNumber(Base64.getEncoder().encodeToString(cipher.doFinal(member.getPhoneNumber().getBytes(UTF_8))));
return member;
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AES256EncodingException(e);
}
}
/**
* Decode Member객체.
* {account, name, phoneNumber, birth} 복호화
*
* @param member the member
* @return the member
*/
public Member decodeMember(Member member) {
try {
Cipher cipher = getCipher(Cipher.DECRYPT_MODE);
member.setAccount(new String(cipher.doFinal(Base64.getDecoder().decode(member.getAccount())), UTF_8));
member.setName(new String(cipher.doFinal(Base64.getDecoder().decode(member.getName())), UTF_8));
member.setPhoneNumber(new String(cipher.doFinal(Base64.getDecoder().decode(member.getPhoneNumber())), UTF_8));
return member;
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException |
InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AES256EncodingException(e);
}
}
MemberService
public void signup(MemberSaveForm form) {
memberRepository.findByAccount(aes256Encoder.encodeString(form.getAccount()))
.ifPresentOrElse(
user -> {
throw new AlreadyRegisteredUserException();
},
() -> {
Member member = aes256Encoder.encodeMember(
Member.builder()
.account(form.getAccount())
.password(passwordEncoder.encode(form.getPassword()))
.name(form.getName())
.phoneNumber(form.getPhoneNumber())
.role(Role.ROLE_USER)
.build()
);
memberRepository.save(member);
}
);
}
실제로 객체의 정보를 암호화 하는 부분을 객체 내부로 감춰서 서비스 객체에서는 AES256Encoder 객체가 하는 일은 알지 못하게 느슨한 결합을 할 수 있게 만들었다. 이로 인해서 서비스 객체에서만 AES256Encoder를 이용하는 것이 아닌 다른 곳에서도 사용할 수 있게 객체를 하나의 독립적인 섬과 같은 존재로 바꾸었다. 다시 말해 재사용성을 높여 다른 개발자가 내가 적은 주석만으로도 인코더 객체를 쉽게 사용할 수 있게 만들었다.
처음 암호화하는 인코더 객체의 설계와 서비스 객체의 구현 등에서 제대로 생각하지 않고 코딩한 부분이다. 그래서 어떤 정보를 복호화하고 암호화 해야 하는 것인지 정확하게 몰랐다.
암호화를 할 때 중요한 것은 "다시 되돌릴 상황이 있는가?"라고 생각한다. 이와 같이 생각한 부분을 쉽게 만날 수 있는 것이 바로 회원 정보 수정이다. 네이버나 깃허브 등 회원 정보를 가진 많은 웹사이트에서 회원 정보 수정 화면을 보게 되면 많은 정보는 수정이 가능하게 이미 작성된 정보가 보이는 방식으로 구현되어 있다.
하지만 그 어떤 웹 사이트에서 비밀번호를 수정할 때 미리 내가 이전에 입력해 둔 비밀번호가 입력되어 있던 경우는 본 적이 없다. 대신 새로운 비밀번호를 입력 받고 이전의 비밀번호 위에 새롭게 저장하는 형태로 되어 있다.
이와 같은 방식은 단방향과 양방향으로 설명될 수 있다. 단방향은 한쪽 방향으로 흐르는 다시 원래 데이터로 돌릴 수 없는 방식으로 비밀번호나 주민등록번호와 같은 정보를 암호화 하는데 사용한다. 내가 이번 프로젝트에서 사용한 방식은 PasswordEncoder의 BCryptPasswordEncoder방식으로 해시 알고리즘을 이용한 방식이다.
회원 정보 수정 화면에서 알 수 있듯이 내가 DB에 암호화 하여 저장한 정보를 다시 복호화 하여서 페이지에 표시하기 위해서는 다시 원래 데이터로 돌릴 수 있는 방식이다. 내가 사용한 방식은 AES256방식으로 개인 정보 중에서 다시 복호화할 이름, 계정, 휴대전화 와 같은 정보를 양방향으로 구현하였다.
이건 개인적인 궁금증으로 구현한 부분인데 '생년월일 또한 암호화 해야할까?'라는 궁금증에서 시작된 것이다. 암호화란 결국 누군가가 우리의 정보를 가져갔을 때 사용자를 특정하거나 악용할 여지가 있는 정보를 감추는 작업이다.
이런 상황에서 나는 이번 프로젝트가 '회사에서 사용하는 연차/당직'이고 누군가가 정보를 가져간다고 생각한다면 회사에는 만약 엄청나게 큰 회사라고 하더라도 충분히 생년월일만으로도 Member의 정보로 회사원을 특정 지을 수 있다고 생각했다. 따라서 생년월일도 추가적으로 암호화 하기로 했다. 이 과정에서도 단방향, 양방향을 고려했고 내가 사용했던 웹 사이트들에서도 그렇듯 복호화 하여 웹사이트에서 표시되는 정보이기에 양방향으로 암호화 하였다.
AES256Encoder
public Member encodeMember(Member member) {
try {
Cipher cipher = getCipher(Cipher.ENCRYPT_MODE);
...
member.setBirth(Base64.getEncoder().encodeToString(cipher.doFinal(member.getBirth().getBytes(UTF_8))));
...
}
}
...
내가 이번에 새롭게 자바 클래스를 만들면서 고민한 것은 빈으로 등록하냐 마냐 였다. 클래스를 만들 때마다 고민하는 부분인데 이번에 조금 더 확실하게 알게 된 것 같다. 일단 내가 고려한 부분이다.
내가 AES256Encoder를 빈으로 등록하였기에 서비스 객체에서 객체를 생성하지 않고 간단한 코드만으로 바로 사용할 수 있었다.
@Service
@RequiredArgsConstructor
public class MemberService {
...
private final AES256Encoder aes256Encoder;
...
}
이전에는 람다식을 잘 사용을 못했다. 사실 못하는 것을 넘어서 사용하기 무서워했다. 그러다가 Optional에 대해서 공부할 일이 생겨서 공부하게 되고 null의 관리가 너무나도 쉽게 변하는 과정을 겪어보니 람다식에 대한 관심이 점점 강해졌다. 그래서 이번 프로젝트에서 람다식을 적극적으로 활용하여 프로젝트를 진행하게 되었다.
memberRepository.findByAccount(aes256Encoder.encodeString(form.getAccount()))
.ifPresentOrElse(
user -> { // ifPresent로 구성하여 그냥 아래 코드와 분리하기
throw new AlreadyRegisteredUserException();
},
() -> {
Member member = aes256Encoder.encodeMember(
Member.builder()
.account(form.getAccount())
.password(passwordEncoder.encode(form.getPassword()))
.name(form.getName())
.birth(form.getBirth().toString())
.phoneNumber(form.getPhoneNumber())
.role(Role.ROLE_USER)
.build()
);
memberRepository.save(member);
}
);
물론 이게 잘된 일인지는 약간 모르겠다. 왜냐하면 내가 서비스 클래스에 만든 람다식이 거의 10줄에 가까운 코드인데 오히려 가독성이 더 좋지 않은 코드를 만들게 된 것이 아닌가? 라는 그런 걱정도 섞여있다. 그래서 최근에 람다식에 관련된 공부를 시작하려고 한다.
내가 이번 프로젝트를 진행하면서 가장 중요하다고 여긴 부분은 협업 과정에서 다른 개발자가 내가 만든 API를 쉽고 편하게 사용하도록 만드는 것이었다. 그래서 Springdoc을 이용하여 문서화하고 쉽게 테스트 해보도록 만들었다. 이와 같이 암호화하는 과정의 클래스에서 다른 개발자가 어떻게 하면 더 편하고 쉽게 사용할 수 있을까를 많이 고민하였다.
그래서 객체지향적인 관점에서 바라보았고 따라서 원래라면 다른 개발자가 DB에 어떤 정보가 어떤 방식으로 암호화해서 들어가는 지 정확하게 알고 각각의 정보를 받아와서 Member객체의 모든 정보를 get으로 받아와 String 타입을 매개변수로 작동하는 decodeString(String cipherText) 메서드를 이용하여 하나씩 복호화 하는 작업을 하는 과정을 변경하였다.
따라서 다른 개발자가 DB에 어떤 정보가 어떤 방식으로 암호화 했는지는 별로 중요하지 않고 그저 decodeMember(Member member)로 객체를 넘겨주기만 하면 바로 복호화해서 사용할 수 있게 만들었다. 그리고 주석으로 추가 정보를 넣어서 혹시라도 코드를 보게 될 일이 있다면 코드를 분석하는 과정을 돕도록 만들었다.
프로젝트를 끝내고 다시 보고 좀 아쉽다고 생각이 드는 부분들이다.
인터페이스로 한번 감싸고 구현했다면 어땟을까? 라는 생각이 조금 든다. 서비스 클래스의 코드를 보면 조금 강하게 결합된 것 같은 생각이 많이 들어서 InfoEncoder와 같은 인터페이스를 만들고 이를 구현하는 클래스로 만들었다면 좋았다고 생각한다.
MemberSaveForm객체를 Member객체로 변환하는 과정이 좀 매끄럽지 않아서 상당히 불만이 많았지만 당시의 지식과 검색으로 ModelMapper 존재를 몰랐다. 굉장히 아쉽다는 생각이 많이 드는 부분으로 만약 내가 ModelMapper를 사용할 줄 알았다면
memberRepository.findByAccount(aes256Encoder.encodeString(form.getAccount()))
.ifPresentOrElse(
user -> { // ifPresent로 구성하여 그냥 아래 코드와 분리하기
throw new AlreadyRegisteredUserException();
},
() -> {
Member member = aes256Encoder.encodeMember(
modelmapper.map(form, Member.class);
}
);
정말 가독성 높고 굉장히 간편한 코드로 만들 수 있었을 텐데... 너무 아쉬운 부분이다.
이번 프로젝트에서 가장 많이 고민한 부분이었다. 하나를 구현하더라도 이게 맞나? 라는 생각으로 찾아보고 공식문서도 보면서 구현했다. 그래서 한 줄의 코드라도 이게 맞는지에 대해서 몇 시간을 고민해 본적도 있었다.(빈으로 등록하는 것이 옳은가?) 그래서 내가 만든 코드가 점점 더 발전해 가는 과정을 직접 느꼈고 하나의 메서드를 구현하는 과정에서 조차 2~3개를 배우고 또 이런 배운 점들을 적용하는 과정에서 또 구현 방식을 고민하는 그런 꼬리에 꼬리를 무는 생각을 많이 하면서 깊게 생각하고 코딩을 하게 되었다.
이전에는 일단 돌아가면 그만 이라는 생각이 강했지만 이제는 정말 코드 한줄 한줄 더 좋은 코드가 뭘까? 더 좋은 구현이 뭘까? 라는 생각을 많이 하게 만들어준 프로젝트였다.