Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
af5813f
docs: Spring Security 의존성을 build.gradle에 명시
Joonseok-Lee May 17, 2026
73f1ff8
Feat: PW 암호화 빈, CSRF 설정 비활성화, Swagger 관련 URL과 인증-인가 요청 API에 대해 인증 불필요…
Joonseok-Lee May 17, 2026
3a7cb85
Feat: User 엔터티에 누락되어 있었던 비밀번호 필드 추가
Joonseok-Lee May 17, 2026
35efc5b
Feat: UserRepository에 unique 필드인 Email 필드 값으로 findBy하는 메소드, 반환타입을 Opt…
Joonseok-Lee May 17, 2026
b9488a1
Feat: Security Context Holder에 저장하기 위한 AuthUser 객체를 UserDetails의 자식으로…
Joonseok-Lee May 17, 2026
6ff9a1a
Feat: Security Context Holder에 저장하기 위해 CustomUserDetailsService 객체를 U…
Joonseok-Lee May 17, 2026
3ff9b52
Feat: 권한이 부여되지 않아 인가 단계에서 Forbidden 응답을 JSON 템플릿으로 전달하기 위해 AccessDeni…
Joonseok-Lee May 17, 2026
0b5c5a8
Feat: permitAll로 설정하지 않은 API 요청에 대해 인증 토큰 없이 접근했을 시, 던질 Forbidden 예외 …
Joonseok-Lee May 17, 2026
d75b57b
Feat: Forbidden, Unauthorized 응답 래퍼에 @Component 어노테이션을 부착하여 Bean으로 등록…
Joonseok-Lee May 17, 2026
bdb023f
Feat: Spring boot 4.x.x에서 제거된 ObjectMapper를 Bean으로 등록, Forbidden, Una…
Joonseok-Lee May 17, 2026
0a95316
docs: 챕터 8 Spring Security 핵심 키워드 작성
Joonseok-Lee May 17, 2026
88e78d7
fix: /api 패스를 permitAll()하지 않고 있던 문제 해결
Joonseok-Lee May 19, 2026
1147569
fix: sign-in api에 auth prefix를 추가
Joonseok-Lee May 19, 2026
4421d20
fix: CustomUserDetailsService를 빈으로 등록하게끔 Component 어노테이션 추가
Joonseok-Lee May 19, 2026
d51b5d2
fix: 로그인 성공 시 swagger 기본 페이지를 리다이렉트하게끔 설정, /api를 prefix 설정
Joonseok-Lee May 19, 2026
e464abe
docs: 8주차 학습 정리를 7주차라고 명명한 것 수정
Joonseok-Lee May 19, 2026
c889c52
feat: 회원가입 요청 DTO 작성
Joonseok-Lee May 19, 2026
ab8b900
fix: sign-in 성공 코드를 sign-up 성공 코드로 수정
Joonseok-Lee May 19, 2026
14d56e1
feat: 회원가입 메소드에 유저 빌드해서 저장하는 로직 추가
Joonseok-Lee May 19, 2026
2338fab
feat: 회원가입 요청에 실제 회원가입 비즈니스 로직을 호출하도록 변경
Joonseok-Lee May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Joonseok/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
58 changes: 58 additions & 0 deletions Joonseok/keyword_summary/ch08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
- Spring Security가 무엇인가?

<aside>
🌱

### Authentication - Authorization,
일반적인 공격으로 보호하는 라이브러리!

</aside>

MVC 구조를 사용하여 동기적으로 통신하는 서버와 Webflux를 사용하여 비동기적 반응형 서버 모두 적용 가능하고, 서버를 안전하게 보호하는데에 편리한 기능을 제공합니다.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring Security와 Java 버전의 관계는 사용 중인 Spring Boot/Security 버전에 따라 달라집니다. 예를 들어 Spring Security 6 계열은 Java 17 기준으로 이해할 수 있지만, 모든 Spring Security가 Java 17 이상에서만 동작한다고 일반화하면 개념이 부정확해질 수 있습니다. 또한 보안 설정 파일이 필요 없다는 표현은 현재 SecurityConfig를 작성한 구조와도 충돌하므로, 자동 설정과 명시적 SecurityFilterChain 설정의 차이를 함께 정리하는 것을 권장합니다.


