최저가 장바구니 코드 리뷰

hellonayeon·2021년 11월 12일
3
post-thumbnail

Annotation

@MappedSuperclass

공통 정보 매핑

여러 클래스들이 동일한 성질의 멤버 변수를 가져야하는 경우 별도의 클래스를 만들어서 공통된 변수를 정의해 놓고 @MappedSuperclass 어노테이션을 붙인다. 그러면 이 클래스를 상속받은 자식 클래스는 부모 클래스에 정의된 멤버 변수를 포함시켜 가지게 된다.

@EnableJpaAuditing

감사 기능 활성화 JPA Auditing 기능

하나의 객체는 하나의 데이터며 이 데이터의 생성과 변경을 추적하는 일은 중요하다. 따라서 객체마다 생성시간 수정시간에 대한 정보를 가지고 있는게 일반적이다. Spring Data JPA 는 객체의 생성과 변화를 정교하게 추적해주는 Auditing 기능을 가지고 있으며, 이 기능을 활성화 시켜주는 어노테이션이 EnableJpaAuditing 이다. @Config 또는 @SpringBootApplication 에 작성할 수 있다.

@EntityListeners(AuditingEntityListener.class)

감사 적용 객체 지정 JPA Auditing 기능

@ManyToMany

연관 관계 매핑

JPA 에서 엔티티 간의 다대다 연관 관계를 논리적으로 나타내는 어노테이션이다. 실제로 데이터베이스에서는 직접적인 다대다 관계가 없다. 따라서 이 어노테이션을 멤버 변수에 붙여두면 연관 관계가 있는 두 객체의 아이디로 중간 매핑 테이블 을 생성해준다. PRODUCTFOLDER 테이블의 경우 테이블 자체 컬럼으로 외래키를 가지고 있지 않지만 🗝 중간 매핑 테이블의 키로 매핑 되어 서로 참조되고 있는 모습을 확인할 수 있다.

연관 관계 매핑 어노테이션

  • [Product.java] @ManyToMany List<Folder> folderList
    FOLDER ↔️ Middle Mapping Table ↔️ PRODUCT

  • [Folder.java] @ManyToOne @JoinColumn(nullable = false) User user
    FOLDER(USER_ID) ➡️ USER

[Product.java] 에서 매핑 어노테이션은 명시해주지 않았지만 사용자의 아이디를 저장하는 Long userId 변수를 가지고 있다.

디폴트 생성자가 필요한 경우

실습을 따라가면서 모든 클래스마다 코드에서 명시적으로 디폴트 생성자를 호출하지 않는데 @NoArgsConstructor 가 왜 붙어있는건가 싶어서 불필요한 부분은 지웠다. 그러던 도중 데이터베이스 내용을 조회하는 Service 단에서 다음과 같은 오류를 만났다.

// UserService.java
Optional<User> found = userRepository.findByUsername(username);
# Error Message
org.hibernate.InstantiationException: No default constructor for entity

앞서 몇몇 클래스들의 @NoArgsConstructor 를 떼왔고 문제가 없었는데 무슨일인고..🤭 문서에는 다음과 같이 적혀있었다.

The JPA specification requires that all persistent classes have a no-arg constructor. This constructor may be public or protected. Because the compiler automatically creates a default no-arg constructor when no other constructor is defined, only classes that define constructors must also include a no-arg constructor.

openjpa.apache.org "Chapter 4. Entity - Part 2. Java Persistence API"

영속적인 클래스 의 경우 기본 생성자를 요구한다. 클래스에 생성자가 정의되어 있지 않을 경우 @NoArgsConstructor 를 붙여주지 않아도 기본적으로 디폴트 생성자 가 생성된다. 하지만 별도의 생성자를 정의한 경우라면 기본 생성자 가 요구된다는 말이다. 따라서 디폴트 생성자 외에 다른 생성자가 정의되어 있는 경우라면 @NoArgsConstructor 를 명시해줘야 한다.

생성자 관련 어노테이션을 제거했던 클래스는 DTO 클래스들이고 이들은 데이터베이스 연산과 관련이 없었다. 즉 영속성 이 없는 객체들이었다. 뿐만 아니라 내가 클래스에 별도의 생성자를 정의하지 않았다. 반면에 @Entity 로 정의된 클래스의 객체는 생성 후 데이터베이스에 저장되어 Entity로 관리되는 객체이기 때문에 영속성 이 부여된 상태다. 생성자 관련해서 어노테이션을 명시해주지 않아도 디폴트 생성자가 자동으로 생성되지만 만약 다른 생성자를 내가 만들어 놨다면 @NoArgsConstructor 를 붙여줬어야 했다. DTO 객체 내용을 바탕으로 @Entity 객체를 생성하기 때문에 디폴트 생성자 외의 파라미터가 있는 생성자가 이미 정의되어 있는 상태였고 따라서 @NoArgsConstructor 가 반드시 필요했다.

package com.sparta.springcore.domain;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

@Entity
@NoArgsConstructor // 다른 생성자가 존재하므로 반드시 명시
@Getter
@Setter
public class User extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRole role;

    // DTO 객체 내용을 파라미터로 갖는 생성자 정의
    public User(String username, String password, String email, UserRole role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }
}

