개발 공부/프로젝트

[사이드 프로젝트] 가상 주식 거래소 만들기 (15) - 내 자산은 안전한가요? (Spring Security 흐름 잡기)

baby-t 2026. 1. 13. 15:20

1. 들어가며: 조회에서 '관리'로 넘어가는 시점

지난 포스팅까지는 단순히 DB에 있는 데이터를 조회해서 화면에 뿌려주는 것에 집중했습니다. 하지만 '개인화된 자산'을 다루는 주식 거래소 프로젝트에서 가장 중요한 것은 **"누가 요청했는가?"**를 식별하는 것입니다.

단순히 문자열 비교(if pw == dbPw)로 로그인을 구현하는 것은 보안상 위험할 뿐더러, 세션 관리 등 고려해야 할 게 너무 많습니다. 그래서 이번에는 Java 진영의 표준 보안 프레임워크인 Spring Security를 도입하여, **인증(Authentication)**과 인가(Authorization) 체계를 잡아보았습니다.


2. 준비 단계: 의존성 및 DTO

가장 먼저 문지기(Security)를 고용해야 합니다. build.gradle에 의존성을 추가합니다. 이번에는 단순 보안뿐만 아니라 입력값 검증과 화면 처리를 위한 도구들도 함께 챙겼습니다.

1. 의존성 추가 (build.gradle)

Groovy
dependencies {
    // 1. 스프링 시큐리티 (보안 핵심)
    implementation 'org.springframework.boot:spring-boot-starter-security'
    
    // 2. Validation (회원가입 입력값 검증용)
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    
    // 3. Thymeleaf Extras (뷰에서 로그인 여부/권한 쉽게 확인용)
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
}
  • Validation: 이메일 형식이 맞는지, 비밀번호가 비어있지 않은지 검사하기 위해 추가했습니다.
  • Thymeleaf Extras: 나중에 HTML 화면에서 <sec:authorize> 태그를 사용하여 "로그인한 사람만 보이는 버튼" 등을 쉽게 만들기 위해 미리 추가했습니다.
 

2. 요청을 담을 그릇 (DTO) 회원가입 시 프론트엔드에서 넘어오는 데이터를 받을 DTO를 생성합니다. 엔티티를 직접 노출하지 않기 위함입니다.

Java
@Getter @Setter
@NoArgsConstructor
public class SignupRequestDto {
    private String email;
    private String name;
    private String password;
}

3. 문지기 설정 (SecurityConfig)

스프링 시큐리티를 도입하면 기본적으로 모든 요청이 막힙니다. 따라서 "어떤 사람은 들어올 수 있고, 어떤 사람은 검사를 받아야 하는지" 규칙을 정해줘야 합니다.

이 설정 파일은 보안의 최전선입니다.

Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 1. CSRF 비활성화: 개발 초기 단계라 403 에러 방지를 위해 잠시 꺼둡니다.
            .csrf(AbstractHttpConfigurer::disable)

            // 2. 구역별 출입 통제 (인가)
            .authorizeHttpRequests(auth -> auth
                // 정적 리소스(CSS, JS)는 로그인 안 해도 보여야 함 (안 그러면 화면 깨짐)
                .requestMatchers("/css/**", "/js/**", "/images/**", "/favicon.ico").permitAll()
                
                // 메인화면, 로그인, 회원가입 API는 누구나 접근 가능
                .requestMatchers("/", "/login", "/order_list.html", "/api/signup").permitAll()
                
                // 그 외 모든 요청은 '인증된(로그인한)' 사용자만 접근 가능
                .anyRequest().authenticated()
            )

            // 3. 로그인 폼 설정
            .formLogin(form -> form
                .loginPage("/login")        // 우리가 만든 커스텀 로그인 페이지 사용
                .usernameParameter("email") // 중요! (기본값 username -> email로 변경)
                .defaultSuccessUrl("/")     // 로그인 성공 시 메인으로 이동
                .permitAll()
            );
            
            // 4. 로그아웃 설정 등... (생략)

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 비밀번호를 안전하게 암호화해주는 기계(BCrypt) 등록
        return new BCryptPasswordEncoder();
    }
}
  • 포인트: requestMatchers 순서가 중요합니다. 넓은 범위보다는 구체적인 경로를 먼저 적어주는 것이 좋습니다.

🛡️ CSRF(Cross-Site Request Forgery)란?

