// 회원가입
@PostMapping("/join")
public Long join(@RequestBody Map<String, String> user) {
return userRepository.save(User.builder()
.email(user.get("email"))
.password(passwordEncoder.encode(user.get("password")))
.roles(Collections.singletonList("ROLE_ADMIN")) // 최초 가입시 USER 로 설정
.build()).getId();
}
그냥 별거없다. 이메일과 비밀번호를 입력받아서 Jpa의 save를 해준다. 참고로 데이터베이스는 H2를 썼다.
이렇게 포스트맨의 바디에 회원가입 정보를 담아서 요청을 보냈다.
로그인 컨트롤러
// 로그인
@PostMapping("/login")
public String login(@RequestBody Map<String, String> user) {
User member = userRepository.findByEmail(user.get("email"))
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
if (!passwordEncoder.matches(user.get("password"), member.getPassword())) {
throw new IllegalArgumentException("잘못된 비밀번호입니다.");
}
return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
}
로그인 컨트롤러에서는 입력받은 이메일과 비밀번호가 데이터베이스에 있는 정보와 일치하는지 확인하고
일치한다면 JWT 토큰을 만들어서 반환해준다.
토큰에 사용자의 이메일(getUsername)과 권한 정보를 넣어줄 것이기에 파라미터로 넘겨준다.
회원가입한 정보 그대로 로그인 요청을 보낸다. 그러면 응답으로 JWT토큰이 돌아왔다!
JWT 토큰 생성
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "llshlllshlllshlllshl";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey를 Base64로 인코딩한다.
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
토큰이 권한을 가져다 주는지 확인하기 위해 Spring Security의 WebSecurityConfig 클래스를 설정해준다.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().disable();
http.authorizeRequests()
.antMatchers("/h2-console/**").permitAll(); // 누구나 h2-console 접속 허용
http
.httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
.csrf().disable() // csrf 보안 토큰 disable처리.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
.and()
.authorizeRequests() // 요청에 대한 사용권한 체크
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.authorizeRequests(); // 권한요청 처리 설정 메서드
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
}
configure 메소드에서 user와 admin으로 시작하는 요청에는
각각 USER와 ADMIN의 권한이 있어야만 접근 가능하도록 제한을 걸어두었다.
권한은 어떻게 확인?
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 를 받아옵니다.
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인합니다.
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
System.out.println("토큰 유효하다");
}
chain.doFilter(request, response);
}
}
이 필터가 요청을 가로채어 JWT 토큰이 유효한지 판단한다. 토큰 검증 후 유효하다면 다시 요청을 진행시킨다.
확인해보자
@PostMapping("/user/test")
public Map userResponseTest() {
Map<String, String> result = new HashMap<>();
result.put("result","user ok");
return result;
}
@PostMapping("/admin/test")
public Map adminResponseTest() {
Map<String, String> result = new HashMap<>();
result.put("result","admin ok");
return result;
}
접근 제한을 걸어둔 이 두개의 리소스에 요청을 보내어 결과가 잘 받아지는지 확인해보자.
회원가입시 기본적으로 얻는 권한은 USER다.
Headers에 우리가 설정해둔 키인 X-AUTH-TOKEN에 대응되게 value로 JWT 토큰을 넣어서 요청을 보내주면 올바른 응답이 돌아옴을 확인할 수 있다. 즉 인증이 되었다는 것.
선택했다기보단 지금 모 코딩 교육 프로그램에서 한번 배웠기에 간단하게라도 복기하는 느낌으로 정리하는거다.
1. 소셜 로그인?
우리가 사용하는 웹 사이트들마다 전부 회원가입을 하는것은 사용자에게 너무 부담이 된다. 귀찮으니까. 또한 대부분 지키지 않겠지만 웹 사이트마다 다른 아이디와 비밀번호를 사용해야 보안적으로도 좋기에 각각 어떤 아이디였는지 외우기도 힘들다. 웹사이트를 운영하는 측면에서도 개인정보를 지켜야하는 부담이 있다.
따라서 OAuth를 사용한 소셜로그인이 등장했다.
OAuth란,
인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다. 사용자가 애플리케이션에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜이다.
테이블에서는 외래 키 하나로 조인을 통해서 테이블간 양방향 참조가 가능하기 때문에 사실상 방향성이 없다. 하지만 객체에서는 참조용 필드를 통해 단방향으로만 접근할 수 있다. 단방향으로 서로 참조하고있으면 이를 양방향이라고 부른다.
연관관계의 주인
테이블에서는 외래 키 하나로 두 테이블의 연관관계를 맺는다. 즉, 테이블의 연관관계를 관리하는 포인트는 외래 키 하나이다. 하지만 객체의 양방향 관계는 A->B , B->A 처럼 2개의 참조가 필요하다. JPA는 두 객체 연관관계 중 하나를 정해서 데이터베이스의 연관관계를 관리하는데 이를 연관관계의 주인이라고 한다.
외래 키를 가진 쪽이 연관관계의 주인이고 외래키는 다(N)쪽에 존재한다.
다대일(N:1)
가장 많이 사용하는 관계이다. 외래 키는 항상 다(N)쪽에 있다. 따라서 객체 양방향 관계에서 연관관계의 주인은 항상 다(N)쪽이다. 여기서는 Member의 team이 연관관계의 주인이다.
또한 양방향 연관관계에서는 항상 서로를 참조하고 있어야한다. 이를 위해서는 이전 포스팅의 순수객체 상태 관련 메소드를 사용하면 좋다.
일대다(1:N)
일대다 단방향은 일(1)이 연관관계의 주인이다.
객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조이다. 꼭 @JoinColunm을 사용해야한다. 사용하지 않으면 중간에 매핑테이블이 자동으로 생성돼버린다.
엔티티가 관리하는 외래 키가 다른 테이블에 있기에 추가로 update SQL이 실행된다. 일대다 단방향보다는 다대일 양방향을 이용하자.
이런 매핑은 공식적으로 존재하지 않는다.
@JoinColumn(insertable=false, updatable=false) 읽기 전용 필드를 사용해서 양방향 처럼 사용하는 방법이다.
즉, 그냥 다대일 양방향을 사용하는 것이 좋다.
일대일 (1:1)
주 테이블이나 대상 테이블 중 어떤곳에 외래 키를 두어도 된다.
외래 키에 데이터베이스 유니크 제약조건 추가돼야 한다.
주 테이블에 외래 키
주 객체가 대상 객체를 참조하는 형태로, 객체지향 개발자들이 선호한다.
장점은 주 테이블만 확인해도 대상 테이블과의 연관관계를 알 수 있다.
단점은 값이 없으면 외래 키에 null 허용
대상 테이블에 외래 키
전통적인 데이터베이스 개발자들은 대상 테이블에 외래 키를 두는 것을 선호한다.
장점은 테이블 관계를 일대일에서 일대다로 변경할 때 테이블 구조를 그대로 유지할 수 있다.
단점은 프록시 기능의 한계로 지연로딩으로 설정해도 항상 즉시로딩이 된다.
양방향 매핑이므로 연관관계의 주인을 정해야한다. 이 경우 MEMBER 테이블이 외래 키를 가지고 있으므로 Member 엔티티의 locker가 연관관계의 주인이다.
따라서 반대 매핑인 Locker 엔티티의 member는 mappedBy를 통해 연관관계의 주인이 아니라고 명시해야 한다.
다대다 (N:N)
김영한 강사님이 쓰지 말라신다. 쓰지 말자.
관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야한다.
예를 들어 회원들(N)은 상품들(N)을 주문하고, 상품들(N)도 회원들(N)에게 주문된다고 하면, 이 둘은 다대다 관계다.
고로 회원 테이블과 상품 테이블만으로 이 관계를 표현할 수 없고 아래처럼 중간 테이블을 통해 다대일, 일대다 관계로 풀어내야 한다. (@ManyToMany => @ManyToOne , @OneToMany)
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
@Column(name = "TEAM_ID")
private Long teamId;
}
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
}
//팀 저장
Team team = new Team();
team.setName("TeamA");
em.persist(team);
//회원 저장
Member member = new Member();
member.setName("member1");
member.setTeamId(team.getId());
em.persist(member);
라는 상황이 있을때, 특정 회원이 속한 팀을 찾으려면 어떻게 해야할까?
일반적인 경우는 다음과 같은 방법을 사용한다.
Member findMember = em.find(Member.class, member.getId());
Long findTeamId = findMember.getId();
Team findTeam = em.find(Team.class, findTeamId);
먼저 특정 회원을 조회하고, 그 회원의 팀 ID를 getter로 가져와서
팀 ID로 다시 조회한다.
벌써 두번이나 쿼리가 발생했다. 이런것을 보고 객체지향스럽지 못하다고 한다.
즉, 객체를 테이블에 맞춰서 데이터 중심으로 모델링하면, 협력 관계를 만들 수 없다.
테이블은 외래키를 사용한 조인을 통해 연관된 테이블을 찾는다.
객체는 참조를 통해서 연관된 객체를 찾는다.
이것이 객체와 테이블이 가지는 가장 큰 차이점.
단방향 연관관계
객체지향 모델링
위에서 작성한 Member 엔티티를 조금 수정해보자.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Column(name = "USERNAME")
private String name;
// @Column(name = "TEAM_ID")
// private Long teamId;
//Team에 대한 참조를 넣음
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
Team을 참조하는 Long형 teamId 대신 Team에 대한 참조값 team을 넣어서 매핑을 한다면 어떻게 될까?
이렇게 모델링을 한다면 위에서 예시로 들었던 특정 멤버의 팀을 조회하는 코드가 한결 간결해지고 쿼리도 줄어든다.
//조회
Member findMember = em.find(Member.class, member.getId());
//참조를 사용해서 연관관계 조회
Team findTeam = findMember.getTeam();
해당 회원의 팀 ID를 조회하고 그것으로 다시 팀을 조회하는것이 아닌,
한번에 해당 회원의 팀을 조회할 수 있다. team 참조가 있기 때문에.
하지만 Team에서는 어떤 Member가 있는지 조회할 수 없다. 단방향 연관관계이기 때문이다.
객체는 team이라는참조를 통해 단방향으로 연관관계를 가진다. Member에서는 team을 통해 팀에 대한 정보를 알 수 있지만 Team에서는 Member를 알 수 있는 방법이 없기에 단방향 연관관계다.
테이블에서는 TEAM_ID라는 외래키를 통해 연관관계를 가진다. 이는 Team에서도 Member를 조회할 수 있기에 양방향 관계라고 할 수 있다.
즉, 정리하자면
객체는 참조를 통해 연관관계 가진다.
객체의 연관관계는 단방향이다.
테이블은 외래키를 통해 연관관계 가진다.
테이블의 연관관계는 양방향이다.
양방향 연관관계
팀 1개에 N명의 멤버가 있을 수 있으므로 Team 엔티티에 리스트형으로 member의 참조값을 넣었다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team")
List<Member> members = new ArrayList<Member>();
이를 통해 객체에서도 Team -> Member로의 조회가 가능하다.
//조회
Team findTeam = em.find(Team.class, team.getId());
int memberSize = findTeam.getMembers().size(); //역방향 조회
연관관계의 주인
양방향 연관관계에서는 주인이 필요하다.
테이블의 연관관계는 외래키 하나로 양쪽을 다 조회할 수 있지만,
객체에서는 양쪽에서 서로를 참조하는 값이 있어야 서로간의 조회가 가능하다. 즉 까보면 단방향 연관관계 두개로 인해 양방향처럼 보이는 것이다.
class A {
B b;
}
class B {
A a;
}
이런 윈리지만 우리는 이를 양방향 연관관계라고 부른다.
엔티티를 양방향으로 연관관계를 가지게 하기 위해 참조가 2개 필요한데,
데이터베이스에서는 외래키는 1개 필요하다.
이 차이로인해 JPA에서는 객체 연관관계 중 하나를 주인으로 삼고 이 외래키를 보관하도록 한다.
연관관계의 주인만이 데이터베이스의 연관관계와 매핑되고 외래키를 관리할 수 있다. 주인이 아닌쪽은 읽기만 간으하다.
누구를 주인으로?
외래키가 있는 곳을 주인으로.
외래키는 N이 있는 곳.
(이 예시에서는 Member쪽이다)
양방향 매핑 규칙
연관관계의 주인만이 외래키를 관리(등록, 수정)
주인이 아닌쪽은 읽기만 가능
주인은 mappedBy 속성 사용X
주인이 아닌쪽에서 mappedBy 속성을 통해 주인 지정
주의할 점
연관관계의 주인에 값을 입력하지 않음
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
//역방향(주인이 아닌 방향)만 연관관계 설정
team.getMembers().add(member);
em.persist(member);
순수한 객체 관계를 고려하면 연관관계 주인과 반대편 양쪽 다 값을 입력 해야한다.
Team team = new Team();
team.setName("TeamA");
em.persist(team);
Member member = new Member();
member.setName("member1");
team.getMembers().add(member); //반대편인 여기랑
//연관관계의 주인에 값 설정
member.setTeam(team); //주인인 여기 양쪽 모두 값 넣어야함
em.persist(member);
DB에 올라가지 않고 1차 캐시에서 엔티티를 다시 바로 가져오는 경우를 순수한 객체상태라고 하는데 이때 연관관계의 주인쪽에만 값을 입력해주면 다른 한쪽엔 반영이 안된다. 1차 캐시를 없애고 조회하는 순간부터 반대편에서도 참조가 가능한 문제가 생기기에 영속성 컨텍스트로 올릴때 부터 양쪽에 값을 넣어주자.
이때 한가지 팁이 있다면,
연관관계 주인(Member)쪽 엔티티에서 setter 메소드를
public void setTeam(Team team){
this.team = team;
team.getMembers.add(this);
}
이처럼 수정하여 주인쪽은 자신의 인스턴스를 한번에 넣어주도록 수정하면 된다.
이때 setter 메소드는 관례적인 setter와 다르므로 메소드 이름을 changeTeam과 같이 이례적으로 바꾸면 더 좋다.