JPA / Spring Data JPA

JPA의 핵심

  • ORM
  • Persistence Context

ORM자바 객체와 데이터베이스의 엔티티의 상호변환 기술이다. 클래스를 데이터베이스의 테이블화하고 객체를 레코드화 하며 역으로 데이터베이스의 레코드를 가지고 자바 객체로 변환할 수 있다. 이런 기술이 제공되지 않는다면 SQL 쿼리로 클래스와 객체에 해당되는 테이블과 레코드를 직접 만들어 줬어야 했을 것이다.

Persistence Context는 객체가 지속적으로 저장되있는 환경을 의미한다. 객체를 데이터베이스에 저장함으로써 객체라는 데이터에 영속성을 부여한다.

JPA인터페이스기술 명세서이며 실제 구현체는 아니다. 실제로 JPA 기능을 구현한 것은 Hibernate와 같은 프레임워크이다. Spring Data JPA는 이 둘의 앞단에서 개발자의 편의를 위해 한 단계 더 감싼 층이라고 생각하면 된다.

Tomcat Session

사용자 인증 절차로서 로그인을 거치면 Session ID가 부여되야 한다고 생각했는데, 로그인을 하지 않은 상태인데 localhost에 접근만 하면 Session ID 가 발급되어 있어서 의아했다. 알고보니 WAS 가 연결을 관리하기 위해 내려주는 것이라고 한다. 이 답변을 듣고 의문이 한 가지 더 생겼다. 그럼 로그인을 하면 WAS가 내려준 Session ID 값이 사라지고 웹 애플리케이션 Spring Security에서 인증을 처리하고 새로운 Session ID 를 내려주는데, 그럼 값이 바뀌어 버렸는데 WAS 는 연결을 어떻게 관리하나? 그들의 내부 사정은 제대로 알 수 없지만.. 아마도 내부적으로 이런 상황들에 대해 Session ID 가 바뀜을 관리하는 로직들이 있을 것이라 추측하고 마무리 지었다.

JWT 인증 방식

Session 방식의 문제점

웹 서비스 상에서 Session 방식의 인증은 요청에 사용자와 관련한 정보가 실리지 않고 중요한 정보가 아닌 서버에서 내려준 Session ID 값이 들어가기 때문에 보안상 안전하다. 하지만 Session과 관련해서 서버의 자원(메모리)를 사용해서 관리해줘야 하기 때문에 서버에 부담이 간다. 뿐만아니라 서버의 가용성을 늘리기 위해 서버를 증설하는 경우 여러개의 서버들이 Session 을 공유하기 위한 방법이 필요하다는 단점이 있다.

뿐만아니라 Session 방식은 서버쪽에서 어쨌든 연결 상태를 관리하고 있는 것이므로 서버가 stateful 상태라고 말할 수 있다. 이렇게 될 경우 로그인과 관련된 API나 사용자의 인증이 필요한 API는 로그인을 해야만 호출할 수 있기 때문에 테스트 자동화가 어려워진다. (+ 서버 사이드 렌더링 으로 구현된 페이지인 경우 서버로부터 데이터를 받아 페이지를 구성해야 하기 때문에 테스트 자동화에 문제가 생긴다.)

Session ➡️ JWT

기존의 Session 방식에서는 로그인을 안했으면 / 경로에 접속할 수 없었다. 하지만 JWT 변경 후에는 인증을 하지 않았더라도 / 접속이 가능하도록 권한을 열어줬다. 로그인이 안됐으면 서비스를 이용 못하는거 아닌가!? 라고 생각했는데, 다시 잘 생각해보면 모든 웹 서비스 홈페이지 들어가자마자 로그인 해라 라고 하지는 않잖아!!🙄

@Configuration // configuration class
@EnableWebSecurity // enable 'spring security'
@EnableGlobalMethodSecurity(securedEnabled = true) // enable 'Authorization`, @Secured annotation
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
        
        http.authorizeRequests()
                .antMatchers("/h2-console/**").permitAll()
                .antMatchers("/images/**", "/css/**", "/secret/**").permitAll() // 특정 리소스 요청을 '권한 없이' 허용 (URL 허용 정책 변경)
                .antMatchers("/user/**").permitAll() // 특정 API 요청을 '권한 없이' 허용
                // 사용자의 '인증' 없이도 접근이 가능해야하는 프론트 리소스
                .antMatchers("/basic.js").permitAll()
                // 로그인을 안하더라도 요청할 수 있는 API
                .antMatchers("/login").permitAll()
                .antMatchers("/signup").permitAll()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated() // 모든 요청은 '인증 완료' 후에 허용
                ...

        // HTTP 요청 필터 등록
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }

    ...
}

세션 방식에서는 로그인을 안했으면 / 로 요청을 보낼 수 없었다. 왜냐하면 Session 방식을 사용할때에는 WebSecurityConfig에서 / URL 의 권한을 허용해주지 않았고, 그 말은 즉 인증이 없으면 들여보내지 않겠다는 의미였으니. 그래서 JWT로 변경하고 / URL 권한을 똑같이 열어주지 않았더니 JWT Exception Handler 에서 막았다.