Java 17 이상의 JRE에서만 사용 가능하며, 별도의 Spring Security 관련 설정 파일을 두거나, 서버 클래스 로더에 Spring Security를 포함할 필요가 없습니다.

- 인증(Authentication)vs 인가(Authorization)

<aside>
⚠️

### Auth… 다 똑같은 말 아니에요?

</aside>

### Authentication

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인증은 사용자가 누구인지 확인하는 절차이고, 인가는 인증된 사용자가 특정 리소스나 기능에 접근할 권한을 가지는지 판단하는 절차입니다. 현재 설명은 인증을 ‘등록 기록’ 중심으로, 인가를 ‘발급된 리소스 접근 권한 검증’ 중심으로 표현하여 두 개념의 핵심 경계가 흐려질 수 있습니다. Authentication 객체, Principal, GrantedAuthority가 각각 어떤 역할을 맡는지 함께 정리하면 Spring Security 흐름을 이해하기 좋아집니다.

인증이란 의미로, 해당 사용자가 서버에 등록되어 서버의 리소스에 접근할 수 있게 기록해두는 행위입니다.

### Authorization

인가라는 의미로, 해당 사용자가 서버에서 발급한 리소스 접근 권한이 유효한지 검증하는 행위입니다.

### 가장 일반적인 경우…
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UsernamePasswordAuthenticationFilter는 주로 formLogin 기반 인증 흐름에서 동작하는 필터입니다. ID와 Password를 사용하더라도 JSON 로그인, JWT 발급, 커스텀 필터를 사용하는 구조에서는 다른 필터나 AuthenticationProvider 설계가 필요할 수 있습니다. 필터 체인에서 인증 요청이 AuthenticationManager와 AuthenticationProvider로 위임되는 흐름까지 함께 조사하는 것을 권장합니다.


현대 서비스의 경우, 철저히 관리되는 대규모 SNS 서비스의 보안을 신뢰하며, 소셜 로그인 기능을 주력으로 밀고 나가는 경우도 많지만, 보편적이며 레거시에서는 ID와 Password를 기반으로 인증을 구현합니다.

이 경우에는, UsernamePasswordAuthenticationFilter라는 필터 체인 단계에서 체크하게 됩니다.

- Stateful vs Stateless

<aside>
<img src="/icons/database_gray.svg" alt="/icons/database_gray.svg" width="40px" />

### Session과 Token

</aside>

### stateful

서버가 `stateful` 하다는 의미는, 서버에서 사용자를 기억한다는 의미입니다. Session 기반으로 재인증하는 서버가 이와 같이 작동하며, 온라인 뱅킹과 같이 이전 트랜잭션에 영향을 받을 수 있는 서비스의 경우 사용합니다.

상태 저장 트랜잭션이 중단되더라도 컨텍스트와 기록이 중단된 부분부터 복구할 수 있습니다.

### stateless

`stateless`는 서버가 사용자를 기억하지 못한다는 것입니다. stateful은 전체 서비스 흐름에서 매 API 요청을 일종의 트랜잭션처럼 사용하여 사용자가 현재 어디까지 요청하였는지 확인할 수 있지만, stateless는 모든 API 요청에 대해 이전에 진행된 요청을 저장하지 않습니다.

