# 개요

이전 글에서 Spring Security를 추가하고 관련 설정들을 SecurityFilterChain으로 작성해주었다.

이번 글에서는 Controller 계층에서 구현했던 Login API를 SecurityFilter로 옮겨보겠다.

선요약

  1. formLogin의 흐름
  2. Login API 기존 Controller 계층에서 구현 → JsonLoginFilter를 추가하여 SecurityFilterChain에 등록
    • JsonLoginFilter 추가
    • Success/Failure Handler 추가
    • 리팩토링
  3. CustomUserDetailsService와 CustomUserDetails 추가
    • AuthService 추가
    • AuthenticatedUser 추가
    • 패키지 구조 정리
  4. Success/Failure Handler 수정

 

# SecurityFilter로 옮긴 이유

소위 무지성으로 블로그들을 따라 구현했기 때문에 SecurityFilter에 구현한 이유를 명확하게 알지 못했다. 그리고 기존에 알던 방식이 아니었기 때문에 따라하면서도 이해하기가 많이 어려웠다.

블로그 글을 작성하기위해 코드를 다시 작성하면서 장점과 단점에 대해 내가 느낀것과 블로그, GPT 등을 참고한 내용을 정리해 보겠다.

장점

단일 책임 원칙: 인증/인가에 대한 책임을 SecurityFilter로 분리하여 단일 책임 원칙(SRP)을 준수할 수 있었다. 일반적인 사용자 요청은 Controller에서 처리하고, 인증/인가와 관련된 로직은 SecurityFilter에서 처리하여 역할을 명확하게 분리할 수 있었다. 그래서 각 코드의 가독성과 유지보수를 높힐 수 있었다.

보안 강화와 표준화된 인증 프로세스: 인증/인가에 관한 로직을 SecurityFilter에 작성함으로써 인증 요청이 DispatcherServlet에 도달하기 전에 SecurityFilter가 가로채어 처리하게 되었다. Spring Security의 표준 인증 프로세스를 따를 수 있고 보안 측면에서도 더 안전하게 구현할 수 있었다.

개방 폐쇄 원칙: 인증/인가에 관련된 로직을 SecurityFilter로 분리함으로써 OCP를 잘 준수할 수 있었다. Session 방식에서 JWT 방식으로 변경하는 등 새로운 기능을 추가하거나 기존의 기능을 수정하는데 유연하게 대처할 수 있었다.

단점

학습 곡선: Spring Security 프레임워크를 제대로 사용하기 위한 학습 곡선이 상당히 크다고 느꼈다. 여러 블로그에서 잘 설명해주었고, 공식 문서에 아키텍처를 잘 표현해주었기 때문에 익힐 수 있었지만 꽤 시간이 걸렸다. 새로운 프레임워크를 배우고 적용해볼 수 있어서 좋았지만, 더 잘 다루기 위해서는 더 공부가 필요하다고 느꼈다.


 

# Security formLogin 동작 흐름 (formLogin)

먼저 formLogin의 흐름을 이해하고나서 Custom Login Filter를 만들어보겠다. 디버깅을 통해 formLogin의 흐름을 살펴보자.

꽤 길다..... 디버깅 과정이 보기 싫으면 시퀀스 다이어그램만 봐도 될것 같다. 그것도 싫다면 바로 Custom Login Filter를 구현하는 부분부터 봐도 된다.

더보기

form Login 화면에서 username과 password를 입력하고 로그인 요청을 디버깅 해보면 다음과 같은 순서의 필터를 확인할 수 있다.

여기서 인증부분을 담당하는 필터는 UsernamePasswordAuthenticationFilter이다.

 

이 클래스를 살펴보면

request로부터 username과 password를 가져와서 UsernamePasswordAuthenticationToken을 만들어 이를AuthenticationManagerauthenticate() 메서드에 전달한다.

 

AuthenticationManager를 살펴보면

ProviderManager인 것을 확인할 수 있고

ProviderManagerauthenticate() 메서드를 살펴보면

ProviderManager 내부에 가지고 있는 AuthenticationProvider들을 순회하며 전달받은 Authentication (UsernamePasswordAuthenticationToken)을 지원한다면 해당 AuthenticationProviderauthenticate() 메서드를 호출하고, 지원하는 AuthenticationProvider가 없다면 ProviderManager의 parent의 authenticate()를 호출한다.

 

디버깅을 계속 해보자

UesrnamePasswordAuthenticationFilterProviderManager(13113)AnonymousAuthenticationProvider를 가지고 있고 이 ProviderManager(13113)의 parent인 ProviderManager(13117)는 DaoAuthenticationProvider를 가지고있다.

AnonymousAuthenticationProviderUsernamePasswordAuthenticationToken을 지원한다면 AnonymousAuthenticationProviderauthenticate() 메서드를 호출할 것이고, 지원하지 않는다면 parent ProviderManager(13117)authenticate()를 호출할 것이다.

 

그럼 어느 AuthenticationProviderauthenticate()를 호출하는지 확인해보자. AnonymousAuthenticationProvider일까? DaoAuthenticationProvider일까? 아니면 둘 다 아니라서 Exception이 발생할까?

DaoAuthenticationProviderauthenticate() 메서드가 실행되었다. 즉 UsernamePasswordAuthenticationTokenAnonymousAuthenticationProvider는 지원하지 않고, DaoAuthenticationProvider는 지원한다는 뜻!

 

계속해서 살펴보자. AbstractUserDetailsAuthenticationProvider authenticate() 메서드를 살펴보면( DaoAuthenticationProviderAbstractuUserDetailsAuthenticationProvider를 상속)

retrieveUser() 메서드를 통해 user를 반환받고 그 user를 additionalAuthenticationChecks() 메서드에 전달하여 추가적인 확인을 하는것 같다.

 

차례대로 확인해보자. retrieveUser() 메서드를 살펴보면

DaoAuthenticationProvider가 가지고있는 UserDetailsServiceloadUserByUsername() 메서드를 통해 UserDetails를 반환한다.

 

계속해서 디버깅 해보면

UserDetailsServiceInMemoryUserDetailsManager이고 loadUserByUsername() 메서드를 살펴보면

여느 getUser() 메서드처럼 User를 반환한다.

 

이번엔 addtionalAuthenticationChecks() 메서드를 살펴보자.

비밀번호를 확인하여 틀리면 Exception을 발생한다.

 

로그인을 성공하면 SuccessHandler에서 실패하면 FailureHandler에서 응답처리한다.

SimpleUrlAuthenticationSuccessHandler를 상속받은 SavedRequestAwareAuthenticationSuccessHandler와 SimpleUrlAuthenticationFailureHandler

위의 복잡한 디버깅으로 알아본 formLogin의 흐름을 간단한 시퀀스 다이어그램으로 표현하면 다음과 같다.

이 흐름을 토대로 Custom Login Filter를 만들어 볼 것이다. 


 

# Custom Login Filter 구현 (JsonLoginFilter)

formLogin의 인증 부분을 담당하는 필터는 UsernamePasswordAuthenticationFilter였다. 이를 대체할 CustomAuthenticationFilter를 만들것인데 이후 Header에서 JWT를 확인하는 JwtAuthenticationFilter를 구현할 것인데 괜히 JsonAuthenticationFilter라고 이름지어서 혼란을 줄 수 있으니 편의상 JsonLoginFilter라고 이름짓겠다.

JsonLoginFilter 추가

JsonLoginFilterFilter 클래스들 중 하나를 상속하여 구현할 수 있다.

Filter 계층구조(이 외에도 Filter를 구현한 클래스가 많다.)

블로그와 책 등을 살펴보니 가장 많이 구현한 형태가 OncePerRequestFilter, AbstractAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter를 상속한 클래스였다. 각 차이점이 뭔지 AI에게 물어보았다. (답변이 틀릴수도 있다...)

  • CustomLoginFilter를 구현함에 있어서 AbstractAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter, OncePerRequestFilter를 상속받아 구현하는것 중 어느것이 가장 선호 돼?

답변

  • OncePerRequestFilter: 요청 당 한번만 실행되는 필터, 범용성이 높으나 인증 관련 메서드가 제공되지 않기 때문에 직접 구현해야함
  • AbstractAuthenticationProcessingFilter: Spring Security에서 기본적으로 제공하는 필터, 주로 로그인 인증 프로세스에서 사용됨
  • UsernamePasswordAuthenticationFilter: AbstractAuthenticationProcessingFilter를 상속받아 구현, 주로 폼 기반 로그인에서 사용됨

나는 AbstractAuthenticationProcessingFilter를 상속받아 구현해보겠다.

더보기

JsonLoginFilter.java 추가

package com.example.securityexample.global.filter;

// 생략...

public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
  }

  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException, IOException, ServletException {
    if (!isApplicationJson(request.getContentType())) {
      throw new AuthenticationServiceException("Not Supported Content-Type: " + request.getContentType());
    }

    LoginDto loginDto = new ObjectMapper().readValue(request.getInputStream(), LoginDto.class);
    return getAuthentication(loginDto);
  }

  private Authentication getAuthentication(LoginDto loginDto) {
    UsernamePasswordAuthenticationToken authentication =
        UsernamePasswordAuthenticationToken.unauthenticated(loginDto.getEmail(), loginDto.getPassword());
    return this.getAuthenticationManager().authenticate(authentication);
  }

  private boolean isApplicationJson(String contentType) {
    return contentType != null && contentType.equals("application/json");
  }
}

큰 흐름은 Request로 LoginDto를 받아 Authentication을 생성하여 AuthenticationManagerauthenticate() 메서드로 전달하는 것이다.

그리고 SecurityFilterChain에 JsonLoginFilter를 등록한다.

 

SecurityConfig.java의 filterChain() 수정

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .headers(frame -> frame.frameOptions(FrameOptionsConfig::sameOrigin));
        
    // 생략...

    httpSecurity.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class);

    return httpSecurity.build();
  }

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

  @Bean
  public JsonLoginFilter jsonLoginFilter() {
    return new JsonLoginFilter();
  }
}

 이러고 실행 했는데 오류가 났다.

내용은 JsonLoginFilterAuthenticationManager를 등록해야한다는 것, formLogin의 실행 흐름을 생각해보면 UsernamePasswordAuthenticationFilterProviderManager가 있고 ProcierManagerDaoAuthenticationProvider가 있던 것처럼 JsonLoginFilter에도 ProviderManager를 추가하고, ProviderManagerDaoAuthenticationProvider를 추가해주어야 한다.

 

JsonLoginFilter.java Constructor 수정

package com.example.securityexample.global.filter;
// 생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    ProviderManager authenticationManager = new ProviderManager(authenticationProvider);

    setAuthenticationManager(authenticationManager);
  }
  
  // 생략...
}

이제 실행이 된다! Postman을 이용해서 Login API 테스트를 했는데 Exception이 발생했다.

내용은 DaoAuthenticationProviderUserDetailsService가 null이라는 것, 이것도 formLogin의 실행 흐름을 생각해보면 DaoAuthenticationProviderUserDetailsServiceInMemoryUserDetailsManager였다. 등록해주자

 

JsonLoginFilter.java Constructor 수정

