728x90

아니 뭐 그래.. 이걸 가지고 생색낼 건 아니다만.. 그래도 짜는데 얼마 안걸렸어?

다만 이게 대용량 트래픽 고려하고 그럴꺼 같으면 캐싱도 고려하고 그럴려면 Redis 도 넣고 이것저것 해야했단 말이야?

 

근데 In-memory DB 하라그래서 일단 H2 하긴 했는데 Redis 를 언제 하냐고.. 그래서 한 3~4시간만에 구현하고 사전과제는 제출했는데 ... 아 카카오뱅크 사전과제 너란녀석..

 

그래 카뱅은 안녕하는거로 하자

'Java > Spring Boot JPA' 카테고리의 다른 글

Spring Boot에서 JPA 사용하기  (0) 2023.05.15
JAVA CLASS 버전차이에 의한 오류 및 해결  (0) 2023.05.15
Spring JWT (Json Web Token)  (0) 2022.08.15
Hibernate vs JPA vs Spring Data JPA  (0) 2022.06.07
JPA의 DTO와 Entity  (0) 2022.01.10
728x90

개발 스펙

  • Java(11)
  • Amazon Corretto JDK(11)
  • Spring Boot(2.5.3)
  • Spring Security(boot-starer)
  • JWT(0.9.1)

Config

1. Dependency 추가

Spring Security와 JWT의 Dependency 추가

dependencies {
	...
    
	// Spring Security
    implementation "org.springframework.boot:spring-boot-starter-security"
    // Spring Security Test
	testImplementation 'org.springframework.security:spring-security-test'
    // JWT
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    
	...
}

2. 정적 자원 제공 클래스 생성

정적 자원을 제공하는 클래스를 생성하여 아래와 같이 설정한다.

@Configuration 
public class WebMvcConfig implements WebMvcConfigurer {

    private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
            "classpath:/static/",
            "classpath:/public/",
            "classpath:/",
            "classpath:/resources/",
            "classpath:/META-INF/resources/",
            "classpath:/META-INF/resources/webjars/"
    };

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 경로에 해당하는 url을 forword
//        registry.addViewController("/loginPage").setViewName("login");
        // 우선순위를 가장 높게 잡는다.
//        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 정적 자원의 경로를 허용
        registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
    }

}

3. SpringSecurity 설정

스프링 시큐리티의 웹 보안 기능을 초기화 및 설정

WebSecurityConfigurerAdapter를 상속받아 HttpSecurity를 이용해 설정한다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // JWT 제공 클래스
    private final JwtProvider jwtProvider;
    // 인증 실패 또는 인증헤더가 전달받지 못했을때 핸들러
    private final AuthenticationEntryPoint authenticationEntryPoint;
    // 인증 성공 핸들러
    private final AuthenticationSuccessHandler authenticationSuccessHandler;
    // 인증 실패 핸들러
    private final AuthenticationFailureHandler authenticationFailureHandler;
    // 인가 실패 핸들러
    private final AccessDeniedHandler accessDeniedHandler;

    /**
     * Security 적용 무시
     */
    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                .mvcMatchers("/docs/**");
    }

    /**
     * 보안 기능 초기화 및 설정
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        final String[] GET_WHITELIST = new String[]{
                "/login",
                "/user/login-id/**",
                "/user/email/**",
                "/affiliate"
        };

        final String[] POST_WHITELIST = new String[]{
                "/client-user"
        };

        http.csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint) // 인증 실패
                .accessDeniedHandler(accessDeniedHandler) // 인가 실패
                .and().authorizeRequests()
                .antMatchers(HttpMethod.GET, GET_WHITELIST).permitAll() // 해당 GET URL은 모두 허용
                .antMatchers(HttpMethod.POST, POST_WHITELIST).permitAll() // 해당 POST URL은 모두 허용
                .antMatchers("/client-user/**").hasAnyRole(UserType.CL.getRoll()) // 권한 적용
                .anyRequest().authenticated() // 나머지 요청에 대해서는 인증을 요구
                .and() // 로그인하는 경우에 대해 설정함
                .formLogin().disable() // 로그인 페이지 사용 안함
//                .loginPage("/user/loginView") // 로그인 성공 URL을 설정함
//                .successForwardUrl("/index") // 로그인 실패 URL을 설정함
//                .failureForwardUrl("/index").permitAll()
                .addFilterBefore(authenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    /**
     * 사용자 요청 정보로 UserPasswordAuthenticationToken 발급하는 필터
     */
    @Bean
    public CustomAuthenticationFilter authenticationFilter() throws Exception {
        CustomAuthenticationFilter customAuthenticationFilter = new CustomAuthenticationFilter(authenticationManager());
        // 필터 URL 설정
        customAuthenticationFilter.setFilterProcessesUrl("/login");
        // 인증 성공 핸들러
        customAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        // 인증 실패 핸들러
        customAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
        // BeanFactory에 의해 모든 property가 설정되고 난 뒤 실행
        customAuthenticationFilter.afterPropertiesSet();
        return customAuthenticationFilter;
    }

    /**
     * JWT의 인증 및 권한을 확인하는 필터
     */
    @Bean
    public JwtFilter jwtFilter() {
        return new JwtFilter(jwtProvider);
    }

}

