Spring Security와 배포 · 선택 확장

역할별 CRUD와 CSRF를 따로 검증하고 실습 계정과 운영 인증·배포의 경계 이해하기

선택 확장: 역할에 따라 API 보호하기

Spring 10주 프로젝트 뒤에 진행합니다. 이번 실습의 정책은 명단 조회는 USER·ADMIN, 생성·수정·삭제는 ADMIN입니다. 인증은 사용자 신원, 인가는 허용 행동을 결정합니다. 단순히 로그인했다는 이유로 관리 기능을 허용하지 않습니다.

아래는 가상 계정을 쓰는 로컬 HTTP Basic 학습 예제입니다. 실제 서비스의 회원 가입·비밀번호 재설정·토큰 갱신까지 구현된 예제가 아닙니다. 공개 운영 인증은 제품과 배포 환경에 맞춰 별도 구성합니다.

의존성과 실습 계정 설정

build.gradle의 dependencies에 추가합니다.

Groovy
implementation 'org.springframework.boot:spring-boot-starter-security'

src/main/resources/application.properties에 추가합니다. 실제 비밀번호는 이 파일에 적지 않습니다.

JavaScript
app.practice.user-password=${PRACTICE_USER_PASSWORD}app.practice.admin-password=${PRACTICE_ADMIN_PASSWORD}

실행할 터미널이나 IDE의 환경변수 설정에 서로 다른 연습용 비밀번호를 지정합니다. 운영 계정 비밀번호를 재사용하지 않습니다. 환경변수가 없으면 시작이 실패하는 편이 하드코딩된 공통 비밀번호로 열리는 것보다 의도를 확인하기 쉽습니다.

SecurityFilterChain 구성

src/main/java/com/example/lion/SecurityConfig.java:

Java
package com.example.lion;
import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.http.HttpMethod;import org.springframework.security.config.Customizer;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.provisioning.InMemoryUserDetailsManager;import org.springframework.security.web.SecurityFilterChain;
@Configurationpublic class SecurityConfig {    @Bean    PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
    @Bean    UserDetailsService users(PasswordEncoder encoder,            @Value("${app.practice.user-password}") String userPassword,            @Value("${app.practice.admin-password}") String adminPassword) {        return new InMemoryUserDetailsManager(            User.withUsername("student").password(encoder.encode(userPassword)).roles("USER").build(),            User.withUsername("mentor").password(encoder.encode(adminPassword)).roles("ADMIN").build()        );    }
    @Bean    SecurityFilterChain security(HttpSecurity http) throws Exception {        return http            .cors(Customizer.withDefaults())            .authorizeHttpRequests(auth -> auth                .requestMatchers("/api/csrf").authenticated()                .requestMatchers(HttpMethod.GET, "/api/members", "/api/members/**").hasAnyRole("USER", "ADMIN")                .requestMatchers(HttpMethod.POST, "/api/members").hasRole("ADMIN")                .requestMatchers(HttpMethod.PUT, "/api/members/**").hasRole("ADMIN")                .requestMatchers(HttpMethod.DELETE, "/api/members/**").hasRole("ADMIN")                .anyRequest().denyAll()            )            .httpBasic(Customizer.withDefaults())            .build();    }}

구체적인 메서드·경로를 먼저 지정하고 나머지는 거절합니다. 기존 LocalCorsConfig는 local profile에서 사용되며 Security의 cors 설정과 연결됩니다. 새 endpoint를 추가할 때 권한 규칙도 함께 검토합니다.

기본 CSRF 보호를 끄지 않았습니다. Basic 인증도 브라우저가 credential을 자동 재전송할 수 있으므로 “쿠키를 안 쓰니 CSRF를 무조건 꺼도 된다”고 판단하지 않습니다.

CSRF 토큰으로 쓰기 요청 확인하기

같은 Java 폴더에 CsrfController.java를 만듭니다.