package com.example.securityexample.global.filter;
// 생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(new InMemoryUserDetailsManager());
    ProviderManager authenticationManager = new ProviderManager(authenticationProvider);

    setAuthenticationManager(authenticationManager);
  }
  // 생략...
}

다시 Postman을 이용해서 Login API 테스트를 해보았는데 계속 403 Forbidden 응답이왔다;; 디버깅을 다시 해보니 InMemoryUserDetailsManagerloadUserByUsername() 메서드에서 User가 null이 나와서 그런것이었다. 원래라면 CustomUserDetailsService를 구현해야 하지만 우선 테스트를 위해서 InMemoryUserDetailsManager에 User를 추가해보겠다.

 

JsonLoginFilter.java의 Constructor 수정

package com.example.securityexample.global.filter;
//생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  public JsonLoginFilter() {
    super(new AntPathRequestMatcher("/api/v1/login", "POST"));
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(new InMemoryUserDetailsManager(
        new User("jinho4744@naver.com", new BCryptPasswordEncoder().encode("12345678"),
            Collections.singleton(new SimpleGrantedAuthority("read")))));
    // 임의로 InMemoryUserDetailsMananger에 User 추가, Password Encode
    
    authenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder()); // PasswordEncoder도 추가
    ProviderManager authenticationManager = new ProviderManager(authenticationProvider);

    setAuthenticationManager(authenticationManager);
  }
  
  // 생략...
  
  // 테스트를 위해 success/failure handler 메서드 추가
  @Override
  protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
      Authentication authResult) throws IOException, ServletException {
    response.setContentType("text/html;charset=utf-8");
    response.setStatus(HttpServletResponse.SC_OK);
    response.getWriter().write("로그인 성공");
  }

  @Override
  protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException failed) throws IOException, ServletException {
    response.setContentType("text/html;charset=utf-8");
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.getWriter().write("로그인 실패");
  }
  // 생략...
}

다시 Postman을 이용하여 Login API 테스트를 해보니 성공했다.

 

리팩토링

이제 JsonLoginFilterSecurityConfig를 리팩토링 해보겠다. JsonLoginFilter의 Constructor에서 등록한 AuthenticationManagerAuthenticationProviderSecurityConfig에서 Bean으로 등록하고, SuccessHandler와 FailureHandler 클래스를 추가하여 Bean으로 등록했다. 그리고 LoginDto Validation Check와 매직넘버들을 상수로 추출했다.

더보기

JsonLoginFilter.java 리팩토링

package com.example.securityexample.global.filter;
// 생략...
public class JsonLoginFilter extends AbstractAuthenticationProcessingFilter {

  // 매직넘버 상수로 추출
  private static final String LOGIN_REQUEST_URL = "/api/v1/login";
  private static final String LOGIN_REQUEST_HTTP_METHOD = "POST";
  private static final String LOGIN_REQUEST_CONTENT_TYPE = "application/json";
  private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
      new AntPathRequestMatcher(LOGIN_REQUEST_URL, LOGIN_REQUEST_HTTP_METHOD);

  // For LoginDto Validation Check
  private final Validator validator;

  // JsonLoginFilter를 위한 AuthenticationManager, AuthenticationProvider SecurityConfig로 이동
  // Success/Failure Handler SecurityConfig로 이동
  public JsonLoginFilter(Validator validator) {
    super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
    this.validator = validator;
  }

  @Override
  public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException, IOException {
    if (!isApplicationJson(request.getContentType())) {
      throw new AuthenticationServiceException("Not Supported Content-Type: " + request.getContentType());
    }

    LoginDto loginDto = parseDto(request);
    return getAuthentication(loginDto);
  }

  private LoginDto parseDto(HttpServletRequest request) throws IOException {
    ObjectMapper objectMapper = new ObjectMapper();
    LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
    Set<ConstraintViolation<LoginDto>> violations = validator.validate(loginDto);
    if (!violations.isEmpty()) {
      Map<String, String> errorMap = violations
          .stream()
          .collect(Collectors.toMap(k -> k.getPropertyPath().toString(), ConstraintViolation::getMessage));
      throw new AuthenticationServiceException(objectMapper.writeValueAsString(errorMap));
    }
    return loginDto;
  }

  private Authentication getAuthentication(LoginDto loginDto) {
    UsernamePasswordAuthenticationToken authentication =
        UsernamePasswordAuthenticationToken.unauthenticated(loginDto.getEmail(), loginDto.getPassword());
    return this.getAuthenticationManager().authenticate(authentication);
  }

  private boolean isApplicationJson(String contentType) {
    return contentType != null && contentType.equals(LOGIN_REQUEST_CONTENT_TYPE);
  }
}

 

SecurityConfig.java 리팩토링

package com.example.securityexample.global.config;
// 생략...
public class SecurityConfig {

  private final Validator validator;

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
        .formLogin(AbstractHttpConfigurer::disable)
        .httpBasic(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .headers(frame -> frame.frameOptions(FrameOptionsConfig::sameOrigin));

    httpSecurity.authorizeHttpRequests(requests -> requests
        .requestMatchers(HttpMethod.POST, "/api/v1/login", "/api/v1/signup").permitAll()
        .requestMatchers("/h2-console/**").permitAll()
        .requestMatchers("/").permitAll()
    );

    httpSecurity.addFilterBefore(jsonLoginFilter(), UsernamePasswordAuthenticationFilter.class);

    return httpSecurity.build();
  }

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

  @Bean
  public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    User tempUser = new User(
        "jinho4744@naver.com",
        new BCryptPasswordEncoder().encode("12345678"),
        Collections.singleton(new SimpleGrantedAuthority("read"))
    );
    authenticationProvider.setUserDetailsService(new InMemoryUserDetailsManager(tempUser));
    authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());

    return new ProviderManager(authenticationProvider);
  }

  @Bean
  public JsonLoginSuccessHandler jsonLoginSuccessHandler() {
    return new JsonLoginSuccessHandler();
  }

  @Bean
  public JsonLoginFailureHandler jsonLoginFailureHandler() {
    return new JsonLoginFailureHandler();
  }

  @Bean
  public AbstractAuthenticationProcessingFilter jsonLoginFilter() {
    AbstractAuthenticationProcessingFilter loginFilter = new JsonLoginFilter(validator);
    loginFilter.setAuthenticationManager(authenticationManager());
    loginFilter.setAuthenticationSuccessHandler(jsonLoginSuccessHandler());
    loginFilter.setAuthenticationFailureHandler(jsonLoginFailureHandler());
    return loginFilter;
  }
}

 

JsonLoginSuccessHandler.java 추가

package com.example.securityexample.global.handler;
// 생략...
public class JsonLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";

  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception) throws IOException {
    response.setContentType(RESPONSE_CONTENT_TYPE);
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.getWriter().write(exception.getLocalizedMessage());
  }
}

 

JsonLoginFailureHandler.java 추가

package com.example.securityexample.global.handler;
// 생략...
public class JsonLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";
  private static final String SUCCESS_MESSAGE = "Login Success: ";

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException {
    response.setStatus(HttpServletResponse.SC_OK);
    response.setContentType(RESPONSE_CONTENT_TYPE);

    response.getWriter().write(SUCCESS_MESSAGE + authentication.getName());
  }
}

 

 

JsonLoginFilter 추가, SecurityFilterChain에 등록 · JinHoooooou/SecurityExample@d83508d

JinHoooooou committed Jul 15, 2024

github.com

 

 

CustomUserDetailsService 추가

실제로는 위처럼 InMemoryUserDetailsManagerUser를 등록하여 조회하지 않고, 데이터베이스를 조회하여 등록된 User가 있는지 찾는다. 따라서 CustomUserDetailsService를 추가하고 이를 DaoAuthenticationProvider에 등록해야한다.

  • 물론 Spring Security에서 제공하는 구현체중에 JdbcUserDetailsManager가 있긴한데, 여기 구현되어있는 findBy~~ 메서드를 오버라이딩하려면 직접 쿼리를 입력해야한다. 결국 그러한 DB I/O 메서드들을 재정의해서 JdbcUserDetailsManager를 Bean으로 등록해야하기 때문에 그럴 바에는 구현체를 직접 작성하는게 낫다고 생각했다.

UserDetailsService인터페이스를 구현하는 클래스인 AuthService를 작성하고, loadUserByUsername() 메서드의 반환 타입인 UserDetails 인터페이스를 구현하는 클래스 AuthenticatedUser를 작성했다.

  • UserDetails 인터페이스를 구현하는 AuthenticatedUser 클래스를 작성하는 것 대신 기존의 User Entity 클래스가 UserDetails 인터페이스를 구현하는것으로 수정해도 됐는데, User Entity의 결합도가 높아질 것 같다고 생각하여 별도의 클래스로 작성했다.
더보기

AuthService.java 추가

package com.example.securityexample.auth.service;
// 생략...
@Service
@RequiredArgsConstructor
public class AuthService implements UserDetailsService {

  private final UserRepository userRepository;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    User user = userRepository.findByEmail(username).orElseThrow(
        () -> new BadCredentialsException(Message.NOT_MATCH_LOGIN_DTO)
    );
    return new AuthenticatedUser(user);
  }
}

 

AuthenticatedUser.java 추가

package com.example.securityexample.auth.dto;
//생략...
public class AuthenticatedUser implements UserDetails {

  private final User user;

  public AuthenticatedUser(User user) {
    this.user = user;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
  }

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

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

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}

 

Provider에 AuthService 등록

package com.example.securityexample.global.config;
// 생략...
public class SecurityConfig {
  // 생략...
  private final AuthService

  @Bean
  public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
    authenticationProvider.setUserDetailsService(authService); // InMemoryUserDetailsManager 대신 AuthService 추가
    authenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());

    return new ProviderManager(authenticationProvider);
  }

}

 

그리고 auth관련 도메인 패키지를 만들어서 Login API와 관련된 클래스들을 옮겨주었고, 기존 Controller계층에 작성했던 LoginController와 Service계층의 login() 메서드를 삭제했다.

 

CustomUserDetailsService(AuthService), CustomUserDetails(Authenticate… · JinHoooooou/SecurityExample@02cdde1

…dUser) 추가 및 패키지 구조 변경

github.com

 

 

Success/Failure Handler 수정

지금의 Login API의 성공과 실패는 단순히 text형식으로 메시지만 반환하고 있는데 이를 Json 형태로 보내고, 로그인 성공시 세션에 추가하도록 변경해보겠다.

더보기

JsonLoginSuccessHandler.java 수정

package com.example.securityexample.auth.handler;
// 생략...
public class JsonLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";
  private static final String SUCCESS_MESSAGE = "Login Success: ";

  @Override
  public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication authentication) throws IOException {
    response.setStatus(HttpServletResponse.SC_OK);
    response.setContentType(RESPONSE_CONTENT_TYPE);

    HttpSession session = request.getSession();
    session.setAttribute("authenticatedUser", authentication.getPrincipal());

    new ObjectMapper().writeValue(response.getWriter(),
        LoginResponseDto.builder().message(SUCCESS_MESSAGE + authentication.getName()).build());
  }
}

 

JsonLoginFailureHandler.java 수정

