Spring Security를 이용해 회원 관리나 로그인 기능을 구현할 때, 비밀번호를 평문(plain text)으로 저장하면 보안상 매우 취약합니다. 이번 글에서는 Spring Security에서 제공하는 BCryptPasswordEncoder를 사용하여 안전하게 비밀번호를 암호화하는 방법을 소개하겠습니다.
1. BCryptPasswordEncoder란?
BCryptPasswordEncoder는 Spring Security에서 제공하는 비밀번호 암호화 도구로, 단방향 해싱(hashing)을 수행합니다. 한번 암호화된 비밀번호는 복호화가 불가능하며, 로그인 시에는 입력된 비밀번호를 다시 암호화하여 데이터베이스에 저장된 암호화된 비밀번호와 비교하여 인증합니다.
주요 특징:
단방향 암호화 방식
안전한 salt 자동 생성 및 관리
비밀번호 강도 조절 가능
2. BCryptPasswordEncoder 설정 방법
다음은 Spring 프로젝트에서BCryptPasswordEncoder를 Bean으로 등록하는 예제입니다.
@Configuration
public class PasswordConfig {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
위 코드에서@Configuration어노테이션은 Spring에게 이 클래스가 설정 클래스임을 알려줍니다. 그리고@Bean어노테이션을 사용하여BCryptPasswordEncoder인스턴스를 Spring의 관리 대상(Bean)으로 등록합니다.
Flutter에서 initState 함수는 StatefulWidget의 상태를 초기화할 때 사용하는 메서드입니다. 쉽게 말해, "앱이 처음 실행될 때 한 번만 호출되는 준비 작업 함수"라고 생각하면 됩니다. 예를 들어, 데이터를 불러오거나 타이머를 설정하는 등 초기화가 필요한 작업을 initState에서 수행합니다.
initState의 주요 특징
딱 한 번 호출됨
initState는 State 객체가 처음 생성될 때 한 번만 호출됩니다.
이후 상태가 변경되어도 initState는 다시 호출되지 않습니다.
초기화 작업에 적합
데이터를 불러오거나 리스너를 추가하는 등 초기 설정 작업에 사용됩니다.
super.initState() 호출 필요
initState를 오버라이드할 때 super.initState() 를 반드시 호출해야 Flutter의 내부 초기화가 제대로 동작합니다.
initState 함수의 기본 구조
@override
void initState() {
super.initState(); // 부모 클래스의 초기화 메서드 호출
// 초기화 작업
}
간단한 예제: 앱 시작 시 타이머 설정
아래는 앱이 실행되면 3초 후에 텍스트를 업데이트하는 예제입니다.
import 'package:flutter/material.dart';
import 'dart:async'; // 타이머를 사용하기 위해 필요
void main() {
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
String message = "안녕하세요!";
@override
void initState() {
super.initState();
// 3초 후에 메시지 변경
Timer(Duration(seconds: 3), () {
setState(() {
message = "Flutter를 배우고 있어요!";
});
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('initState 함수 예제')),
body: Center(
child: Text(
message,
style: TextStyle(fontSize: 24),
),
),
),
);
}
}
언제 initState를 사용해야 할까?
초기 데이터 로드:
예: API 호출, 데이터베이스에서 데이터 가져오기.
애니메이션 시작:
애니메이션 컨트롤러 초기화.
리스너 추가:
텍스트 입력이나 네트워크 이벤트를 감지하는 리스너 설정.
타이머 설정:
특정 시간 후에 동작을 실행.
주의할 점
UI 관련 작업 금지:
initState에서 직접 UI를 업데이트하거나 context를 사용해 위젯 트리에 접근하면 오류가 발생할 수 있습니다.
이유: 위젯이 아직 완전히 렌더링되지 않았기 때문입니다.
반드시 super.initState() 호출:
initState를 오버라이드할 때 Flutter의 기본 초기화 작업을 위해 super.initState()를 호출해야 합니다.
dispose와 함께 사용:
리스너나 애니메이션 컨트롤러를 initState에서 추가했다면, dispose 함수에서 반드시 정리(cleanup)해야 메모리 누수를 방지할 수 있습니다.
/** dname(부서명) like 검색 + dname 내림차순 조회 */
public List<Dept> findAllByDnameContainingOrderByDnameDesc(String dname){
List<Dept> list
= deptRepository.findAllByDnameContainingOrderByDnameDesc(dname);
return list;
}
📂 DeptController.java 추가
/** 전체조회 + dname(부서명) like 검색 + dname 내림차순 조회 */
@GetMapping("/dept/dname/containing/desc/{dname}")
public ResponseEntity<Object> findAllByDnameContainingOrderByDnameDesc(
@PathVariable String dname
){
try {
// todo : 전체 조회 + 정렬(dno 내림차순)함수 호출
List<Dept> list
= deptService
.findAllByDnameContainingOrderByDnameDesc(dname);
if (list.isEmpty() == false) {
// 성공
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
// 데이터 없음
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e) {
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
✔ 서버 재시작 후 API 테스트
1) 전체조회 - 부서명 like 검색 + dname 내림차순 조회
✅ TODO 4) 쿼리메소드 응용 연습문제
// todo) 응용연습
// todo: 응용 연습문제
// todo: 연습 4) EMP 테이블에서 Job 이 manager 이고
// 매개변수로 부서번호(dno)를 받는 함수를 작성하세요.
// todo: 연습 5) Emp 테이블에서 salary 가 1000 ~ 1500 사이의 값을 같는
// 사원을 조회하려고 합니다. 함수를 작성해 주세요
List<Emp> findAllByJobAndDno(String job, int dno);
List<Emp> findAllBySalaryBetween(int first, int last);
📂 EmpService.java
// todo: 연습 4) EMP 테이블에서 Job 이 manager 이고
// 매개변수로 부서번호(dno)를 받는 함수를 작성하세요.
public List<Emp> findAllByJobAndDno(String job, int dno){
List<Emp> list = empRepository.findAllByJobAndDno(job, dno);
return list;
}
// todo: 연습 5) Emp 테이블에서 salary 가 1000 ~ 1500 사이의 값을 같는
// 사원을 조회하려고 합니다. 함수를 작성해 주세요
public List<Emp> findAllBySalaryBetween(int first, int last){
List<Emp> list = empRepository.findAllBySalaryBetween(first, last);
return list;
}
📂 EmpController.java
/** 연습 4) : 쿼리메소드 */
@GetMapping("/emp/dno/{dno}")
public ResponseEntity<Object> findAllByEnameContaining(
@PathVariable int dno
) {
try {
// 전체 조회 + 정렬(dno 내림차순) 호출
List<Emp> list
= empService.findAllByJobAndDno("MANAGER", dno);
if (list.isEmpty() == false) {
// 성공
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
// 데이터 없음
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e){
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
/** 연습5 */
@GetMapping("emp/salary/{first}/{last}")
public ResponseEntity<Object> findAllBySalaryBetween(@PathVariable int first, @PathVariable int last){
try {
List<Emp> list
= empService.findAllBySalaryBetween(first, last);
if(list.isEmpty() == false){
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e) {
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
데이터베이스에서 값을 가져올 때는 위의 코드처럼 메서드의 이름만으로 쿼리 메서드를 생성할 수 도 있습니다.
이번에는 @Query 어노테이션을 사용해 직접 JPQL을 작성해봅시다.
📂 DeptRepository.java
// ----------------------------------------
// @Query 예제
// 1) 오라클 쿼리
// 2) 객체 쿼리
// ----------------------------------------
// todo 1) ename like 검색
@Query(value = "SELECT TD.* FROM TB_EMP TD WHERE TD.ENAME LIKE '%' || :dname || '%'", nativeQuery = true)
List<Emp> selectByEname(@Param("ename") String ename);
위 코드를 다르게 작성하는 방법(JDK 버전에 따라 사용 불가능할 수도 있음)
// todo: 1-1) 위의 코드를 다르게 코딩
@Query(value = "SELECT TD.* FROM TB_DEPT TD WHERE TD.DNAME LIKE '%'||:dname||'%'",
nativeQuery = true)
List<Dept> selectByDname(String dname);
// todo: 1-2) 위의 코드를 다르게 코딩 : 참고
@Query(value = "SELECT TD.* FROM TB_DEPT TD WHERE TD.DNAME LIKE '%'|| ?1 ||'%'",
nativeQuery = true)
List<Dept> selectByDname(String dname);
✅ SQL에서 쿼리문 결과가 올바른지 확인
📂 DeptService.java
/** 전체조회 + dname like 검색 : @Query */
public List<Dept> selectByDname(String dname) {
List<Dept> list
= deptRepository.selectByDname(dname);
return list;
}
📂 DeptController.java
/** 전체조회 + dname like 검색 : @Query */
@GetMapping("/dept/dname/{dname}")
public ResponseEntity<Object> selectByDname(
@PathVariable String dname
) {
try {
// 전체 조회 + 정렬(dno 오름차순) 호출
List<Dept> list
= deptService.selectByDname(dname);
if (list.isEmpty() == false) {
// 성공
return new ResponseEntity<>(list, HttpStatus.OK);
} else {
// 데이터 없음
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
} catch (Exception e){
log.debug(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
앞 전 예제에서는 쿼리스트링 방식으로 매개변수를 전달하였습니다. 이번에는 쿼리스트링 방식을 개선한 파라미터 방식의 매개변수 전달 방법에 대해 알아보겠습니다.
📂PathVariableController.java
@Controller
@RequestMapping("/exam05")
public class PathVariableController {
// TODO : url 테스트 파라미터 방식 : http://localhost:8000/exam05/path-variable/LeeJunHyuk
// => @GetMapping("/path-variable/{웹매개변수명}")
// => 웹 브라우저 주소창 사용 : url/값
// TODO : url 테스트 쿼리스트링 방식 : http://localhost:8000/exam05/path-variable?name=LeeJunHyuk
@GetMapping("/path-variable/{name}")
public String getPathVariable(
@PathVariable String name,
Model model)
{
model.addAttribute("name", name);
return "exam05/path_variable.jsp";
}
}