2/9(월) Spring 숙련주차 -3

dev_joo·2026년 2월 9일

본캠프 시작, OT

오늘부터 사전캠프 이후 본 캠프가 시작되었다.
출석과 강의, 취업지원 등 캠프 진행과 운영에 대해 OT를 통해 설명을 받고 팀을 배정받아 팀원들과 자기소개를 하고 팀 규칙을 정하고, 나머지 시간에 강의를 들었다.

Spring 숙련주차 -3

오늘의 목표는 Validation까지 실습하는것! 오늘 일과시간이 지나도 남아서 할 예정이다.

JPA, SQL 의존성 추가

// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// MySQL
runtimeOnly 'com.mysql:mysql-connector-j'
spring.application.name=spring-auth
#jwt.secret.key=${JWT_SECRET_KEY}

spring.datasource.url=jdbc:mysql://localhost:3306/auth
spring.datasource.username=root
#spring.datasource.password=${DB_PASSWORD}
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update

spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true

DB 생성

create database auth;

회원가입 구현

회원가입

@Controller
@RequestMapping("/api")
public class UserController {

...[생략]
    @GetMapping("/user/signup")
    public String signupPage() {
        return "signup";
    }
      @PostMapping("/user/signup")
    public String signup(SignupRequestDto requestDto){
        userService.signup(requestDto);
        return "redirect:login-page";
    }
}

여기서 redirect:주소는 컨텍스트 루트(Context Root)를 기준으로 한 상대 경로로 리다이렉트된다.
예를 들어, 컨텍스트 루트가 /app인 경우 redirect:home/app/home으로 이동한다.

반면, redirect:/home은 서버 루트(/)를 기준으로 하므로 /home으로 리다이렉트된다.


자꾸 (alert가 뜨는) 기본 login 화면으로 리다이렉트 되어서 뭐지 한참 해멨는데 프로젝트에 미리 추가해뒀던 SpringSecurity 의존성으로 인해 SpringSecurity 기본 설정이 모든 요청을 인증이 필요한 요청으로 만들고 login 으로 강제 이동시켜 생긴 일이었다.

Spring Security 기본값:

모든 요청 → 인증 필요

인증 안 됨 → /login 으로 redirect

GET 요청 → 302

로그인 페이지도 자동 생성

SecurityAutoConfiguration.class를 통해 Spring Boot의 기본 Security 자동 설정을 제외해준다.

@SpringBootApplication(exclude = SecurityAutoConfiguration.class)
public class SpringAuthApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringAuthApplication.class, args);
	}

}

또는 어플리케이션 하위 패키지에 Config 파일을 생성해 해결한다.

나는 예제와 SpringBoot 버전이 달라SecurityAutoConfiguration.class를 제외하면 빌드가 되지 않아서 아래와 같이 설정해줬다.


@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/",
                                "/api/user/signup",
                                "/api/user/login-page",
                                "/css/style.css"
                        ).permitAll()
                        .anyRequest().authenticated()
                );

        return http.build();
    }
}

userService

import org.springframework.security.crypto.password.PasswordEncoder;
@Service
@RequiredArgsConstructor
public class UserService {
	private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    
 public void signup(SignupRequestDto requestDto) {
        String username = requestDto.getUsername();
        String password = passwordEncoder.encode(requestDto.getPassword());

        // 회원 중복 확인 
        // @Column(nullable = false, unique = true)
        // private String username;
    private String username;)
        Optional<User> checkUsername = userRepository.findByUsername(username);
        if (checkUsername.isPresent()) {
            throw new IllegalArgumentException("중복된 사용자가 존재합니다.");
        }

        // email 중복확인
        // @Column(nullable = false, unique = true)
    	// private String email;
        String email = requestDto.getEmail();
        Optional<User> checkEmail = userRepository.findByEmail(email);
        if (checkEmail.isPresent()) {
            throw new IllegalArgumentException("중복된 Email 입니다.");
        }

        // 사용자 ROLE 확인
        // @Column(nullable = false)
    	// @Enumerated(value = EnumType.STRING)
    	// private UserRoleEnum role;
        UserRoleEnum role = UserRoleEnum.USER;
        if (requestDto.isAdmin()) {
            if (!ADMIN_TOKEN.equals(requestDto.getAdminToken())) {
                throw new IllegalArgumentException("관리자 암호가 틀려 등록이 불가능합니다.");
            }
            role = UserRoleEnum.ADMIN;
        }