package com.example.securityexample.auth.handler;
// 생략...
public class JsonLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

  private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=utf-8";

  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception) throws IOException {
    response.setContentType(RESPONSE_CONTENT_TYPE);
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    new ObjectMapper().writeValue(response.getWriter(),
        LoginResponseDto.builder().message(Message.NOT_MATCH_LOGIN_DTO).build());
  }
}

 

LoginResponseDto.java 추가

package com.example.securityexample.auth.dto;
// 생략..
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginResponseDto {

  private String message;
}
 

Login Success/Failure Handler 수정 · JinHoooooou/SecurityExample@845ca8c

JinHoooooou committed Jul 19, 2024

github.com


 

# Postman Login API 테스트

성공

쿠키로 세션ID도 잘 전송하는 모씁

 

실패


 

# 결론

이번 글에서 추가 한 내용은 다음과 같다.

  1. formLogin의 흐름
  2. Login API 기존 Controller 계층에서 구현 → JsonLoginFilter를 추가하여 SecurityFilterChain에 등록
    • JsonLoginFilter 추가
    • Success/Failure Handler 추가
    • 리팩토링
  3. CustomUserDetailsService와 CustomUserDetails 추가
    • AuthService 추가
    • AuthenticatedUser 추가
    • 패키지 구조 정리
  4. Success/Failure Handler 수정

다음 글에서는 Session 방식을 Token 방식으로 바꿔 보겠다.


 

# 전체 코드

 

GitHub - JinHoooooou/SecurityExample: Spring Security 적용기

Spring Security 적용기. Contribute to JinHoooooou/SecurityExample development by creating an account on GitHub.

github.com

 

 

# 개요

이전 글에서 인증/인가를 구현하기 위해 가장 기본적인 User Entity, 회원가입 API, 로그인 API를 구현했다.

이번 글에서는 본격적으로 Spring Security를 도입한 과정을 작성해보겠다.

선 요약

  1. Spring Security 라이브러리 추가
  2. Spring Security Config 작성
    • formLogin disable
    • csrf disable
    • api 및 사용할 url permitAll으로 등록
    • h2 console을 위한 x-frame-options 설정 

 

# Session vs Token

팀 프로젝트 회의를 하면서 내가 Session 방식 대신에 Token 방식을 사용해보자는 제안을 했다. 물론 어짜피 내가 구현해야하는 것이니 팀원들은 별 의견없이 그러자고 했다. (의견을 좀 내주었으면 좋겠다 싶었는데.. ㅜ)

세션 방식과 토큰 방식의 차이점을 잘 모르는 상태로 토큰 방식을 도입하려고 했다. 단순히 "세션 방식은 구현해봤고 토큰 방식은 한 번도 구현해보지 않았으니까 이번 기회에 토큰 방식을 사용하자!"라는 생각으로 구현하려고 했다.

그냥 구현만하고 배경지식이 없으면 안된다고 생각해서 세션 방식과 토큰 방식의 차이도 공부했다.

 

인증/인가 - Session vs Token

# 개요인증/인가를 구현하는 과정에서 세션 방식과 토큰 방식이 있다는 것을 알게되어 정리한다.내용만 정리하고 별도 코드는 작성하지 않는다.지적, 오타 등 환영합니다.. # 인증? 인가?영어로

jino-dev-diary.tistory.com


 

# Spring Security 추가 및 설정

책과 블로그를 참고하여 Spring Security를 추가하고 Config.java를 추가하여 SecurityFilterChain을 작성했다. 그 과정을 코드와 설명으로 작성해보겠다.

 

## Spring Security 추가

책과 블로그를 참고하여 프로젝트를 할 때는 Spring Security Dependency를 추가하고 바로 Config.java를 추가했는데 좀 더 상세한 흐름을 작성해보겠다.

Spring Security Dependency 추가

더보기

build.gradle

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'

 

추가 후 프로젝트를 실행하면 다음과 같이 못보던 로그도 나오고, localhost:8080으로 접속하면 로그인 창이 나온다.

기본 주소로 접속하면 자동으로 로그인 페이지가 나오며 id에 "user", password에 로그에서 제시한 password를 입력하면 메인페이지로 이동한다.

SignUp API를 구현하면서 BCryptPasswordEncoder 빈 주입을 추가했었는데, 이것 때문에 로그인을 시도하면 "Encoded password does not look like BCrypt"라는 로그가 나오면서 로그인이 되지 않는다. 참고하자

 

Spring Security Config 추가

Spring Security에서 기본적인 로그인 페이지를 제공해주긴 하지만, 나는 프론트엔드와 백엔드를 나누어서 UI/UX와 API서버를 따로 구현했기 때문에 사용하지 않을것이다.

그래서 Spring Security에서 제공해주는 로그인 페이지를 사용하지 않기 위해서 Security Config를 작성했다.

더보기

SecurityConfig.java 추가

package com.example.securityexample.global.config;

// 생략...

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable);

    return httpSecurity.build();
  }

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

Spring Security 설정을 사용하기 위해 @EnableWebSecurity를 붙여준다.

Spring Security 기본 제공하는 formLogin을 사용하지 않기위해 disable설정했다.

PasswordEncoderConfig.java에 있던 PasswordEncoder 설정도 SecuriyConfig.java로 옮겼다.

 

다시 기본 주소인 localhost:8080으로 접속하면

로그인 창이 안뜨는 것을 볼 수 있다.

localhost:8080/login으로도 접속해보면

login에 매핑되는 페이지가 없기 때문에 No static resource login 오류가 난다.

 

 

API 요청 권한 오류 수정

내가 Controller에서 작성했던 Login API를 다시 사용하기 위해 Postman으로 테스트를 했지만 403 Forbidden으로 응답이 왔다.

??????

403 Forbidden 이면 권한 오류라는 건데 갑자기 이런게 왜 나왔나..? 찾아보니 Spring Security를 사용하면 각 API마다 권한 설정을 해주어야 한다고 한다.

더보기

SecurityConfig.java의 filterChain()에 권한 관련 설정 추가

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable);

    httpSecurity.authorizeHttpRequests(requests -> requests
//      .anyRequest().permitAll()
        .requestMatchers(HttpMethod.POST, "/api/v1/login", "/api/v1/signup").permitAll()
        .requestMatchers("/").permitAll()
    );

    return httpSecurity.build();
  }
  // 생략...
}

anyRequest().permitAll()을 사용해도 되지만 좀 더 명확하게 작성하기 위해 HTTP 메서드와 url 전부 작성해주었다.

그리고 기본 페이지(localhost:8080)도 접근을 허용하기위해 추가해주었다.

하지만 그래도 403 Forbidden으로 응답이 왔다.

프로젝트할 때는 그냥 무지성으로 filterChain()에 설정 작성 해 주고 잘 되는걸 확인했었는데 글을 작성하며 순차적으로 설정을 추가하니까 안되었고 그 이유도 몰랐다.

그래서 팀 프로젝트를 할 때 작성했던 설정들에 대해 검색해보니 권한 설정을 해주었어도 403 응답이 온 이유는 CSRF 설정을 안해주었기 때문이었다.

 

[Spring Security] CORS, CSRF란?

CORS란? CORS란 “Cross-Origin Resource Sharing”의 약자입니다. CORS는 프로토콜인데, 서로 다른 origin일 시 리소스와 상호 작용하기 위해 클라이언트인 브라우저에서 실행되는 스크립트입니다. 예를 들어

jaykaybaek.tistory.com

요약하자면 Spring Security는 기본적으로 CSRF 보호 설정이 되어있기 때문에 클라이언트에서 요청을 보낼 때 CSRF 토큰을 포함해서 요청을 보내지 않으면 안된다. 하지만 내가 API 테스트할 때는 CSRF 토큰을 포함하지 않았기 때문에 계속 403 Forbidden으로 응답이 왔던 것이었다.

따라서 요청시 CSRF 토큰을 포함시키거나, Security FilterChain에 CSRF 보호 기능을 명시적으로 해제하여 이를 해결할 수 있다. 내가 본 블로그들에서도 그래서 CSRF 기능을 해제했던 것이었다. 그냥 무지성으로 따라했으니 몰랐던거였다. 모지리...

더보기

SecurityConfig.java의 filterChain()에 CSRF 기능 해제 설정 추가

package com.example.securityexample.global.config;

// 생략...

public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable);

    // 생략...
  }
  
  // 생략...
}

성공!

 

 

H2 DB 관련 설정 추가

DB를 직접 조회하기 위해 localhost:8080/h2-console로 접속해보니 접속이 안됐다. (/h2-console인 이유는 application-db.yml에 그렇게 설정했기 때문)

역시 403 에러가 발생...

이전 api url 권한을 permitAll()을 통해 모두 허용한 것처럼 h2-console도 등록해주었다.

더보기

SecurityConfig.java의 filterChain()에 권한 설정 추가

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    // 생략...

    httpSecurity.authorizeHttpRequests(requests -> requests
        .requestMatchers(HttpMethod.POST, "/api/v1/login", "/api/v1/signup").permitAll()
        .requestMatchers("/h2-console/**").permitAll()
        .requestMatchers("/").permitAll()
    );

    return httpSecurity.build();
  }
  // 생략...
}

그러고 /h2-console로 접속하여 로그인을 해보았더니 이런 화면이 나왔다

???? 이건 또 뭐여

팀 프로젝트를 할 당시에는 H2가 아닌 Mysql이라서 이런 문제를 전혀 몰랐는데, 이 글을 작성하기 위해 H2를 사용하려고 하니 이런 오류가 발생했다.

관련 검색을 해보니 Spring Security를 사용하면 기본적으로 Header의 X-Frame-Options설정이 되어있어서 iframe나 object 요소를 통해 다른 컨텐츠가 embedded 되는것을 방지한다고 한다. H2 콘솔의 UI는 iframe으로 이루어져 있기 때문에 위와 같이 embedded가 되지 않는 것이었다. 

해결 방법은 X-Frame-Options를 disable하거나 same-origin으로 바꾸는 것이다. 나는 same-origin으로 바꿔주었다.

더보기

SecurityConfig.java의 filterChain()에 Header 설정 추가

package com.example.securityexample.global.config;

// 생략...
public class SecurityConfig {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.formLogin(AbstractHttpConfigurer::disable)
        .csrf(AbstractHttpConfigurer::disable)
        .headers(frame -> frame.frameOptions(FrameOptionsConfig::sameOrigin));
        
    // 생략...
    );

    return httpSecurity.build();
  }
  // 생략...
}

 

와! 잘 나온다


 

# 결론

이번 글에서 추가 한 내용은 다음과 같다.

  1. Spring Security 라이브러리 추가
  2. Spring Security Config 작성
    • formLogin disable
    • csrf disable
    • api 및 사용할 url permitAll으로 등록
    • h2 console을 위한 x-frame-options 설정 

다음 글에는 Controller 계층에서 처리하던 Login API를 Spring Security의 Filter에서 처리하도록 바꿔보겠다.


 

# 전체 코드

 

GitHub - JinHoooooou/SecurityExample: Spring Security 적용기

Spring Security 적용기. Contribute to JinHoooooou/SecurityExample development by creating an account on GitHub.

github.com

 

# 개요

