공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
화면 기획서에서 보이는 것처럼, 포스트를 작성할 때 필요한 정보는 제목과 본문이다. 포스트 작성 시 몇 가지 실패하는 경우들이 존재하는데, 대표적으로 로그인하지 않은 경우, DB 에러, 내부 서버 에러 등이 있다. 이러한 경우를 제외하고는 성공적으로 포스트가 작성되어야 한다. 이 플로우 차트는 이러한 실패 및 성공 시나리오를 시각적으로 표현한 것이다. 이 요구사항을 바탕으로, 실제 포스트 작성을 처리하는 API를 구현해 보도록 하겠다.
Security 및 인증 관련 설정
Spring Security와 JWT(JSON Web Token)를 사용한 인증 과정을 강화했다. 기존 설정에서는 기본적인 인증만 처리했지만, 이번 수정에서는 JWT 기반의 인증 처리, 필터링, 예외 처리 등의 구체적인 보안 로직이 추가되었다. 이를 통해 인증된 사용자만이 시스템의 특정 API에 접근할 수 있게 되며, 인증 과정에서 발생하는 다양한 상황에 맞춰 적절한 응답을 제공한다.
AuthenticationConfig
수정 전
Spring Security의 기본 설정으로, 인증되지 않은 사용자도 회원가입과 로그인 API에 접근할 수 있도록 하고, 나머지 API에 대해서는 인증된 사용자만 접근할 수 있도록 제한했다. 하지만 JWT 토큰을 활용한 인증 기능이 없어서 로그인 후 요청마다 서버가 사용자 정보를 다시 확인하는 로직은 미구현된 상태였다.
package com.example.sns.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
@Configuration
@EnableWebSecurity
public class AuthenticationConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/*/users/join", "/api/*/users/login").permitAll()
.antMatchers("/api/**").authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
수정 후
JWT 토큰 기반 인증을 처리하는 필터가 추가되었다. 이 필터는 요청마다 JWT 토큰을 확인하고, 해당 토큰이 유효한지, 만료되지 않았는지 검증한다. 유효한 토큰인 경우에만 사용자가 인증된 것으로 간주하여 API 접근을 허용한다. 또한, 사용자 인증 실패 시 이를 처리하는 커스텀 인증 엔트리 포인트가 추가되어, 에러 메시지와 상태 코드를 클라이언트에 반환할 수 있게 되었다.
package com.example.sns.configuration;
import com.example.sns.configuration.filter.JwtTokenFilter;
import com.example.sns.exception.CustomAuthenticationEntryPoint;
import com.example.sns.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class AuthenticationConfig extends WebSecurityConfigurerAdapter {
private final UserService userService;
@Value("${jwt.secret-key}")
private String key;
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/api/*/users/join", "/api/*/users/login").permitAll()
.antMatchers("/api/**").authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(new JwtTokenFilter(key, userService), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint());
}
}
- JWT 토큰 기반 인증 필터 추가
- JwtTokenFilter가 추가되어, 모든 요청에서 Authorization 헤더의 JWT 토큰을 검증한다. 토큰이 유효하면 인증된 사용자로 간주하고, 이를 통해 API에 접근할 수 있다.
- 필터 위치: Spring Security의 인증 필터 전에 추가되며, JWT 검증이 우선적으로 수행된다.
- 세션 관리 정책: STATELESS
- 세션을 사용하지 않는 방식으로, JWT를 사용해 요청마다 인증을 처리한다. 서버는 인증 상태를 세션에 저장하지 않고, 요청마다 토큰을 통해 인증을 확인한다.
- API 경로별 접근 권한 설정
- /api/*/users/join 및 /api/*/users/login 경로는 인증 없이 접근할 수 있고, 그 외 모든 /api/** 경로는 인증된 사용자만 접근 가능하다.
- 인증 실패 시 처리
- CustomAuthenticationEntryPoint가 추가되어, 인증 실패 시 401 Unauthorized 상태 코드와 함께 JSON 형태의 에러 메시지를 반환한다.
JwtTokenFilter 추가
JwtTokenFilter는 JWT 토큰 인증 처리를 담당하는 필터이다. 이 필터는 모든 요청마다 Authorization 헤더에 포함된 JWT 토큰을 확인하고, 유효성을 검증한 후 해당 사용자를 인증된 상태로 설정한다.
package com.example.sns.configuration.filter;
import com.example.sns.model.User;
import com.example.sns.service.UserService;
import com.example.sns.util.JwtTokenUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
private final String key;
private final UserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// get header
final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
log.error("Error occurs while getting header. header is null or invalid");
filterChain.doFilter(request, response);
return;
}
try {
final String token = header.split(" ")[1].trim();
if(JwtTokenUtils.isExpired(token, key)){
log.error("Key is expired");
filterChain.doFilter(request, response);
return;
};
String userName = JwtTokenUtils.getUserName(token, key);
User user = userService.loadUserByUserName(userName);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (RuntimeException e) {
log.error("Error occurs while validating. {}", e.toString());
filterChain.doFilter(request, response);
return;
}
filterChain.doFilter(request, response);
}
}
- JWT 토큰 추출 및 검증
- 클라이언트가 요청을 보낼 때 Authorization 헤더에서 JWT 토큰을 추출한다. 일반적으로 Bearer <토큰> 형식으로 전달되며, 이 중 실제 토큰 부분만을 추출하여 검증한다.
- 토큰의 만료 여부와 서명이 유효한지를 검증한다. 토큰이 만료되었거나, 서명이 유효하지 않다면 인증이 실패하고 요청이 처리되지 않는다.
- 사용자 정보 로드
- JWT 토큰이 유효한 경우, 토큰에 포함된 사용자 이름을 추출하여 UserService를 통해 해당 사용자의 세부 정보를 로드한다. 이는 DB에서 사용자의 권한 정보 등을 불러와 인증을 위한 UsernamePasswordAuthenticationToken 객체를 생성하는 데 사용된다.
- SecurityContext 설정
- 토큰이 유효하고, 사용자 정보를 정상적으로 로드하면 SecurityContext에 인증 정보를 설정한다. 이렇게 설정된 인증 정보는 이후 Spring Security의 인증된 요청으로 간주되어, 보호된 리소스에 접근할 수 있게 된다.
- 예외 처리
- 토큰이 없거나 잘못된 경우, 또는 인증 중 오류가 발생할 경우, 해당 요청을 처리하지 않고 필터 체인에서 다음 단계로 넘어가도록 한다. 이 과정에서 오류는 로그로 남기고, 클라이언트에는 인증 실패 응답을 반환한다.
CustomAuthenticationEntryPoint 추가
JWT 토큰이 유효하지 않거나 인증되지 않은 사용자가 보호된 리소스에 접근하려고 할 때, 이 클래스가 동작한다. Spring Security에서 제공하는 기본적인 인증 실패 처리를 커스터마이징하여, 사용자에게 JSON 형식의 에러 메시지를 반환하고, 상태 코드를 적절히 설정할 수 있다.
package com.example.sns.exception;
import com.example.sns.controller.response.Response;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json");
response.setStatus(ErrorCode.INVALID_TOKEN.getStatus().value());
response.getWriter().write(Response.error(ErrorCode.INVALID_TOKEN.name()).toStream());
}
}
- 인증 실패 처리
- 인증이 필요한 리소스에 JWT 토큰이 없거나 유효하지 않을 때 호출된다.
- commence() 메서드가 실행되어, 클라이언트에 401 Unauthorized 상태 코드와 함께 응답을 반환한다.
- JSON 형식의 에러 메시지 반환
- 에러 응답은 JSON 형식으로 반환되며, ErrorCode와 함께 에러 메시지를 포함한다.
- 이로 인해 클라이언트는 인증 실패 원인을 명확히 알 수 있다.
- 응답 내용 구성
- Content-Type을 application/json으로 설정하여 JSON 형식으로 응답을 반환한다.
- 클라이언트는 이 응답을 통해 인증 실패를 인식하고, 필요한 조치를 취할 수 있다.
응답 처리 및 공통 클래스
Response 클래스에 기능이 추가되고, UserResponse 클래스가 새로 추가되었다. 응답의 일관성을 유지하고, 클라이언트가 API 결과를 쉽게 해석할 수 있도록 응답 포맷을 개선했다.
Response
수정 전
package com.example.sns.controller.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class Response<T> {
private String resultCode;
private T result;
public static Response<Void> error(String errorCode) {
return new Response<>(errorCode, null);
}
public static <T> Response<T> success(T result) {
return new Response<>("SUCCESS", result);
}
}
- Response 클래스는 성공(success())과 실패(error()) 응답을 처리했다. 하지만, 성공 응답에서 객체가 없는 경우에 대한 처리가 명확하지 않았다.
수정 후
package com.example.sns.controller.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class Response<T> {
private String resultCode;
private T result;
public static Response<Void> error(String errorCode) {
return new Response<>(errorCode, null);
}
public static Response<Void> success() {
return new Response<Void>("SUCCESS", null);
}
public static <T> Response<T> success(T result) {
return new Response<>("SUCCESS", result);
}
public String toStream() {
if (result == null) {
return "{" +
"\"resultCode\":" + "\"" + resultCode + "\","+
"\"result\":" + null + "}";
}
return "{" +
"\"resultCode\":" + "\"" + resultCode + "\","+
"\"result\":" + "\"" + result + "\"" + "}";
}
}
- success() 메서드 추가: 객체가 없는 경우도 성공 응답을 처리할 수 있도록 success() 메서드를 추가했다. 이 메서드는 반환할 결과가 없는 경우(null)에도 SUCCESS 코드를 반환할 수 있게 수정되었다.
- toStream() 메서드 추가: 응답 객체를 JSON 형식의 문자열로 변환하는 메서드가 추가되었다. 이는 클라이언트에게 결과를 직관적으로 전달하기 위해 JSON 형식의 문자열을 생성하는 메서드다. 반환 값이 null일 때와 값이 있을 때를 구분하여 JSON 형식을 유지한다.
UserResponse 추가
package com.example.sns.controller.response;
import com.example.sns.model.User;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public
class UserResponse {
private Integer id;
private String userName;
public static UserResponse fromUser(User user) {
return new UserResponse(
user.getId(),
user.getUsername()
);
}
}
- UserResponse 클래스는 사용자 정보(id와 userName)를 응답할 때 필요한 데이터 구조로 정의한 새 클래스이다.
- 기존의 User 엔티티 전체를 반환하는 대신, 필요한 정보만을 추출하여 클라이언트에 전달한다.
- fromUser() 메서드: User 객체를 받아서 필요한 정보만을 UserResponse로 변환해 준다. 이로써 사용자 정보 중 id와 userName만 반환되도록 제한된다.
에러 및 예외 처리
ErrorCode 열거형에 새로운 에러 코드가 추가되어, JWT 토큰과 관련된 인증 에러를 처리할 수 있게 되었다. 이로 인해 인증 및 권한 부여 과정에서 발생하는 다양한 예외 상황을 더 세분화하여 처리할 수 있게 되었다.
package com.example.sns.exception;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
@AllArgsConstructor
public enum ErrorCode {
DUPLICATED_USER_NAME(HttpStatus.CONFLICT, "User name is duplicated"),
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not founded"),
INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "Password is invalid"),
//추가
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Token is invalid"),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error"),;
private HttpStatus status;
private String message;
}
- JWT 토큰과 관련된 에러 코드 추가: 새로운 에러 코드인 INVALID_TOKEN이 추가되었다. 이 코드는 인증 과정에서 잘못된 토큰이 전달된 경우, 혹은 만료된 토큰을 처리할 때 사용된다.
엔티티 및 서비스
User 엔티티, Post 엔티티, 그리고 서비스 클래스(UserService, PostService)에서 기능이 추가되고 수정되었다. 특히, Spring Security와 JWT를 활용한 인증 처리, 그리고 게시글 작성 로직이 개선되었다.
User 엔티티
수정 전
package com.example.sns.model;
import com.example.sns.model.entity.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.sql.Timestamp;
@Getter
@AllArgsConstructor
public class User {
private Integer id;
private String userName;
private String password;
private UserRole userRole;
private Timestamp registeredAt;
private Timestamp updatedAt;
private Timestamp deletedAt;
public static User fromEntity(UserEntity entity) {
return new User(
entity.getId(),
entity.getUserName(),
entity.getPassword(),
entity.getRole(),
entity.getRegisteredAt(),
entity.getUpdatedAt(),
entity.getDeletedAt()
);
}
}
- User 클래스는 단순 사용자 정보(id, userName, password 등)를 포함하고 있었고, Spring Security의 UserDetails 인터페이스는 구현되지 않았다.
수정 후
package com.example.sns.model;
import com.example.sns.model.entity.UserEntity;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.sql.Timestamp;
import java.util.Collection;
import java.util.List;
@Getter
@AllArgsConstructor
public class User implements UserDetails {
private Integer id;
private String userName;
private String password;
private UserRole userRole;
private Timestamp registeredAt;
private Timestamp updatedAt;
private Timestamp deletedAt;
public static User fromEntity(UserEntity entity) {
return new User(
entity.getId(),
entity.getUserName(),
entity.getPassword(),
entity.getRole(),
entity.getRegisteredAt(),
entity.getUpdatedAt(),
entity.getDeletedAt()
);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(this.getUserRole().toString()));
}
@Override
public String getUsername() {
return this.userName;
}
@Override
public boolean isAccountNonExpired() {
return this.deletedAt == null;
}
@Override
public boolean isAccountNonLocked() {
return this.deletedAt == null;
}
@Override
public boolean isCredentialsNonExpired() {
return this.deletedAt == null;
}
@Override
public boolean isEnabled() {
return this.deletedAt == null;
}
}
- User 클래스가 UserDetails 인터페이스를 구현하여 Spring Security와 통합되었다. 이를 통해 Spring Security가 사용자의 인증 상태를 처리할 수 있게 되었고, 사용자 권한 및 계정 상태(만료 여부 등)를 확인할 수 있는 메서드들이 추가되었다.
Post 엔티티
package com.example.sns.model.entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import javax.persistence.*;
import java.sql.Timestamp;
import java.time.Instant;
@Entity
@Table(name = "\"post\"")
@Getter
@Setter
@SQLDelete(sql = "UPDATE \"post\" SET deleted_at = NOW() where id=?")
@Where(clause = "deleted_at is NULL")
@NoArgsConstructor
public class PostEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "title")
private String title;
@Column(name = "body", columnDefinition = "TEXT")
private String body;
@ManyToOne
@JoinColumn(name = "user_id")
private UserEntity user;
@Column(name = "registered_at")
private Timestamp registeredAt;
@Column(name = "updated_at")
private Timestamp updatedAt;
@Column(name = "deleted_at")
private Timestamp deletedAt;
@PrePersist
void registeredAt() {
this.registeredAt = Timestamp.from(Instant.now());
}
@PreUpdate
void updatedAt() {
this.updatedAt = Timestamp.from(Instant.now());
}
//추가
public static PostEntity of(String title, String body, UserEntity userEntity) {
PostEntity entity = new PostEntity();
entity.setTitle(title);
entity.setBody(body);
entity.setUser(userEntity);
return entity;
}
}
- 팩토리 메서드 추가: PostEntity.of(String title, String body, UserEntity userEntity) 메서드가 추가되었다. 이를 통해 새로운 게시글을 생성할 때 코드의 가독성이 향상되었으며, 간단한 방식으로 게시글을 생성할 수 있게 되었다.
UserService
package com.example.sns.service;
import com.example.sns.exception.ErrorCode;
import com.example.sns.exception.SnsApplicationException;
import com.example.sns.model.User;
import com.example.sns.model.entity.UserEntity;
import com.example.sns.repository.UserEntityRepository;
import com.example.sns.util.JwtTokenUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserEntityRepository userEntityRepository;
private final BCryptPasswordEncoder encoder;
@Value("${jwt.secret-key}")
private String secretKey;
@Value("${jwt.token.expired-time-ms}")
private Long expiredTimeMs;
//추가
public User loadUserByUserName(String userName) {
return userEntityRepository.findByUserName(userName).map(User::fromEntity).orElseThrow(() ->
new SnsApplicationException(ErrorCode.USER_NOT_FOUND, String.format("%s not founded", userName)));
}
@Transactional
public User join(String userName, String password) {
// 회원가입하려는 userName으로 회원가입된 user가 있는지
userEntityRepository.findByUserName(userName).ifPresent(it -> {
throw new SnsApplicationException(ErrorCode.DUPLICATED_USER_NAME, String.format("%s is duplicated", userName));
});
// 회원가입 진행 = user를 등록
UserEntity userEntity = userEntityRepository.save(UserEntity.of(userName, encoder.encode(password)));
return User.fromEntity(userEntity);
}
// TODO : implement
public String login(String userName, String password) {
// 회원가입 여부 체크
UserEntity userEntity = userEntityRepository.findByUserName(userName).orElseThrow(() -> new SnsApplicationException(ErrorCode.USER_NOT_FOUND, String.format("%s not founded", userName)));
// 비밀번호 체크
if(!encoder.matches(password, userEntity.getPassword())){
throw new SnsApplicationException(ErrorCode.INVALID_PASSWORD);
}
// 토큰 생성
String token = JwtTokenUtils.generateToken(userName, secretKey, expiredTimeMs);
return token;
}
}
loadUserByUserName 메서드 추가
- 사용자 정보 조회
- userEntityRepository.findByUserName(userName): userEntityRepository를 통해 DB에서 사용자 정보를 조회한다. 사용자 이름을 기준으로 UserEntity 객체를 찾는다.
- Optional로 감싸진 결과를 반환받으며, 사용자가 존재하지 않는 경우를 대비한 예외 처리도 함께 수행된다.
- UserEntity를 User로 변환
- map(User::fromEntity): DB에서 조회된 UserEntity 객체를 User 객체로 변환한다. User는 Spring Security의 UserDetails 인터페이스를 구현한 클래스이므로, Spring Security가 사용자 인증 정보를 처리할 수 있다.
- User::fromEntity는 정적 메서드로, UserEntity 객체를 User 객체로 변환하여 필요한 사용자 정보를 반환한다.
- 예외 처리
- orElseThrow: 사용자가 존재하지 않을 경우, **SnsApplicationException**을 발생시켜 예외를 처리한다. USER_NOT_FOUND라는 에러 코드를 사용해, 사용자 정보를 찾을 수 없다는 에러를 발생시킨다.
PostService
수정 전
@Service
@RequiredArgsConstructor
public class PostService {
private final PostEntityRepository postEntityRepository;
private final UserEntityRepository postUserEntityRepository;
private final UserEntityRepository userEntityRepository;
@Transactional
public void create(String title, String body, String userName) {
// user find
UserEntity userEntity = userEntityRepository.findByUserName(userName).orElseThrow(() ->
new SnsApplicationException(ErrorCode.USER_NOT_FOUND, String.format("%s not founded", userName)));
// post save
postEntityRepository.save(new PostEntity());
// return
}
}
- 게시글 생성 시 게시글의 내용은 저장했지만, 이를 사용자 정보와 연결하는 부분이 미흡했다.
수정 후
게시글 생성 시 사용자 정보를 바탕으로 게시글을 저장하도록 변경되었다. 이 과정에서 PostEntity와 UserEntity를 연결하고, DB에 게시글을 저장하는 로직이 추가 및 개선되었다.
@Service
@RequiredArgsConstructor
public class PostService {
private final PostEntityRepository postEntityRepository;
private final UserEntityRepository postUserEntityRepository;
private final UserEntityRepository userEntityRepository;
@Transactional
public void create(String title, String body, String userName) {
UserEntity userEntity = userEntityRepository.findByUserName(userName).orElseThrow(() ->
new SnsApplicationException(ErrorCode.USER_NOT_FOUND, String.format("%s not founded", userName)));
postEntityRepository.save(PostEntity.of(title, body, userEntity));
}
}
1. 사용자 조회
- 이 부분에서는 게시글을 작성할 사용자를 DB에서 조회한다.
- userName을 기준으로 UserEntityRepository를 통해 DB에서 사용자 정보를 가져오며, 사용자가 존재하지 않으면 SnsApplicationException을 발생시켜 예외를 처리한다.
- USER_NOT_FOUND 에러 코드를 사용하여 사용자 정보를 찾을 수 없는 상황을 명확히 처리한다.
2. 게시글 생성 및 저장
- 사용자가 확인되면, 게시글 제목과 내용, 그리고 해당 사용자 정보를 기반으로 PostEntity 객체를 생성한다.
- PostEntity.of()는 새로 추가된 팩토리 메서드로, 게시글 객체를 간단하게 생성할 수 있도록 한다. 여기서 title, body, userEntity를 전달받아 새로운 PostEntity 객체를 생성한다.
- 생성된 게시글 객체는 PostEntityRepository를 통해 DB에 저장된다.
3. 트랜잭션 관리
- 트랜잭션이 적용되어 있어, 게시글 생성 과정이 하나의 트랜잭션으로 관리된다. 즉, 게시글 작성 중에 오류가 발생하면 해당 트랜잭션 내에서 이루어진 변경 사항은 모두 롤백된다.
- 이를 통해 데이터의 일관성과 무결성을 보장한다.
컨트롤러
PostController는 게시글과 관련된 API 요청을 처리하는 컨트롤러로, 클라이언트의 요청을 받아 PostService를 통해 게시글을 생성하는 역할을 한다. 새로운 게시글 작성 API를 추가하여, 클라이언트가 요청한 데이터를 받아 서비스 계층으로 전달하고, 결과를 응답하는 구조이다.
package com.example.sns.controller;
import com.example.sns.controller.request.PostCreateRequest;
import com.example.sns.controller.response.Response;
import com.example.sns.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@PostMapping
public Response<Void> create(@RequestBody PostCreateRequest request, Authentication authentication) {
postService.create(request.getTitle(), request.getBody(), authentication.getName());
return Response.success();
}
}
- 게시글 생성 요청 처리: /api/v1/posts 경로로 POST 요청을 받아, PostCreateRequest에서 제목과 본문을 추출하고, Authentication 객체에서 인증된 사용자의 이름을 가져온다.
- 서비스 호출: PostService를 통해 게시글을 생성한다. 제목, 본문, 작성자 정보를 서비스에 전달한다.
- 응답 반환: 성공적으로 게시글이 생성되면, Response.success()를 통해 클라이언트에 성공 응답을 반환한다.
JWT 유틸리티
JwtTokenUtils 클래스는 JWT 토큰 생성 및 검증을 처리하는 유틸리티 클래스로, 이번 수정에서는 토큰 검증과 사용자 정보 추출 기능이 추가되었다.
package com.example.sns.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
public class JwtTokenUtils {
// 추가
public static String getUserName(String token, String key) {
return extractClaims(token, key).get("userName", String.class);
}
public static boolean isExpired(String token, String key) {
Date expiredDate = extractClaims(token, key).getExpiration();
return expiredDate.before(new Date());
}
private static Claims extractClaims(String token, String key) {
return Jwts.parserBuilder().setSigningKey(getKey(key))
.build().parseClaimsJws(token).getBody();
}
public static String generateToken(String userName, String key, long expiredTimeMs) {
Claims claims = Jwts.claims();
claims.put("userName", userName);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredTimeMs))
.signWith(getKey(key), SignatureAlgorithm.HS256)
.compact();
}
private static Key getKey(String key) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
사용자 이름 추출 (getUserName 메서드 추가)
- 이 메서드는 JWT 토큰에서 사용자 이름을 추출하는 역할을 한다. 토큰과 서명 키를 사용해 Claims 객체에서 userName을 가져온다.
- 인증 과정에서 사용자 정보를 확인할 때 사용된다.
토큰 만료 여부 확인 (isExpired 메서드 추가)
- 이 메서드는 JWT 토큰이 만료되었는지 확인하는 기능을 한다. 토큰에서 만료일을 추출하고, 현재 시간과 비교하여 만료 여부를 반환한다.
- 만료된 토큰은 사용할 수 없으므로, 이를 통해 토큰 유효성을 검사한다.
Claims 추출 (extractClaims 메서드 추가)
- 이 메서드는 JWT 토큰에서 Claims(토큰에 저장된 정보)를 추출한다. 토큰과 서명 키를 사용해 JWT 토큰을 해석하고, 내부 정보를 가져온다.
- 추출한 Claims는 getUserName과 isExpired 메서드에서 사용된다.
테스트 코드
PostControllerTest
PostControllerTest는 게시글 작성 API에 대한 테스트를 작성한 것으로, 로그인된 사용자와 로그인하지 않은 사용자의 게시글 작성에 대한 테스트를 수행한다.
@SpringBootTest
@AutoConfigureMockMvc
public class PostControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
//추가
@MockBean
private PostService postService;
...
}
- @MockBean: 테스트 시 실제 PostService 대신 Mock 객체를 사용한다. 이를 통해 테스트가 독립적으로 수행될 수 있으며, 실제 서비스 로직이 아닌 가짜 서비스를 사용하여 테스트를 진행한다.
- 목적: 실제 DB나 외부 서비스에 의존하지 않고, PostController 자체의 동작만을 검증하기 위함이다.
이제 서버를 실행하고 테스트를 진행해보자.
먼저 LOGIN API에 요청을 보내면 JWT 토큰을 받게 된다. 이 토큰은 이후의 인증이 필요한 요청에서 사용된다.
그리고 Create Post API에 로그인에서 받은 토큰을 Authorization 헤더에 Bearer Token 형태로 추가하여 요청을 보내면, 성공적으로 포스트가 작성되었음을 알리는 SUCCESS 응답을 받을 수 있다.
실제로 Heroku의 데이터 클립에서도 포스트가 정상적으로 DB에 저장된 것을 확인할 수 있다.
토큰이 없는 상태로 Create Post API를 호출하면, 서버는 INVALID_TOKEN 에러 메시지를 반환하게 된다.
이 과정을 통해 JWT 토큰 기반 인증 시스템이 제대로 작동하는지 확인할 수 있다.
이번 작업을 통해 JWT 기반 인증 및 권한 부여를 적용한 포스트 작성 기능을 구현하고 테스트해 보았다. 토큰을 통한 인증 흐름과 예외 처리의 중요성을 확인할 수 있었으며, 이를 통해 보다 안전한 애플리케이션 구조를 설계할 수 있었다.
'BackEnd > Project' 카테고리의 다른 글
[SNS] Ch02. 포스트 수정 기능 개발 (1) | 2024.09.08 |
---|---|
[SNS] Ch02. 포스트 수정 기능 테스트 작성 (0) | 2024.09.08 |
[SNS] Ch02. 포스트 작성 기능 테스트 작성 (0) | 2024.09.08 |
[SNS] Ch02. 회원가입과 로그인 기능 개발(2) (1) | 2024.09.08 |
[SNS] Ch02. 회원가입과 로그인 기능 개발(1) (3) | 2024.09.07 |