**CSRF(사이트 간 요청 위조)**는 사용자가 자신의 의지와는 무관하게, 공격자가 의도한 행위(데이터 수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 만드는 공격 방식입니다.

1. 공격의 원리

사용자가 A라는 사이트에 로그인하여 브라우저에 인증 쿠키가 남아있는 상태라고 가정해 보겠습니다. 이때 사용자가 공격자가 만든 악성 사이트에 접속하면, 그 사이트에 숨겨진 코드가 브라우저를 통해 A 사이트로 "비밀번호 변경"이나 "게시글 작성" 같은 요청을 보냅니다. 브라우저는 A 사이트의 쿠키를 자동으로 포함해서 보내기 때문에, A 사이트의 서버는 이 요청을 **"정상적인 사용자의 요청"**으로 착각하고 실행하게 됩니다.

2. 스프링 시큐리티의 CSRF 보호

스프링 시큐리티는 기본적으로 이 공격을 막기 위해 모든 상태 변경 요청(POST, PUT, DELETE 등)에 CSRF 토큰을 요구합니다. 서버가 발행한 무작위 토큰이 요청에 포함되어야만 정상적인 요청으로 간주하는 방식입니다.

3. 왜 개발 초기나 REST API에서 비활성화하나요?

코드 설정에서 .csrf(AbstractHttpConfigurer::disable)를 통해 이 기능을 끈 이유는 다음과 같습니다.

  • REST API의 특성: 보통 백엔드 API 서버는 세션(Session)과 쿠키 대신 JWT(JSON Web Token) 같은 무상태(Stateless) 인증 방식을 주로 사용합니다. 쿠키를 사용하지 않는다면 CSRF 공격의 위험도 자연스럽게 낮아집니다.
  • 개발 편의성: CSRF 기능이 켜져 있으면 Postman 같은 도구로 API를 테스트할 때마다 매번 토큰을 함께 보내야 하므로 번거롭습니다. 따라서 개발 단계나 순수 API 서버에서는 기능을 끄는 경우가 많습니다.

💡 요약하자면: > CSRF는 사용자의 쿠키를 도용하여 원치 않는 요청을 보내는 공격이며, 이를 막기 위해 스프링 시큐리티는 토큰 검증을 수행합니다. 다만, 쿠키를 쓰지 않는 REST API 환경에서는 보통 이 설정을 끄고 개발합니다.


4. 연결고리: PrincipalDetailsService (핵심 흐름)

이 부분이 시큐리티를 처음 접할 때 가장 헷갈리는 부분입니다. "내 DB에는 User 엔티티가 있는데, 시큐리티는 자꾸 UserDetails를 내놓으라고 합니다."

스프링 시큐리티는 우리가 만든 User 객체를 모릅니다. 그래서 중간에서 **[DB의 User]**를 **[시큐리티용 UserDetails]**로 변환해주는 '통역사'가 필요합니다. 그게 바로 UserDetailsService입니다.

[로그인 흐름도]

  1. 사용자 로그인 시도 (Email 입력)
  2. 시큐리티가 loadUserByUsername(email) 자동 호출
  3. 개발자가 작성한 로직 실행 (DB 조회)
  4. 조회된 정보를 UserDetails 포맷으로 포장해서 시큐리티에게 전달
Java
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        
        // 1. DB에서 내 방식대로 회원을 찾습니다.
        User user = userRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("회원 없음: " + username));

        // 2. 찾은 회원을 시큐리티가 이해할 수 있는 형태(UserDetails)로 변환합니다.
        // (여기서는 시큐리티가 제공하는 User 빌더를 사용했습니다)
        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getEmail())       // 아이디
                .password(user.getPassword())    // 암호화된 비번
                .roles(user.getRole().name())    // 권한
                .build();
    }
}
 

5. Controller: 외부와 소통하는 문 (API 구현)

비즈니스 로직(Service)이 완성되었으니, 이제 외부(프론트엔드/클라이언트)에서 신호를 보낼 수 있는 진입점(Controller)을 만들어야 합니다.

우선 화면을 만들기 전에, 로직이 정상 동작하는지 확인하기 위해 간단한 API 컨트롤러를 작성했습니다.

Java
 
@RestController // 데이터만 반환하는 컨트롤러 (화면 X)
@RequiredArgsConstructor
@RequestMapping("/api")
public class MemberApiController {

    private final UserService userService;

    @PostMapping("/signup")
    public ResponseEntity<String> signup(@RequestBody @Valid SignupRequestDto request) {
        // DTO로 받은 데이터를 Service로 전달
        userService.join(request.getEmail(), request.getName(), request.getPassword());
        
        return ResponseEntity.ok("회원가입 성공");
    }
}
  • @RequestBody: JSON 형태로 넘어오는 데이터를 자바 객체(DTO)로 변환해 줍니다.
  • @Valid: DTO에 설정된 제약조건(빈 값 체크 등)을 검사합니다. 