이전 글에서 User Entity를 구현했고, 간단한 회원가입 API도 구현했다.

이번 글에서는 기존에 내가 알고 있던 로그인 API를 작성해보겠다.

선요약

  1. Login API 추가
    • LoginController 추가
    • UserService에 login() 추가

 

# Login API (Controller 이용)

프로젝트에서 먼저 회원가입을 API를 구현했고(이메일 중복체크, 닉네임 중복체크, 인증 코드 메일 API 등 회원가입에 부수적으로 필요한 API도 있었지만 이 글에서는 스킵했다.) 바로 로그인 API도 구현했다.

인증은 세션 방식과 토큰 방식이 있다는 것은 알고 있었지만 이 시점에서는 토큰 방식이 세션 방식보다 더 좋다는것도 몰랐고, 무엇보다도 구현하는 방법을 몰랐기 때문에 기존에 알고 있던 데로 세션에 방식으로 구현했다.

 

## Login API 구현

로그인을 위한 요구사항은 다음과 같았다.

  • 이메일과 비밀번호를 입력받는다.
  • 로그인에 성공하면 세션에 추가한다. → 이후에 세션, 토큰 방식의 차이를 알아보고 변경할 사항이 있다면 변경

이전 글과 마찬가지로 TDD 느낌을 내기 위해 API 테스트 후 구현하는 흐름으로 작성하겠다.

Login API 요청

코드를 작성하지 않았으니 당연히 실패한다. 위 요청 형식과 url에 맞게 Controller를 추가하고 그에 대한 비즈니스 로직을 Service 계층에 구현했다.

RestController 추가

UserController 하나로 회원가입과 로그인을 같은 클래스내에서 처리할 수 있었지만 SignUpController에 이메일,닉네임 중복체크, 인증코드 메일 전송, 인증코드 확인 API들이 있었기 때문에(이 프로젝트에는 생략했지만) LoginController를 추가했다.

더보기

LoginController.java 추가

package com.example.securityexample.user.controller;

// 생략...

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/login")
public class LoginController {

  @PostMapping("")
  public ResponseEntity<String> login(@RequestBody @Valid LoginDto loginDto) {
    
    return ResponseEntity.ok(Message.LOGIN_SUCCESS);
  }

}

Service 계층의 메서드 호출없이 응답으로 Status와 Message만 반환한다.

 

LoginDto.java 추가

package com.example.securityexample.user.dto;

// 생략...
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginDto {

  @NotNull(message = Message.INVALID_EMAIL)
  @Pattern(regexp = Regexp.EMAIL, message = Message.INVALID_EMAIL)
  private String email;

  @NotNull(message = Message.NOT_MATCH_LOGIN_DTO)
  private String password;
}

 

Service 추가

더보기

UserService.java에 login() 메서드 추가

package com.example.securityexample.user.service;

// 생략...

@Service
@RequiredArgsConstructor
public class UserService {

  // 생략...

  public User login(LoginDto loginDto) {
    User user = userRepository.findByEmail(loginDto.getEmail())
        .orElseThrow(()-> new ResourceNotFoundException(Message.NOT_MATCH_LOGIN_DTO));

    if(passwordEncoder.matches(user.getPassword(), loginDto.getPassword())) {
      throw new ResourceNotFoundException(Message.NOT_MATCH_LOGIN_DTO);
    }

    return user;
  }
  
  // 생략...
}

 

LoginController에서 login() 호출

package com.example.securityexample.user.controller;

// 생략...
public class LoginController {

  private final UserService userService;

  @PostMapping("")
  public ResponseEntity<String> login(@RequestBody @Valid LoginDto loginDto, HttpSession session) {
    User loggedInUser = userService.login(loginDto);
    session.setAttribute("user", loggedInUser);

    return ResponseEntity.ok(Message.LOGIN_SUCCESS);
  }

}

세션에 User를 추가하는것으로 인증처리 했다.

 

API 테스트

성공

실패


 

# 결론

이번 글에서 추가한 내용은 다음과 같다.

  1. Login API 추가
    • LoginController 추가
    • UserService에 login() 추가

다음 글에서는 Spring Security 라이브러리 추가해보겠다.


 

# 전체 코드

 

GitHub - JinHoooooou/SecurityExample: Spring Security 적용기

Spring Security 적용기. Contribute to JinHoooooou/SecurityExample development by creating an account on GitHub.

github.com

 

# 개요

팀 프로젝트 진행 중 User Entity 관련 담당을 맡았다. 인증/인가를 구현하는 과정에서 평소에 하던대로 세션과 쿠키를 이용해서 구현하려고 했는데 의문점이 생겼다.

  1. 클라이언트는 리액트를 이용해서 구현하고 서버는 API 서버로 사용할 예정인데 세션과 쿠키를 이용한 인증/인가가 유효할까?
  2. 세션/쿠키 대안으로 JWT를 이용한 인증 방식이 많이 사용 된다고 하는데, 실제로는 어떻게 구현할까?
  3. 두 방식의 차이는 무엇이고 왜 JWT를 활용한 인증방식이 많이 쓰일까?

1번이 궁금했던 이유는 기존에 JSP나 템플릿 엔진을 사용해서 서버 사이드 렌더링을 할 때는 세션 방식이 유효하지만 클라이언트 사이드 렌더링할때도 과연 그럴까? 라는 의문이 들었었다.

2,3번은 내가 정확히 아는 내용도 아니고 "그냥 그렇다고 하더라~"정도로만 인식하고 있었다.

글을 작성하면서 2,3번에 대한 내용을 따로 정리했다.

 

인증/인가 - Session vs Token

# 개요인증/인가를 구현하는 과정에서 세션 방식과 토큰 방식이 있다는 것을 알게되어 정리한다.내용만 정리하고 별도 코드는 작성하지 않는다.지적, 오타 등 환영합니다.. # 인증? 인가?영어로

jino-dev-diary.tistory.com

 

 

검색을 통해 알아낸 정보를 ChatGPT에 다시 질문함으로써 간단히 정리해보고자 한다.

더보기

 

 

결론은 클라이언트 사이드 렌더링 구조라고 하더라도 세션 방식 인증/인가로 구현할 수 있다고한다.

하지만 2번 3번 때문에 한 번 쯤은 구현해보고 싶었고, JWT를 이용한 인증방식이 어떻게 동작하는지 이해할 필요가 있다고 생각했다.

JWT 관련 내용은 세션에서 JWT으로 변경할 때 정리하고 이 글에서는 제목처럼 팀 프로젝트 할 당시의 User Entity와 간단한 SignUp API를 구현하고 테스트 하겠다.

테스트 코드를 추가하면 좋겠지만 그것도 이번 프로젝트를 하면서 공부한 내용이 있으므로 따로 포스팅 하겠다.

선요약

  1. H2 DB 추가 및 설정
  2. User Entity 추가
  3. SignUp API 추가
    • SignUpController 추가
    • UserService 추가
    • UserRepository 추가
  4. PasswordEncoder 추가
    • 라이브러리 추가
    • Bean 추가
    • 비밀번호 DB에 저장할 때 암호화
  5. DuplicateResourceException 추가
  6. Borough Entity, Repository 추가
    • 미리 DB에 25개의 Borough 레코드 추가
    • 회원 가입 시 User와 Borough 연관 관계 매핑

# DB 설정

실제 프로젝트 때는 AWS RDS Mysql을 사용했는데 여기서는 H2로 구현한다.

DB설정 추가

더보기

application-db.yml 추가

spring:
  h2:
    console:
      enabled: true
      path: /h2-console
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:~/test
    username: sa
    password: q1w2e3r4
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true

 

application.properties 수정

spring.application.name=securityExample
spring.config.import=classpath:application-db.yml

 

# SignUp API 추가

Spring Security를 이용한 인증/인가에 관한 글이기 때문에 가장 먼저 User에 대한 Entity가 필요하다고 생각하여, User Entity와 그것을 DB에 레코드로 저장하는 SignUp API (Create)를 추가했다.

## User Entity 추가

프로젝트에 필요한 User Entity에 대한 설계는 다음과 같았다.

  • 이메일을 기반으로 회원가입 한다.
  • 이메일, 비밀번호, 닉네임, 주소, 연락처를 입력받는다.
  • 이메일, 비밀번호, 닉네임, 주소는 반드시 필요하다. → email, password, nickname, address not null
  • 이메일과 닉네임은 중복되어서는 안된다.  → email, nickname unique

User Entity 추가

더보기

User.java 추가

package com.example.securityexample.user.entity;

// 생략...

@Entity(name = "users") // H2에 user 테이블이 이미 있기 때문에 users로 수정했다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {

  @Id
  @Column(name = "USER_ID")
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Column(unique = true, nullable = false)
  private String email;
  @Column(nullable = false)
  private String password;
  private String phone;
  @Column(unique = true, nullable = false)
  private String nickname;
  @Column(nullable = false)
  private String address;
}

 

## SignUp API 구현

회원가입을 위한 요구사항은 다음과 같았다.

  • 이메일, 비밀번호, 비밀번호 확인, 닉네임, 주소, 연락처를 입력받는다.
  • 각 입력은 각자 유효한 형식이 있다.
  • 이메일은 인증된 이메일만 가입할 수 있다.
  • 이메일과 닉네임은 중복되어서는 안된다.
  • 비밀번호는 암호화되어 DB에 저장되어야 한다.

여기서 메일 인증은 Security와는 관련 없으니 구현하지 않겠다. (별도 포스팅 예정)

TDD 느낌을 내기 위해 API 테스트 후 코드 작성하는 흐름으로 작성하겠다.

API 테스트

Request Body는 생략

코드를 작성하지 않았으니 당연히 실패한다. 요청 형식과 url 맞게 Controller를 구현하고 그에 대한 비즈니스 로직을 Service 계층에 구현했다.

 

RestController 추가

Rest API 서버를 구현하기 때문에 @RestController로 Controller 클래스를 추가하고, 클라이언트 요청에 대한 SignUpRequestDto를 추가했다.

더보기

SignUpController.java 추가

package com.example.securityexample.user.controller;

// 생략...

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/signup")
public class SignUpController {

  @PostMapping(value = "", produces = "application/json;charset=UTF-8")
  public ResponseEntity<Map<String, String>> signUp(@RequestBody @Valid SignUpRequestDto signUpRequestDto) {

    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(Map.of("message", Message.SIGNUP_SUCCESS));
  }
}

Service 계층 없이 응답으로 Status와 message만 반환한다. (Message는 생략)

 

SignUpDto.java 추가

package com.example.securityexample.user.dto;

// 생략...

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SignUpDto {

  @Pattern(regexp = Regexp.EMAIL, message = Message.INVALID_EMAIL)
  private String email;
  @Pattern(regexp = Regexp.PASSWORD, message = Message.INVALID_PASSWORD)
  private String password;
  private String passwordConfirm;
  @Pattern(regexp = Regexp.NICKNAME, message = Message.INVALID_NICKNAME)
  private String nickname;
  @Pattern(regexp = Regexp.ADDRESS, message = Message.INVALID_ADDRESS)
  private String address;
  @Pattern(regexp = Regexp.PHONE, message = Message.INVALID_PHONE)
  private String phone;

  @AssertTrue(message = Message.INVALID_PASSWORD_CONFIRM)
  public boolean isPasswordConfirm() {
    return this.password.equals(this.passwordConfirm);
  }
}