- RESTful
- Micro Service Archtecture
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public class User extends BaseEntity {
@Column(nullable = false)
private String email;

@Column(nullable = false)
private String password;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

비밀번호 필드를 엔터티에 추가한 점은 인증 구현에 필요하지만, 저장 시점에는 반드시 PasswordEncoder를 거친 해시 값만 들어가도록 서비스 책임을 분리해야 합니다. 엔터티는 상태를 보관하고, 가입/비밀번호 변경 유스케이스에서 암호화 정책을 적용하는 구조가 계층 책임 분리에 더 적절합니다.


@Column(nullable = false)
private String phoneNum;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
@AllArgsConstructor
public enum UserSuccessCode implements BaseResponseCode {

USER_SIGN_IN_CREATED(
USER_SIGN_UP_CREATED(
HttpStatus.CREATED,
"USER201_1",
"회원가입에 성공했습니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ select count(mh)
""")
long countCompletedMissions(@Param("userId") Long userId);

Optional<User> findByEmail(String username);
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
package com.umc.study.domain.user.service;

import com.umc.study.domain.mission.entity.Location;
import com.umc.study.domain.mission.entity.Mission;
import com.umc.study.domain.mission.entity.Restaurant;
import com.umc.study.domain.mission.exception.PageOverflowException;
import com.umc.study.domain.mission.repository.MissionRepository;
import com.umc.study.domain.mission.repository.RestaurantRepository;
import com.umc.study.domain.user.entity.User;
import com.umc.study.domain.user.enums.Role;
import com.umc.study.domain.user.exception.UserNotFoundException;
import com.umc.study.domain.user.repository.UserRepository;
import com.umc.study.domain.user.web.dto.GetHomeRes;
import com.umc.study.domain.user.web.dto.GetMyPageRes;
import com.umc.study.domain.user.web.dto.SignInReq;
import com.umc.study.domain.user.web.dto.SignUpReq;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

Expand All @@ -25,6 +31,27 @@ public class UserService {
private final UserRepository userRepository;
private final MissionRepository missionRepository;
private final RestaurantRepository restaurantRepository;
private final PasswordEncoder encoder;

@Transactional
public void saveUser(SignUpReq request) {
User user = User.builder()
.name(request.name())
.isMale(request.isMale())
.birth(request.birth())
.role(Role.GUEST)
.email(request.email())
.password(encoder.encode(request.password()))
.phoneNum(request.phoneNum())
.pointDeposit(0)
.location(Location.builder()
.streetAddress(request.address())
.detailedAddress(request.detailAddress())
.build())
.build();

userRepository.save(user);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

회원가입 저장 전에 이메일 중복 여부를 확인하는 흐름이 필요합니다. findByEmail이 이미 있으므로 중복 이메일이면 도메인 예외를 던지고, 엔터티의 email 컬럼에도 unique 제약을 함께 두는 것을 권장합니다. 서비스 검증과 DB 제약을 같이 두면 비즈니스 규칙과 데이터 무결성이 함께 보장됩니다.

}

public GetMyPageRes getMyPage(Long userId) {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.umc.study.domain.user.service.UserService;
import com.umc.study.domain.user.web.dto.GetHomeRes;
import com.umc.study.domain.user.web.dto.GetMyPageRes;
import com.umc.study.domain.user.web.dto.SignUpReq;
import com.umc.study.global.apiPayload.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
Expand All @@ -21,15 +22,17 @@ public class UserController {

private final UserService userService;

@PostMapping("/sign-in")
public ResponseEntity<ApiResponse<?>> signIn(
@Valid @RequestBody Object request
@PostMapping("/auth/sign-up")
public ResponseEntity<ApiResponse<?>> signUp(
@Valid @RequestBody SignUpReq request
) {
// call Service method

userService.saveUser(request);

return ResponseEntity
.status(UserSuccessCode.USER_SIGN_IN_CREATED.getStatus())
.body(ApiResponse.onComplete(UserSuccessCode.USER_SIGN_IN_CREATED, null));
.status(UserSuccessCode.USER_SIGN_UP_CREATED.getStatus())
.body(ApiResponse.onComplete(UserSuccessCode.USER_SIGN_UP_CREATED, null));
}

@PostMapping("/my/pref")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.umc.study.domain.user.web.dto;

import com.umc.study.domain.user.entity.Food;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import java.time.LocalDate;
import java.util.List;

public record SignUpReq(
@NotNull(message = "서비스 이용 동의 필드는 비어있을 수 없습니다.")
Agree agree,

@NotBlank(message = "이름 필드는 비어있을 수 없습니다.")
String name,

@NotNull(message = "성별 필드는 비어있을 수 없습니다.")
Boolean isMale,

@NotNull(message = "생년월일 필드는 비어있을 수 없습니다.")
LocalDate birth,

@NotBlank(message = "주소 필드는 비어있을 수 없습니다.")
String address,

@NotBlank(message = "상세 주소 필드는 비어있을 수 없습니다.")
String detailAddress,

@NotEmpty(message = "선호 음식 배열은 비어있을 수 없습니다.")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

foodList를 회원가입 필수 입력으로 받지만 현재 User 엔터티와 saveUser 저장 흐름에는 반영되지 않습니다. 필수 DTO 필드라면 선호 음식 매핑 엔터티까지 저장하거나, 이번 범위에서 저장하지 않을 값이라면 요청 DTO에서 분리하는 것을 권장합니다. DTO와 도메인 저장 책임이 맞아야 계층 간 계약이 명확해집니다.

List<Food> foodList,

@NotBlank(message = "이메일 필드는 비어있을 수 없습니다.") @Email(message = "이메일 형식이 맞지 않습니다.")
String email,

@NotBlank(message = "비밀번호 필드는 비어있을 수 없습니다.")
String password,

@NotBlank(message = "전화번호 필드는 비어있을 수 없습니다.")
String phoneNum
) {

public record Agree(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree 내부 필드가 모두 nullable Boolean이라 agree 객체만 존재하면 필수 약관 값이 빠져도 검증을 통과할 수 있습니다. 약관 동의는 회원가입 불변식에 가까우므로 각 필드에 @NotNull을 붙이고, 필수 동의 항목은 true 여부까지 서비스나 커스텀 validator에서 검증하는 것을 권장합니다.

Boolean age,
Boolean service,
Boolean privacy,
Boolean location,
Boolean marketing
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.umc.study.global.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.umc.study.global.security.CustomEntryPoint;
import com.umc.study.global.security.exception.CustomAccessDenied;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
@Configuration
public class SecurityConfig {

@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}

private final String[] allowUris = {
"/api/swagger-ui/**",
"/api/swagger-resources/**",
"/api/v3/api-docs/**",
"/api/auth/**" // sign-up, login request allow
};

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAccessDenied customAccessDenied, CustomEntryPoint customEntryPoint) throws Exception {

http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(request -> request
.requestMatchers(allowUris).permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.defaultSuccessUrl("/api/swagger-ui/index.html", true)
.permitAll())
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.permitAll())
.exceptionHandling(e -> e
.accessDeniedHandler(customAccessDenied)
.authenticationEntryPoint(customEntryPoint));

return http.build();
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.umc.study.global.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.umc.study.global.apiPayload.ApiResponse;
import com.umc.study.global.apiPayload.code.BaseResponseCode;
import com.umc.study.global.apiPayload.code.GeneralErrorCode;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class CustomEntryPoint implements AuthenticationEntryPoint {

private final ObjectMapper objectMapper;

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException {

BaseResponseCode errorCode = GeneralErrorCode.UNAUTHORIZED;

response.setContentType("application/json;charset=UTF-8");
response.setStatus(errorCode.getStatus().value());

ApiResponse<Void> errorResponse = ApiResponse.onFailure(errorCode, null);

response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.umc.study.global.security.entity;

import com.umc.study.domain.user.entity.User;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.jspecify.annotations.Nullable;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Getter
@RequiredArgsConstructor
public class AuthUser implements UserDetails {

private final User user;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 권한 목록을 항상 빈 리스트로 반환하면 User.role 정보가 SecurityContext까지 전달되지 않아 역할 기반 인가를 확장하기 어렵습니다. RoleSimpleGrantedAuthority로 변환해 반환하도록 구성하면 도메인 모델의 역할 책임과 Spring Security의 권한 모델이 자연스럽게 연결됩니다. hasRole, hasAuthority의 차이도 함께 학습하기를 권장합니다.

}

@Override
public @Nullable String getPassword() {
return user.getPassword();
}

@Override
public String getUsername() {
return user.getEmail();
}
}
Loading