        // 사용자 등록
        User user = new User(username, password, email, role);
        userRepository.save(user);
    }   
}

예전엔 회원가입에 어떤 로직이 필요하고 왜 이 쿼리 메서드를 사용하고, 옵셔널 리턴을 왜 이렇게 처리하는지 몰랐는데 JPA 기초를 탄탄히 하니까 코드를 이해하는 능력이 생긴 것 같다.

❓DTO는 데이터를 전달만 하는 기능을 해야한다고 생각하는데,
사용자가 관리자인지, 일반 사용자인지 RequestDto에서 확인하는게 맞을까?
isAdmin() :

💡DTO가 판단을 하는 게 아니라 그냥 요청에서 넘어온 값을 전달해주는것이기 때문에 가능하다.

❌진짜 안되는 것은 아래의 경우:

// 놉놉. 안됀는것은안돼는것. (DTO가 비즈니스 규칙을 알게 된다.)
public boolean isValidAdmin() {
    return isAdmin && ADMIN_TOKEN.equals(adminToken);
}

➕추가로, 예제와 같이 클라이언트가 user인지 admin인지 전달하는 것이 아닌, 토큰만 전달해 서버가 검증하는것이 바람직하다.

public UserRoleEnum resolveRole(String adminToken) {
    if (ADMIN_TOKEN.equals(adminToken)) {
        return UserRoleEnum.ADMIN;
    }
    return UserRoleEnum.USER;
}

패스워드 암호화

회원 등록 시 '비밀번호'는 사용자가 입력한 문자 그대로 DB에 등록할 수 없다.

DB 조회가 가능한 내부 관계자들이 볼 수 있고, 패스워드가 갈취당할 수 있기 때문에 정보통신망법, 개인정보보호법에 의해 비밀번호 암호화는 의무되어있다.

DB에 있는 패스워드 정보가 갈취당하더라도 실제 암호를 알 수 없도록 복호화가 불가능한 단방향 암호 알고리즘을 사용해야한다.

Spring Security에서 비밀번호 암호화 메서드 PasswordEncoder.matches()를 제공한다.

// 비밀번호 저장
passwordEncoder.encode("평문");
// 비밀번호 확인
if(!passwordEncoder.matches("평문", "암호문")) {
		   throw new IllegalAccessError("비밀번호가 일치하지 않습니다.");
 }

로그인 구현

    @PostMapping("/user/login")
    public String login(LoginbRequestDto requestDto, HttpServletResponse res) {
        try {
            userService.login(requestDto, res);
        } catch (Exception e) {
            // 로그인 실패를 프론트에서 감지할 수 있도록 error 파라미터 전달
            return "redirect:/api/user/login-page?error";
        }
        return "redirect:/login";
    }
 public void login(LoginbRequestDto requestDto, HttpServletResponse res) {
        String username = requestDto.getUsername();
        String password = requestDto.getPassword();

        // 회원 존재 확인
        User user = userRepository.findByUsername(username).orElseThrow(
                () -> new IllegalArgumentException("등록된 사용자가 없습니다.")
        );

        // 비밀번호 확인
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
        }

        // JWT 생성 및 쿠키에 저장 후 Response 객체에 추가
        String token = jwtUtil.createToken(user.getUsername(), user.getRole());
        jwtUtil.addJwtToCookie(token, res);
    }
// 토큰 생성
public String createToken(String username, UserRoleEnum role) {
    Date date = new Date();

    return BEARER_PREFIX + Jwts.builder().setSubject(username) // 사용자 식별자값(ID)
            .claim(AUTHORIZATION_KEY, role) // 사용자 권한
            .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
            .setIssuedAt(date) // 발급일
            .signWith(key, signatureAlgorithm) // 암호화 알고리즘
            .compact();
}

// JWT Cookie 에 저장
    public void addJwtToCookie(String token, HttpServletResponse res) {
        try {
            token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

            Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
            cookie.setPath("/");

            // Response 객체에 Cookie 추가
            res.addCookie(cookie);
        } catch (UnsupportedEncodingException e) {
            logger.error(e.getMessage());
        }
    }
profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글