회원 인증

1. UsernamePasswordAuthenticationFilter 구현

사용자 요청 정보로 UserPasswordAuthenticationToken 발급 후 AuthenticationManager에게 전달하고 AuthenticationProvider의 인증 메서드를 실행하는 UsernamePasswordAuthenticationFilter를 구현

public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        final UsernamePasswordAuthenticationToken authRequest;

        final LoginDTO loginDTO;
        try {
            // 사용자 요청 정보로 UserPasswordAuthenticationToken 발급
            loginDTO = new ObjectMapper().readValue(request.getInputStream(), LoginDTO.class);
            authRequest = new UsernamePasswordAuthenticationToken(loginDTO.getLogin(), loginDTO.getPass());
        } catch (IOException e) {
            throw new NotValidException();
        }
        setDetails(request, authRequest);

        // AuthenticationManager에게 전달 -> AuthenticationProvider의 인증 메서드 실행
        return this.getAuthenticationManager().authenticate(authRequest);
    }

}

2. AuthenticationProvider 구현

AuthenticationManager 하위에 실제로 인증을 처리할 AuthenticationProvider를 구현

@Component
@RequiredArgsConstructor
public class AuthenticationProviderImpl implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    /**
     * 인증 구현
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 전달 받은 UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;

        // AuthenticaionFilter에서 생성된 토큰으로부터 아이디와 비밀번호를 추출
        String username = token.getName();
        String password = (String) token.getCredentials();
        // 해당 회원 Database 조회
        UserDetailsImpl userDetail = (UserDetailsImpl) userDetailsService.loadUserByUsername(username);

        // 비밀번호 확인
        if (!passwordEncoder.matches(password, userDetail.getPassword()))
            throw new BadCredentialsException(userDetail.getUsername() + "Invalid password");

        // 인증 성공 시 UsernamePasswordAuthenticationToken 반환
        return new UsernamePasswordAuthenticationToken(userDetail.getUsername(), "", userDetail.getAuthorities());
    }

    /**
     * provider의 동작 여부를 설정
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

3. UserDetailsService 구현

인증 과정 중 실제 Database에 회원을 데이터를 조회하는UserDetailsService를 구현

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findFirstUserByLoginOrderByIdAsc(username).orElseThrow(() -> new NotFoundDataException("User"));
        return new UserDetailsImpl(
                user.getLogin(),
                user.getPass(),
                user.getEmail(),
                Collections.singleton(new SimpleGrantedAuthority("ROLE_" + user.getType().getRoll()))
        );
    }

}

4. UserDetails 구현

회원 데이터를 조회하고 해당 정보와 권한을 저장하는 UserDetails를 구현

@AllArgsConstructor
@Getter
@ToString
public class UserDetailsImpl implements UserDetails {

    private final String username;
    private final String password;
    private final String email;
    private final Collection<? extends GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

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

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

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

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

5. PasswordEncoder 구현

기본적으로 제공해주는 PasswordEncoder를 구현한 클래스를 사용할 경우 Bean 등록만 하면된다.  
(BCryptPasswordEncoder OR DelegatingPasswordEncoder)

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

 

 

Spring Password Encoder

Spring에서는 인증/권한인가 등의 처리가 필요할 때 사용하라고 만든 Spring Security 패키지가 존재한다. 그 중 유저가 입력하는 Password를 암호화해서 저장하는 방법에 대해서 알아보자 아, 그 전에

gompangs.tistory.com

 

다른 로직의 비밀번호 인증이 필요할 경우는 PasswordEncoder를 구현해서 사용한다.

@Component
public class PasswordEncoderImpl implements PasswordEncoder {

    /**
     * 비밀번호 해시
     */
    @Override
    public String encode(CharSequence rawPassword) {
        return passwordEncode((String) rawPassword); // 커스텀 메서드
    }

    /**
     * 비밀번호 확인
     */
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return passwordMatches((String) rawPassword, encodedPassword); // 커스텀 메서드
    }

}

