
이번 단계는 “백엔드 보안”을 실제로 적용해보는 구간이다. 단순히 로그인 화면이 뜨는 걸로 끝이 아니라, Spring Data REST 때문에 자동 생성되는 엔드포인트까지 포함해서 “어디가 열리고 어디가 막히는지”를 개발자가 통제해야 한다. 특히 /api/appUsers처럼 사용자/권한/비밀번호 해시가 노출될 수 있는 리소스는 실수로라도 외부 API로 공개되면 안 된다.
username=user, password는 콘솔에 매번 랜덤 출력)/css, /images 같은 정적 리소스는 보통 보안 필터에서 제외/login으로 리다이렉트됨문제는 “서버를 껐다 켤 때마다 패스워드가 바뀐다”는 점이다. 학습 단계에서는 불편하고, 실제 서비스에서는 “사용자 계정이 DB에 있어야” 하므로 Config와 DB 기반 인증으로 넘어가야 한다.
이 설정은 “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();
}
}

인메모리는 서버를 재시작하면 초기화된다. 그래서 사용자 정보를 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);
}
예전 방식이면 “없으면 null 반환”을 매번 직접 처리해야 했다. 하지만 Repository는 인터페이스라서 공통 로직을 강제하기 어렵고, 조회 결과가 없을 수 있는 상황을 타입으로 표현하는 게 Optional이다.
Optional.empty(): 결과 없음optional.isPresent(): 값이 존재하는지 확인optional.get(): 값 꺼내기 (존재할 때만) 이 부분은 “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();
}
}
이제 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();
}
}
bcrypt 해시는 문자열 안에 정보가 같이 들어있다. 예를 들어 $2a$12$... 형태면 다음을 의미한다.
$2a: 알고리즘 버전$12: cost(강도). 숫자가 커질수록 해싱이 느려지고 보안성이 올라감중요한 건 이거다.
PasswordEncoder.matches(raw, encoded)로 수행됨즉 “로그인할 때마다 salt를 새로 만들어 저장한다”가 아니라, 이미 저장된 해시 문자열 속 salt/cost를 이용해서 입력값을 동일 규칙으로 계산하고 비교하는 구조다.
아래처럼 /api/appUsers로 접근했을 때 password 해시까지 포함된 응답이 내려오는 상황이 생긴다. 평문이 아니더라도, 보안 설계상 password 컬럼이 API 응답에 포함되는 건 금지에 가깝다.
원인은 단순하다. Spring Data REST가 Repository 기반 CRUD 엔드포인트를 자동으로 노출했기 때문이다. 그래서 해결도 “Repository 노출 차단”이 가장 깔끔하다.
이건 “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 자체가 외부에 노출되지 않는다. 즉 “로그인만 하면 누구든 사용자 목록을 볼 수 있다” 같은 문제가 근본적으로 차단된다.
UserDetailsServiceImpl은 AppUser를 UserDetails로 변환하는 인증 어댑터다