Spring Boot 백엔드 보호: Spring Security + DB 사용자 + Spring Data REST 노출 차단

최병현·2026년 2월 24일

spring boot

목록 보기
6/34

이번 단계는 “백엔드 보안”을 실제로 적용해보는 구간이다. 단순히 로그인 화면이 뜨는 걸로 끝이 아니라, Spring Data REST 때문에 자동 생성되는 엔드포인트까지 포함해서 “어디가 열리고 어디가 막히는지”를 개발자가 통제해야 한다. 특히 /api/appUsers처럼 사용자/권한/비밀번호 해시가 노출될 수 있는 리소스는 실수로라도 외부 API로 공개되면 안 된다.


1. Spring Security를 추가했을 때 기본으로 생기는 일

  • 인메모리 사용자 1명이 자동 생성됨 (username=user, password는 콘솔에 매번 랜덤 출력)
  • /css, /images 같은 정적 리소스는 보통 보안 필터에서 제외
  • 그 외 대부분의 엔드포인트는 인증 요구
  • 기본 로그인 페이지가 자동 생성되어 /login으로 리다이렉트됨
  • HSTS, XSS, CSRF 등 기본 보안 기능들이 기본 활성화

문제는 “서버를 껐다 켤 때마다 패스워드가 바뀐다”는 점이다. 학습 단계에서는 불편하고, 실제 서비스에서는 “사용자 계정이 DB에 있어야” 하므로 Config와 DB 기반 인증으로 넘어가야 한다.


2. 인메모리 로그인 고정 (테스트 전용)

이 설정은 “Backend Security Configuration Layer”에 해당한다. 인메모리에 고정 계정을 하나 만들어서 계속 같은 계정으로 로그인할 수 있게 한다. (실서비스에서는 거의 사용하지 않고 테스트용으로만 적합하다.)

package com.korit12.cardatabase.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user = User.builder()
                .username("user")
                .password(passwordEncoder().encode("password"))
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}


3. DB 사용자로 인증하기 위한 준비: AppUser + Repository

인메모리는 서버를 재시작하면 초기화된다. 그래서 사용자 정보를 DB에 저장해야 한다. 이 부분은 “Backend Domain Layer + Persistence Layer(JPA)” 작업이다.

그리고 username은 유니크여야 하므로, 결과가 0개 또는 1개인 조회가 된다. 그래서 Optional을 쓰는 게 자연스럽다.

package com.korit12.cardatabase.domain;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface AppUserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByUsername(String username);
}

4. Optional을 쓰는 이유 (NullPointerException 방지)

예전 방식이면 “없으면 null 반환”을 매번 직접 처리해야 했다. 하지만 Repository는 인터페이스라서 공통 로직을 강제하기 어렵고, 조회 결과가 없을 수 있는 상황을 타입으로 표현하는 게 Optional이다.

  • Optional.empty(): 결과 없음
  • optional.isPresent(): 값이 존재하는지 확인
  • optional.get(): 값 꺼내기 (존재할 때만)

5. UserDetailsServiceImpl: AppUser를 UserDetails로 변환하는 “어댑터”

이 부분은 “Backend Authentication Layer”다. Spring Security는 내부적으로 UserDetails 타입을 기준으로 인증을 처리한다. 하지만 우리가 만든 엔티티는 AppUser다.

그래서 “DB에서 AppUser 조회 → Spring Security가 쓰는 UserDetails로 변환”하는 계층이 필요하고, 그 역할이 UserDetailsService 구현체다.

package com.korit12.cardatabase.service;

import com.korit12.cardatabase.domain.AppUser;
import com.korit12.cardatabase.domain.AppUserRepository;
import lombok.AllArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.User.UserBuilder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private AppUserRepository appUserRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<AppUser> user = appUserRepository.findByUsername(username);

        UserBuilder builder;
        if (user.isPresent()) {
            AppUser currentUser = user.get();
            builder = User.withUsername(username)
                    .password(currentUser.getPassword())
                    .roles(currentUser.getRole());
        } else {
            throw new UsernameNotFoundException("해당 username을 가진 사용자를 찾지 못했습니다.");
        }

        return builder.build();
    }
}