SignUp API에 필요한 입력들 각자 유효한 형식이 있기 때문에 Spring Validation을 이용했다. (Message, Regexp는 생략)

 

Service 추가

SignUp API에 비즈니스 로직을 Service 계층에 작성하고, Controller 계층에서는 Service의 메서드를 호출하도록 했다. 

더보기

UserService.java 추가

package com.example.securityexample.user.service;

// 생략...

@Service
@RequiredArgsConstructor
public class UserService {

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    User user = signUpRequestDto.toEntity();
  }
}

Repository계층이 없으므로 RequestDTO → Entity 클래스로 변환하는 로직만 추가했다.

 

SignUpRequestDto.java에 toEntity() 메서드 추가

package com.kh.bookfinder.user.dto;

// 생략 ...

public class SignUpDto {
	
  // 생략 ...
	
  public User toEntity() {
    return User.builder()
        .email(this.email)
        .nickname(this.nickname)
        .password(this.password)
        .address(this.address)
        .phone(this.phone)
        .build();
  }
}

 

SignUpController.java에서 UserService의 메서드 호출

package com.example.securityexample.user.controller;

// 생략...
public class SignUpController {

  private final UserService userService;

  @PostMapping(value = "", produces = "application/json;charset=UTF-8")
  public ResponseEntity<Map<String, String>> signUp(@RequestBody @Valid SignUpRequestDto signUpRequestDto) {
    userService.createNewUser(signUpRequestDto);

    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(Map.of("message", Message.SIGNUP_SUCCESS));
  }
}

 

Repository 추가

SignUp API를 통해 User Entity를 DB에 저장하기 위해서 다음과 같은 로직이 필요하다.

  • SignUpRequestDto의 email이 중복되는지 체크
  • SignUpRequestDto의 nickname이 중복되는지 체크

Repository 계층에 email, nickname을 조회하는 메서드를 작성하고 Service 계층에서 호출하도록 했다.

더보기

UserRepository.java 추가

package com.example.securityexample.user.repository;

// 생략...

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

  Optional<User> findByEmail(String email);

  Optional<User> findByNickname(String nickname);
}

JPA를 사용했다.

 

UserService.java에 UserRepository의 메서드 호출

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    if (alreadyExistEmail(signUpRequestDto.getEmail())) {
      throw new RuntimeException(Message.DUPLICATE_EMAIL);
    }
    if (alreadyExistNickname(signUpRequestDto.getNickname())) {
      throw new RuntimeException(Message.DUPLICATE_NICKNAME);
    }

    User user = signUpRequestDto.toEntity();
    this.userRepository.save(user);
  }

  private boolean alreadyExistEmail(String email) {
    return userRepository.findByEmail(email).isPresent();
  }

  private boolean alreadyExistNickname(String nickname) {
    return userRepository.findByNickname(nickname).isPresent();
  }
}

추상화를 위해 메서드로 추출했다.

 

PasswordEncoder 추가

DB에 User Entity를 저장할때 패스워드를 암호화 하기 위해 PasswordEncoder를 추가했다.

그냥 사용하면 되는줄 알았는데 Bean으로 등록해서 사용해야 한다고 한다.

더보기

build.gradle에 dependency 추가

// build.gradle
implementation 'org.springframework.security:spring-security-crypto`

 

PasswordEncoderConfig.java 추가

package com.example.securityexample.global.config;

// 생략...

@Configuration
public class PasswordEncoderConfig {

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

 BCryptPasswordEncoder 외에도 다양한 Encoder가 있다고 하는데, Spring Security가 기본적으로 BCrypt를 사용하기도 하고 가장 안전하다고 해서 사용했다.

 

SignUpRequestDto의 toEntity() 메서드 수정

package com.kh.bookfinder.user.dto;

// 생략 ...

public class SignUpDto {
	
  // 생략 ...
	
  public User toEntity(PasswordEncoder passwordEncoder) {
    return User.builder()
        .email(this.email)
        .nickname(this.nickname)
        .password(passwordEncoder.encode(this.password))
        .address(this.address)
        .phone(this.phone)
        .build();
  }
}

 

UserService에 PasswordEncoder 필드 추가

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    // 생략...

    User user = signUpRequestDto.toEntity(passwordEncoder);
    this.userRepository.save(user);
  }
  // 생략...
}

 

DuplicateResourceException 추가

물론 중복된다는 메시지가 있긴 하지만 RuntimeException으로 모호하게 처리하는 것 보다는 명확한 이름을 가진 Exception을 사용하는 것이 낫다고 생각해서 DuplicateResourceException을 추가했다

더보기

DuplicateResourceException.java 추가

package com.example.securityexample.global.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.CONFLICT)
public class DuplicateResourceException extends RuntimeException {

  public DuplicateResourceException(String message) {
    super(message);
  }
}

 

UserService.java의 createNewUser() 메서드 수정

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    if (alreadyExistEmail(signUpRequestDto.getEmail())) {
      throw new DuplicateResourceException(Message.DUPLICATE_EMAIL);
    }
    if (alreadyExistNickname(signUpRequestDto.getNickname())) {
      throw new DuplicateResourceException(Message.DUPLICATE_NICKNAME);
    }

    User user = signUpRequestDto.toEntity(passwordEncoder);
    this.userRepository.save(user);
  }
  // 생략...
}

 

Borough 도메인 추가

API를 계속 작성하는 중에 설계가 조금 추가 되었다.

회원이 등록한 자치구(강남구, 관악구 등)의 도서관 목록을 보여주게 하고 싶어요

그래서 Borough라는 Entity를 만들어 Borough 테이블에 서울 내 모든 자치구를 미리 추가했다.

그리고 회원가입 시 User 테이블과 Borough 테이블의 연관관계를 매핑해주었다.

더보기

Borough.java 추가

package com.example.securityexample.borough.entity;

// 생략...

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Borough {

  @Id
  @Column(name = "BOROUGH_ID")
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Column(unique = true, nullable = false)
  private String name;
}

 

BoroughRepository.java 추가

package com.example.securityexample.borough.repository;

// 생략...

@Repository
public interface BoroughRepository extends JpaRepository<Borough, Long> {

  Optional<Borough> findById(Long id);

  Optional<Borough> findByName(String name);
}

 

User.java 수정

package com.example.securityexample.user.entity;

// 생략...
public class User {

  // 생략...
  @ManyToOne
  @JoinColumn(name = "BOROUGH_ID")
  private Borough borough;

  public String extractBoroughName() {
    return this.address.split(" ")[1];
  }

}

Borough 필드와, 주소에서 자치구를 추출하는 메서드를 추가했다. 


UserService.java 수정

package com.example.securityexample.user.service;

// 생략...
public class UserService {

  private final UserRepository userRepository;
  private final BoroughRepository boroughRepository;
  private final PasswordEncoder passwordEncoder;

  public void createNewUser(SignUpRequestDto signUpRequestDto) {
    // 생략...

    User user = signUpRequestDto.toEntity(passwordEncoder);
    Borough borough = this.boroughRepository
        .findByName(user.extractBoroughName())
        .orElseThrow(() -> new ResourceNotFoundException(Message.INVALID_ADDRESS));
    user.setBorough(borough);
    this.userRepository.save(user);
  }
  // 생략...

}

User Entity의 주소 필드에서 자치구를 추출하고 해당하는 Borough Entity를 가져온다. 그리고 User Entity와 매핑한다.

 

Spring data REST 라이브러리 추가

implementation 'org.springframework.boot:spring-boot-starter-data-rest'

ResourceNotFoundException을 사용하기 위해 추가했다.

 

API 테스트


 

# 결론

이번 글에서 추가 한 것은 다음과 같다.

  1. H2 DB 추가 및 설정
  2. User Entity 추가
  3. SignUp API 추가
    • SignUpController 추가
    • UserService 추가
    • UserRepository 추가
  4. PasswordEncoder 추가
    • 라이브러리 추가
    • Bean 추가
    • 비밀번호 DB에 저장할 때 암호화
  5. DuplicateResourceException 추가
  6. Borough Entity, Repository 추가
    • 미리 DB에 25개의 Borough 레코드 추가
    • 회원 가입 시 User와 Borough 연관 관계 매핑

 

# 전체 코드

 

GitHub - JinHoooooou/SecurityExample: Spring Security 적용기

Spring Security 적용기. Contribute to JinHoooooou/SecurityExample development by creating an account on GitHub.

github.com

 

# 개요

학원 다니면서 개인 프로젝트로 플래너 개발을 해보기로 했다.

처음에는 학원에서 배운 내용 + 내가 알고 있었는데 잊었던 것들 + 이번 개인 프로젝트로 새로 배우고 싶었던 것들 세 가지를 적절히 섞어 구현할 예정이다.

수업 내용을 기반으로 Front 화면 구현을 진행했다.

 


 

# 화면 설계

처음에는 내가 SPA(Single Page Application)에 대한 이해가 없었기 때문에 모든 기능에 대한 페이지가 구현되어야 한다고 생각했다. 그래서

  • User → 회원 가입 페이지, 로그인 페이지, 회원 정보 조회 페이지, 회원 정보 수정 페이지 등
  • Plan → 플랜 작성 페이지, 플랜 조회 페이지, 플랜 수정 페이지 등
  • DetailPlan → 디테일 작성 페이지, 디테일 조회 페이지, 디테일 수정 페이지 등

모든 페이지를 구현해야 한다고 생각했고 그에 따라 html 파일을 작성했다.

디자인이나 UI는 최대한 Bootstrap을 사용하려고 했다.

 

HTML 파일 추가 · JinHoooooou/Planner@d34b6e1

JinHoooooou committed Feb 28, 2024

github.com

각 화면에 대한 와이어 프레임을 그려보았다.

 

Figma

Created with FigJam

www.figma.com

 


 

# 클라이언트 - 서버 아키텍쳐 설계

서버의 데이터를 클라이언트로 어떻게 전달할까?를 생각해봤다.

  • 처음 계획은 JSP를 이용하여 서버 사이드 렌더링을 구현하려고 했다. 하지만 JSP는 내가 매우 싫어하는 구조이다. JSTL이 있다고는 하지만 그럴바에는 Thymeleaf나 Mustache같은 Template Engine을 사용하는게 더 낫겠다라는 생각이 들었다.
  • 그래서 다음 계획은 Thymeleaf를 사용하는 것이었다. 하지만 Thymeleaf를 위한 라이브러리를 추가해야 했고, 스프링 프레임워크를 사용하지 않았기 때문에 Embedded Tomcat에서 설정 추가해야하는 부분이 다소 어려웠다. 또한 팀원들도 Template Engine에 대해 모를 것이기 때문에 이를 설명하는 것도 문제가 될 것 같았다.
  • 마지막 계획은 ajax를 이용하는 것이었다. ajax는 수업 때 배우기도 했고, 서버와 클라이언트를 완전히 분리하여 클라이언트 사이드 렌더링을 구현하는 것이 내 개인 학습에도 도움이 되고 코드 관리하기도 더 편할 것 같다는 생각이 들었다.