6. AuthenticationSuccessHandler 구현

인증 성공 시 핸들링하는 AuthenticationSuccessHandler를 구현

@Component
@RequiredArgsConstructor
public class AuthenticationSuccessHandlerImpl implements AuthenticationSuccessHandler {

    private final JwtProvider jwtProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        // 전달받은 인증정보 SecurityContextHolder에 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // JWT Token 발급
        final String token = jwtProvider.generateJwtToken(authentication);
        // Response
        ApiResponse.token(response, token);
    }

}

7. AuthenticationFailureHandler 구현

인증 실패 시 핸들링하는 AuthenticationFailureHandler를 구현

@Component
public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        ApiResponse.error(response, ApiResponseType.UNAUTHORIZED_RESPONSE);
    }

}

JWT 인증

1. JwtProvider 생성

JWT를 발급하고 확인하는 클래스 생성

@Component
@RequiredArgsConstructor
public final class JwtProvider {

    private final UserDetailsService userDetailsService;

    // secret key
    @Value("${jwt.secret-key}")
    private String secretKey;

    // access token 유효시간
    private final long accessTokenValidTime = 2 * 60 * 60 * 1000L;

    // refresh token 유효시간
    private final long refreshTokenValidTime = 2 * 7 * 24 * 60 * 60 * 1000L;