@Configuration // configuration class
@EnableWebSecurity // enable 'spring security'
@EnableGlobalMethodSecurity(securedEnabled = true) // enable 'Authorization`, @Secured annotation
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAuthenticationFilter jwtRequestFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
        
        http.authorizeRequests()
        
                ...
                
                // .antMatchers("/").permitAll() // '/' URL 허용 X
                .anyRequest().authenticated()
                
                ...
                
                // 예외처리 핸들러: JwtAuthenticationEntryPoint
                .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .and()
                // disable session (<-> enable session = sever stateful status)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                
                ...

        // HTTP 요청 필터 등록
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }

    ...
}

JWT 에서는 / 를 허용해줘야 서비스가 정상적으로 동작할까? 간단했다! Session의 경우 인증 정보인 Session ID를 관리하고 있기 때문에 요청에 Session ID 가 없으면 튕겨낼 수 있었다. 하지만 JWT 의 경우 서버는 stateless 상태가 되기 때문에 서버쪽에서는 사용자와의 연결 상태를 알 길이 없다. 뿐만아니라 로그인도 안하고 요청을 날렸을 경우 JWT가 없기 때문에 백엔드 쪽에서는 토큰 유효성 검사 결과로 401 오류를 돌려준다. 가장 쉬운 방법은 jQuery$(document).ready() 에서 Local Storage에 토큰의 유무를 검사하고 로그인 페이지로 리다이렉트 해주는 방법이다.

이 문제에 대해 고민해 보면서 WebSecurityConfig 파일의 역할에 대해 어느정도 감을 잡았고 메서드들의 역할을 알게 됐다. WebSecurityConfig는 일종의 보안 관련 설정 클래스, 즉 설정 파일의 역할을 한다. 디버깅 도구로 해당 클래스에 브레이크포인트를 걸어놓고 실행시키면 애플리케이션이 시작 되기 전에 걸리는 것을 확인할 수 있다.

WebSecurityConfig 메서드 의미

  • .antMatchers("URL/File").permitAll()
    명시된 URL 또는 리소스 파일들은 사용자가 누구든 공개 권한 열어주기

  • .anyRequest().authenticated()
    권한을 열어준 경로들을 제외한 요청들은 모두 사용자 인증이 필요 (로그인이 필요한 서비스)

  • http.addFilterBefore()
    권한을 열어준 경로들을 제외한 HTTP 요청들이 들어왔을 때 JWT가 유효한지 검사할 필터 추가

Welcomfile

HomeController/ API를 지워봤다. 놀랍게도 index 페이지가 나왔다. 그 어디에도 / 로 오면 index 를 응답해주겠다고 나는 명시한 적이 없는데🤔 범인은 Tomcat 이었다! 알아서 welcomfileindex.html로 설정해줬나보다. 스프링 부트는 설정이 간편해서 좋은데 또 설정이 눈에 보이지 않아 혼란스럽기도 하다.

카카오 로그인

카카오 로그인은 Authorization Code를 바탕으로 Access Token Refresh Token 을 발급받아 사용자 정보에 접근하는 방식이다. 그래서 애플리케이션 인증 과정 초기에 카카오 인증 서버에서 Authorization Code 값을 받을 수 있도록 백엔드 쪽에 콜백 URL를 작성해야한다.

콜백을 생략하고 싶다면 프론트단에서 접근 토큰을 받으면 된다. 그래서 튜터님께 질문했다. 코드를 받지 않고 접근 토큰을 받는 방법이 있는거면 왜 굳이 코드를 쓰는걸까? 보안상의 측면에서 한 단계 절차가 더 있는게 안전하기 때문이라 추측했다.

좀 더 생각해보니 개발자 입장에서 콜백을 달아주지 않아도 되는 것이지 Kakao SDK 함수를 호출하면 내부적으로 코드를 발급받아 토큰을 발급받는 과정을 거치는 것 같다.

참고문서

📌 nroo nroo. "[JPA] @MappedSuperclass", Namjun Kim. 26 Aug 2019.

📌 Spring Data JPA Document. "3. Auditing - Part I. Reference Documentation"

📌 Apache OpenJPA Document. "Chapter 4. Entity - Part 2. Java Persistence API"

📌 StackOverflow. "JPA: is the default constructor required to be empty?"

📌 StackOverflow. "Java entity - why do I need an empty constructor?"

📌 Kakao Developers 문서. "카카오 로그인"

4개의 댓글

comment-user-thumbnail
2021년 11월 15일

점점 논문 수준의 퀄리티가 되어가는 나연님의 벨로그,,, 오늘도 잘 보고 갑니당 총총✨

1개의 답글
comment-user-thumbnail
2021년 11월 17일

내용이 탄탄하고 체계적인걸 보니 같이 고민했던 부분이 맞나 헷갈렸습니다!
나연님만의 언어로 잘 바꿔놓으신걸 보니 정말 대단하다는 말밖에 안나오네요
너무 잘 읽었습니다!

1개의 답글