그리하여 서버 - 클라이언트 아키텍쳐 구조를 다음과 같이 설계했다.

  1. 클라이언트에서 ajax 통신으로 서버에 요청을 보낸다.
  2. 서버에서는 클라이언트 요청에 대한 응답으로 JSON 형식의 데이터만 응답한다.
  3. 클라이언트는 서버의 응답 JSON 데이터를 통해 동적으로 html 요소를 렌더링하거나 다른 페이지로 이동한다. 

이 설계를 팀원들에게 얘기했는데 당시에는 서버 - 클라이언트 구조에 대한 이해를 잘 못하고 있었기 때문에 내 계획이 무슨 말인지 잘 이해하지 못한 상태에서 그냥 따랐던 것 같다.

 


 

# 화면 구현

3명의 팀원이서 각 User, Plan, Detail에 대한 화면 및 UI를 구현하기로 했는데, 나는 Detail 영역을 구현하기로 했다. 

Plan에 대한 화면 구현 설계가 나왔고 그에 따라 Detail 화면을 다음과 같이 구현해야겠다고 생각했다.

 

Figma

Created with FigJam

www.figma.com

구현 과정에서 Bootstrap 공식문서도 엄청 많이 찾아보고 Chat GPT에게도 엄청 많이 물어봤다.

지금 와서 생각해보면 기능 구현보다는 화면 배치 때문에 계속 찾아보고 검색한 것들이라 여기 정리할 의미가 없는 것 같다..

그래서 발표했던 최종 결과물 화면들중 메인 페이지로 대체한다.

팀원이 구현한 화면이다. 여기서 PlanList중 하나의 제목(빨간 박스 부분)을 클릭하면 Offcanvas영역이 나오며 내가 구현한 Detail 영역이 나온다.

내가 구현한 Offcanvas 영역이다. 부트스트랩의 Offcanvas Component를 사용했으며 대부분 UI 구현은 부트스트랩 docs를 참고하여 만들었다. (빨간 부분)

메인 페이지의 Plan의 정보를 담고 있으며 해당 부분은 input 요소로 이루어져 있어 수정할 수 있다. input 요소의 내용을 수정 후 저장 버튼을 누르면 수정한 내용으로 Plan 데이터가 변경된다. (파란 부분)

"디테일 추가" 버튼을 누르면 위 사진처럼 버튼 아래로 Detail Create Form이 나온다. form에 대한 input들을 입력 후 "생성" 버튼을 누르면 아래 쪽에 Detail 리스트가 추가된다.

추가한 Detail 만큼 List가 추가된다. 완료 체크를 누르면 위쪽의 달성률 bar에 반영이 된다.

List중 하나의 element를 클릭하면 위와 같이 확장되어 보여준다.

확장되어 보여주는 영역은 input 영역이라 수정할 수 있다. 수정 후 다시 눌러 확장 영역을 닫으면 수정한 내용으로 변경된다.

휴지통 아이콘을 누르면 삭제할 수 있다.

 

# 간단 회고

어쨌든 서버에 요청을 보내고 받아온 응답 데이터를 통해 화면을 렌더링하거나 다른 페이지로 이동해야하기 때문에, 화면 구현에 대한 HTML/CSS/JS 파일을 다 작성하고 나서 서블릿 코드를 작성한 것이 아니고 병행 작업 했다.

그 과정을 상세하게 작성하고 싶었지만 막상 글을 쓰려니 정리도 잘 안되고, 기억도 잘 안나고, 크게 기능적인 구현을 한 것이 없다. 그래서 결과 화면만 포스팅 했다.

서블릿 코드 작성하는 글에서는 서블릿 코드 작성하면서 생각했던 점과 구현한 화면 및 기능을 최대한 상세하게 적어볼 예정이다.

진짜 화면 구현하기 위해 부트스트랩 Docs를 많이 참고하고 삽질을 많이 한 것 치고는 정리할 내용이 없어서 아쉽다.

# 개요

학원 다니면서 개인 프로젝트로 플래너 개발을 해보기로 했다.

처음에는 학원에서 배운 내용 + 내가 알고 있었는데 잊었던 것들 + 이번 개인 프로젝트로 새로 배우고 싶었던 것들 세 가지를 적절히 섞어 구현할 예정이다.

설계 했던 테이블을 기반으로 DAO 클래스를 작성했다.

 


 

# 테이블 - VO 매핑

VO 클래스 작성을 위해 회의를 다시 진행했다. 그 때 각 테이블에 당장 사용하지 않은 속성이 많기 때문에 일단은 최소한으로 테이블을 설계하고 이후에 수정하는 방식으로 가자고 강하게 어필했다. 팀원들은 잘 이해하지 못했지만 일단 따르기로 했던것 같다. 

 

ERD | Notion

SQL

tin-digit-d17.notion.site

테이블에 따라 각자 VO 클래스들을 작성했다. 각자 작성한 VO클래스를 보며 리뷰하고 리팩토링하여 최종본을 확정짓기로 했다.

 

Planner/src/main/java/com/kh/model/vo at 6f774f66ec005fe1edfe6b89ba594cf2f1aeef70 · JinHoooooou/Planner

Mini Timer For Semi Project. Contribute to JinHoooooou/Planner development by creating an account on GitHub.

github.com

사실 VO 클래스라 리팩토링 할 것은 없었다.

 


 

# DAO클래스 작성 (CRUD)

각 Plan, User, DetailPlan에 따른 DAO 클래스 그리고 그 중 가장 기본적인 CRUD메서드는 수업시간에 다 배웠다고 생각해서 각자 하나씩 맡아서 작성해보기로 했다.

내가 User 객체를 맡고, 두명씩 짝지어서 Plan과 DetailPlan을 맡아서 작성하기로 했는데, DetailPlan 작성하기로 한 두 명 중 한 명은 나가고 한 명은 잘 따라오질 못해서 일단 User와 Plan 객체만 작성됐다.

 

Planner/src/main/java/com/kh/model/dao/UserDao.java at 78bdc96406ca17a77e06c537feb06ebc3cc75319 · JinHoooooou/Planner

Mini Timer For Semi Project. Contribute to JinHoooooou/Planner development by creating an account on GitHub.

github.com

내가 작성한 UserDao이고 Create, Read (One/All) 메서드 밖에 없는데, 이것을 참고해서 작성하라고 했다.

 

Planner/src/main/java/com/kh/model/dao/PlanDao_Minseok.java at f73a0df8af32f181ff9cbeca255b10da27584deb · JinHoooooou/Planner

Mini Timer For Semi Project. Contribute to JinHoooooou/Planner development by creating an account on GitHub.

github.com

 

 

Planner/src/main/java/com/kh/model/dao/PlanDao_Sim.java at 6e876137389f505eb6186f4f728ab7cb2b8c50b0 · JinHoooooou/Planner

Mini Timer For Semi Project. Contribute to JinHoooooou/Planner development by creating an account on GitHub.

github.com

 


 

# DAO 클래스 리팩토링

내가 작성한 UserDAO와 팀원들이 작성한 PlanDAO 클래스를 책(자바 웹 프로그래밍 Next Step)을 참고하여 리팩토링을 진행했다. DetailPlan까지 작성 후 할까 리팩토링을 진행할까 생각했지만, 먼저 리팩토링을 하면 그 구조에 맞게 DetailPlan도 작성하는게 더 편할 것 같고 코드 리뷰할 때 클린코드, 리팩토링 관련 설명을 해주고 싶어서 회의 전에 미리 했다.

 

## JDBC Template 리팩토링

  1. 기존의 JdbcTemplate에서 DB와 연결하는 클래스를 작성했었는데, 이를 ConnectionManager로 옮겼다.
  2. DAO 클래스를 잘 보면 각 메서드마다 공통적으로 작성되는 부분과 메서드마다 다르게 작성되는 부분이 있다.
    • 쿼리를 작성하고 그 쿼리에 맞게 변수들을 setting하는 부분은 메서드마다 다르게 작성되는 부분이다.
    • ConnectionManager를 통해 Connection 인스턴스를 가져오고 그 Connection 인스턴스를 통해 Statement 인스턴스를 가져오고 Statement의 executeUpdate()나 executeQuery()를 호출하는 부분은 공통적으로 작성되는 부분이다.
    • 이에 따라 JdbcTemplate에 INSERT, UPDATE, DELETE 쿼리를 담당하는 executeUpdate() 메서드와 SELECT 쿼리를 담당하는 executeQuery(), executeQueryForOne()을 작성했다.

3. SQLException 대신 RuntimeException을 상속받는 DataAccessException을 추가했다.

 

Jdbc 템플릿 추가 · JinHoooooou/Planner@3358d07

JinHoooooou committed Feb 28, 2024

github.com

 

## UserDAO, PlanDAO 리팩토링

DAO의 CRUD 메서드들을 위에서 리팩토링한 JdbcTemplate을 사용하는 것으로 리팩토링했다.

 

User 관련 코드 리팩토링 · JinHoooooou/Planner@d1943f3

JinHoooooou committed Feb 28, 2024

github.com

 

 

Plan 관련 코드 리팩토링 · JinHoooooou/Planner@fb31201

JinHoooooou committed Feb 28, 2024

github.com

 


 

# 코드 리뷰

내가 리팩토링한 코드를 토대로 코드 리뷰를 진행했다...만 다들 잘 이해를 못했던것 같다. 제네릭을 사용한 것, ResultMap을 이용해서 테이블 → VO로 파싱하는 부분을 RowMap이라는 인터페이스를 통해 해결한 것 등 어려워 했다. 그래서 "나중에 더 배우게 되면 이해할 수 있으니 지금은 테스트 코드와 리팩토링을 통해 이런식으로 더 깔끔하게 코드를 작성할 수 있고, 이전 코드보다 가독성이 더 좋아졌다고 느낀다면 오케이다." 라고 설명했다.

내가 팀원들보다 쪼오오오오금 더 안다고 "내가 작성한 코드가 옳다." 라고 설명하고 싶진 않았는데, 설명하다보니 그렇게 된 것 같아 아쉬웠다;

 


 

# DetailPlan VO, DAO 클래스 작성

리팩토링하여 구조화 된 DAO 클래스에 따라 DetailPlan에 대한 VO, DAO 클래스도 쉽게 작성할 수 있었다.

 

PlanDao 리팩토링 · JinHoooooou/Planner@316e1be

JinHoooooou committed Feb 29, 2024

github.com

커밋 메시지 잘못 작성함...

 


 

# 간단 회고

지금 블로그 글을 작성하면서 가장 크게 후회하는 것은 역시 commit 메시지 관련이다.

  • 팀원들이 git, github의 기본 flow? (코드 작성 → git add → git commit으로 커밋 메시지 작성 → git push로 remote에 push)도 이해하기 어려워 했어서 branch를 구분하여 작업하고 PR을 통해 Merge하는 방식을 알려주면 과부하가 올 것 같았다.
  • 그래서 organization에 Repo를 하나 생성하고 각 개인 Repo로 fork하게 했고, fork한 Repo에서 작업 후 push하여 main 브랜치에서 main 브랜치로의 PR을 하도록 했다.
  • 문제는 팀원들이 commit 메시지를 전혀 작성하지 않아서 팀장인 나의 입장에서는 이해하기가 매우 어려웠다. 그렇다고 팀원들한테 "commit 메시지 다시 작성해서 PR 주세요"라고 하기에도 좀 그래서 그냥 '내가 감수하자'라는 마인드로 일일이 다 보기로 했다.
  • 지금 시간이 지나고 나서 커밋들을 보는데 가관이다 ㅋㅋ... 물론 나도 이상하게 작성한게 많긴 하다. 다음 파이널 프로젝트 때는 이런것을 미리 정하고 하는게 나을 것 같다..

 

