이번에는 회원가입 기능에 비밀번호 암호화를 적용해보겠습니다.
비밀번호를 평문으로 DB에 저장하면 데이터베이스가 유출되었을 때 사용자 정보 전체가 위험에 노출될 수 있습니다.
따라서 평문이 아닌 암호화된 형태로 저장하고 내부 로직에서만 비교하는 방식으로 구현해보겠습니다.
비밀번호 암호화에 자주 사용되는 대표적인 알고리즘은 다음과 같습니다:
PBKDF2
Argon2
BCrypt
각 알고리즘마다 장단점이 존재합니다.
이 중 Argon2
는 최신 암호화 알고리즘으로 메모리를 적극적으로 사용하는 구조를 통해 GPU 병렬 공격에 강한 보안성을 제공합니다. 다만 설정이 비교적 복잡하고 Java 환경에서는 별도 라이브러리 추가가 필요하다는 단점이 있습니다.
PBKDF2
는 설정이 유연하고 표준화되어 있다는 장점이 있지만 반복 수나 솔트 등을 잘못 설정할 경우 보안이 떨어질 수 있습니다.
반면 BCrypt
는 솔트를 자동으로 붙여주고 설정이 간단하여 실무에서 가장 널리 사용되는 알고리즘 중 하나입니다.
이번 구현에서는 간편하게 적용 가능하고 기본적인 보안 요건을 만족하는 BCrypt
를 사용하였습니다.
먼저 build.gradle.kts
파일에 다음 의존성을 추가합니다:
dependencies {
implementation("org.springframework.security:spring-security-crypto")
}
이후 BCrypt
인코더를 빈으로 등록합니다:
@Configuration
class PasswordEncoderConfig {
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}
이 설정으로 PasswordEncoder
인터페이스를 주입받아 사용할 수 있게 됩니다.
PasswordEncoder
가 잘 동작하는지 테스트 코드를 작성해보았습니다.
@SpringBootTest
class PasswordEncoderConfigTest(
val encoder: PasswordEncoder,
) : FunSpec({
fun generateRandomPassword(length: Int): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#\$%^&*"
return (1..length)
.map { chars.random() }
.joinToString("")
}
test("비밀번호 암호화 성공") {
val rawPassword = generateRandomPassword(12)
val encoded = encoder.encode(rawPassword)
encoded shouldNotBe rawPassword
encoder.matches(rawPassword, encoded) shouldBe true
encoder.matches("wrongPassword", encoded) shouldBe false
}
})
generateRandomPassword()
함수로 랜덤 비밀번호를 생성하고
암호화된 문자열이 원래 문자열과 다름을 확인하고 matches()
로 일치 여부를 검증하였습니다.
이제 암호화를 활용해 회원가입 기능을 구현하겠습니다.
username
, password
, email
, nickname
username
이면 가입을 막는다@RestController
@RequestMapping("/api/v1/auth")
class AuthController(
private val authService: AuthService,
) {
@PostMapping("/signup")
fun signUp(@RequestBody request: SignupRequest): ResponseEntity<Void> {
authService.signUp(request.toCommand())
return ResponseEntity.status(HttpStatus.CREATED).build()
}
}
Controller 에서는 SignupRequest
DTO 를 받아
내부 로직에서 사용할 수 있는 SignupCommand
로 변환한 후 서비스에 전달합니다.
@Schema(description = "회원가입 요청 DTO")
data class SignupRequest(
@Schema(description = "사용자 id", example = "dustle1313")
val username: String,
@Schema(description = "사용자 password", example = "securePass123!")
val password: String,
@Schema(description = "사용자 email", example = "asdf@gmail.com")
val email: String,
@Schema(description = "사용자 nickname", example = "dustle")
val nickname: String,
) {
fun toCommand() = SignupCommand(
username = this.username,
password = this.password,
email = this.email,
nickname = this.nickname
)
}
data class SignupCommand(
val username: String,
val password: String,
val email: String,
val nickname: String,
)
SignupRequest
는 외부 요청을 받을 때 사용하는 구조체이며 SignupCommand
는 서비스 내부에서 사용되는 객체입니다.
이 둘을 구분함으로써 계층 간 의존성을 분리하였습니다.
@Service
class AuthService(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
) {
fun signUp(signupCommand: SignupCommand) {
validateDuplicateUsername(signupCommand)
validatePasswordPolicy(signupCommand)
val encodedPassword = passwordEncoder.encode(signupCommand.password)
val user = User(
username = signupCommand.username,
password = encodedPassword,
email = signupCommand.email,
nickname = signupCommand.nickname
)
userRepository.save(user)
}
private fun validateDuplicateUsername(signupCommand: SignupCommand) {
if (userRepository.existsByUsername(signupCommand.username)) {
throw IllegalArgumentException("Username already exists")
}
}
private fun validatePasswordPolicy(signupCommand: SignupCommand) {
if (signupCommand.password.length < 10) {
throw IllegalArgumentException("Password must be at least 10 characters long")
}
}
}
회원가입 시 중복 username
을 확인하고,
비밀번호가 정책을 만족하는지 검증한 후 암호화를 거쳐 저장합니다.
@SpringBootTest
class AuthServiceTest(
val authService: AuthService,
val userRepository: UserRepository,
) : FunSpec({
afterTest {
userRepository.deleteAll()
}
context("회원가입 테스트") {
test("성공") {
val signupCommand = SignupCommand(
username = "dustle1",
password = "111111111111111",
email = "111",
nickname = "Dustle"
)
authService.signUp(signupCommand)
userRepository.existsByUsername("dustle1") shouldBe true
}
test("이미 존재하는 username 이 있어서 실패") {
val user = User(
username = "dustle2",
password = "111111111111111",
email = "111",
nickname = "Dustle"
).also { userRepository.save(it) }
val signupCommand = SignupCommand(
username = "dustle2",
password = "111111111111111",
email = "111",
nickname = "Dustle"
)
shouldThrow<IllegalArgumentException> {
authService.signUp(signupCommand)
}.message shouldBe "Username already exists"
}
test("비밀번호가 정책을 통과하지 못해서 실패") {
val signupCommand = SignupCommand(
username = "dustle3",
password = "1212",
email = "111",
nickname = "Dustle"
)
shouldThrow<IllegalArgumentException> {
authService.signUp(signupCommand)
}.message shouldBe "Password must be at least 10 characters long"
}
}
})
shouldThrow
를 활용하여 예외 상황을 검증합니다.
테스트 후에는 userRepository.deleteAll()
을 실행해 데이터를 정리합니다.
@SpringBootTest
@AutoConfigureMockMvc
class AuthControllerTest(
val mockMvc: MockMvc,
val userRepository: UserRepository,
) : FunSpec({
afterTest {
userRepository.deleteAll()
}
test("POST /api/v1/auth/signup 요청이 들어왔을 때 200 OK") {
val signupRequest = SignupRequest(
username = "dustle13",
password = "securePass123!",
email = "dustle@example.com",
nickname = "Dustle"
)
val requestBody = """
{
"username": "${signupRequest.username}",
"password": "${signupRequest.password}",
"email": "${signupRequest.email}",
"nickname": "${signupRequest.nickname}"
}
""".trimIndent()
mockMvc.post("/api/v1/auth/signup") {
contentType = org.springframework.http.MediaType.APPLICATION_JSON
content = requestBody
}.andDo { print() }
.andExpect {
status { isCreated() }
}
}
})
Controller 테스트에서는 MockMvc
를 사용하여 실제 API 호출처럼 테스트를 수행하였습니다.
Swagger UI 에서도 정상적으로 동작하는 것을 확인할 수 있었습니다.
이번 작업을 통해 계층 간 의존성 문제를 피하면서 비밀번호 암호화를 안전하게 적용할 수 있었습니다.
처음에는 단순히 DTO 를 Service 까지 넘기면 편할 것 같았지만,
의존성의 방향성과 책임 분리를 고려하면 변환 객체(Command)를 따로 두는 것이 훨씬 유지보수에 유리하다는 점을 알 수 있었습니다.
구현할 때는 조금 번거롭지만 확장성과 테스트 편의성 코드 안정성 측면에서는 큰 차이를 만들어주는 구조라고 생각합니다.