[kotlog] 코프링으로 블로그 만들기 - 9 (BCrypt)

dustle·2025년 7월 8일
1

kotlog

목록 보기
9/9

이번에는 회원가입 기능에 비밀번호 암호화를 적용해보겠습니다.
비밀번호를 평문으로 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() 로 일치 여부를 검증하였습니다.

회원가입 기능 설계

이제 암호화를 활용해 회원가입 기능을 구현하겠습니다.

1. 요구사항 정리

  1. 사용자 입력으로 다음 값을 받는다: username, password, email, nickname
  2. 이미 존재하는 username 이면 가입을 막는다
  3. 비밀번호는 정책(최소 10자 이상)에 맞아야 한다
  4. 비밀번호는 암호화해서 저장한다

2. Controller 구성

@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 로 변환한 후 서비스에 전달합니다.

3. DTO 및 Command 객체 정의

@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 는 서비스 내부에서 사용되는 객체입니다.
이 둘을 구분함으로써 계층 간 의존성을 분리하였습니다.

4. Service 구현

@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 을 확인하고,
비밀번호가 정책을 만족하는지 검증한 후 암호화를 거쳐 저장합니다.

테스트 코드 작성

Service 테스트

@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() 을 실행해 데이터를 정리합니다.

Controller 테스트

@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)를 따로 두는 것이 훨씬 유지보수에 유리하다는 점을 알 수 있었습니다.

구현할 때는 조금 번거롭지만 확장성과 테스트 편의성 코드 안정성 측면에서는 큰 차이를 만들어주는 구조라고 생각합니다.

0개의 댓글