6. SecurityConfig를 DB 사용자 기반으로 연결

이제 Spring Security가 “인메모리 사용자”가 아니라 “DB의 AppUser”를 기준으로 인증하게 만들어야 한다. 이건 “Backend Security Configuration Layer” 작업이다.

구버전에서 AuthenticationManagerBuilder를 직접 만지는 방식도 있지만, 학습 목적상 지금 코드를 유지한다면 아래처럼 “UserDetailsService를 인증 소스로 연결”하는 흐름이 핵심이다.

package com.korit12.cardatabase.config;

import com.korit12.cardatabase.service.UserDetailsServiceImpl;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.context.annotation.Bean;

@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig {

    private UserDetailsServiceImpl userDetailsService;

    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

7. bcrypt가 “로그인할 때” 어떻게 맞는지

bcrypt 해시는 문자열 안에 정보가 같이 들어있다. 예를 들어 $2a$12$... 형태면 다음을 의미한다.

  • $2a: 알고리즘 버전
  • $12: cost(강도). 숫자가 커질수록 해싱이 느려지고 보안성이 올라감
  • salt + hash 결과가 한 문자열로 저장됨

중요한 건 이거다.

  • DB에는 “평문”이 아니라 “bcrypt 결과 문자열”이 저장됨
  • 로그인 시에는 사용자가 입력한 평문을 bcrypt로 다시 계산해서 DB의 문자열과 비교
  • 이 비교는 Spring Security 내부에서 PasswordEncoder.matches(raw, encoded)로 수행됨

즉 “로그인할 때마다 salt를 새로 만들어 저장한다”가 아니라, 이미 저장된 해시 문자열 속 salt/cost를 이용해서 입력값을 동일 규칙으로 계산하고 비교하는 구조다.


8. 진짜 문제: Spring Data REST가 AppUser API를 자동 생성해버림

아래처럼 /api/appUsers로 접근했을 때 password 해시까지 포함된 응답이 내려오는 상황이 생긴다. 평문이 아니더라도, 보안 설계상 password 컬럼이 API 응답에 포함되는 건 금지에 가깝다.

원인은 단순하다. Spring Data REST가 Repository 기반 CRUD 엔드포인트를 자동으로 노출했기 때문이다. 그래서 해결도 “Repository 노출 차단”이 가장 깔끔하다.


9. AppUserRepository를 외부 API로 노출하지 않기

이건 “Backend Data Layer 보호”다. 사용자 테이블은 인증 로직 내부에서만 쓰고, 외부 REST 리소스로 공개하지 않는다.

package com.korit12.cardatabase.domain;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

import java.util.Optional;

@RepositoryRestResource(exported = false)
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
    Optional<AppUser> findByUsername(String username);
}

이렇게 하면 /api/appUsers 자체가 외부에 노출되지 않는다. 즉 “로그인만 하면 누구든 사용자 목록을 볼 수 있다” 같은 문제가 근본적으로 차단된다.


10. 핵심 정리

  • Spring Security 추가만으로도 기본 로그인/인증 요구가 자동 적용된다
  • 인메모리 계정은 테스트용이고, 실서비스는 DB 기반(AppUser)로 가야 한다
  • UserDetailsServiceImpl은 AppUser를 UserDetails로 변환하는 인증 어댑터다
  • bcrypt는 저장된 해시 문자열 내부 정보(salt/cost)를 이용해 입력값과 비교한다
  • Spring Data REST 자동 엔드포인트는 편하지만, AppUser 같은 민감 리소스는 반드시 노출 차단해야 한다
profile
Develop

0개의 댓글