# 개요

학원 다니면서 개인 프로젝트로 플래너 개발을 해보기로 했다.

처음에는 학원에서 배운 내용 + 내가 알고 있었는데 잊었던 것들 + 이번 개인 프로젝트로 새로 배우고 싶었던 것들 세 가지를 적절히 섞어 구현할 예정이다.

세미 프로젝트 주제로 내 개인 프로젝트가 채택됐다.

 


 

# 세미 팀 프로젝트

강사님이 팀을 짜주었고, 우리 팀이 모이자마자 "그래도 해본 사람이 조장을 하는게 낫지 않겠어요?"시전으로 팀장을 맡게 되었다;

주제 의견을 회의했었는데, 내가 혼자 진행하던 플래너를 팀 프로젝트로 하는게 어떻냐는 의견을 냈는데 (사실은 주제 생각하기 귀찮아서;;) 그러기로 했다.

 


 

# 유사 어플리케이션 조사

 

Focus To-Do: 뽀모도로 타이머 + 업무 관리

⏱ 포모도로 타이머 ✅ 업무 관리자 📊타임 트래커 📅 스케쥴 플래너 🔔 알림

chromewebstore.google.com

유사 어플리케이션은 많았지만 초기에 내가 계획했던 뽀모도로 타이머를 기준으로 잡기로 했다. 가장 큰 이유는 간단하다고 생각해서.

애초에 나를 제외하고는 다들 개발이 처음이었기 때문에, 뭔가 대단한걸 만드는 것 보다는 간단한 프로젝트를 통해서 성취감을 느끼는 것이 중요하다고 생각했다.

 


 

# 팀 개발 방향성(?)

내가 팀장이고, 개발 프로세스 방향을 생각 해 봤을 때 진도에 맞춰서 진행하는게 낫겠다고 생각했다.

당연히 웹 어플리케이션을 만들 것이지만, 서블릿 및 JSP 관련 진도가 안나갔기 때문에 지금까지 배워 온 데이터베이스 연결(JDBC), Html/Css/JavaScript 부분을 다 같이 하고 서버와의 연결은 내가 맡아서 했다.

설계 및 개발 과정에서도 최대한 팀원들의 의견을 따르려고 했다.

 

## 테이블 설계 및 DDL 작성

내가 처음 계획했던 것은 간단하게 Plan과 User에 대한 테이블만 있으면 된다고 생각했는데, 회의를 하다보니 조금 확장 되었다.

회의를 통해 처음 나온 테이블 및 attribute(속성) 정보이다. 몇몇 속성은 당장 필요하지 않은 것인데도 불구하고 넣었다. 회의를 하는데 계속 당장 필요하고 필수적인 속성을 얘기하지 않고 이후의 부수적인 속성 얘기가 계속 나왔다. 지금 당장 필요한 것은 아니고 설계는 언제든지 변할 수 있으니 지금 당장 필요한 것만 기입하고 DDL을 작성하자고 얘기했는데 잘 받아들이지 못한 것 같다. 그래서 어쩔 수 없이 일단 다 넣었다.

-- ORACLE

CREATE TABLE USERS (
    USERNO NUMBER PRIMARY KEY,
    USERID VARCHAR2(20) UNIQUE NOT NULL,
    USERPW VARCHAR2(20) NOT NULL,
    USERNAME VARCHAR2(20) NOT NULL,
    NICKNAME VARCHAR2(20) NOT NULL UNIQUE,
    EMAIL VARCHAR2(30) NOT NULL,
    PHONE VARCHAR2(15),
    SSN VARCHAR2(30) UNIQUE NOT NULL,
    ADDRESS VARCHAR2(100) NOT NULL,
    INTEREST VARCHAR2(500),
    ENROLLDATE DATE);
    
CREATE SEQUENCE SEQ_USER
NOCACHE;

CREATE TABLE PLANNER (
    PLANNO NUMBER PRIMARY KEY,
    WRITERNO NUMBER REFERENCES USERS ON DELETE CASCADE,
    TITLE VARCHAR2(30) NOT NULL,
    CREATEDATE DATE,
    STARTDATE DATE,
    ENDDATE DATE,
    REMINDALARM DATE,
    REPEATTASK_YN CHAR(3) DEFAULT 'N' CHECK(REPEATTASK_YN IN ('Y', 'N')),
    COMPLETE_YN CHAR(3) CHECK(COMPLETE_YN IN ('Y', 'N'))
);

CREATE SEQUENCE SEQ_PLAN
NOCACHE;

CREATE TABLE DETAIL (
    DETAILNO NUMBER PRIMARY KEY,
    PLANNO NUMBER REFERENCES PLANNER ON DELETE CASCADE,
    DETATILNAME VARCHAR2(30),
    CONTENTS VARCHAR2(50),
    STARTTIME DATE,
    ENTTIME DATE,
    ALARM DATE,
    REPEAT_YN CHAR(3) DEFAULT 'N' CHECK(REPEATTASK_YN IN ('Y', 'N')),
    PRIORITY NUMBER DEFAULT 1 CHECK(PLAN_PRIORITY IN (1,2,3)),
    COMPLETE_YN CHAR(3) CHECK(COMPLETE_YN IN ('Y', 'N'))
);    
    
CREATE SEQUENCE SEQ_DETAIL
NOCACHE;

 


 

# git, github, gradle 알려주기

git에 대한 지식을 알고 있어야 팀 프로젝트를 원활하게 진행할 수 있기 때문에 가르치려고 했다.

또한, 빌드 및 라이브러리 관리를 gradle로 할 계획이어서 이것 또한 알려줘야했다. 그동안 학원에서 배운 것은 직접 jar파일을 설치해서 build path에 등록하는 방식으로 라이브러리를 추가했었는데, 너무 불편하다고 느꼈기 때문에 gradle를 사용하려고 했다.

하지만 문제가 있었는데, 나는 intelliJ를 쓰고 조원들은 eclipse를 썼다. 나는 eclipse가 익숙하지 않아서 가르치기가 어려웠다.

사실 처음부터 CLI로 배우면 어느 IDE를 쓰던 잘 쓸 수 있었을텐데 팀원들이 CLI랑 친하지 않았다.. 이 기회에 친해졌으면 그냥 좋았을텐데 되게 어려워해서 어쩔 수 없이 내가 eclipse에 맞춰 알려주기로 했다.

나름 정리하며 알려주려고 노력했는데, 잘 되었는지는 모르겠다 ㅋㅋ... 아래 링크는 내가 가르치기 위해 정리한 내용이다.

 

 

Task | Notion

Git

tin-digit-d17.notion.site

 

커밋, push, PR 과정이랑 -부분 +부분 어떻게 다른지, 어떻게 하면 충돌이나고 해결할 수 있는지 알려주려고 여러 테스트 커밋을 했었다 ㅋㅋ...


 

# 간단 회고

팀 프로젝트 과정 글 쓰는 시점은 팀 프로젝트가 끝난 이후인데, 역시 미리 작성하지 않으니 그 때의 기억이 잘 나지 않는다.

상세하게 까진 아니더라도 메모하는 습관을 들여야겠다 ㅜ

근데 위에 정리한 git, gradle 관련 노션 글을 보니 "열심히는 했다"라고 느껴지긴 한다 ㅋㅋ... 설명은 잘 못했지만..

 

# 개요

JSTL 라이브러리 추가하는 과정에서의 삽질하다가 발견한 Tomcat 9와 Tomcat 10 차이 정리

JSTL에 대한 정리가 아니다!!!

 


 

# 선 결론

그냥 Tomcat 버전 문제였다.

수업에서는 Tomcat 9 버전을 썼는데, 나는 Tomcat 10 버전을 썼다.

하필 Tomcat 9와 10의 차이가 Java EE 패키지에서 Jakarta EE 패키지로 바뀌고, 그에 따라 추가해야하는 JSTL 라이브러리도 바뀐 것이다

그리고 그걸 몰랐어서 계속 삽질했던 것.....

 


 

# JSTL 라이브러리 추가

Tomcat 9

// Gradle
implementation 'jstl:jstl:1.2'

그냥 얘 추가하면 된다.

 

Tomcat 10

세 가지를 추가해야한다

// Gradle
implementation 'jakarta.servlet:jakarta.servlet-api' 
implementation 'jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api'
implementation 'org.glassfish.web:jakarta.servlet.jsp.jstl'

 


 

# Tomcat 9와 Tomcat 10의 차이

자세하게는 모르지만 삽질하는 과정에서 찾아본 내용만 정리한다.

일단 Tomcat 공식 페이지에서도 친절하게 알려주기는 한다;

대충 보면 javax.*에서 jakarta.*로 변경되었다. migration tool이 있다. 라는 내용을 볼 수 있다... 이걸 몰라서 그렇게 삽질했던것;

참고로 공식 페이지에 가면 Tomcat 버전별로 서블릿, JSP, EL 등의 스펙이 나와있다


 

# 결론

Tomcat 9 버전을 사용한다면 대부분의 블로그를 따라하면 될 듯 하다. 검색해본 결과 대부분이 JavaEE를 사용했다.

근데 Tomcat 10 버전을 사용하는 것이 더 좋을 것 같다는 생각이 들었다. 내 노트북에서는 JDK 17을 사용할 것이고, 이후에 스프링부트도 3.0버전 이상을 사용하게 될텐데 JakartaEE에 조금 더 익숙해져야 할 것 같다.

 


 

# Reference

 

members.jsp에서 jstl를 사용하려고 하면 500번에러가 .. ㅠ - 인프런

학습하는 분들께 도움이 되고, 더 좋은 답변을 드릴 수 있도록 질문전에 다음을 꼭 확인해주세요.1. 강의 내용과 관련된 질문을 남겨주세요.2. 인프런의 질문 게시판과 자주 하는 질문(링크)을 먼

www.inflearn.com

 

 

스프링부트 3 에서 jstl 사용 시 에러 해결

