5.10 보안과 개인정보 보호
개요
사용자 도메인에서 보안과 개인정보 보호는 가장 중요한 요구사항 중 하나입니다. 이 장에서는 사용자의 개인정보를 안전하게 보호하고 시스템의 보안을 유지하기 위한 구체적인 구현 방법을 살펴보겠습니다.
비밀번호 보안
1. 비밀번호 해시화 전략
비밀번호는 절대로 평문으로 저장해서는 안 되며, 항상 안전한 해시 함수를 통해 해시화하여 저장해야 합니다. 다음은 BCrypt를 사용한 비밀번호 해시화 구현의 예시입니다:
@Service
public class BCryptPasswordHasher implements PasswordHasher {
private final BCryptPasswordEncoder encoder;
private static final int DEFAULT_STRENGTH = 12; // 추천되는 강도
public BCryptPasswordHasher() {
this.encoder = new BCryptPasswordEncoder(DEFAULT_STRENGTH);
}
@Override
public HashedPassword hash(String password) {
String hashedValue = encoder.encode(password);
return HashedPassword.from(hashedValue);
}
@Override
public boolean matches(String inputPassword, String hashedPassword) {
return encoder.matches(inputPassword, hashedPassword);
}
}2. 비밀번호 정책 구현
비밀번호는 강력한 정책을 통해 안전성을 보장해야 합니다:
public final class Password extends AbstractValueObject {
private static final int MIN_LENGTH = 8;
private static final int MAX_LENGTH = 50;
private static final Pattern LETTER_PATTERN = Pattern.compile("[a-zA-Z]");
private static final Pattern DIGIT_PATTERN = Pattern.compile("\\d");
private static final Pattern SPECIAL_CHAR_PATTERN =
Pattern.compile("[!@#$%^&*(),.?\":{}|<>]");
private static Result<String> validate(String value) {
return ContractValidator.start()
.requireNotBlank(value, ErrorCodes.USER_PASSWORD_BLANK)
.require(meetsLengthRequirement(value),
ErrorCodes.USER_PASSWORD_LENGTH_INVALID)
.require(meetsComplexityRequirement(value),
ErrorCodes.USER_PASSWORD_INVALID_STRENGTH)
.validate()
.map(__ -> value);
}
private static boolean meetsComplexityRequirement(String password) {
return LETTER_PATTERN.matcher(password).find() &&
DIGIT_PATTERN.matcher(password).find() &&
SPECIAL_CHAR_PATTERN.matcher(password).find();
}
}개인정보 보호
1. 개인정보 암호화
민감한 개인정보는 반드시 암호화하여 저장해야 합니다:
@Component
public class AESEncryption implements EncryptionService {
private final SecretKey secretKey;
private static final String ALGORITHM = "AES/GCM/NoPadding";
public AESEncryption(@Value("${encryption.key}") String encodedKey) {
this.secretKey = decodeKey(encodedKey);
}
@Override
public String encrypt(String plainText) {
try {
Cipher cipher = Cipher.getInstance(ALGORITHM);
byte[] iv = generateIV();
GCMParameterSpec spec = new GCMParameterSpec(128, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec);
byte[] encrypted = cipher.doFinal(plainText.getBytes());
// IV와 암호화된 데이터를 함께 저장
return Base64.getEncoder()
.encodeToString(concatenate(iv, encrypted));
} catch (Exception e) {
throw new EncryptionException("암호화 실패", e);
}
}
@Override
public String decrypt(String encryptedText) {
// 복호화 구현...
}
}2. 개인정보 처리 정책
개인정보는 명확한 처리 정책에 따라 관리되어야 합니다:
public class PersonalInformation {
@Encrypted // 커스텀 애노테이션
private final String nationalId;
@Encrypted
private final String phoneNumber;
private final String address; // 기본 정보는 평문 저장 가능
// 개인정보 조회 로그를 남기는 AOP
@Aspect
@Component
public static class PersonalInfoAccessLogger {
@Before("@annotation(AccessLog) && args(userId,..)")
public void logAccess(JoinPoint jp, String userId) {
// 접근 로그 기록
}
}
}접근 제어
1. 권한 관리 시스템
사용자의 권한을 체계적으로 관리하는 시스템을 구현합니다:
public interface AccessControl {
boolean hasPermission(UserId userId, Permission permission);
Set<Permission> getPermissions(UserId userId);
}
@Service
public class RoleBasedAccessControl implements AccessControl {
private final UserRoleRepository roleRepository;
private final PermissionRegistry permissionRegistry;
@Override
@Cacheable(value = "permissions", key = "#userId")
public boolean hasPermission(UserId userId, Permission permission) {
Set<Role> roles = roleRepository.findByUserId(userId);
return roles.stream()
.flatMap(role -> role.getPermissions().stream())
.anyMatch(p -> p.equals(permission));
}
}2. 메서드 수준 보안
중요한 작업은 메서드 수준에서 보안을 적용합니다:
@Service
public class SecureUserService {
@PreAuthorize("hasPermission(#userId, 'USER_MODIFY')")
public Result<Void> updateUser(UserId userId,
UpdateUserCommand command) {
// 구현...
}
@PostAuthorize("returnObject.userId == authentication.principal.id")
public Result<UserDetails> getUserDetails(UserId userId) {
// 구현...
}
}보안 감사 (Audit)
1. 감사 로그 시스템
모든 중요한 작업에 대한 감사 로그를 기록합니다:
@Entity
@Table(name = "security_audit_logs")
public class SecurityAuditLog extends AbstractAuditableEntity {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false)
private String action;
@Column(nullable = false)
private String userId;
@Column(nullable = false)
private String resourceType;
private String resourceId;
@Column(nullable = false)
private String ipAddress;
@Column(length = 1000)
private String details;
@CreatedDate
private LocalDateTime createdAt;
}
@Service
public class SecurityAuditService {
private final SecurityAuditLogRepository auditLogRepository;
public void logSecurityEvent(SecurityEvent event) {
SecurityAuditLog log = new SecurityAuditLog();
log.setAction(event.getAction());
log.setUserId(event.getUserId());
log.setResourceType(event.getResourceType());
log.setResourceId(event.getResourceId());
log.setIpAddress(event.getIpAddress());
log.setDetails(event.getDetails());
auditLogRepository.save(log);
}
}2. 변경 이력 추적
엔티티의 변경 이력을 자동으로 추적합니다:
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class AbstractAuditableEntity {
@CreatedBy
@Column(nullable = false, updatable = false)
private String createdBy;
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedBy
@Column(nullable = false)
private String lastModifiedBy;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime lastModifiedAt;
}보안 테스트
1. 단위 테스트
보안 관련 기능의 단위 테스트:
class PasswordHasherTest {
private PasswordHasher hasher;
@BeforeEach
void setUp() {
hasher = new BCryptPasswordHasher();
}
@Test
@DisplayName("동일한 비밀번호에 대해 다른 해시값이 생성되어야 함")
void shouldGenerateDifferentHashesForSamePassword() {
String password = "Test123!";
HashedPassword hash1 = hasher.hash(password);
HashedPassword hash2 = hasher.hash(password);
assertThat(hash1).isNotEqualTo(hash2);
assertThat(hasher.matches(password, hash1.value())).isTrue();
assertThat(hasher.matches(password, hash2.value())).isTrue();
}
}2. 통합 테스트
보안 정책의 통합 테스트:
@SpringBootTest
class SecurityIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private SecurityAuditService auditService;
@Test
@WithMockUser(roles = "USER")
@DisplayName("권한이 없는 사용자는 관리자 기능에 접근할 수 없어야 함")
void shouldDenyAccessToAdminFunctions() {
UserId targetUserId = UserId.newId();
assertThrows(AccessDeniedException.class, () ->
userService.getUserDetails(targetUserId));
}
}보안 모범 사례
-
깊이 있는 방어(Defense in Depth)
- 여러 계층의 보안 통제를 구현합니다
- 각 계층에서 독립적인 보안 검사를 수행합니다
-
최소 권한 원칙
- 사용자에게 필요한 최소한의 권한만 부여합니다
- 권한 상승이 필요한 경우 명시적인 승인 절차를 구현합니다
-
실패 안전(Fail-safe) 기본값
- 보안 관련 설정의 기본값은 가장 제한적인 옵션으로 설정합니다
- 예외 상황에서는 접근 거부가 기본 동작이 되도록 합니다
-
완전한 중재(Complete Mediation)
- 모든 접근 시도에 대해 권한 검사를 수행합니다
- 캐시된 권한 정보의 적절한 무효화 처리를 구현합니다
결론
보안과 개인정보 보호는 지속적인 관리와 개선이 필요한 영역입니다. 이 장에서 설명한 구현 방법들은 기본적인 보안을 제공하지만, 실제 운영 환경에서는 다음과 같은 추가적인 고려사항들이 필요합니다:
- 정기적인 보안 감사 및 취약점 평가
- 보안 패치와 업데이트의 신속한 적용
- 사용자 보안 교육 및 인식 제고
- 보안 사고 대응 계획 수립 및 훈련
또한, 보안 요구사항은 시간이 지남에 따라 변화할 수 있으므로, 시스템의 보안 아키텍처는 새로운 위협에 대응할 수 있도록 충분한 유연성을 가져야 합니다.
변경 이력 추적은 도메인 계층과 인프라스트럭처 계층에서 각각 다른 방식으로 구현할 수 있습니다. 도메인 계층에서는 중요한 비즈니스 이벤트와 변경 사항을 도메인 이벤트로 표현하고, 인프라스트럭처 계층에서는 JPA Auditing과 같은 기술적인 방법을 사용하여 모든 데이터 변경 사항을 자동으로 기록합니다.
도메인 계층의 비즈니스 변경 이력
도메인 계층에서는 중요한 비즈니스 변경 사항을 명시적으로 추적합니다:
package net.badnom.library.user.domain.user.event;
public record UserProfileChangedEvent(
UserId userId,
String fieldName,
String oldValue,
String newValue,
LocalDateTime occurredAt
) implements TimedDomainEvent {
// 생성자 및 유효성 검증...
}이러한 도메인 이벤트는 중요한 비즈니스 변경 사항을 표현하고, 이를 통해 업무적으로 의미 있는 변경 이력을 관리할 수 있습니다.
인프라스트럭처 계층의 기술적 변경 이력
인프라스트럭처 계층에서는 JPA Auditing을 활용하여 모든 데이터 변경 사항을 자동으로 기록합니다. 이러한 접근 방식은 도메인 모델과 독립적으로 작동하며, 데이터베이스 수준에서 변경 이력을 관리할 수 있습니다.
이러한 종합적인 접근 방식을 통해 시스템은 보안과 개인정보 보호에 대한 높은 수준의 완전성과 가시성을 확보할 수 있습니다.