    @PostConstruct
    private void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    /**
     * 토큰에서 Claim 추출
     */
    private Claims getClaimsFormToken(String token) {
        return Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(secretKey)).parseClaimsJws(token).getBody();
    }

    /**
     * 토큰에서 인증 subject 추출
     */
    private String getSubject(String token) {
        return getClaimsFormToken(token).getSubject();
    }

    /**
     * 토큰에서 인증 정보 추출
     */
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getSubject(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    /**
     * 토큰 발급
     */
    public String generateJwtToken(Authentication authentication) {
        Claims claims = Jwts.claims().setSubject(String.valueOf(authentication.getPrincipal()));
        claims.put("roles", authentication.getAuthorities());
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessTokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    /**
     * 토큰 검증
     */
    public boolean isValidToken(String token) {
        try {
            Claims claims = getClaimsFormToken(token);
            return !claims.getExpiration().before(new Date());
        } catch (JwtException | NullPointerException exception) {
            return false;
        }
    }

}

2. JwtFilter 생성

Header를 통해 JWT의 인증 요청이 왔을때 처리하는 Filter 생성

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final JwtProvider jwtProvider;

    /**
     * 토큰 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {

        // Request Header에서 토큰 추출
        String jwt = resolveToken(request);

        // Token 유효성 검사
        if (StringUtils.hasText(jwt) && jwtProvider.isValidToken(jwt)) {
            // 토큰으로 인증 정보를 추출
            Authentication authentication = jwtProvider.getAuthentication(jwt);
            // SecurityContext에 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    /**
     * Request Header에서 토큰 추출
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }
    
}

3. AuthenticationEntryPoint 구현

토큰 인증이 실패하거나 인증 헤더를 정상적으로 받지 못했을때 핸들링하는 AuthenticationEntryPoint를 구현

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ApiResponse.error(response, ApiResponseType.UNAUTHORIZED_RESPONSE);
    }

}

4. AccessDeniedHandler 구현

토큰 인가 실패 시 핸들링하는 AccessDeniedHandler를 구현

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ApiResponse.error(response, ApiResponseType.FORBIDDEN_RESPONSE);
    }

}

Flowchart


전체 소스는 github에서 확인하실 수 있습니다.

 

GitHub - devbeekei/SpringSecurityJwt: SpringSecurity + JWT example

SpringSecurity + JWT example. Contribute to devbeekei/SpringSecurityJwt development by creating an account on GitHub.

github.com

 

728x90

Hello everyone. In this article, we will see the differences between JPA, Hibernate, and Spring Data JPA.

ORM Frameworks

ORM Stands for Object-Relational Mapping that maps the data in the database to the Java Class which is called an Entity. Not only that, but ORM also preserves the relationship between the tables at the Entity level.

There are many ORM frameworks in the market and the famous ones for Java are

  1. Hibernate
  2. EclipseLink
  3. iBATIS
 

What is Hibernate?

As we all know, Hibernate is an ORM framework that sits between the application and the database. Before Hibernate, developers used to write queries using JDBC and retrieve the data and manually set it to the DTO Objects and send it to the Front End. This was time-consuming and painful.

So Hibernate is a framework in the ORM layer that maps the relational data to the Java Objects. It also provides an abstraction to the developers so that they don't need to worry about the data source. It also provides configuration options to configure the data store and developers can also write queries with Hibernate

Features of Hibernate

1. Light Weight

2. Open Source

3. ORM (Object Relation Mapping)

4. High Performance

5. HQL (Hibernate Query Language)

6. Caching

7. Auto-Generation

8. Scalability

9. Lazy Loading

10. Database Independent

Lets us see only a few important features below

 

Hibernate Query Language (HQL)

SQL is low-level programming where developers have to query for the database columns in a database table. But HQL is simplified for developers in such a way that the Java class names and attributes are used in the query. Internally, hibernate converts HQL into SQL and executes it in the database.

Hibernate also supports native SQL queries along with the HQL but it is recommended to use HQL as it is independent of the underlying database. Whereas if we write SQL, the syntax differs from database to database

The Query interface provides object-oriented methods and capabilities for representing and manipulating HQL queries.

Example of an HQL:

String queryStr = "SELECT e.name FROM Employee e";
Query query = session.createQuery(queryStr);
List results = query.list();

The attribute name is selected from the Entity Employee

Lazy Loading in Hibernate

Hibernate supports the following loading patterns

  • Eager Loading is a design pattern in which data initialization occurs on the spot.
  • Lazy Loading is a design pattern that we use to defer the initialization of an object as long as it’s possible and load only on demand

Lazy Loading is the default one and it makes the application efficient by not loading all the data and exhausting the DB Connection pool.

Eg: If a table has 1 million records and the relationship tables have another 1 million records. In case of lazy loading, it will only load the main table and only if requested, it will load data from the child tables.

Caching in Hibernate

Caching is the process of storing data into cache memory and improves the speed of data access.

Hibernate supports two levels of caching, first-level and second-level caching.

First Level Cache

The first level cache is a session-level cache and it is always associated with session-level object

Second Level Cache

Second-level cache is the session factory level cache and it is available across all sessions. For a second-level cache, we have to enable the cache and provide a cache provider like Ehcache and add its dependency.

Configuration

hibernate.cache.use_second_level_cache=true hibernate.cache.region.factory_class=org.hibernate.cache.ehcache.EhCacheRegionFactory
 

What is JPA?

JPA stands for Java Persistence API and it is the Java specification that defines how to persist java objects. It is considered as a link between an object-oriented model and a relational database system. Hibernate is the standard implementation of JPA. JPA cannot be used alone and it always needs an implementation like Hibernate, EclipseLink, iBatis, etc.

For data persistence, the java. persistence package contains the JPA classes and interfaces.

  1. JPA provides JPQL (Java Persistence Query Language) and HQL provided by Hibernate is a superset of it. Either can be used in the application.

2. JPA provides EntityManagerFactory interface whereas Hibernate provides SessionFactory to create the Session instances

3. For CRUD operations on instances of mapped entity classes, JPA uses EntityManager whereas Hibernate uses the Session interface

So, as seen above JPA provides its in-built stuff so that things won't break if we change to other ORM frameworks later and it will remain consistent.

But Hibernate provides advanced features and if you are sure that you will not change the ORM framework, then it's better to stick to the Hibernate specs.

 

What is Spring Data JPA?

There is always confusion between JPA and Spring Data JPA.

As we saw above, JPA is a standard for defining the persistence layer and Spring Data JPA is a sub-project under the Spring Framework umbrella which allows Spring applications to integrate with JPA.

Spring Data JPA is an abstraction that makes it easier to work with a JPA provider like Hibernate which is used by default. Specifically, Spring Data JPA provides a set of interfaces for easily creating data access repositories.

Before Spring Data JPA, we used to write all the CRUD methods in every single DAO and write an implementation for those. But then came Spring Data JPA, which abstracts the developer from that, and behind the scenes, it provides implementations for the basic crud methods. This avoids a lot of boilerplate code and makes it efficient for developers. We can still add custom methods and can use HQL or criteria etc.

Spring Data JPA also allows developers to use Transactional annotation to control the transaction boundaries.

Spring Data JPA comes with a concept called JPA Repository and Query methods. JPA Repository is nothing but a set of interfaces that defines query methods like findByFirstName or findByLastName etc. These methods are converted into low-level SQL queries by Spring.

Because of this cleaner approach, many Spring-based applications are using Spring Data JPA to implement their Data Access Layer or DAO Layer

The Spring Data Jpa dependency can be added as below and this will do the data source auto-configuration as well. After adding this, we just need to add a database dependency to make sure it is available in the class path.

<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId><version>2.6.1</version></dependency>

Summary

in this article, we saw what is Hibernate, JPA, and Spring Data JPA. In a nutshell, JPA is a specification and we have to use one of its implementations to connect to the database. Spring Data JPA cannot work without a JPA provider and a JPA provider is mandatory to connect with the database. Spring Data JPA is not a mandatory one but it is for adding convenience to developers by removing the boilerplate code

Hope you all have enjoyed this article. Happy learning!!!

Please read my article on Transaction Management if you want to know more

 
728x90

❗️JPA의 DTO와 Entity

이번 시간에는 제가 JPA를 사용하면서 Entity와 DTO의 적절히 사용했던 경험에 대해 알려드리려고 합니다.

보통 회원가입 기능을 만든다고하면, 회원에 관련된 데이터베이스 컬럼과, 회원가입 폼에서 사용하는 컬럼은 대부분 다를 것 입니다.

회원가입의 Entity가 있다고 가정해 보겠습니다. 회원의 관련된 Entity인 User 클래스는 다음과 같습니다.

public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false)
    private String email;

    private String nickname;

    @JsonIgnore
    private String password;

    @Column(columnDefinition="bit(1) default 1")
    private boolean enabled;

    @Column(columnDefinition="bit(1) default 1")
    private boolean acceptOptionalBenefitAlerts;

    private String fcmToken;

    private boolean privateAccount;
}

그러나 회원가입을 진행할 때 클라이언트로부터 받는 데이터는 email과 nickname 그리고 password만 필요합니다. 이 3개의 데이터를 어떻게 받아 올 수 있을까요?

저는 주로 각 기능마다 필요로 하는 필드들을 모은 DTO 클래스를 만들어 받습니다.

예를 들어 회원가입 로직이 들어가있는 API의 형태는 다음과 같습니다.

@PostMapping("/join")
public UserJoinResponse joinUser(@RequestBody @Valid UserJoinRequest userJoinRequest) {
	return userService.joinUser(userJoinRequest);
}

UserJoinRequest의 구성은 다음과 같습니다.

public class UserJoinRequest {
    @NotBlank(message = "이메일을 다시 확인해주세요.")
    String email;

    @NotBlank(message = "비밀번호를 다시 확인해주세요.")
    String password;

    @NotBlank(message = "닉네임을 다시 확인해주세요.")
    String nickname;
}

그러나 Service 단에서, DAO로 데이터를 저장시키기 위해서 이 DTO를 Entity로 변환해주는 작업이 필요합니다.

public UserJoinResponse joinUser(UserJoinRequest userJoinRequest) {
  User user=userRepository.save(user); //??
}

이 작업은 어떤 식으로 진행하는 것이 좋을까요?

UserJoinRequest를 User Entity로 변환해주는 책임은 바로 UserJoinRequest에게 있습니다. 그렇기 때문에 UserJoinRequest가 User로 변환을 해주는 로직이 필요합니다.

public class UserJoinRequest {
    @NotBlank(message = "이메일을 다시 확인해주세요.")
    String email;

    @NotBlank(message = "비밀번호를 다시 확인해주세요.")
    String password;

    @NotBlank(message = "닉네임을 다시 확인해주세요.")
    String nickname;

    public User toEntity() {
        return User.builder()
                .email(email)
                .nickname(nickname)
                .password(password)
                .build();
    }
}

toEntity() 함수에서 User 객체를 만들때는 Builder 패턴을 이용했습니다.

서비스 단에서는 다음과 같이 사용합니다.

public UserJoinResponse joinUser(UserJoinRequest userJoinRequest) {
  User saveUser = userJoinRequest.toEntity();
  User user = userRepository.save(user);
}

정상적으로 User가 데이터베이스에 저장되었다면, 클라이언트에게 보내줄 데이터를 만들어 보겠습니다.

저는 예제로 클라이언트에게 유저의 email을 클라이언트에게 보내보겠습니다. 현재 서비스단의 joinUser() 함수의 리턴 값은 UserJoinResponse 이기 때문에 User를 UserJoinResponse로 변경해주는 작업이 필요합니다.

이 또한 이 변경의 책임 은 누구한테 있는지 고민한다면 User 에게 있을 것입니다. 그러므로 User 클래스 안에 UserJoinResponse 객체를 만들어 주는 함수가 있어야 합니다.

회원가입이 성공적으로 완료되면 클라이언트에게 email 값을 주는 UserJoinResponse 입니다.

public class UserJoinResponse {
    String email;
}

User의 함수는 다음과 같습니다.

public class User {
	...필드 생략
	
	public UserJoinResponse toUserJoinResponse() {
		 return User.UserJoinResponse()
                .email(email)
                .build();
	}
}

지금까지 제가 JPA를 사용하면서 주로 사용했던 내용을 작성했습니다.

저는 위와 같은 방법을 주로 사용하지만, 이렇게 클래스마다 Entity <-> DTO 변환 클래스를 작성하지 않아도, 자동으로 변환해주는 라이브러리가 있어서 소개해 드리려고 합니다.

😯 ModelMapper

ModelMapper 라이브러리를 사용하게 되면 간편하게 Entity <-> DTO 변환이 가능해 집니다.

public UserJoinResponse joinUser(UserJoinRequest userJoinRequest) {
  ModelMapper mm = new ModelMapper(); 
  User user = new User();
  mm.map(userJoinRequest, user);
  User user =	userRepository.save(user);
}

이런 방법도 있지만, 개인적으로 처음 설명해드렸던 방법을 추천합니다.

🧐 Entity vs DTO

이렇게 특정 필드만 받을 수 있게 DTO 클래스를 만들었습니다. 그러나 프로젝트가 커지게 되면 DTO 클래스는 계속 증가합니다.

그냥 Entity 클래스를 받아서 필요한 값만 추출해서 사용하면 될것인데, 왜 굳이 이렇게 DTO 클래스를 새로 만들어서 사용 할까요?

DTO 클래스를 사용하는 이유는 다음과 같습니다.

서비스 단은 데이터베이스와 독립적이어야 합니다. 데이터베이스의 변경사항이 있으면, Entity를 사용하는 서비스 단에도 변경이 일어날 수 있습니다.

또한 Entity 클래스를 DTO로 재사용 하는 일은 코드가 더러워질 수 있습니다. 클래스가 DTO로써 사용 될 때 사용하는 메소드와 Entity로써 사용 될 때 사용하는 메소드가 공존하게 됩니다. 이런 점에서 관심사 가 깔끔하게 분리되지 않고, 클래스의 결합력만 높이게 됩니다.

참고문서

https://stackoverflow.com/questions/5216633/jpa-entities-and-vs-dtos

https://auth0.com/blog/automatically-mapping-dto-to-entity-on-spring-boot-apis/

+ Recent posts