스프링부트 3을 이용하여 jsp와 jstl을 이용할때 아래와 같은 에러가 뜨는 경우가 있다. ERROR 7332 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[.[mvcMemberListServlet] : Servlet.service() for servlet [mvcMemberListServlet] in context with

dev-n-life.tistory.com

 

 

[JSP] Maven jakarta JSTL 적용 (Java EE에서 Jakarta EE로)

https://velog.io/@gmg0521/JSP-jakarta-JSTL-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95-Java-EE%EC%97%90%EC%84%9C-Jakarta-EE%EB%A1%9C

velog.io

 

 

Tomcat 9 vs 10 - 𝝅번째 알파카의 개발 낙서장

필자는 Jersey 3 라이브러리를 활용하여 API 서버를 구성하고 있다. 코드를 작성하고 올바른 URL을 호출했음에도, 404 오류가 계속 출력됐다. 간단한 테스트 서블릿을 작성하고, 공식 문서와 아무리

blog.itcode.dev

 

'Java > Tomcat' 카테고리의 다른 글

[Tomcat] IntelliJ(Ultimate) 외장 톰캣 연동 과정  (0) 2024.04.04

# 개요

내장 톰캣만 쓰다가 외장 톰캣 설치해서 연동해보았는데 생각보다 삽질을 많이했다.

삽질한 과정 정리

 


 

# 톰캣 설치

 !! 중요: 설치할 때 버전 확인을 잘 해야한다. 버전에 따라 Servlet, JSTL, EL, JDK 버전이 다르기 때문에 내가 어떤 버전의 톰캣을 사용할 지 알고있어야 한다. ("Which version?"에 나와있다.)

나는 Tomcat 9를 사용했다.

  • Tomcat 10 부터는 Java EE가 아닌 Jakarta EE 패키지를 사용하기 때문에 이식성이 높지 않다고 생각했다.
  • 물론 Migration Tool이 있다고는 하지만 Migration Tool 관련 문서를 참고할 시간에 그냥 Tomcat 9 쓰는게 낫다고 판단했다.

설치 과정은 그냥 zip 파일 다운 받아서 압축 해제 하면 된다.

 


 

# intelliJ 프로젝트 생성

intelliJ 버전: 2020.03.1

## File → new → project → Java Enterprise

  • Project template는 Web application, REST service, Library가 있는데 Web application을 선택, 다른 template은 사용해보지 않았다.
  • Application Server는 설치한 Tomcat의 경로로 설정해주면 된다.
  • Build tool은 더 익숙하기도 하고 편하기도 한 Gradle 선택

 

## Java EE (Jakarta EE), Libraries and Framework

Tomcat 9를 사용하기 때문에 Jakarta EE가 아닌 Java EE를 선택했고, 간단하게 톰캣 연동과 마이바티스만 추가할 것 이기 때문에 별도의 라이브러리나 프레임워크는 선택하지 않았다. (Servlet은 기본 선택인듯 하다)

이후 Name, Group, Location 등 설정 후 Finish

 


 

# Tomcat 설정

프로젝트 빌드가 성공적으로 된다면 build.gradle, Servlet, jsp 파일이 생성되어 있을 것이다.

나는 이전에 톰캣 Configuration을 추가해서 저렇게 있지만 없다면 직접 추가하면 된다.

Edit configurations 선택

 

## 톰캣 Run/Debug Configuration 추가, 설정

Tomcat Server → Local 선택

처음 선택하면 아래쪽에 "Warning: No artifacts marked for deplyment"가 있다. artifact를 추가해주기 위해 Deployment 탭으로 이동한다.

Artifact 선택

war 와 war (exploded)가 있다.

war (Archive)

  • 아카이브(.war, .ear) 파일로 배포
  • WAS(Tomcat)에 의해 압축이 풀린다.
  • 파일이 많은 경우 압축을 푸는 시간이 오래 걸릴 수 있다.
  • 원격 서버에 배포시 하나의 파일만 전송하면 된다.
  • WAS에서 제공하는 업로드를 통한 배포가 가능하다.

exploded (Expended)

  • 아카이브를 압축 해제한 형태의 디렉토리로 배포
  • 별도의 디렉토리에 원본 소스를 복사하여 만든다. 
  • 압축 및 해제 과정이 불필요하다.
  • 원본 소스를 건드리지 않고 배포하는 경우에 적합하다.

나는 exploded를 선택했다.

Application context가 길게 쓰여있는데 그대로 사용해도 되지만 나는 / (root)로 변경 후 설정 완료했다.

 


 

# 서버 실행

이제 서버를 실행 해보자

실행해보면 로그가 쭉 뜨다가 완료되었다고 뜬다.

근데 이상하다. 보통 다른 블로그 참조했을 때는 실행이 완료되었다고 뜨면서 자동으로 브라우저가 실행되던데.. 브라우저가 실행되지도 않고 밑의 창을 자세히 보면 주황색 X 아이콘이 보이고 로그도 not found for the web module이라고 뜬다. 뭔가 잘못 된 것 같다.

localhost:8080으로 접속해봐도 index.jsp가 나오지 않고 404페이지가 나온다.

 


 

# Artifact를 다시 한 번 보자

연필모양 눌러보면 artifacts settings 창이 나온다

저 경로를 자세히 보면 project/build/libs/exploded/project~~~.war로 되어있다. 

근데 실제 경로를 확인해보면 libs디렉토리 내에 exploded라는 디렉토리는 없고 바로 project~~~.war만 있다. 그러므로 libs뒤에 있는 exploded를 지워준다.

 


 

# 수정 후 서버 다시 실행

not found the web module이라는 로그도 안보이고, 브라우저도 자동으로 실행된다.

 


 

# Artifact를 exploded로 선택한 이유

exploded가 아니라 war를 선택한다면 위 설정(경로에 exploded 빼는 것)할 필요없이 잘 된다. 근데 exploded를 선택한 이유는

바로 요놈 때문이다. 

자바 코드가 변경되면 (클래스, 패키지,  라이브러리 등) 서버를 재시작 해야한다.

이것은 내장톰캣 쓸때도 마찬가지였기 때문에 상관 없는데

문제는 resource(html, jsp, css 등)도 바로 적용이 안된다는 것이다. 내장 톰캣 쓸 때는 바로 적용이 되었는데.. 외장 톰캣을 쓰니 바로 적용이 안되어서 매우 불편했다.

찾아보니 exploded로 deployment 설정을 해야 저 on update → update classes and resources가 생긴다고 하더라.

그래서 exploded를 사용했다.

 


 

# 후기(?)

그냥 내장 톰캣 쓰는게 나을 것 같다 ㅋㅋ;; 어짜피 스프링부트도 내장 톰캣 사용하는데.. 스프링은 아닌가?

 


 

# Reference

 

IntelliJ + Apache tomcat 연동 및 설정

IntelliJ(인텔리제이) 다운로드 링크https://www.jetbrains.com/ko-kr/idea/download/?section=windowsApache tomcat(아파치 톰캣) 다운로드 링크 zip 파일로 다운https://tomcat.

velog.io

 

 

금주의 실패사례 - IntelliJ 버전 업 후 Tomcat에서 exploded artifact 실종 « Personal Tech Note

IntelliJ에서 exploded된 artifact를 못찾는 문제 무슨 바람이 불었을까? 한참 잘 쓰던 IntelliJ를 2020.1.1로 업그레이드 했다. 이것 저것 좀 더 좋아지려나 했더니, 왠걸.. 잘되던 Tomcat 실행만 안된다. 잘 되

mystria.github.io

 

[Intellij] 웹 어플리케이션 배포 위한 패키지 유형과 war, war(exploaed) 차이

tomcat 으로 war 파일을 배포할 때 궁금증이 생겼다. war와 war(exploaed)의 차이가 무엇일까? 빌드(Build) : 소스코드 파일을 실행 가능한 소프트웨어 산출물로 만드는 일련의 과정을 말한다. Maven에서 Arti

velog.io

'Java > Tomcat' 카테고리의 다른 글

[Tomcat] JSTL 라이브러리 추가 (부제: Tomcat 9 vs 10)  (0) 2024.04.04

# 개요

톰캣을 설치해서 사용할 때는 바로 jsp 파일을 읽을 수 있었는데

내장 톰캣을 사용하니 jsp 파일을 읽을 수가 없다.

JSP를 서블릿으로 변환시켜주는 Jasper에 대해 알아보자.

 


 

# JSP에 대한 오해

JSP가 여러모로 불편하기 때문에 안써왔어서(물론 앞으로도 쓸 생각은 별로 없음..) 잘 몰랐는데, 나는 여태 JSP가 HTML처럼 그냥 화면에 보여주는 줄 알았다.

그런데 그게 아니고, JSP 파일을 컴파일 해서 PrintWriter같은 클래스를 이용해서 HTML처럼 보이게 출력해주는 것이었다.

 

## 자바로 HTML 그리기

기존에 자바로 HTML을 그릴 때 방식을 보면

import java.io.*;

public class HTMLPrinter {
    public static void main(String[] args) {
        String fileName = "output.html"; // HTML 파일명

        try {
            PrintWriter writer = new PrintWriter(new FileWriter(fileName));

            // HTML 페이지 시작
            writer.println("<!DOCTYPE html>");
            writer.println("<html>");
            writer.println("<head>");
            writer.println("<title>간단한 HTML 페이지</title>");
            writer.println("</head>");
            writer.println("<body>");
            writer.println("<h1>Hello, World!</h1>");
            writer.println("<p>This is a simple HTML page generated using Java.</p>");
            writer.println("</body>");
            writer.println("</html>");

            // 파일 닫기
            writer.close();

            System.out.println("HTML 파일이 생성되었습니다. 파일명: " + fileName);
        } catch (IOException e) {
            System.out.println("파일 생성 중 오류가 발생했습니다: " + e.getMessage());
        }
    }
}

이런 방식으로 일일이 자바 코드 내에서 HTML태그를 사용해야 했었다. 그리고  HTML태그 String에 자바에서 사용하는 변수나 객체를 추가하는 방식으로 사용했다.

하지만 이런 방식은 매우 번거롭고 불편하다.

 

## HTML to JSP

정적 페이지인 HTML에 자바 데이터를 동적으로 사용하기 위해 JSP가 만들어졌다. 사용 방법도 html 확장자를 jsp로 바꿔 주고 스크립트릿만 추가해주면 된다.

그러나 JSP는 자바코드에 의한 동적인 페이지가 되었기 때문에 이를 컴파일을 해주어야 한다.

 


 

# Jasper

Jasper는 JSP파일을 서블릿 코드로 변환하고 컴파일 하여 HTML 파일처럼 실행하는 톰캣의 JSP 엔진이다.

JSP파일이 그냥 화면에 HTML처럼 보여지는 것이 아니라 톰캣의 Jasper를 통해 서블릿 코드로 변환하고 이를 HTML로 그려주는 것이다.

 


 

# 결론

톰캣을 설치하면 Jasper가 같이 설치되어 있기 때문에 별 신경쓰지 않고 바로 JSP를 읽을 수 있지만, Embedded Tomcat의 경우는 그렇지 않기 때문에 라이브러리를 따로 추가해야 한다.

 


 

# Reference

 

JSP를 서블릿으로 변환시켜주는 Jasper

JSP(Java Server Pages)는 HTML에 Java코드를 편리하게 적을 수 있도록 도와줍니다. 그런데 HTML에 Java코드를 적다 보니 많은 분들께서 오해하는 것이 있습니다. 바로 브라우저의 화면에 띄워지는 것이 JSP

thisisnew-storage.tistory.com

 

'Java > Java' 카테고리의 다른 글

[Java] Map 사용법(1) - Map 정렬  (0) 2020.11.17
[Java] Time 패키지 (LocalTime, LocalDate, LocalDateTime)  (0) 2020.11.12
[Java] Date, Calendar 클래스  (0) 2020.10.31

+ Recent posts