Java
package com.example.lion;
import java.util.Map;import org.springframework.security.web.csrf.CsrfToken;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;
@RestControllerpublic class CsrfController {    @GetMapping("/api/csrf")    public Map<String, String> token(CsrfToken token) {        return Map.of("headerName", token.getHeaderName(), "token", token.getToken());    }}

환경변수를 설정한 뒤 local profile로 서버를 실행합니다. curl에서 -u mentor만 주면 비밀번호를 대화형으로 입력할 수 있어 명령에 평문을 적는 일을 줄입니다. Windows는 curl.exe를 사용합니다.

Shell
curl -i http://localhost:8080/api/memberscurl -i -u student http://localhost:8080/api/memberscurl -u mentor -c cookies.txt http://localhost:8080/api/csrf

첫 요청은 401과 Basic challenge, 두 번째는 200이어야 합니다. 세 번째 응답의 headerName과 token을 사용하고 같은 쿠키 파일을 보내 POST합니다. {headerName}·{token}은 실제 응답 값으로 바꾸고 임시 토큰·쿠키 파일은 커밋하지 않습니다.

Shell
curl -i -u mentor -b cookies.txt -X POST http://localhost:8080/api/members -H "Content-Type: application/json" -H "{headerName}: {token}" --data-binary @member.json

PowerShell의 파일 인자는 --data-binary '@member.json'로 감쌉니다. 유효한 입력이면 201, CSRF 헤더를 빼면 403이어야 합니다. student도 자기 인증·쿠키로 토큰을 받은 뒤 쓰기를 시도하여 유효한 CSRF를 보냈는데 역할 때문에 403인지 구분합니다. CSRF가 먼저 거절한 결과만으로 역할 검증을 통과했다고 보지 않습니다.

10주의 프론트 예제는 인증 없는 GET이었으므로 이제 401이 되는 것이 의도된 변화입니다. 인증 정보를 소스나 localStorage에 하드코딩해 맞추지 않습니다. 실제 브라우저 앱은 세션 로그인 또는 적합한 인증 공급자와 연결하는 별도 확장으로 진행합니다.

역할 권한과 객체 소유권은 다르다

위 코드는 관리자 명단 관리 정책입니다. “자기 신청만 읽기” 같은 규칙은 아직 구현하지 않았습니다. 이를 추가할 때는 로그인 신원과 DB의 소유 관계를 Service 또는 조회 범위에서 검사하고 요청 body의 userId를 신뢰하지 않습니다.

@PreAuthorize를 선택하면 @EnableMethodSecurity 등록과 실제 권한 검사 Bean이 필요합니다. 이름만 있는 applicationAccess 예제를 복사해 존재하지 않는 Bean을 호출하지 않습니다. 동일 Service의 내부 호출·새 action·관리자 예외까지 권한 경계를 검토합니다.

검증과 배포 전 기준

요청 조건기대 결과
익명 GET 명단401
USER·ADMIN GET 명단200
USER + 유효한 CSRF로 쓰기403, 데이터 변화 없음
ADMIN + 유효한 CSRF로 쓰기정상 CRUD
ADMIN + CSRF 없는 쓰기403
허용하지 않은 경로거절

권장 120분: 정책 20분, 인증·권한 35분, CSRF와 역할 검사 40분, 운영 검토 25분입니다. 테스트 자동화에는 spring-security-test를 사용해 인증 사용자와 CSRF 유무를 명시할 수 있습니다. 현재 수동 확인과 실제 자동화된 검사 범위를 구별해 기록합니다.

공개 배포에는 HTTPS, 비밀값 관리, 운영 사용자 저장소, 로그인·로그아웃·만료·비밀번호 복구, DB migration·백업, 프록시 신뢰, health endpoint 공개 범위, 로그 민감정보를 확인합니다. H2 create-drop·연습용 메모리 계정 설정을 운영에 그대로 사용하지 않습니다. JWT를 선택한다면 issuer·audience·만료·키 회전·폐기와 권한 변경 반영까지 설계합니다.

완료 기준은 역할 거절과 CSRF 거절을 별도로 재현하고 운영 전에 남은 인증 기능을 정확히 설명하는 것입니다.

더 읽어보기