✅ @Valid: 자동 데이터 검증기

@Valid는 외부에서 들어오는 데이터(주로 DTO)가 우리가 정한 규칙에 맞는지 입구에서 자동으로 검사해 주는 도구입니다.

1. 왜 필요한가?

사용자가 회원가입을 할 때 이메일을 빈칸으로 보내거나, 비밀번호를 너무 짧게 설정할 수 있습니다. 이때 서버에서 일일이 if (email == null) ... 같은 코드를 작성하면 코드가 매우 길어지고 지저분해집니다. @Valid는 이 과정을 자동화해 줍니다.

2. 어떻게 작동하는가?

두 단계만 기억하면 됩니다.

  1. DTO에 규칙 적기: 필드 위에 @NotBlank(비어있으면 안 됨), @Size(길이 제한), @Email(이메일 형식) 등의 어노테이션을 붙입니다.
  2. 컨트롤러에서 실행하기: 컨트롤러 메서드의 파라미터(DTO) 앞에 @Valid를 붙여줍니다.

3. 결과는?

만약 사용자가 규칙에 어긋나는 데이터를 보내면, 메서드가 실행되기도 전에 400 Bad Request(잘못된 요청) 에러를 발생시키며 요청을 차단합니다. 덕분에 서비스 로직 안에서는 "데이터가 깨끗하다"고 믿고 개발에만 집중할 수 있습니다.


6. 중간 점검: 눈으로 확인하기 (Postman 테스트)

아직 예쁜 회원가입 화면(HTML)은 없지만, 백엔드 로직은 완성되었습니다. Postman을 이용해 실제로 데이터가 잘 들어가는지 테스트해 보았습니다.

1. 요청 보내기

  • URL: POST http://localhost:8080/api/signup
  • Body (JSON):
    JSON
    {
        "email": "signupTest@test.com",
        "password": "1234",
        "name": "hello"
    }
    

2. 결과 확인 & 로그인

Send 버튼을 누르자 201 Created와 함께 "회원가입 성공" 메시지가 반환되었습니다.

 

3. DB 확인 (가장 중요!)

가장 중요한 비밀번호 암호화가 잘 되었는지 DB를 조회해 봅니다.

User

 

보시는 것처럼 제가 입력한 1234는 온데간데없고, 알 수 없는 해시값($2a$10...)으로 안전하게 변환되어 저장된 것을 확인할 수 있습니다. 또한 account 테이블을 조회해보니, 해당 유저의 ID로 계좌도 동시에 생성되어 있었습니다. 트랜잭션 처리가 완벽하게 동작했네요!

Account


 

7. 기술적 회고 (Deep Dive) 💡

이번 구현을 하면서 미래의 제가 다시 볼 때 헷갈리지 않도록 중요 포인트를 정리합니다.

Q1. 왜 SecurityConfig에서 usernameParameter("email")을 설정했나? 스프링 시큐리티는 기본적으로 로그인 폼에서 넘어오는 파라미터 이름을 username으로 기대합니다. 하지만 우리 프로젝트 기획상 **"이메일을 아이디로 사용"**하기 때문에, 프론트엔드 input 태그의 name 속성도 email입니다. 이를 시큐리티에게 알려주지 않으면 "username이 없는데?" 하고 로그인을 거부합니다. 따라서 설정 파일에서 명시적으로 매핑해 준 것입니다.

Q2. org...User vs com...User 코드를 짜다 보니 User 클래스가 두 개라 매우 헷갈렸습니다.

  • com.example.entity.User: 내가 설계한 DB 엔티티
  • org.springframework.security.core.userdetails.User: 시큐리티 내부 인증용 객체 PrincipalDetailsService는 결국 전자(Entity)를 후자(UserDetails)로 바꿔주는 어댑터 역할을 한다는 것을 기억해야 합니다.

8. 마치며

이제 백엔드의 든든한 문지기(Security)와 회원가입 로직이 완성되었습니다. Postman으로 테스트해 본 결과, 비밀번호가 안전하게 암호화되어 저장되고 계좌도 동시에 생성되는 것을 확인했습니다.

다음 포스팅에서는 이 백엔드 로직에 타임리프(Thymeleaf) 화면을 입혀서, 사용자가 실제로 가입하고 로그인하는 과정을 완성해 보겠습니다.